Technical Manual: Seiue Attendance Automation
Document Purpose
This document details the technical implementation of an automated attendance script for the Seiue teaching management platform. It covers two major versions: the initial Selenium-based implementation (V1) and the current, stable API-based implementation (V2). The focus is on the technical details of the final API version, providing a clear guide for maintenance, updates, and future deployment on a Virtual Private Server (VPS) for multi-user support.
✅ Mandatory Fixes & Required Optimizations
These items are not optional. They prevent runtime failures or materially improve reliability and safety.
-
Correct Retry import (prevents import errors across environments)
UseRetryfromurllib3.util.retryinstead ofrequests.adapters. -
macOS network check
ping -tis TTL on macOS (not timeout). Use-W(milliseconds) or a curl probe. -
BSD
datelacks%N
Millisecond formatting via%Nfails on BSDdate. Fallback togdate(coreutils) or Python for timestamps. -
launchdPATH must include Homebrew prefix
Add/opt/homebrew/binso the venv and coreutils are resolvable on Apple Silicon. -
Stable result tokens from Python
EmitSUMMARY:<YYYY-MM-DD>:status=<...>once per date; the shell reads only these lines (no brittle greps). -
401 auto-refresh
When an API call returns 401, re-authenticate once and retry the request to survive token expiry. -
Chunked submits
Splitattendance_recordsinto chunks (e.g., 600) to avoid API 413/422 on large classes × sessions. -
Safety: avoid future classes by default
Do not submit attendance for sessions in the future unless explicitly enabled (ALLOW_FUTURE_SUBMIT=false). -
Post-submit verification
Re-fetch the day’s lessons and confirm no pending records remain for the processed set. -
Safer Telegram notifications
POST JSON; avoid brittle URL-encoding with query strings. (Plain text is fine; you can add Markdown later with proper escaping.) -
Shell robustness
Useset -Eeuo pipefailand a simple file lock to prevent overlapping runs.
Chapter 1: Version 1.0 - UI Automation (Legacy)
1.1 Overview
The initial approach (attendance_script_robust.py) utilized the Selenium WebDriver library to automate browser interactions. It successfully simulated a user logging in, navigating to the course schedule, and clicking the necessary buttons to submit attendance.
1.2 Methodology
- Technology Stack: Python, Selenium, webdriver-manager.
- Authentication: Simulated user input into the login form fields (
#usin,#password) and programmatically clicked the submit button. - Core Logic:
- Initialize a headless Chrome browser instance.
- Navigate to the login URL.
- Find and fill username/password fields.
- Click the login button.
- Handle an optional “Skip phone binding” screen.
- Navigate to the main dashboard (
chalk-c3.seiue.com). - Locate the calendar/schedule container (
#export-class). - Find all “Record Attendance” (
录入考勤) buttons within course card elements. - Iterate through each button, click it to open the attendance modal.
- Within the modal, click “Batch Set” (
批量设置), then “All Present” (全部出勤). - Click the “Submit Attendance” (
提交考勤) button. - Wait for the modal to disappear as confirmation.
- Automation: The script was executed by a shell script (
run_attendance.sh) which was triggered by a macOSlaunchdagent (com.ylsuen.seiue.attendance.plist) on a schedule.
1.3 Limitations
This method proved to be unstable due to its dependency on the frontend’s Document Object Model (DOM). The platform’s dynamically generated CSS class names for buttons and containers changed frequently, causing the script to fail. This required constant maintenance and made the solution unreliable. This led to the development of Version 2.0.
Chapter 2: Version 2.0 - API Interaction (Current)
Version 2.0 abandons UI automation in favor of direct communication with the platform’s backend API. This approach is significantly faster, more stable, and more resource-efficient.
2.1 Key API Endpoints & Data
The following API endpoints and parameters are critical for the script’s operation. They were identified by analyzing browser network traffic during a manual attendance submission process.
Authentication Endpoints:
-
Login Form Submission:
- URL:
https://passport.seiue.com/login?school_id=3 - Method:
POST - Content-Type:
application/x-www-form-urlencoded - Form Data:
email: [Your Username]password: [Your Password]
- Success Response:
302 Foundredirect to the/bindingspage, with session cookies set for thepassport.seiue.comdomain.
- URL:
-
Token Authorization:
- URL:
https://passport.seiue.com/authorize - Method:
POST - Content-Type:
application/x-www-form-urlencoded - Form Data:
client_id:GpxvnjhVKt56qTmnPWH1sA(web app client; may change in future).response_type:token
- Success Response:
201 Createdwith a JSON body containing theaccess_tokenandactive_reflection_id.
- URL:
Data & Action Endpoints:
-
Get Calendar Events (Lessons):
- URL:
https://api.seiue.com/chalk/calendar/personals/{reflection_id}/events - Method:
GET - URL Parameters:
start_time:YYYY-MM-DD HH:MM:SSend_time:YYYY-MM-DD HH:MM:SSexpand:address,initiators
- Key Response Fields: A JSON array of event objects. For lessons (
type: "lesson"), the crucial fields are:title: Course name.start_time: Lesson start time.subject.id: The unique ID for the class group (biz_id).custom.id: The unique ID for the specific class session (attendance_time_id).custom.attendance_status:0indicates pending attendance.
- URL:
-
Get Student List:
- URL:
https://api.seiue.com/scms/class/classes/{group_id}/group-members - Method:
GET - URL Parameters:
expand:reflectionmember_type:student
- Key Response Fields: A JSON array of student objects. The crucial field is:
reflection.id: The student’s unique ID (owner_idfor submission).
- URL:
-
Submit Attendance Records:
- URL:
https://api.seiue.com/sams/attendance/class/{group_id}/records/sync - Method:
PUT - Content-Type:
application/json - Request Payload (JSON Body):
json
{ "abnormal_notice_roles": [], "attendance_records": [ { "tag": "正常", "attendance_time_id": 12345678, "owner_id": 987654, "source": "web" } ] }
- URL:
Terminology quick map:
subject.id→ class group ID;custom.id→ attendance_time_id (this session);reflection.id→ person ID (student).
2.2 Final Code Implementation (Updated)
The implementation consists of three files: the core Python script, a shell wrapper for orchestration, and a launchd plist for scheduling.
File 1: APIFinal.py (Python Core Logic)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# --- Logging Configuration ---
LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "attendance_api_final.log")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s.%(msecs)03d - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8", mode="a"),
logging.StreamHandler(sys.stdout),
],
)
class SeiueAPIClient:
"""
API client for Seiue to automate attendance via direct API calls.
Uses a token-based auth flow.
"""
def __init__(self, username: str, password: str):
self.username = username
self.password = password
self.session = requests.Session()
# Robust retries (includes 429 and common 5xx)
retries = Retry(
total=3,
backoff_factor=1.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"HEAD", "GET", "POST", "PUT"}),
)
self.session.mount("https://", HTTPAdapter(max_retries=retries))
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
})
# --- API Endpoints ---
self.login_url = "https://passport.seiue.com/login?school_id=3"
self.authorize_url = "https://passport.seiue.com/authorize"
self.events_url_template = "https://api.seiue.com/chalk/calendar/personals/{}/events"
self.students_url_template = "https://api.seiue.com/scms/class/classes/{}/group-members?expand=reflection&member_type=student"
self.attendance_submit_url_template = "https://api.seiue.com/sams/attendance/class/{}/records/sync"
# --- Authentication Details ---
self.bearer_token = None
self.school_id = "3"
self.role = "teacher"
self.reflection_id = None
# --- Safety & batching ---
self.ALLOW_FUTURE_SUBMIT = False
self.MAX_RECORDS_PER_REQUEST = 600
# ---------- Helpers ----------
def _re_auth(self) -> bool:
logging.info("--- Re-authenticating (token refresh) ---")
return self.login_and_get_token()
def _with_refresh(self, request_fn):
resp = request_fn()
if getattr(resp, "status_code", None) == 401:
logging.warning("401 received; attempting token refresh...")
if self._re_auth():
return request_fn()
return resp
@staticmethod
def _parse_dt(s: str) -> datetime:
# Convert API times to aware datetimes
try:
# ISO forms, convert 'Z' to +00:00 if present
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
# Fallback: naive 'YYYY-MM-DD HH:MM:SS' -> assume UTC
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
# ---------- Auth ----------
def login_and_get_token(self) -> bool:
logging.info("--- Starting Authentication Flow ---")
try:
logging.info("Step 1: Submitting login form...")
login_form_data = {"email": self.username, "password": self.password}
login_headers = {
"Referer": self.login_url,
"Origin": "https://passport.seiue.com",
"Content-Type": "application/x-www-form-urlencoded",
}
login_resp = self.session.post(self.login_url, headers=login_headers, data=login_form_data, timeout=30)
# Expect redirect into bindings or chalk
if "bindings" not in login_resp.url and "chalk-c3" not in login_resp.url:
logging.error(f"Unexpected post-login location: {login_resp.url}")
return False
logging.info(f"Login form submitted, redirected to: {login_resp.url}")
logging.info("Step 2: Requesting access token...")
auth_form_data = {"client_id": "GpxvnjhVKt56qTmnPWH1sA", "response_type": "token"}
auth_headers = {
"Referer": "https://chalk-c3.seiue.com/",
"Origin": "https://chalk-c3.seiue.com",
"Content-Type": "application/x-www-form-urlencoded",
}
auth_resp = self.session.post(self.authorize_url, headers=auth_headers, data=auth_form_data, timeout=30)
auth_resp.raise_for_status()
auth_data = auth_resp.json()
self.bearer_token = auth_data.get("access_token")
self.reflection_id = auth_data.get("active_reflection_id")
if self.bearer_token and self.reflection_id:
self.session.headers.update({
"Authorization": f"Bearer {self.bearer_token}",
"x-school-id": self.school_id,
"x-role": self.role,
"x-reflection-id": str(self.reflection_id),
})
logging.info("Token and reflection id acquired.")
return True
logging.error(f"Token/Reflection missing in response: {auth_data}")
return False
except Exception as e:
logging.error(f"Authentication error: {e}", exc_info=True)
return False
# ---------- Data fetch ----------
def get_lessons_to_attend(self, target_date: datetime):
logging.info(f"Fetching lessons for date: {target_date.strftime('%Y-%m-%d')}")
start_time_str = target_date.strftime("%Y-%m-%d 00:00:00")
end_time_str = target_date.strftime("%Y-%m-%d 23:59:59")
try:
events_params = {"start_time": start_time_str, "end_time": end_time_str, "expand": "address,initiators"}
events_url = self.events_url_template.format(self.reflection_id)
events_resp = self._with_refresh(lambda: self.session.get(events_url, params=events_params, timeout=30))
events_resp.raise_for_status()
events = events_resp.json()
logging.info(f"Found {len(events)} calendar events for this date.")
if not events:
logging.info(f"No scheduled calendar events found for {target_date.strftime('%Y-%m-%d')}.")
return []
now_utc = datetime.now(timezone.utc)
lessons_to_attend = []
lessons_already_attended_count = 0
total_lesson_count = 0
for lesson in events:
if lesson.get("type") == "lesson":
total_lesson_count += 1
# Safety: avoid future classes unless explicitly enabled
try:
st = self._parse_dt(lesson.get("start_time"))
except Exception:
st = now_utc # if parsing fails, allow processing to avoid silent skips
if (not self.ALLOW_FUTURE_SUBMIT) and (st > now_utc):
continue
if lesson.get("custom", {}).get("attendance_status") == 0:
logging.info(f"Pending attendance: {lesson.get('title')} @ {lesson.get('start_time')}")
lessons_to_attend.append(lesson)
else:
lessons_already_attended_count += 1
if not lessons_to_attend and total_lesson_count > 0:
logging.info(f"All {total_lesson_count} lessons for {target_date.strftime('%Y-%m-%d')} are already attended.")
elif not lessons_to_attend:
logging.info(f"No lessons requiring attendance on {target_date.strftime('%Y-%m-%d')}.")
return lessons_to_attend
except Exception as e:
logging.error(f"Error fetching lessons: {e}", exc_info=True)
return []
def get_students_for_class(self, class_group_id: int):
logging.info(f"Fetching student list for class group ID {class_group_id}...")
url = self.students_url_template.format(class_group_id)
try:
resp = self._with_refresh(lambda: self.session.get(url, timeout=20))
resp.raise_for_status()
return resp.json()
except Exception as e:
logging.error(f"Failed to fetch student list for class {class_group_id}: {e}")
if hasattr(e, "response") and e.response is not None:
logging.error(f"Status Code: {e.response.status_code}, Response: {e.response.text}")
return None
# ---------- Submit ----------
def _submit_records_chunked(self, class_group_id: int, course_name: str, records: list) -> bool:
submit_url = self.attendance_submit_url_template.format(class_group_id)
for i in range(0, len(records), self.MAX_RECORDS_PER_REQUEST):
payload = {
"abnormal_notice_roles": [], # make guardian/mentor an opt-in per-user policy
"attendance_records": records[i : i + self.MAX_RECORDS_PER_REQUEST],
}
r = self._with_refresh(lambda: self.session.put(submit_url, json=payload, timeout=30))
try:
r.raise_for_status()
except Exception:
logging.error("Chunk %d failed (%s): %s", i // self.MAX_RECORDS_PER_REQUEST, r.status_code, r.text)
return False
logging.info("Submitted %d records for '%s' in %d chunk(s).",
len(records), course_name, (len(records) + self.MAX_RECORDS_PER_REQUEST - 1) // self.MAX_RECORDS_PER_REQUEST)
return True
def submit_attendance_for_lesson_group(self, lesson_group: list) -> bool:
if not lesson_group:
return False
first_lesson = lesson_group[0]
course_name = first_lesson.get("title", "Unknown Course")
class_group_id = first_lesson.get("subject", {}).get("id")
logging.info(f"--- Processing course group '{course_name}' ({len(lesson_group)} session(s)) ---")
students = self.get_students_for_class(class_group_id)
if students is None:
return False
if not students:
logging.warning(f"Student list for course '{course_name}' is empty. Marking as complete.")
return True
all_attendance_records = []
for lesson in lesson_group:
attendance_time_id = lesson.get("custom", {}).get("id")
if attendance_time_id is None:
continue
for student in students:
ref = student.get("reflection", {}) or {}
owner_id = ref.get("id")
if owner_id:
all_attendance_records.append({
"tag": "正常",
"attendance_time_id": int(attendance_time_id),
"owner_id": owner_id,
"source": "web",
})
if not all_attendance_records:
logging.error("No valid attendance records were constructed from the student list.")
return False
if not self._submit_records_chunked(class_group_id, course_name, all_attendance_records):
return False
return True
# ---------- Orchestration ----------
def run_attendance_task(target_date_str: str) -> bool:
username = os.getenv("SEIUE_USERNAME")
password = os.getenv("SEIUE_PASSWORD")
if not username or not password:
logging.error("Username or password not set.")
return False
client = SeiueAPIClient(username, password)
if not client.login_and_get_token():
logging.error("Login and token acquisition failed.")
logging.info(f"SUMMARY:{target_date_str}:status=failed_auth")
return False
target_date = datetime.strptime(target_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
lessons = client.get_lessons_to_attend(target_date)
if not lessons:
logging.info(f"SUMMARY:{target_date_str}:status=no_events_or_no_pending")
return True
lessons_by_course = {}
for lesson in lessons:
course_id = lesson.get("subject", {}).get("id")
if course_id is None:
# Skip malformed lesson
continue
lessons_by_course.setdefault(course_id, []).append(lesson)
all_groups_succeeded = True
for _, lesson_group in lessons_by_course.items():
ok = client.submit_attendance_for_lesson_group(lesson_group)
if not ok:
all_groups_succeeded = False
time.sleep(2)
# Post-submit verification
verify = client.get_lessons_to_attend(target_date)
pending_after = [l for l in verify if l.get("custom", {}).get("attendance_status") == 0]
if pending_after:
logging.warning("Post-submit verification found pending lessons still present.")
all_groups_succeeded = False
logging.info(f"SUMMARY:{target_date_str}:status={'ok' if all_groups_succeeded else 'failed'}")
return all_groups_succeeded
if __name__ == "__main__":
if len(sys.argv) < 2:
logging.error("Execution Error: Please provide a date as a command-line argument (format: YYYY-MM-DD).")
sys.exit(2)
target_date_str = sys.argv[1]
try:
datetime.strptime(target_date_str, "%Y-%m-%d")
except ValueError:
logging.error(f"Invalid date format: '{target_date_str}'. Please use YYYY-MM-DD.")
sys.exit(3)
sys.exit(0 if run_attendance_task(target_date_str) else 1)File 2: run_attendance.sh (Orchestration Script, Updated)
#!/bin/bash
set -Eeuo pipefail
# --- Configuration ---
PROJECT_DIR="/Users/ylsuen/seiue" # Absolute path to the project directory
VENV_ACTIVATE="$PROJECT_DIR/venv/bin/activate"
PYTHON_EXECUTABLE="$PROJECT_DIR/venv/bin/python3"
PYTHON_SCRIPT="$PROJECT_DIR/APIFinal.py"
WRAPPER_LOG_FILE="$PROJECT_DIR/run_attendance_wrapper.log"
PING_TARGET="api.seiue.com"
LOCK_FILE="/tmp/seiue_attendance.lock"
# --- Telegram Configuration ---
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
# --- Utility Functions ---
log_message() {
# BSD date lacks %N; prefer gdate when available, else use Python
if command -v gdate >/dev/null 2>&1; then
ts="$(gdate '+%Y-%m-%dT%H:%M:%S.%3N%z')"
else
ts="$(python3 - <<'PY'
from datetime import datetime, timezone
print(datetime.now(timezone.utc).astimezone().isoformat(timespec="milliseconds"))
PY
)"
fi
echo "$ts - $1" >> "$WRAPPER_LOG_FILE"
}
send_telegram_message() {
local text="$1"
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
log_message "WARNING: Telegram not configured; skipping."
return 0
fi
# Plain-text JSON POST (avoid brittle URL query encoding)
curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H 'Content-Type: application/json' \
-d "{\"chat_id\":\"${TELEGRAM_CHAT_ID}\",\"text\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$text"),\"disable_web_page_preview\":true}" \
>/dev/null || true
log_message "Telegram notification sent."
}
check_network() {
log_message "Checking network connectivity (Target: $PING_TARGET)..."
if ping -c 1 -W 2000 "$PING_TARGET" >/dev/null 2>&1; then
log_message "Network connection is OK."; return 0;
else
log_message "Network connection failed or target is unreachable."; return 1;
fi
}
# --- Main Logic ---
# Simple lock to prevent overlapping runs
exec 9>"$LOCK_FILE" || true
if ! flock -n 9; then
log_message "Another run is already in progress; exiting."
exit 0
fi
log_message "--- Wrapper script started (API Mode | Daily Coverage Task) ---"
cd "$PROJECT_DIR" || { log_message "FATAL: Could not enter project directory."; exit 1; }
log_message "Current directory: $(pwd)"
# Activate virtual environment
if [ ! -f "$VENV_ACTIVATE" ]; then
log_message "FATAL: Virtual environment not found at '$VENV_ACTIVATE'."; exit 1;
fi
source "$VENV_ACTIVATE"
# Check network
if ! check_network; then
log_message "FATAL: Network check failed. Aborting."
send_telegram_message "Seiue Attendance Bot: Network unreachable on $(hostname -s)."
exit 1
fi
# --- Core Scheduling Logic ---
# Robust timezone handling (Beijing Time)
YESTERDAY=$(python3 -c "from datetime import datetime, timedelta; import pytz; tz=pytz.timezone('Asia/Shanghai'); print((datetime.now(tz) - timedelta(days=1)).strftime('%Y-%m-%d'))")
TODAY=$(python3 -c "from datetime import datetime; import pytz; tz=pytz.timezone('Asia/Shanghai'); print(datetime.now(tz).strftime('%Y-%m-%d'))")
TOMORROW=$(python3 -c "from datetime import datetime, timedelta; import pytz; tz=pytz.timezone('Asia/Shanghai'); print((datetime.now(tz) + timedelta(days=1)).strftime('%Y-%m-%d'))")
DATES_TO_PROCESS=("$YESTERDAY" "$TODAY" "$TOMORROW")
summary_lines=""
overall_success=true
send_telegram_message "[${HOSTNAME:-$(hostname -s)}] Starting Seiue Attendance Task..."
for date_str in "${DATES_TO_PROCESS[@]}"; do
log_message "--- Processing date: $date_str ---"
output="$("$PYTHON_EXECUTABLE" "$PYTHON_SCRIPT" "$date_str" 2>&1)"
status=$?
log_message "Output for $date_str:
$output"
# Prefer stable SUMMARY tokens
token="$(printf "%s\n" "$output" | grep -E '^SUMMARY:' | tail -n1 || true)"
if [[ "$status" -eq 0 && -n "$token" ]]; then
case "$token" in
*status=ok*) summary_lines+=$(printf "✅ %s: OK\n" "$date_str");;
*status=failed_auth*) summary_lines+=$(printf "❌ %s: Auth failed\n" "$date_str"); overall_success=false;;
*status=no_events_or_no_pending*) summary_lines+=$(printf "✅ %s: No events / no pending\n" "$date_str");;
*status=failed*) summary_lines+=$(printf "❌ %s: Failed\n" "$date_str"); overall_success=false;;
*) summary_lines+=$(printf "✅ %s: Completed\n" "$date_str");;
esac
else
summary_lines+=$(printf "❌ %s: Exit %s\n" "$date_str" "$status")
overall_success=false
fi
done
# --- Final Reporting ---
CURRENT_TIME_CST=$(python3 -c "from datetime import datetime; import pytz; print(datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S'))")
final_message=$(printf "[Seiue Attendance Summary]\n\n%s\nTimestamp (CST): %s" "$summary_lines" "$CURRENT_TIME_CST")
log_message "Final summary prepared. Overall success: $overall_success"
send_telegram_message "$final_message"
log_message "--- Wrapper script finished ---"
if $overall_success; then
exit 0
else
exit 1
fiFile 3: com.ylsuen.seiue.attendance.api.plist (Scheduler, Updated PATH)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ylsuen.seiue.attendance.api</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/ylsuen/seiue/run_attendance.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<!-- This is your Mac's local time. -->
<!-- Set to 5 AM to correspond to 8 PM in Beijing (PDT). -->
<key>Hour</key>
<integer>5</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>WorkingDirectory</key>
<string>/Users/ylsuen/seiue</string>
<key>StandardOutPath</key>
<string>/Users/ylsuen/seiue/launchd_api.log</string>
<key>StandardErrorPath</key>
<string>/Users/ylsuen/seiue/launchd_api.error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:/Users/ylsuen/seiue/venv/bin</string>
</dict>
<key>RunAtLoad</key>
<false/>
<key>Disabled</key>
<false/>
</dict>
</plist>Chapter 3: Deployment on a VPS for Multi-User Support
Transitioning this single-user script to a multi-user service on a VPS (e.g., a Linux server) requires architectural changes.
3.1 Core Concepts
- Centralized Scheduler: Instead of each user running their own
launchdagent, a singlesystemd timer(recommended) orcronjob on the VPS will trigger the master script. - User Database: A simple database (like SQLite) or a JSON file is needed to store user credentials (
username, encryptedpassword) and their desired attendance schedule. - State Management: Track the last successful run for each user to avoid duplicates and support retries with backoff.
- Web Interface (Optional): A simple web front-end (Flask/Django) for user self-service.
3.2 VPS Implementation Steps
-
Server Setup
- Provision a Linux VPS (e.g., Ubuntu 22.04).
- Install Python 3, pip, and create a virtual environment.
- Install required libraries:
bash
pip install requests urllib3 pytz cryptography
-
User Data Storage
- Example
users.json:json[ { "username": "user1_username", "encrypted_password": "encrypted_password_string_1", "telegram_chat_id": "user1_chat_id", "timezone": "Asia/Shanghai", "enabled": true }, { "username": "user2_username", "encrypted_password": "encrypted_password_string_2", "telegram_chat_id": "user2_chat_id", "timezone": "America/Los_Angeles", "enabled": true } ] - Encrypt passwords with
cryptography.Fernet; keep the key in an env file (not in Git).
- Example
-
Refactor the Python Script
- Multi-user runner should:
a. Load & decrypt user data from
users.json
b. Iterate enabled users
c. Compute each user’s yesterday/today/tomorrow in their timezone
d. InstantiateSeiueAPIClientand run the three dates
e. Send per-user Telegram status
- Multi-user runner should:
a. Load & decrypt user data from
-
Scheduling
- systemd timer (recommended):
OnCalendar=*-*-* 00:05:00 UTC(08:05 Beijing).
Or usecron:cron5 0 * * * /path/to/venv/bin/python3 /path/to/APIFinal.py >> /path/to/cron.log 2>&1
- systemd timer (recommended):
Environment & Dependencies
Create a requirements.txt and pin versions you’ve tested:
requests>=2.32
urllib3>=2.2
pytz>=2024.1
cryptography>=42.0Mac (Homebrew Python) quickstart:
/opt/homebrew/bin/python3 -m venv /Users/ylsuen/seiue/venv
/Users/ylsuen/seiue/venv/bin/pip install -r /Users/ylsuen/seiue/requirements.txtSecurity & Compliance Notes
- Default
abnormal_notice_rolesto[](no guardian pings) unless explicitly intended. - Store secrets outside the repo; rotate periodically.
- Ensure usage complies with platform ToS and local policies.
- Keep logs; avoid exposing PII in notifications.