- 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 类型解析正确)
1122 lines
40 KiB
Python
1122 lines
40 KiB
Python
#!/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()) |