ChatNet 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.
- chatnet/__init__.py +5 -0
- chatnet/cli.py +58 -0
- chatnet/config.py +21 -0
- chatnet/ecnu/__init__.py +5 -0
- chatnet/ecnu/captcha.py +183 -0
- chatnet/ecnu/cli.py +410 -0
- chatnet/ecnu/portal.py +685 -0
- chatnet-0.1.0.dist-info/METADATA +115 -0
- chatnet-0.1.0.dist-info/RECORD +13 -0
- chatnet-0.1.0.dist-info/WHEEL +5 -0
- chatnet-0.1.0.dist-info/entry_points.txt +5 -0
- chatnet-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatnet-0.1.0.dist-info/top_level.txt +1 -0
chatnet/__init__.py
ADDED
chatnet/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI entrypoint for chatnet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from chatstyle import (
|
|
7
|
+
CommandField,
|
|
8
|
+
CommandSchema,
|
|
9
|
+
add_interactive_option,
|
|
10
|
+
render_success,
|
|
11
|
+
resolve_command_inputs,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HELLO_SCHEMA = CommandSchema(
|
|
16
|
+
name="hello",
|
|
17
|
+
fields=(CommandField("name", prompt="name", required=True),),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChatNetGroup(click.Group):
|
|
22
|
+
"""Top-level group that loads heavier feature groups on demand."""
|
|
23
|
+
|
|
24
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
25
|
+
commands = set(super().list_commands(ctx))
|
|
26
|
+
commands.add("ecnu")
|
|
27
|
+
return sorted(commands)
|
|
28
|
+
|
|
29
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
30
|
+
if cmd_name == "ecnu":
|
|
31
|
+
from chatnet.ecnu.cli import cli as ecnu_cli
|
|
32
|
+
|
|
33
|
+
return ecnu_cli
|
|
34
|
+
return super().get_command(ctx, cmd_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group(cls=ChatNetGroup)
|
|
38
|
+
def main() -> None:
|
|
39
|
+
"""chatnet command line interface."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@main.command()
|
|
43
|
+
@click.argument("name", required=False)
|
|
44
|
+
@add_interactive_option
|
|
45
|
+
def hello(name: str | None, interactive: bool | None) -> None:
|
|
46
|
+
"""Print a greeting with ChatStyle-backed input resolution."""
|
|
47
|
+
|
|
48
|
+
values = resolve_command_inputs(
|
|
49
|
+
schema=HELLO_SCHEMA,
|
|
50
|
+
provided={"name": name},
|
|
51
|
+
interactive=interactive,
|
|
52
|
+
usage="Usage: chatnet hello [NAME]",
|
|
53
|
+
)
|
|
54
|
+
render_success(f"Hello, {values['name']}!")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
chatnet/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ChatEnv configuration schemas provided by ChatNet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from chatenv.fields import BaseEnvConfig, EnvField
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ECNUConfig(BaseEnvConfig):
|
|
9
|
+
"""ECNU self-service portal environment variables."""
|
|
10
|
+
|
|
11
|
+
_title = "ECNU"
|
|
12
|
+
_aliases = ["ecnu", "chatnet-ecnu"]
|
|
13
|
+
_storage_dir = "ECNU"
|
|
14
|
+
|
|
15
|
+
ECNU_USERNAME = EnvField("ECNU_USERNAME", desc="ECNU username.")
|
|
16
|
+
ECNU_PASSWORD = EnvField("ECNU_PASSWORD", desc="ECNU password.", is_sensitive=True)
|
|
17
|
+
ECNU_COOKIE = EnvField("ECNU_COOKIE", desc="Authenticated ECNU portal Cookie header.", is_sensitive=True)
|
|
18
|
+
ECNU_BASE_URL = EnvField("ECNU_BASE_URL", default="https://login.ecnu.edu.cn:8800", desc="ECNU portal base URL.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["ECNUConfig"]
|
chatnet/ecnu/__init__.py
ADDED
chatnet/ecnu/captcha.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Optional CAPTCHA recognition helpers for ECNU login automation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_ocr_deps() -> tuple[Any, Any, Any]:
|
|
11
|
+
try:
|
|
12
|
+
import ddddocr
|
|
13
|
+
import numpy as np
|
|
14
|
+
from PIL import Image
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
'CAPTCHA auto-login requires optional dependencies. Install them with: pip install "ChatNet[captcha]"'
|
|
18
|
+
) from exc
|
|
19
|
+
return ddddocr, np, Image
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def recognize_captcha_topk(image_bytes: bytes, topk: int = 5, expected_length: int = 4) -> list[str]:
|
|
23
|
+
ddddocr, _, _ = _load_ocr_deps()
|
|
24
|
+
ocr = ddddocr.DdddOcr(show_ad=False)
|
|
25
|
+
variants = build_variants(image_bytes)
|
|
26
|
+
variant_results = {
|
|
27
|
+
name: topk_for_variant(ocr, data, expected_length=expected_length)
|
|
28
|
+
for name, data in variants.items()
|
|
29
|
+
}
|
|
30
|
+
aggregate = aggregate_variant_candidates(variant_results, topk=topk)
|
|
31
|
+
return [row["text"] for row in aggregate]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pil_to_bytes(image: Any) -> bytes:
|
|
35
|
+
buf = io.BytesIO()
|
|
36
|
+
image.save(buf, format="PNG")
|
|
37
|
+
return buf.getvalue()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dilate(mask: Any, iterations: int = 1) -> Any:
|
|
41
|
+
_, np, _ = _load_ocr_deps()
|
|
42
|
+
out = mask.copy()
|
|
43
|
+
for _ in range(iterations):
|
|
44
|
+
padded = np.pad(out, 1, constant_values=False)
|
|
45
|
+
grown = np.zeros_like(out)
|
|
46
|
+
for dy in range(3):
|
|
47
|
+
for dx in range(3):
|
|
48
|
+
grown |= padded[dy : dy + out.shape[0], dx : dx + out.shape[1]]
|
|
49
|
+
out = grown
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def erode(mask: Any, iterations: int = 1) -> Any:
|
|
54
|
+
_, np, _ = _load_ocr_deps()
|
|
55
|
+
out = mask.copy()
|
|
56
|
+
for _ in range(iterations):
|
|
57
|
+
padded = np.pad(out, 1, constant_values=True)
|
|
58
|
+
shrunk = np.ones_like(out)
|
|
59
|
+
for dy in range(3):
|
|
60
|
+
for dx in range(3):
|
|
61
|
+
shrunk &= padded[dy : dy + out.shape[0], dx : dx + out.shape[1]]
|
|
62
|
+
out = shrunk
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def closing(mask: Any, iterations: int = 1) -> Any:
|
|
67
|
+
return erode(dilate(mask, iterations), iterations)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mask_bbox(mask: Any) -> tuple[int, int, int, int] | None:
|
|
71
|
+
_, np, _ = _load_ocr_deps()
|
|
72
|
+
ys, xs = np.where(mask)
|
|
73
|
+
if len(xs) == 0:
|
|
74
|
+
return None
|
|
75
|
+
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_variants(image_bytes: bytes) -> dict[str, bytes]:
|
|
79
|
+
_, np, Image = _load_ocr_deps()
|
|
80
|
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
81
|
+
arr = np.array(image, dtype=np.uint8)
|
|
82
|
+
mean = arr.mean(axis=2)
|
|
83
|
+
spread = arr.max(axis=2) - arr.min(axis=2)
|
|
84
|
+
text_loose = (spread < 40) & (mean > 60)
|
|
85
|
+
text_tight = (spread < 30) & (mean > 95)
|
|
86
|
+
text_closed = closing(text_loose, iterations=1)
|
|
87
|
+
variants = {
|
|
88
|
+
"original": pil_to_bytes(image),
|
|
89
|
+
"text_loose": pil_to_bytes(Image.fromarray((text_loose.astype(np.uint8) * 255), mode="L")),
|
|
90
|
+
"text_tight": pil_to_bytes(Image.fromarray((text_tight.astype(np.uint8) * 255), mode="L")),
|
|
91
|
+
"text_closed": pil_to_bytes(Image.fromarray((text_closed.astype(np.uint8) * 255), mode="L")),
|
|
92
|
+
}
|
|
93
|
+
bbox = mask_bbox(text_closed)
|
|
94
|
+
if bbox is not None:
|
|
95
|
+
x1, y1, x2, y2 = bbox
|
|
96
|
+
crop = Image.fromarray((text_closed[y1 : y2 + 1, x1 : x2 + 1].astype(np.uint8) * 255), mode="L")
|
|
97
|
+
nearest = getattr(getattr(Image, "Resampling", Image), "NEAREST")
|
|
98
|
+
variants["text_closed_crop_x6"] = pil_to_bytes(
|
|
99
|
+
crop.resize((crop.width * 6, crop.height * 6), nearest)
|
|
100
|
+
)
|
|
101
|
+
return variants
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def logsumexp_pair(a: float, b: float) -> float:
|
|
105
|
+
_, np, _ = _load_ocr_deps()
|
|
106
|
+
if a == -np.inf:
|
|
107
|
+
return b
|
|
108
|
+
if b == -np.inf:
|
|
109
|
+
return a
|
|
110
|
+
m = a if a > b else b
|
|
111
|
+
return float(m + np.log(np.exp(a - m) + np.exp(b - m)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ctc_prefix_beam_search(
|
|
115
|
+
probs: Any,
|
|
116
|
+
digit_indices: list[int],
|
|
117
|
+
index_to_digit: dict[int, str],
|
|
118
|
+
beam_size: int = 25,
|
|
119
|
+
topk: int = 5,
|
|
120
|
+
expected_length: int = 4,
|
|
121
|
+
blank_index: int = 0,
|
|
122
|
+
) -> list[dict[str, Any]]:
|
|
123
|
+
_, np, _ = _load_ocr_deps()
|
|
124
|
+
beam: dict[str, tuple[float, float]] = {"": (0.0, -np.inf)}
|
|
125
|
+
for t in range(probs.shape[0]):
|
|
126
|
+
next_beam: dict[str, tuple[float, float]] = defaultdict(lambda: (-np.inf, -np.inf))
|
|
127
|
+
for prefix, (p_b, p_nb) in beam.items():
|
|
128
|
+
total = logsumexp_pair(p_b, p_nb)
|
|
129
|
+
nb = next_beam[prefix]
|
|
130
|
+
next_beam[prefix] = (
|
|
131
|
+
logsumexp_pair(nb[0], total + float(np.log(probs[t, blank_index] + 1e-12))),
|
|
132
|
+
nb[1],
|
|
133
|
+
)
|
|
134
|
+
if len(prefix) > expected_length:
|
|
135
|
+
continue
|
|
136
|
+
for idx in digit_indices:
|
|
137
|
+
digit = index_to_digit[idx]
|
|
138
|
+
p = float(np.log(probs[t, idx] + 1e-12))
|
|
139
|
+
last = prefix[-1] if prefix else None
|
|
140
|
+
if digit == last:
|
|
141
|
+
nb = next_beam[prefix]
|
|
142
|
+
next_beam[prefix] = (nb[0], logsumexp_pair(nb[1], p_nb + p))
|
|
143
|
+
new_prefix = prefix + digit
|
|
144
|
+
if len(new_prefix) <= expected_length:
|
|
145
|
+
nb2 = next_beam[new_prefix]
|
|
146
|
+
next_beam[new_prefix] = (nb2[0], logsumexp_pair(nb2[1], p_b + p))
|
|
147
|
+
else:
|
|
148
|
+
new_prefix = prefix + digit
|
|
149
|
+
if len(new_prefix) <= expected_length:
|
|
150
|
+
nb2 = next_beam[new_prefix]
|
|
151
|
+
next_beam[new_prefix] = (nb2[0], logsumexp_pair(nb2[1], total + p))
|
|
152
|
+
ranked = sorted(next_beam.items(), key=lambda kv: logsumexp_pair(kv[1][0], kv[1][1]), reverse=True)
|
|
153
|
+
beam = dict(ranked[:beam_size])
|
|
154
|
+
finals = []
|
|
155
|
+
for prefix, (p_b, p_nb) in beam.items():
|
|
156
|
+
if len(prefix) == expected_length:
|
|
157
|
+
finals.append({"text": prefix, "logprob": logsumexp_pair(p_b, p_nb)})
|
|
158
|
+
finals.sort(key=lambda row: row["logprob"], reverse=True)
|
|
159
|
+
return finals[:topk]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def topk_for_variant(ocr: Any, img: bytes, expected_length: int = 4) -> dict[str, Any]:
|
|
163
|
+
_, np, _ = _load_ocr_deps()
|
|
164
|
+
result = ocr.classification(img, probability=True)
|
|
165
|
+
probs = np.array(result["probabilities"], dtype=np.float64)
|
|
166
|
+
if probs.ndim == 3:
|
|
167
|
+
probs = probs[:, 0, :]
|
|
168
|
+
charset = result["charset"]
|
|
169
|
+
digit_indices = [i for i, ch in enumerate(charset) if ch.isdigit()]
|
|
170
|
+
index_to_digit = {i: charset[i] for i in digit_indices}
|
|
171
|
+
candidates = ctc_prefix_beam_search(probs, digit_indices, index_to_digit, expected_length=expected_length)
|
|
172
|
+
return {"text": result["text"], "confidence": result["confidence"], "candidates": candidates}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def aggregate_variant_candidates(variant_results: dict[str, dict[str, Any]], topk: int = 5) -> list[dict[str, Any]]:
|
|
176
|
+
scores: dict[str, float] = defaultdict(float)
|
|
177
|
+
support: dict[str, int] = defaultdict(int)
|
|
178
|
+
for result in variant_results.values():
|
|
179
|
+
for rank, row in enumerate(result["candidates"]):
|
|
180
|
+
scores[row["text"]] += row["logprob"]
|
|
181
|
+
support[row["text"]] += max(0, topk - rank)
|
|
182
|
+
ranked = sorted(scores, key=lambda text: (support[text], scores[text]), reverse=True)
|
|
183
|
+
return [{"text": text, "score": scores[text], "support": support[text]} for text in ranked[:topk]]
|
chatnet/ecnu/cli.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Click commands for ECNU self-service portal operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from chatstyle import CommandField, CommandSchema, add_interactive_option, resolve_command_inputs
|
|
11
|
+
from chatenv.fields import BaseEnvConfig
|
|
12
|
+
from chatenv.paths import get_paths
|
|
13
|
+
|
|
14
|
+
from chatnet.config import ECNUConfig
|
|
15
|
+
from .portal import BASE_URL
|
|
16
|
+
|
|
17
|
+
LOGIN_SCHEMA = CommandSchema(
|
|
18
|
+
name="ecnu-login",
|
|
19
|
+
fields=(
|
|
20
|
+
CommandField("username", prompt="ECNU username", required=True),
|
|
21
|
+
CommandField("password", prompt="ECNU password", required=True, sensitive=True),
|
|
22
|
+
CommandField("captcha", prompt="captcha", required=True),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
LOGIN_AUTO_SCHEMA = CommandSchema(
|
|
27
|
+
name="ecnu-login-auto",
|
|
28
|
+
fields=(
|
|
29
|
+
CommandField("username", prompt="ECNU username", required=True),
|
|
30
|
+
CommandField("password", prompt="ECNU password", required=True, sensitive=True),
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
VISITOR_CREATE_SCHEMA = CommandSchema(
|
|
35
|
+
name="ecnu-visitor-create",
|
|
36
|
+
fields=(CommandField("remark", prompt="visitor remark", required=True),),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
VISITOR_UPDATE_SCHEMA = CommandSchema(
|
|
40
|
+
name="ecnu-visitor-update",
|
|
41
|
+
fields=(
|
|
42
|
+
CommandField("visitor_id", prompt="visitor id", required=True),
|
|
43
|
+
CommandField("remark", prompt="visitor remark", required=True),
|
|
44
|
+
CommandField("password", prompt="visitor password", required=True, sensitive=True),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
VISITOR_ID_SCHEMA = CommandSchema(
|
|
49
|
+
name="ecnu-visitor-id",
|
|
50
|
+
fields=(CommandField("visitor_id", prompt="visitor id", required=True),),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.group(name="ecnu")
|
|
55
|
+
@click.option("--base-url", default=None, help="ECNU portal base URL. Defaults to chatenv ECNU_BASE_URL.")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--state-file",
|
|
58
|
+
default=None,
|
|
59
|
+
help="Session state JSON file. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-session.json.",
|
|
60
|
+
)
|
|
61
|
+
@click.option("--cookie", default=None, help="Existing authenticated Cookie header. Defaults to chatenv ECNU_COOKIE.")
|
|
62
|
+
@click.option("-e", "--env", "env_profile", default=None, help="Use a named chatenv ECNU profile.")
|
|
63
|
+
@click.option("--env-file", default=None, help="Explicit env file override for ECNU values.")
|
|
64
|
+
@click.option("--timeout", default=20, show_default=True, type=int, help="HTTP timeout in seconds.")
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def cli(
|
|
67
|
+
ctx: click.Context,
|
|
68
|
+
base_url: str | None,
|
|
69
|
+
state_file: str | None,
|
|
70
|
+
cookie: str | None,
|
|
71
|
+
env_profile: str | None,
|
|
72
|
+
env_file: str | None,
|
|
73
|
+
timeout: int,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""ECNU self-service portal helpers."""
|
|
76
|
+
|
|
77
|
+
load_chatenv(env_profile=env_profile, env_file=env_file)
|
|
78
|
+
ctx.obj = {
|
|
79
|
+
"base_url": base_url or ECNUConfig.ECNU_BASE_URL.value or BASE_URL,
|
|
80
|
+
"state_file": Path(state_file).expanduser() if state_file else default_state_file(),
|
|
81
|
+
"cookie": cookie or ECNUConfig.ECNU_COOKIE.value,
|
|
82
|
+
"timeout": timeout,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command(name="selftest")
|
|
87
|
+
def selftest() -> None:
|
|
88
|
+
"""Run local parser self-test without network access."""
|
|
89
|
+
|
|
90
|
+
from .portal import run_selftest
|
|
91
|
+
|
|
92
|
+
echo_json(run_selftest())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command(name="login-init")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--captcha-path",
|
|
98
|
+
default=None,
|
|
99
|
+
help="Where to save the captcha image. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-login-captcha.png.",
|
|
100
|
+
)
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def login_init(ctx: click.Context, captcha_path: str | None) -> None:
|
|
103
|
+
"""Fetch login page, reset session state, and download captcha."""
|
|
104
|
+
|
|
105
|
+
target_path = Path(captcha_path).expanduser() if captcha_path else default_captcha_path()
|
|
106
|
+
echo_json(call_client(ctx, lambda client: client.login_init(target_path)))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command(name="login")
|
|
110
|
+
@click.option("--username", default=None, help="ECNU username, or set ECNU_USERNAME.")
|
|
111
|
+
@click.option("--password", default=None, help="ECNU password, or set ECNU_PASSWORD.")
|
|
112
|
+
@click.option("--captcha", default=None, help="Captcha text from login-init image.")
|
|
113
|
+
@click.option("--sms-code", default=None, help="SMS code when required by the server.")
|
|
114
|
+
@add_interactive_option
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def login(
|
|
117
|
+
ctx: click.Context,
|
|
118
|
+
username: str | None,
|
|
119
|
+
password: str | None,
|
|
120
|
+
captcha: str | None,
|
|
121
|
+
sms_code: str | None,
|
|
122
|
+
interactive: bool | None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Complete login using username, password, and captcha."""
|
|
125
|
+
|
|
126
|
+
values = resolve_command_inputs(
|
|
127
|
+
schema=LOGIN_SCHEMA,
|
|
128
|
+
provided={
|
|
129
|
+
"username": username or ECNUConfig.ECNU_USERNAME.value,
|
|
130
|
+
"password": password or ECNUConfig.ECNU_PASSWORD.value,
|
|
131
|
+
"captcha": captcha,
|
|
132
|
+
},
|
|
133
|
+
interactive=interactive,
|
|
134
|
+
usage="Usage: chatnet ecnu login --username TEXT --password TEXT --captcha TEXT [-i|-I]",
|
|
135
|
+
)
|
|
136
|
+
echo_json(call_client(ctx, lambda client: client.login(values["username"], values["password"], values["captcha"], sms_code=sms_code)))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@cli.command(name="login-auto")
|
|
140
|
+
@click.option("--username", default=None, help="ECNU username, or set ECNU_USERNAME.")
|
|
141
|
+
@click.option("--password", default=None, help="ECNU password, or set ECNU_PASSWORD.")
|
|
142
|
+
@click.option("--sms-code", default=None, help="SMS code when required by the server.")
|
|
143
|
+
@click.option(
|
|
144
|
+
"--captcha-path",
|
|
145
|
+
default=None,
|
|
146
|
+
help="Where to save the latest captcha image. Defaults to $CHATARCH_HOME/cache/chatnet/ecnu-login-captcha.png.",
|
|
147
|
+
)
|
|
148
|
+
@click.option("--rounds", default=3, show_default=True, type=int, help="Captcha refresh rounds.")
|
|
149
|
+
@click.option("--topk", default=5, show_default=True, type=int, help="OCR candidates per captcha.")
|
|
150
|
+
@add_interactive_option
|
|
151
|
+
@click.pass_context
|
|
152
|
+
def login_auto(
|
|
153
|
+
ctx: click.Context,
|
|
154
|
+
username: str | None,
|
|
155
|
+
password: str | None,
|
|
156
|
+
sms_code: str | None,
|
|
157
|
+
captcha_path: str | None,
|
|
158
|
+
rounds: int,
|
|
159
|
+
topk: int,
|
|
160
|
+
interactive: bool | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Auto-solve captcha with optional OCR dependencies and try login."""
|
|
163
|
+
|
|
164
|
+
values = resolve_command_inputs(
|
|
165
|
+
schema=LOGIN_AUTO_SCHEMA,
|
|
166
|
+
provided={"username": username or ECNUConfig.ECNU_USERNAME.value, "password": password or ECNUConfig.ECNU_PASSWORD.value},
|
|
167
|
+
interactive=interactive,
|
|
168
|
+
usage="Usage: chatnet ecnu login-auto --username TEXT --password TEXT [-i|-I]",
|
|
169
|
+
)
|
|
170
|
+
target_path = Path(captcha_path).expanduser() if captcha_path else default_captcha_path()
|
|
171
|
+
echo_json(
|
|
172
|
+
call_client(
|
|
173
|
+
ctx,
|
|
174
|
+
lambda client: client.login_auto(
|
|
175
|
+
values["username"],
|
|
176
|
+
values["password"],
|
|
177
|
+
sms_code=sms_code,
|
|
178
|
+
rounds=rounds,
|
|
179
|
+
topk=topk,
|
|
180
|
+
captcha_path=target_path,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@cli.command(name="session-info")
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def session_info(ctx: click.Context) -> None:
|
|
189
|
+
"""Show saved session metadata with Cookie values redacted."""
|
|
190
|
+
|
|
191
|
+
echo_json(redact_state(make_client(ctx).state))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@cli.command(name="cookie-header")
|
|
195
|
+
@click.pass_context
|
|
196
|
+
def cookie_header(ctx: click.Context) -> None:
|
|
197
|
+
"""Print the current Cookie header from state/session."""
|
|
198
|
+
|
|
199
|
+
click.echo(make_client(ctx).cookie_header())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@cli.command(name="logout")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
def logout(ctx: click.Context) -> None:
|
|
205
|
+
"""Logout and update saved session state."""
|
|
206
|
+
|
|
207
|
+
echo_json(call_client(ctx, lambda client: client.logout()))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@cli.command(name="home")
|
|
211
|
+
@click.pass_context
|
|
212
|
+
def home(ctx: click.Context) -> None:
|
|
213
|
+
"""Fetch home summary."""
|
|
214
|
+
|
|
215
|
+
echo_json(call_client(ctx, lambda client: client.home_summary()))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@cli.command(name="user-info")
|
|
219
|
+
@click.pass_context
|
|
220
|
+
def user_info(ctx: click.Context) -> None:
|
|
221
|
+
"""Fetch user information."""
|
|
222
|
+
|
|
223
|
+
echo_json(call_client(ctx, lambda client: client.user_info()))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@cli.command(name="auth-log")
|
|
227
|
+
@click.option("--start", default=None, help="Start time.")
|
|
228
|
+
@click.option("--end", default=None, help="End time.")
|
|
229
|
+
@click.option("--limit", default=None, type=int, help="Maximum rows to print.")
|
|
230
|
+
@click.pass_context
|
|
231
|
+
def auth_log(ctx: click.Context, start: str | None, end: str | None, limit: int | None) -> None:
|
|
232
|
+
"""Query authentication logs."""
|
|
233
|
+
|
|
234
|
+
echo_json(call_client(ctx, lambda client: client.auth_logs(start, end, limit)))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@cli.command(name="detail-log")
|
|
238
|
+
@click.option("--start", default=None, help="Start time.")
|
|
239
|
+
@click.option("--end", default=None, help="End time.")
|
|
240
|
+
@click.option("--limit", default=None, type=int, help="Maximum rows to print.")
|
|
241
|
+
@click.pass_context
|
|
242
|
+
def detail_log(ctx: click.Context, start: str | None, end: str | None, limit: int | None) -> None:
|
|
243
|
+
"""Query network detail logs."""
|
|
244
|
+
|
|
245
|
+
echo_json(call_client(ctx, lambda client: client.detail_logs(start, end, limit)))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cli.group(name="visitor")
|
|
249
|
+
def visitor_group() -> None:
|
|
250
|
+
"""Visitor account management."""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@visitor_group.command(name="list")
|
|
254
|
+
@click.pass_context
|
|
255
|
+
def visitor_list(ctx: click.Context) -> None:
|
|
256
|
+
"""List visitor accounts."""
|
|
257
|
+
|
|
258
|
+
echo_json(call_client(ctx, lambda client: client.list_visitors()))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@visitor_group.command(name="get")
|
|
262
|
+
@click.option("--id", "visitor_id", default=None, help="Visitor record id.")
|
|
263
|
+
@click.option("--account", default=None, help="Visitor account name.")
|
|
264
|
+
@click.pass_context
|
|
265
|
+
def visitor_get(ctx: click.Context, visitor_id: str | None, account: str | None) -> None:
|
|
266
|
+
"""Get one visitor by id or account."""
|
|
267
|
+
|
|
268
|
+
if not visitor_id and not account:
|
|
269
|
+
raise click.UsageError("Provide either --id or --account.")
|
|
270
|
+
echo_json(call_client(ctx, lambda client: client.get_visitor(visitor_id=visitor_id, account=account)))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@visitor_group.command(name="create")
|
|
274
|
+
@click.option("--remark", default=None, help="Visitor remark, 2-14 Chinese or English letters.")
|
|
275
|
+
@click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
|
|
276
|
+
@add_interactive_option
|
|
277
|
+
@click.pass_context
|
|
278
|
+
def visitor_create(ctx: click.Context, remark: str | None, dry_run: bool, interactive: bool | None) -> None:
|
|
279
|
+
"""Create a visitor account."""
|
|
280
|
+
|
|
281
|
+
values = resolve_command_inputs(
|
|
282
|
+
schema=VISITOR_CREATE_SCHEMA,
|
|
283
|
+
provided={"remark": remark},
|
|
284
|
+
interactive=interactive,
|
|
285
|
+
usage="Usage: chatnet ecnu visitor create --remark TEXT [-i|-I]",
|
|
286
|
+
)
|
|
287
|
+
echo_json(call_client(ctx, lambda client: client.create_visitor(values["remark"], dry_run=dry_run)))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@visitor_group.command(name="update")
|
|
291
|
+
@click.option("--id", "visitor_id", default=None, help="Visitor record id.")
|
|
292
|
+
@click.option("--remark", default=None, help="Visitor remark, 2-14 Chinese or English letters.")
|
|
293
|
+
@click.option("--password", default=None, help="New visitor password.")
|
|
294
|
+
@click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
|
|
295
|
+
@add_interactive_option
|
|
296
|
+
@click.pass_context
|
|
297
|
+
def visitor_update(
|
|
298
|
+
ctx: click.Context,
|
|
299
|
+
visitor_id: str | None,
|
|
300
|
+
remark: str | None,
|
|
301
|
+
password: str | None,
|
|
302
|
+
dry_run: bool,
|
|
303
|
+
interactive: bool | None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Update visitor remark and password."""
|
|
306
|
+
|
|
307
|
+
values = resolve_command_inputs(
|
|
308
|
+
schema=VISITOR_UPDATE_SCHEMA,
|
|
309
|
+
provided={"visitor_id": visitor_id, "remark": remark, "password": password},
|
|
310
|
+
interactive=interactive,
|
|
311
|
+
usage="Usage: chatnet ecnu visitor update --id ID --remark TEXT --password TEXT [-i|-I]",
|
|
312
|
+
)
|
|
313
|
+
echo_json(
|
|
314
|
+
call_client(
|
|
315
|
+
ctx,
|
|
316
|
+
lambda client: client.update_visitor(
|
|
317
|
+
values["visitor_id"],
|
|
318
|
+
values["remark"],
|
|
319
|
+
values["password"],
|
|
320
|
+
dry_run=dry_run,
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@visitor_group.command(name="delete")
|
|
327
|
+
@click.option("--id", "visitor_id", default=None, help="Visitor record id.")
|
|
328
|
+
@click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
|
|
329
|
+
@add_interactive_option
|
|
330
|
+
@click.pass_context
|
|
331
|
+
def visitor_delete(ctx: click.Context, visitor_id: str | None, dry_run: bool, interactive: bool | None) -> None:
|
|
332
|
+
"""Delete a visitor account."""
|
|
333
|
+
|
|
334
|
+
values = resolve_command_inputs(
|
|
335
|
+
schema=VISITOR_ID_SCHEMA,
|
|
336
|
+
provided={"visitor_id": visitor_id},
|
|
337
|
+
interactive=interactive,
|
|
338
|
+
usage="Usage: chatnet ecnu visitor delete --id ID [-i|-I]",
|
|
339
|
+
)
|
|
340
|
+
echo_json(call_client(ctx, lambda client: client.delete_visitor(values["visitor_id"], dry_run=dry_run)))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@visitor_group.command(name="lock")
|
|
344
|
+
@click.option("--id", "visitor_id", default=None, help="Visitor record id.")
|
|
345
|
+
@click.option("--dry-run", is_flag=True, help="Print request spec without submitting.")
|
|
346
|
+
@add_interactive_option
|
|
347
|
+
@click.pass_context
|
|
348
|
+
def visitor_lock(ctx: click.Context, visitor_id: str | None, dry_run: bool, interactive: bool | None) -> None:
|
|
349
|
+
"""Lock a visitor account."""
|
|
350
|
+
|
|
351
|
+
values = resolve_command_inputs(
|
|
352
|
+
schema=VISITOR_ID_SCHEMA,
|
|
353
|
+
provided={"visitor_id": visitor_id},
|
|
354
|
+
interactive=interactive,
|
|
355
|
+
usage="Usage: chatnet ecnu visitor lock --id ID [-i|-I]",
|
|
356
|
+
)
|
|
357
|
+
echo_json(call_client(ctx, lambda client: client.lock_visitor(values["visitor_id"], dry_run=dry_run)))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def make_client(ctx: click.Context) -> Any:
|
|
361
|
+
from .portal import PortalClient
|
|
362
|
+
|
|
363
|
+
config = ctx.find_root().obj or ctx.obj or {}
|
|
364
|
+
return PortalClient(
|
|
365
|
+
base_url=config["base_url"],
|
|
366
|
+
state_file=config["state_file"],
|
|
367
|
+
cookie_header=config.get("cookie"),
|
|
368
|
+
timeout=config["timeout"],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def call_client(ctx: click.Context, func: Callable[[Any], Any]) -> Any:
|
|
373
|
+
try:
|
|
374
|
+
return func(make_client(ctx))
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
raise click.ClickException(str(exc)) from exc
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def echo_json(data: Any) -> None:
|
|
380
|
+
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def load_chatenv(env_profile: str | None = None, env_file: str | None = None) -> None:
|
|
384
|
+
envs_dir = get_paths().envs_dir
|
|
385
|
+
if env_profile:
|
|
386
|
+
BaseEnvConfig.load_all_with_override(
|
|
387
|
+
envs_dir,
|
|
388
|
+
ECNUConfig.get_profile_env_file(envs_dir, env_profile),
|
|
389
|
+
)
|
|
390
|
+
return
|
|
391
|
+
if env_file:
|
|
392
|
+
BaseEnvConfig.load_all_with_override(envs_dir, Path(env_file).expanduser())
|
|
393
|
+
return
|
|
394
|
+
BaseEnvConfig.load_all(envs_dir)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def default_state_file() -> Path:
|
|
398
|
+
return get_paths().home_dir / "cache" / "chatnet" / "ecnu-session.json"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def default_captcha_path() -> Path:
|
|
402
|
+
return get_paths().home_dir / "cache" / "chatnet" / "ecnu-login-captcha.png"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def redact_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
406
|
+
redacted = dict(state)
|
|
407
|
+
cookies = redacted.get("cookies")
|
|
408
|
+
if isinstance(cookies, dict):
|
|
409
|
+
redacted["cookies"] = {key: "***" for key in cookies}
|
|
410
|
+
return redacted
|