封面 《創作彼女の恋愛公式》

前言

因为 cc98 老是忘记签到,于是想着写一个自动签到脚本,然后用 crontab 挂一个定期执行,这样就不需要自己每天记得上 98 了,顺便用 golang 写一下当个练手小玩具

分析

首先可以很明确的分析登录和签到两个接口

登录

到登陆页面打开开发者工具,然后输入账号密码,点击登录,可以看到登录接口的请求

登陆

可以看到登录接口是 https://openid.cc98.org/connect/token,然后请求方式是 POST,请求参数是 usernamepassword,此外还有 Oauth2 涉及到的参数,关于 client_idclient_secret 可以使用 cc98 自带的,也可以自己到 https://openid.cc98.org/ 去申请自己的应用进行使用

返回的是一个 json,格式如下

1
2
3
4
5
6
7
{
"access_token": "",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "",
"scope": "cc98-api offline_access openid"
}

主要为使用 access_token 去进行我们后续的操作,否则报 401,后续需要用户身份的接口中需要加上 header,其中 key 为 Authorization,值为 Bearer {access_token}。s 另外需要注意的是请求时候的 content-typeapplication/x-www-form-urlencoded

签到

点击签到按钮,观察后续的请求。因为我的号都进行了测试无法再看,这里直接说一下请求的接口是 https://api.cc98.org/me/signinpost 方法,无需任何请求参数,但是 content-typeapplication/json,,返回的是一个字符串。当未签到的时候该值为签到获得的 98 币,如果已经完成签到,那么返回的字符串为 has_signed_in_today

获取签到天数

和 98 小程序比,我们明显的看到缺少了连续签到天数,因此继续看接口,可以看到签到点击后还有一个和签到接口一样的地址为 https://api.cc98.org/me/signin,但是方法为 get,无需任何参数,但是 content-typeapplication/json

该接口返回一个 json,其结果如下

1
2
3
4
5
{
"lastSignInTime": "2023-12-22T00:02:50.453",
"lastSignInCount": 2,
"hasSignedInToday": true
}

其中 lastSignInCount 就是连续签到天数,hasSignedInToday 为今天是否签到,lastSignInTime 为最后一次签到时间

代码

总体代码仓库在 github, https://github.com/qxdn/cc98-sign

登录

主要为 http 包使用,模拟登录接口,获取用户的登录返回的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package login

import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
)

// 登陆用户
type User struct {
Username string
Password string
}

type LoginInfo struct {
AccessToken string `json:"access_token"`
ExpiresIn string `json:"expires_in"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}

func Login(user *User) *LoginInfo {

resp, err := http.PostForm("https://openid.cc98.org/connect/token", url.Values{
"username": {user.Username},
"password": {user.Password},
"grant_type": {"password"},
"scope": {"cc98-api openid offline_access"},
"client_id": {"9a1fd200-8687-44b1-4c20-08d50a96e5cd"}, // cc98 clientid 也可以到 https://openid.cc98.org/ 申请
"client_secret": {"8b53f727-08e2-4509-8857-e34bf92b27f2"},
})
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {

panic(err)
}
var info LoginInfo
json.Unmarshal(body, &info)
return &info
}

签到和签到结果

此处代码主要为模拟登陆接口和获取签到后的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package sign

import (
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
)

func SignIn(token string) int {
client := &http.Client{}
req, err := http.NewRequest("POST", "https://api.cc98.org/me/signin", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
rawBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
body := string(rawBody)
if body == "has_signed_in_today" {
//fmt.Println("今天已经签到过了")
return 0
}
coins, _ := strconv.Atoi(body)
return coins
}

type SignResult struct {
LastSignInTime string `json:"lastSignInTime"`
LastSignInCount int `json:"lastSignInCount"`
HasSignedInToday bool `json:"hasSignedInToday"`
}

func GetSignResult(token string) *SignResult {
client := &http.Client{}
req, err := http.NewRequest("GET", "https://api.cc98.org/me/signin", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var result SignResult
json.Unmarshal(body, &result)
return &result
}

配置

此处主要为生成默认配置文件和读取配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package config

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
)

type User struct {
Username string `json:"username"`
Password string `json:"password"`
}

type Config struct {
Users []User `json:"users"`
}

func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

func ReadConfig(filepath string) *Config {
var configs Config
exists, _ := PathExists(filepath)
if !exists {
configs.Users = []User{{
Username: "用户名",
Password: "密码",
}}
file, _ := json.MarshalIndent(configs, "", " ")
ioutil.WriteFile(filepath, file, 0644)
fmt.Println("配置文件不存在,已经生成默认配置文件")
return nil
}
jsonFile, err := os.Open(filepath)
if err != nil {
panic(err)
}
defer jsonFile.Close()
byteValue, _ := ioutil.ReadAll(jsonFile)
json.Unmarshal(byteValue, &configs)
return &configs
}

主函数

这里主要为定义一个函数进行自动登陆,采用协程启动多个账户。需要注意的是使用 waitGroup 进行同步,否则主函数执行完,协程还没启动就退出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"fmt"
"sync"

"github.com/qxdn/cc98sign/pkg/config"
"github.com/qxdn/cc98sign/pkg/login"
"github.com/qxdn/cc98sign/pkg/sign"
)

func AutoSign(user *login.User, done func()) {
defer done()
info := login.Login(user)
coins := sign.SignIn(info.AccessToken)
if coins == 0 {
fmt.Printf("用户(%s)今日已经签到\n", user.Username)
return
}
result := sign.GetSignResult(info.AccessToken)
fmt.Printf("用户(%s)已经连续签到%d天,今日签到获得%d 98币\n", user.Username, result.LastSignInCount, coins)
}

func main() {
configs := config.ReadConfig("config.json")
if configs == nil {
return
}
var waitGroup sync.WaitGroup
waitGroup.Add(len(configs.Users))
for _, cuser := range configs.Users {
user := &login.User{
Username: cuser.Username,
Password: cuser.Password,
}
go AutoSign(user, waitGroup.Done)
}
waitGroup.Wait()
}

效果

结果

后记

后续需要做的是使用 crontab 执行定期执行,和使用 webvpn 来规避 ip 限制