Skip to content

此页由 Zensical 配合 mkdocstrings 根据源码自动生成,适合在以下场景使用:

  • 查看公开模块和导出符号
  • 确认方法签名、参数名和返回值
  • 查询 Pydantic 模型字段

如果你更关注接入步骤和调用示例,优先阅读 Usage。

zzupy¤

zzupy ¤

Modules:

  • aio –

    各模块的异步实现

  • app –

    移动应用 API 抽象层

  • crypto –

    纯 Python 实现的轻量级 RSA 加密

  • exception –

    异常处理模块。

  • logging –

    zzupy 日志工具。

  • model –
  • utils –

    工具函数库

  • web –

    Web API 客户端模块

Attributes:

logger module-attribute ¤

logger = _logger

__version__ module-attribute ¤

__version__ = importlib.metadata.version(__name__)

__all__ module-attribute ¤

__all__ = ['aio', 'app', 'web', 'exception', 'logging']

aio ¤

各模块的异步实现

Modules:

  • app –

    移动应用 API 抽象层

  • web –

    Web API 客户端模块

app ¤

移动应用 API 抽象层

Modules:

Classes:

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.CASClient
                


              click zzupy.aio.app.CASClient href "" "zzupy.aio.app.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)
user_token property ¤
user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤
refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤
logged_in: bool

当前会话是否已登录

MFAClient ¤
MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required async ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code async ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms async ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()
verify_sms_code async ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤ (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms async ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)
set_token ¤
set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token
set_device ¤
set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()
login async ¤
login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc
get_user_info async ¤
get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤
    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None
login async ¤
login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")
get_default_room async ¤
get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc
recharge_energy async ¤
recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
get_balance async ¤
get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc
get_room_dict async ¤
get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
get_remaining_energy async ¤
get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

auth ¤

统一认证

Classes:

  • CASClient –

    统一认证系统 (CAS) App 客户端。

CASClient ¤
CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.auth.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.auth.CASClient
                


              click zzupy.aio.app.auth.CASClient href "" "zzupy.aio.app.auth.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)
user_token property ¤
user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤
refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤
logged_in: bool

当前会话是否已登录

