尊重自己的内心,重拾编程手艺hh。计划开发一个多平台聚合客户端。目前代码三百行左右,已实现自动登录学习通,获取课程列表、通知和群聊消息等。部分代码如下,后续在 GitHub 上继续更新:
import base64
import json
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
from playwright.sync_api import Cookie, sync_playwright
import easemob_pb2 as pb2
@dataclass
class Channel:
chat_id: str
group_id: str
sender_id: str
recent_message: str
@dataclass
class ChannelMessage:
group_id: str
sender_id: str
message: str
attachment: dict[str, object] | None
@dataclass
class Course:
clazz_id: str
course_id: str
cur_person_id: str
course_name: str
is_finished: bool
@dataclass
class Notice:
notice_id: str
title: str
sender: str
send_time: str
content: str
content_html: str
@dataclass
class ImCredentials:
uid: int
hx_token: str
class ChaoxingClient:
def __init__(self, account: str, password: str) -> None:
if not account or any(c < '0' or c > '9' for c in account):
raise ValueError("账号必须由 0-9 数字组成")
self.account = account
self.password = password
self.session = requests.Session()
self.session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/157.0.0.0 Safari/537.36 Edg/157.0.0.0"
)
})
self.auth_file = Path(__file__).parent / ".auth" / f"{account}.json"
if self.auth_file.is_file():
cookies = json.loads(self.auth_file.read_text(encoding="utf-8"))
else:
cookies = self._login(auto_save=True)
self._load_cookies(cookies)
def _login(self, auto_save: bool = True) -> list[Cookie]:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://fysso.chaoxing.com/oauthsso/fudan")
page.locator("#login-username").fill(self.account)
page.locator("#login-password").fill(self.password)
page.get_by_role("button", name="登录", exact=True).click()
page.wait_for_url("https://fudan.mooc.chaoxing.com/portal")
page.goto("https://i.chaoxing.com")
cookies = page.context.cookies()
browser.close()
if auto_save:
self.auth_file.parent.mkdir(parents=True, exist_ok=True)
self.auth_file.write_text(json.dumps(cookies), encoding="utf-8")
return cookies
def _load_cookies(self, cookies: list[Cookie]) -> None:
self.session.cookies.clear()
for cookie in cookies:
if "name" not in cookie or "value" not in cookie:
raise ValueError("无法加载 Cookie: 缺少 name 或 value 字段")
expires = cookie.get("expires", -1)
http_only = cookie.get("httpOnly", False)
self.session.cookies.set(
name=cookie["name"],
value=cookie["value"],
domain=cookie.get("domain", ""),
path=cookie.get("path", "/"),
secure=cookie.get("secure", False),
expires=int(expires) if expires >= 0 else None,
discard=expires < 0,
rest={"HttpOnly": None} if http_only else {},
)
@staticmethod
def _is_login_page(res: requests.Response) -> bool:
target = urlparse(res.url)
return (
target.hostname == "passport2.chaoxing.com"
and target.path.rstrip("/") == "/login"
)
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
kwargs.setdefault("timeout", 30)
res = self.session.request(method, url, **kwargs)
if self._is_login_page(res):
self._load_cookies(self._login(auto_save=True))
res = self.session.request(method, url, **kwargs)
if self._is_login_page(res):
raise RuntimeError("重新登录后,请求仍跳转到登录页")
res.raise_for_status()
return res
def get_course_list(self) -> list[Course]:
url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata"
data = {
"courseType": "1",
"courseFolderId": "0",
"query": "",
"pageHeader": "-1",
"single": "0",
"superstarClass": "0",
"isFirefly": "0",
"courseLevelLabelId": "0",
"fid": "",
"from": "",
}
res = self._request("POST", url, data=data)
soup = BeautifulSoup(res.content, "html.parser", from_encoding="utf-8")
course_list = soup.select_one("#courseList")
if course_list is None:
raise RuntimeError("响应中没有课程列表,请检查登录状态或返回内容")
courses: list[Course] = []
for div in course_list.select("div[info]"):
clazz_id = div.select_one("input.clazzId")
course_id = div.select_one("input.courseId")
person_id = div.select_one("input.curPersonId")
course_name = div.select_one(".course-name")
if (
clazz_id is None
or course_id is None
or person_id is None
or course_name is None
):
raise ValueError(f"课程字段不完整:{div.get('info')}")
course = Course(
clazz_id=str(clazz_id["value"]),
course_id=str(course_id["value"]),
cur_person_id=str(person_id["value"]),
course_name=course_name.get_text(strip=True),
is_finished=div.select_one(".not-open-tip") is not None,
)
courses.append(course)
return courses
def get_recent_notices(self, year: int | None = None) -> list[Notice]:
if year is None:
year = datetime.now().year
url = "https://notice.chaoxing.com/pc/notice/getNoticeList"
data = {
"type": "2",
"notice_type": "",
"lastValue": "",
"sort": "",
"folderUUID": "",
"kw": "",
"startTime": "",
"endTime": "",
"gKw": "",
"gName": "",
"year": str(year),
"tag": "",
"fidsCode": "",
"queryFolderNoticePrevYear": "0",
"filterSenderPuids": "",
"filterTags": "",
}
res = self._request("POST", url, data=data)
notices = []
for item in res.json()["notices"]["list"]:
notice = Notice(
notice_id=item["idCode"],
title=item["title"],
sender=item["createrName"],
send_time=item["sendTime"],
content=item["content"],
content_html=item["rtf_content"],
)
notices.append(notice)
return notices
def get_im_credentials(self) -> ImCredentials:
url = "https://learn.chaoxing.com/apis/user/getUserImToken"
res = self._request("GET", url, params={"crossOrigin": True})
msg = res.json()["msg"]
return ImCredentials(uid=msg["uid"], hx_token=msg["hxToken"])
def get_user_name(self, tuid: str) -> str:
url = "https://im.chaoxing.com/webim/user/getUserInfoByTuid"
params = {"crossOrigin": "true", "tuid": tuid}
headers = {"Accept": "application/json, text/plain, */*"}
res = self._request("GET", url, params=params, headers=headers)
return res.json()["name"]
def get_group_name(self, room_id: str) -> str:
url = "https://im.chaoxing.com/webim/huanxin/getGroupInfo"
params = {"crossOrigin": "true", "roomId": room_id}
headers = {"Accept": "application/json, text/plain, */*"}
res = self._request("GET", url, params=params, headers=headers)
return res.json()[0]["name"]
def get_channel_list(
self,
credentials: ImCredentials | None = None,
limit: int = 50
) -> list[Channel]:
if credentials is None:
credentials = self.get_im_credentials()
url = f"https://a3-vip6.easemob.com/cx-dev/cxstudy/sdk/user/{credentials.uid}/user_channels/list"
params = {
"limit": limit,
"cursor": "",
"need_mark": "true",
"needEmptySession": "true",
"_v": int(time.time() * 1000),
}
headers = {"Authorization": f"Bearer {credentials.hx_token}"}
res = self._request("GET", url, params=params, headers=headers)
channels = []
for item in res.json()["data"]["channel_infos"]:
meta = item.get("meta")
if not meta or not meta.get("payload"):
continue
payload = json.loads(meta["payload"])
if payload["type"] == "groupchat":
recent_message = next(
(body["msg"] for body in payload["bodies"] if body["type"] == "txt"),
"",
)
channel = Channel(
chat_id = payload["to"],
group_id = payload["to"],
sender_id = payload["from"],
recent_message = recent_message,
)
channels.append(channel)
return channels
def get_channel_message(
self,
chat_id: str,
credentials: ImCredentials | None = None,
limit: int = 20
) -> list[ChannelMessage]:
if credentials is None:
credentials = self.get_im_credentials()
url = f"https://a3-vip6.easemob.com/cx-dev/cxstudy/users/{credentials.uid}/messageroaming"
data = {
"queue": f"{chat_id}@conference.easemob.com",
"start": -1,
"end": -1,
"pull_number": limit,
"is_positive": False,
"msgType": "",
"startTime": None,
"endTime": None,
"userId": None,
}
headers = {"Authorization": f"Bearer {credentials.hx_token}"}
res = self._request("POST", url, json=data, headers=headers)
messages = []
for item in res.json()["data"]["msgs"]:
raw = base64.b64decode(item["msg"], validate=True)
meta = pb2.Meta()
meta.ParseFromString(raw)
if meta.ns != pb2.Meta.CHAT:
continue
body = pb2.MessageBody()
body.ParseFromString(meta.payload)
if body.type == pb2.MessageBody.GROUPCHAT:
message = next((
content.text
for content in body.contents
if content.type == pb2.MessageBody.Content.TEXT
), "")
attach_item = next((
item.stringValue
for item in body.ext
if item.key == "attachment"
and item.type == pb2.KeyValue.JSON_STRING
), None)
channel_message = ChannelMessage(
group_id = body.to.name,
sender_id = getattr(body, "from").name,
message = message,
attachment = json.loads(attach_item) if attach_item is not None else None,
)
messages.append(channel_message)
return messages
if __name__ == "__main__":
client = ChaoxingClient("xxxxxxxxxxx", "xxxxxxxx")
#....