courser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
courser/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """PKU 补退选空余名额监控器。
2
+
3
+ 使用 opencli 驱动真实 Chrome(后台窗口),按人类节奏定期登录补退选页面,
4
+ 解析课程列表中的 限数/已选,命中筛选条件且有空余名额时发送邮件通知。
5
+ """
6
+
7
+ __version__ = "0.1.0"
courser/config.py ADDED
@@ -0,0 +1,176 @@
1
+ """配置管理。
2
+
3
+ - config.json:TUI 可编辑的全部配置(轮询间隔、筛选条件、凭据、邮件、浏览器会话)
4
+ - 环境变量(.env):敏感信息(密码等)可选覆盖
5
+ 所有配置集中在「设置」界面维护,代码里不散落魔法配置。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
17
+ CONFIG_PATH = Path(os.environ.get("COURSER_CONFIG", str(PROJECT_ROOT / "config.json")))
18
+ DATA_DIR = PROJECT_ROOT / "data"
19
+ STATE_FILE = DATA_DIR / "notified.json"
20
+ SEND_LOG_FILE = DATA_DIR / "send_log.json" # 每小时发送预算的发送时间戳日志
21
+
22
+
23
+ def _env(key: str, default: str = "") -> str:
24
+ return os.environ.get(key, default)
25
+
26
+
27
+ @dataclass
28
+ class Credentials:
29
+ username: str = "" # 学号;留空则依赖密码管理器自动填充
30
+ password: str = "" # 密码;留空则依赖密码管理器自动填充
31
+
32
+ @classmethod
33
+ def from_dict(cls, d: Optional[dict]) -> "Credentials":
34
+ d = d or {}
35
+ return cls(
36
+ username=str(d.get("username", "") or _env("PKU_USERNAME", "")),
37
+ password=str(d.get("password", "") or _env("PKU_PASSWORD", "")),
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class Notify:
43
+ """邮件通知(通过 gws = Google Workspace CLI 发送,需用户自行安装,
44
+ 首次 `gws auth setup` 后 `gws auth login` 授权)。"""
45
+
46
+ to: str = "" # 收件人邮箱(提醒的目标地址,TUI 设置 / MAIL_TO 提供)
47
+ gws_from: str = "" # gws 发件账号(Gmail 地址,可选;默认取认证账号)
48
+ min_interval_min: float = 15.0 # 同一课程两次通知的最小间隔(分钟)
49
+ max_per_hour: int = 5 # 每小时最多发送的邮件封数(只限发信,不影响查询轮次)
50
+
51
+ @classmethod
52
+ def from_dict(cls, d: Optional[dict]) -> "Notify":
53
+ d = d or {}
54
+ return cls(
55
+ to=str(d.get("to", "") or _env("MAIL_TO", "")),
56
+ gws_from=str(d.get("gws_from", "") or _env("GWS_FROM", "")),
57
+ min_interval_min=float(d.get("min_interval_min", 15.0)),
58
+ max_per_hour=max(1, int(d.get("max_per_hour", 5))),
59
+ )
60
+
61
+ @property
62
+ def configured(self) -> bool:
63
+ return bool(self.to)
64
+
65
+
66
+ @dataclass
67
+ class Filters:
68
+ names: list[str] = field(default_factory=list) # 课程名(子串匹配)
69
+ categories: list[str] = field(default_factory=list) # 课程类别(子串匹配,如 通识核心课I类)
70
+ depts: list[str] = field(default_factory=list) # 开课单位(子串匹配,如 英语语言文学系)
71
+ match: str = "any" # any=任一命中 / all=全部命中
72
+
73
+ @classmethod
74
+ def from_dict(cls, d: Optional[dict]) -> "Filters":
75
+ d = d or {}
76
+ return cls(
77
+ names=list(d.get("names", [])),
78
+ categories=list(d.get("categories", [])),
79
+ depts=list(d.get("depts", [])),
80
+ match=str(d.get("match", "any")),
81
+ )
82
+
83
+ @property
84
+ def empty(self) -> bool:
85
+ return not (self.names or self.categories or self.depts)
86
+
87
+ @property
88
+ def active_groups(self) -> list[tuple[str, list[str]]]:
89
+ out = []
90
+ if self.names:
91
+ out.append(("课程名", self.names))
92
+ if self.categories:
93
+ out.append(("课程类别", self.categories))
94
+ if self.depts:
95
+ out.append(("开课院系", self.depts))
96
+ return out
97
+
98
+ def to_dict(self) -> dict:
99
+ return {"names": self.names, "categories": self.categories,
100
+ "depts": self.depts, "match": self.match}
101
+
102
+
103
+ @dataclass
104
+ class Config:
105
+ interval_min: float = 8.0 # 轮询基本间隔(分钟),实际带随机抖动
106
+ interval_jitter: float = 0.3 # 抖动比例(±30%)
107
+ page_delay_min: float = 0.8 # 相邻操作随机间隔下限(秒,人类速度)
108
+ page_delay_max: float = 2.0 # 相邻操作随机间隔上限(秒)
109
+ credentials: Credentials = field(default_factory=Credentials)
110
+ filters: Filters = field(default_factory=Filters)
111
+ notify: Notify = field(default_factory=Notify)
112
+ session: str = "courser-watch" # opencli 浏览器会话名
113
+ window: str = "background" # background=后台窗口,不抢焦点
114
+ force_relogin: bool = False # 默认复用有效会话;开启才每轮登出重登
115
+ first_run_done: bool = False # 初次启动向导是否已完成
116
+ cli_log: bool = False # CLI 模式(无 TUI)
117
+
118
+ @classmethod
119
+ def load(cls, path: Optional[Path] = None) -> "Config":
120
+ path = path or CONFIG_PATH
121
+ cfg = cls()
122
+ if path.exists():
123
+ try:
124
+ d = json.loads(path.read_text(encoding="utf-8"))
125
+ except Exception:
126
+ d = {}
127
+ cfg.interval_min = float(d.get("interval_min", cfg.interval_min))
128
+ cfg.interval_jitter = float(d.get("interval_jitter", cfg.interval_jitter))
129
+ cfg.page_delay_min = float(d.get("page_delay_min", cfg.page_delay_min))
130
+ cfg.page_delay_max = float(d.get("page_delay_max", cfg.page_delay_max))
131
+ cfg.session = str(d.get("session", cfg.session))
132
+ cfg.window = str(d.get("window", cfg.window))
133
+ cfg.force_relogin = bool(d.get("force_relogin", cfg.force_relogin))
134
+ cfg.first_run_done = bool(d.get("first_run_done", cfg.first_run_done))
135
+ cfg.credentials = Credentials.from_dict(d.get("credentials"))
136
+ cfg.filters = Filters.from_dict(d.get("filters"))
137
+ cfg.notify = Notify.from_dict(d.get("notify"))
138
+ return cfg
139
+
140
+ def save(self, path: Optional[Path] = None) -> None:
141
+ path = path or CONFIG_PATH
142
+ d = {
143
+ "interval_min": self.interval_min,
144
+ "interval_jitter": self.interval_jitter,
145
+ "page_delay_min": self.page_delay_min,
146
+ "page_delay_max": self.page_delay_max,
147
+ "session": self.session,
148
+ "window": self.window,
149
+ "force_relogin": self.force_relogin,
150
+ "first_run_done": self.first_run_done,
151
+ "credentials": {"username": self.credentials.username,
152
+ "password": self.credentials.password},
153
+ "filters": self.filters.to_dict(),
154
+ "notify": {"to": self.notify.to, "gws_from": self.notify.gws_from,
155
+ "min_interval_min": self.notify.min_interval_min,
156
+ "max_per_hour": self.notify.max_per_hour},
157
+ }
158
+ path.parent.mkdir(parents=True, exist_ok=True)
159
+ path.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
160
+
161
+ @property
162
+ def pacing(self) -> tuple[float, float]:
163
+ return (self.page_delay_min, self.page_delay_max)
164
+
165
+
166
+ def load_env_file(path: Optional[Path] = None) -> None:
167
+ """加载项目根目录 .env(若存在),用于填充 PKU 凭据、GWS_FROM 等环境变量。"""
168
+ path = path or (PROJECT_ROOT / ".env")
169
+ if not path.exists():
170
+ return
171
+ for line in path.read_text(encoding="utf-8").splitlines():
172
+ line = line.strip()
173
+ if not line or line.startswith("#") or "=" not in line:
174
+ continue
175
+ k, v = line.split("=", 1)
176
+ os.environ.setdefault(k.strip(), v.strip())
@@ -0,0 +1,123 @@
1
+ """课程表规范 schema + 单元格格式化(唯一来源)。
2
+
3
+ 课程表是所有输出渠道共享的**核心展示对象**,这里确立它的 canonical 定义:
4
+
5
+ Course
6
+ │
7
+ ▼
8
+ COURSE_COLUMNS(列定义) + course_display(字段→显示文本)
9
+ │
10
+ ┌───┴─────────────┐
11
+ ▼ ▼
12
+ TUI 列布局 Email(HTML/纯文本)
13
+ 终端宽度/折行/颜色 HTML/markdown 标签
14
+
15
+ 各渠道**共享**:列 key / 标题 / 对齐语义 / 数据格式化(course_display)。
16
+ 各渠道**不共享**:终端宽度、折行规则、ANSI 颜色、HTML/markdown 标签。
17
+
18
+ 这样『限选/已选 拆分』『空余右对齐』只改这一处,TUI 主表 / TUI 筛选表 /
19
+ 邮件 HTML / 邮件纯文本 自动同步,杜绝各模块手写字符串再分裂。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Optional
26
+
27
+ from .models import Course
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Column:
32
+ """一列课程表的规范定义。
33
+
34
+ - 结构列(页数/课程号/限选/已选/空余):width 固定;weight 为 0。
35
+ - 文本列(课程名/类别/开课单位/教师):width=None,按 weight 弹性分配。
36
+ """
37
+
38
+ key: str
39
+ title: str
40
+ align: str = "left"
41
+ width: Optional[int] = None # 固定宽(结构列/数值列)
42
+ min_width: int = 0 # 弹性文本列的底线
43
+ weight: float = 0.0 # 弹性文本列的宽度权重
44
+ preferred: Optional[int] = None # 弹性文本列的 cap
45
+
46
+ @property
47
+ def is_structural(self) -> bool:
48
+ """结构列(固定宽,数值/ID),文本列(弹性)的对立面。"""
49
+ return self.width is not None
50
+
51
+
52
+ # 课程表规范顺序:页数 → 空余。TUI 实际渲染会把文本列塞进中间
53
+ # (页数/课程号, [文本列…], 限选/已选/空余);这是终端布局 adapter 的决定,
54
+ # 仍在 _columns() 里基于本 schema 推导,不另写第二份列定义。
55
+ COURSE_COLUMNS: list[Column] = [
56
+ Column("page", "页数", align="right", width=4),
57
+ Column("no", "课程号", align="left", width=10),
58
+ Column("name", "课程名", align="left", min_width=12, weight=3.0, preferred=30),
59
+ Column("cat", "课程类别", align="center", min_width=8, weight=1.5, preferred=20),
60
+ Column("dept", "开课单位", align="left", min_width=8, weight=1.5, preferred=20),
61
+ Column("teacher", "教师", align="left", min_width=10, weight=2.0, preferred=26),
62
+ Column("quota", "限选", align="right", width=5),
63
+ Column("selected", "已选", align="right", width=5),
64
+ Column("avail", "空余", align="right", width=5),
65
+ ]
66
+
67
+ # key → Column 的便捷查找
68
+ _COLUMN_BY_KEY: dict[str, Column] = {c.key: c for c in COURSE_COLUMNS}
69
+
70
+
71
+ def column(key: str) -> Optional[Column]:
72
+ """按 key 取列定义;未知 key 返回 None。"""
73
+ return _COLUMN_BY_KEY.get(key)
74
+
75
+
76
+ def course_display(c: Course, key: str) -> str:
77
+ """课程某一列的**显示文本**(canonical 格式化)。
78
+
79
+ 所有渠道都从这里取值,保证格式一致:
80
+ - 数值列:None/未知 → '—',有值 → str
81
+ - 文本列:空 → '—'
82
+ 邮件额外用到的课程字段(班号/上课信息)也归入本表单。
83
+ """
84
+ if key == "page":
85
+ return str(c.page) if c.page else "—"
86
+ if key == "no":
87
+ return c.course_no or "—"
88
+ if key == "name":
89
+ return c.name or "—"
90
+ if key == "cat":
91
+ return c.category or "—"
92
+ if key == "dept":
93
+ return c.dept or "—"
94
+ if key == "teacher":
95
+ return c.teacher or "—"
96
+ if key == "class_no":
97
+ return c.class_no or "—"
98
+ if key == "schedule":
99
+ return c.schedule or "—"
100
+ if key == "quota":
101
+ return str(c.quota) if c.quota is not None else "—"
102
+ if key == "selected":
103
+ return str(c.selected) if c.selected is not None else "—"
104
+ if key == "avail":
105
+ return str(c.avail) if c.avail >= 0 else "—"
106
+ return ""
107
+
108
+
109
+ def course_order_key(c: Course) -> tuple[int]:
110
+ """选课网顺序:页码升序;同页保持原始列表顺序。"""
111
+ return (c.page if c.page > 0 else 10**9,)
112
+
113
+
114
+ def ordered_courses(courses: list[Course]) -> list[Course]:
115
+ """按选课网顺序返回课程副本;Python 稳定排序保留同页上下顺序。"""
116
+ return sorted(courses, key=course_order_key)
117
+
118
+
119
+ def seats_display(c: Course) -> str:
120
+ """限选/已选 合并串(邮件纯文本等散文场景用);缺失回退原始串。"""
121
+ if c.quota is not None and c.selected is not None:
122
+ return f"{c.selected}/{c.quota}"
123
+ return (c.seats_raw or "").replace(" ", "") or "—"
courser/editing.py ADDED
@@ -0,0 +1,99 @@
1
+ """可复用的行内字段编辑器(设置页 / 首启向导等所有输入共用)。
2
+
3
+ 它不依赖任何具体 UI 控件:只维护一段文本缓冲 + 光标位置,外部把按键的原始信息
4
+ 喂进来(key / char / printable),它处理移动光标、删除、在光标处插入并返回
5
+ 'commit' / 'cancel' 让调用方决定何时落盘。渲染也由它统一负责(含密码打码与
6
+ 块状光标 ▌),保证各处输入外观与行为完全一致——原值保留、不清空重输、键盘
7
+ 移动光标、支持中文输入。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from rich.markup import escape
13
+
14
+
15
+ class FieldEditor:
16
+ """一个复用的行内单行编辑器实例。
17
+
18
+ 用法:
19
+ ed.begin(value, kind="text") # 进入编辑,光标置于末尾
20
+ out = ed.feed(key, char, printable) # 每来一个键调用一次
21
+ # out 为 'commit' / 'cancel' 时由调用方落盘或放弃;其余在内部处理
22
+ markup = ed.markup() # 渲染带光标的文本(供页面显示)
23
+ ed.reset() # 结束编辑
24
+ """
25
+
26
+ CURSOR = "▌"
27
+
28
+ def __init__(self) -> None:
29
+ self.text = ""
30
+ self.caret = 0
31
+ self.kind = "text" # text | password
32
+ self.active = False
33
+
34
+ # -- 生命周期 -----------------------------------------------------
35
+ def begin(self, value: str, kind: str = "text") -> None:
36
+ """开始编辑:原值保留,光标默认在末尾(追加式修改)。"""
37
+ self.active = True
38
+ self.text = value
39
+ self.kind = kind
40
+ self.caret = len(value)
41
+
42
+ def reset(self) -> None:
43
+ self.active = False
44
+ self.text = ""
45
+ self.caret = 0
46
+
47
+ # -- 取值 ---------------------------------------------------------
48
+ def markup(self) -> str:
49
+ """渲染当前缓冲:密码打码为 •,光标处显示块状 ▌(转义后安全)。"""
50
+ shown = self.text if self.kind != "password" else "•" * len(self.text)
51
+ c = max(0, min(self.caret, len(shown)))
52
+ return ("[cyan]" + escape(shown[:c]) + self.CURSOR +
53
+ escape(shown[c:]) + "[/]")
54
+
55
+ def _insert(self, text: str) -> None:
56
+ self.text = self.text[:self.caret] + text + self.text[self.caret:]
57
+ self.caret += len(text)
58
+
59
+ def _backspace(self) -> None:
60
+ if self.caret > 0:
61
+ self.text = self.text[:self.caret - 1] + self.text[self.caret:]
62
+ self.caret -= 1
63
+
64
+ def _delete(self) -> None:
65
+ if self.caret < len(self.text):
66
+ self.text = self.text[:self.caret] + self.text[self.caret + 1:]
67
+
68
+ def feed(self, key: str, char: str | None,
69
+ printable: bool) -> str | None:
70
+ """处理一次按键,返回 'commit' / 'cancel' / None。
71
+
72
+ key / char / printable 取自某个 TUI 的按键事件;本方法只做纯文本编辑,
73
+ 不落盘、不渲染——这样任何调用方(设置页、首启向导…)都能复用。
74
+ """
75
+ if not self.active:
76
+ return None
77
+ if key == "enter":
78
+ return "commit"
79
+ if key == "escape":
80
+ return "cancel"
81
+ if key == "left":
82
+ self.caret = max(0, self.caret - 1)
83
+ elif key == "right":
84
+ self.caret = min(len(self.text), self.caret + 1)
85
+ elif key == "home":
86
+ self.caret = 0
87
+ elif key == "end":
88
+ self.caret = len(self.text)
89
+ elif key == "backspace":
90
+ self._backspace()
91
+ elif key == "delete":
92
+ self._delete()
93
+ else:
94
+ # 可打印字符在光标处插入(含中文)。Textual 里字母常只有 key 无 char,
95
+ # 这里统一回退到单字符的 key。
96
+ t = char if char else (key if len(key) == 1 else None)
97
+ if t and printable:
98
+ self._insert(t)
99
+ return None
courser/filters.py ADDED
@@ -0,0 +1,61 @@
1
+ """筛选规则匹配。
2
+
3
+ 三个维度:课程名 / 课程类别 / 开课院系。
4
+ - 每个维度可以有多个条目;同一维度内为「或」匹配(子串包含即可)。
5
+ - 维度之间的组合由 match 决定:
6
+ any = 任一维度命中即算(默认,对应「英语系 或 通识课I类」这类需求)
7
+ all = 所有非空维度都必须命中
8
+ - 未配置任何筛选时,不命中任何课程(避免误报)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ from .config import Filters
16
+ from .models import Course
17
+
18
+
19
+ @dataclass
20
+ class FilterSet:
21
+ filters: Filters = field(default_factory=Filters)
22
+
23
+ def _hit(self, field_value: str, entries: list[str]) -> bool:
24
+ if not entries:
25
+ return False
26
+ fv = field_value or ""
27
+ return any(e and e in fv for e in entries)
28
+
29
+ def _hit_categories(self, c: Course, entries: list[str]) -> bool:
30
+ """课程类别命中:一门课可同时属于多类,
31
+ 只要任一独立类别包含任一筛选条目即命中(保留子串语义)。"""
32
+ if not entries:
33
+ return False
34
+ cats = c.categories
35
+ if not cats:
36
+ return False
37
+ return any(e and any(e in cat for cat in cats) for e in entries)
38
+
39
+ def matches(self, c: Course) -> bool:
40
+ if self.filters.empty:
41
+ return False
42
+ groups_hit = []
43
+ if self.filters.names:
44
+ groups_hit.append(self._hit(c.name, self.filters.names))
45
+ if self.filters.categories:
46
+ groups_hit.append(self._hit_categories(c, self.filters.categories))
47
+ if self.filters.depts:
48
+ groups_hit.append(self._hit(c.dept, self.filters.depts))
49
+ if self.filters.match == "all":
50
+ return all(groups_hit)
51
+ return any(groups_hit)
52
+
53
+ def matched(self, courses: list[Course]) -> list[Course]:
54
+ return [c for c in courses if self.matches(c)]
55
+
56
+ def describe(self) -> str:
57
+ if self.filters.empty:
58
+ return "(未配置筛选 → 不会告警)"
59
+ mode = "满足任一条件" if self.filters.match != "all" else "满足全部条件"
60
+ parts = [f"{k}: {' / '.join(v)}" for k, v in self.filters.active_groups]
61
+ return f"{mode} | " + " ; ".join(parts) if parts else "(未配置筛选)"
courser/human.py ADDED
@@ -0,0 +1,33 @@
1
+ """人类节奏模拟:相邻操作之间随机间隔。
2
+
3
+ 所有 sleep 都使用随机区间,避免固定间隔暴露自动化痕迹;
4
+ 同时保持低频(间隔较短的操作按秒计,轮询按分钟计)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ import time
11
+
12
+ __all__ = ["sleep_rand", "jitter", "human_delay_range"]
13
+
14
+
15
+ def sleep_rand(lo: float, hi: float) -> float:
16
+ """随机休眠 [lo, hi] 秒并返回实际休眠时长。"""
17
+ if hi < lo:
18
+ lo, hi = hi, lo
19
+ secs = random.uniform(lo, hi)
20
+ time.sleep(secs)
21
+ return secs
22
+
23
+
24
+ def jitter(value: float, ratio: float = 0.3) -> float:
25
+ """在 value*(1-ratio) ~ value*(1+ratio) 之间随机抖动(用于轮询间隔,分钟级)。"""
26
+ return value * random.uniform(1.0 - ratio, 1.0 + ratio)
27
+
28
+
29
+ def human_delay_range(base_lo: float = 6.0, base_hi: float = 14.0) -> tuple[float, float]:
30
+ """两次页面操作之间的随机间隔范围(秒):在基准区间上再做一次随机缩放。"""
31
+ lo = base_lo * random.uniform(0.8, 1.2)
32
+ hi = base_hi * random.uniform(0.9, 1.3)
33
+ return (min(lo, hi), max(lo, hi))
courser/logfile.py ADDED
@@ -0,0 +1,35 @@
1
+ """持久化日志:写入 data/courser.log(gitignored),自动滚动。
2
+
3
+ 所有运行痕迹(每轮登录/抓取/命中/发送/失败原因/风控)都会落盘,
4
+ 便于排查"为什么登录不进去"这类问题。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from .config import DATA_DIR
15
+
16
+ LOG_FILE = Path(os.environ.get("COURSER_LOG", str(DATA_DIR / "courser.log")))
17
+ _MAX_BYTES = 2 * 1024 * 1024 # 单文件 2MB,超出滚动为 courser.log.1
18
+ _lock = threading.Lock()
19
+
20
+
21
+ def log(msg: str, level: str = "INFO") -> None:
22
+ line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{level}] {msg}\n"
23
+ try:
24
+ with _lock:
25
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
26
+ if LOG_FILE.exists() and LOG_FILE.stat().st_size > _MAX_BYTES:
27
+ LOG_FILE.replace(LOG_FILE.with_suffix(".log.1"))
28
+ with LOG_FILE.open("a", encoding="utf-8") as f:
29
+ f.write(line)
30
+ except Exception:
31
+ pass # 日志失败绝不能影响主流程
32
+
33
+
34
+ def error(msg: str) -> None:
35
+ log(msg, "ERROR")
courser/models.py ADDED
@@ -0,0 +1,116 @@
1
+ """领域模型(domain objects)。
2
+
3
+ 全系统共用的数据对象,不归属任何具体实现(抓取 / 监控 / UI 都不该自己定义它们)。
4
+ - Course 补退选列表中的一门课程
5
+ - FetchResult 一次抓取(一轮登录 + 翻页)的原始结果
6
+ - RoundResult 一轮完整监控(抓取 → 筛选 → 通知决策)的最终结果
7
+ - FetchError / LoginError 抓取流程异常
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+
17
+ # 同一门课可同时属于多类(如「任选、思政选择性必修」),类别格内用分隔符并列
18
+ _CATEGORY_SPLIT_RE = re.compile(r"[、,,/;;]")
19
+
20
+ # 表格脚部分页栏文本特征:这些行会被误判成 Course,必须剔除
21
+ _PAGER_MARKERS = ("page", "previous", "next", "跳到", "dopagersubmit",
22
+ "first /", "/ last")
23
+
24
+
25
+ class FetchError(RuntimeError):
26
+ """抓取流程中的一般错误。"""
27
+
28
+
29
+ class LoginError(FetchError):
30
+ """登录失败(可能需要验证码/二次验证,或账号问题)。"""
31
+
32
+
33
+ @dataclass
34
+ class Course:
35
+ """补退选列表中的一门课程(仅可用课程列表,不含已选课程)。"""
36
+
37
+ course_no: str = ""
38
+ name: str = ""
39
+ category: str = ""
40
+ credits: str = ""
41
+ weekly_hours: str = ""
42
+ teacher: str = ""
43
+ class_no: str = ""
44
+ dept: str = ""
45
+ grade: str = ""
46
+ schedule: str = ""
47
+ pnp: str = ""
48
+ seats_raw: str = ""
49
+ quota: Optional[int] = None # 限数
50
+ selected: Optional[int] = None # 已选
51
+ avail: int = -1 # 空余 = 限数 - 已选;-1 表示未知
52
+ status: str = "" # 选课状态:可申请 / 不可申请 / 已选上 / (空)
53
+ seq: str = "" # 课程稳定 id(course_seq_no)
54
+ links: dict = field(default_factory=dict)
55
+ page: int = 0 # 在选课网列表中的页码(1 起)
56
+
57
+ @property
58
+ def categories(self) -> list[str]:
59
+ """该课的独立课程类别。同一门课可同时属于多类,
60
+ 如「任选、思政选择性必修」拆成 ['任选', '思政选择性必修'] 两类。"""
61
+ return [p for p in (s.strip() for s in _CATEGORY_SPLIT_RE.split(self.category)) if p]
62
+
63
+ @property
64
+ def has_seats(self) -> bool:
65
+ return self.avail > 0
66
+
67
+ @property
68
+ def key(self) -> str:
69
+ return self.seq or f"{self.course_no}#{self.class_no}"
70
+
71
+
72
+ @dataclass
73
+ class FetchResult:
74
+ courses: list[Course] = field(default_factory=list)
75
+ pages: int = 0
76
+ login_mode: str = "" # login_click / sso_auto
77
+ ok: bool = True
78
+ error: str = ""
79
+ cancelled: bool = False # 用户主动停止本轮,不属于抓取故障
80
+ warning_hit: bool = False # 页面文本中检测到风控/警告提示语
81
+ warning_checked: bool = False # 本轮是否真正进入了补退选页面、并读取了至少一页文本
82
+ # (有观察风控警告的机会,才计入风控触发率分母)
83
+
84
+
85
+ def is_real_course(c: "Course") -> bool:
86
+ """剔除被误当课程的表格脚/分页栏:无课程号,或文本带分页栏特征。
87
+
88
+ 选课网可用列表每页末尾有一行「Page X of Y · First/Previous/Next/Last ·
89
+ 跳到:function doPagerSubmit(...)」的页脚,会被 parse_course 读成一门伪课程,
90
+ 其字符串含 JS 文本,渲染时可能触发 markup 解析崩溃。这里做兜底过滤。
91
+ """
92
+ no = (c.course_no or "").strip()
93
+ if not no:
94
+ return False
95
+ text = (f"{no} {c.name or ''}").lower()
96
+ return not any(m in text for m in _PAGER_MARKERS)
97
+
98
+
99
+ @dataclass
100
+ class RoundResult:
101
+ ts: float = 0.0
102
+ ok: bool = True
103
+ error: str = ""
104
+ cancelled: bool = False # 用户主动停止本轮,不显示为抓取失败
105
+ login_mode: str = ""
106
+ pages: int = 0
107
+ total: int = 0
108
+ courses: list = field(default_factory=list)
109
+ matched: list = field(default_factory=list)
110
+ notified: list = field(default_factory=list)
111
+ duration_s: float = 0.0
112
+ warning_hit: bool = False # 页面检测到风控提示语
113
+ risk_percent: int = 0 # 最近 N 次有效抓取中实际警告触发率(0~100)
114
+ risk_label: str = "无"
115
+ risk_hits: int = 0 # 最近窗口内触发警告的有效抓取次数
116
+ risk_total: int = 0 # 最近窗口内的有效抓取总次数