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 +7 -0
- courser/config.py +176 -0
- courser/course_table.py +123 -0
- courser/editing.py +99 -0
- courser/filters.py +61 -0
- courser/human.py +33 -0
- courser/logfile.py +35 -0
- courser/models.py +116 -0
- courser/notifier.py +220 -0
- courser/opencli.py +291 -0
- courser/pku/__init__.py +1 -0
- courser/pku/client.py +738 -0
- courser/pku/extract.py +74 -0
- courser/pku/parser.py +131 -0
- courser/risk.py +124 -0
- courser/runner.py +236 -0
- courser/scheduler.py +110 -0
- courser/storage.py +157 -0
- courser/tui/__init__.py +1 -0
- courser/tui/__main__.py +5 -0
- courser/tui/app.py +2175 -0
- courser/tui/state.py +79 -0
- courser/tui/theme.py +311 -0
- courser-0.1.0.dist-info/METADATA +128 -0
- courser-0.1.0.dist-info/RECORD +28 -0
- courser-0.1.0.dist-info/WHEEL +4 -0
- courser-0.1.0.dist-info/entry_points.txt +2 -0
- courser-0.1.0.dist-info/licenses/LICENSE +21 -0
courser/__init__.py
ADDED
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())
|
courser/course_table.py
ADDED
|
@@ -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 # 最近窗口内的有效抓取总次数
|