MFAClient ¤
MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤ (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required async ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code async ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms async ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()
verify_sms_code async ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤ (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms async ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)
set_token ¤
set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤ (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤ (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token
set_device ¤
set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤ (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()
login async ¤
login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤ (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc
get_user_info async ¤
get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

eas ¤

Modules:

Classes:

UndergradEASClient ¤
UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")
undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤
UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

ecard ¤

一卡通

Classes:

ECardClient ¤
ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤
    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None
login async ¤
login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")
get_default_room async ¤
get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc
recharge_energy async ¤
recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤ (str) –

    支付密码

  • amt ¤ (int) –

    充值金额

  • room ¤ (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
get_balance async ¤
get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc
get_room_dict async ¤
get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤ (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
get_remaining_energy async ¤
get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤ (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

interfaces ¤

抽象接口

web ¤

Web API 客户端模块

Modules:

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤
    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤
    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤
    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )
portal_auth async ¤
portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
auth async ¤
auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False
login async ¤
login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc
get_online_devices async ¤
get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
kick_device async ¤
kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
logout async ¤
logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

network ¤

校园网模块

Classes:

Functions:

EPortalClient ¤
EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤
    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤
    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤
    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )
portal_auth async ¤
portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤ (str) –

    账户

  • password ¤ (str) –

    密码

  • encrypt ¤ (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
auth async ¤
auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤ (str) –

    账户

  • password ¤ (str) –

    密码

  • isp_suffix ¤ (str | None, default: None ) –

    运营商后缀

  • encrypt ¤ (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)
SelfServiceSystem ¤
SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False
login async ¤
login(account: str, password: str) -> None

登录

Parameters:

  • account ¤ (str) –

    账号

  • password ¤ (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc
get_online_devices async ¤
get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
kick_device async ¤
kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤ (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
logout async ¤
logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False
discover_portal_info async ¤
discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

app ¤

移动应用 API 抽象层

Modules:

Classes:

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.app.CASClient[CASClient]
              zzupy.app.interfaces.ICASClient[ICASClient]

                              zzupy.app.interfaces.ICASClient --> zzupy.app.CASClient
                


              click zzupy.app.CASClient href "" "zzupy.app.CASClient"
              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/app/auth.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/app/auth.py
233
234
235
236
237
238
239
240
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证

Returns:

  • bool –

    是否需要 MFA 验证

Raises:

Source code in zzupy/app/auth.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证

    Returns:
        是否需要 MFA 验证

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。

    """
    public_key = self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/app/auth.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/app/auth.py
481
482
483
def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    self.request_sms_code()
verify_sms_code ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/app/auth.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/app/auth.py
568
569
570
def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/app/auth.py
74
75
76
77
78
79
80
81
82
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/app/auth.py
84
85
86
87
88
89
90
91
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/app/auth.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = self._client.post(self.LOGIN_URL, params=params, headers=headers)
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/app/auth.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
@require_auth
def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/auth.py
798
799
800
801
802
803
804
805
806
807
808
809
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._logged_in = False

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/auth.py
811
812
813
814
815
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None

login ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/app/ecard.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    self._get_tid()
    self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/app/ecard.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@require_auth
def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/app/ecard.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/app/ecard.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@require_auth
def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/app/ecard.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
@require_auth
def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/app/ecard.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
@require_auth
def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/ecard.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/ecard.py
747
748
749
750
751
752
753
754
755
756
def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.info("校园卡客户端已关闭")

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

auth ¤

统一认证

Classes:

  • CASClient –

    统一认证系统 (CAS) App 客户端。

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.app.auth.CASClient[CASClient]
              zzupy.app.interfaces.ICASClient[ICASClient]

                              zzupy.app.interfaces.ICASClient --> zzupy.app.auth.CASClient
                


              click zzupy.app.auth.CASClient href "" "zzupy.app.auth.CASClient"
              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)
user_token property ¤
user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤
refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤
logged_in: bool

当前会话是否已登录

MFAClient ¤
MFAClient(cas: CASClient)

统一认证 MFA 辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/app/auth.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/app/auth.py
233
234
235
236
237
238
239
240
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证

Returns:

  • bool –

    是否需要 MFA 验证

Raises:

Source code in zzupy/app/auth.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证

    Returns:
        是否需要 MFA 验证

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。

    """
    public_key = self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/app/auth.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/app/auth.py
481
482
483
def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    self.request_sms_code()
verify_sms_code ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤ (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/app/auth.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/app/auth.py
568
569
570
def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return self.verify_sms_code(code)
set_token ¤
set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/app/auth.py
74
75
76
77
78
79
80
81
82
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token
set_device ¤
set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/app/auth.py
84
85
86
87
88
89
90
91
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()
login ¤
login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/app/auth.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = self._client.post(self.LOGIN_URL, params=params, headers=headers)
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc
get_user_info ¤
get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/app/auth.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
@require_auth
def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/auth.py
798
799
800
801
802
803
804
805
806
807
808
809
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._logged_in = False
close ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/auth.py
811
812
813
814
815
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()

eas ¤

Modules:

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤
UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

ecard ¤

一卡通

Classes:

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤
    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
login ¤
login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/app/ecard.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    self._get_tid()
    self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")
get_default_room ¤
get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/app/ecard.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@require_auth
def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc
recharge_energy ¤
recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/app/ecard.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
get_balance ¤
get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/app/ecard.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@require_auth
def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc
get_room_dict ¤
get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/app/ecard.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
@require_auth
def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
get_remaining_energy ¤
get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/app/ecard.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
@require_auth
def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/ecard.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")
close ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/ecard.py
747
748
749
750
751
752
753
754
755
756
def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.info("校园卡客户端已关闭")

interfaces ¤

抽象接口

crypto ¤

纯 Python 实现的轻量级 RSA 加密

Classes:

  • RSAPublicKey –

    轻量级 RSA 公钥实现

  • padding –

    填充方案命名空间(兼容 cryptography)

  • serialization –

    序列化命名空间(兼容 cryptography)

Functions:

RSAPublicKey ¤

RSAPublicKey(modulus: int, exponent: int)

轻量级 RSA 公钥实现

Parameters:

  • modulus ¤

    (int) –

    RSA 模数 (n)

  • exponent ¤

    (int) –

    RSA 公开指数 (e),通常是 65537

Methods:

  • encrypt –

    使用 PKCS#1 v1.5 填充进行 RSA 加密

  • verify_rs512 –

    验证 RS512 (RSA-SHA512) 签名

Source code in zzupy/crypto.py
12
13
14
15
16
17
18
19
20
21
22
def __init__(self, modulus: int, exponent: int):
    """初始化 RSA 公钥

    Args:
        modulus: RSA 模数 (n)
        exponent: RSA 公开指数 (e),通常是 65537
    """
    self.modulus = modulus
    self.exponent = exponent
    self.key_size_bits = modulus.bit_length()
    self.key_size_bytes = (self.key_size_bits + 7) // 8

encrypt ¤

encrypt(data: bytes, padding_scheme=None) -> bytes

使用 PKCS#1 v1.5 填充进行 RSA 加密

Parameters:

  • data ¤
    (bytes) –

    要加密的数据

  • padding_scheme ¤
    –

    填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

Returns:

  • bytes –

    加密后的字节数据

Source code in zzupy/crypto.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def encrypt(self, data: bytes, padding_scheme=None) -> bytes:
    """使用 PKCS#1 v1.5 填充进行 RSA 加密

    Args:
        data: 要加密的数据
        padding_scheme: 填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

    Returns:
        加密后的字节数据
    """
    # PKCS#1 v1.5 填充
    # 格式: 0x00 || 0x02 || PS || 0x00 || M
    # PS 是随机非零字节,长度至少为 8

    max_message_length = self.key_size_bytes - 11  # 预留给填充的空间

    if len(data) > max_message_length:
        raise ValueError(f"消息太长,最大长度为 {max_message_length} 字节")

    # 构建填充
    padding_length = self.key_size_bytes - len(data) - 3

    # 生成随机非零填充字节
    padding = bytearray()
    while len(padding) < padding_length:
        random_bytes = os.urandom(padding_length - len(padding))
        # 移除零字节
        for byte in random_bytes:
            if byte != 0:
                padding.append(byte)
            if len(padding) >= padding_length:
                break

    # 构建完整的填充消息
    padded_message = bytearray([0x00, 0x02])
    padded_message.extend(padding)
    padded_message.append(0x00)
    padded_message.extend(data)

    # 转换为整数并进行 RSA 加密
    m = int.from_bytes(padded_message, byteorder="big")

    # RSA 加密: c = m^e mod n
    c = pow(m, self.exponent, self.modulus)

    # 转换回字节,确保长度正确
    encrypted = c.to_bytes(self.key_size_bytes, byteorder="big")

    return encrypted

verify_rs512 ¤

verify_rs512(message: bytes, signature: bytes) -> bool

验证 RS512 (RSA-SHA512) 签名

Parameters:

  • message ¤
    (bytes) –

    原始消息

  • signature ¤
    (bytes) –

    RSA 签名

Returns:

  • bool –

    签名是否有效

Source code in zzupy/crypto.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def verify_rs512(self, message: bytes, signature: bytes) -> bool:
    """验证 RS512 (RSA-SHA512) 签名

    Args:
        message: 原始消息
        signature: RSA 签名

    Returns:
        签名是否有效
    """
    # 计算消息的 SHA-512 哈希
    message_hash = hashlib.sha512(message).digest()

    # RSA 验证: m = s^e mod n
    s = int.from_bytes(signature, byteorder="big")
    m = pow(s, self.exponent, self.modulus)
    decrypted = m.to_bytes(self.key_size_bytes, byteorder="big")

    # 验证 PKCS#1 v1.5 填充格式
    # 格式: 0x00 || 0x01 || PS || 0x00 || DigestInfo
    if decrypted[0] != 0x00 or decrypted[1] != 0x01:
        return False

    # 查找 0x00 分隔符
    separator_index = decrypted.find(b"\x00", 2)
    if separator_index == -1:
        return False

    # DigestInfo 结构 (SHA-512)
    # SHA-512 的 DigestInfo 前缀
    sha512_digest_info = bytes.fromhex(
        "3051"  # SEQUENCE
        "300d"  # AlgorithmIdentifier SEQUENCE
        "0609"  # OID (9 bytes)
        "608648016503040203"  # SHA-512 OID
        "0500"  # NULL
        "0440"  # OCTET STRING (64 bytes)
    )

    digest_info_start = separator_index + 1
    digest_info = decrypted[digest_info_start:]

    # 验证 DigestInfo 结构
    expected_digest_info = sha512_digest_info + message_hash

    return digest_info == expected_digest_info

padding ¤

填充方案命名空间(兼容 cryptography)

Classes:

PKCS1v15 ¤

PKCS#1 v1.5 填充方案

serialization ¤

序列化命名空间(兼容 cryptography)

Methods:

load_pem_public_key staticmethod ¤

load_pem_public_key(data: bytes) -> RSAPublicKey

加载 PEM 格式公钥(兼容 cryptography 接口)

Source code in zzupy/crypto.py
249
250
251
252
@staticmethod
def load_pem_public_key(data: bytes) -> RSAPublicKey:
    """加载 PEM 格式公钥(兼容 cryptography 接口)"""
    return load_pem_public_key(data)

load_pem_public_key ¤

load_pem_public_key(pem_data: bytes) -> RSAPublicKey

从 PEM 格式加载 RSA 公钥

Parameters:

  • pem_data ¤

    (bytes) –

    PEM 格式的公钥数据

Returns:

Source code in zzupy/crypto.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def load_pem_public_key(pem_data: bytes) -> RSAPublicKey:
    """从 PEM 格式加载 RSA 公钥

    Args:
        pem_data: PEM 格式的公钥数据

    Returns:
        RSAPublicKey 对象
    """
    # 转换为字符串
    if isinstance(pem_data, bytes):
        pem_str = pem_data.decode("utf-8")
    else:
        pem_str = pem_data

    # 清理 PEM 头尾和空白字符
    pem_str = pem_str.strip()
    pem_str = pem_str.replace("-----BEGIN PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----END PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----BEGIN RSA PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----END RSA PUBLIC KEY-----", "")
    pem_str = pem_str.replace("\n", "")
    pem_str = pem_str.replace("\r", "")
    pem_str = pem_str.replace(" ", "")

    # Base64 解码得到 DER 格式数据
    der_bytes = base64.b64decode(pem_str)

    # 解析 DER 格式获取模数和指数
    modulus, exponent = _parse_der_public_key(der_bytes)

    return RSAPublicKey(modulus, exponent)

exception ¤

异常处理模块。

Classes:

ZZUError ¤

ZZUError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: Exception


              flowchart TD
              zzupy.exception.ZZUError[ZZUError]

              

              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

项目异常基类。

除了人类可读的错误消息外,还会保留额外上下文,便于调用方记录日志、 序列化或调试。

Parameters:

  • message ¤

    (str | None, default: None ) –

    错误消息。

  • context ¤

    (Mapping[str, Any] | None, default: None ) –

    结构化上下文信息。

  • error_code ¤

    (str | None, default: None ) –

    可选错误代码。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ClientStateError ¤

ClientStateError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, RuntimeError


              flowchart TD
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                


              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

客户端状态错误。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.exception.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.InvalidArgumentError
                


              click zzupy.exception.InvalidArgumentError href "" "zzupy.exception.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.NetworkError
                


              click zzupy.exception.NetworkError href "" "zzupy.exception.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.LoginError
                


              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.ParsingError
                


              click zzupy.exception.ParsingError href "" "zzupy.exception.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.exception.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.exception.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.exception.NotLoggedInError href "" "zzupy.exception.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

AuthenticationError ¤

AuthenticationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.exception.AuthenticationError[AuthenticationError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.exception.AuthenticationError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.exception.AuthenticationError href "" "zzupy.exception.AuthenticationError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

认证失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.OperationError
                


              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

DataNotFoundError ¤

DataNotFoundError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: OperationError, LookupError


              flowchart TD
              zzupy.exception.DataNotFoundError[DataNotFoundError]
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.OperationError --> zzupy.exception.DataNotFoundError
                                zzupy.exception.ZZUError --> zzupy.exception.OperationError
                



              click zzupy.exception.DataNotFoundError href "" "zzupy.exception.DataNotFoundError"
              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

请求的数据不存在或当前上下文中无法找到。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

MFAError ¤

MFAError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: LoginError


              flowchart TD
              zzupy.exception.MFAError[MFAError]
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.LoginError --> zzupy.exception.MFAError
                                zzupy.exception.ZZUError --> zzupy.exception.LoginError
                



              click zzupy.exception.MFAError href "" "zzupy.exception.MFAError"
              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

MFA 状态错误,当前会话可能需要 MFA 验证

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

logging ¤

zzupy 日志工具。

Functions:

sanitize_http_headers ¤

sanitize_http_headers(headers: Headers) -> dict[str, str]

脱敏并标准化 HTTP 头,便于安全输出到日志。

Source code in zzupy/logging.py
70
71
72
73
74
75
def sanitize_http_headers(headers: httpx2.Headers) -> dict[str, str]:
    """脱敏并标准化 HTTP 头,便于安全输出到日志。"""
    return {
        key: _REDACTED if _is_sensitive_key(key) else value
        for key, value in headers.items()
    }

sanitize_http_url ¤

sanitize_http_url(url: str | URL) -> str

脱敏 URL 查询参数。

Source code in zzupy/logging.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def sanitize_http_url(url: str | httpx2.URL) -> str:
    """脱敏 URL 查询参数。"""
    parsed = urlsplit(str(url))
    if not parsed.query:
        return str(url)

    sanitized_query = urlencode(
        [
            (key, _REDACTED if _is_sensitive_key(key) else value)
            for key, value in parse_qsl(parsed.query, keep_blank_values=True)
        ],
        doseq=True,
    )
    return urlunsplit(parsed._replace(query=sanitized_query))

sanitize_http_body ¤

sanitize_http_body(body: bytes | str | None, *, content_type: str | None = None, limit: int = _MAX_BODY_LENGTH) -> str

按内容类型对 HTTP Body 做脱敏与截断。

Source code in zzupy/logging.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def sanitize_http_body(
    body: bytes | str | None,
    *,
    content_type: str | None = None,
    limit: int = _MAX_BODY_LENGTH,
) -> str:
    """按内容类型对 HTTP Body 做脱敏与截断。"""
    if body in (None, b"", ""):
        return ""

    if isinstance(body, bytes):
        try:
            text = body.decode("utf-8")
        except UnicodeDecodeError:
            return "(binary)"
    else:
        text = body

    stripped = text.strip()
    normalized_type = (content_type or "").split(";", 1)[0].strip().lower()

    if normalized_type == "application/x-www-form-urlencoded":
        sanitized_form = urlencode(
            [
                (key, _REDACTED if _is_sensitive_key(key) else value)
                for key, value in parse_qsl(text, keep_blank_values=True)
            ],
            doseq=True,
        )
        return _truncate_text(sanitized_form, limit)

    if normalized_type == "application/json" or stripped.startswith(("{", "[")):
        try:
            return _truncate_text(
                json.dumps(
                    _sanitize_value(None, json.loads(text)),
                    ensure_ascii=False,
                    sort_keys=True,
                ),
                limit,
            )
        except json.JSONDecodeError:
            pass

    if normalized_type and not any(
        normalized_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES
    ):
        return "(binary)"

    return _truncate_text(text, limit)

log_http_headers ¤

log_http_headers(message: str, headers: Headers, *, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 头日志。

Source code in zzupy/logging.py
146
147
148
149
150
151
152
153
def log_http_headers(
    message: str,
    headers: httpx2.Headers,
    *,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 头日志。"""
    logger.log(level.upper(), "{}: {}", message, sanitize_http_headers(headers))

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

model ¤

Modules:

auth ¤

Classes:

PersonalInfoCardModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoCardModel[PersonalInfoCardModel]

              

              click zzupy.model.auth.PersonalInfoCardModel href "" "zzupy.model.auth.PersonalInfoCardModel"
            

‘我的’页中个人信息卡片 API 响应根模型

Attributes:

code instance-attribute ¤
code: int

响应结果码

message instance-attribute ¤
message: str | None

响应消息

data instance-attribute ¤
data: List[InnerData]

卡片数据列表

PersonalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfo[PersonalInfo]

              

              click zzupy.model.auth.PersonalInfo href "" "zzupy.model.auth.PersonalInfo"
            

个人信息模型

Attributes:

uid instance-attribute ¤
uid: str

学号

name instance-attribute ¤
name: str

用户姓名

student_type instance-attribute ¤
student_type: str

学生类型。比如‘本科生’

student_type_id instance-attribute ¤
student_type_id: str

学生类型 ID

college instance-attribute ¤
college: str

学院

college_id instance-attribute ¤
college_id: str

学院 ID

unread_email_count instance-attribute ¤
unread_email_count: int

邮箱未读邮件数

balance instance-attribute ¤
balance: float

一卡通余额

research_count instance-attribute ¤
research_count: int

科研信息数量

PersonalInfoModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel[PersonalInfoModel]

              

              click zzupy.model.auth.PersonalInfoModel href "" "zzupy.model.auth.PersonalInfoModel"
            

个人信息 API 响应根模型

Classes:

Data ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data[Data]

              

              click zzupy.model.auth.PersonalInfoModel.Data href "" "zzupy.model.auth.PersonalInfoModel.Data"
            

Classes:

Attributes:

username instance-attribute ¤
username: str

学号

Attributes ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data.Attributes[Attributes]

              

              click zzupy.model.auth.PersonalInfoModel.Data.Attributes href "" "zzupy.model.auth.PersonalInfoModel.Data.Attributes"
            

Attributes:

organization_id instance-attribute ¤
organization_id: str

学院 ID

organization_name instance-attribute ¤
organization_name: str

学院名

identity_type_name instance-attribute ¤
identity_type_name: str

学生类型

identity_type_id instance-attribute ¤
identity_type_id: str

学生类型 ID

user_name instance-attribute ¤
user_name: str

学生姓名

user_uid instance-attribute ¤
user_uid: str

学号

eas ¤

Classes:

  • Campus –

    校区信息

  • CultivateType –

    培养类型,如主修、辅修等

  • PeriodInfo –

    课时详情,描述一门课程各类学时的分配情况

  • Course –

    课程基本信息

  • OpenDepartment –

    开课院系

  • CourseType –

    课程类型,如必修课、选修课等

  • DateTimeText –

    上课时间的文字描述

  • DateTimePlaceText –

    上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

  • DateTimePlacePersonText –

    上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

  • ScheduleText –

    教学班排课的综合文字描述,聚合了三个层次的描述信息

  • ScheduleGroup –

    排课组,将一个教学班的多次课归入同一组

  • Building –

    楼栋信息

  • Room –

    教室信息

  • Schedule –

    单次课程的具体排课记录

  • Datum –

    教学班信息,包含课程、排课等完整数据

  • LessonModel –

    课程表查询 API 响应根模型

  • Lesson –

    课表中的一节课

  • TeachingWeek –

    教学周课表,7 天 × 10 节的网格

  • TeachingWeeks –
  • Semester –

    单个学期

  • SemesterModel –

    获取全部学期数据 API 响应根模型

  • CurrentSemesterModel –

    获取当前学期数据 API 响应根模型

  • WeekIndexModel –

    获取某日期的教学周序数 API 响应根模型

Campus ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Campus[Campus]

              

              click zzupy.model.eas.Campus href "" "zzupy.model.eas.Campus"
            

校区信息

Attributes:

name_zh instance-attribute ¤
name_zh: str

校区中文名

name_en class-attribute instance-attribute ¤
name_en: None | str = None

校区英文名

code instance-attribute ¤
code: str

校区编号

CultivateType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CultivateType[CultivateType]

              

              click zzupy.model.eas.CultivateType href "" "zzupy.model.eas.CultivateType"
            

培养类型,如主修、辅修等

Attributes:

name_zh instance-attribute ¤
name_zh: str

培养类型中文名

name_en class-attribute instance-attribute ¤
name_en: str | None = None

培养类型英文名

code instance-attribute ¤
code: str

培养类型编号

PeriodInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.PeriodInfo[PeriodInfo]

              

              click zzupy.model.eas.PeriodInfo href "" "zzupy.model.eas.PeriodInfo"
            

课时详情,描述一门课程各类学时的分配情况

Attributes:

total instance-attribute ¤
total: int

总学时

weeks instance-attribute ¤
weeks: int

开课周数

theory instance-attribute ¤
theory: int | None

理论学时数

theory_unit instance-attribute ¤
theory_unit: str | None

理论学时单位

require_theory instance-attribute ¤
require_theory: int | None

要求完成的理论学时数

experiment instance-attribute ¤
experiment: int | None

实验学时数

experiment_unit instance-attribute ¤
experiment_unit: str | None

实验学时单位

require_experiment instance-attribute ¤
require_experiment: int | None

要求完成的实验学时数

periods_per_week instance-attribute ¤
periods_per_week: int

每周课时数

Course ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Course[Course]

              

              click zzupy.model.eas.Course href "" "zzupy.model.eas.Course"
            

课程基本信息

Attributes:

code instance-attribute ¤
code: str

课程编号

name_zh instance-attribute ¤
name_zh: str

课程中文名

name_en class-attribute instance-attribute ¤
name_en: str | None = None

课程英文名

credits instance-attribute ¤
credits: float

学分

period_info instance-attribute ¤
period_info: PeriodInfo

课时详情

theory instance-attribute ¤
theory: bool

是否含理论课

experiment instance-attribute ¤
experiment: bool

是否含实验课

practice instance-attribute ¤
practice: bool

是否含实践课

test instance-attribute ¤
test: bool

是否含考试课时

machine instance-attribute ¤
machine: bool

是否含上机课

design instance-attribute ¤
design: bool

是否含设计课

extra instance-attribute ¤
extra: bool

是否含其他课时类型

OpenDepartment ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.OpenDepartment[OpenDepartment]

              

              click zzupy.model.eas.OpenDepartment href "" "zzupy.model.eas.OpenDepartment"
            

开课院系

Attributes:

name_zh instance-attribute ¤
name_zh: str

院系中文名

name_en class-attribute instance-attribute ¤
name_en: None | str = None

院系英文名

code instance-attribute ¤
code: str

院系编号

CourseType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CourseType[CourseType]

              

              click zzupy.model.eas.CourseType href "" "zzupy.model.eas.CourseType"
            

课程类型,如必修课、选修课等

Attributes:

name_zh instance-attribute ¤
name_zh: str

课程类型中文名

name_en class-attribute instance-attribute ¤
name_en: None | str = None

课程类型英文名

code instance-attribute ¤
code: str

课程类型编号

DateTimeText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimeText[DateTimeText]

              

              click zzupy.model.eas.DateTimeText href "" "zzupy.model.eas.DateTimeText"
            

上课时间的文字描述

Attributes:

text_zh instance-attribute ¤
text_zh: str

中文描述

text_en instance-attribute ¤
text_en: str

英文描述

text instance-attribute ¤
text: str

默认显示文本

DateTimePlaceText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlaceText[DateTimePlaceText]

              

              click zzupy.model.eas.DateTimePlaceText href "" "zzupy.model.eas.DateTimePlaceText"
            

上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

Attributes:

text_zh instance-attribute ¤
text_zh: str

中文描述

text_en instance-attribute ¤
text_en: str

英文描述

text instance-attribute ¤
text: str

默认显示文本

DateTimePlacePersonText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlacePersonText[DateTimePlacePersonText]

              

              click zzupy.model.eas.DateTimePlacePersonText href "" "zzupy.model.eas.DateTimePlacePersonText"
            

上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

Attributes:

text_zh instance-attribute ¤
text_zh: str

中文描述

text_en instance-attribute ¤
text_en: str

英文描述

text instance-attribute ¤
text: str

默认显示文本

ScheduleText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleText[ScheduleText]

              

              click zzupy.model.eas.ScheduleText href "" "zzupy.model.eas.ScheduleText"
            

教学班排课的综合文字描述,聚合了三个层次的描述信息

Attributes:

date_time_text instance-attribute ¤
date_time_text: DateTimeText

仅时间描述

date_time_place_text instance-attribute ¤
date_time_place_text: DateTimePlaceText

时间+地点描述

date_time_place_person_text instance-attribute ¤
date_time_place_person_text: DateTimePlacePersonText

时间+地点+教师描述

ScheduleGroup ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleGroup[ScheduleGroup]

              

              click zzupy.model.eas.ScheduleGroup href "" "zzupy.model.eas.ScheduleGroup"
            

排课组,将一个教学班的多次课归入同一组

Attributes:

id instance-attribute ¤
id: int

排课组 ID

lesson_id instance-attribute ¤
lesson_id: int

所属教学班 ID

no instance-attribute ¤
no: int

排课组序号

Building ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Building[Building]

              

              click zzupy.model.eas.Building href "" "zzupy.model.eas.Building"
            

楼栋信息

Attributes:

name_zh instance-attribute ¤
name_zh: str

楼栋中文名

name_en class-attribute instance-attribute ¤
name_en: None | str = None

楼栋英文名

code instance-attribute ¤
code: str

楼栋编号

Room ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Room[Room]

              

              click zzupy.model.eas.Room href "" "zzupy.model.eas.Room"
            

教室信息

Attributes:

name_zh instance-attribute ¤
name_zh: str

教室中文名

name_en class-attribute instance-attribute ¤
name_en: None | str = None

教室英文名

building instance-attribute ¤
building: Building

所在楼栋

campus instance-attribute ¤
campus: Campus

所在校区

seat_number instance-attribute ¤
seat_number: None

座位数

Schedule ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Schedule[Schedule]

              

              click zzupy.model.eas.Schedule href "" "zzupy.model.eas.Schedule"
            

单次课程的具体排课记录

Attributes:

schedule_group_id instance-attribute ¤
schedule_group_id: int

所属排课组 ID,关联 ScheduleGroup.id

date instance-attribute ¤
date: Date

上课日期,格式 "YYYY-MM-DD"

original_date class-attribute instance-attribute ¤
original_date: None | str = None

原始日期

weekday instance-attribute ¤
weekday: int

星期几,1=周一,4=周四,7=周日

start_time instance-attribute ¤
start_time: ZonedDateTime

开始时间,格式 HHMM,如 1010 表示 10:10

end_time instance-attribute ¤
end_time: ZonedDateTime

结束时间,格式 HHMM,如 1150 表示 11:50

teacher_name instance-attribute ¤
teacher_name: str

授课教师中文姓名

teacher_name_en instance-attribute ¤
teacher_name_en: str | None

授课教师英文姓名

custom_place class-attribute instance-attribute ¤
custom_place: None | str = None

自定义上课地点

room instance-attribute ¤
room: Room | None

教室信息

start_unit instance-attribute ¤
start_unit: int

开始节次,如 3(第3节)

end_unit instance-attribute ¤
end_unit: int

结束节次,如 4(第4节)

state instance-attribute ¤
state: str

课程状态

week_index instance-attribute ¤
week_index: int

本次课所在教学周,如 1 表示第1周

lesson_type instance-attribute ¤
lesson_type: str

课时类型,如 "THEORY"(理论课)

periods instance-attribute ¤
periods: int

本次课课时数,如 2

real_start_time instance-attribute ¤
real_start_time: ZonedDateTime

实际开始时间,格式同 startTime

real_end_time instance-attribute ¤
real_end_time: ZonedDateTime

实际结束时间,格式同 endTime

Datum ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Datum[Datum]

              

              click zzupy.model.eas.Datum href "" "zzupy.model.eas.Datum"
            

教学班信息,包含课程、排课等完整数据

Attributes:

id instance-attribute ¤
id: int

教学班 ID

biz_type_id instance-attribute ¤
biz_type_id: int

业务类型 ID

campus instance-attribute ¤
campus: Campus

开课校区

cultivate_type instance-attribute ¤
cultivate_type: CultivateType

培养类型

code instance-attribute ¤
code: str

教学班编号

course instance-attribute ¤
course: Course

课程基本信息

remark class-attribute instance-attribute ¤
remark: None | str = None

备注

schedule_state instance-attribute ¤
schedule_state: str

排课状态

std_count instance-attribute ¤
std_count: int

选课学生人数

open_department instance-attribute ¤
open_department: OpenDepartment

开课院系

course_type instance-attribute ¤
course_type: CourseType

课程类型

teacher_assignment_list instance-attribute ¤
teacher_assignment_list: list[str]

授课教师中文姓名列表

teacher_assignment_en_list instance-attribute ¤
teacher_assignment_en_list: list[str | None]

授课教师英文姓名列表

schedule_text instance-attribute ¤
schedule_text: ScheduleText

排课文字描述(时间/地点/教师)

schedule_groups instance-attribute ¤
schedule_groups: list[ScheduleGroup]

排课组列表

schedules instance-attribute ¤
schedules: list[Schedule]

每次课的具体排课记录列表

students instance-attribute ¤
students: list[Any]

学生列表

time_table_layout_assoc instance-attribute ¤
time_table_layout_assoc: int

关联的课表布局 ID

suggest_schedule_weeks_info instance-attribute ¤
suggest_schedule_weeks_info: None

建议排课周信息

LessonModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.LessonModel[LessonModel]

              

              click zzupy.model.eas.LessonModel href "" "zzupy.model.eas.LessonModel"
            

课程表查询 API 响应根模型

Attributes:

result instance-attribute ¤
result: int

响应结果码

message class-attribute instance-attribute ¤
message: None | str = None

响应消息

data instance-attribute ¤
data: list[Datum]

教学班数据列表

Lesson ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Lesson[Lesson]

              

              click zzupy.model.eas.Lesson href "" "zzupy.model.eas.Lesson"
            

课表中的一节课

Attributes:

course instance-attribute ¤
course: Course

对应课程

schedule instance-attribute ¤
schedule: Schedule

对应时间

TeachingWeek ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.TeachingWeek[TeachingWeek]

              

              click zzupy.model.eas.TeachingWeek href "" "zzupy.model.eas.TeachingWeek"
            

教学周课表,7 天 × 10 节的网格

Methods:

  • set –

    设置某天某节的课程

  • get –

    获取某天某节的课程

  • get_day –

    获取某天的全部课程

  • get_unit –

    获取某节 7 天的课程

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

Attributes:

lessons class-attribute instance-attribute ¤
lessons: dict[tuple[int, int], Lesson] = Field(default_factory=dict)

内部存储:仅存储非空课程,key 为 (weekday, unit),value 为 Lesson

grid property ¤
grid: list[list[Lesson | None]]

网格形式的课表,仅在访问时动态生成

set ¤
set(weekday: int, unit: int, lesson: Lesson) -> None

设置某天某节的课程

Parameters:

  • weekday ¤
    (int) –

    第几天

  • unit ¤
    (int) –

    第几节

  • lesson ¤
    (Lesson) –

    课程

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
449
450
451
452
453
454
455
456
457
458
459
460
461
def set(self, weekday: int, unit: int, lesson: Lesson) -> None:
    """设置某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节
        lesson: 课程

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    self.lessons[(weekday, unit)] = lesson
get ¤
get(weekday: int, unit: int) -> Lesson | None

获取某天某节的课程

Parameters:

  • weekday ¤
    (int) –

    第几天

  • unit ¤
    (int) –

    第几节

Returns:

  • Lesson | None –

    Lesson | None: 对应课程或 None

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def get(self, weekday: int, unit: int) -> Lesson | None:
    """获取某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节

    Returns:
        Lesson | None: 对应课程或 None

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    return self.lessons.get((weekday, unit))
get_day ¤
get_day(weekday: int) -> list[Lesson | None]

获取某天的全部课程

Parameters:

  • weekday ¤
    (int) –

    第几天

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def get_day(self, weekday: int) -> list[Lesson | None]:
    """获取某天的全部课程

    Args:
        weekday: 第几天

    Returns:
        list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {weekday} 超出范围
    """
    self._validate_index(weekday, 1)
    return [self.lessons.get((weekday, unit)) for unit in range(1, self.UNITS + 1)]
get_unit ¤
get_unit(unit: int) -> list[Lesson | None]

获取某节 7 天的课程

Parameters:

  • unit ¤
    (int) –

    第几节课

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def get_unit(self, unit: int) -> list[Lesson | None]:
    """获取某节 7 天的课程

    Args:
        unit: 第几节课

    Returns:
        list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {unit} 超出范围
    """
    self._validate_index(1, unit)
    return [self.lessons.get((day, unit)) for day in range(1, self.DAYS + 1)]
to_calendar ¤
to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤
    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()

    for lesson in self.lessons.values():
        if not lesson:
            continue

        schedule = lesson.schedule
        course = lesson.course

        # 去重
        if schedule in processed_schedule_ids:
            continue
        processed_schedule_ids.add(schedule)

        event = Event()

        # 课程中文名
        event.add("summary", course.name_zh)

        # 起始时间
        start_time = (
            schedule.real_start_time.to_stdlib() or schedule.start_time.to_stdlib()
        )
        end_time = (
            schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
        )
        event.add("dtstart", start_time)
        event.add("dtend", end_time)

        # 事件生成时间
        event.add("dtstamp", Instant.now().to_stdlib())

        # 事件 UID
        event.add("uid", f"{uuid.uuid4()}@schedule")

        # 上课地点
        location = ""
        if schedule.room:
            location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
        elif schedule.custom_place:
            location = schedule.custom_place

        if location:
            event.add("location", location)

        # 描述
        description_lines = [
            f"授课教师: {schedule.teacher_name or '未知'}",
            f"课程代码: {course.code}",
            f"学分: {course.credits}",
            f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
            f"教学周: 第 {schedule.week_index} 周",
            f"类型: {schedule.lesson_type}",
        ]
        event.add("description", "\n".join(description_lines))

        cal.add_component(event)
    return cal

TeachingWeeks ¤

Bases: RootModel


              flowchart TD
              zzupy.model.eas.TeachingWeeks[TeachingWeeks]

              

              click zzupy.model.eas.TeachingWeeks href "" "zzupy.model.eas.TeachingWeeks"
            

Methods:

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

to_calendar ¤
to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤
    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()
    for teaching_week in self.root:
        for lesson in teaching_week.lessons.values():
            if not lesson:
                continue

            schedule = lesson.schedule
            course = lesson.course

            # 去重
            if schedule in processed_schedule_ids:
                continue
            processed_schedule_ids.add(schedule)

            event = Event()

            # 课程中文名
            event.add("summary", course.name_zh)

            # 起始时间
            start_time = (
                schedule.real_start_time.to_stdlib()
                or schedule.start_time.to_stdlib()
            )
            end_time = (
                schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
            )
            event.add("dtstart", start_time)
            event.add("dtend", end_time)

            # 事件生成时间
            event.add("dtstamp", Instant.now().to_stdlib())

            # 事件 UID
            event.add("uid", f"{uuid.uuid4()}@schedule")

            # 上课地点
            location = ""
            if schedule.room:
                location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
            elif schedule.custom_place:
                location = schedule.custom_place

            if location:
                event.add("location", location)

            # 描述
            description_lines = [
                f"授课教师: {schedule.teacher_name or '未知'}",
                f"课程代码: {course.code}",
                f"学分: {course.credits}",
                f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
                f"教学周: 第 {schedule.week_index} 周",
                f"类型: {schedule.lesson_type}",
            ]
            event.add("description", "\n".join(description_lines))

            cal.add_component(event)

    return cal

Semester ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Semester[Semester]

              

              click zzupy.model.eas.Semester href "" "zzupy.model.eas.Semester"
            

单个学期

SemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.SemesterModel[SemesterModel]

              

              click zzupy.model.eas.SemesterModel href "" "zzupy.model.eas.SemesterModel"
            

获取全部学期数据 API 响应根模型

Attributes:

result instance-attribute ¤
result: int

响应结果码

message instance-attribute ¤
message: None

响应消息

data instance-attribute ¤
data: list[Semester]

学期数据列表

CurrentSemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CurrentSemesterModel[CurrentSemesterModel]

              

              click zzupy.model.eas.CurrentSemesterModel href "" "zzupy.model.eas.CurrentSemesterModel"
            

获取当前学期数据 API 响应根模型

Attributes:

result instance-attribute ¤
result: int

响应结果码

message instance-attribute ¤
message: None

响应消息

data instance-attribute ¤
data: Semester

学期数据列表

WeekIndexModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.WeekIndexModel[WeekIndexModel]

              

              click zzupy.model.eas.WeekIndexModel href "" "zzupy.model.eas.WeekIndexModel"
            

获取某日期的教学周序数 API 响应根模型

Attributes:

  • code (int) –

    响应结果码

  • message (str | None) –

    响应消息

  • data (InnerData) –

    学期数据列表

code instance-attribute ¤
code: int

响应结果码

message instance-attribute ¤
message: str | None

响应消息

data instance-attribute ¤
data: InnerData

学期数据列表

ecard ¤

Classes:

ECardTemplate ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardTemplate[ECardTemplate]

              

              click zzupy.model.ecard.ECardTemplate href "" "zzupy.model.ecard.ECardTemplate"
            

一卡通模板项

ECardAccountData ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountData[ECardAccountData]

              

              click zzupy.model.ecard.ECardAccountData href "" "zzupy.model.ecard.ECardAccountData"
            

一卡通账户数据

ECardAccountModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountModel[ECardAccountModel]

              

              click zzupy.model.ecard.ECardAccountModel href "" "zzupy.model.ecard.ECardAccountModel"
            

一卡通账户 API 响应根模型

network ¤

Classes:

OnlineDevice ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.OnlineDevice[OnlineDevice]

              

              click zzupy.model.network.OnlineDevice href "" "zzupy.model.network.OnlineDevice"
            

在线设备信息

Methods:

Attributes:

brasid instance-attribute ¤
brasid: str

BRAS ID

down_flow instance-attribute ¤
down_flow: str

下行流量

host_name class-attribute instance-attribute ¤
host_name: str = ''

主机名

ip instance-attribute ¤
ip: str

IP地址

login_time instance-attribute ¤
login_time: str

登录时间,格式为YYYY-MM-DD HH:MM:SS

mac instance-attribute ¤
mac: str

MAC地址

session_id instance-attribute ¤
session_id: str

会话ID

terminal_type instance-attribute ¤
terminal_type: str

终端类型

up_flow instance-attribute ¤
up_flow: str

上行流量

use_time instance-attribute ¤
use_time: str

使用时间(秒)

user_id instance-attribute ¤
user_id: int

用户ID

dump_json ¤
dump_json(indent: Optional[int] = None) -> str

格式化为JSON字符串

Source code in zzupy/model/network.py
85
86
87
def dump_json(self, indent: Optional[int] = None) -> str:
    """格式化为JSON字符串"""
    return json.dumps(self.model_dump(), ensure_ascii=False, indent=indent)

AuthResult ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.AuthResult[AuthResult]

              

              click zzupy.model.network.AuthResult href "" "zzupy.model.network.AuthResult"
            

Portal 认证结果

Attributes:

result instance-attribute ¤
result: int

认证结果

message class-attribute instance-attribute ¤
message: str = Field(..., alias='msg')

Portal 服务器返回信息

PortalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.PortalInfo[PortalInfo]

              

              click zzupy.model.network.PortalInfo href "" "zzupy.model.network.PortalInfo"
            

探测出的 Portal 认证信息

Attributes:

auth_url instance-attribute ¤
auth_url: str

认证网页 URL

portal_server_url instance-attribute ¤
portal_server_url: str

Portal 服务器 URL

user_ip instance-attribute ¤
user_ip: str

客户端 IP

utils ¤

工具函数库

Classes:

  • XorCipher –

    一个使用异或 (XOR) 算法进行简单加密和解密的类。

  • JsonPParser –

    JsonP 格式数据解析器

Functions:

XorCipher ¤

XorCipher(key_string: str = '')

一个使用异或 (XOR) 算法进行简单加密和解密的类。

Methods:

  • encrypt –

    将明文与实例密钥进行异或运算,并转为十六进制字符串。

  • decrypt –

    将十六进制字符串解密回原始密码。

Source code in zzupy/utils.py
223
224
def __init__(self, key_string: str = ""):
    self._key: int = self._generate_key(key_string)

encrypt ¤

encrypt(string: str) -> str

将明文与实例密钥进行异或运算,并转为十六进制字符串。

Parameters:

  • string ¤
    (str) –

    明文

Source code in zzupy/utils.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def encrypt(self, string: str) -> str:
    """将明文与实例密钥进行异或运算,并转为十六进制字符串。

    Args:
        string: 明文
    """
    if len(string) > 512:
        return "-1"

    encrypted_output = []
    for char in string:
        ch = ord(char) ^ self._key
        hex_str = format(ch, "02x")
        encrypted_output.append(hex_str)

    return "".join(encrypted_output)

decrypt ¤

decrypt(hex_string: str) -> str

将十六进制字符串解密回原始密码。

Parameters:

  • hex_string ¤
    (str) –

    十六进制字符串

Raises:

  • ValueError –

    如果十六进制字符串格式错误

Source code in zzupy/utils.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def decrypt(self, hex_string: str) -> str:
    """将十六进制字符串解密回原始密码。

    Args:
        hex_string: 十六进制字符串

    Raises:
        ValueError: 如果十六进制字符串格式错误
    """
    if len(hex_string) % 2 != 0:
        raise ValueError("十六进制字符串长度必须为偶数")

    original_password = []
    for i in range(0, len(hex_string), 2):
        hex_pair = hex_string[i : i + 2]
        decimal_value = int(hex_pair, 16)
        # 与实例密钥进行异或
        original_char = chr(decimal_value ^ self._key)
        original_password.append(original_char)

    return "".join(original_password)

JsonPParser ¤

JsonPParser(text: str)

JsonP 格式数据解析器

Source code in zzupy/utils.py
283
284
285
286
287
def __init__(self, text: str):
    self.text = text
    self._callback: str | None = None
    self._data: str | None = None
    self._parse()

decode_jwt_payload ¤

decode_jwt_payload(token: str) -> dict[str, Any]

在不验证签名的情况下解析 JWT payload。

该函数只用于读取 CAS Token 的过期时间,不应作为 Token 身份验证手段。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

Raises:

  • ValueError –

    如果 Token 过长、结构无效或 JSON 内容不正确。

Source code in zzupy/utils.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def decode_jwt_payload(token: str) -> dict[str, Any]:
    """在不验证签名的情况下解析 JWT payload。

    该函数只用于读取 CAS Token 的过期时间,不应作为 Token 身份验证手段。

    Args:
        token: JWT 字符串。

    Returns:
        JWT payload。

    Raises:
        ValueError: 如果 Token 过长、结构无效或 JSON 内容不正确。
    """
    if not token or len(token) > _MAX_JWT_LENGTH:
        raise ValueError("JWT 为空或超过长度限制")

    segments = token.split(".")
    if len(segments) != 3 or any(not segment for segment in segments):
        raise ValueError("JWT 结构无效")

    try:
        header = json.loads(_decode_jwt_segment(segments[0]))
        payload = json.loads(_decode_jwt_segment(segments[1]))
        _decode_jwt_segment(segments[2])
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise ValueError("JWT 包含无效的 JSON") from exc

    if not isinstance(header, dict) or not isinstance(payload, dict):
        raise ValueError("JWT header 和 payload 必须为 JSON 对象")
    return payload

get_jwt_expiration ¤

get_jwt_expiration(token: str) -> datetime

读取 JWT 的过期时间。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

  • datetime –

    JWT exp 对应的本地时间。

Raises:

  • ValueError –

    如果 Token 无效或缺少有效的 exp。

Source code in zzupy/utils.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def get_jwt_expiration(token: str) -> datetime:
    """读取 JWT 的过期时间。

    Args:
        token: JWT 字符串。

    Returns:
        JWT `exp` 对应的本地时间。

    Raises:
        ValueError: 如果 Token 无效或缺少有效的 `exp`。
    """
    payload = decode_jwt_payload(token)
    try:
        return datetime.fromtimestamp(float(payload["exp"]))
    except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
        raise ValueError("JWT 缺少有效的 exp") from exc

extract_first_html_attr ¤

extract_first_html_attr(html_content: str, tag: str, attr: str, match_attrs: dict[str, str] | None = None) -> str | None

提取第一个匹配 HTML 标签的属性值。

Source code in zzupy/utils.py
111
112
113
114
115
116
117
118
119
120
121
def extract_first_html_attr(
    html_content: str,
    tag: str,
    attr: str,
    match_attrs: dict[str, str] | None = None,
) -> str | None:
    """提取第一个匹配 HTML 标签的属性值。"""
    parser = _FirstHtmlAttributeParser(tag, attr, match_attrs)
    parser.feed(html_content)
    parser.close()
    return parser.value

get_sign ¤

get_sign(dynamic_secret: str, params: str) -> str

获取sign值

Parameters:

  • dynamic_secret ¤

    (str) –

    login 后自动获取,来自 login-token 请求

  • params ¤

    (str) –

    URL 请求参数

Returns:

  • str ( str ) –

    sign 值

Source code in zzupy/utils.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def get_sign(dynamic_secret: str, params: str) -> str:
    """获取sign值

    Args:
        dynamic_secret (str): login 后自动获取,来自 login-token 请求
        params (str): URL 请求参数

    Returns:
        str: sign 值
    """
    parsed_params: Dict[str, str] = {k: v[0] for k, v in parse_qs(params).items()}

    timestamp = parsed_params.pop("timestamp", "")
    random = parsed_params.pop("random", "")

    sorted_values = [v for k, v in sorted(parsed_params.items())]

    parts_to_sign = [dynamic_secret] + sorted_values + [timestamp, random]
    original_string = "|".join(parts_to_sign)

    sign = hashlib.md5(original_string.encode("utf-8")).hexdigest().upper()
    return sign

pkcs7_unpad ¤

pkcs7_unpad(padded_data: bytes, block_size: int) -> bytes

去除数据中的PKCS#7填充。

Parameters:

  • padded_data ¤

    (bytes) –

    带填充的数据

  • block_size ¤

    (int) –

    用于填充的块大小

Returns:

  • bytes ( bytes ) –

    去除填充后的数据

Raises:

Source code in zzupy/utils.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def pkcs7_unpad(padded_data: bytes, block_size: int) -> bytes:
    """去除数据中的PKCS#7填充。

    Args:
        padded_data (bytes): 带填充的数据
        block_size (int): 用于填充的块大小

    Returns:
        bytes: 去除填充后的数据

    Raises:
        ValueError: 如果填充无效
    """
    if not padded_data or len(padded_data) % block_size != 0:
        raise ValueError("无效的填充数据长度")

    # 从最后一个字节获取填充长度
    padding_len = padded_data[-1]

    # 检查填充长度是否有效
    if padding_len > block_size or padding_len == 0:
        raise ValueError("无效的填充长度")

    # 检查所有填充字节是否正确
    for i in range(1, padding_len + 1):
        if padded_data[-i] != padding_len:
            raise ValueError("无效的填充")

    # 返回去除填充后的数据
    return padded_data[:-padding_len]

sm4_decrypt_ecb ¤

sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str

SM4 解密,ECB模式

Parameters:

Returns:

  • str –

    明文 Hex

Source code in zzupy/utils.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str:
    """SM4 解密,ECB模式

    Args:
        ciphertext (bytes): 密文
        key (bytes): 密钥

    Returns:
        明文 Hex
    """
    sm4 = gmalg.SM4(key)
    block_size = 16
    decrypted_padded = b""
    for i in range(0, len(ciphertext), block_size):
        block = ciphertext[i : i + block_size]
        decrypted_padded += sm4.decrypt(block)
    decrypted = pkcs7_unpad(decrypted_padded, block_size)
    return decrypted.decode()

get_local_ip ¤

get_local_ip(target: str = '8.8.8.8') -> str | None

获取用于连接到特定目标IP的本地IP地址。

Parameters:

  • target ¤

    (str, default: '8.8.8.8' ) –

    目标主机名或IP地址。默认为 '8.8.8.8'。

Returns:

  • str ( str | None ) –

    用于到达目标的本地IP地址

  • None ( str | None ) –

    如果发生网络错误

Source code in zzupy/utils.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_local_ip(target: str = "8.8.8.8") -> str | None:
    """
    获取用于连接到特定目标IP的本地IP地址。

    Args:
        target: 目标主机名或IP地址。默认为 '8.8.8.8'。

    Returns:
        str: 用于到达目标的本地IP地址
        None: 如果发生网络错误
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect((target, 80))
            local_ip = s.getsockname()[0]
            return local_ip
    except socket.error:
        return None

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

web ¤

Web API 客户端模块

Modules:

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.HTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.HTTPTransport()

    else:
        transport = httpx2.HTTPTransport(local_address=self._bind_address)
    self._client = httpx2.Client(
        transport=transport,
    )

portal_auth ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/web/network.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/web/network.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/web/network.py
292
293
294
def __init__(self, base_url: str):
    self._client = httpx2.Client(base_url=base_url)
    self._logged_in = False

login ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/web/network.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/web/network.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@require_auth
def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device ¤

kick_device(session_id: str) -> None

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
@require_auth
def kick_device(self, session_id: str) -> None:
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout ¤

logout() -> None

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@require_auth
def logout(self) -> None:
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    def _get_portal_server_url(client: httpx2.Client, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        with httpx2.Client(timeout=10.0) as client:
            response = client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

network ¤

校园网模块

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤
    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤
    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤
    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.HTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.HTTPTransport()

    else:
        transport = httpx2.HTTPTransport(local_address=self._bind_address)
    self._client = httpx2.Client(
        transport=transport,
    )
portal_auth ¤
portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/web/network.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
auth ¤
auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/web/network.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/web/network.py
292
293
294
def __init__(self, base_url: str):
    self._client = httpx2.Client(base_url=base_url)
    self._logged_in = False
login ¤
login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/web/network.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc
get_online_devices ¤
get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/web/network.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@require_auth
def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
kick_device ¤
kick_device(session_id: str) -> None

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
@require_auth
def kick_device(self, session_id: str) -> None:
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
logout ¤
logout() -> None

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@require_auth
def logout(self) -> None:
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    def _get_portal_server_url(client: httpx2.Client, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        with httpx2.Client(timeout=10.0) as client:
            response = client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

zzupy.aio¤

aio ¤

各模块的异步实现

Modules:

  • app –

    移动应用 API 抽象层

  • web –

    Web API 客户端模块

app ¤

移动应用 API 抽象层

Modules:

Classes:

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.CASClient
                


              click zzupy.aio.app.CASClient href "" "zzupy.aio.app.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required async ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code async ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms async ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()
verify_sms_code async ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms async ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login async ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info async ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None

login async ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room async ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy async ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance async ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict async ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy async ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

auth ¤

统一认证

Classes:

  • CASClient –

    统一认证系统 (CAS) App 客户端。

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.auth.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.auth.CASClient
                


              click zzupy.aio.app.auth.CASClient href "" "zzupy.aio.app.auth.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)
user_token property ¤
user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤
refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤
logged_in: bool

当前会话是否已登录

MFAClient ¤
MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required async ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code async ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms async ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()
verify_sms_code async ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤ (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms async ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)
set_token ¤
set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token
set_device ¤
set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()
login async ¤
login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc
get_user_info async ¤
get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

eas ¤

Modules:

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤
UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

ecard ¤

一卡通

Classes:

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤
    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None
login async ¤
login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")
get_default_room async ¤
get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc
recharge_energy async ¤
recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
get_balance async ¤
get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc
get_room_dict async ¤
get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
get_remaining_energy async ¤
get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

interfaces ¤

抽象接口

web ¤

Web API 客户端模块

Modules:

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )

portal_auth async ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth async ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False

login async ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices async ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device async ¤

kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout async ¤

logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

network ¤

校园网模块

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤
    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤
    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤
    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )
portal_auth async ¤
portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
auth async ¤
auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False
login async ¤
login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc
get_online_devices async ¤
get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
kick_device async ¤
kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
logout async ¤
logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

zzupy.aio.app¤

app ¤

移动应用 API 抽象层

Modules:

Classes:

Attributes:

__all__ module-attribute ¤

__all__ = ['CASClient', 'ECardClient', 'UndergradEASClient']

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.CASClient
                


              click zzupy.aio.app.CASClient href "" "zzupy.aio.app.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤

    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

reset ¤

reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

is_required async ¤

is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc

request_sms_code async ¤

request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

send_sms async ¤

send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()

verify_sms_code async ¤

verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

verify_sms async ¤

verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤

    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤

    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤

    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login async ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤

    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info async ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None

login async ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room async ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy async ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤

    (str) –

    支付密码

  • amt ¤

    (int) –

    充值金额

  • room ¤

    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance async ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict async ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤

    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy async ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤

    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

auth ¤

统一认证

Classes:

  • CASClient –

    统一认证系统 (CAS) App 客户端。

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.auth.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.auth.CASClient
                


              click zzupy.aio.app.auth.CASClient href "" "zzupy.aio.app.auth.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required async ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code async ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms async ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()
verify_sms_code async ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms async ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login async ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info async ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

eas ¤

Modules:

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login async ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week async ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks async ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index async ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters async ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close async ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

ecard ¤

一卡通

Classes:

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None

login async ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room async ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy async ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance async ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict async ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy async ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

interfaces ¤

抽象接口

zzupy.aio.app.auth¤

auth ¤

统一认证

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.aio.app.auth.ICASClient[ICASClient]

              

              click zzupy.aio.app.auth.ICASClient href "" "zzupy.aio.app.auth.ICASClient"
            
Source code in zzupy/aio/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

RSAPublicKey ¤

RSAPublicKey(modulus: int, exponent: int)

轻量级 RSA 公钥实现

Parameters:

  • modulus ¤

    (int) –

    RSA 模数 (n)

  • exponent ¤

    (int) –

    RSA 公开指数 (e),通常是 65537

Methods:

  • encrypt –

    使用 PKCS#1 v1.5 填充进行 RSA 加密

  • verify_rs512 –

    验证 RS512 (RSA-SHA512) 签名

Source code in zzupy/crypto.py
12
13
14
15
16
17
18
19
20
21
22
def __init__(self, modulus: int, exponent: int):
    """初始化 RSA 公钥

    Args:
        modulus: RSA 模数 (n)
        exponent: RSA 公开指数 (e),通常是 65537
    """
    self.modulus = modulus
    self.exponent = exponent
    self.key_size_bits = modulus.bit_length()
    self.key_size_bytes = (self.key_size_bits + 7) // 8

encrypt ¤

encrypt(data: bytes, padding_scheme=None) -> bytes

使用 PKCS#1 v1.5 填充进行 RSA 加密

Parameters:

  • data ¤

    (bytes) –

    要加密的数据

  • padding_scheme ¤

    –

    填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

Returns:

  • bytes –

    加密后的字节数据

Source code in zzupy/crypto.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def encrypt(self, data: bytes, padding_scheme=None) -> bytes:
    """使用 PKCS#1 v1.5 填充进行 RSA 加密

    Args:
        data: 要加密的数据
        padding_scheme: 填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

    Returns:
        加密后的字节数据
    """
    # PKCS#1 v1.5 填充
    # 格式: 0x00 || 0x02 || PS || 0x00 || M
    # PS 是随机非零字节,长度至少为 8

    max_message_length = self.key_size_bytes - 11  # 预留给填充的空间

    if len(data) > max_message_length:
        raise ValueError(f"消息太长,最大长度为 {max_message_length} 字节")

    # 构建填充
    padding_length = self.key_size_bytes - len(data) - 3

    # 生成随机非零填充字节
    padding = bytearray()
    while len(padding) < padding_length:
        random_bytes = os.urandom(padding_length - len(padding))
        # 移除零字节
        for byte in random_bytes:
            if byte != 0:
                padding.append(byte)
            if len(padding) >= padding_length:
                break

    # 构建完整的填充消息
    padded_message = bytearray([0x00, 0x02])
    padded_message.extend(padding)
    padded_message.append(0x00)
    padded_message.extend(data)

    # 转换为整数并进行 RSA 加密
    m = int.from_bytes(padded_message, byteorder="big")

    # RSA 加密: c = m^e mod n
    c = pow(m, self.exponent, self.modulus)

    # 转换回字节,确保长度正确
    encrypted = c.to_bytes(self.key_size_bytes, byteorder="big")

    return encrypted

verify_rs512 ¤

verify_rs512(message: bytes, signature: bytes) -> bool

验证 RS512 (RSA-SHA512) 签名

Parameters:

  • message ¤

    (bytes) –

    原始消息

  • signature ¤

    (bytes) –

    RSA 签名

Returns:

  • bool –

    签名是否有效

Source code in zzupy/crypto.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def verify_rs512(self, message: bytes, signature: bytes) -> bool:
    """验证 RS512 (RSA-SHA512) 签名

    Args:
        message: 原始消息
        signature: RSA 签名

    Returns:
        签名是否有效
    """
    # 计算消息的 SHA-512 哈希
    message_hash = hashlib.sha512(message).digest()

    # RSA 验证: m = s^e mod n
    s = int.from_bytes(signature, byteorder="big")
    m = pow(s, self.exponent, self.modulus)
    decrypted = m.to_bytes(self.key_size_bytes, byteorder="big")

    # 验证 PKCS#1 v1.5 填充格式
    # 格式: 0x00 || 0x01 || PS || 0x00 || DigestInfo
    if decrypted[0] != 0x00 or decrypted[1] != 0x01:
        return False

    # 查找 0x00 分隔符
    separator_index = decrypted.find(b"\x00", 2)
    if separator_index == -1:
        return False

    # DigestInfo 结构 (SHA-512)
    # SHA-512 的 DigestInfo 前缀
    sha512_digest_info = bytes.fromhex(
        "3051"  # SEQUENCE
        "300d"  # AlgorithmIdentifier SEQUENCE
        "0609"  # OID (9 bytes)
        "608648016503040203"  # SHA-512 OID
        "0500"  # NULL
        "0440"  # OCTET STRING (64 bytes)
    )

    digest_info_start = separator_index + 1
    digest_info = decrypted[digest_info_start:]

    # 验证 DigestInfo 结构
    expected_digest_info = sha512_digest_info + message_hash

    return digest_info == expected_digest_info

padding ¤

填充方案命名空间(兼容 cryptography)

Classes:

PKCS1v15 ¤

PKCS#1 v1.5 填充方案

serialization ¤

序列化命名空间(兼容 cryptography)

Methods:

load_pem_public_key staticmethod ¤

load_pem_public_key(data: bytes) -> RSAPublicKey

加载 PEM 格式公钥(兼容 cryptography 接口)

Source code in zzupy/crypto.py
249
250
251
252
@staticmethod
def load_pem_public_key(data: bytes) -> RSAPublicKey:
    """加载 PEM 格式公钥(兼容 cryptography 接口)"""
    return load_pem_public_key(data)

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.auth.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.auth.LoginError
                


              click zzupy.aio.app.auth.LoginError href "" "zzupy.aio.app.auth.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.auth.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.auth.ParsingError
                


              click zzupy.aio.app.auth.ParsingError href "" "zzupy.aio.app.auth.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.auth.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.auth.NetworkError
                


              click zzupy.aio.app.auth.NetworkError href "" "zzupy.aio.app.auth.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.aio.app.auth.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.aio.app.auth.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.aio.app.auth.NotLoggedInError href "" "zzupy.aio.app.auth.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.auth.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.auth.OperationError
                


              click zzupy.aio.app.auth.OperationError href "" "zzupy.aio.app.auth.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

MFAError ¤

MFAError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: LoginError


              flowchart TD
              zzupy.aio.app.auth.MFAError[MFAError]
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.LoginError --> zzupy.aio.app.auth.MFAError
                                zzupy.exception.ZZUError --> zzupy.exception.LoginError
                



              click zzupy.aio.app.auth.MFAError href "" "zzupy.aio.app.auth.MFAError"
              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

MFA 状态错误,当前会话可能需要 MFA 验证

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

PersonalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.auth.PersonalInfo[PersonalInfo]

              

              click zzupy.aio.app.auth.PersonalInfo href "" "zzupy.aio.app.auth.PersonalInfo"
            

个人信息模型

Attributes:

uid instance-attribute ¤

uid: str

学号

name instance-attribute ¤

name: str

用户姓名

student_type instance-attribute ¤

student_type: str

学生类型。比如‘本科生’

student_type_id instance-attribute ¤

student_type_id: str

学生类型 ID

college instance-attribute ¤

college: str

学院

college_id instance-attribute ¤

college_id: str

学院 ID

unread_email_count instance-attribute ¤

unread_email_count: int

邮箱未读邮件数

balance instance-attribute ¤

balance: float

一卡通余额

research_count instance-attribute ¤

research_count: int

科研信息数量

PersonalInfoCardModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.auth.PersonalInfoCardModel[PersonalInfoCardModel]

              

              click zzupy.aio.app.auth.PersonalInfoCardModel href "" "zzupy.aio.app.auth.PersonalInfoCardModel"
            

‘我的’页中个人信息卡片 API 响应根模型

Attributes:

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: List[InnerData]

卡片数据列表

PersonalInfoModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.auth.PersonalInfoModel[PersonalInfoModel]

              

              click zzupy.aio.app.auth.PersonalInfoModel href "" "zzupy.aio.app.auth.PersonalInfoModel"
            

个人信息 API 响应根模型

Classes:

Data ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.auth.PersonalInfoModel.Data[Data]

              

              click zzupy.aio.app.auth.PersonalInfoModel.Data href "" "zzupy.aio.app.auth.PersonalInfoModel.Data"
            

Classes:

Attributes:

username instance-attribute ¤

username: str

学号

Attributes ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.auth.PersonalInfoModel.Data.Attributes[Attributes]

              

              click zzupy.aio.app.auth.PersonalInfoModel.Data.Attributes href "" "zzupy.aio.app.auth.PersonalInfoModel.Data.Attributes"
            

Attributes:

organization_id instance-attribute ¤
organization_id: str

学院 ID

organization_name instance-attribute ¤
organization_name: str

学院名

identity_type_name instance-attribute ¤
identity_type_name: str

学生类型

identity_type_id instance-attribute ¤
identity_type_id: str

学生类型 ID

user_name instance-attribute ¤
user_name: str

学生姓名

user_uid instance-attribute ¤
user_uid: str

学号

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.aio.app.auth.CASClient[CASClient]
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

                              zzupy.aio.app.interfaces.ICASClient --> zzupy.aio.app.auth.CASClient
                


              click zzupy.aio.app.auth.CASClient href "" "zzupy.aio.app.auth.CASClient"
              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 异步辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/aio/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 异步辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.aio.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤

    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/aio/app/auth.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 异步辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

reset ¤

reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/aio/app/auth.py
232
233
234
235
236
237
238
239
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

is_required async ¤

is_required() -> bool

检测当前环境是否需要 MFA 验证。

Returns:

  • bool –

    是否需要 MFA 验证。

Raises:

Source code in zzupy/aio/app/auth.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
async def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证。

    Returns:
        是否需要 MFA 验证。

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    public_key = await self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = await self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc

request_sms_code async ¤

request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/aio/app/auth.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
async def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        await self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

send_sms async ¤

send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
478
479
480
async def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    return await self.request_sms_code()

verify_sms_code async ¤

verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/aio/app/auth.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
async def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.aio.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = await self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

verify_sms async ¤

verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/aio/app/auth.py
565
566
567
async def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.aio.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return await self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤

    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤

    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/aio/app/auth.py
76
77
78
79
80
81
82
83
84
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤

    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/aio/app/auth.py
86
87
88
89
90
91
92
93
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login async ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤

    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/aio/app/auth.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
async def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.aio.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.aio.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.aio.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.aio.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = await self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        await self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = await self._client.post(
            self.LOGIN_URL, params=params, headers=headers
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info async ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/aio/app/auth.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
@require_auth
async def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/auth.py
797
798
799
800
801
802
803
804
805
806
807
808
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_task is not None:
        self._refresh_task.cancel()
        self._refresh_task = None
    self._logged_in = False

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/auth.py
810
811
812
813
814
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

get_jwt_expiration ¤

get_jwt_expiration(token: str) -> datetime

读取 JWT 的过期时间。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

  • datetime –

    JWT exp 对应的本地时间。

Raises:

  • ValueError –

    如果 Token 无效或缺少有效的 exp。

Source code in zzupy/utils.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def get_jwt_expiration(token: str) -> datetime:
    """读取 JWT 的过期时间。

    Args:
        token: JWT 字符串。

    Returns:
        JWT `exp` 对应的本地时间。

    Raises:
        ValueError: 如果 Token 无效或缺少有效的 `exp`。
    """
    payload = decode_jwt_payload(token)
    try:
        return datetime.fromtimestamp(float(payload["exp"]))
    except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
        raise ValueError("JWT 缺少有效的 exp") from exc

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper
zzupy.aio.app.eas¤

eas ¤

Modules:

Classes:

Attributes:

__all__ module-attribute ¤

__all__ = ['UndergradEASClient']

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")
zzupy.aio.app.eas.undergrad¤

undergrad ¤

140w 教务

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.aio.app.eas.undergrad.ICASClient[ICASClient]

              

              click zzupy.aio.app.eas.undergrad.ICASClient href "" "zzupy.aio.app.eas.undergrad.ICASClient"
            
Source code in zzupy/aio/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

DataNotFoundError ¤

DataNotFoundError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: OperationError, LookupError


              flowchart TD
              zzupy.aio.app.eas.undergrad.DataNotFoundError[DataNotFoundError]
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.OperationError --> zzupy.aio.app.eas.undergrad.DataNotFoundError
                                zzupy.exception.ZZUError --> zzupy.exception.OperationError
                



              click zzupy.aio.app.eas.undergrad.DataNotFoundError href "" "zzupy.aio.app.eas.undergrad.DataNotFoundError"
              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

请求的数据不存在或当前上下文中无法找到。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.aio.app.eas.undergrad.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.eas.undergrad.InvalidArgumentError
                


              click zzupy.aio.app.eas.undergrad.InvalidArgumentError href "" "zzupy.aio.app.eas.undergrad.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.eas.undergrad.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.eas.undergrad.NetworkError
                


              click zzupy.aio.app.eas.undergrad.NetworkError href "" "zzupy.aio.app.eas.undergrad.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.aio.app.eas.undergrad.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.aio.app.eas.undergrad.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.aio.app.eas.undergrad.NotLoggedInError href "" "zzupy.aio.app.eas.undergrad.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.eas.undergrad.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.eas.undergrad.OperationError
                


              click zzupy.aio.app.eas.undergrad.OperationError href "" "zzupy.aio.app.eas.undergrad.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.eas.undergrad.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.eas.undergrad.ParsingError
                


              click zzupy.aio.app.eas.undergrad.ParsingError href "" "zzupy.aio.app.eas.undergrad.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

Lesson ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.Lesson[Lesson]

              

              click zzupy.aio.app.eas.undergrad.Lesson href "" "zzupy.aio.app.eas.undergrad.Lesson"
            

课表中的一节课

Attributes:

course instance-attribute ¤

course: Course

对应课程

schedule instance-attribute ¤

schedule: Schedule

对应时间

TeachingWeek ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.TeachingWeek[TeachingWeek]

              

              click zzupy.aio.app.eas.undergrad.TeachingWeek href "" "zzupy.aio.app.eas.undergrad.TeachingWeek"
            

教学周课表,7 天 × 10 节的网格

Methods:

  • set –

    设置某天某节的课程

  • get –

    获取某天某节的课程

  • get_day –

    获取某天的全部课程

  • get_unit –

    获取某节 7 天的课程

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

Attributes:

lessons class-attribute instance-attribute ¤

lessons: dict[tuple[int, int], Lesson] = Field(default_factory=dict)

内部存储:仅存储非空课程,key 为 (weekday, unit),value 为 Lesson

grid property ¤

grid: list[list[Lesson | None]]

网格形式的课表,仅在访问时动态生成

set ¤

set(weekday: int, unit: int, lesson: Lesson) -> None

设置某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

  • lesson ¤

    (Lesson) –

    课程

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
449
450
451
452
453
454
455
456
457
458
459
460
461
def set(self, weekday: int, unit: int, lesson: Lesson) -> None:
    """设置某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节
        lesson: 课程

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    self.lessons[(weekday, unit)] = lesson

get ¤

get(weekday: int, unit: int) -> Lesson | None

获取某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

Returns:

  • Lesson | None –

    Lesson | None: 对应课程或 None

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def get(self, weekday: int, unit: int) -> Lesson | None:
    """获取某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节

    Returns:
        Lesson | None: 对应课程或 None

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    return self.lessons.get((weekday, unit))

get_day ¤

get_day(weekday: int) -> list[Lesson | None]

获取某天的全部课程

Parameters:

  • weekday ¤

    (int) –

    第几天

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def get_day(self, weekday: int) -> list[Lesson | None]:
    """获取某天的全部课程

    Args:
        weekday: 第几天

    Returns:
        list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {weekday} 超出范围
    """
    self._validate_index(weekday, 1)
    return [self.lessons.get((weekday, unit)) for unit in range(1, self.UNITS + 1)]

get_unit ¤

get_unit(unit: int) -> list[Lesson | None]

获取某节 7 天的课程

Parameters:

  • unit ¤

    (int) –

    第几节课

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def get_unit(self, unit: int) -> list[Lesson | None]:
    """获取某节 7 天的课程

    Args:
        unit: 第几节课

    Returns:
        list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {unit} 超出范围
    """
    self._validate_index(1, unit)
    return [self.lessons.get((day, unit)) for day in range(1, self.DAYS + 1)]

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()

    for lesson in self.lessons.values():
        if not lesson:
            continue

        schedule = lesson.schedule
        course = lesson.course

        # 去重
        if schedule in processed_schedule_ids:
            continue
        processed_schedule_ids.add(schedule)

        event = Event()

        # 课程中文名
        event.add("summary", course.name_zh)

        # 起始时间
        start_time = (
            schedule.real_start_time.to_stdlib() or schedule.start_time.to_stdlib()
        )
        end_time = (
            schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
        )
        event.add("dtstart", start_time)
        event.add("dtend", end_time)

        # 事件生成时间
        event.add("dtstamp", Instant.now().to_stdlib())

        # 事件 UID
        event.add("uid", f"{uuid.uuid4()}@schedule")

        # 上课地点
        location = ""
        if schedule.room:
            location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
        elif schedule.custom_place:
            location = schedule.custom_place

        if location:
            event.add("location", location)

        # 描述
        description_lines = [
            f"授课教师: {schedule.teacher_name or '未知'}",
            f"课程代码: {course.code}",
            f"学分: {course.credits}",
            f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
            f"教学周: 第 {schedule.week_index} 周",
            f"类型: {schedule.lesson_type}",
        ]
        event.add("description", "\n".join(description_lines))

        cal.add_component(event)
    return cal

LessonModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.LessonModel[LessonModel]

              

              click zzupy.aio.app.eas.undergrad.LessonModel href "" "zzupy.aio.app.eas.undergrad.LessonModel"
            

课程表查询 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message class-attribute instance-attribute ¤

message: None | str = None

响应消息

data instance-attribute ¤

data: list[Datum]

教学班数据列表

SemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.SemesterModel[SemesterModel]

              

              click zzupy.aio.app.eas.undergrad.SemesterModel href "" "zzupy.aio.app.eas.undergrad.SemesterModel"
            

获取全部学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: list[Semester]

学期数据列表

Semester ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.Semester[Semester]

              

              click zzupy.aio.app.eas.undergrad.Semester href "" "zzupy.aio.app.eas.undergrad.Semester"
            

单个学期

WeekIndexModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.WeekIndexModel[WeekIndexModel]

              

              click zzupy.aio.app.eas.undergrad.WeekIndexModel href "" "zzupy.aio.app.eas.undergrad.WeekIndexModel"
            

获取某日期的教学周序数 API 响应根模型

Attributes:

  • code (int) –

    响应结果码

  • message (str | None) –

    响应消息

  • data (InnerData) –

    学期数据列表

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: InnerData

学期数据列表

CurrentSemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.CurrentSemesterModel[CurrentSemesterModel]

              

              click zzupy.aio.app.eas.undergrad.CurrentSemesterModel href "" "zzupy.aio.app.eas.undergrad.CurrentSemesterModel"
            

获取当前学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: Semester

学期数据列表

TeachingWeeks ¤

Bases: RootModel


              flowchart TD
              zzupy.aio.app.eas.undergrad.TeachingWeeks[TeachingWeeks]

              

              click zzupy.aio.app.eas.undergrad.TeachingWeeks href "" "zzupy.aio.app.eas.undergrad.TeachingWeeks"
            

Methods:

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()
    for teaching_week in self.root:
        for lesson in teaching_week.lessons.values():
            if not lesson:
                continue

            schedule = lesson.schedule
            course = lesson.course

            # 去重
            if schedule in processed_schedule_ids:
                continue
            processed_schedule_ids.add(schedule)

            event = Event()

            # 课程中文名
            event.add("summary", course.name_zh)

            # 起始时间
            start_time = (
                schedule.real_start_time.to_stdlib()
                or schedule.start_time.to_stdlib()
            )
            end_time = (
                schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
            )
            event.add("dtstart", start_time)
            event.add("dtend", end_time)

            # 事件生成时间
            event.add("dtstamp", Instant.now().to_stdlib())

            # 事件 UID
            event.add("uid", f"{uuid.uuid4()}@schedule")

            # 上课地点
            location = ""
            if schedule.room:
                location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
            elif schedule.custom_place:
                location = schedule.custom_place

            if location:
                event.add("location", location)

            # 描述
            description_lines = [
                f"授课教师: {schedule.teacher_name or '未知'}",
                f"课程代码: {course.code}",
                f"学分: {course.credits}",
                f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
                f"教学周: 第 {schedule.week_index} 周",
                f"类型: {schedule.lesson_type}",
            ]
            event.add("description", "\n".join(description_lines))

            cal.add_component(event)

    return cal

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login async ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = (await self._get_current_semester()).id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week async ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@require_auth
async def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = await self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks async ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@require_auth
async def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in await self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index async ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
@require_auth
async def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = await self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters async ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/aio/app/eas/undergrad.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
@require_auth
async def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = await self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/eas/undergrad.py
456
457
458
459
460
461
462
463
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/eas/undergrad.py
465
466
467
468
469
470
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.debug("EASClient 已关闭")

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper
zzupy.aio.app.ecard¤

ecard ¤

一卡通

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.aio.app.ecard.ICASClient[ICASClient]

              

              click zzupy.aio.app.ecard.ICASClient href "" "zzupy.aio.app.ecard.ICASClient"
            
Source code in zzupy/aio/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.aio.app.ecard.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.ecard.InvalidArgumentError
                


              click zzupy.aio.app.ecard.InvalidArgumentError href "" "zzupy.aio.app.ecard.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.ecard.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.ecard.NetworkError
                


              click zzupy.aio.app.ecard.NetworkError href "" "zzupy.aio.app.ecard.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.aio.app.ecard.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.aio.app.ecard.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.aio.app.ecard.NotLoggedInError href "" "zzupy.aio.app.ecard.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.ecard.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.ecard.OperationError
                


              click zzupy.aio.app.ecard.OperationError href "" "zzupy.aio.app.ecard.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.app.ecard.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.app.ecard.ParsingError
                


              click zzupy.aio.app.ecard.ParsingError href "" "zzupy.aio.app.ecard.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ECardAccountModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.app.ecard.ECardAccountModel[ECardAccountModel]

              

              click zzupy.aio.app.ecard.ECardAccountModel href "" "zzupy.aio.app.ecard.ECardAccountModel"
            

一卡通账户 API 响应根模型

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/aio/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.AsyncClient(
        event_hooks=build_http_event_hooks(async_client=True)
    )
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_task: asyncio.Task[None] | None = None

login async ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/aio/app/ecard.py
146
147
148
149
150
151
152
153
154
155
156
157
158
async def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    await self._get_tid()
    await self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room async ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/aio/app/ecard.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@require_auth
async def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = await self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy async ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤

    (str) –

    支付密码

  • amt ¤

    (int) –

    充值金额

  • room ¤

    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/aio/app/ecard.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@require_auth
async def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = await self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = await self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance async ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/aio/app/ecard.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@require_auth
async def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = await self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict async ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤

    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/aio/app/ecard.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@require_auth
async def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = await self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy async ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤

    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/aio/app/ecard.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
@require_auth
async def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.aio.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = await self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = await self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/aio/app/ecard.py
742
743
744
745
746
747
748
749
750
751
752
753
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    self._cancel_token_refresh()
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close async ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/aio/app/ecard.py
755
756
757
758
759
760
761
762
async def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    self._cancel_token_refresh()
    if self._logged_in:
        self.logout()
    await self._client.aclose()
    logger.info("校园卡客户端已关闭")

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_headers ¤

log_http_headers(message: str, headers: Headers, *, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 头日志。

Source code in zzupy/logging.py
146
147
148
149
150
151
152
153
def log_http_headers(
    message: str,
    headers: httpx2.Headers,
    *,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 头日志。"""
    logger.log(level.upper(), "{}: {}", message, sanitize_http_headers(headers))

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

sm4_decrypt_ecb ¤

sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str

SM4 解密,ECB模式

Parameters:

Returns:

  • str –

    明文 Hex

Source code in zzupy/utils.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str:
    """SM4 解密,ECB模式

    Args:
        ciphertext (bytes): 密文
        key (bytes): 密钥

    Returns:
        明文 Hex
    """
    sm4 = gmalg.SM4(key)
    block_size = 16
    decrypted_padded = b""
    for i in range(0, len(ciphertext), block_size):
        block = ciphertext[i : i + block_size]
        decrypted_padded += sm4.decrypt(block)
    decrypted = pkcs7_unpad(decrypted_padded, block_size)
    return decrypted.decode()

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper
zzupy.aio.app.interfaces¤

interfaces ¤

抽象接口

Classes:

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.aio.app.interfaces.ICASClient[ICASClient]

              

              click zzupy.aio.app.interfaces.ICASClient href "" "zzupy.aio.app.interfaces.ICASClient"
            
Source code in zzupy/aio/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

zzupy.aio.web¤

web ¤

Web API 客户端模块

Modules:

Classes:

Functions:

Attributes:

__all__ module-attribute ¤

__all__ = ['EPortalClient', 'SelfServiceSystem', 'discover_portal_info']

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )

portal_auth async ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth async ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • isp_suffix ¤

    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False

login async ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices async ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device async ¤

kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤

    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout async ¤

logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

network ¤

校园网模块

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )

portal_auth async ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth async ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False

login async ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices async ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device async ¤

kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout async ¤

logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc
zzupy.aio.web.network¤

network ¤

校园网模块

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.web.network.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.web.network.LoginError
                


              click zzupy.aio.web.network.LoginError href "" "zzupy.aio.web.network.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.web.network.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.web.network.NetworkError
                


              click zzupy.aio.web.network.NetworkError href "" "zzupy.aio.web.network.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.web.network.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.web.network.OperationError
                


              click zzupy.aio.web.network.OperationError href "" "zzupy.aio.web.network.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.aio.web.network.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.aio.web.network.ParsingError
                


              click zzupy.aio.web.network.ParsingError href "" "zzupy.aio.web.network.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ZZUError ¤

ZZUError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: Exception


              flowchart TD
              zzupy.aio.web.network.ZZUError[ZZUError]

              

              click zzupy.aio.web.network.ZZUError href "" "zzupy.aio.web.network.ZZUError"
            

项目异常基类。

除了人类可读的错误消息外,还会保留额外上下文,便于调用方记录日志、 序列化或调试。

Parameters:

  • message ¤

    (str | None, default: None ) –

    错误消息。

  • context ¤

    (Mapping[str, Any] | None, default: None ) –

    结构化上下文信息。

  • error_code ¤

    (str | None, default: None ) –

    可选错误代码。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

AuthResult ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.web.network.AuthResult[AuthResult]

              

              click zzupy.aio.web.network.AuthResult href "" "zzupy.aio.web.network.AuthResult"
            

Portal 认证结果

Attributes:

result instance-attribute ¤

result: int

认证结果

message class-attribute instance-attribute ¤

message: str = Field(..., alias='msg')

Portal 服务器返回信息

OnlineDevice ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.web.network.OnlineDevice[OnlineDevice]

              

              click zzupy.aio.web.network.OnlineDevice href "" "zzupy.aio.web.network.OnlineDevice"
            

在线设备信息

Methods:

Attributes:

brasid instance-attribute ¤

brasid: str

BRAS ID

down_flow instance-attribute ¤

down_flow: str

下行流量

host_name class-attribute instance-attribute ¤

host_name: str = ''

主机名

ip instance-attribute ¤

ip: str

IP地址

login_time instance-attribute ¤

login_time: str

登录时间,格式为YYYY-MM-DD HH:MM:SS

mac instance-attribute ¤

mac: str

MAC地址

session_id instance-attribute ¤

session_id: str

会话ID

terminal_type instance-attribute ¤

terminal_type: str

终端类型

up_flow instance-attribute ¤

up_flow: str

上行流量

use_time instance-attribute ¤

use_time: str

使用时间(秒)

user_id instance-attribute ¤

user_id: int

用户ID

dump_json ¤

dump_json(indent: Optional[int] = None) -> str

格式化为JSON字符串

Source code in zzupy/model/network.py
85
86
87
def dump_json(self, indent: Optional[int] = None) -> str:
    """格式化为JSON字符串"""
    return json.dumps(self.model_dump(), ensure_ascii=False, indent=indent)

PortalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.aio.web.network.PortalInfo[PortalInfo]

              

              click zzupy.aio.web.network.PortalInfo href "" "zzupy.aio.web.network.PortalInfo"
            

探测出的 Portal 认证信息

Attributes:

auth_url instance-attribute ¤

auth_url: str

认证网页 URL

portal_server_url instance-attribute ¤

portal_server_url: str

Portal 服务器 URL

user_ip instance-attribute ¤

user_ip: str

客户端 IP

JsonPParser ¤

JsonPParser(text: str)

JsonP 格式数据解析器

Source code in zzupy/utils.py
283
284
285
286
287
def __init__(self, text: str):
    self.text = text
    self._callback: str | None = None
    self._data: str | None = None
    self._parse()

XorCipher ¤

XorCipher(key_string: str = '')

一个使用异或 (XOR) 算法进行简单加密和解密的类。

Methods:

  • encrypt –

    将明文与实例密钥进行异或运算,并转为十六进制字符串。

  • decrypt –

    将十六进制字符串解密回原始密码。

Source code in zzupy/utils.py
223
224
def __init__(self, key_string: str = ""):
    self._key: int = self._generate_key(key_string)

encrypt ¤

encrypt(string: str) -> str

将明文与实例密钥进行异或运算,并转为十六进制字符串。

Parameters:

  • string ¤

    (str) –

    明文

Source code in zzupy/utils.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def encrypt(self, string: str) -> str:
    """将明文与实例密钥进行异或运算,并转为十六进制字符串。

    Args:
        string: 明文
    """
    if len(string) > 512:
        return "-1"

    encrypted_output = []
    for char in string:
        ch = ord(char) ^ self._key
        hex_str = format(ch, "02x")
        encrypted_output.append(hex_str)

    return "".join(encrypted_output)

decrypt ¤

decrypt(hex_string: str) -> str

将十六进制字符串解密回原始密码。

Parameters:

  • hex_string ¤

    (str) –

    十六进制字符串

Raises:

  • ValueError –

    如果十六进制字符串格式错误

Source code in zzupy/utils.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def decrypt(self, hex_string: str) -> str:
    """将十六进制字符串解密回原始密码。

    Args:
        hex_string: 十六进制字符串

    Raises:
        ValueError: 如果十六进制字符串格式错误
    """
    if len(hex_string) % 2 != 0:
        raise ValueError("十六进制字符串长度必须为偶数")

    original_password = []
    for i in range(0, len(hex_string), 2):
        hex_pair = hex_string[i : i + 2]
        decimal_value = int(hex_pair, 16)
        # 与实例密钥进行异或
        original_char = chr(decimal_value ^ self._key)
        original_password.append(original_char)

    return "".join(original_password)

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/aio/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    self._client = httpx2.AsyncClient()
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.AsyncHTTPTransport()

    else:
        transport = httpx2.AsyncHTTPTransport(local_address=self._bind_address)
    self._client = httpx2.AsyncClient(
        transport=transport,
    )

portal_auth async ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/aio/web/network.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.aio.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = await self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth async ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • isp_suffix ¤

    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/aio/web/network.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return await self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/aio/web/network.py
293
294
295
def __init__(self, base_url: str):
    self._client = httpx2.AsyncClient(base_url=base_url)
    self._logged_in = False

login async ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Raises:

Source code in zzupy/aio/web/network.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
async def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = await self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        await self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = await self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices async ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/aio/web/network.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@require_auth
async def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device async ¤

kick_device(session_id: str)

将设备踢下线

Parameters:

  • session_id ¤

    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
@require_auth
async def kick_device(self, session_id: str):
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = await self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout async ¤

logout()

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/aio/web/network.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
async def logout(self):
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        await self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

extract_first_html_attr ¤

extract_first_html_attr(html_content: str, tag: str, attr: str, match_attrs: dict[str, str] | None = None) -> str | None

提取第一个匹配 HTML 标签的属性值。

Source code in zzupy/utils.py
111
112
113
114
115
116
117
118
119
120
121
def extract_first_html_attr(
    html_content: str,
    tag: str,
    attr: str,
    match_attrs: dict[str, str] | None = None,
) -> str | None:
    """提取第一个匹配 HTML 标签的属性值。"""
    parser = _FirstHtmlAttributeParser(tag, attr, match_attrs)
    parser.feed(html_content)
    parser.close()
    return parser.value

get_local_ip ¤

get_local_ip(target: str = '8.8.8.8') -> str | None

获取用于连接到特定目标IP的本地IP地址。

Parameters:

  • target ¤

    (str, default: '8.8.8.8' ) –

    目标主机名或IP地址。默认为 '8.8.8.8'。

Returns:

  • str ( str | None ) –

    用于到达目标的本地IP地址

  • None ( str | None ) –

    如果发生网络错误

Source code in zzupy/utils.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_local_ip(target: str = "8.8.8.8") -> str | None:
    """
    获取用于连接到特定目标IP的本地IP地址。

    Args:
        target: 目标主机名或IP地址。默认为 '8.8.8.8'。

    Returns:
        str: 用于到达目标的本地IP地址
        None: 如果发生网络错误
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect((target, 80))
            local_ip = s.getsockname()[0]
            return local_ip
    except socket.error:
        return None

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

discover_portal_info async ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/aio/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    async def _get_portal_server_url(client: httpx2.AsyncClient, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = await client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        async with httpx2.AsyncClient(timeout=10.0) as client:
            response = await client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = await _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

zzupy.app¤

app ¤

移动应用 API 抽象层

Modules:

Classes:

Attributes:

__all__ module-attribute ¤

__all__ = ['CASClient', 'ECardClient', 'UndergradEASClient']

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.app.CASClient[CASClient]
              zzupy.app.interfaces.ICASClient[ICASClient]

                              zzupy.app.interfaces.ICASClient --> zzupy.app.CASClient
                


              click zzupy.app.CASClient href "" "zzupy.app.CASClient"
              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤

    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/app/auth.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

reset ¤

reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/app/auth.py
233
234
235
236
237
238
239
240
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

is_required ¤

is_required() -> bool

检测当前环境是否需要 MFA 验证

Returns:

  • bool –

    是否需要 MFA 验证

Raises:

Source code in zzupy/app/auth.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证

    Returns:
        是否需要 MFA 验证

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。

    """
    public_key = self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc

request_sms_code ¤

request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/app/auth.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

send_sms ¤

send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/app/auth.py
481
482
483
def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    self.request_sms_code()

verify_sms_code ¤

verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/app/auth.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

verify_sms ¤

verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/app/auth.py
568
569
570
def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤

    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤

    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/app/auth.py
74
75
76
77
78
79
80
81
82
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤

    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/app/auth.py
84
85
86
87
88
89
90
91
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤

    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/app/auth.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = self._client.post(self.LOGIN_URL, params=params, headers=headers)
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/app/auth.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
@require_auth
def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/auth.py
798
799
800
801
802
803
804
805
806
807
808
809
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._logged_in = False

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/auth.py
811
812
813
814
815
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None

login ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/app/ecard.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    self._get_tid()
    self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/app/ecard.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@require_auth
def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤

    (str) –

    支付密码

  • amt ¤

    (int) –

    充值金额

  • room ¤

    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/app/ecard.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/app/ecard.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@require_auth
def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤

    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/app/ecard.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
@require_auth
def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤

    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/app/ecard.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
@require_auth
def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/ecard.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/ecard.py
747
748
749
750
751
752
753
754
755
756
def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.info("校园卡客户端已关闭")

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

auth ¤

统一认证

Classes:

  • CASClient –

    统一认证系统 (CAS) App 客户端。

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.app.auth.CASClient[CASClient]
              zzupy.app.interfaces.ICASClient[ICASClient]

                              zzupy.app.interfaces.ICASClient --> zzupy.app.auth.CASClient
                


              click zzupy.app.auth.CASClient href "" "zzupy.app.auth.CASClient"
              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤
    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/app/auth.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
reset ¤
reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/app/auth.py
233
234
235
236
237
238
239
240
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False
is_required ¤
is_required() -> bool

检测当前环境是否需要 MFA 验证

Returns:

  • bool –

    是否需要 MFA 验证

Raises:

Source code in zzupy/app/auth.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证

    Returns:
        是否需要 MFA 验证

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。

    """
    public_key = self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
request_sms_code ¤
request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/app/auth.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
send_sms ¤
send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/app/auth.py
481
482
483
def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    self.request_sms_code()
verify_sms_code ¤
verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/app/auth.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc
verify_sms ¤
verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/app/auth.py
568
569
570
def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤
    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤
    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/app/auth.py
74
75
76
77
78
79
80
81
82
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤
    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/app/auth.py
84
85
86
87
88
89
90
91
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤
    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/app/auth.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = self._client.post(self.LOGIN_URL, params=params, headers=headers)
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/app/auth.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
@require_auth
def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/auth.py
798
799
800
801
802
803
804
805
806
807
808
809
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._logged_in = False

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/auth.py
811
812
813
814
815
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()

eas ¤

Modules:

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None
login ¤
login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")
get_teaching_week ¤
get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]
get_teaching_weeks ¤
get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)
get_week_index ¤
get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None
get_semesters ¤
get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data
logout ¤
logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")
close ¤
close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

ecard ¤

一卡通

Classes:

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None

login ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/app/ecard.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    self._get_tid()
    self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/app/ecard.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@require_auth
def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤
    (str) –

    支付密码

  • amt ¤
    (int) –

    充值金额

  • room ¤
    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/app/ecard.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/app/ecard.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@require_auth
def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤
    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/app/ecard.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
@require_auth
def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤
    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/app/ecard.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
@require_auth
def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/ecard.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/ecard.py
747
748
749
750
751
752
753
754
755
756
def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.info("校园卡客户端已关闭")

interfaces ¤

抽象接口

zzupy.app.auth¤

auth ¤

统一认证

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.app.auth.ICASClient[ICASClient]

              

              click zzupy.app.auth.ICASClient href "" "zzupy.app.auth.ICASClient"
            
Source code in zzupy/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

RSAPublicKey ¤

RSAPublicKey(modulus: int, exponent: int)

轻量级 RSA 公钥实现

Parameters:

  • modulus ¤

    (int) –

    RSA 模数 (n)

  • exponent ¤

    (int) –

    RSA 公开指数 (e),通常是 65537

Methods:

  • encrypt –

    使用 PKCS#1 v1.5 填充进行 RSA 加密

  • verify_rs512 –

    验证 RS512 (RSA-SHA512) 签名

Source code in zzupy/crypto.py
12
13
14
15
16
17
18
19
20
21
22
def __init__(self, modulus: int, exponent: int):
    """初始化 RSA 公钥

    Args:
        modulus: RSA 模数 (n)
        exponent: RSA 公开指数 (e),通常是 65537
    """
    self.modulus = modulus
    self.exponent = exponent
    self.key_size_bits = modulus.bit_length()
    self.key_size_bytes = (self.key_size_bits + 7) // 8

encrypt ¤

encrypt(data: bytes, padding_scheme=None) -> bytes

使用 PKCS#1 v1.5 填充进行 RSA 加密

Parameters:

  • data ¤

    (bytes) –

    要加密的数据

  • padding_scheme ¤

    –

    填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

Returns:

  • bytes –

    加密后的字节数据

Source code in zzupy/crypto.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def encrypt(self, data: bytes, padding_scheme=None) -> bytes:
    """使用 PKCS#1 v1.5 填充进行 RSA 加密

    Args:
        data: 要加密的数据
        padding_scheme: 填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

    Returns:
        加密后的字节数据
    """
    # PKCS#1 v1.5 填充
    # 格式: 0x00 || 0x02 || PS || 0x00 || M
    # PS 是随机非零字节,长度至少为 8

    max_message_length = self.key_size_bytes - 11  # 预留给填充的空间

    if len(data) > max_message_length:
        raise ValueError(f"消息太长,最大长度为 {max_message_length} 字节")

    # 构建填充
    padding_length = self.key_size_bytes - len(data) - 3

    # 生成随机非零填充字节
    padding = bytearray()
    while len(padding) < padding_length:
        random_bytes = os.urandom(padding_length - len(padding))
        # 移除零字节
        for byte in random_bytes:
            if byte != 0:
                padding.append(byte)
            if len(padding) >= padding_length:
                break

    # 构建完整的填充消息
    padded_message = bytearray([0x00, 0x02])
    padded_message.extend(padding)
    padded_message.append(0x00)
    padded_message.extend(data)

    # 转换为整数并进行 RSA 加密
    m = int.from_bytes(padded_message, byteorder="big")

    # RSA 加密: c = m^e mod n
    c = pow(m, self.exponent, self.modulus)

    # 转换回字节,确保长度正确
    encrypted = c.to_bytes(self.key_size_bytes, byteorder="big")

    return encrypted

verify_rs512 ¤

verify_rs512(message: bytes, signature: bytes) -> bool

验证 RS512 (RSA-SHA512) 签名

Parameters:

  • message ¤

    (bytes) –

    原始消息

  • signature ¤

    (bytes) –

    RSA 签名

Returns:

  • bool –

    签名是否有效

Source code in zzupy/crypto.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def verify_rs512(self, message: bytes, signature: bytes) -> bool:
    """验证 RS512 (RSA-SHA512) 签名

    Args:
        message: 原始消息
        signature: RSA 签名

    Returns:
        签名是否有效
    """
    # 计算消息的 SHA-512 哈希
    message_hash = hashlib.sha512(message).digest()

    # RSA 验证: m = s^e mod n
    s = int.from_bytes(signature, byteorder="big")
    m = pow(s, self.exponent, self.modulus)
    decrypted = m.to_bytes(self.key_size_bytes, byteorder="big")

    # 验证 PKCS#1 v1.5 填充格式
    # 格式: 0x00 || 0x01 || PS || 0x00 || DigestInfo
    if decrypted[0] != 0x00 or decrypted[1] != 0x01:
        return False

    # 查找 0x00 分隔符
    separator_index = decrypted.find(b"\x00", 2)
    if separator_index == -1:
        return False

    # DigestInfo 结构 (SHA-512)
    # SHA-512 的 DigestInfo 前缀
    sha512_digest_info = bytes.fromhex(
        "3051"  # SEQUENCE
        "300d"  # AlgorithmIdentifier SEQUENCE
        "0609"  # OID (9 bytes)
        "608648016503040203"  # SHA-512 OID
        "0500"  # NULL
        "0440"  # OCTET STRING (64 bytes)
    )

    digest_info_start = separator_index + 1
    digest_info = decrypted[digest_info_start:]

    # 验证 DigestInfo 结构
    expected_digest_info = sha512_digest_info + message_hash

    return digest_info == expected_digest_info

padding ¤

填充方案命名空间(兼容 cryptography)

Classes:

PKCS1v15 ¤

PKCS#1 v1.5 填充方案

serialization ¤

序列化命名空间(兼容 cryptography)

Methods:

load_pem_public_key staticmethod ¤

load_pem_public_key(data: bytes) -> RSAPublicKey

加载 PEM 格式公钥(兼容 cryptography 接口)

Source code in zzupy/crypto.py
249
250
251
252
@staticmethod
def load_pem_public_key(data: bytes) -> RSAPublicKey:
    """加载 PEM 格式公钥(兼容 cryptography 接口)"""
    return load_pem_public_key(data)

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.auth.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.auth.LoginError
                


              click zzupy.app.auth.LoginError href "" "zzupy.app.auth.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.auth.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.auth.ParsingError
                


              click zzupy.app.auth.ParsingError href "" "zzupy.app.auth.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.auth.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.auth.NetworkError
                


              click zzupy.app.auth.NetworkError href "" "zzupy.app.auth.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.app.auth.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.app.auth.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.app.auth.NotLoggedInError href "" "zzupy.app.auth.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.auth.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.auth.OperationError
                


              click zzupy.app.auth.OperationError href "" "zzupy.app.auth.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

MFAError ¤

MFAError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: LoginError


              flowchart TD
              zzupy.app.auth.MFAError[MFAError]
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.LoginError --> zzupy.app.auth.MFAError
                                zzupy.exception.ZZUError --> zzupy.exception.LoginError
                



              click zzupy.app.auth.MFAError href "" "zzupy.app.auth.MFAError"
              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

MFA 状态错误,当前会话可能需要 MFA 验证

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

PersonalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.auth.PersonalInfo[PersonalInfo]

              

              click zzupy.app.auth.PersonalInfo href "" "zzupy.app.auth.PersonalInfo"
            

个人信息模型

Attributes:

uid instance-attribute ¤

uid: str

学号

name instance-attribute ¤

name: str

用户姓名

student_type instance-attribute ¤

student_type: str

学生类型。比如‘本科生’

student_type_id instance-attribute ¤

student_type_id: str

学生类型 ID

college instance-attribute ¤

college: str

学院

college_id instance-attribute ¤

college_id: str

学院 ID

unread_email_count instance-attribute ¤

unread_email_count: int

邮箱未读邮件数

balance instance-attribute ¤

balance: float

一卡通余额

research_count instance-attribute ¤

research_count: int

科研信息数量

PersonalInfoModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.auth.PersonalInfoModel[PersonalInfoModel]

              

              click zzupy.app.auth.PersonalInfoModel href "" "zzupy.app.auth.PersonalInfoModel"
            

个人信息 API 响应根模型

Classes:

Data ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.auth.PersonalInfoModel.Data[Data]

              

              click zzupy.app.auth.PersonalInfoModel.Data href "" "zzupy.app.auth.PersonalInfoModel.Data"
            

Classes:

Attributes:

username instance-attribute ¤

username: str

学号

Attributes ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.auth.PersonalInfoModel.Data.Attributes[Attributes]

              

              click zzupy.app.auth.PersonalInfoModel.Data.Attributes href "" "zzupy.app.auth.PersonalInfoModel.Data.Attributes"
            

Attributes:

organization_id instance-attribute ¤
organization_id: str

学院 ID

organization_name instance-attribute ¤
organization_name: str

学院名

identity_type_name instance-attribute ¤
identity_type_name: str

学生类型

identity_type_id instance-attribute ¤
identity_type_id: str

学生类型 ID

user_name instance-attribute ¤
user_name: str

学生姓名

user_uid instance-attribute ¤
user_uid: str

学号

PersonalInfoCardModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.auth.PersonalInfoCardModel[PersonalInfoCardModel]

              

              click zzupy.app.auth.PersonalInfoCardModel href "" "zzupy.app.auth.PersonalInfoCardModel"
            

‘我的’页中个人信息卡片 API 响应根模型

Attributes:

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: List[InnerData]

卡片数据列表

CASClient ¤

CASClient(account: str, password: str)

Bases: ICASClient


              flowchart TD
              zzupy.app.auth.CASClient[CASClient]
              zzupy.app.interfaces.ICASClient[ICASClient]

                              zzupy.app.interfaces.ICASClient --> zzupy.app.auth.CASClient
                


              click zzupy.app.auth.CASClient href "" "zzupy.app.auth.CASClient"
              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            

统一认证系统 (CAS) App 客户端。

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Classes:

  • MFAClient –

    统一认证 MFA 辅助客户端。

Methods:

  • set_token –

    设置统一认证 Token。

  • set_device –

    设置认证请求使用的设备标识。

  • login –

    登录统一认证。

  • get_user_info –

    获取当前用户的聚合个人信息。

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Attributes:

  • user_token (str | None) –

    当前会话的 userToken,约一个月有效期

  • refresh_token (str | None) –

    当前会话的 refreshToken,约两个月有效期

  • logged_in (bool) –

    当前会话是否已登录

Source code in zzupy/app/auth.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    account: str,
    password: str,
) -> None:
    """初始化认证服务。

    Args:
        account: 账号
        password: 密码
    """
    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._account = account
    self._password = password
    self._public_key: RSAPublicKey | None = None
    self._user_token: str | None = None
    self._refresh_token: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None
    self._device_id = "ZZU.Py"
    self.mfa = self.MFAClient(self)

user_token property ¤

user_token: str | None

当前会话的 userToken,约一个月有效期

refresh_token property ¤

refresh_token: str | None

当前会话的 refreshToken,约两个月有效期

logged_in property ¤

logged_in: bool

当前会话是否已登录

MFAClient ¤

MFAClient(cas: CASClient)

统一认证 MFA 辅助客户端。

本客户端由 CASClient 自动创建,通常通过 [CASClient.mfa][zzupy.app.auth.CASClient.mfa] 访问。它负责检测 MFA 状态、发送手机号验证码并校验验证码。

Parameters:

  • cas ¤

    (CASClient) –

    所属的统一认证客户端。

Methods:

Source code in zzupy/app/auth.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def __init__(self, cas: "CASClient") -> None:
    """初始化 MFA 辅助客户端。

    Args:
        cas: 所属的统一认证客户端。
    """
    self._cas = cas
    self._client = self._cas._client
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

reset ¤

reset() -> None

清除当前 MFA 流程状态。

Source code in zzupy/app/auth.py
233
234
235
236
237
238
239
240
def reset(self) -> None:
    """清除当前 MFA 流程状态。"""
    self.state = ""
    self.gid = ""
    self.attest_server_url = ""
    self.required = False
    self.secure_phone_available = False
    self.verified = False

is_required ¤

is_required() -> bool

检测当前环境是否需要 MFA 验证

Returns:

  • bool –

    是否需要 MFA 验证

Raises:

Source code in zzupy/app/auth.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def is_required(self) -> bool:
    """检测当前环境是否需要 MFA 验证

    Returns:
        是否需要 MFA 验证

    Raises:
        OperationError: 如果检测失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。

    """
    public_key = self._ensure_public_key()
    encrypted_account = self._cas._encrypt_and_encode(
        self._cas._account, public_key
    )
    encrypted_password = self._cas._encrypt_and_encode(
        self._cas._password, public_key
    )

    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "deviceId": self._cas._device_id,
    }

    try:
        logger.debug("正在向 {} 发送 MFA 检测请求...", self._cas.MFA_DETECT_URL)
        response = self._client.post(
            self._cas.MFA_DETECT_URL,
            params=params,
            headers=self._app_headers(),
        )
        response.raise_for_status()

        log_http_response_body(
            self._cas.MFA_DETECT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 检测请求失败: {}", error_message)
            raise LoginError(f"MFA 检测失败: {error_message}")

        mfa_data = data["data"]
        self.state = mfa_data["state"]
        self.gid = ""
        self.attest_server_url = ""
        self.required = bool(mfa_data["need"])
        self.secure_phone_available = bool(
            mfa_data.get("mfaTypeSecurePhone", False)
        )
        self.verified = False
        logger.info("MFA 检测成功")
        return self.required

    except httpx2.HTTPStatusError as exc:
        logger.error("MFA 检测请求返回失败状态码: {}", exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /mfa/detect 响应中提取 MFA 状态失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 检测网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self._cas.MFA_DETECT_URL},
        ) from exc

request_sms_code ¤

request_sms_code() -> None

发送 MFA 短信验证码。

如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

Raises:

Source code in zzupy/app/auth.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def request_sms_code(self) -> None:
    """发送 MFA 短信验证码。

    如果尚未初始化手机号 MFA,会自动调用内部初始化流程。

    Raises:
        LoginError: 如果当前登录不需要 MFA 验证。
        OperationError: 如果短信发送失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        self._init_secure_phone()

    url = self._attest_url("api/guard/securephone/send")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码发送失败: {}", error_message)
            raise OperationError(f"MFA 短信验证码发送失败: {error_message}")

        data["data"]["result"]
        logger.info("MFA 短信验证码发送成功")
        return None

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码发送返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/send 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码发送网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

send_sms ¤

send_sms() -> None

request_sms_code() 的别名。

Source code in zzupy/app/auth.py
481
482
483
def send_sms(self) -> None:
    """[`request_sms_code()`][zzupy.app.auth.CASClient.MFAClient.request_sms_code] 的别名。"""
    self.request_sms_code()

verify_sms_code ¤

verify_sms_code(code: str) -> str

校验 MFA 短信验证码。

调用前必须先发送 MFA 短信验证码。 校验成功后,CASClient.login() 会使用当前 MFA state 完成登录。

Parameters:

  • code ¤
    (str) –

    短信验证码。

Returns:

  • str –

    可用于登录的 MFA state。

Raises:

Source code in zzupy/app/auth.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
def verify_sms_code(self, code: str) -> str:
    """校验 MFA 短信验证码。

    调用前必须先发送 MFA 短信验证码。
    校验成功后,[`CASClient.login()`][zzupy.app.auth.CASClient.login]
    会使用当前 MFA state 完成登录。

    Args:
        code: 短信验证码。

    Returns:
        可用于登录的 MFA state。

    Raises:
        MFAError: 如果尚未发送 MFA 短信验证码。
        LoginError: 如果验证码校验失败。
        OperationError: 如果服务器返回失败状态。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not self.gid:
        raise MFAError("MFA 状态错误,请先发送短信验证码。")

    url = self._attest_url("api/guard/securephone/valid")
    try:
        logger.debug("正在向 {} 发送 MFA 短信验证码校验请求...", url)
        response = self._client.post(
            url,
            json={"gid": self.gid, "code": code},
            headers=self._app_headers(),
        )
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data: dict = response.json()
        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("MFA 短信验证码校验失败: {}", error_message)
            raise LoginError(f"MFA 短信验证码校验失败: {error_message}")

        mfa_data = data["data"]
        if mfa_data.get("status") != 2:
            logger.error(
                "MFA 短信验证码校验失败,状态码: {}", mfa_data.get("status")
            )
            raise LoginError("MFA 短信验证码校验失败。")

        mfa_data["result"]
        self.verified = True
        logger.info("MFA 短信验证码校验成功")
        return self.state

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "MFA 短信验证码校验返回失败状态码: {}", exc.response.status_code
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error(
            "从 /api/guard/securephone/valid 响应中提取数据失败: {}", exc
        )
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("MFA 短信验证码校验网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

verify_sms ¤

verify_sms(code: str) -> str

verify_sms_code() 的别名。

Source code in zzupy/app/auth.py
568
569
570
def verify_sms(self, code: str) -> str:
    """[`verify_sms_code()`][zzupy.app.auth.CASClient.MFAClient.verify_sms_code] 的别名。"""
    return self.verify_sms_code(code)

set_token ¤

set_token(user_token: str, refresh_token: str) -> None

设置统一认证 Token。

Parameters:

  • user_token ¤

    (str) –

    userToken。对豫见郑大 APP 抓包获取,或账密登录后访问 user_token 获取

  • refresh_token ¤

    (str) –

    refreshToken。对豫见郑大 APP 抓包获取,或账密登录后访问 refresh_token 获取

Source code in zzupy/app/auth.py
74
75
76
77
78
79
80
81
82
def set_token(self, user_token: str, refresh_token: str) -> None:
    """设置统一认证 Token。

    Args:
        user_token: `userToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`user_token`][zzupy.app.auth.CASClient.user_token] 获取
        refresh_token: `refreshToken`。对豫见郑大 APP 抓包获取,或账密登录后访问 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 获取
    """
    self._user_token = user_token
    self._refresh_token = refresh_token

set_device ¤

set_device(device_id: str) -> None

设置认证请求使用的设备标识。

Parameters:

  • device_id ¤

    (str) –

    登录和 MFA 检测请求中的 deviceId。

Source code in zzupy/app/auth.py
84
85
86
87
88
89
90
91
def set_device(self, device_id: str) -> None:
    """设置认证请求使用的设备标识。

    Args:
        device_id: 登录和 MFA 检测请求中的 `deviceId`。
    """
    self._device_id = device_id
    self.mfa.reset()

login ¤

login(force_login: bool = False) -> None

登录统一认证。

成功后,userToken 和 refreshToken 会被存储在实例中.

若 user_token 和 refresh_token 已通过 set_token 设置且有效,则会跳过账密登录。

Parameters:

  • force_login ¤

    (bool, default: False ) –

    强制使用账密登录

Raises:

Source code in zzupy/app/auth.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def login(self, force_login: bool = False) -> None:
    """登录统一认证。

    成功后,[`userToken`][zzupy.app.auth.CASClient.user_token] 和 [`refreshToken`][zzupy.app.auth.CASClient.refresh_token] 会被存储在实例中.

    若 [`user_token`][zzupy.app.auth.CASClient.user_token] 和 [`refresh_token`][zzupy.app.auth.CASClient.refresh_token] 已通过 [`set_token`][zzupy.app.auth.CASClient.set_token] 设置且有效,则会跳过账密登录。

    Args:
        force_login: 强制使用账密登录

    Raises:
        MFAError: 如果当前登录需要 MFA 但尚未完成验证。
        LoginError: 如果登录失败。
        ParsingError: 如果服务器响应无法解析。
        NetworkError: 如果出现网络错误。
    """
    if not force_login:
        if self._user_token is None or self._refresh_token is None:
            logger.debug("userToken 或 refreshToken 不存在,使用账密登录")
        elif self._validate_jwt(True):
            logger.debug("userToken 和 refreshToken 已设置且有效,跳过账密登录")
            self._logged_in = True
            return
    else:
        logger.info("强制使用账密登录")

    if self._public_key is None:
        self._public_key = self._get_public_key()

    assert self._public_key is not None

    if not self.mfa.state:
        self.mfa.is_required()
    if self.mfa.required and not self.mfa.verified:
        raise MFAError("当前登录需要完成 MFA 验证")

    encrypted_account = self._encrypt_and_encode(self._account, self._public_key)
    encrypted_password = self._encrypt_and_encode(self._password, self._public_key)

    headers = {"User-Agent": f"{self.APP_VERSION}()"}
    params = {
        "username": encrypted_account,
        "password": encrypted_password,
        "appId": self.APP_ID,
        "osType": self.OS_TYPE,
        "geo": "",
        "deviceId": self._device_id,
        "clientId": "",
        "mfaState": self.mfa.state,
    }

    try:
        logger.debug("正在向 {} 发送登录请求...", self.LOGIN_URL)
        response = self._client.post(self.LOGIN_URL, params=params, headers=headers)
        response.raise_for_status()

        log_http_response_body(
            self.LOGIN_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        data = response.json()

        if data.get("code") != 0:
            error_message = data.get("message", "未知错误")
            logger.error("登录请求失败: {}", error_message)
            raise LoginError(f"登录失败: {error_message}")

        token_data = data["data"]
        self._user_token = token_data["idToken"]
        self._refresh_token = token_data["refreshToken"]
        self._validate_jwt()
        self._logged_in = True

        logger.info("统一认证登录成功")

    except httpx2.HTTPStatusError as exc:
        logger.error("登录请求返回失败状态码: {}", exc.response.status_code)
        raise LoginError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOGIN_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /passwordLogin 响应中提取 token 失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOGIN_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("登录网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOGIN_URL},
        ) from exc

get_user_info ¤

get_user_info() -> PersonalInfo

获取当前用户的聚合个人信息。

返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

Returns:

Raises:

Source code in zzupy/app/auth.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
@require_auth
def get_user_info(self) -> PersonalInfo:
    """获取当前用户的聚合个人信息。

    返回学号、姓名、身份类型、学院、邮箱未读数、一卡通余额和科研信息数量。

    Returns:
        当前用户的个人信息

    Raises:
        NotLoggedInError: 如果当前认证状态缺少 userToken。
        OperationError: 如果服务端返回失败结果。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}
    url = f"{self.PERSONAL_INFO_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_data = PersonalInfoModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    url = f"{self.PERSONAL_INFO_CARD_URL}"
    try:
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["message"])
        raise OperationError(f"服务器返回消息 {response_data['message']}")

    try:
        personal_info_card_data = PersonalInfoCardModel.model_validate(
            response_data
        )
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return PersonalInfo(
        uid=personal_info_data.data.attributes.user_uid,
        name=personal_info_data.data.attributes.user_name,
        student_type=personal_info_data.data.attributes.identity_type_name,
        student_type_id=personal_info_data.data.attributes.identity_type_id,
        college=personal_info_data.data.attributes.organization_name,
        college_id=personal_info_data.data.attributes.organization_id,
        unread_email_count=int(personal_info_card_data.data[0].amount),
        balance=float(personal_info_card_data.data[1].amount),
        research_count=int(personal_info_card_data.data[2].amount),
    )

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/auth.py
798
799
800
801
802
803
804
805
806
807
808
809
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    self._client.cookies.clear()
    self._client.headers.clear()
    self._user_token = None
    self._refresh_token = None
    self.mfa.reset()
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._logged_in = False

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/auth.py
811
812
813
814
815
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

get_jwt_expiration ¤

get_jwt_expiration(token: str) -> datetime

读取 JWT 的过期时间。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

  • datetime –

    JWT exp 对应的本地时间。

Raises:

  • ValueError –

    如果 Token 无效或缺少有效的 exp。

Source code in zzupy/utils.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def get_jwt_expiration(token: str) -> datetime:
    """读取 JWT 的过期时间。

    Args:
        token: JWT 字符串。

    Returns:
        JWT `exp` 对应的本地时间。

    Raises:
        ValueError: 如果 Token 无效或缺少有效的 `exp`。
    """
    payload = decode_jwt_payload(token)
    try:
        return datetime.fromtimestamp(float(payload["exp"]))
    except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
        raise ValueError("JWT 缺少有效的 exp") from exc

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

zzupy.app.eas¤

eas ¤

Modules:

Classes:

Attributes:

__all__ module-attribute ¤

__all__ = ['UndergradEASClient']

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

undergrad ¤

140w 教务

Classes:

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")
zzupy.app.eas.undergrad¤

undergrad ¤

140w 教务

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.app.eas.undergrad.ICASClient[ICASClient]

              

              click zzupy.app.eas.undergrad.ICASClient href "" "zzupy.app.eas.undergrad.ICASClient"
            
Source code in zzupy/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

DataNotFoundError ¤

DataNotFoundError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: OperationError, LookupError


              flowchart TD
              zzupy.app.eas.undergrad.DataNotFoundError[DataNotFoundError]
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.OperationError --> zzupy.app.eas.undergrad.DataNotFoundError
                                zzupy.exception.ZZUError --> zzupy.exception.OperationError
                



              click zzupy.app.eas.undergrad.DataNotFoundError href "" "zzupy.app.eas.undergrad.DataNotFoundError"
              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

请求的数据不存在或当前上下文中无法找到。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.app.eas.undergrad.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.eas.undergrad.InvalidArgumentError
                


              click zzupy.app.eas.undergrad.InvalidArgumentError href "" "zzupy.app.eas.undergrad.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.eas.undergrad.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.eas.undergrad.NetworkError
                


              click zzupy.app.eas.undergrad.NetworkError href "" "zzupy.app.eas.undergrad.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.app.eas.undergrad.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.app.eas.undergrad.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.app.eas.undergrad.NotLoggedInError href "" "zzupy.app.eas.undergrad.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.eas.undergrad.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.eas.undergrad.OperationError
                


              click zzupy.app.eas.undergrad.OperationError href "" "zzupy.app.eas.undergrad.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.eas.undergrad.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.eas.undergrad.ParsingError
                


              click zzupy.app.eas.undergrad.ParsingError href "" "zzupy.app.eas.undergrad.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

Lesson ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.Lesson[Lesson]

              

              click zzupy.app.eas.undergrad.Lesson href "" "zzupy.app.eas.undergrad.Lesson"
            

课表中的一节课

Attributes:

course instance-attribute ¤

course: Course

对应课程

schedule instance-attribute ¤

schedule: Schedule

对应时间

TeachingWeek ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.TeachingWeek[TeachingWeek]

              

              click zzupy.app.eas.undergrad.TeachingWeek href "" "zzupy.app.eas.undergrad.TeachingWeek"
            

教学周课表,7 天 × 10 节的网格

Methods:

  • set –

    设置某天某节的课程

  • get –

    获取某天某节的课程

  • get_day –

    获取某天的全部课程

  • get_unit –

    获取某节 7 天的课程

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

Attributes:

lessons class-attribute instance-attribute ¤

lessons: dict[tuple[int, int], Lesson] = Field(default_factory=dict)

内部存储:仅存储非空课程,key 为 (weekday, unit),value 为 Lesson

grid property ¤

grid: list[list[Lesson | None]]

网格形式的课表,仅在访问时动态生成

set ¤

set(weekday: int, unit: int, lesson: Lesson) -> None

设置某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

  • lesson ¤

    (Lesson) –

    课程

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
449
450
451
452
453
454
455
456
457
458
459
460
461
def set(self, weekday: int, unit: int, lesson: Lesson) -> None:
    """设置某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节
        lesson: 课程

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    self.lessons[(weekday, unit)] = lesson

get ¤

get(weekday: int, unit: int) -> Lesson | None

获取某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

Returns:

  • Lesson | None –

    Lesson | None: 对应课程或 None

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def get(self, weekday: int, unit: int) -> Lesson | None:
    """获取某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节

    Returns:
        Lesson | None: 对应课程或 None

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    return self.lessons.get((weekday, unit))

get_day ¤

get_day(weekday: int) -> list[Lesson | None]

获取某天的全部课程

Parameters:

  • weekday ¤

    (int) –

    第几天

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def get_day(self, weekday: int) -> list[Lesson | None]:
    """获取某天的全部课程

    Args:
        weekday: 第几天

    Returns:
        list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {weekday} 超出范围
    """
    self._validate_index(weekday, 1)
    return [self.lessons.get((weekday, unit)) for unit in range(1, self.UNITS + 1)]

get_unit ¤

get_unit(unit: int) -> list[Lesson | None]

获取某节 7 天的课程

Parameters:

  • unit ¤

    (int) –

    第几节课

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def get_unit(self, unit: int) -> list[Lesson | None]:
    """获取某节 7 天的课程

    Args:
        unit: 第几节课

    Returns:
        list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {unit} 超出范围
    """
    self._validate_index(1, unit)
    return [self.lessons.get((day, unit)) for day in range(1, self.DAYS + 1)]

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()

    for lesson in self.lessons.values():
        if not lesson:
            continue

        schedule = lesson.schedule
        course = lesson.course

        # 去重
        if schedule in processed_schedule_ids:
            continue
        processed_schedule_ids.add(schedule)

        event = Event()

        # 课程中文名
        event.add("summary", course.name_zh)

        # 起始时间
        start_time = (
            schedule.real_start_time.to_stdlib() or schedule.start_time.to_stdlib()
        )
        end_time = (
            schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
        )
        event.add("dtstart", start_time)
        event.add("dtend", end_time)

        # 事件生成时间
        event.add("dtstamp", Instant.now().to_stdlib())

        # 事件 UID
        event.add("uid", f"{uuid.uuid4()}@schedule")

        # 上课地点
        location = ""
        if schedule.room:
            location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
        elif schedule.custom_place:
            location = schedule.custom_place

        if location:
            event.add("location", location)

        # 描述
        description_lines = [
            f"授课教师: {schedule.teacher_name or '未知'}",
            f"课程代码: {course.code}",
            f"学分: {course.credits}",
            f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
            f"教学周: 第 {schedule.week_index} 周",
            f"类型: {schedule.lesson_type}",
        ]
        event.add("description", "\n".join(description_lines))

        cal.add_component(event)
    return cal

LessonModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.LessonModel[LessonModel]

              

              click zzupy.app.eas.undergrad.LessonModel href "" "zzupy.app.eas.undergrad.LessonModel"
            

课程表查询 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message class-attribute instance-attribute ¤

message: None | str = None

响应消息

data instance-attribute ¤

data: list[Datum]

教学班数据列表

SemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.SemesterModel[SemesterModel]

              

              click zzupy.app.eas.undergrad.SemesterModel href "" "zzupy.app.eas.undergrad.SemesterModel"
            

获取全部学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: list[Semester]

学期数据列表

Semester ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.Semester[Semester]

              

              click zzupy.app.eas.undergrad.Semester href "" "zzupy.app.eas.undergrad.Semester"
            

单个学期

WeekIndexModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.WeekIndexModel[WeekIndexModel]

              

              click zzupy.app.eas.undergrad.WeekIndexModel href "" "zzupy.app.eas.undergrad.WeekIndexModel"
            

获取某日期的教学周序数 API 响应根模型

Attributes:

  • code (int) –

    响应结果码

  • message (str | None) –

    响应消息

  • data (InnerData) –

    学期数据列表

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: InnerData

学期数据列表

CurrentSemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.eas.undergrad.CurrentSemesterModel[CurrentSemesterModel]

              

              click zzupy.app.eas.undergrad.CurrentSemesterModel href "" "zzupy.app.eas.undergrad.CurrentSemesterModel"
            

获取当前学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: Semester

学期数据列表

TeachingWeeks ¤

Bases: RootModel


              flowchart TD
              zzupy.app.eas.undergrad.TeachingWeeks[TeachingWeeks]

              

              click zzupy.app.eas.undergrad.TeachingWeeks href "" "zzupy.app.eas.undergrad.TeachingWeeks"
            

Methods:

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()
    for teaching_week in self.root:
        for lesson in teaching_week.lessons.values():
            if not lesson:
                continue

            schedule = lesson.schedule
            course = lesson.course

            # 去重
            if schedule in processed_schedule_ids:
                continue
            processed_schedule_ids.add(schedule)

            event = Event()

            # 课程中文名
            event.add("summary", course.name_zh)

            # 起始时间
            start_time = (
                schedule.real_start_time.to_stdlib()
                or schedule.start_time.to_stdlib()
            )
            end_time = (
                schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
            )
            event.add("dtstart", start_time)
            event.add("dtend", end_time)

            # 事件生成时间
            event.add("dtstamp", Instant.now().to_stdlib())

            # 事件 UID
            event.add("uid", f"{uuid.uuid4()}@schedule")

            # 上课地点
            location = ""
            if schedule.room:
                location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
            elif schedule.custom_place:
                location = schedule.custom_place

            if location:
                event.add("location", location)

            # 描述
            description_lines = [
                f"授课教师: {schedule.teacher_name or '未知'}",
                f"课程代码: {course.code}",
                f"学分: {course.credits}",
                f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
                f"教学周: 第 {schedule.week_index} 周",
                f"类型: {schedule.lesson_type}",
            ]
            event.add("description", "\n".join(description_lines))

            cal.add_component(event)

    return cal

UndergradEASClient ¤

UndergradEASClient(cas_client: ICASClient)

Methods:

  • login –

    登录到新本科教务系统

  • get_teaching_week –

    获取 {semester_id} 号学期的第 {week_index} 教学周

  • get_teaching_weeks –

    获取 {semester_id} 号学期的全部教学周

  • get_week_index –

    获取指定日期的教学周序数

  • get_semesters –

    获取所有学期数据

  • logout –

    登出账户,清除 Cookie 但保留连接池

  • close –

    清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, cas_client: ICASClient):
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._client.cookies.set(
        "userToken", self._require_user_token(), ".zzu.edu.cn", "/"
    )
    self._logged_in = False
    self._current_semester_id: int | None = None

login ¤

login() -> None

登录到新本科教务系统

Raises:

Source code in zzupy/app/eas/undergrad.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def login(self) -> None:
    """登录到新本科教务系统

    Raises:
        OperationError: 如果登录失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.info("尝试从本科教务系统获取用户信息...")

    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(
            self.USER_INFO_URL,
            headers=headers,
        )
        response.raise_for_status()
        log_http_response_body(
            self.USER_INFO_URL,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

        if response_data["result"] != 0:
            logger.error("服务器返回消息 {}", response_data["msg"])
            raise OperationError(f"服务器返回消息 {response_data['msg']}")

    except httpx2.HTTPStatusError as exc:
        logger.error(
            "{}请求返回失败状态码: {}",
            self.USER_INFO_URL,
            exc.response.status_code,
        )
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", self.USER_INFO_URL, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.USER_INFO_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", self.USER_INFO_URL, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.USER_INFO_URL},
        ) from exc

    self._current_semester_id = self._get_current_semester().id

    self._logged_in = True
    logger.info("教务系统登录成功")

get_teaching_week ¤

get_teaching_week(week: int, semester_id: int | None = None) -> TeachingWeek

获取 {semester_id} 号学期的第 {week_index} 教学周 Args: week: 教学周序数 semester_id: 学期 ID。不填写则为本学期。

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@require_auth
def get_teaching_week(
    self,
    week: int,
    semester_id: int | None = None,
) -> TeachingWeek:
    """
    获取 {semester_id} 号学期的第 {week_index} 教学周
    Args:
        week: 教学周序数
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeek: 教学周

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        InvalidArgumentError: 如果教学周序数不正确。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取第 {} 教学周...", week)
    if week < 1:
        raise InvalidArgumentError("教学周序数不可小于 1")
    if semester_id is None:
        semester_id = self._current_semester_id
    teaching_weeks = self.get_teaching_weeks(semester_id)
    if week > len(teaching_weeks):
        raise InvalidArgumentError(
            f"教学周序数超出范围: {week}",
            context={"week": week, "total_weeks": len(teaching_weeks)},
        )
    return teaching_weeks[week - 1]

get_teaching_weeks ¤

get_teaching_weeks(semester_id: int | None = None) -> TeachingWeeks

获取 {semester_id} 号学期的全部教学周 Args: semester_id: 学期 ID。不填写则为本学期。

Returns:

  • TeachingWeeks ( TeachingWeeks ) –

    由一个学期的全部教学周组成的列表模型。

Raises:

Source code in zzupy/app/eas/undergrad.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@require_auth
def get_teaching_weeks(
    self,
    semester_id: int | None = None,
) -> TeachingWeeks:
    """
    获取 {semester_id} 号学期的全部教学周
    Args:
        semester_id: 学期 ID。不填写则为本学期。

    Returns:
        TeachingWeeks: 由一个学期的全部教学周组成的列表模型。

    Raises:
        ParsingError: 如果响应解析失败
        NetworkError: 如果网络请求失败
        DataNotFoundError: 如果学期不存在。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取全部教学周...")
    if semester_id is None:
        semester_id = self._current_semester_id

    for semester in self.get_semesters():
        if semester.id == semester_id:
            week_indices = semester.week_indices
            break
    else:
        raise DataNotFoundError(
            "semester_id 不存在",
            context={"semester_id": semester_id},
        )

    url = f"{self.COURSE_URL}/{semester_id}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = LessonModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    teaching_weeks = []
    for week_index in week_indices:
        teaching_week = TeachingWeek()
        for datum in data.data:
            for schedule in datum.schedules:
                if schedule.week_index != week_index:
                    continue
                lesson = Lesson(course=datum.course, schedule=schedule)
                for unit in range(schedule.start_unit, schedule.end_unit + 1):
                    teaching_week.set(schedule.weekday, unit, lesson)
        teaching_weeks.append(teaching_week)

    return TeachingWeeks(teaching_weeks)

get_week_index ¤

get_week_index(date: Date) -> int | None

获取指定日期的教学周序数

Returns:

  • int | None –

    int | None: 教学周序数

Raises:

Source code in zzupy/app/eas/undergrad.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@require_auth
def get_week_index(self, date: Date) -> int | None:
    """
    获取指定日期的教学周序数

    Returns:
        int | None: 教学周序数

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取 {} 的教学周序数...", date.format_iso())
    url = f"{self.WEEK_INDEX_URL}"
    params = {"today": date.format_iso()}
    try:
        headers = {"X-Id-Token": self._require_user_token()}
        response = self._client.get(url, headers=headers, params=params)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["code"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = WeekIndexModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    if data.data.data.date[0] != "":
        return int(data.data.data.date[0])
    else:
        return None

get_semesters ¤

get_semesters() -> list[Semester]

获取所有学期数据

Returns:

Raises:

Source code in zzupy/app/eas/undergrad.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def get_semesters(
    self,
) -> list[Semester]:
    """
    获取所有学期数据

    Returns:
        list[Semester]: 所有学期的数据

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
        OperationError: 如果服务器发生异常。
    """
    logger.info("尝试获取所有学期数据...")
    url = f"{self.ALL_SEMESTERS_URL}"
    try:
        headers = {"Authorization": self._require_user_token()}
        response = self._client.get(url, headers=headers)
        response.raise_for_status()
        log_http_response_body(
            url,
            response.text,
            content_type=response.headers.get("content-type"),
        )

        response_data = response.json()

    except httpx2.HTTPStatusError as exc:
        logger.error("{}请求返回失败状态码: {}", url, exc.response.status_code)
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": url},
        ) from exc
    except (json.JSONDecodeError, TypeError) as exc:
        logger.error("从 {} 响应中提取数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("{} 请求失败: {}", url, exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": url},
        ) from exc

    if response_data["result"] != 0:
        logger.error("服务器返回消息 {}", response_data["msg"])
        raise OperationError(f"服务器返回消息 {response_data['msg']}")

    try:
        data = SemesterModel.model_validate(response_data)
    except ValidationError as exc:
        logger.error("从 {} 响应中解析数据失败: {}", url, exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": url},
        ) from exc

    return data.data

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/eas/undergrad.py
454
455
456
457
458
459
460
461
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出教务系统")
    self._client.cookies.clear()
    self._client.headers.clear()
    self._current_semester_id = None
    self._logged_in = False
    logger.debug("EASClient 已登出")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/eas/undergrad.py
463
464
465
466
467
468
def close(self) -> None:
    """清除 Cookie 和连接池"""
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.debug("EASClient 已关闭")

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

zzupy.app.ecard¤

ecard ¤

一卡通

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.app.ecard.ICASClient[ICASClient]

              

              click zzupy.app.ecard.ICASClient href "" "zzupy.app.ecard.ICASClient"
            
Source code in zzupy/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.app.ecard.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.ecard.InvalidArgumentError
                


              click zzupy.app.ecard.InvalidArgumentError href "" "zzupy.app.ecard.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.ecard.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.ecard.NetworkError
                


              click zzupy.app.ecard.NetworkError href "" "zzupy.app.ecard.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.app.ecard.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.app.ecard.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.app.ecard.NotLoggedInError href "" "zzupy.app.ecard.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.ecard.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.ecard.OperationError
                


              click zzupy.app.ecard.OperationError href "" "zzupy.app.ecard.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.app.ecard.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.app.ecard.ParsingError
                


              click zzupy.app.ecard.ParsingError href "" "zzupy.app.ecard.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ECardAccountModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.app.ecard.ECardAccountModel[ECardAccountModel]

              

              click zzupy.app.ecard.ECardAccountModel href "" "zzupy.app.ecard.ECardAccountModel"
            

一卡通账户 API 响应根模型

ECardClient ¤

ECardClient(cas_client: ICASClient)

一卡通客户端

Parameters:

  • cas_client ¤

    (ICASClient) –

    已登录的 CASClient 实例

Methods:

Source code in zzupy/app/ecard.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, cas_client: ICASClient) -> None:
    """初始化 ECardClient 实例

    Args:
        cas_client: 已登录的 CASClient 实例
    """
    if not cas_client.logged_in:
        raise NotLoggedInError("CASClient 必须已经登录")

    self._client = httpx2.Client(event_hooks=build_http_event_hooks())
    self._cas_client = cas_client
    self._access_token: str | None = None
    self._refresh_token: str | None = None
    self._tid: str | None = None
    self._default_room: str | None = None
    self._logged_in: bool = False
    self._refresh_timer: threading.Timer | None = None

login ¤

login() -> None

登录到校园卡系统

Raises:

Source code in zzupy/app/ecard.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def login(self) -> None:
    """登录到校园卡系统

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    logger.debug("开始登录校园卡系统")
    self._get_tid()
    self._get_tokens()
    self._logged_in = True
    self._schedule_token_refresh()
    logger.info("校园卡系统登录成功")

get_default_room ¤

get_default_room() -> str

获取账户默认房间

Returns:

  • str –

    默认的房间

Raises:

Source code in zzupy/app/ecard.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
@require_auth
def get_default_room(self) -> str:
    """获取账户默认房间

    Returns:
        默认的房间

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if self._default_room is not None:
        return self._default_room

    headers = {"Authorization": self._require_access_token()}
    data = {"utilityType": "electric"}

    try:
        logger.debug("正在向 {} 发送请求获取默认房间...", self.CONFIG_URL)
        response = self._client.post(
            self.CONFIG_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.CONFIG_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        room = response_data["resultData"]["location"]["room"]
        logger.info("获取默认房间成功: {}", room)
        self._default_room = room
        return room

    except httpx2.HTTPStatusError as exc:
        logger.error("获取默认房间请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.CONFIG_URL},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从 /utilities/config 响应中提取房间信息失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.CONFIG_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取默认房间网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.CONFIG_URL},
        ) from exc

recharge_energy ¤

recharge_energy(payment_password: str, amt: int, room: str) -> None

为 room 充值电费

Parameters:

  • payment_password ¤

    (str) –

    支付密码

  • amt ¤

    (int) –

    充值金额

  • room ¤

    (str) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Raises:

Source code in zzupy/app/ecard.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
@require_auth
def recharge_energy(self, payment_password: str, amt: int, room: str) -> None:
    """为 room 充值电费

    Args:
        payment_password: 支付密码
        amt: 充值金额
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Raises:
        InvalidArgumentError: 如果金额或房间参数不合法。
        OperationError: 如果充值失败。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    if amt <= 0:
        logger.error("充值金额必须大于0")
        raise InvalidArgumentError("充值金额必须大于 0", context={"amt": amt})

    logger.debug("准备为房间 {} 充值 {} 元", room, amt)

    headers = {"Authorization": self._require_access_token()}

    try:
        # 获取加密信息
        logger.debug("正在向 {} 发送请求获取加密信息...", self.ENCRYPT_URL)
        response = self._client.post(
            self.ENCRYPT_URL,
            headers=headers,
        )
        response.raise_for_status()

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        pay_id = response_data["resultData"]["id"]
        encrypted_public_key = response_data["resultData"]["publicKey"]

        logger.debug("开始解密公钥")
        # 解密被加密的公钥
        public_key = sm4_decrypt_ecb(
            base64.b64decode(encrypted_public_key),
            self.SM4_KEY,
        )

        # 解析房间信息
        try:
            area, building = room.split("--")[0].split("-")
            level = room.split("--")[1].split("-")[0]
        except (IndexError, ValueError) as exc:
            logger.error("房间格式不正确: {}", room)
            raise InvalidArgumentError(
                f"房间格式不正确: {room}",
                context={"room": room},
            ) from exc

        # 构建请求体
        json_data = {
            "utilityType": "electric",
            "payCode": "06",
            "password": payment_password,
            "amt": str(amt),
            "timestamp": int(round(time.time() * 1000)),
            "bigArea": "",
            "area": area,
            "building": building,
            "unit": "",
            "level": level,
            "room": room,
            "subArea": "",
            "customfield": {},
        }
        json_string = json.dumps(json_data, separators=(",", ":"))

        logger.debug("开始加密支付信息")
        # 加密 params
        sm2 = gmalg.SM2(pk=bytes.fromhex(public_key))
        encrypted_params = sm2.encrypt(json_string.encode())
        data = {"id": pay_id, "params": (encrypted_params.hex())[2:]}

        logger.debug("正在向 {} 发送充值请求...", self.PAY_URL)
        response = self._client.post(
            self.PAY_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.PAY_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        if response_data.get("success") is False:
            error_msg = response_data.get("message", "充值失败")
            logger.error("充值失败: {}", error_msg)
            raise OperationError(error_msg)

        logger.info("成功为房间 {} 充值 {} 元", room, amt)

    except httpx2.HTTPStatusError as exc:
        logger.error("充值请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从充值响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.PAY_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("充值网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.PAY_URL, "room": room},
        ) from exc

get_balance ¤

get_balance() -> float

获取校园卡余额

Returns:

  • float –

    校园卡余额

Raises:

Source code in zzupy/app/ecard.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@require_auth
def get_balance(self) -> float:
    """获取校园卡余额

    Returns:
        校园卡余额

    Raises:
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    headers = {"X-Id-Token": self._require_user_token()}

    try:
        logger.debug("正在向 {} 发送请求获取校园卡余额...", self.BALANCE_URL)
        response = self._client.get(
            self.BALANCE_URL,
            headers=headers,
        )
        response.raise_for_status()

        log_http_response_body(
            self.BALANCE_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "data" not in response_data or len(response_data["data"]) < 2:
            logger.error("响应数据格式不正确")
            raise ParsingError("服务器响应格式不正确")

        balance = float(response_data["data"][1]["amount"])
        logger.info("获取校园卡余额成功: {} 元", balance)
        return balance

    except httpx2.HTTPStatusError as exc:
        logger.error("获取余额请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.BALANCE_URL},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
    ) as exc:
        logger.error("从余额响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.BALANCE_URL},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取余额网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.BALANCE_URL},
        ) from exc

get_room_dict ¤

get_room_dict(room_id: str) -> dict

获取房间的字典

Parameters:

  • room_id ¤

    (str) –

    已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

Returns:

  • dict –

    对应的字典

Raises:

Source code in zzupy/app/ecard.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
@require_auth
def get_room_dict(self, room_id: str) -> dict:
    """获取房间的字典

    Args:
        room_id: 已知房间 ID 。例如: '', '99', '99-12', '99-12--33'

    Returns:
        对应的字典

    Raises:
        InvalidArgumentError: 如果参数格式不正确。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """

    logger.debug("正在获取房间列表,room_id: {}", room_id)

    # 解析房间ID
    num = room_id.count("-")
    if num == 0 and room_id == "":
        area = building = level = ""
        location_type = "bigArea"
    elif num == 0 and room_id != "":
        building = level = ""
        area = room_id
        location_type = "building"
    elif num == 1:
        area, building = room_id.split("-")
        level = ""
        location_type = "unit"
    elif num == 3:
        try:
            area, building = room_id.split("--")[0].split("-")
            level = room_id.split("--")[1]
            location_type = "room"
        except (IndexError, ValueError) as exc:
            logger.error("房间ID格式不正确: {}", room_id)
            raise InvalidArgumentError(
                f"房间ID格式不正确: {room_id}",
                context={"room_id": room_id},
            ) from exc
    else:
        logger.error("房间ID格式不合法: {}", room_id)
        raise InvalidArgumentError(
            f"房间ID格式不合法: {room_id}",
            context={"room_id": room_id},
        )

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "locationType": location_type,
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": "",
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取房间列表...", self.LOCATION_URL)
        response = self._client.post(
            self.LOCATION_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.LOCATION_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()

        if "resultData" not in response_data:
            logger.error("响应中缺少 resultData")
            raise ParsingError("服务器响应格式不正确")

        location_list = response_data["resultData"].get("locationList", [])

        room_dict = {}
        for location in location_list:
            room_dict[location["id"]] = location["name"]

        logger.info("成功获取房间列表,共 {} 个房间", len(room_dict))
        return room_dict

    except httpx2.HTTPStatusError as exc:
        logger.error("获取房间列表请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except (json.JSONDecodeError, KeyError, TypeError) as exc:
        logger.error("从房间列表响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取房间列表网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.LOCATION_URL, "room_id": room_id},
        ) from exc

get_remaining_energy ¤

get_remaining_energy(room: str | None = None) -> float

获取剩余电量

Parameters:

  • room ¤

    (str | None, default: None ) –

    房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过 get_room_dict() 获取

Returns:

Raises:

Source code in zzupy/app/ecard.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
@require_auth
def get_remaining_energy(self, room: str | None = None) -> float:
    """获取剩余电量

    Args:
        room: 房间 ID 。格式应为 'areaid-buildingid--unitid-roomid',可通过
            [`get_room_dict()`][zzupy.app.ecard.ECardClient.get_room_dict] 获取

    Returns:
        剩余电量

    Raises:
        InvalidArgumentError: 如果房间参数不合法。
        ParsingError: 如果响应解析失败。
        NetworkError: 如果网络请求失败。
    """
    room = self.get_default_room() if room is None else room
    logger.debug("正在获取房间 {} 的剩余电量", room)

    # 解析房间信息
    try:
        area, building = room.split("--")[0].split("-")
        level = room.split("--")[1].split("-")[0]
    except (IndexError, ValueError) as exc:
        logger.error("房间格式不正确: {}", room)
        raise InvalidArgumentError(
            f"房间格式不正确: {room}",
            context={"room": room},
        ) from exc

    headers = {"Authorization": self._require_access_token()}
    data = {
        "utilityType": "electric",
        "bigArea": "",
        "area": area,
        "building": building,
        "unit": "",
        "level": level,
        "room": room,
        "subArea": "",
    }

    try:
        logger.debug("正在向 {} 发送请求获取剩余电量...", self.ACCOUNT_URL)
        response = self._client.post(
            self.ACCOUNT_URL,
            headers=headers,
            json=data,
        )
        response.raise_for_status()

        log_http_response_body(
            self.ACCOUNT_URL,
            response.text,
            content_type=response.headers.get("content-type"),
            level="DEBUG",
        )

        response_data = response.json()
        account_data = ECardAccountModel.model_validate(response_data)
        remaining_energy = account_data.remaining_energy

        if remaining_energy is None:
            raise ParsingError("服务器响应数据不完整,无法找到剩余电量 quantity")

        logger.info("房间 {} 剩余电量: {} 度", room, remaining_energy)
        return remaining_energy

    except httpx2.HTTPStatusError as exc:
        logger.error("获取剩余电量请求返回失败状态码: {}", exc.response.status_code)
        raise NetworkError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except (
        json.JSONDecodeError,
        KeyError,
        IndexError,
        TypeError,
        ValueError,
        ValidationError,
    ) as exc:
        logger.error("从剩余电量响应中提取数据失败: {}", exc)
        raise ParsingError.from_exception(
            exc,
            "服务器响应格式不正确",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc
    except httpx2.RequestError as exc:
        logger.error("获取剩余电量网络请求失败: {}", exc)
        raise NetworkError.from_exception(
            exc,
            "网络连接异常",
            context={"url": self.ACCOUNT_URL, "room": room},
        ) from exc

logout ¤

logout() -> None

登出账户,清除 Cookie 但保留连接池

Source code in zzupy/app/ecard.py
732
733
734
735
736
737
738
739
740
741
742
743
744
745
@require_auth
def logout(self) -> None:
    """登出账户,清除 Cookie 但保留连接池"""
    logger.debug("正在登出校园卡系统")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    self._access_token = None
    self._refresh_token = None
    self._tid = None
    self._client.cookies.clear()
    self._client.headers.clear()
    self._logged_in = False
    logger.info("已登出校园卡系统")

close ¤

close() -> None

清除 Cookie 和连接池

Source code in zzupy/app/ecard.py
747
748
749
750
751
752
753
754
755
756
def close(self) -> None:
    """清除 Cookie 和连接池"""
    logger.debug("正在关闭校园卡客户端")
    if self._refresh_timer is not None:
        self._refresh_timer.cancel()
        self._refresh_timer = None
    if self._logged_in:
        self.logout()
    self._client.close()
    logger.info("校园卡客户端已关闭")

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

log_http_headers ¤

log_http_headers(message: str, headers: Headers, *, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 头日志。

Source code in zzupy/logging.py
146
147
148
149
150
151
152
153
def log_http_headers(
    message: str,
    headers: httpx2.Headers,
    *,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 头日志。"""
    logger.log(level.upper(), "{}: {}", message, sanitize_http_headers(headers))

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

sm4_decrypt_ecb ¤

sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str

SM4 解密,ECB模式

Parameters:

Returns:

  • str –

    明文 Hex

Source code in zzupy/utils.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str:
    """SM4 解密,ECB模式

    Args:
        ciphertext (bytes): 密文
        key (bytes): 密钥

    Returns:
        明文 Hex
    """
    sm4 = gmalg.SM4(key)
    block_size = 16
    decrypted_padded = b""
    for i in range(0, len(ciphertext), block_size):
        block = ciphertext[i : i + block_size]
        decrypted_padded += sm4.decrypt(block)
    decrypted = pkcs7_unpad(decrypted_padded, block_size)
    return decrypted.decode()

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

zzupy.app.interfaces¤

interfaces ¤

抽象接口

Classes:

ICASClient ¤

ICASClient(account: str, password: str)

Bases: ABC


              flowchart TD
              zzupy.app.interfaces.ICASClient[ICASClient]

              

              click zzupy.app.interfaces.ICASClient href "" "zzupy.app.interfaces.ICASClient"
            
Source code in zzupy/app/interfaces.py
7
8
9
@abstractmethod
def __init__(self, account: str, password: str) -> None:
    pass

zzupy.crypto¤

crypto ¤

纯 Python 实现的轻量级 RSA 加密

Classes:

  • RSAPublicKey –

    轻量级 RSA 公钥实现

  • padding –

    填充方案命名空间(兼容 cryptography)

  • serialization –

    序列化命名空间(兼容 cryptography)

Functions:

RSAPublicKey ¤

RSAPublicKey(modulus: int, exponent: int)

轻量级 RSA 公钥实现

Parameters:

  • modulus ¤

    (int) –

    RSA 模数 (n)

  • exponent ¤

    (int) –

    RSA 公开指数 (e),通常是 65537

Methods:

  • encrypt –

    使用 PKCS#1 v1.5 填充进行 RSA 加密

  • verify_rs512 –

    验证 RS512 (RSA-SHA512) 签名

Source code in zzupy/crypto.py
12
13
14
15
16
17
18
19
20
21
22
def __init__(self, modulus: int, exponent: int):
    """初始化 RSA 公钥

    Args:
        modulus: RSA 模数 (n)
        exponent: RSA 公开指数 (e),通常是 65537
    """
    self.modulus = modulus
    self.exponent = exponent
    self.key_size_bits = modulus.bit_length()
    self.key_size_bytes = (self.key_size_bits + 7) // 8

encrypt ¤

encrypt(data: bytes, padding_scheme=None) -> bytes

使用 PKCS#1 v1.5 填充进行 RSA 加密

Parameters:

  • data ¤

    (bytes) –

    要加密的数据

  • padding_scheme ¤

    –

    填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

Returns:

  • bytes –

    加密后的字节数据

Source code in zzupy/crypto.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def encrypt(self, data: bytes, padding_scheme=None) -> bytes:
    """使用 PKCS#1 v1.5 填充进行 RSA 加密

    Args:
        data: 要加密的数据
        padding_scheme: 填充方案(保留接口兼容性,实际使用 PKCS#1 v1.5)

    Returns:
        加密后的字节数据
    """
    # PKCS#1 v1.5 填充
    # 格式: 0x00 || 0x02 || PS || 0x00 || M
    # PS 是随机非零字节,长度至少为 8

    max_message_length = self.key_size_bytes - 11  # 预留给填充的空间

    if len(data) > max_message_length:
        raise ValueError(f"消息太长,最大长度为 {max_message_length} 字节")

    # 构建填充
    padding_length = self.key_size_bytes - len(data) - 3

    # 生成随机非零填充字节
    padding = bytearray()
    while len(padding) < padding_length:
        random_bytes = os.urandom(padding_length - len(padding))
        # 移除零字节
        for byte in random_bytes:
            if byte != 0:
                padding.append(byte)
            if len(padding) >= padding_length:
                break

    # 构建完整的填充消息
    padded_message = bytearray([0x00, 0x02])
    padded_message.extend(padding)
    padded_message.append(0x00)
    padded_message.extend(data)

    # 转换为整数并进行 RSA 加密
    m = int.from_bytes(padded_message, byteorder="big")

    # RSA 加密: c = m^e mod n
    c = pow(m, self.exponent, self.modulus)

    # 转换回字节,确保长度正确
    encrypted = c.to_bytes(self.key_size_bytes, byteorder="big")

    return encrypted

verify_rs512 ¤

verify_rs512(message: bytes, signature: bytes) -> bool

验证 RS512 (RSA-SHA512) 签名

Parameters:

  • message ¤

    (bytes) –

    原始消息

  • signature ¤

    (bytes) –

    RSA 签名

Returns:

  • bool –

    签名是否有效

Source code in zzupy/crypto.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def verify_rs512(self, message: bytes, signature: bytes) -> bool:
    """验证 RS512 (RSA-SHA512) 签名

    Args:
        message: 原始消息
        signature: RSA 签名

    Returns:
        签名是否有效
    """
    # 计算消息的 SHA-512 哈希
    message_hash = hashlib.sha512(message).digest()

    # RSA 验证: m = s^e mod n
    s = int.from_bytes(signature, byteorder="big")
    m = pow(s, self.exponent, self.modulus)
    decrypted = m.to_bytes(self.key_size_bytes, byteorder="big")

    # 验证 PKCS#1 v1.5 填充格式
    # 格式: 0x00 || 0x01 || PS || 0x00 || DigestInfo
    if decrypted[0] != 0x00 or decrypted[1] != 0x01:
        return False

    # 查找 0x00 分隔符
    separator_index = decrypted.find(b"\x00", 2)
    if separator_index == -1:
        return False

    # DigestInfo 结构 (SHA-512)
    # SHA-512 的 DigestInfo 前缀
    sha512_digest_info = bytes.fromhex(
        "3051"  # SEQUENCE
        "300d"  # AlgorithmIdentifier SEQUENCE
        "0609"  # OID (9 bytes)
        "608648016503040203"  # SHA-512 OID
        "0500"  # NULL
        "0440"  # OCTET STRING (64 bytes)
    )

    digest_info_start = separator_index + 1
    digest_info = decrypted[digest_info_start:]

    # 验证 DigestInfo 结构
    expected_digest_info = sha512_digest_info + message_hash

    return digest_info == expected_digest_info

padding ¤

填充方案命名空间(兼容 cryptography)

Classes:

PKCS1v15 ¤

PKCS#1 v1.5 填充方案

serialization ¤

序列化命名空间(兼容 cryptography)

Methods:

load_pem_public_key staticmethod ¤

load_pem_public_key(data: bytes) -> RSAPublicKey

加载 PEM 格式公钥(兼容 cryptography 接口)

Source code in zzupy/crypto.py
249
250
251
252
@staticmethod
def load_pem_public_key(data: bytes) -> RSAPublicKey:
    """加载 PEM 格式公钥(兼容 cryptography 接口)"""
    return load_pem_public_key(data)

load_pem_public_key ¤

load_pem_public_key(pem_data: bytes) -> RSAPublicKey

从 PEM 格式加载 RSA 公钥

Parameters:

  • pem_data ¤

    (bytes) –

    PEM 格式的公钥数据

Returns:

Source code in zzupy/crypto.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def load_pem_public_key(pem_data: bytes) -> RSAPublicKey:
    """从 PEM 格式加载 RSA 公钥

    Args:
        pem_data: PEM 格式的公钥数据

    Returns:
        RSAPublicKey 对象
    """
    # 转换为字符串
    if isinstance(pem_data, bytes):
        pem_str = pem_data.decode("utf-8")
    else:
        pem_str = pem_data

    # 清理 PEM 头尾和空白字符
    pem_str = pem_str.strip()
    pem_str = pem_str.replace("-----BEGIN PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----END PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----BEGIN RSA PUBLIC KEY-----", "")
    pem_str = pem_str.replace("-----END RSA PUBLIC KEY-----", "")
    pem_str = pem_str.replace("\n", "")
    pem_str = pem_str.replace("\r", "")
    pem_str = pem_str.replace(" ", "")

    # Base64 解码得到 DER 格式数据
    der_bytes = base64.b64decode(pem_str)

    # 解析 DER 格式获取模数和指数
    modulus, exponent = _parse_der_public_key(der_bytes)

    return RSAPublicKey(modulus, exponent)

_parse_der_public_key ¤

_parse_der_public_key(der_data: bytes) -> Tuple[int, int]

解析 DER 编码的 RSA 公钥

支持两种格式: 1. PKCS#1 RSAPublicKey (常见于 OpenSSL) 2. X.509 SubjectPublicKeyInfo (标准格式)

Parameters:

  • der_data ¤

    (bytes) –

    DER 编码的公钥数据

Returns:

Source code in zzupy/crypto.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def _parse_der_public_key(der_data: bytes) -> Tuple[int, int]:
    """解析 DER 编码的 RSA 公钥

    支持两种格式:
    1. PKCS#1 RSAPublicKey (常见于 OpenSSL)
    2. X.509 SubjectPublicKeyInfo (标准格式)

    Args:
        der_data: DER 编码的公钥数据

    Returns:
        (modulus, exponent) 元组
    """

    def read_length(data: bytes, pos: int) -> Tuple[int, int]:
        """读取 ASN.1 长度编码"""
        if data[pos] & 0x80 == 0:
            # 短格式
            return data[pos], pos + 1
        else:
            # 长格式
            num_bytes = data[pos] & 0x7F
            pos += 1
            length = int.from_bytes(data[pos : pos + num_bytes], "big")
            return length, pos + num_bytes

    def read_integer(data: bytes, pos: int) -> Tuple[int, int]:
        """读取 ASN.1 INTEGER"""
        if data[pos] != 0x02:
            raise ValueError(f"期望 INTEGER (0x02),得到 {hex(data[pos])}")
        pos += 1
        length, pos = read_length(data, pos)
        value = int.from_bytes(data[pos : pos + length], "big")
        return value, pos + length

    pos = 0

    # 检查是否是 SEQUENCE
    if der_data[pos] != 0x30:
        raise ValueError("无效的 DER 格式:期望 SEQUENCE")
    pos += 1

    seq_length, pos = read_length(der_data, pos)

    # 检查下一个元素
    if pos < len(der_data) and der_data[pos] == 0x30:
        # X.509 SubjectPublicKeyInfo 格式
        # 跳过算法标识符
        pos += 1
        algo_length, pos = read_length(der_data, pos)
        pos += algo_length

        # BIT STRING 包含实际的公钥
        if der_data[pos] != 0x03:
            raise ValueError("期望 BIT STRING")
        pos += 1

        bit_string_length, pos = read_length(der_data, pos)

        # 跳过未使用的位数标记
        if der_data[pos] != 0x00:
            raise ValueError("期望未使用位数为 0")
        pos += 1

        # 现在应该是另一个 SEQUENCE(RSAPublicKey)
        if der_data[pos] != 0x30:
            raise ValueError("期望内部 SEQUENCE")
        pos += 1

        inner_seq_length, pos = read_length(der_data, pos)

    # 读取模数
    modulus, pos = read_integer(der_data, pos)

    # 读取公开指数
    exponent, pos = read_integer(der_data, pos)

    return modulus, exponent

zzupy.exception¤

exception ¤

异常处理模块。

Classes:

Attributes:

__all__ module-attribute ¤

__all__ = ['AuthenticationError', 'ClientStateError', 'DataNotFoundError', 'InvalidArgumentError', 'LoginError', 'NetworkError', 'NotLoggedInError', 'OperationError', 'ParsingError', 'ZZUError', 'MFAError']

ZZUError ¤

ZZUError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: Exception


              flowchart TD
              zzupy.exception.ZZUError[ZZUError]

              

              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

项目异常基类。

除了人类可读的错误消息外,还会保留额外上下文,便于调用方记录日志、 序列化或调试。

Parameters:

  • message ¤

    (str | None, default: None ) –

    错误消息。

  • context ¤

    (Mapping[str, Any] | None, default: None ) –

    结构化上下文信息。

  • error_code ¤

    (str | None, default: None ) –

    可选错误代码。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ClientStateError ¤

ClientStateError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, RuntimeError


              flowchart TD
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                


              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

客户端状态错误。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

InvalidArgumentError ¤

InvalidArgumentError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError, ValueError


              flowchart TD
              zzupy.exception.InvalidArgumentError[InvalidArgumentError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.InvalidArgumentError
                


              click zzupy.exception.InvalidArgumentError href "" "zzupy.exception.InvalidArgumentError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

调用参数不合法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.NetworkError
                


              click zzupy.exception.NetworkError href "" "zzupy.exception.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.LoginError
                


              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.ParsingError
                


              click zzupy.exception.ParsingError href "" "zzupy.exception.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.exception.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.exception.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.exception.NotLoggedInError href "" "zzupy.exception.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

AuthenticationError ¤

AuthenticationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.exception.AuthenticationError[AuthenticationError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.exception.AuthenticationError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.exception.AuthenticationError href "" "zzupy.exception.AuthenticationError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

认证失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.exception.OperationError
                


              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

DataNotFoundError ¤

DataNotFoundError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: OperationError, LookupError


              flowchart TD
              zzupy.exception.DataNotFoundError[DataNotFoundError]
              zzupy.exception.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.OperationError --> zzupy.exception.DataNotFoundError
                                zzupy.exception.ZZUError --> zzupy.exception.OperationError
                



              click zzupy.exception.DataNotFoundError href "" "zzupy.exception.DataNotFoundError"
              click zzupy.exception.OperationError href "" "zzupy.exception.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

请求的数据不存在或当前上下文中无法找到。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

MFAError ¤

MFAError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: LoginError


              flowchart TD
              zzupy.exception.MFAError[MFAError]
              zzupy.exception.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.LoginError --> zzupy.exception.MFAError
                                zzupy.exception.ZZUError --> zzupy.exception.LoginError
                



              click zzupy.exception.MFAError href "" "zzupy.exception.MFAError"
              click zzupy.exception.LoginError href "" "zzupy.exception.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

MFA 状态错误,当前会话可能需要 MFA 验证

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

zzupy.logging¤

logging ¤

zzupy 日志工具。

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

_REDACTED module-attribute ¤

_REDACTED = '[REDACTED]'

_MAX_BODY_LENGTH module-attribute ¤

_MAX_BODY_LENGTH = 2048

_DEFAULT_HANDLER_ID module-attribute ¤

_DEFAULT_HANDLER_ID = 0

_TEXT_CONTENT_TYPES module-attribute ¤

_TEXT_CONTENT_TYPES = ('application/json', 'application/x-www-form-urlencoded', 'application/xml', 'application/javascript', 'text/')

_SENSITIVE_KEYS module-attribute ¤

_SENSITIVE_KEYS = frozenset({'access_token', 'accesstoken', 'authorization', 'clientid', 'cookie', 'deviceid', 'password', 'refresh_token', 'refreshtoken', 'set-cookie', 'token', 'user_token', 'usertoken', 'username'})

_normalize_key ¤

_normalize_key(key: str) -> str
Source code in zzupy/logging.py
43
44
def _normalize_key(key: str) -> str:
    return key.strip().lower().replace("_", "").replace("-", "")

_is_sensitive_key ¤

_is_sensitive_key(key: str) -> bool
Source code in zzupy/logging.py
47
48
def _is_sensitive_key(key: str) -> bool:
    return _normalize_key(key) in _SENSITIVE_KEYS

_truncate_text ¤

_truncate_text(text: str, limit: int = _MAX_BODY_LENGTH) -> str
Source code in zzupy/logging.py
51
52
53
54
def _truncate_text(text: str, limit: int = _MAX_BODY_LENGTH) -> str:
    if len(text) <= limit:
        return text
    return f"{text[:limit]}... (truncated, total={len(text)})"

_sanitize_value ¤

_sanitize_value(key: str | None, value: Any) -> Any
Source code in zzupy/logging.py
57
58
59
60
61
62
63
64
65
66
67
def _sanitize_value(key: str | None, value: Any) -> Any:
    if key is not None and _is_sensitive_key(key):
        return _REDACTED

    if isinstance(value, dict):
        return {k: _sanitize_value(k, v) for k, v in value.items()}
    if isinstance(value, list):
        return [_sanitize_value(key, item) for item in value]
    if isinstance(value, tuple):
        return tuple(_sanitize_value(key, item) for item in value)
    return value

sanitize_http_headers ¤

sanitize_http_headers(headers: Headers) -> dict[str, str]

脱敏并标准化 HTTP 头,便于安全输出到日志。

Source code in zzupy/logging.py
70
71
72
73
74
75
def sanitize_http_headers(headers: httpx2.Headers) -> dict[str, str]:
    """脱敏并标准化 HTTP 头,便于安全输出到日志。"""
    return {
        key: _REDACTED if _is_sensitive_key(key) else value
        for key, value in headers.items()
    }

sanitize_http_url ¤

sanitize_http_url(url: str | URL) -> str

脱敏 URL 查询参数。

Source code in zzupy/logging.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def sanitize_http_url(url: str | httpx2.URL) -> str:
    """脱敏 URL 查询参数。"""
    parsed = urlsplit(str(url))
    if not parsed.query:
        return str(url)

    sanitized_query = urlencode(
        [
            (key, _REDACTED if _is_sensitive_key(key) else value)
            for key, value in parse_qsl(parsed.query, keep_blank_values=True)
        ],
        doseq=True,
    )
    return urlunsplit(parsed._replace(query=sanitized_query))

sanitize_http_body ¤

sanitize_http_body(body: bytes | str | None, *, content_type: str | None = None, limit: int = _MAX_BODY_LENGTH) -> str

按内容类型对 HTTP Body 做脱敏与截断。

Source code in zzupy/logging.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def sanitize_http_body(
    body: bytes | str | None,
    *,
    content_type: str | None = None,
    limit: int = _MAX_BODY_LENGTH,
) -> str:
    """按内容类型对 HTTP Body 做脱敏与截断。"""
    if body in (None, b"", ""):
        return ""

    if isinstance(body, bytes):
        try:
            text = body.decode("utf-8")
        except UnicodeDecodeError:
            return "(binary)"
    else:
        text = body

    stripped = text.strip()
    normalized_type = (content_type or "").split(";", 1)[0].strip().lower()

    if normalized_type == "application/x-www-form-urlencoded":
        sanitized_form = urlencode(
            [
                (key, _REDACTED if _is_sensitive_key(key) else value)
                for key, value in parse_qsl(text, keep_blank_values=True)
            ],
            doseq=True,
        )
        return _truncate_text(sanitized_form, limit)

    if normalized_type == "application/json" or stripped.startswith(("{", "[")):
        try:
            return _truncate_text(
                json.dumps(
                    _sanitize_value(None, json.loads(text)),
                    ensure_ascii=False,
                    sort_keys=True,
                ),
                limit,
            )
        except json.JSONDecodeError:
            pass

    if normalized_type and not any(
        normalized_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES
    ):
        return "(binary)"

    return _truncate_text(text, limit)

log_http_headers ¤

log_http_headers(message: str, headers: Headers, *, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 头日志。

Source code in zzupy/logging.py
146
147
148
149
150
151
152
153
def log_http_headers(
    message: str,
    headers: httpx2.Headers,
    *,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 头日志。"""
    logger.log(level.upper(), "{}: {}", message, sanitize_http_headers(headers))

log_http_response_body ¤

log_http_response_body(url: str | URL, text: str, *, content_type: str | None = None, level: str = 'TRACE') -> None

输出脱敏后的 HTTP 响应体日志。

Source code in zzupy/logging.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def log_http_response_body(
    url: str | httpx2.URL,
    text: str,
    *,
    content_type: str | None = None,
    level: str = "TRACE",
) -> None:
    """输出脱敏后的 HTTP 响应体日志。"""
    logger.log(
        level.upper(),
        "{} 请求响应体: {}",
        sanitize_http_url(url),
        sanitize_http_body(text, content_type=content_type),
    )

build_http_event_hooks ¤

build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]

创建带脱敏能力的 HTTP 请求/响应日志钩子。

Source code in zzupy/logging.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def build_http_event_hooks(*, async_client: bool = False) -> dict[str, list[Any]]:
    """创建带脱敏能力的 HTTP 请求/响应日志钩子。"""

    def log_request(request: httpx2.Request) -> None:
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(">>> {} {}", request.method, sanitized_url)
        log_http_headers(">>> Headers", request.headers)

        body = sanitize_http_body(
            request.content,
            content_type=request.headers.get("content-type"),
        )
        if body:
            logger.trace(">>> Body: {}", body)

    def log_response(response: httpx2.Response) -> None:
        request = response.request
        sanitized_url = sanitize_http_url(request.url)
        logger.trace(
            "<<< {} {} {}", response.status_code, request.method, sanitized_url
        )
        log_http_headers("<<< Headers", response.headers)

    if async_client:

        async def async_request_logger(request: httpx2.Request) -> None:
            log_request(request)

        async def async_response_logger(response: httpx2.Response) -> None:
            log_response(response)

        return {
            "request": [async_request_logger],
            "response": [async_response_logger],
        }

    return {
        "request": [log_request],
        "response": [log_response],
    }

zzupy.model¤

model ¤

Modules:

auth ¤

Classes:

PersonalInfoCardModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoCardModel[PersonalInfoCardModel]

              

              click zzupy.model.auth.PersonalInfoCardModel href "" "zzupy.model.auth.PersonalInfoCardModel"
            

‘我的’页中个人信息卡片 API 响应根模型

Attributes:

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: List[InnerData]

卡片数据列表

PersonalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfo[PersonalInfo]

              

              click zzupy.model.auth.PersonalInfo href "" "zzupy.model.auth.PersonalInfo"
            

个人信息模型

Attributes:

uid instance-attribute ¤

uid: str

学号

name instance-attribute ¤

name: str

用户姓名

student_type instance-attribute ¤

student_type: str

学生类型。比如‘本科生’

student_type_id instance-attribute ¤

student_type_id: str

学生类型 ID

college instance-attribute ¤

college: str

学院

college_id instance-attribute ¤

college_id: str

学院 ID

unread_email_count instance-attribute ¤

unread_email_count: int

邮箱未读邮件数

balance instance-attribute ¤

balance: float

一卡通余额

research_count instance-attribute ¤

research_count: int

科研信息数量

PersonalInfoModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel[PersonalInfoModel]

              

              click zzupy.model.auth.PersonalInfoModel href "" "zzupy.model.auth.PersonalInfoModel"
            

个人信息 API 响应根模型

Classes:

Data ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data[Data]

              

              click zzupy.model.auth.PersonalInfoModel.Data href "" "zzupy.model.auth.PersonalInfoModel.Data"
            

Classes:

Attributes:

username instance-attribute ¤
username: str

学号

Attributes ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data.Attributes[Attributes]

              

              click zzupy.model.auth.PersonalInfoModel.Data.Attributes href "" "zzupy.model.auth.PersonalInfoModel.Data.Attributes"
            

Attributes:

organization_id instance-attribute ¤
organization_id: str

学院 ID

organization_name instance-attribute ¤
organization_name: str

学院名

identity_type_name instance-attribute ¤
identity_type_name: str

学生类型

identity_type_id instance-attribute ¤
identity_type_id: str

学生类型 ID

user_name instance-attribute ¤
user_name: str

学生姓名

user_uid instance-attribute ¤
user_uid: str

学号

eas ¤

Classes:

  • Campus –

    校区信息

  • CultivateType –

    培养类型,如主修、辅修等

  • PeriodInfo –

    课时详情,描述一门课程各类学时的分配情况

  • Course –

    课程基本信息

  • OpenDepartment –

    开课院系

  • CourseType –

    课程类型,如必修课、选修课等

  • DateTimeText –

    上课时间的文字描述

  • DateTimePlaceText –

    上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

  • DateTimePlacePersonText –

    上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

  • ScheduleText –

    教学班排课的综合文字描述,聚合了三个层次的描述信息

  • ScheduleGroup –

    排课组,将一个教学班的多次课归入同一组

  • Building –

    楼栋信息

  • Room –

    教室信息

  • Schedule –

    单次课程的具体排课记录

  • Datum –

    教学班信息,包含课程、排课等完整数据

  • LessonModel –

    课程表查询 API 响应根模型

  • Lesson –

    课表中的一节课

  • TeachingWeek –

    教学周课表,7 天 × 10 节的网格

  • TeachingWeeks –
  • Semester –

    单个学期

  • SemesterModel –

    获取全部学期数据 API 响应根模型

  • CurrentSemesterModel –

    获取当前学期数据 API 响应根模型

  • WeekIndexModel –

    获取某日期的教学周序数 API 响应根模型

Campus ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Campus[Campus]

              

              click zzupy.model.eas.Campus href "" "zzupy.model.eas.Campus"
            

校区信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

校区中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

校区英文名

code instance-attribute ¤

code: str

校区编号

CultivateType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CultivateType[CultivateType]

              

              click zzupy.model.eas.CultivateType href "" "zzupy.model.eas.CultivateType"
            

培养类型,如主修、辅修等

Attributes:

name_zh instance-attribute ¤

name_zh: str

培养类型中文名

name_en class-attribute instance-attribute ¤

name_en: str | None = None

培养类型英文名

code instance-attribute ¤

code: str

培养类型编号

PeriodInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.PeriodInfo[PeriodInfo]

              

              click zzupy.model.eas.PeriodInfo href "" "zzupy.model.eas.PeriodInfo"
            

课时详情,描述一门课程各类学时的分配情况

Attributes:

total instance-attribute ¤

total: int

总学时

weeks instance-attribute ¤

weeks: int

开课周数

theory instance-attribute ¤

theory: int | None

理论学时数

theory_unit instance-attribute ¤

theory_unit: str | None

理论学时单位

require_theory instance-attribute ¤

require_theory: int | None

要求完成的理论学时数

experiment instance-attribute ¤

experiment: int | None

实验学时数

experiment_unit instance-attribute ¤

experiment_unit: str | None

实验学时单位

require_experiment instance-attribute ¤

require_experiment: int | None

要求完成的实验学时数

periods_per_week instance-attribute ¤

periods_per_week: int

每周课时数

Course ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Course[Course]

              

              click zzupy.model.eas.Course href "" "zzupy.model.eas.Course"
            

课程基本信息

Attributes:

code instance-attribute ¤

code: str

课程编号

name_zh instance-attribute ¤

name_zh: str

课程中文名

name_en class-attribute instance-attribute ¤

name_en: str | None = None

课程英文名

credits instance-attribute ¤

credits: float

学分

period_info instance-attribute ¤

period_info: PeriodInfo

课时详情

theory instance-attribute ¤

theory: bool

是否含理论课

experiment instance-attribute ¤

experiment: bool

是否含实验课

practice instance-attribute ¤

practice: bool

是否含实践课

test instance-attribute ¤

test: bool

是否含考试课时

machine instance-attribute ¤

machine: bool

是否含上机课

design instance-attribute ¤

design: bool

是否含设计课

extra instance-attribute ¤

extra: bool

是否含其他课时类型

OpenDepartment ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.OpenDepartment[OpenDepartment]

              

              click zzupy.model.eas.OpenDepartment href "" "zzupy.model.eas.OpenDepartment"
            

开课院系

Attributes:

name_zh instance-attribute ¤

name_zh: str

院系中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

院系英文名

code instance-attribute ¤

code: str

院系编号

CourseType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CourseType[CourseType]

              

              click zzupy.model.eas.CourseType href "" "zzupy.model.eas.CourseType"
            

课程类型,如必修课、选修课等

Attributes:

name_zh instance-attribute ¤

name_zh: str

课程类型中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

课程类型英文名

code instance-attribute ¤

code: str

课程类型编号

DateTimeText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimeText[DateTimeText]

              

              click zzupy.model.eas.DateTimeText href "" "zzupy.model.eas.DateTimeText"
            

上课时间的文字描述

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

DateTimePlaceText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlaceText[DateTimePlaceText]

              

              click zzupy.model.eas.DateTimePlaceText href "" "zzupy.model.eas.DateTimePlaceText"
            

上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

DateTimePlacePersonText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlacePersonText[DateTimePlacePersonText]

              

              click zzupy.model.eas.DateTimePlacePersonText href "" "zzupy.model.eas.DateTimePlacePersonText"
            

上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

ScheduleText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleText[ScheduleText]

              

              click zzupy.model.eas.ScheduleText href "" "zzupy.model.eas.ScheduleText"
            

教学班排课的综合文字描述,聚合了三个层次的描述信息

Attributes:

date_time_text instance-attribute ¤

date_time_text: DateTimeText

仅时间描述

date_time_place_text instance-attribute ¤

date_time_place_text: DateTimePlaceText

时间+地点描述

date_time_place_person_text instance-attribute ¤

date_time_place_person_text: DateTimePlacePersonText

时间+地点+教师描述

ScheduleGroup ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleGroup[ScheduleGroup]

              

              click zzupy.model.eas.ScheduleGroup href "" "zzupy.model.eas.ScheduleGroup"
            

排课组,将一个教学班的多次课归入同一组

Attributes:

id instance-attribute ¤

id: int

排课组 ID

lesson_id instance-attribute ¤

lesson_id: int

所属教学班 ID

no instance-attribute ¤

no: int

排课组序号

Building ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Building[Building]

              

              click zzupy.model.eas.Building href "" "zzupy.model.eas.Building"
            

楼栋信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

楼栋中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

楼栋英文名

code instance-attribute ¤

code: str

楼栋编号

Room ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Room[Room]

              

              click zzupy.model.eas.Room href "" "zzupy.model.eas.Room"
            

教室信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

教室中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

教室英文名

building instance-attribute ¤

building: Building

所在楼栋

campus instance-attribute ¤

campus: Campus

所在校区

seat_number instance-attribute ¤

seat_number: None

座位数

Schedule ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Schedule[Schedule]

              

              click zzupy.model.eas.Schedule href "" "zzupy.model.eas.Schedule"
            

单次课程的具体排课记录

Attributes:

schedule_group_id instance-attribute ¤

schedule_group_id: int

所属排课组 ID,关联 ScheduleGroup.id

date instance-attribute ¤

date: Date

上课日期,格式 "YYYY-MM-DD"

original_date class-attribute instance-attribute ¤

original_date: None | str = None

原始日期

weekday instance-attribute ¤

weekday: int

星期几,1=周一,4=周四,7=周日

start_time instance-attribute ¤

start_time: ZonedDateTime

开始时间,格式 HHMM,如 1010 表示 10:10

end_time instance-attribute ¤

end_time: ZonedDateTime

结束时间,格式 HHMM,如 1150 表示 11:50

teacher_name instance-attribute ¤

teacher_name: str

授课教师中文姓名

teacher_name_en instance-attribute ¤

teacher_name_en: str | None

授课教师英文姓名

custom_place class-attribute instance-attribute ¤

custom_place: None | str = None

自定义上课地点

room instance-attribute ¤

room: Room | None

教室信息

start_unit instance-attribute ¤

start_unit: int

开始节次,如 3(第3节)

end_unit instance-attribute ¤

end_unit: int

结束节次,如 4(第4节)

state instance-attribute ¤

state: str

课程状态

week_index instance-attribute ¤

week_index: int

本次课所在教学周,如 1 表示第1周

lesson_type instance-attribute ¤

lesson_type: str

课时类型,如 "THEORY"(理论课)

periods instance-attribute ¤

periods: int

本次课课时数,如 2

real_start_time instance-attribute ¤

real_start_time: ZonedDateTime

实际开始时间,格式同 startTime

real_end_time instance-attribute ¤

real_end_time: ZonedDateTime

实际结束时间,格式同 endTime

Datum ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Datum[Datum]

              

              click zzupy.model.eas.Datum href "" "zzupy.model.eas.Datum"
            

教学班信息,包含课程、排课等完整数据

Attributes:

id instance-attribute ¤

id: int

教学班 ID

biz_type_id instance-attribute ¤

biz_type_id: int

业务类型 ID

campus instance-attribute ¤

campus: Campus

开课校区

cultivate_type instance-attribute ¤

cultivate_type: CultivateType

培养类型

code instance-attribute ¤

code: str

教学班编号

course instance-attribute ¤

course: Course

课程基本信息

remark class-attribute instance-attribute ¤

remark: None | str = None

备注

schedule_state instance-attribute ¤

schedule_state: str

排课状态

std_count instance-attribute ¤

std_count: int

选课学生人数

open_department instance-attribute ¤

open_department: OpenDepartment

开课院系

course_type instance-attribute ¤

course_type: CourseType

课程类型

teacher_assignment_list instance-attribute ¤

teacher_assignment_list: list[str]

授课教师中文姓名列表

teacher_assignment_en_list instance-attribute ¤

teacher_assignment_en_list: list[str | None]

授课教师英文姓名列表

schedule_text instance-attribute ¤

schedule_text: ScheduleText

排课文字描述(时间/地点/教师)

schedule_groups instance-attribute ¤

schedule_groups: list[ScheduleGroup]

排课组列表

schedules instance-attribute ¤

schedules: list[Schedule]

每次课的具体排课记录列表

students instance-attribute ¤

students: list[Any]

学生列表

time_table_layout_assoc instance-attribute ¤

time_table_layout_assoc: int

关联的课表布局 ID

suggest_schedule_weeks_info instance-attribute ¤

suggest_schedule_weeks_info: None

建议排课周信息

LessonModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.LessonModel[LessonModel]

              

              click zzupy.model.eas.LessonModel href "" "zzupy.model.eas.LessonModel"
            

课程表查询 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message class-attribute instance-attribute ¤

message: None | str = None

响应消息

data instance-attribute ¤

data: list[Datum]

教学班数据列表

Lesson ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Lesson[Lesson]

              

              click zzupy.model.eas.Lesson href "" "zzupy.model.eas.Lesson"
            

课表中的一节课

Attributes:

course instance-attribute ¤

course: Course

对应课程

schedule instance-attribute ¤

schedule: Schedule

对应时间

TeachingWeek ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.TeachingWeek[TeachingWeek]

              

              click zzupy.model.eas.TeachingWeek href "" "zzupy.model.eas.TeachingWeek"
            

教学周课表,7 天 × 10 节的网格

Methods:

  • set –

    设置某天某节的课程

  • get –

    获取某天某节的课程

  • get_day –

    获取某天的全部课程

  • get_unit –

    获取某节 7 天的课程

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

Attributes:

lessons class-attribute instance-attribute ¤

lessons: dict[tuple[int, int], Lesson] = Field(default_factory=dict)

内部存储:仅存储非空课程,key 为 (weekday, unit),value 为 Lesson

grid property ¤

grid: list[list[Lesson | None]]

网格形式的课表,仅在访问时动态生成

set ¤

set(weekday: int, unit: int, lesson: Lesson) -> None

设置某天某节的课程

Parameters:

  • weekday ¤
    (int) –

    第几天

  • unit ¤
    (int) –

    第几节

  • lesson ¤
    (Lesson) –

    课程

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
449
450
451
452
453
454
455
456
457
458
459
460
461
def set(self, weekday: int, unit: int, lesson: Lesson) -> None:
    """设置某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节
        lesson: 课程

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    self.lessons[(weekday, unit)] = lesson

get ¤

get(weekday: int, unit: int) -> Lesson | None

获取某天某节的课程

Parameters:

  • weekday ¤
    (int) –

    第几天

  • unit ¤
    (int) –

    第几节

Returns:

  • Lesson | None –

    Lesson | None: 对应课程或 None

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def get(self, weekday: int, unit: int) -> Lesson | None:
    """获取某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节

    Returns:
        Lesson | None: 对应课程或 None

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    return self.lessons.get((weekday, unit))

get_day ¤

get_day(weekday: int) -> list[Lesson | None]

获取某天的全部课程

Parameters:

  • weekday ¤
    (int) –

    第几天

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def get_day(self, weekday: int) -> list[Lesson | None]:
    """获取某天的全部课程

    Args:
        weekday: 第几天

    Returns:
        list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {weekday} 超出范围
    """
    self._validate_index(weekday, 1)
    return [self.lessons.get((weekday, unit)) for unit in range(1, self.UNITS + 1)]

get_unit ¤

get_unit(unit: int) -> list[Lesson | None]

获取某节 7 天的课程

Parameters:

  • unit ¤
    (int) –

    第几节课

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def get_unit(self, unit: int) -> list[Lesson | None]:
    """获取某节 7 天的课程

    Args:
        unit: 第几节课

    Returns:
        list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {unit} 超出范围
    """
    self._validate_index(1, unit)
    return [self.lessons.get((day, unit)) for day in range(1, self.DAYS + 1)]

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤
    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()

    for lesson in self.lessons.values():
        if not lesson:
            continue

        schedule = lesson.schedule
        course = lesson.course

        # 去重
        if schedule in processed_schedule_ids:
            continue
        processed_schedule_ids.add(schedule)

        event = Event()

        # 课程中文名
        event.add("summary", course.name_zh)

        # 起始时间
        start_time = (
            schedule.real_start_time.to_stdlib() or schedule.start_time.to_stdlib()
        )
        end_time = (
            schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
        )
        event.add("dtstart", start_time)
        event.add("dtend", end_time)

        # 事件生成时间
        event.add("dtstamp", Instant.now().to_stdlib())

        # 事件 UID
        event.add("uid", f"{uuid.uuid4()}@schedule")

        # 上课地点
        location = ""
        if schedule.room:
            location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
        elif schedule.custom_place:
            location = schedule.custom_place

        if location:
            event.add("location", location)

        # 描述
        description_lines = [
            f"授课教师: {schedule.teacher_name or '未知'}",
            f"课程代码: {course.code}",
            f"学分: {course.credits}",
            f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
            f"教学周: 第 {schedule.week_index} 周",
            f"类型: {schedule.lesson_type}",
        ]
        event.add("description", "\n".join(description_lines))

        cal.add_component(event)
    return cal

TeachingWeeks ¤

Bases: RootModel


              flowchart TD
              zzupy.model.eas.TeachingWeeks[TeachingWeeks]

              

              click zzupy.model.eas.TeachingWeeks href "" "zzupy.model.eas.TeachingWeeks"
            

Methods:

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤
    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()
    for teaching_week in self.root:
        for lesson in teaching_week.lessons.values():
            if not lesson:
                continue

            schedule = lesson.schedule
            course = lesson.course

            # 去重
            if schedule in processed_schedule_ids:
                continue
            processed_schedule_ids.add(schedule)

            event = Event()

            # 课程中文名
            event.add("summary", course.name_zh)

            # 起始时间
            start_time = (
                schedule.real_start_time.to_stdlib()
                or schedule.start_time.to_stdlib()
            )
            end_time = (
                schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
            )
            event.add("dtstart", start_time)
            event.add("dtend", end_time)

            # 事件生成时间
            event.add("dtstamp", Instant.now().to_stdlib())

            # 事件 UID
            event.add("uid", f"{uuid.uuid4()}@schedule")

            # 上课地点
            location = ""
            if schedule.room:
                location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
            elif schedule.custom_place:
                location = schedule.custom_place

            if location:
                event.add("location", location)

            # 描述
            description_lines = [
                f"授课教师: {schedule.teacher_name or '未知'}",
                f"课程代码: {course.code}",
                f"学分: {course.credits}",
                f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
                f"教学周: 第 {schedule.week_index} 周",
                f"类型: {schedule.lesson_type}",
            ]
            event.add("description", "\n".join(description_lines))

            cal.add_component(event)

    return cal

Semester ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Semester[Semester]

              

              click zzupy.model.eas.Semester href "" "zzupy.model.eas.Semester"
            

单个学期

SemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.SemesterModel[SemesterModel]

              

              click zzupy.model.eas.SemesterModel href "" "zzupy.model.eas.SemesterModel"
            

获取全部学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: list[Semester]

学期数据列表

CurrentSemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CurrentSemesterModel[CurrentSemesterModel]

              

              click zzupy.model.eas.CurrentSemesterModel href "" "zzupy.model.eas.CurrentSemesterModel"
            

获取当前学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: Semester

学期数据列表

WeekIndexModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.WeekIndexModel[WeekIndexModel]

              

              click zzupy.model.eas.WeekIndexModel href "" "zzupy.model.eas.WeekIndexModel"
            

获取某日期的教学周序数 API 响应根模型

Attributes:

  • code (int) –

    响应结果码

  • message (str | None) –

    响应消息

  • data (InnerData) –

    学期数据列表

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: InnerData

学期数据列表

ecard ¤

Classes:

ECardTemplate ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardTemplate[ECardTemplate]

              

              click zzupy.model.ecard.ECardTemplate href "" "zzupy.model.ecard.ECardTemplate"
            

一卡通模板项

ECardAccountData ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountData[ECardAccountData]

              

              click zzupy.model.ecard.ECardAccountData href "" "zzupy.model.ecard.ECardAccountData"
            

一卡通账户数据

ECardAccountModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountModel[ECardAccountModel]

              

              click zzupy.model.ecard.ECardAccountModel href "" "zzupy.model.ecard.ECardAccountModel"
            

一卡通账户 API 响应根模型

network ¤

Classes:

OnlineDevice ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.OnlineDevice[OnlineDevice]

              

              click zzupy.model.network.OnlineDevice href "" "zzupy.model.network.OnlineDevice"
            

在线设备信息

Methods:

Attributes:

brasid instance-attribute ¤

brasid: str

BRAS ID

down_flow instance-attribute ¤

down_flow: str

下行流量

host_name class-attribute instance-attribute ¤

host_name: str = ''

主机名

ip instance-attribute ¤

ip: str

IP地址

login_time instance-attribute ¤

login_time: str

登录时间,格式为YYYY-MM-DD HH:MM:SS

mac instance-attribute ¤

mac: str

MAC地址

session_id instance-attribute ¤

session_id: str

会话ID

terminal_type instance-attribute ¤

terminal_type: str

终端类型

up_flow instance-attribute ¤

up_flow: str

上行流量

use_time instance-attribute ¤

use_time: str

使用时间(秒)

user_id instance-attribute ¤

user_id: int

用户ID

dump_json ¤

dump_json(indent: Optional[int] = None) -> str

格式化为JSON字符串

Source code in zzupy/model/network.py
85
86
87
def dump_json(self, indent: Optional[int] = None) -> str:
    """格式化为JSON字符串"""
    return json.dumps(self.model_dump(), ensure_ascii=False, indent=indent)

AuthResult ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.AuthResult[AuthResult]

              

              click zzupy.model.network.AuthResult href "" "zzupy.model.network.AuthResult"
            

Portal 认证结果

Attributes:

result instance-attribute ¤

result: int

认证结果

message class-attribute instance-attribute ¤

message: str = Field(..., alias='msg')

Portal 服务器返回信息

PortalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.PortalInfo[PortalInfo]

              

              click zzupy.model.network.PortalInfo href "" "zzupy.model.network.PortalInfo"
            

探测出的 Portal 认证信息

Attributes:

auth_url instance-attribute ¤

auth_url: str

认证网页 URL

portal_server_url instance-attribute ¤

portal_server_url: str

Portal 服务器 URL

user_ip instance-attribute ¤

user_ip: str

客户端 IP

zzupy.model.auth¤

auth ¤

Classes:

PersonalInfoCardModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoCardModel[PersonalInfoCardModel]

              

              click zzupy.model.auth.PersonalInfoCardModel href "" "zzupy.model.auth.PersonalInfoCardModel"
            

‘我的’页中个人信息卡片 API 响应根模型

Attributes:

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: List[InnerData]

卡片数据列表

PersonalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfo[PersonalInfo]

              

              click zzupy.model.auth.PersonalInfo href "" "zzupy.model.auth.PersonalInfo"
            

个人信息模型

Attributes:

uid instance-attribute ¤

uid: str

学号

name instance-attribute ¤

name: str

用户姓名

student_type instance-attribute ¤

student_type: str

学生类型。比如‘本科生’

student_type_id instance-attribute ¤

student_type_id: str

学生类型 ID

college instance-attribute ¤

college: str

学院

college_id instance-attribute ¤

college_id: str

学院 ID

unread_email_count instance-attribute ¤

unread_email_count: int

邮箱未读邮件数

balance instance-attribute ¤

balance: float

一卡通余额

research_count instance-attribute ¤

research_count: int

科研信息数量

PersonalInfoModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel[PersonalInfoModel]

              

              click zzupy.model.auth.PersonalInfoModel href "" "zzupy.model.auth.PersonalInfoModel"
            

个人信息 API 响应根模型

Classes:

Data ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data[Data]

              

              click zzupy.model.auth.PersonalInfoModel.Data href "" "zzupy.model.auth.PersonalInfoModel.Data"
            

Classes:

Attributes:

username instance-attribute ¤

username: str

学号

Attributes ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.auth.PersonalInfoModel.Data.Attributes[Attributes]

              

              click zzupy.model.auth.PersonalInfoModel.Data.Attributes href "" "zzupy.model.auth.PersonalInfoModel.Data.Attributes"
            

Attributes:

organization_id instance-attribute ¤
organization_id: str

学院 ID

organization_name instance-attribute ¤
organization_name: str

学院名

identity_type_name instance-attribute ¤
identity_type_name: str

学生类型

identity_type_id instance-attribute ¤
identity_type_id: str

学生类型 ID

user_name instance-attribute ¤
user_name: str

学生姓名

user_uid instance-attribute ¤
user_uid: str

学号

zzupy.model.eas¤

eas ¤

Classes:

  • Campus –

    校区信息

  • CultivateType –

    培养类型,如主修、辅修等

  • PeriodInfo –

    课时详情,描述一门课程各类学时的分配情况

  • Course –

    课程基本信息

  • OpenDepartment –

    开课院系

  • CourseType –

    课程类型,如必修课、选修课等

  • DateTimeText –

    上课时间的文字描述

  • DateTimePlaceText –

    上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

  • DateTimePlacePersonText –

    上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

  • ScheduleText –

    教学班排课的综合文字描述,聚合了三个层次的描述信息

  • ScheduleGroup –

    排课组,将一个教学班的多次课归入同一组

  • Building –

    楼栋信息

  • Room –

    教室信息

  • Schedule –

    单次课程的具体排课记录

  • Datum –

    教学班信息,包含课程、排课等完整数据

  • LessonModel –

    课程表查询 API 响应根模型

  • Lesson –

    课表中的一节课

  • TeachingWeek –

    教学周课表,7 天 × 10 节的网格

  • TeachingWeeks –
  • Semester –

    单个学期

  • SemesterModel –

    获取全部学期数据 API 响应根模型

  • CurrentSemesterModel –

    获取当前学期数据 API 响应根模型

  • WeekIndexModel –

    获取某日期的教学周序数 API 响应根模型

Campus ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Campus[Campus]

              

              click zzupy.model.eas.Campus href "" "zzupy.model.eas.Campus"
            

校区信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

校区中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

校区英文名

code instance-attribute ¤

code: str

校区编号

CultivateType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CultivateType[CultivateType]

              

              click zzupy.model.eas.CultivateType href "" "zzupy.model.eas.CultivateType"
            

培养类型,如主修、辅修等

Attributes:

name_zh instance-attribute ¤

name_zh: str

培养类型中文名

name_en class-attribute instance-attribute ¤

name_en: str | None = None

培养类型英文名

code instance-attribute ¤

code: str

培养类型编号

PeriodInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.PeriodInfo[PeriodInfo]

              

              click zzupy.model.eas.PeriodInfo href "" "zzupy.model.eas.PeriodInfo"
            

课时详情,描述一门课程各类学时的分配情况

Attributes:

total instance-attribute ¤

total: int

总学时

weeks instance-attribute ¤

weeks: int

开课周数

theory instance-attribute ¤

theory: int | None

理论学时数

theory_unit instance-attribute ¤

theory_unit: str | None

理论学时单位

require_theory instance-attribute ¤

require_theory: int | None

要求完成的理论学时数

experiment instance-attribute ¤

experiment: int | None

实验学时数

experiment_unit instance-attribute ¤

experiment_unit: str | None

实验学时单位

require_experiment instance-attribute ¤

require_experiment: int | None

要求完成的实验学时数

periods_per_week instance-attribute ¤

periods_per_week: int

每周课时数

Course ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Course[Course]

              

              click zzupy.model.eas.Course href "" "zzupy.model.eas.Course"
            

课程基本信息

Attributes:

code instance-attribute ¤

code: str

课程编号

name_zh instance-attribute ¤

name_zh: str

课程中文名

name_en class-attribute instance-attribute ¤

name_en: str | None = None

课程英文名

credits instance-attribute ¤

credits: float

学分

period_info instance-attribute ¤

period_info: PeriodInfo

课时详情

theory instance-attribute ¤

theory: bool

是否含理论课

experiment instance-attribute ¤

experiment: bool

是否含实验课

practice instance-attribute ¤

practice: bool

是否含实践课

test instance-attribute ¤

test: bool

是否含考试课时

machine instance-attribute ¤

machine: bool

是否含上机课

design instance-attribute ¤

design: bool

是否含设计课

extra instance-attribute ¤

extra: bool

是否含其他课时类型

OpenDepartment ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.OpenDepartment[OpenDepartment]

              

              click zzupy.model.eas.OpenDepartment href "" "zzupy.model.eas.OpenDepartment"
            

开课院系

Attributes:

name_zh instance-attribute ¤

name_zh: str

院系中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

院系英文名

code instance-attribute ¤

code: str

院系编号

CourseType ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CourseType[CourseType]

              

              click zzupy.model.eas.CourseType href "" "zzupy.model.eas.CourseType"
            

课程类型,如必修课、选修课等

Attributes:

name_zh instance-attribute ¤

name_zh: str

课程类型中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

课程类型英文名

code instance-attribute ¤

code: str

课程类型编号

DateTimeText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimeText[DateTimeText]

              

              click zzupy.model.eas.DateTimeText href "" "zzupy.model.eas.DateTimeText"
            

上课时间的文字描述

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

DateTimePlaceText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlaceText[DateTimePlaceText]

              

              click zzupy.model.eas.DateTimePlaceText href "" "zzupy.model.eas.DateTimePlaceText"
            

上课时间与地点的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111」

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

DateTimePlacePersonText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.DateTimePlacePersonText[DateTimePlacePersonText]

              

              click zzupy.model.eas.DateTimePlacePersonText href "" "zzupy.model.eas.DateTimePlacePersonText"
            

上课时间、地点与教师的文字描述,如「1~16周 星期四 3~4节 主校区 北3_111 王艳玲」

Attributes:

text_zh instance-attribute ¤

text_zh: str

中文描述

text_en instance-attribute ¤

text_en: str

英文描述

text instance-attribute ¤

text: str

默认显示文本

ScheduleText ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleText[ScheduleText]

              

              click zzupy.model.eas.ScheduleText href "" "zzupy.model.eas.ScheduleText"
            

教学班排课的综合文字描述,聚合了三个层次的描述信息

Attributes:

date_time_text instance-attribute ¤

date_time_text: DateTimeText

仅时间描述

date_time_place_text instance-attribute ¤

date_time_place_text: DateTimePlaceText

时间+地点描述

date_time_place_person_text instance-attribute ¤

date_time_place_person_text: DateTimePlacePersonText

时间+地点+教师描述

ScheduleGroup ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.ScheduleGroup[ScheduleGroup]

              

              click zzupy.model.eas.ScheduleGroup href "" "zzupy.model.eas.ScheduleGroup"
            

排课组,将一个教学班的多次课归入同一组

Attributes:

id instance-attribute ¤

id: int

排课组 ID

lesson_id instance-attribute ¤

lesson_id: int

所属教学班 ID

no instance-attribute ¤

no: int

排课组序号

Building ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Building[Building]

              

              click zzupy.model.eas.Building href "" "zzupy.model.eas.Building"
            

楼栋信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

楼栋中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

楼栋英文名

code instance-attribute ¤

code: str

楼栋编号

Room ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Room[Room]

              

              click zzupy.model.eas.Room href "" "zzupy.model.eas.Room"
            

教室信息

Attributes:

name_zh instance-attribute ¤

name_zh: str

教室中文名

name_en class-attribute instance-attribute ¤

name_en: None | str = None

教室英文名

building instance-attribute ¤

building: Building

所在楼栋

campus instance-attribute ¤

campus: Campus

所在校区

seat_number instance-attribute ¤

seat_number: None

座位数

Schedule ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Schedule[Schedule]

              

              click zzupy.model.eas.Schedule href "" "zzupy.model.eas.Schedule"
            

单次课程的具体排课记录

Attributes:

schedule_group_id instance-attribute ¤

schedule_group_id: int

所属排课组 ID,关联 ScheduleGroup.id

date instance-attribute ¤

date: Date

上课日期,格式 "YYYY-MM-DD"

original_date class-attribute instance-attribute ¤

original_date: None | str = None

原始日期

weekday instance-attribute ¤

weekday: int

星期几,1=周一,4=周四,7=周日

start_time instance-attribute ¤

start_time: ZonedDateTime

开始时间,格式 HHMM,如 1010 表示 10:10

end_time instance-attribute ¤

end_time: ZonedDateTime

结束时间,格式 HHMM,如 1150 表示 11:50

teacher_name instance-attribute ¤

teacher_name: str

授课教师中文姓名

teacher_name_en instance-attribute ¤

teacher_name_en: str | None

授课教师英文姓名

custom_place class-attribute instance-attribute ¤

custom_place: None | str = None

自定义上课地点

room instance-attribute ¤

room: Room | None

教室信息

start_unit instance-attribute ¤

start_unit: int

开始节次,如 3(第3节)

end_unit instance-attribute ¤

end_unit: int

结束节次,如 4(第4节)

state instance-attribute ¤

state: str

课程状态

week_index instance-attribute ¤

week_index: int

本次课所在教学周,如 1 表示第1周

lesson_type instance-attribute ¤

lesson_type: str

课时类型,如 "THEORY"(理论课)

periods instance-attribute ¤

periods: int

本次课课时数,如 2

real_start_time instance-attribute ¤

real_start_time: ZonedDateTime

实际开始时间,格式同 startTime

real_end_time instance-attribute ¤

real_end_time: ZonedDateTime

实际结束时间,格式同 endTime

Datum ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Datum[Datum]

              

              click zzupy.model.eas.Datum href "" "zzupy.model.eas.Datum"
            

教学班信息,包含课程、排课等完整数据

Attributes:

id instance-attribute ¤

id: int

教学班 ID

biz_type_id instance-attribute ¤

biz_type_id: int

业务类型 ID

campus instance-attribute ¤

campus: Campus

开课校区

cultivate_type instance-attribute ¤

cultivate_type: CultivateType

培养类型

code instance-attribute ¤

code: str

教学班编号

course instance-attribute ¤

course: Course

课程基本信息

remark class-attribute instance-attribute ¤

remark: None | str = None

备注

schedule_state instance-attribute ¤

schedule_state: str

排课状态

std_count instance-attribute ¤

std_count: int

选课学生人数

open_department instance-attribute ¤

open_department: OpenDepartment

开课院系

course_type instance-attribute ¤

course_type: CourseType

课程类型

teacher_assignment_list instance-attribute ¤

teacher_assignment_list: list[str]

授课教师中文姓名列表

teacher_assignment_en_list instance-attribute ¤

teacher_assignment_en_list: list[str | None]

授课教师英文姓名列表

schedule_text instance-attribute ¤

schedule_text: ScheduleText

排课文字描述(时间/地点/教师)

schedule_groups instance-attribute ¤

schedule_groups: list[ScheduleGroup]

排课组列表

schedules instance-attribute ¤

schedules: list[Schedule]

每次课的具体排课记录列表

students instance-attribute ¤

students: list[Any]

学生列表

time_table_layout_assoc instance-attribute ¤

time_table_layout_assoc: int

关联的课表布局 ID

suggest_schedule_weeks_info instance-attribute ¤

suggest_schedule_weeks_info: None

建议排课周信息

LessonModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.LessonModel[LessonModel]

              

              click zzupy.model.eas.LessonModel href "" "zzupy.model.eas.LessonModel"
            

课程表查询 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message class-attribute instance-attribute ¤

message: None | str = None

响应消息

data instance-attribute ¤

data: list[Datum]

教学班数据列表

Lesson ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Lesson[Lesson]

              

              click zzupy.model.eas.Lesson href "" "zzupy.model.eas.Lesson"
            

课表中的一节课

Attributes:

course instance-attribute ¤

course: Course

对应课程

schedule instance-attribute ¤

schedule: Schedule

对应时间

TeachingWeek ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.TeachingWeek[TeachingWeek]

              

              click zzupy.model.eas.TeachingWeek href "" "zzupy.model.eas.TeachingWeek"
            

教学周课表,7 天 × 10 节的网格

Methods:

  • set –

    设置某天某节的课程

  • get –

    获取某天某节的课程

  • get_day –

    获取某天的全部课程

  • get_unit –

    获取某节 7 天的课程

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

Attributes:

lessons class-attribute instance-attribute ¤

lessons: dict[tuple[int, int], Lesson] = Field(default_factory=dict)

内部存储:仅存储非空课程,key 为 (weekday, unit),value 为 Lesson

grid property ¤

grid: list[list[Lesson | None]]

网格形式的课表,仅在访问时动态生成

set ¤

set(weekday: int, unit: int, lesson: Lesson) -> None

设置某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

  • lesson ¤

    (Lesson) –

    课程

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
449
450
451
452
453
454
455
456
457
458
459
460
461
def set(self, weekday: int, unit: int, lesson: Lesson) -> None:
    """设置某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节
        lesson: 课程

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    self.lessons[(weekday, unit)] = lesson

get ¤

get(weekday: int, unit: int) -> Lesson | None

获取某天某节的课程

Parameters:

  • weekday ¤

    (int) –

    第几天

  • unit ¤

    (int) –

    第几节

Returns:

  • Lesson | None –

    Lesson | None: 对应课程或 None

Raises:

  • IndexError –

    如果 {weekday} 或 {unit} 超出范围

Source code in zzupy/model/eas.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def get(self, weekday: int, unit: int) -> Lesson | None:
    """获取某天某节的课程

    Args:
        weekday: 第几天
        unit: 第几节

    Returns:
        Lesson | None: 对应课程或 None

    Raises:
        IndexError: 如果 {weekday} 或 {unit} 超出范围
    """
    self._validate_index(weekday, unit)
    return self.lessons.get((weekday, unit))

get_day ¤

get_day(weekday: int) -> list[Lesson | None]

获取某天的全部课程

Parameters:

  • weekday ¤

    (int) –

    第几天

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def get_day(self, weekday: int) -> list[Lesson | None]:
    """获取某天的全部课程

    Args:
        weekday: 第几天

    Returns:
        list[Lesson | None]: 由第 {weekday} 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {weekday} 超出范围
    """
    self._validate_index(weekday, 1)
    return [self.lessons.get((weekday, unit)) for unit in range(1, self.UNITS + 1)]

get_unit ¤

get_unit(unit: int) -> list[Lesson | None]

获取某节 7 天的课程

Parameters:

  • unit ¤

    (int) –

    第几节课

Returns:

  • list[Lesson | None] –

    list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

Raises:

Source code in zzupy/model/eas.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def get_unit(self, unit: int) -> list[Lesson | None]:
    """获取某节 7 天的课程

    Args:
        unit: 第几节课

    Returns:
        list[Lesson | None]: 由 7 天中的第 {unit} 节课组成的列表

    Raises:
        IndexError: 如果 {unit} 超出范围
    """
    self._validate_index(1, unit)
    return [self.lessons.get((day, unit)) for day in range(1, self.DAYS + 1)]

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()

    for lesson in self.lessons.values():
        if not lesson:
            continue

        schedule = lesson.schedule
        course = lesson.course

        # 去重
        if schedule in processed_schedule_ids:
            continue
        processed_schedule_ids.add(schedule)

        event = Event()

        # 课程中文名
        event.add("summary", course.name_zh)

        # 起始时间
        start_time = (
            schedule.real_start_time.to_stdlib() or schedule.start_time.to_stdlib()
        )
        end_time = (
            schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
        )
        event.add("dtstart", start_time)
        event.add("dtend", end_time)

        # 事件生成时间
        event.add("dtstamp", Instant.now().to_stdlib())

        # 事件 UID
        event.add("uid", f"{uuid.uuid4()}@schedule")

        # 上课地点
        location = ""
        if schedule.room:
            location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
        elif schedule.custom_place:
            location = schedule.custom_place

        if location:
            event.add("location", location)

        # 描述
        description_lines = [
            f"授课教师: {schedule.teacher_name or '未知'}",
            f"课程代码: {course.code}",
            f"学分: {course.credits}",
            f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
            f"教学周: 第 {schedule.week_index} 周",
            f"类型: {schedule.lesson_type}",
        ]
        event.add("description", "\n".join(description_lines))

        cal.add_component(event)
    return cal

TeachingWeeks ¤

Bases: RootModel


              flowchart TD
              zzupy.model.eas.TeachingWeeks[TeachingWeeks]

              

              click zzupy.model.eas.TeachingWeeks href "" "zzupy.model.eas.TeachingWeeks"
            

Methods:

  • to_calendar –

    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。

to_calendar ¤

to_calendar(prodid: str = '-//ZZU.Py//Teaching Schedule Calendar//CN') -> Calendar

将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。 可以使用以下代码将其写入 .ics 或对它做你想做的任何事

with open('my_schedule.ics', 'wb') as f:
    f.write(aTeachingWeek.to_calendar().to_ical())

Parameters:

  • prodid ¤

    (str, default: '-//ZZU.Py//Teaching Schedule Calendar//CN' ) –

    Calendar 的 prodid 参数。

Returns:

  • Calendar –

    Calendar 对象。

Source code in zzupy/model/eas.py
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def to_calendar(
    self, prodid: str = "-//ZZU.Py//Teaching Schedule Calendar//CN"
) -> Calendar:
    """
    将教学周的课表转换为符合 RFC 5545 的 Calendar 对象。
    可以使用以下代码将其写入 .ics 或对它做你想做的任何事
    ```python
    with open('my_schedule.ics', 'wb') as f:
        f.write(aTeachingWeek.to_calendar().to_ical())
    ```

    Args:
        prodid: Calendar 的 prodid 参数。

    Returns:
        Calendar 对象。
    """
    cal = Calendar()
    cal.add("prodid", prodid)
    cal.add("version", "2.0")

    processed_schedule_ids = set()
    for teaching_week in self.root:
        for lesson in teaching_week.lessons.values():
            if not lesson:
                continue

            schedule = lesson.schedule
            course = lesson.course

            # 去重
            if schedule in processed_schedule_ids:
                continue
            processed_schedule_ids.add(schedule)

            event = Event()

            # 课程中文名
            event.add("summary", course.name_zh)

            # 起始时间
            start_time = (
                schedule.real_start_time.to_stdlib()
                or schedule.start_time.to_stdlib()
            )
            end_time = (
                schedule.real_end_time.to_stdlib() or schedule.end_time.to_stdlib()
            )
            event.add("dtstart", start_time)
            event.add("dtend", end_time)

            # 事件生成时间
            event.add("dtstamp", Instant.now().to_stdlib())

            # 事件 UID
            event.add("uid", f"{uuid.uuid4()}@schedule")

            # 上课地点
            location = ""
            if schedule.room:
                location = f"{schedule.room.campus.name_zh} {schedule.room.building.name_zh} {schedule.room.name_zh}"
            elif schedule.custom_place:
                location = schedule.custom_place

            if location:
                event.add("location", location)

            # 描述
            description_lines = [
                f"授课教师: {schedule.teacher_name or '未知'}",
                f"课程代码: {course.code}",
                f"学分: {course.credits}",
                f"节次: 第 {schedule.start_unit} - {schedule.end_unit} 节",
                f"教学周: 第 {schedule.week_index} 周",
                f"类型: {schedule.lesson_type}",
            ]
            event.add("description", "\n".join(description_lines))

            cal.add_component(event)

    return cal

Semester ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.Semester[Semester]

              

              click zzupy.model.eas.Semester href "" "zzupy.model.eas.Semester"
            

单个学期

SemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.SemesterModel[SemesterModel]

              

              click zzupy.model.eas.SemesterModel href "" "zzupy.model.eas.SemesterModel"
            

获取全部学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: list[Semester]

学期数据列表

CurrentSemesterModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.CurrentSemesterModel[CurrentSemesterModel]

              

              click zzupy.model.eas.CurrentSemesterModel href "" "zzupy.model.eas.CurrentSemesterModel"
            

获取当前学期数据 API 响应根模型

Attributes:

result instance-attribute ¤

result: int

响应结果码

message instance-attribute ¤

message: None

响应消息

data instance-attribute ¤

data: Semester

学期数据列表

WeekIndexModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.eas.WeekIndexModel[WeekIndexModel]

              

              click zzupy.model.eas.WeekIndexModel href "" "zzupy.model.eas.WeekIndexModel"
            

获取某日期的教学周序数 API 响应根模型

Attributes:

  • code (int) –

    响应结果码

  • message (str | None) –

    响应消息

  • data (InnerData) –

    学期数据列表

code instance-attribute ¤

code: int

响应结果码

message instance-attribute ¤

message: str | None

响应消息

data instance-attribute ¤

data: InnerData

学期数据列表

zzupy.model.ecard¤

ecard ¤

Classes:

ECardTemplate ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardTemplate[ECardTemplate]

              

              click zzupy.model.ecard.ECardTemplate href "" "zzupy.model.ecard.ECardTemplate"
            

一卡通模板项

ECardAccountData ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountData[ECardAccountData]

              

              click zzupy.model.ecard.ECardAccountData href "" "zzupy.model.ecard.ECardAccountData"
            

一卡通账户数据

ECardAccountModel ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.ecard.ECardAccountModel[ECardAccountModel]

              

              click zzupy.model.ecard.ECardAccountModel href "" "zzupy.model.ecard.ECardAccountModel"
            

一卡通账户 API 响应根模型

zzupy.model.network¤

network ¤

Classes:

Functions:

OnlineDevice ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.OnlineDevice[OnlineDevice]

              

              click zzupy.model.network.OnlineDevice href "" "zzupy.model.network.OnlineDevice"
            

在线设备信息

Methods:

Attributes:

brasid instance-attribute ¤

brasid: str

BRAS ID

down_flow instance-attribute ¤

down_flow: str

下行流量

host_name class-attribute instance-attribute ¤

host_name: str = ''

主机名

ip instance-attribute ¤

ip: str

IP地址

login_time instance-attribute ¤

login_time: str

登录时间,格式为YYYY-MM-DD HH:MM:SS

mac instance-attribute ¤

mac: str

MAC地址

session_id instance-attribute ¤

session_id: str

会话ID

terminal_type instance-attribute ¤

terminal_type: str

终端类型

up_flow instance-attribute ¤

up_flow: str

上行流量

use_time instance-attribute ¤

use_time: str

使用时间(秒)

user_id instance-attribute ¤

user_id: int

用户ID

dump_json ¤

dump_json(indent: Optional[int] = None) -> str

格式化为JSON字符串

Source code in zzupy/model/network.py
85
86
87
def dump_json(self, indent: Optional[int] = None) -> str:
    """格式化为JSON字符串"""
    return json.dumps(self.model_dump(), ensure_ascii=False, indent=indent)

AuthResult ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.AuthResult[AuthResult]

              

              click zzupy.model.network.AuthResult href "" "zzupy.model.network.AuthResult"
            

Portal 认证结果

Attributes:

result instance-attribute ¤

result: int

认证结果

message class-attribute instance-attribute ¤

message: str = Field(..., alias='msg')

Portal 服务器返回信息

PortalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.model.network.PortalInfo[PortalInfo]

              

              click zzupy.model.network.PortalInfo href "" "zzupy.model.network.PortalInfo"
            

探测出的 Portal 认证信息

Attributes:

auth_url instance-attribute ¤

auth_url: str

认证网页 URL

portal_server_url instance-attribute ¤

portal_server_url: str

Portal 服务器 URL

user_ip instance-attribute ¤

user_ip: str

客户端 IP

_warn_deprecated_alias ¤

_warn_deprecated_alias(old_name: str, new_name: str) -> None
Source code in zzupy/model/network.py
 9
10
11
12
13
14
def _warn_deprecated_alias(old_name: str, new_name: str) -> None:
    warnings.warn(
        f"{old_name} 已弃用,请使用 {new_name}",
        DeprecationWarning,
        stacklevel=3,
    )

zzupy.utils¤

utils ¤

工具函数库

Classes:

Functions:

Attributes:

_MAX_JWT_LENGTH module-attribute ¤

_MAX_JWT_LENGTH = 16384

NotLoggedInError ¤

NotLoggedInError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ClientStateError


              flowchart TD
              zzupy.utils.NotLoggedInError[NotLoggedInError]
              zzupy.exception.ClientStateError[ClientStateError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ClientStateError --> zzupy.utils.NotLoggedInError
                                zzupy.exception.ZZUError --> zzupy.exception.ClientStateError
                



              click zzupy.utils.NotLoggedInError href "" "zzupy.utils.NotLoggedInError"
              click zzupy.exception.ClientStateError href "" "zzupy.exception.ClientStateError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

在未登录状态下调用了需要登录的方法。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

_FirstHtmlAttributeParser ¤

_FirstHtmlAttributeParser(tag: str, attr: str, match_attrs: dict[str, str] | None = None)

Bases: HTMLParser


              flowchart TD
              zzupy.utils._FirstHtmlAttributeParser[_FirstHtmlAttributeParser]

              

              click zzupy.utils._FirstHtmlAttributeParser href "" "zzupy.utils._FirstHtmlAttributeParser"
            
Source code in zzupy/utils.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def __init__(
    self,
    tag: str,
    attr: str,
    match_attrs: dict[str, str] | None = None,
) -> None:
    super().__init__(convert_charrefs=True)
    self._tag = tag.lower()
    self._attr = attr.lower()
    self._match_attrs = {
        key.lower(): value for key, value in (match_attrs or {}).items()
    }
    self._found = False
    self.value: str | None = None

XorCipher ¤

XorCipher(key_string: str = '')

一个使用异或 (XOR) 算法进行简单加密和解密的类。

Methods:

  • encrypt –

    将明文与实例密钥进行异或运算,并转为十六进制字符串。

  • decrypt –

    将十六进制字符串解密回原始密码。

Source code in zzupy/utils.py
223
224
def __init__(self, key_string: str = ""):
    self._key: int = self._generate_key(key_string)

encrypt ¤

encrypt(string: str) -> str

将明文与实例密钥进行异或运算,并转为十六进制字符串。

Parameters:

  • string ¤

    (str) –

    明文

Source code in zzupy/utils.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def encrypt(self, string: str) -> str:
    """将明文与实例密钥进行异或运算,并转为十六进制字符串。

    Args:
        string: 明文
    """
    if len(string) > 512:
        return "-1"

    encrypted_output = []
    for char in string:
        ch = ord(char) ^ self._key
        hex_str = format(ch, "02x")
        encrypted_output.append(hex_str)

    return "".join(encrypted_output)

decrypt ¤

decrypt(hex_string: str) -> str

将十六进制字符串解密回原始密码。

Parameters:

  • hex_string ¤

    (str) –

    十六进制字符串

Raises:

  • ValueError –

    如果十六进制字符串格式错误

Source code in zzupy/utils.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def decrypt(self, hex_string: str) -> str:
    """将十六进制字符串解密回原始密码。

    Args:
        hex_string: 十六进制字符串

    Raises:
        ValueError: 如果十六进制字符串格式错误
    """
    if len(hex_string) % 2 != 0:
        raise ValueError("十六进制字符串长度必须为偶数")

    original_password = []
    for i in range(0, len(hex_string), 2):
        hex_pair = hex_string[i : i + 2]
        decimal_value = int(hex_pair, 16)
        # 与实例密钥进行异或
        original_char = chr(decimal_value ^ self._key)
        original_password.append(original_char)

    return "".join(original_password)

JsonPParser ¤

JsonPParser(text: str)

JsonP 格式数据解析器

Source code in zzupy/utils.py
283
284
285
286
287
def __init__(self, text: str):
    self.text = text
    self._callback: str | None = None
    self._data: str | None = None
    self._parse()

_decode_jwt_segment ¤

_decode_jwt_segment(segment: str) -> bytes

解码经过 Base64URL 编码的 JWT 片段。

Source code in zzupy/utils.py
22
23
24
25
26
27
28
def _decode_jwt_segment(segment: str) -> bytes:
    """解码经过 Base64URL 编码的 JWT 片段。"""
    padding = "=" * (-len(segment) % 4)
    try:
        return base64.b64decode(f"{segment}{padding}", altchars=b"-_", validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("JWT 包含无效的 Base64URL 片段") from exc

decode_jwt_payload ¤

decode_jwt_payload(token: str) -> dict[str, Any]

在不验证签名的情况下解析 JWT payload。

该函数只用于读取 CAS Token 的过期时间,不应作为 Token 身份验证手段。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

Raises:

  • ValueError –

    如果 Token 过长、结构无效或 JSON 内容不正确。

Source code in zzupy/utils.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def decode_jwt_payload(token: str) -> dict[str, Any]:
    """在不验证签名的情况下解析 JWT payload。

    该函数只用于读取 CAS Token 的过期时间,不应作为 Token 身份验证手段。

    Args:
        token: JWT 字符串。

    Returns:
        JWT payload。

    Raises:
        ValueError: 如果 Token 过长、结构无效或 JSON 内容不正确。
    """
    if not token or len(token) > _MAX_JWT_LENGTH:
        raise ValueError("JWT 为空或超过长度限制")

    segments = token.split(".")
    if len(segments) != 3 or any(not segment for segment in segments):
        raise ValueError("JWT 结构无效")

    try:
        header = json.loads(_decode_jwt_segment(segments[0]))
        payload = json.loads(_decode_jwt_segment(segments[1]))
        _decode_jwt_segment(segments[2])
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise ValueError("JWT 包含无效的 JSON") from exc

    if not isinstance(header, dict) or not isinstance(payload, dict):
        raise ValueError("JWT header 和 payload 必须为 JSON 对象")
    return payload

get_jwt_expiration ¤

get_jwt_expiration(token: str) -> datetime

读取 JWT 的过期时间。

Parameters:

  • token ¤

    (str) –

    JWT 字符串。

Returns:

  • datetime –

    JWT exp 对应的本地时间。

Raises:

  • ValueError –

    如果 Token 无效或缺少有效的 exp。

Source code in zzupy/utils.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def get_jwt_expiration(token: str) -> datetime:
    """读取 JWT 的过期时间。

    Args:
        token: JWT 字符串。

    Returns:
        JWT `exp` 对应的本地时间。

    Raises:
        ValueError: 如果 Token 无效或缺少有效的 `exp`。
    """
    payload = decode_jwt_payload(token)
    try:
        return datetime.fromtimestamp(float(payload["exp"]))
    except (KeyError, TypeError, ValueError, OverflowError, OSError) as exc:
        raise ValueError("JWT 缺少有效的 exp") from exc

extract_first_html_attr ¤

extract_first_html_attr(html_content: str, tag: str, attr: str, match_attrs: dict[str, str] | None = None) -> str | None

提取第一个匹配 HTML 标签的属性值。

Source code in zzupy/utils.py
111
112
113
114
115
116
117
118
119
120
121
def extract_first_html_attr(
    html_content: str,
    tag: str,
    attr: str,
    match_attrs: dict[str, str] | None = None,
) -> str | None:
    """提取第一个匹配 HTML 标签的属性值。"""
    parser = _FirstHtmlAttributeParser(tag, attr, match_attrs)
    parser.feed(html_content)
    parser.close()
    return parser.value

get_sign ¤

get_sign(dynamic_secret: str, params: str) -> str

获取sign值

Parameters:

  • dynamic_secret ¤

    (str) –

    login 后自动获取,来自 login-token 请求

  • params ¤

    (str) –

    URL 请求参数

Returns:

  • str ( str ) –

    sign 值

Source code in zzupy/utils.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def get_sign(dynamic_secret: str, params: str) -> str:
    """获取sign值

    Args:
        dynamic_secret (str): login 后自动获取,来自 login-token 请求
        params (str): URL 请求参数

    Returns:
        str: sign 值
    """
    parsed_params: Dict[str, str] = {k: v[0] for k, v in parse_qs(params).items()}

    timestamp = parsed_params.pop("timestamp", "")
    random = parsed_params.pop("random", "")

    sorted_values = [v for k, v in sorted(parsed_params.items())]

    parts_to_sign = [dynamic_secret] + sorted_values + [timestamp, random]
    original_string = "|".join(parts_to_sign)

    sign = hashlib.md5(original_string.encode("utf-8")).hexdigest().upper()
    return sign

pkcs7_unpad ¤

pkcs7_unpad(padded_data: bytes, block_size: int) -> bytes

去除数据中的PKCS#7填充。

Parameters:

  • padded_data ¤

    (bytes) –

    带填充的数据

  • block_size ¤

    (int) –

    用于填充的块大小

Returns:

  • bytes ( bytes ) –

    去除填充后的数据

Raises:

Source code in zzupy/utils.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def pkcs7_unpad(padded_data: bytes, block_size: int) -> bytes:
    """去除数据中的PKCS#7填充。

    Args:
        padded_data (bytes): 带填充的数据
        block_size (int): 用于填充的块大小

    Returns:
        bytes: 去除填充后的数据

    Raises:
        ValueError: 如果填充无效
    """
    if not padded_data or len(padded_data) % block_size != 0:
        raise ValueError("无效的填充数据长度")

    # 从最后一个字节获取填充长度
    padding_len = padded_data[-1]

    # 检查填充长度是否有效
    if padding_len > block_size or padding_len == 0:
        raise ValueError("无效的填充长度")

    # 检查所有填充字节是否正确
    for i in range(1, padding_len + 1):
        if padded_data[-i] != padding_len:
            raise ValueError("无效的填充")

    # 返回去除填充后的数据
    return padded_data[:-padding_len]

sm4_decrypt_ecb ¤

sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str

SM4 解密,ECB模式

Parameters:

Returns:

  • str –

    明文 Hex

Source code in zzupy/utils.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def sm4_decrypt_ecb(ciphertext: bytes, key: bytes) -> str:
    """SM4 解密,ECB模式

    Args:
        ciphertext (bytes): 密文
        key (bytes): 密钥

    Returns:
        明文 Hex
    """
    sm4 = gmalg.SM4(key)
    block_size = 16
    decrypted_padded = b""
    for i in range(0, len(ciphertext), block_size):
        block = ciphertext[i : i + block_size]
        decrypted_padded += sm4.decrypt(block)
    decrypted = pkcs7_unpad(decrypted_padded, block_size)
    return decrypted.decode()

get_local_ip ¤

get_local_ip(target: str = '8.8.8.8') -> str | None

获取用于连接到特定目标IP的本地IP地址。

Parameters:

  • target ¤

    (str, default: '8.8.8.8' ) –

    目标主机名或IP地址。默认为 '8.8.8.8'。

Returns:

  • str ( str | None ) –

    用于到达目标的本地IP地址

  • None ( str | None ) –

    如果发生网络错误

Source code in zzupy/utils.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_local_ip(target: str = "8.8.8.8") -> str | None:
    """
    获取用于连接到特定目标IP的本地IP地址。

    Args:
        target: 目标主机名或IP地址。默认为 '8.8.8.8'。

    Returns:
        str: 用于到达目标的本地IP地址
        None: 如果发生网络错误
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect((target, 80))
            local_ip = s.getsockname()[0]
            return local_ip
    except socket.error:
        return None

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

zzupy.web¤

web ¤

Web API 客户端模块

Modules:

Classes:

Functions:

Attributes:

__all__ module-attribute ¤

__all__ = ['EPortalClient', 'SelfServiceSystem', 'discover_portal_info']

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.HTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.HTTPTransport()

    else:
        transport = httpx2.HTTPTransport(local_address=self._bind_address)
    self._client = httpx2.Client(
        transport=transport,
    )

portal_auth ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/web/network.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • isp_suffix ¤

    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/web/network.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/web/network.py
292
293
294
def __init__(self, base_url: str):
    self._client = httpx2.Client(base_url=base_url)
    self._logged_in = False

login ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Raises:

Source code in zzupy/web/network.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/web/network.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@require_auth
def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device ¤

kick_device(session_id: str) -> None

将设备踢下线

Parameters:

  • session_id ¤

    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
@require_auth
def kick_device(self, session_id: str) -> None:
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout ¤

logout() -> None

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@require_auth
def logout(self) -> None:
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    def _get_portal_server_url(client: httpx2.Client, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        with httpx2.Client(timeout=10.0) as client:
            response = client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

network ¤

校园网模块

Classes:

Functions:

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.HTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.HTTPTransport()

    else:
        transport = httpx2.HTTPTransport(local_address=self._bind_address)
    self._client = httpx2.Client(
        transport=transport,
    )

portal_auth ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/web/network.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤
    (str) –

    账户

  • password ¤
    (str) –

    密码

  • isp_suffix ¤
    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤
    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/web/network.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/web/network.py
292
293
294
def __init__(self, base_url: str):
    self._client = httpx2.Client(base_url=base_url)
    self._logged_in = False

login ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤
    (str) –

    账号

  • password ¤
    (str) –

    密码

Raises:

Source code in zzupy/web/network.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/web/network.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@require_auth
def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device ¤

kick_device(session_id: str) -> None

将设备踢下线

Parameters:

  • session_id ¤
    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
@require_auth
def kick_device(self, session_id: str) -> None:
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout ¤

logout() -> None

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@require_auth
def logout(self) -> None:
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

discover_portal_info ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    def _get_portal_server_url(client: httpx2.Client, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        with httpx2.Client(timeout=10.0) as client:
            response = client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc

zzupy.web.network¤

network ¤

校园网模块

Classes:

Functions:

Attributes:

logger module-attribute ¤

logger = _logger

LoginError ¤

LoginError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.web.network.LoginError[LoginError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.web.network.LoginError
                


              click zzupy.web.network.LoginError href "" "zzupy.web.network.LoginError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

登录失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

NetworkError ¤

NetworkError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.web.network.NetworkError[NetworkError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.web.network.NetworkError
                


              click zzupy.web.network.NetworkError href "" "zzupy.web.network.NetworkError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

网络请求失败或网络环境异常。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

OperationError ¤

OperationError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.web.network.OperationError[OperationError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.web.network.OperationError
                


              click zzupy.web.network.OperationError href "" "zzupy.web.network.OperationError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

服务端接受请求但业务处理失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ParsingError ¤

ParsingError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: ZZUError


              flowchart TD
              zzupy.web.network.ParsingError[ParsingError]
              zzupy.exception.ZZUError[ZZUError]

                              zzupy.exception.ZZUError --> zzupy.web.network.ParsingError
                


              click zzupy.web.network.ParsingError href "" "zzupy.web.network.ParsingError"
              click zzupy.exception.ZZUError href "" "zzupy.exception.ZZUError"
            

响应解析或数据校验失败。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

ZZUError ¤

ZZUError(message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None)

Bases: Exception


              flowchart TD
              zzupy.web.network.ZZUError[ZZUError]

              

              click zzupy.web.network.ZZUError href "" "zzupy.web.network.ZZUError"
            

项目异常基类。

除了人类可读的错误消息外,还会保留额外上下文,便于调用方记录日志、 序列化或调试。

Parameters:

  • message ¤

    (str | None, default: None ) –

    错误消息。

  • context ¤

    (Mapping[str, Any] | None, default: None ) –

    结构化上下文信息。

  • error_code ¤

    (str | None, default: None ) –

    可选错误代码。

Methods:

  • to_dict –

    将异常转换为便于日志记录或序列化的字典。

  • from_exception –

    基于底层异常构造项目异常。

  • from_http_status –

    基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(
    self,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> None:
    self.message = message or self.default_message
    self.context = dict(context or {})
    self.error_code = error_code
    super().__init__(self.message)

to_dict ¤

to_dict() -> dict[str, Any]

将异常转换为便于日志记录或序列化的字典。

Source code in zzupy/exception.py
45
46
47
48
49
50
51
52
53
54
55
56
def to_dict(self) -> dict[str, Any]:
    """将异常转换为便于日志记录或序列化的字典。"""
    data = {
        "type": self.__class__.__name__,
        "message": self.message,
        "context": self.context,
    }
    if self.error_code is not None:
        data["error_code"] = self.error_code
    if self.__cause__ is not None:
        data["cause"] = repr(self.__cause__)
    return data

from_exception classmethod ¤

from_exception(exc: Exception, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于底层异常构造项目异常。

Source code in zzupy/exception.py
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_exception(
    cls,
    exc: Exception,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于底层异常构造项目异常。"""
    merged_context = dict(context or {})
    merged_context.setdefault("cause_type", type(exc).__name__)
    return cls(message, context=merged_context, error_code=error_code)

from_http_status classmethod ¤

from_http_status(exc: HTTPStatusError, message: str | None = None, *, context: Mapping[str, Any] | None = None, error_code: str | None = None) -> Self

基于 HTTP 状态错误构造项目异常。

Source code in zzupy/exception.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def from_http_status(
    cls,
    exc: httpx2.HTTPStatusError,
    message: str | None = None,
    *,
    context: Mapping[str, Any] | None = None,
    error_code: str | None = None,
) -> Self:
    """基于 HTTP 状态错误构造项目异常。"""
    request = exc.request
    response = exc.response
    merged_context = dict(context or {})
    merged_context.setdefault("method", request.method)
    merged_context.setdefault("url", str(request.url))
    merged_context.setdefault("status_code", response.status_code)
    detail = message or cls.default_message
    return cls(
        f"{detail} {response.status_code}",
        context=merged_context,
        error_code=error_code,
    )

AuthResult ¤

Bases: BaseModel


              flowchart TD
              zzupy.web.network.AuthResult[AuthResult]

              

              click zzupy.web.network.AuthResult href "" "zzupy.web.network.AuthResult"
            

Portal 认证结果

Attributes:

result instance-attribute ¤

result: int

认证结果

message class-attribute instance-attribute ¤

message: str = Field(..., alias='msg')

Portal 服务器返回信息

OnlineDevice ¤

Bases: BaseModel


              flowchart TD
              zzupy.web.network.OnlineDevice[OnlineDevice]

              

              click zzupy.web.network.OnlineDevice href "" "zzupy.web.network.OnlineDevice"
            

在线设备信息

Methods:

Attributes:

brasid instance-attribute ¤

brasid: str

BRAS ID

down_flow instance-attribute ¤

down_flow: str

下行流量

host_name class-attribute instance-attribute ¤

host_name: str = ''

主机名

ip instance-attribute ¤

ip: str

IP地址

login_time instance-attribute ¤

login_time: str

登录时间,格式为YYYY-MM-DD HH:MM:SS

mac instance-attribute ¤

mac: str

MAC地址

session_id instance-attribute ¤

session_id: str

会话ID

terminal_type instance-attribute ¤

terminal_type: str

终端类型

up_flow instance-attribute ¤

up_flow: str

上行流量

use_time instance-attribute ¤

use_time: str

使用时间(秒)

user_id instance-attribute ¤

user_id: int

用户ID

dump_json ¤

dump_json(indent: Optional[int] = None) -> str

格式化为JSON字符串

Source code in zzupy/model/network.py
85
86
87
def dump_json(self, indent: Optional[int] = None) -> str:
    """格式化为JSON字符串"""
    return json.dumps(self.model_dump(), ensure_ascii=False, indent=indent)

PortalInfo ¤

Bases: BaseModel


              flowchart TD
              zzupy.web.network.PortalInfo[PortalInfo]

              

              click zzupy.web.network.PortalInfo href "" "zzupy.web.network.PortalInfo"
            

探测出的 Portal 认证信息

Attributes:

auth_url instance-attribute ¤

auth_url: str

认证网页 URL

portal_server_url instance-attribute ¤

portal_server_url: str

Portal 服务器 URL

user_ip instance-attribute ¤

user_ip: str

客户端 IP

JsonPParser ¤

JsonPParser(text: str)

JsonP 格式数据解析器

Source code in zzupy/utils.py
283
284
285
286
287
def __init__(self, text: str):
    self.text = text
    self._callback: str | None = None
    self._data: str | None = None
    self._parse()

XorCipher ¤

XorCipher(key_string: str = '')

一个使用异或 (XOR) 算法进行简单加密和解密的类。

Methods:

  • encrypt –

    将明文与实例密钥进行异或运算,并转为十六进制字符串。

  • decrypt –

    将十六进制字符串解密回原始密码。

Source code in zzupy/utils.py
223
224
def __init__(self, key_string: str = ""):
    self._key: int = self._generate_key(key_string)

encrypt ¤

encrypt(string: str) -> str

将明文与实例密钥进行异或运算,并转为十六进制字符串。

Parameters:

  • string ¤

    (str) –

    明文

Source code in zzupy/utils.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def encrypt(self, string: str) -> str:
    """将明文与实例密钥进行异或运算,并转为十六进制字符串。

    Args:
        string: 明文
    """
    if len(string) > 512:
        return "-1"

    encrypted_output = []
    for char in string:
        ch = ord(char) ^ self._key
        hex_str = format(ch, "02x")
        encrypted_output.append(hex_str)

    return "".join(encrypted_output)

decrypt ¤

decrypt(hex_string: str) -> str

将十六进制字符串解密回原始密码。

Parameters:

  • hex_string ¤

    (str) –

    十六进制字符串

Raises:

  • ValueError –

    如果十六进制字符串格式错误

Source code in zzupy/utils.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def decrypt(self, hex_string: str) -> str:
    """将十六进制字符串解密回原始密码。

    Args:
        hex_string: 十六进制字符串

    Raises:
        ValueError: 如果十六进制字符串格式错误
    """
    if len(hex_string) % 2 != 0:
        raise ValueError("十六进制字符串长度必须为偶数")

    original_password = []
    for i in range(0, len(hex_string), 2):
        hex_pair = hex_string[i : i + 2]
        decimal_value = int(hex_pair, 16)
        # 与实例密钥进行异或
        original_char = chr(decimal_value ^ self._key)
        original_password.append(original_char)

    return "".join(original_password)

EPortalClient ¤

EPortalClient(base_url: str, bind_address: str | None = None, force_bind: bool = False)

Portal 认证客户端 / 校园网认证客户端

Parameters:

  • base_url ¤

    (str) –

    Portal 服务器的 Base URL

  • bind_address ¤

    (str | None, default: None ) –

    绑定的本地 IP

  • force_bind ¤

    (bool, default: False ) –

    即便 IP 绑定失败也在请求参数中使用该 IP。

    如果你在路由器后使用本方法,则需要把 bind_address 填写为路由器分配的内网 IP 并启用 force_bind

Methods:

Source code in zzupy/web/network.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def __init__(
    self,
    base_url: str,
    bind_address: str | None = None,
    force_bind: bool = False,
) -> None:
    """初始化一个 Portal 客户端

    Args:
        base_url: Portal 服务器的 Base URL
        bind_address: 绑定的本地 IP
        force_bind: 即便 IP 绑定失败也在请求参数中使用该 IP。

            如果你在路由器后使用本方法,则需要把 `bind_address` 填写为路由器分配的内网 IP 并启用 `force_bind`
    """
    self._base_url = base_url
    if bind_address is None:
        self._bind_address = get_local_ip() or ""
    else:
        self._bind_address = bind_address
    self._xor_cipher = XorCipher(self._bind_address)
    if force_bind:
        local_ips = [
            ip.ip for adapter in ifaddr.get_adapters() for ip in adapter.ips
        ]

        if self._bind_address in local_ips:
            transport = httpx2.HTTPTransport(local_address=self._bind_address)
        else:
            transport = httpx2.HTTPTransport()

    else:
        transport = httpx2.HTTPTransport(local_address=self._bind_address)
    self._client = httpx2.Client(
        transport=transport,
    )

portal_auth ¤

portal_auth(account: str, password: str, encrypt: bool = False) -> AuthResult

auth() 的底层实现,允许完全自定义账户

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Raises:

Source code in zzupy/web/network.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def portal_auth(
    self,
    account: str,
    password: str,
    encrypt: bool = False,
) -> AuthResult:
    """[`auth()`][zzupy.web.EPortalClient.auth] 的底层实现,允许完全自定义账户

    Args:
        account: 账户
        password: 密码
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果

    Raises:
        ParsingError: 如果无法解析 API 响应。
        NetworkError: 如果发生网络错误。
    """
    params: list[tuple[str, str | int | float | None]]
    if encrypt:
        params = [
            ("callback", self._xor_cipher.encrypt("dr1003")),
            ("login_method", self._xor_cipher.encrypt("1")),
            ("user_account", self._xor_cipher.encrypt(f",0,{account}")),
            (
                "user_password",
                self._xor_cipher.encrypt(
                    base64.b64encode(password.encode()).decode(),
                ),
            ),
            ("wlan_user_ip", self._xor_cipher.encrypt(self._bind_address)),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", self._xor_cipher.encrypt("000000000000")),
            ("wlan_vlan_id", self._xor_cipher.encrypt("0")),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", self._xor_cipher.encrypt("4.2.2")),
            ("terminal_type", self._xor_cipher.encrypt("3")),
            ("lang", self._xor_cipher.encrypt("zh-cn")),
            ("encrypt", "1"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    else:
        params = [
            ("callback", "dr1003"),
            ("login_method", "1"),
            ("user_account", f",0,{account}"),
            (
                "user_password",
                base64.b64encode(password.encode()).decode(),
            ),
            ("wlan_user_ip", self._bind_address),
            ("wlan_user_ipv6", ""),
            ("wlan_user_mac", "000000000000"),
            ("wlan_vlan_id", "0"),
            ("wlan_ac_ip", ""),
            ("wlan_ac_name", ""),
            ("authex_enable", ""),
            ("jsVersion", "4.2.2"),
            ("terminal_type", "3"),
            ("lang", "zh-cn"),
            ("v", str(random.randint(500, 10499))),
            ("lang", "zh"),
        ]
    try:
        response = self._client.get(
            f"{self._base_url}/eportal/portal/login", params=params
        )
        response.raise_for_status()
        res_json = json.loads(JsonPParser(response.text).data)
        return AuthResult.model_validate(res_json)
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc
    except (json.JSONDecodeError, ValueError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": f"{self._base_url}/eportal/portal/login"},
        ) from exc

auth ¤

auth(account: str, password: str, isp_suffix: str | None = None, encrypt: bool = False) -> AuthResult

进行 Portal 认证

Parameters:

  • account ¤

    (str) –

    账户

  • password ¤

    (str) –

    密码

  • isp_suffix ¤

    (str | None, default: None ) –

    运营商后缀

  • encrypt ¤

    (bool, default: False ) –

    是否启用加密

Returns:

Source code in zzupy/web/network.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def auth(
    self,
    account: str,
    password: str,
    isp_suffix: str | None = None,
    encrypt: bool = False,
) -> AuthResult:
    """进行 Portal 认证

    Args:
        account: 账户
        password: 密码
        isp_suffix: 运营商后缀
        encrypt: 是否启用加密

    Returns:
        AuthResult: 认证结果
    """
    return self.portal_auth(f"{account}{isp_suffix or ''}", password, encrypt)

SelfServiceSystem ¤

SelfServiceSystem(base_url: str)

自助服务系统

Methods:

Source code in zzupy/web/network.py
292
293
294
def __init__(self, base_url: str):
    self._client = httpx2.Client(base_url=base_url)
    self._logged_in = False

login ¤

login(account: str, password: str) -> None

登录

Parameters:

  • account ¤

    (str) –

    账号

  • password ¤

    (str) –

    密码

Raises:

Source code in zzupy/web/network.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def login(self, account: str, password: str) -> None:
    """登录

    Args:
        account (str): 账号
        password (str): 密码

    Raises:
        LoginError: 如果登录失败。
        ParsingError: 如果无法解析登录页面。
        NetworkError: 如果发生网络错误。
    """
    try:
        response = self._client.get(
            "/Self/login/",
            follow_redirects=False,
        )
        response.raise_for_status()

        # 提取checkcode
        checkcode = extract_first_html_attr(
            response.text,
            "input",
            "value",
            match_attrs={"name": "checkcode"},
        )
        if not isinstance(checkcode, str):
            raise ParsingError(
                "解析 HTML 失败,无法在登录页面上找到 'checkcode'。页面结构可能已更改。"
            )

        # 不能少
        self._client.get(
            "/Self/login/randomCode",
            params={"t": str(random.random())},
        )

        data = {
            "foo": "",  # 笑死我了😆
            "bar": "",
            "checkcode": checkcode,
            "account": account,
            "password": password,
            "code": "",
        }

        response = self._client.post(
            "/Self/login/verify", data=data, follow_redirects=True
        )
        # 你妈教你这么设计 API 的?
        if "dashboard" not in response.url.path:
            raise LoginError("登录失败。这可能是因为账户和密码不正确。")
        self._logged_in = True
        return None
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/verify"},
        ) from exc

get_online_devices ¤

get_online_devices() -> List[OnlineDevice]

获取当前在线设备

Returns:

Raises:

  • NotLoggedInError –

    如果未登录。

  • ParsingError –

    如果无法解析 API 返回数据。

  • NetworkError –

    如果发生网络错误。

Source code in zzupy/web/network.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@require_auth
def get_online_devices(self) -> List[OnlineDevice]:
    """获取当前在线设备

    Returns:
        List[OnlineDevice]: 在线设备列表

    Raises:
        NotLoggedInError: 如果未登录。
        ParsingError: 如果无法解析 API 返回数据。
        NetworkError: 如果发生网络错误。
    """
    params = {
        "t": str(random.random()),
        "order": "asc",
        "_": str(int(time.time())),
    }
    try:
        response = self._client.get(
            "/Self/dashboard/getOnlineList",
            params=params,
        )
        response.raise_for_status()
        response_data = response.json()
        return [OnlineDevice(**device) for device in response_data]
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc
    except (json.JSONDecodeError, ValidationError, TypeError) as exc:
        raise ParsingError.from_exception(
            exc,
            f"无法解析的 API 响应: {exc}",
            context={"url": "/Self/dashboard/getOnlineList"},
        ) from exc

kick_device ¤

kick_device(session_id: str) -> None

将设备踢下线

Parameters:

  • session_id ¤

    (str) –

    Session ID

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
@require_auth
def kick_device(self, session_id: str) -> None:
    """将设备踢下线

    Args:
        session_id: Session ID

    Raises:
        NotLoggedInError: 如果未登录
    """
    params = {
        "t": str(random.random()),
        "sessionid": session_id,
    }
    try:
        response = self._client.get(
            "/Self/dashboard/tooffline",
            params=params,
        )
        response.raise_for_status()
    except httpx2.HTTPStatusError as exc:
        raise OperationError.from_http_status(
            exc,
            "服务器返回错误状态",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/dashboard/tooffline", "session_id": session_id},
        ) from exc

logout ¤

logout() -> None

登出

Raises:

  • NotLoggedInError –

    如果未登录

Source code in zzupy/web/network.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@require_auth
def logout(self) -> None:
    """登出

    Raises:
        NotLoggedInError: 如果未登录
    """
    try:
        self._client.get(
            "/Self/login/logout",
        )
    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(
            exc,
            f"发生网络错误: {exc}",
            context={"url": "/Self/login/logout"},
        ) from exc
    self._logged_in = False

extract_first_html_attr ¤

extract_first_html_attr(html_content: str, tag: str, attr: str, match_attrs: dict[str, str] | None = None) -> str | None

提取第一个匹配 HTML 标签的属性值。

Source code in zzupy/utils.py
111
112
113
114
115
116
117
118
119
120
121
def extract_first_html_attr(
    html_content: str,
    tag: str,
    attr: str,
    match_attrs: dict[str, str] | None = None,
) -> str | None:
    """提取第一个匹配 HTML 标签的属性值。"""
    parser = _FirstHtmlAttributeParser(tag, attr, match_attrs)
    parser.feed(html_content)
    parser.close()
    return parser.value

get_local_ip ¤

get_local_ip(target: str = '8.8.8.8') -> str | None

获取用于连接到特定目标IP的本地IP地址。

Parameters:

  • target ¤

    (str, default: '8.8.8.8' ) –

    目标主机名或IP地址。默认为 '8.8.8.8'。

Returns:

  • str ( str | None ) –

    用于到达目标的本地IP地址

  • None ( str | None ) –

    如果发生网络错误

Source code in zzupy/utils.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_local_ip(target: str = "8.8.8.8") -> str | None:
    """
    获取用于连接到特定目标IP的本地IP地址。

    Args:
        target: 目标主机名或IP地址。默认为 '8.8.8.8'。

    Returns:
        str: 用于到达目标的本地IP地址
        None: 如果发生网络错误
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect((target, 80))
            local_ip = s.getsockname()[0]
            return local_ip
    except socket.error:
        return None

require_auth ¤

require_auth(func)

装饰器:确保调用方法前已登录

Raises:

Source code in zzupy/utils.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def require_auth(func):
    """装饰器:确保调用方法前已登录

    Raises:
        NotLoggedInError: 如果未登录
    """

    @wraps(func)
    async def async_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return await func(self, *args, **kwargs)

    @wraps(func)
    def sync_wrapper(self, *args, **kwargs):
        if not self._logged_in:
            raise NotLoggedInError("需要登录")
        return func(self, *args, **kwargs)

    import inspect

    if inspect.iscoroutinefunction(func):
        return async_wrapper
    else:
        return sync_wrapper

discover_portal_info ¤

discover_portal_info() -> PortalInfo

自动发现校园网Portal认证信息

Returns:

Raises:

  • NetworkError –

    如果网络错误,或当前环境无法检测到 Portal 信息

  • ParsingError –

    如果响应格式异常

Source code in zzupy/web/network.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def discover_portal_info() -> PortalInfo:
    """自动发现校园网Portal认证信息

    Returns:
        PortalInfo: Portal信息

    Raises:
        NetworkError: 如果网络错误,或当前环境无法检测到 Portal 信息
        ParsingError: 如果响应格式异常
    """

    def _parse_portal_redirect(html_content: str) -> str:
        """解析Portal重定向链接"""
        href = extract_first_html_attr(html_content, "a", "href")
        if not isinstance(href, str):
            raise ParsingError("无法解析网页认证 URL")
        return href

    def _extract_user_ip(portal_url: str) -> str:
        """从Portal URL提取用户IP"""
        parsed = urllib.parse.urlparse(portal_url)
        query_params = parse_qs(parsed.query)

        user_ips = query_params.get("userip", [])

        # 某些园区的奇怪设备
        if not user_ips:
            user_ips = query_params.get("wlanuserip", [])

        if not user_ips:
            raise ParsingError("无法从Portal URL获取用户IP")
        return user_ips[0]

    def _extract_auth_url(portal_url: str) -> str:
        """提取网页认证 URL"""
        parsed = urllib.parse.urlparse(portal_url)
        if not parsed.scheme or not parsed.netloc:
            raise ParsingError("无法从Portal URL获取认证服务器地址")
        return f"{parsed.scheme}://{parsed.netloc}"

    def _get_portal_server_url(client: httpx2.Client, auth_url: str) -> str:
        """获取 Portal 服务器 URL"""
        DEFAULT_HTTP_PORT = 801
        DEFAULT_HTTPS_PORT = 802
        hostname = urllib.parse.urlparse(auth_url).hostname
        if hostname is None:
            raise ParsingError("无法从认证 URL 获取 Portal 主机名")

        try:
            response = client.get(f"{auth_url}/a41.js")
            js_params = _parse_js_config(response.text)

            if js_params.get("enableHttps") == 0:
                port = js_params.get("epHTTPPort", DEFAULT_HTTP_PORT)
                return f"http://{hostname}:{port}"
            else:
                port = js_params.get("enHTTPSPort", DEFAULT_HTTPS_PORT)
                return f"https://{hostname}:{port}"

        except (httpx2.RequestError, ValueError) as exc:
            logger.debug("获取 Portal 服务器配置失败,降级到默认配置: {}", exc)
            return f"http://{hostname}:{DEFAULT_HTTP_PORT}"

    def _parse_js_config(js_content: str) -> dict[str, int]:
        """解析 JavaScript 配置参数"""
        pattern = r"var\s+(\w+)\s*=\s*(\d+);"
        matches = re.findall(pattern, js_content)
        return {key: int(value) for key, value in matches}

    try:
        with httpx2.Client(timeout=10.0) as client:
            response = client.get("http://bilibili.com", follow_redirects=True)

            if str(response.url).startswith("https://"):
                raise NetworkError("未被 MITM,请检查校园网是否已认证")

            if str(response.url) != "http://bilibili.com":
                # 某些园区的奇怪设备
                portal_url = str(response.url)
            else:
                portal_url = _parse_portal_redirect(response.text)

            user_ip = _extract_user_ip(portal_url)

            auth_url = _extract_auth_url(portal_url)
            portal_server_url = _get_portal_server_url(client, auth_url)

            return PortalInfo(
                auth_url=auth_url, portal_server_url=portal_server_url, user_ip=user_ip
            )

    except httpx2.RequestError as exc:
        raise NetworkError.from_exception(exc, f"网络请求失败: {exc}") from exc
    except ZZUError:
        raise
    except Exception as exc:
        raise NetworkError.from_exception(exc, f"Portal信息发现失败: {exc}") from exc