Files
openapi/source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py
T
RRRosmontis 9b9c501fa9 feat(dufe): 新增 mode=auto 自动签到,非签到码逻辑以 bot 为基准同步
- mode=auto: 通知列表(getMessageAll+busiMessage/list)发现签到活动
  (bot ginkgo 同款过滤, 按 signInId 去重) → 逐个自动签到
- 连续重试: 首次+最多5次间隔3s (对齐 bot MAX_SIGNIN_ATTEMPTS/SIGNIN_BACKOFF),
  token 失效重登录一次立即重试; 限流中断本轮; 结果聚合 data.results 列表
- 基准同步 (bot ginkgo): 移植限流防护体系(check_response/is_rate_limited/
  safe_error/_LoginErrors/_request), parse_course_url courseId 放宽 [0-9A-Za-z]+,
  commit_sign_in 错误包装, fetch_sign_in_info 增加 check_response 与
  sess/token 注入 (auto 模式请求内复用登录态)
- 验证: 免凭据单测 + 假凭据 401 线路测试 + bj 真账密只读联调
  (notices=20/busi=18/活动12 类型解析正确)
2026-09-23 11:53:12 +08:00

1122 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ginkgo (DUFE) sign-in code query service.
Endpoint: GET|POST /dufe/ginkgostu_sign_in
params: username, password(base64), url(base64)
[commit=true] optional, default true: also POST the code back
[mode=auto] optional: fetch the notification list, discover
sign-in activities and auto sign-in each one
(bot ginkgo parity, consecutive retries);
results returned as an aggregated list.
mode=auto 时 url 参数可省略。
Listen: 127.0.0.1:9005 (WSGI: waitress)
"""
import base64
import binascii
import html as _html
import json
import logging
import re
import sys
import time
import urllib.parse
import uuid
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from html.parser import HTMLParser
try:
import requests
except ImportError:
sys.exit("Missing dependency, run: pip install requests")
try:
from flask import Flask, request, jsonify
except ImportError:
sys.exit("Missing dependency, run: pip install flask")
try:
from waitress import serve
except ImportError:
sys.exit("Missing dependency, run: pip install waitress")
requests.packages.urllib3.disable_warnings()
# ------------------------------------------------------------------------------
# Site constants
# ------------------------------------------------------------------------------
GINKGO = "https://ginkgostu.dufe.edu.cn"
SSO = "https://sso.dufe.edu.cn"
CUSTOM_CODE = "kb_dconline"
NOTICE_PAGE = GINKGO + "/notice"
SYSTEM_PAGE = GINKGO + "/notice/system"
PAGE_SIZE = 20 # 单类单次抓取条数(与 bot ginkgo 一致)
# 自动签到重试参数(与 bot ginkgo 的 MAX_SIGNIN_ATTEMPTS / SIGNIN_BACKOFF 一致:
# 首次 + 最多 5 次重试,每次间隔 3 秒)
MAX_SIGNIN_ATTEMPTS = 6
SIGNIN_BACKOFF = (3, 3, 3, 3, 3)
TZ8 = timezone(timedelta(hours=8))
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("ginkgo")
# ------------------------------------------------------------------------------
# Error taxonomy
# AuthError -> 401 caller-side credential / captcha problem
# NoSignInCode -> 404 activity exists but has no code configured
# UpstreamError -> 502 remote service misbehaved / changed shape
# anything else -> 500 internal, generic message to the client
# ------------------------------------------------------------------------------
class AuthError(Exception):
"""Authentication failed (bad credentials, captcha rejected, locked account)."""
class NoSignInCodeError(Exception):
"""The numeric-code activity exists but has no code configured."""
class UnsupportedSigninType(Exception):
"""The activity requires a flow this service cannot safely automate."""
SIGNIN_TYPE_NAMES = {
0: "二维码签到",
1: "数字码签到",
2: "普通签到",
3: "直播签到",
}
def signin_type_label(value):
try:
signin_type = int(value)
except (TypeError, ValueError):
return "未知签到类型"
return SIGNIN_TYPE_NAMES.get(signin_type, "未知签到类型(%s)" % signin_type)
class UpstreamError(Exception):
"""Remote service returned an unexpected or erroneous response."""
class RateLimitedError(Exception):
"""An upstream explicitly refused requests due to throttling."""
# ------------------------------------------------------------------------------
# Rate-limit detection & credential-safe errors
# (synced from bot ginkgostu.py 2026-09-23; bot is the baseline)
# ------------------------------------------------------------------------------
_RATE_MESSAGE = re.compile(
r"too\s+many\s+requests|rate[\s_-]*limit(?:ed|ing|\s+exceeded)?|"
r"request(?:s)?\s+throttl|throttl(?:ed|ing)|"
r"(?:请求|访问|操作|登录|登陆|接口|提交).{0,12}(?:过于频繁|太频繁|频繁|频率.{0,6}(?:超|限制))|"
r"(?:限流|请求次数.{0,8}(?:超限|超出|超过)|访问次数.{0,8}(?:超限|超出|超过))",
re.I,
)
_RATE_CODES = {"429", "4290", "40029", "90029", "RATE_LIMIT", "RATE_LIMITED", "RATE_LIMIT_EXCEEDED",
"TOO_MANY_REQUESTS", "REQUEST_TOO_FREQUENT", "THROTTLED",
"THROTTLING", "FREQUENT_REQUESTS"}
_FIELDS = ("code", "msg", "message", "error")
def _status(value):
try:
return int(value)
except (ValueError, TypeError):
return 0
def _explicit(value, depth=0):
if depth > 3:
return False
if isinstance(value, dict):
for key in _FIELDS:
item = value.get(key)
if key == "code" and isinstance(item, (str, int)):
if str(item).strip().upper().replace("-", "_") in _RATE_CODES:
return True
elif isinstance(item, dict):
if _explicit(item, depth + 1):
return True
elif isinstance(item, str) and _RATE_MESSAGE.search(item[:1024]):
return True
return False
class _LoginErrors(HTMLParser):
"""Extract only explicitly marked login error elements; ignore scripts/notices."""
def __init__(self):
super().__init__()
self.stack = []
self.messages = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
marker = (attrs.get("id", "") + " " + attrs.get("class", "")).lower()
explicit = bool(re.search(r"(?:^|[\s_-])(?:error|errors|errormsg|errormessage)(?:$|[\s_-])", marker))
ignored = tag in ("script", "style", "template") or bool(self.stack and self.stack[-1][2])
active = explicit or bool(self.stack and self.stack[-1][1])
if tag not in ("input", "br", "img", "meta", "link", "hr", "wbr", "source"):
self.stack.append((tag, active, ignored))
def handle_endtag(self, tag):
for index in range(len(self.stack) - 1, -1, -1):
if self.stack[index][0] == tag:
del self.stack[index:]
break
def handle_data(self, data):
if self.stack and self.stack[-1][1] and not self.stack[-1][2]:
self.messages.append(data)
def is_rate_limited(status, payload=None, text=""):
"""Check HTTP status and explicit error fields, never arbitrary payload data/HTML."""
status = _status(status)
if status == 429 or _explicit(payload):
return True
# Plain response bodies are evidence only on HTTP errors, and must be short.
if status >= 400 and isinstance(text, str):
plain = text.strip()
if len(plain) <= 1024 and not re.search(r"<[^>]+>", plain):
return bool(_RATE_MESSAGE.search(plain))
return False
def _valid_retry_after(value):
if not isinstance(value, str) or not value.strip():
return False
value = value.strip()
if re.fullmatch(r"[0-9]+", value):
return True
try:
return parsedate_to_datetime(value).tzinfo is not None
except (ValueError, TypeError, OverflowError):
return False
def check_response(response, what="upstream"):
"""Return response unchanged, or raise RateLimitedError before caller parsing.
`what` is deliberately not interpolated: URLs and upstream bodies may contain
credentials. Safe to use as a requests response hook through a small lambda.
"""
if response is None:
return response
status = _status(getattr(response, "status_code", 0))
message = "上游请求过于频繁,请稍后重试"
if status == 429:
raise RateLimitedError(message)
headers = getattr(response, "headers", {}) or {}
retry_after = next((v for k, v in headers.items() if str(k).lower() == "retry-after"), None)
if status >= 400 and _valid_retry_after(retry_after):
raise RateLimitedError(message)
try:
payload = response.json()
except (ValueError, TypeError, AttributeError):
payload = None
text = getattr(response, "text", "") or ""
if is_rate_limited(status, payload, text):
raise RateLimitedError(message)
# Login pages can return HTTP 200 with an explicit error element. Never scan
# the whole successful HTML document, inline scripts, or announcement text.
if payload is None and isinstance(text, str) and "<" in text:
parser = _LoginErrors()
try:
parser.feed(text[:262144])
except (ValueError, TypeError):
pass
if _RATE_MESSAGE.search(" ".join(parser.messages)[:1024]):
raise RateLimitedError(message)
return response
def safe_error(exception):
"""Propagate explicit throttling, otherwise return a credential-free diagnostic.
Never stringify arbitrary exceptions: requests URLs, query strings, tokens,
passwords and JSON decode snippets can all include secrets.
"""
if isinstance(exception, RateLimitedError):
raise exception
response = getattr(exception, "response", None)
if response is not None:
check_response(response, "request error")
status = _status(getattr(response, "status_code", 0))
if status:
return "upstream HTTP %d" % status
return "upstream request or response error (%s)" % type(exception).__name__
# ==============================================================================
# Part 1: Pure-python AES-128
# ==============================================================================
_SBOX = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
]
_INV_SBOX = [0] * 256
for _i, _v in enumerate(_SBOX):
_INV_SBOX[_v] = _i
_RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
def _gmul(a, b):
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xFF
if hi:
a ^= 0x1B
b >>= 1
return p
def _expand_key(key):
w = [list(key[4 * i:4 * i + 4]) for i in range(4)]
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i // 4 - 1]
w.append([w[i - 4][j] ^ t[j] for j in range(4)])
return w
def _add_round_key(s, w, rnd):
for c in range(4):
for r in range(4):
s[r][c] ^= w[rnd * 4 + c][r]
def _sub_bytes(s, box):
for r in range(4):
for c in range(4):
s[r][c] = box[s[r][c]]
def _shift_rows(s):
for r in range(1, 4):
s[r] = s[r][r:] + s[r][:r]
def _inv_shift_rows(s):
for r in range(1, 4):
s[r] = s[r][-r:] + s[r][:-r]
def _mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0], 2) ^ _gmul(a[1], 3) ^ a[2] ^ a[3]
s[1][c] = a[0] ^ _gmul(a[1], 2) ^ _gmul(a[2], 3) ^ a[3]
s[2][c] = a[0] ^ a[1] ^ _gmul(a[2], 2) ^ _gmul(a[3], 3)
s[3][c] = _gmul(a[0], 3) ^ a[1] ^ a[2] ^ _gmul(a[3], 2)
def _inv_mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0],14) ^ _gmul(a[1],11) ^ _gmul(a[2],13) ^ _gmul(a[3],9)
s[1][c] = _gmul(a[0],9) ^ _gmul(a[1],14) ^ _gmul(a[2],11) ^ _gmul(a[3],13)
s[2][c] = _gmul(a[0],13) ^ _gmul(a[1],9) ^ _gmul(a[2],14) ^ _gmul(a[3],11)
s[3][c] = _gmul(a[0],11) ^ _gmul(a[1],13) ^ _gmul(a[2],9) ^ _gmul(a[3],14)
class AES128:
def __init__(self, key):
if len(key) != 16:
raise ValueError("AES-128 requires a 16-byte key, got %d" % len(key))
self.w = _expand_key(key)
def encrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 0)
for rnd in range(1, 10):
_sub_bytes(s, _SBOX)
_shift_rows(s)
_mix_columns(s)
_add_round_key(s, self.w, rnd)
_sub_bytes(s, _SBOX)
_shift_rows(s)
_add_round_key(s, self.w, 10)
return bytes(s[r][c] for c in range(4) for r in range(4))
def decrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 10)
for rnd in range(9, 0, -1):
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, rnd)
_inv_mix_columns(s)
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, 0)
return bytes(s[r][c] for c in range(4) for r in range(4))
def _pkcs7_unpad(data):
if not data:
return data
n = data[-1]
if 1 <= n <= 16 and data[-n:] == bytes([n]) * n:
return data[:-n]
return data
def _zero_pad(data):
if len(data) % 16:
data += b"\x00" * (16 - len(data) % 16)
return data
def aes_ecb_decrypt_b64(cipher_b64, key_text):
aes = AES128(key_text[:16].encode("utf-8"))
raw = base64.b64decode(cipher_b64)
out = b"".join(aes.decrypt_block(raw[i:i + 16]) for i in range(0, len(raw), 16))
return _pkcs7_unpad(out)
def aes_cbc_encrypt_b64_zero(plain, key_text):
key = key_text[:16].encode("utf-8")
aes = AES128(key)
iv = key
data = _zero_pad(plain.encode("utf-8"))
out, prev = b"", iv
for i in range(0, len(data), 16):
blk = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
prev = aes.encrypt_block(blk)
out += prev
return base64.b64encode(out).decode("ascii")
# ==============================================================================
# Part 2: Session & URL parsing
# ==============================================================================
def new_session():
s = requests.Session()
s.trust_env = False
s.verify = False
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
return s
def parse_course_url(url):
parts = urllib.parse.urlsplit(str(url or ""))
m = re.fullmatch(
r"/course/detail/(?P<courseId>[0-9A-Za-z]+)/(?P<batchNo>[^/]+)/"
r"(?P<weekTimeId>\d+)/signin/(?P<signInId>\d+)/(?P<type>\d+)/?",
parts.path)
try:
port = parts.port
except ValueError:
port = -1
if (parts.scheme != "https" or parts.username is not None or parts.password is not None
or port not in (None, 443)
or (parts.hostname or "").lower() != "ginkgostu.dufe.edu.cn" or not m):
raise ValueError(
"URL is not a sign-in page (missing /signin/<signInId>/<type>)")
return m.groupdict()
# ==============================================================================
# Part 3: Login flow
# ==============================================================================
def get_unify_config(sess):
r = sess.get(GINKGO + "/student/getLoginUnifySet", timeout=20)
r.raise_for_status()
try:
return r.json()["data"]
except (ValueError, KeyError) as e:
raise UpstreamError("Malformed getLoginUnifySet response: %s" % e)
def open_authorize(sess, cfg):
params = {
"response_type": cfg["responseType"],
"client_id": cfg["clientId"],
"redirect_uri": cfg["rollBackUrl"],
"scope": cfg.get("scope", "") or "",
"state": cfg.get("state", "") or "",
}
r = sess.get(cfg["loginUrl"], params=params, timeout=20, allow_redirects=True)
r.raise_for_status()
m = re.search(r'name="__token__"\s+value="([^"]+)"', r.text)
if not m:
raise UpstreamError("CSRF token __token__ not found on login page")
return m.group(1), r.url
def exploit_slider(sess, referer):
url = (SSO + "/auth/widget?layer=image_verify&widget=Slider"
"&action=get_verify&type=login_image_verify")
r = sess.post(url, data={"width": 280, "height": 155, "block_size": 80},
headers={"X-Requested-With": "XMLHttpRequest", "Referer": referer},
timeout=20)
r.raise_for_status()
try:
d = r.json()["data"]
token, cipher = d["token"], d["d"]
except (ValueError, KeyError, TypeError) as e:
raise UpstreamError("Malformed slider challenge response: %s" % e)
x = aes_ecb_decrypt_b64(cipher, token).decode("utf-8", "replace")
return {"token": token, "x": x, "cipher": cipher}
def submit_login(sess, post_url, csrf, username, password, slider, referer):
form = {
"fingerprint": uuid.uuid4().hex,
"username": username,
"password": aes_cbc_encrypt_b64_zero(password, csrf),
"verify_token": slider["token"],
"verify_code": slider["x"],
"__token__": csrf,
}
return sess.post(post_url, data=form, timeout=25, allow_redirects=False,
headers={"Referer": referer, "Origin": SSO,
"Content-Type": "application/x-www-form-urlencoded"})
def exchange_token(sess, cfg, code):
r = sess.post(GINKGO + "/student/authLogin", data={"code": code},
timeout=20,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
j = r.json()
except ValueError:
raise UpstreamError("authLogin returned non-JSON") from None
if not isinstance(j, dict) or j.get("code") != "2000":
raise AuthError("OAuth code exchange refused")
return j["data"]["token"]
def login(username, password):
"""Complete SSO login flow. Returns (sess, token).
auto 模式一次登录后复用同一 session/token 逐个活动签到,
对齐 bot ginkgo 复用轮询缓存登录态的思路(请求内级)。"""
sess = new_session()
cfg = get_unify_config(sess)
csrf, page_url = open_authorize(sess, cfg)
slider = exploit_slider(sess, page_url)
resp = submit_login(sess, page_url, csrf, username, password, slider, page_url)
loc = resp.headers.get("Location", "")
m = re.search(r"[?&]code=([A-Za-z0-9_\-]+)", loc)
if not m:
raise AuthError(
"Login failed: no OAuth code in redirect (HTTP %s)" % resp.status_code)
token = exchange_token(sess, cfg, m.group(1))
return sess, token
# ==============================================================================
# Part 4: Sign-in API
# ==============================================================================
def _request(sess, method, *args, **kwargs):
"""Also guard fake sessions and HTTPError.response before any JSON parsing."""
try:
response = getattr(sess, method)(*args, **kwargs)
except requests.RequestException as exc:
raise UpstreamError(safe_error(exc)) from None
return check_response(response, "signin")
def api_headers(token, fingerprint=None):
return {
"User-Agent": UA,
"Origin": GINKGO,
"Referer": GINKGO + "/",
"Content-Type": "application/x-www-form-urlencoded",
"token": token,
"fingerprint": fingerprint or uuid.uuid4().hex,
}
def call_api(sess, path, data, token):
return _request(sess, "post", GINKGO + path, data=data, timeout=20,
headers=api_headers(token))
def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
"""
POST /student/signIn/InTheSign to actually check in with the given code.
Mirrors the browser request:
headers: batchno, fingerprint, token, Referer (course page)
body: signInId=<id>&code=<code>
Returns (ok: bool, message: str, raw: dict|None).
"""
headers = api_headers(token)
if batch_no:
# NOTE: the browser sends a "batchno" header that does not always equal
# the batchNo segment of the course URL. We pass the URL segment here;
# the server appears not to enforce it.
headers["batchno"] = str(batch_no)
headers["Referer"] = referer
try:
r = _request(sess, "post",
GINKGO + "/student/signIn/InTheSign",
data={"signInId": str(sign_in_id), "code": str(code)},
timeout=20,
headers=headers,
)
except (requests.RequestException, UpstreamError) as e:
return False, "request to InTheSign failed: %s" % safe_error(e), None
if r.status_code != 200:
return False, "InTheSign returned HTTP %d" % r.status_code, None
try:
j = r.json()
except ValueError:
return False, "InTheSign returned non-JSON", None
if not isinstance(j, dict):
return False, "Malformed InTheSign response", None
api_code = str(j.get("code", ""))
if api_code == "2000":
return True, "accepted", j
# Never return/log arbitrary upstream messages or bodies: they may contain secrets.
return False, "InTheSign refused the sign-in request", j
def fetch_sign_in_info(username, password, course_url, do_commit=True,
sess=None, token=None):
route = parse_course_url(course_url)
sign_in_id = route["signInId"]
batch_no = route.get("batchNo")
if sess is None or not token:
# 无外部登录态时自登录(保持向后兼容):直接复用上方 SSO 流程
sess, token = login(username, password)
# 否则:sess/token 由调用方提供(auto 模式请求内复用同一登录态),跳过 SSO 登录
r = call_api(sess, "/student/signIn/getSignInInfo",
{"signInId": sign_in_id}, token)
check_response(r, "getSignInInfo")
try:
j = r.json()
except ValueError:
raise UpstreamError("getSignInInfo returned non-JSON") from None
if not isinstance(j, dict):
raise UpstreamError("Malformed getSignInInfo response")
data = j.get("data") or {}
if not isinstance(data, dict):
raise UpstreamError("Malformed getSignInInfo data")
model = data.get("signInModel") or {}
if not isinstance(model, dict):
raise UpstreamError("Malformed getSignInInfo model")
code = model.get("code")
raw_type = model.get("type")
if raw_type is None or raw_type == "":
raw_type = route.get("type")
try:
signin_type = int(raw_type)
except (TypeError, ValueError):
raise UpstreamError("Malformed sign-in type") from None
type_name = signin_type_label(signin_type)
if signin_type == 1 and (code is None or code == ""):
raise NoSignInCodeError("数字码签到未配置签到码")
if signin_type == 2:
code = ""
result = {
"signInId": sign_in_id,
"signInCode": code,
"type": signin_type,
"typeName": type_name,
"title": model.get("title"),
"status": data.get("signStatus"),
}
if do_commit:
if signin_type not in (1, 2):
result["commit"] = {
"success": False,
"message": "%s暂不支持自动签到,需特定流程" % type_name,
}
else:
canonical_referer = GINKGO + urllib.parse.urlsplit(course_url).path
ok, reason, _raw = commit_sign_in(
sess, token, sign_in_id, batch_no, canonical_referer, code
)
result["commit"] = {"success": ok, "message": reason}
return result
# ==============================================================================
# Part 4b: Auto sign-in (mode=auto)
# 通知列表发现签到活动并逐个自动签到 —— 逻辑对齐 bot ginkgo 模块:
# fetch_all(通知+互动消息)→ _validated_signin_item(busi + 文本含"签到" +
# 可解析课程链接)→ 按 signInId 去重 → 每活动连续重试(首次+最多5次,间隔3s,
# token 失效重登录一次)→ 结果聚合为列表返回。
# ==============================================================================
_TAG_RE = re.compile(r"<[^>]+>")
def _html_to_text(raw):
if not raw:
return ""
text = re.sub(r"(?i)<br\s*/?>", "\n", raw)
text = re.sub(r"(?i)</(p|div|tr|li)>", "\n", text)
text = _TAG_RE.sub("", text)
text = _html.unescape(text)
text = re.sub(r"[ \t\u3000]+", " ", text)
text = re.sub(r"\n{2,}", "\n", text)
return text.strip()
def _parse_ts(value):
text = (value or "").strip()
if not text:
return 0.0
if text.endswith("Z"):
text = text[:-1] + "+00:00"
m = re.search(r"([+-]\d{2})(\d{2})$", text)
if m:
text = text[:m.start()] + m.group(1) + ":" + m.group(2)
try:
dt = datetime.fromisoformat(text)
except ValueError:
try:
dt = datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S")
except ValueError:
return 0.0
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TZ8)
return dt.timestamp()
def _normalize_notice(raw):
title = (raw.get("title") or "").strip() or "(无标题)"
ts = _parse_ts(raw.get("createTime"))
return {
"kind": "notice",
"key": "N%s" % raw.get("id"),
"id": str(raw.get("id") or ""),
"title": title,
"text": _html_to_text(raw.get("content") or ""),
"ts": ts,
"link": NOTICE_PAGE,
"jump": "",
}
def _normalize_busi(raw):
text = _html_to_text(raw.get("content") or "")
jump = (raw.get("jumpUrl") or "").strip()
if jump.startswith("http"):
link = jump
elif jump.startswith("/"):
link = GINKGO + jump
else:
link = SYSTEM_PAGE
ts = _parse_ts(raw.get("createTime"))
return {
"kind": "busi",
"key": "B%s" % (raw.get("uuid") or ""),
"uuid": str(raw.get("uuid") or ""),
"id": "",
"title": text[:120] or "(无内容)",
"text": text,
"ts": ts,
"link": link,
"jump": jump,
}
def _api_headers(token):
return {
"User-Agent": UA,
"Origin": GINKGO,
"Referer": GINKGO + "/",
"Content-Type": "application/x-www-form-urlencoded",
"token": token,
"fingerprint": uuid.uuid4().hex,
}
def _call_json(sess, token, method, path, params=None, body=None, what="接口"):
"""消息类 API 调用(对齐 bot _call):check_response + code 必须为 2000。"""
headers = _api_headers(token)
if method == "GET":
r = sess.get(GINKGO + path, params=params, headers=headers, timeout=20)
else:
headers["Content-Type"] = "application/json"
r = sess.post(GINKGO + path, data=json.dumps(body if body is not None else {}),
headers=headers, timeout=20)
check_response(r, what)
try:
payload = r.json()
except ValueError:
raise UpstreamError("%s returned non-JSON (HTTP %s)" % (what, r.status_code))
if not isinstance(payload, dict):
raise UpstreamError("%s JSON must be an object" % what)
if is_rate_limited(None, payload):
raise RateLimitedError("%s 触发频率限制" % what)
if str(payload.get("code")) != "2000":
raise UpstreamError("%s returned code=%s" % (what, payload.get("code")))
return payload
def fetch_message_all(sess, token, page_size=PAGE_SIZE):
"""取两类消息(均为全部,含已读),返回 (notices, busi) 归一化列表。"""
j1 = _call_json(sess, token, "GET", "/student/message/getMessageAll",
params={"pageSize": page_size, "pageNo": 1, "readFlag": 0},
what="通知列表")
notices = [_normalize_notice(x) for x in ((j1.get("data") or {}).get("list") or [])]
j2 = _call_json(sess, token, "POST", "/student/busiMessage/list", body={},
what="互动消息列表")
busi = [_normalize_busi(x) for x in (j2.get("data") or [])]
return notices, busi
def _validated_signin_item(item):
"""Return an annotated copy only for a real interactive sign-in activity.
与 bot ginkgo 的 _validated_signin_item 同逻辑:仅互动消息、文本含"签到"、
跳转链接可解析为课程签到路由。query 参数可能携带 token,不入库不回显。"""
if (not isinstance(item, dict) or item.get("kind") != "busi"
or "签到" not in str(item.get("text") or "")):
return None
link = str(item.get("link") or "").strip()
try:
route = parse_course_url(link)
except (ValueError, TypeError, AttributeError):
return None
row = dict(item)
# Persist/display only the canonical path: query parameters may carry tokens
# and must never reach receipts or push logs.
row["link"] = GINKGO + urllib.parse.urlsplit(link).path
# signInId is the stable activity identity(同一活动的多条消息去重)
row["signin_activity_key"] = "S%s" % route["signInId"]
try:
row["signin_type"] = int(route["type"])
except (TypeError, ValueError, KeyError):
return None
row["signin_type_name"] = signin_type_label(row["signin_type"])
return row
def _signin_one_with_retry(username, password, row, sess, token):
"""对单个活动执行签到:失败连续重试(首次 + 最多 5 次,间隔 3s),
token 疑似失效(上游错误/登录错)时重新登录一次后立即重试(对齐 bot refresh 语义)。
与 bot complete_signin 一致:除限流(外层中断)外,任何未确认成功都消耗重试次数;
重试用尽记最终失败。返回 (entry, sess, token)。"""
link = row["link"]
base = {
"activityKey": row.get("signin_activity_key"),
"signInId": parse_course_url(link).get("signInId"),
"routeType": row.get("signin_type"),
"routeTypeName": row.get("signin_type_name"),
"link": link,
}
attempts = 0
refreshed = False
last_err = "unknown error"
last_data = None
while attempts < MAX_SIGNIN_ATTEMPTS:
attempts += 1
try:
data = fetch_sign_in_info(username, password, link, do_commit=True,
sess=sess, token=token)
last_data = data
commit = data.get("commit")
if isinstance(commit, dict) and commit.get("success") is True:
entry = dict(base)
entry.update({
"type": data.get("type"),
"typeName": data.get("typeName"),
"title": data.get("title"),
"signInCode": data.get("signInCode"),
"status": data.get("status"),
"success": True,
"message": "签到成功",
"attempts": attempts,
})
return entry, sess, token
message = ((commit or {}).get("message")
or "no commit result returned")
last_err = str(message)
except RateLimitedError:
raise
except (UpstreamError, AuthError) as e:
# 疑似 token 失效:重登录一次后立即重试(不消耗退避间隔,对齐 bot)
if not refreshed:
refreshed = True
try:
sess, token = login(username, password)
continue
except Exception:
pass # 重登录也失败:落入常规退避重试
last_err = str(e) or type(e).__name__
except NoSignInCodeError as e:
last_err = str(e)
except Exception as e:
last_err = safe_error(e)
if attempts < MAX_SIGNIN_ATTEMPTS:
time.sleep(SIGNIN_BACKOFF[min(attempts - 1, len(SIGNIN_BACKOFF) - 1)])
entry = dict(base)
if isinstance(last_data, dict):
entry.update({
"type": last_data.get("type"),
"typeName": last_data.get("typeName"),
"title": last_data.get("title"),
"signInCode": last_data.get("signInCode"),
"status": last_data.get("status"),
})
entry.update({"success": False,
"message": "签到失败(重试 %d 次): %s" % (attempts, last_err),
"attempts": attempts})
return entry, sess, token
def auto_sign_in_all(username, password):
"""mode=auto 主流程。返回聚合结果 dict(results 为列表)。"""
sess, token = login(username, password)
notices, busi = fetch_message_all(sess, token)
activities = {}
for item in notices + busi:
row = _validated_signin_item(item)
if row:
activities.setdefault(row["signin_activity_key"], row)
results = []
rate_limited = False
for key in sorted(activities, key=lambda k: activities[k].get("ts") or 0):
row = activities[key]
try:
entry, sess, token = _signin_one_with_retry(
username, password, row, sess, token)
except RateLimitedError:
# 对齐 bot:限流即中断本轮,已完成的结果照常返回
rate_limited = True
break
results.append(entry)
succeeded = sum(1 for r in results if r.get("success"))
return {
"mode": "auto",
"activities": len(activities),
"total": len(results),
"succeeded": succeeded,
"failed": len(results) - succeeded,
"rate_limited": rate_limited,
"results": results,
}
# ==============================================================================
# Part 5: HTTP service (WSGI app)
# ==============================================================================
app = Flask(__name__)
def _b64_decode(value, field):
"""
Decode a base64 query/form parameter tolerantly.
Handles, in order:
* '%3D' / '%2B' / '%20' -- iOS Shortcuts and some HTTP clients
URL-encode the base64 payload (notably the trailing '='), so we
percent-decode first.
* '+' turned into ' ' by application/x-www-form-urlencoded.
* trailing newline from `base64` CLI without -w0.
* both standard (+/) and urlsafe (-_) alphabets.
"""
v = urllib.parse.unquote(value or "")
v = v.strip()
v = v.replace(" ", "+")
v = v.replace("\r", "").replace("\n", "")
if not v:
raise ValueError("Empty base64 for '%s'" % field)
try:
return base64.b64decode(v, altchars=b"-_", validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError) as e:
raise ValueError("Invalid base64 for '%s': %s" % (field, e))
def _error(http_status, code, msg):
return jsonify({"code": code, "msg": msg, "data": None}), http_status
@app.route("/dufe/ginkgostu_sign_in", methods=["GET", "POST"])
def ginkgostu_sign_in():
args = request.values
username = (args.get("username") or "").strip()
password_b64 = args.get("password") or ""
url_b64 = args.get("url") or ""
commit_arg = (args.get("commit") or "true").strip().lower()
do_commit = commit_arg not in ("0", "false", "no")
mode = (args.get("mode") or "").strip().lower()
if not (username and password_b64):
return _error(400, 400,
"Missing parameters: username, password(base64)")
if mode != "auto" and not url_b64:
return _error(400, 400,
"Missing parameters: username, password(base64), url(base64)")
try:
password = _b64_decode(password_b64, "password")
course_url = _b64_decode(url_b64, "url") if url_b64 else ""
except ValueError as e:
return _error(400, 400, str(e))
if mode == "auto":
return _auto_response(username, password)
log.info("query start user=%s commit=%s", username[:2] + "***", do_commit)
try:
data = fetch_sign_in_info(username, password, course_url, do_commit=do_commit)
if do_commit:
# Fail-closed: only an explicit success=true counts as signed in.
commit_info = data.get("commit")
if not isinstance(commit_info, dict) or commit_info.get("success") is not True:
reason = (commit_info.get("message") if isinstance(commit_info, dict) else None) or "no commit result returned"
log.warning("sign-in commit failed user=%s reason=%s",
username[:2] + "***", reason)
return jsonify({
"code": 200,
"msg": "sign-in failed: %s" % reason,
"data": data,
})
log.info("query ok user=%s type=%s", username[:2] + "***", data.get("type"))
return jsonify({"code": 200, "msg": "ok", "data": data})
except AuthError:
log.warning("authentication failed user=%s", username[:2] + "***")
return _error(401, 401, "Authentication failed")
except NoSignInCodeError:
log.info("numeric code missing user=%s", username[:2] + "***")
return _error(404, 404, "数字码签到未配置签到码")
except UpstreamError:
log.error("upstream response error user=%s", username[:2] + "***")
return _error(502, 502, "Upstream response error")
except ValueError:
log.warning("bad request user=%s", username[:2] + "***")
return _error(400, 400, "Invalid request")
except requests.RequestException:
log.error("upstream request failed user=%s", username[:2] + "***")
return _error(502, 502, "Upstream request failed")
except Exception as exc:
log.error("internal error type=%s", type(exc).__name__)
return _error(500, 500, "Internal error")
def _auto_response(username, password):
"""mode=auto:发现 + 自动签到 + 聚合列表返回。"""
log.info("auto sign-in start user=%s", username[:2] + "***")
try:
data = auto_sign_in_all(username, password)
except AuthError:
log.warning("auto sign-in authentication failed user=%s", username[:2] + "***")
return _error(401, 401, "Authentication failed")
except RateLimitedError:
log.warning("auto sign-in rate limited user=%s", username[:2] + "***")
return _error(429, 429, "Upstream rate limited, please retry later")
except UpstreamError:
log.error("auto sign-in upstream error user=%s", username[:2] + "***")
return _error(502, 502, "Upstream response error")
except requests.RequestException:
log.error("auto sign-in upstream request failed user=%s", username[:2] + "***")
return _error(502, 502, "Upstream request failed")
except Exception as exc:
log.error("auto sign-in internal error type=%s", type(exc).__name__)
return _error(500, 500, "Internal error")
log.info("auto sign-in done user=%s total=%s succeeded=%s rate_limited=%s",
username[:2] + "***", data["total"], data["succeeded"],
data["rate_limited"])
if data["rate_limited"]:
msg = ("partial: upstream rate limited, %d/%d activities processed"
% (data["total"], data["activities"]))
elif data["failed"]:
msg = "ok with failures: %d/%d succeeded" % (data["succeeded"], data["total"])
else:
msg = "ok"
return jsonify({"code": 200, "msg": msg, "data": data})
@app.route("/healthz", methods=["GET"])
def healthz():
return jsonify({"code": 200, "msg": "ok", "data": None})
# ==============================================================================
# Part 6: WSGI entrypoint
# ==============================================================================
HOST = "127.0.0.1"
PORT = 9005
THREADS = 16
BACKLOG = 128
CONNECTION_LIMIT = 256
CHANNEL_TIMEOUT = 120
def main():
log.info("serving on http://%s:%d (threads=%d)", HOST, PORT, THREADS)
serve(
app,
host=HOST,
port=PORT,
threads=THREADS,
backlog=BACKLOG,
connection_limit=CONNECTION_LIMIT,
channel_timeout=CHANNEL_TIMEOUT,
ident="ginkgo-signin",
)
return 0
if __name__ == "__main__":
sys.exit(main())