✨ 学习通

This commit is contained in:
sugarBecomer
2025-11-09 22:18:03 +08:00
parent 4a29772de0
commit ae95a871c8
7 changed files with 378 additions and 0 deletions
@@ -0,0 +1,25 @@
package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
var Xuexitong = resty.New().
SetCookieJar(cookiejar.NewPersistCookieJar()).
SetTimeout(30 * time.Second).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
func init() {
if !utils.IsDebug() {
Xuexitong.SetDebug(false)
} else {
Xuexitong.SetProxy("http://127.0.0.1:9000")
}
}
@@ -0,0 +1,37 @@
package cryptoutil
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"likedream-goframe/internal/framework/logger"
"go.uber.org/zap"
)
const (
KEY = "u2oh6Vu^HWe4_AES"
)
func LoginEncrypt(str string) (string, error) {
key := []byte(KEY)
block, err := aes.NewCipher(key)
if err != nil {
logger.Logger.Error("aes.NewCipher err:", zap.Error(err))
return "", err
}
paddedData := pad([]byte(str), block.BlockSize())
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, key)
mode.CryptBlocks(ciphertext, paddedData)
data := base64.StdEncoding.EncodeToString(ciphertext)
return data, nil
}
// pad 确保数据长度是块大小的整数倍,以便符合块加密算法的要求
func pad(src []byte, blockSize int) []byte {
padding := blockSize - len(src)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(src, padText...)
}
@@ -0,0 +1,72 @@
package entity
// XueXiTCourse 课程所有信息
type XueXiTCourseJson struct {
Result int `json:"result"`
Msg string `json:"msg"`
ChannelList []ChannelItem `json:"channelList"`
Mcode string `json:"mcode"`
Createcourse int `json:"createcoursed"`
TeacherEndCourse int `json:"teacherEndCourse"`
ShowEndCourse int `json:"showEndCourse"`
HasMore bool `json:"hasMore"`
StuEndCourse int `json:"stuEndCourse"`
}
// ChannelItem 课程列表
type ChannelItem struct {
Cfid int `json:"cfid"`
Norder int `json:"norder"`
CataName string `json:"cataName"`
Cataid string `json:"cataid"`
Id int `json:"id"`
Cpi int `json:"cpi"`
Key any `json:"key"`
Content struct {
Studentcount int `json:"studentcount"`
Chatid string `json:"chatid"`
IsFiled int `json:"isFiled"`
Isthirdaq int `json:"isthirdaq"`
Isstart bool `json:"isstart"`
Isretire int `json:"isretire"`
Name string `json:"name"`
Course struct {
Data []struct {
BelongSchoolId string `json:"belongSchoolId"`
Coursestate int `json:"coursestate"`
Teacherfactor string `json:"teacherfactor"`
IsCourseSquare int `json:"isCourseSquare"`
Schools string `json:"schools"`
CourseSquareUrl string `json:"courseSquareUrl"`
Imageurl string `json:"imageurl"`
AppInfo string `json:"appInfo"`
Name string `json:"name"`
DefaultShowCatalog int `json:"defaultShowCatalog"`
Id int `json:"id"`
AppData int `json:"appData"`
} `json:"data"`
} `json:"course"`
Roletype int `json:"roletype"`
Id int `json:"id"`
State int `json:"state"`
Cpi int `json:"cpi"`
Bbsid string `json:"bbsid"`
IsSquare int `json:"isSquare"`
} `json:"content"`
Topsign int `json:"topsign"`
}
type XueXiTCourse struct {
Cpi int `json:"cpi"` // 用户唯一标识
Key string `json:"key"` // classID 在课程API中为key
CourseID string `json:"courseId"` // 课程ID
ChatID string `json:"chatId"`
CourseTeacher string `json:"courseTeacher"` // 课程老师
CourseName string `json:"courseName"` //课程名
CourseImage string `json:"courseImage"`
// 两个标识 暂时不知道有什么用
CourseDataID int `json:"courseDataId"`
ContentID int `json:"ContentID"`
IsStart bool `json:"isstart"` //是否开课了,开课了为true,没开课为false。一般来说开课才能刷,不然刷不了的
State int `json:"state"` //课程状态,0为正常,1为课程已经结束
}
@@ -0,0 +1,77 @@
package xuexitong
import (
"context"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/framework/logger"
"strings"
"github.com/bytedance/sonic"
"resty.dev/v3"
)
const (
ApiPullCourses = "https://mooc1-api.chaoxing.com/mycourse/backclazzdata"
)
type XuexitongCourse struct {
client *resty.Client
}
func NewXuexitongCourse(client *resty.Client) *XuexitongCourse {
return &XuexitongCourse{
client: client,
}
}
// PullCourseList 拉取学习通所有课程列表并返回
func (c *XuexitongCourse) PullCourseList(ctx context.Context, retry int) (courseList []entity.XueXiTCourse, err error) {
req := c.client.R()
req.Header.Add("User-Agent", GetUA("mobile"))
get, err := req.Get(ApiPullCourses)
if err != nil {
return nil, err
}
body := get.String()
if strings.Contains(body, "很抱歉,您所浏览的页面不存在") && retry > 0 { //防止学习通抽风
return c.PullCourseList(ctx, retry-1)
}
if strings.Contains(body, "请输入验证码") {
logger.Logger.Warn("触发验证码,正在进行AI智能识别绕过.....")
//todo ddddocr识别
}
var courseResult entity.XueXiTCourseJson
err = sonic.Unmarshal([]byte(body), &courseResult)
if err != nil {
return nil, err
}
for _, channel := range courseResult.ChannelList {
if channel.Content.Course.Data == nil {
continue
}
for _, v := range channel.Content.Course.Data {
flag := false
course := entity.XueXiTCourse{
Cpi: channel.Cpi,
Key: strings.Split(strings.Split(v.CourseSquareUrl, "classId=")[1], "&userId")[0],
CourseID: strings.Split(strings.Split(v.CourseSquareUrl, "courseId=")[1], "&personId")[0],
CourseTeacher: v.Teacherfactor,
CourseImage: v.Name,
CourseDataID: v.Id,
ContentID: channel.Content.Id,
IsStart: channel.Content.Isstart,
State: channel.Content.State,
}
for _, subCourse := range courseList {
if subCourse.CourseID == course.CourseID {
flag = true
break
}
}
if !flag {
courseList = append(courseList, course)
}
}
}
return courseList, nil
}
@@ -0,0 +1,76 @@
package xuexitong
import (
"context"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/application/xuexitong/cryptoutil"
"likedream-goframe/internal/framework/logger"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"resty.dev/v3"
)
const (
ApiLoginWeb = "https://passport2.chaoxing.com/fanyalogin"
)
type XuexitongLogin struct {
client *resty.Client
}
func NewXuexitongLogin(ctx context.Context) *XuexitongLogin {
return &XuexitongLogin{
client: httpclient.Xuexitong.Clone(ctx),
}
}
func (c *XuexitongLogin) Login(ctx context.Context, username, password string, retry int) (err error) {
usernameEncrypted, err := cryptoutil.LoginEncrypt(username)
if err != nil {
logger.Logger.Error("LoginEncrypt err:", zap.Error(err))
return err
}
passwordEncrypted, err := cryptoutil.LoginEncrypt(password)
if err != nil {
logger.Logger.Error("LoginEncrypt err:", zap.Error(err))
return err
}
resp, err := c.client.R().SetFormData(map[string]string{
"fid": "-1",
"uname": usernameEncrypted,
"password": passwordEncrypted,
"refer": "http%3A%2F%2Fi.mooc.chaoxing.com",
"t": "true",
"forbidotherlogin": "0",
"validate": "",
"doubleFactorLogin": "0",
"independentId": "0",
"independentNameId": "0",
}).Post(ApiLoginWeb)
if err != nil {
logger.Logger.Error("LoginPost err:", zap.Error(err))
return err
}
body := resp.String()
//触发风控直接重新登录
if strings.Contains(body, "很抱歉,您所浏览的页面暂时不能访问!") && retry > 0 {
time.Sleep(time.Duration(retry*5) * time.Second)
return c.Login(ctx, username, password, retry-1)
}
return nil
}
func (c *XuexitongLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c *XuexitongLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c *XuexitongLogin) GetClient() *resty.Client {
return c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
}
@@ -0,0 +1,14 @@
package xuexitong
import (
"context"
"likedream-goframe/internal/utils"
"testing"
)
func TestXuexitongLogin(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
t.Log(xuexitongLogin.GetCookieString())
}
@@ -0,0 +1,77 @@
package xuexitong
import (
"crypto/md5"
"encoding/hex"
"fmt"
"math/rand"
"strings"
)
const (
APP_VERSION = "6.6.4"
DEVICE_VENDOR = "MI10"
BUILD = "10831_263"
//BUILD = "10903_285"
ANDROID_VERSION = "Android 11"
)
var IMEI = TokenHex(16)
// GetUA 构建并获取 UA
func GetUA(uaType string) string {
switch uaType {
case "mobile":
//return strings.Join([]string{
// fmt.Sprintf("Dalvik/2.1.0 (Linux; U; %s; %s Build/SKQ1.210216.001)", ANDROID_VERSION, DEVICE_VENDOR),
// fmt.Sprintf("(schild:%s)", MobileUASign(DEVICE_VENDOR, "zh_CN", APP_VERSION, BUILD, IMEI)),
// fmt.Sprintf("(device:%s)", DEVICE_VENDOR),
// "Language/zh_CN",
// fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", APP_VERSION, BUILD),
// //APP_VERSION,
// fmt.Sprintf("(@Kalimdor)_%s", IMEI),
//}, " ")
return strings.Join([]string{
fmt.Sprintf("Mozilla/5.0 (Linux; %s; %s Build/OPM1.171019.019; wv)", ANDROID_VERSION, DEVICE_VENDOR),
fmt.Sprintf("AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36"),
fmt.Sprintf("(schild:%s)", MobileUASign(DEVICE_VENDOR, "zh_CN", APP_VERSION, BUILD, IMEI)),
fmt.Sprintf("(device:%s)", DEVICE_VENDOR),
"Language/zh_CN",
fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", APP_VERSION, BUILD),
fmt.Sprintf("(@Kalimdor)_%s", IMEI),
}, " ")
case "web":
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.35"
default:
return ""
}
}
// 用于生成IMEI
func TokenHex(len int) string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
panic(err)
}
token := hex.EncodeToString(b)
return token
}
func MobileUASign(model, locale, version, build, imei string) string {
// 拼接字符串,和 Python 的 " ".join 相同
str := strings.Join([]string{
"(schild:ipL$TkeiEmfy1gTXb2XHrdLN0a@7c^vu)",
fmt.Sprintf("(device:%s)", model),
fmt.Sprintf("Language/%s", locale),
fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", version, build),
fmt.Sprintf("(@Kalimdor)_%s", imei),
}, " ")
// 计算 md5
hash := md5.Sum([]byte(str))
// 转换为小写 hex 字符串
return fmt.Sprintf("%x", hash)
}