59 | type Property = Component.Property
60 | type Method = Component.Method
61 |
62 | type DefinitionFilter = Component.DefinitionFilter
63 | type Lifetimes = Component.Lifetimes
64 |
65 | type OtherOption = Omit
66 | }
67 | /** 注册一个 `behavior`,接受一个 `Object` 类型的参数。*/
68 | declare let Behavior: WechatMiniprogram.Behavior.Constructor
69 |
--------------------------------------------------------------------------------
/miniprogram/pages/locationPicker/locationPicker.ts:
--------------------------------------------------------------------------------
1 | // pages/locationPicker/locationPicker.ts
2 | Page({
3 |
4 | /**
5 | * 页面的初始数据
6 | */
7 | data: {
8 | longitude: '',
9 | latitude: '',
10 | altitude: '',
11 | random: true,
12 | address: '',
13 | markers: [] as { [key: string]: string | number }[],
14 | coordinateErrorMessage: null as null | string,
15 | addressErrorMessage: null as null | string,
16 | },
17 |
18 | emptyMethod() {},
19 |
20 | copyBaiduCoordinatePicker() {
21 | wx.setClipboardData({
22 | data: 'https://api.map.baidu.com/lbsapi/getpoint/index.html',
23 | });
24 | },
25 |
26 | setMarkers() {
27 | this.setData({ markers: [{
28 | id: 0,
29 | longitude: Number.parseFloat(this.data.longitude) || 113.324520,
30 | latitude: Number.parseFloat(this.data.latitude) || 23.099994,
31 | iconPath: '../../images/location_on_FILL1_wght400_GRAD0_opsz48.svg',
32 | width: 45,
33 | height: 45,
34 | }] });
35 | },
36 |
37 | sign() {
38 | this.setData({ coordinateErrorMessage: null, addressErrorMessage: null });
39 |
40 | const rawLongitude = this.data.longitude;
41 | const rawLatitude = this.data.latitude;
42 | const rawAltitude = this.data.altitude;
43 |
44 | const decimalOfLongitude = rawLongitude.slice(rawLongitude.indexOf('.') + 1);
45 | const decimalOfLatitude = rawLatitude.slice(rawLatitude.indexOf('.') + 1);
46 |
47 | if (decimalOfLongitude.length > 13 || decimalOfLatitude.length > 13) {
48 | return this.setData({ errorMessage: '经纬度小数长度不能超过13' });
49 | }
50 |
51 | const longitude = Number.parseFloat(rawLongitude);
52 | const latitude = Number.parseFloat(rawLatitude);
53 | const altitude = Number.parseFloat(rawAltitude);
54 |
55 | if (Number.isNaN(longitude)) {
56 | return this.setData({ coordinateErrorMessage: '经度应当填写数字' });
57 | }
58 |
59 | if (Number.isNaN(latitude)) {
60 | return this.setData({ coordinateErrorMessage: '纬度应当填写数字' });
61 | }
62 |
63 | this.getOpenerEventChannel().emit(
64 | 'callback',
65 | this.data.random,
66 | longitude,
67 | latitude,
68 | altitude,
69 | this.data.address,
70 | );
71 | wx.navigateBack();
72 | },
73 |
74 | cancel() {
75 | this.data.longitude = '-181';
76 | this.data.latitude = '-91';
77 | this.data.altitude = '';
78 | this.data.random = false;
79 | this.data.address = '';
80 | this.sign();
81 | },
82 |
83 | /**
84 | * 生命周期函数--监听页面加载
85 | */
86 | onLoad() {
87 | if (!this.getOpenerEventChannel()) {
88 | console.warn('openerEventChannel not found');
89 | wx.navigateBack();
90 | }
91 |
92 | this.setMarkers();
93 | },
94 |
95 | /**
96 | * 生命周期函数--监听页面初次渲染完成
97 | */
98 | onReady() {
99 |
100 | },
101 |
102 | /**
103 | * 生命周期函数--监听页面显示
104 | */
105 | onShow() {
106 |
107 | },
108 |
109 | /**
110 | * 生命周期函数--监听页面隐藏
111 | */
112 | onHide() {
113 |
114 | },
115 |
116 | /**
117 | * 生命周期函数--监听页面卸载
118 | */
119 | onUnload() {
120 |
121 | },
122 |
123 | /**
124 | * 页面相关事件处理函数--监听用户下拉动作
125 | */
126 | onPullDownRefresh() {
127 |
128 | },
129 |
130 | /**
131 | * 页面上拉触底事件的处理函数
132 | */
133 | onReachBottom() {
134 |
135 | },
136 |
137 | /**
138 | * 用户点击右上角分享
139 | */
140 | onShareAppMessage() {
141 |
142 | }
143 | });
144 |
--------------------------------------------------------------------------------
/miniprogram/pages/userInfo/userInfo.wxml:
--------------------------------------------------------------------------------
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 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
54 |
55 |
56 |
57 |
65 |
68 | {{errorMessage}}
69 |
70 |
71 |
72 |
73 |
74 | 课程数量:{{totalOfCourse}}
75 |
76 | 信息安全与隐私提醒
77 |
78 |
79 |
80 |
81 |
86 |
91 |
97 |
102 | 登出
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/miniprogram/utils/types.ts:
--------------------------------------------------------------------------------
1 | export interface RawActivity {
2 | id: number;
3 | status: number;
4 | startTime: number;
5 | endTime: number | '';
6 | nameOne: string;
7 | nameFour: string;
8 | }
9 |
10 | export interface RawActivityWithKnownOtherId extends RawActivity {
11 | otherId: '0' | '2' | '3' | '4' | '5';
12 | }
13 |
14 | export interface RawActivityListObject {
15 | data: { activeList: RawActivity[] };
16 | }
17 |
18 | export type SignMethod = 'qrCode' | 'location' | 'gesture' | 'code' | 'clickOrPhoto'
19 | | 'unknown';
20 |
21 | export type SignMethodForHuman = '二维码' | '位置' | '手势' | '签到码' | '点击/拍照'
22 | | '未知';
23 |
24 | export type SignMethodForChoice = 'qrCode' | 'location' | 'gesture' | 'code'
25 | | 'click' | 'photo' | 'unknown';
26 |
27 | export type SignMethodForHumanChoice = '二维码' | '位置' | '手势' | '签到码'
28 | | '点击' | '拍照' | '未知';
29 |
30 | export interface Activity {
31 | id: number
32 | name: string;
33 | signMethod: SignMethod;
34 | startTime: number;
35 | endTime: number;
36 | endTimeForHuman: string;
37 | raw: RawActivityWithKnownOtherId;
38 | }
39 |
40 | export interface Course {
41 | id: number;
42 | name: string;
43 | }
44 |
45 | export interface Class extends Course {}
46 |
47 | export interface CourseInfo {
48 | course: Course;
49 | // TODO: array?
50 | class: Class;
51 | }
52 |
53 | export interface Cookie {
54 | expire: number;
55 | id: number;
56 | uf: string;
57 | vc3: string;
58 | _d: string;
59 | fid: string;
60 | }
61 |
62 | export interface User extends Cookie {
63 | name: string;
64 | }
65 |
66 | export type LoginType = 'fanya' | 'v11';
67 |
68 | export interface Credential {
69 | id: number;
70 | loginType: LoginType;
71 | username: string;
72 | password: string;
73 | }
74 |
75 | export interface CookieWithCourseInfo {
76 | cookie: Cookie;
77 | courseInfoArray: CourseInfo[];
78 | }
79 |
80 | export interface Result {
81 | status: boolean;
82 | message: string;
83 | data: any;
84 | }
85 |
86 | export interface CookieResult extends Result {
87 | cookie: Cookie;
88 | }
89 |
90 | export interface GetCookieSuccessResult extends Result {
91 | status: true;
92 | cookie: Cookie;
93 | }
94 |
95 | export interface GetCookieFailedResult extends Result {
96 | status: false;
97 | cookie: {};
98 | }
99 |
100 | export interface NameSuccessResult extends CookieResult {
101 | status: true;
102 | name: string;
103 | }
104 |
105 | export interface NameFailedResult extends CookieResult {
106 | status: false;
107 | name: '';
108 | }
109 |
110 | export interface LoginResult extends CookieResult {
111 | user: User;
112 | }
113 |
114 | export interface CourseInfoArrayResult extends CookieResult {
115 | courseInfoArray: CourseInfo[];
116 | }
117 |
118 | export interface ActivitiesResult extends CookieResult {
119 | courseInfo: CourseInfo;
120 | activities: Activity[];
121 | }
122 |
123 | export interface CookieStringResult extends Result {
124 | cookieString: string;
125 | }
126 |
127 | interface ActivityIdResult extends CookieResult {
128 | activityId: number;
129 | }
130 |
131 | export interface PreSignResult extends ActivityIdResult {
132 | courseId: number;
133 | classId: number;
134 | }
135 |
136 | export interface IsPhotoSignResult extends ActivityIdResult {
137 | isPhoto: boolean;
138 | }
139 |
140 | export interface SignResult extends ActivityIdResult {
141 | user: User;
142 | }
143 |
144 | export interface QrCodeSignResult extends SignResult {
145 | enc: string;
146 | }
147 |
148 | export interface LocationSignResult extends SignResult {
149 | longitude: number;
150 | latitude: number;
151 | address: string;
152 | }
153 |
154 | export interface PhotoSignResult extends SignResult {
155 | fileId: string;
156 | }
157 |
158 | export interface CloudStorageTokenResult extends CookieResult {
159 | token: string;
160 | }
161 |
162 | export interface UploadFileResult extends CookieResult {
163 | token: string;
164 | filePath: string;
165 | fileId: string;
166 | }
167 |
--------------------------------------------------------------------------------
/miniprogram/pages/locationPicker/locationPicker.wxml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 | 请使用百度地图拾取坐标系统获取坐标。
14 |
15 | 如果是无需定位的二维码签到,请点击取消。
16 |
17 |
18 |
20 | 经纬度
21 |
22 |
23 |
24 |
25 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
61 |
62 |
63 |
72 |
73 |
76 | {{coordinateErrorMessage}}
77 |
78 |
79 |
80 | 地址
81 |
82 |
83 |
84 |
93 |
94 |
95 |
96 |
99 | {{addressErrorMessage}}
100 |
101 |
102 |
103 |
104 |
107 |
110 |
111 |
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 快进到退学
2 | 「快进到退学」是[cxOrz/chaoxing-sign-cli: 超星学习通签到:支持普通签到、拍照签到、手势签到、位置签到、二维码签到,支持自动监测、QQ机器人签到与推送。](https://github.com/cxOrz/chaoxing-sign-cli)项目的微信小程序版本实现。本项目直接与超星学习通服务端进行通信,无后端依赖,可去中心化部署。
3 |
4 | 
5 |
6 | ## 部署教程
7 | ### 注册微信小程序开发者账号并配置
8 | 打开[小程序](https://mp.weixin.qq.com/wxopen/waregister?action=step1),按照流程注册小程序开发者账号并实名。
9 |
10 | 在小程序管理后台内,选择左侧的「开发 - 开发管理」,再点击上方的「开发设置」。
11 |
12 | 
13 |
14 | 点击服务器域名中的「开始配置」配置域名。
15 |
16 | 
17 |
18 | **v1.3.2及以下版本**:
19 | 在「request合法域名」一栏中填入`https://mobilelearn.chaoxing.com;https://mooc1-1.chaoxing.com;https://pan-yz.chaoxing.com;https://passport2.chaoxing.com;`,点击「保存并提交」。
20 |
21 | **v1.4.0及以上版本**:
22 | 在「request合法域名」一栏中填入`https://mobilelearn.chaoxing.com;https://mooc1-1.chaoxing.com;https://pan-yz.chaoxing.com;https://passport2.chaoxing.com;https://passport2-api.chaoxing.com;`,点击「保存并提交」。
23 |
24 | 
25 |
26 | ### 下载源码
27 | 点击下方任意一个打得开的链接下载最新版源码,然后解压到任意位置。
28 |
29 | - [Bitbucket下载](https://bitbucket.org/dropping-out-speedrun/dropping-out-speedrun/downloads/?tab=tags):点击zip
30 |
31 | 
32 |
33 | - [GitHub下载](https://github.com/DroppingOutSpeedrun/dropping-out-speedrun/tags):点击zip
34 |
35 | 
36 |
37 | ### 使用微信开发者工具进行部署
38 | 打开[微信开发者工具下载地址与更新日志 | 微信开放文档](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html),找到「稳定版」一栏。一般下载「Windows 64」版本,新款苹果电脑下载「macOS ARM64」版本,旧款苹果电脑下载「macOS x64」版本,不是苹果电脑但打不开「Windows 64」版本则下载「Windows 32」版本。
39 |
40 | 安装好微信开发者工具之后打开,然后使用微信扫描二维码,并在手机上确认登入。
41 |
42 | 
43 |
44 | 点击导入,选择源码所在位置,打开到能看到miniprogram等文件夹时,点击选择文件夹。
45 |
46 | 
47 |
48 | 点击AppID下拉菜单,选择一个AppID。然后将后端服务设置为「不使用云服务」,其他设置保持默认即可。最后点击确定。
49 |
50 | 
51 |
52 | 选择「信任并运行」
53 |
54 | 
55 |
56 | 点击「预览」,然后使用微信扫描二维码即可运行
57 |
58 | 
59 |
60 | ### 将小程序分享给他人使用
61 | 选择「上传」,然后再点击上传
62 |
63 | 
64 |
65 | 在微信中搜索「小程序助手」,选择小程序
66 |
67 | 
68 |
69 | 点击「成员管理」
70 |
71 | 
72 |
73 | 点击「体验成员」,再点击「新增体验成员」
74 |
75 | 
76 |
77 | 输入受邀者微信号,搜索受邀者
78 |
79 | 
80 |
81 | 回到主页,选择「审核管理」
82 |
83 | 
84 |
85 | 点击刚发布的开发版
86 |
87 | 
88 |
89 | 点击「体验版二维码」
90 |
91 | 
92 |
93 | 将此二维码分享给受邀者即可
94 |
95 | 
96 |
97 | ## 签到类型支持
98 | - 点击签到
99 | - 拍照签到
100 | - 手势签到
101 | - 位置签到
102 | - 二维码签到(支持附带位置信息的二维码签到)
103 | - 签到码签到
104 |
105 | ## 信息安全与隐私
106 | 本小程序不会与超星学习通以外的服务器进行通信,只在本地储存用户信息,通过[微信内置的AES-128加密储存API](https://developers.weixin.qq.com/miniprogram/dev/api/storage/wx.setStorage.html#Object-object)对敏感信息进行加密储存。
107 |
108 | ## Developing
109 | Clone this project.
110 | From GitHub:
111 | ```shell
112 | git clone https://github.com/DroppingOutSpeedrun/dropping-out-speedrun.git
113 | ```
114 |
115 | Or from BitBucket:
116 | ```shell
117 | git clone https://bitbucket.org/dropping-out-speedrun/dropping-out-speedrun.git
118 | ```
119 |
120 | You have to install [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/) (not sure npm could be used) to install dependencies:
121 | ```shell
122 | cd ./dropping-out-speedrun/miniprogram/
123 | pnpm install
124 | ```
125 |
126 | Start reading by services is a good begining:
127 | - `getCookieByFanya()` in `services/login.ts`
128 | - `getCourseInfoArray()` in `services/course.ts`
129 | - `getActivities()` in `services/course.ts`
130 | - `preSign()` in `services/sign.ts`
131 | - `generalSign()` in `services/sign.ts`
132 |
133 | ## LICENSE
134 | Dropping Out Speedrun
135 | Copyright (C) 2023 Dropping Out Speedrun
136 |
137 | This program is free software: you can redistribute it and/or modify
138 | it under the terms of the GNU General Public License as published by
139 | the Free Software Foundation, either version 3 of the License, or
140 | (at your option) any later version.
141 |
142 | This program is distributed in the hope that it will be useful,
143 | but WITHOUT ANY WARRANTY; without even the implied warranty of
144 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
145 | GNU General Public License for more details.
146 |
147 | You should have received a copy of the GNU General Public License
148 | along with this program. If not, see .
149 |
--------------------------------------------------------------------------------
/miniprogram/services/sign.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Cookie, CookieStringResult, IsPhotoSignResult, LocationSignResult,
3 | PhotoSignResult, PreSignResult, QrCodeSignResult, SignResult, User
4 | } from '../utils/types';
5 | import { cookieForSign, parametersToString, toCookieString } from '../utils/util';
6 |
7 | export const preSign = (
8 | cookie: Cookie, activityId: number, courseId: number, classId: number,
9 | ): Promise => new Promise(
10 | (resolve, reject) => wx.request({
11 | url: `https://mobilelearn.chaoxing.com/newsign/preSign?courseId=${courseId}&classId=${classId}&activePrimaryId=${activityId}&general=1&sys=1&ls=1&appType=15&&tid=&uid=${cookie.id}&ut=s`,
12 | header: {
13 | Cookie: toCookieString({
14 | uf: cookie.uf,
15 | _d: cookie._d,
16 | UID: cookie.id,
17 | vc3: cookie.vc3,
18 | })
19 | },
20 | // we don't care about the returned data actually
21 | success: (data) => resolve({
22 | status: true,
23 | data,
24 | message: '',
25 | cookie,
26 | activityId,
27 | courseId,
28 | classId,
29 | }),
30 | fail: (e) => reject(e),
31 | })
32 | );
33 |
34 | export const isPhotoSign = (
35 | activityId: number,
36 | cookie: Cookie
37 | ): Promise => new Promise((resolve, reject) =>
38 | wx.request({
39 | url: `https://mobilelearn.chaoxing.com/v2/apis/active/getPPTActiveInfo?activeId=${activityId}`,
40 | header: {
41 | Cookie: toCookieString({
42 | uf: cookie.uf,
43 | _d: cookie._d,
44 | UID: cookie.id,
45 | vc3: cookie.vc3,
46 | }),
47 | },
48 | success: ({ data }) => {
49 | const rawData = data as any;
50 |
51 | if (!rawData.data || typeof rawData.data.ifphoto !== 'number') {
52 | resolve({
53 | status: false,
54 | message: 'failed to parse ifphoto from data',
55 | data,
56 | cookie,
57 | activityId,
58 | isPhoto: false,
59 | });
60 | return;
61 | }
62 |
63 | resolve({
64 | status: true,
65 | message: '',
66 | data,
67 | cookie,
68 | activityId,
69 | isPhoto: rawData.data.ifphoto === 1,
70 | });
71 | },
72 | fail: (e) => reject(e),
73 | }));
74 |
75 | const sign = (
76 | parameters: { [key: string]: any },
77 | cookie: { [key: string]: any },
78 | ): Promise => new Promise((resolve, reject) => wx.request({
79 | url: `https://mobilelearn.chaoxing.com/pptSign/stuSignajax${Object.keys(parameters).length > 0 ? `?${parametersToString(parameters)}` : ''}`,
80 | header: { Cookie: toCookieString(cookie) },
81 | success: ({ data }) => resolve({
82 | status: data === 'success' || data === '您已签到过了',
83 | message: '',
84 | data,
85 | cookieString: toCookieString(cookie),
86 | }),
87 | fail: (e) => reject(e),
88 | }));
89 |
90 | export const generalSign = (
91 | user: User,
92 | activityId: number,
93 | ): Promise => sign(
94 | {
95 | activeId: activityId.toString(),
96 | uid: user.id,
97 | clientip: '',
98 | latitude: '-1',
99 | longitude: '-1',
100 | appType: '15',
101 | fid: user.fid,
102 | name: encodeURIComponent(user.name),
103 | },
104 | cookieForSign(user),
105 | ).then((result) => ({ ...result, cookie: user, user, activityId }));
106 |
107 | export const qrCodeSign = (
108 | user: User,
109 | activityId: number,
110 | enc: string,
111 | longitude: number,
112 | latitude: number,
113 | altitude: number,
114 | address: string,
115 | ): Promise => sign(
116 | {
117 | enc,
118 | name: encodeURIComponent(user.name),
119 | activeId: activityId.toString(),
120 | uid: user.id,
121 | clientip: '',
122 | ...(
123 | !Number.isNaN(latitude) && !Number.isNaN(longitude)
124 | ? {
125 | location: encodeURIComponent(`{"result":"1","address":"${address}","latitude":${latitude},"longitude":${longitude},"altitude":${!Number.isNaN(altitude) ? altitude : -1}}`),
126 | }
127 | : {}
128 | ),
129 | useragent: '',
130 | latitude: '-1',
131 | longitude: '-1',
132 | fid: user.fid,
133 | appType: '15',
134 | },
135 | cookieForSign(user),
136 | ).then((result) => ({ ...result, cookie: user, user, activityId, enc }));
137 |
138 | export const locationSign = (
139 | user: User,
140 | activityId: number,
141 | longitude: number,
142 | latitude: number,
143 | address: string,
144 | ): Promise => sign(
145 | {
146 | name: encodeURIComponent(user.name),
147 | address,
148 | activeId: activityId.toString(),
149 | uid: user.id,
150 | clientip: '',
151 | latitude: latitude.toString(),
152 | longitude: longitude.toString(),
153 | fid: user.fid,
154 | appType: '15',
155 | ifTiJiao: '1',
156 | },
157 | cookieForSign(user),
158 | ).then((result) => ({
159 | ...result,
160 | cookie: user,
161 | user,
162 | activityId,
163 | longitude,
164 | latitude,
165 | address,
166 | }));
167 |
168 | export const photoSign = (
169 | user: User,
170 | activityId: number,
171 | fileId: string,
172 | ): Promise => sign(
173 | {
174 | activeId: activityId.toString(),
175 | uid: user.id,
176 | clientip: '',
177 | useragent: '',
178 | latitude: '-1',
179 | longitude: '-1',
180 | appType: '15',
181 | fid: user.fid,
182 | objectId: fileId,
183 | name: encodeURIComponent(user.name),
184 | },
185 | cookieForSign(user),
186 | ).then((result) => ({ ...result, cookie: user, user, activityId, fileId }));
187 |
--------------------------------------------------------------------------------
/miniprogram/services/login.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Cookie, GetCookieFailedResult, GetCookieSuccessResult, NameSuccessResult,
3 | NameFailedResult, LoginResult,
4 | } from '../utils/types';
5 | import { parseCookiesFromWx, toCookieString } from '../utils/util';
6 | import crypto from 'crypto-js';
7 |
8 | const toCookieResult = (
9 | data: any, cookies: string[],
10 | ): GetCookieSuccessResult | GetCookieFailedResult => {
11 | const rawData = data as any;
12 |
13 | if (typeof rawData.status === 'boolean' && rawData.status) {
14 | try {
15 | return {
16 | status: true,
17 | data: rawData,
18 | message: '',
19 | cookie: parseCookiesFromWx(cookies),
20 | };
21 | } catch (e) {
22 | return {
23 | status: false,
24 | data,
25 | message: e instanceof TypeError
26 | ? `failed to parse cookie: ${e.message}`
27 | : `unknown error: ${JSON.stringify(e)}`,
28 | cookie: {},
29 | };
30 | }
31 | } else {
32 | return {
33 | status: false,
34 | data,
35 | message: 'Chaoxing returned false of status',
36 | cookie: {},
37 | };
38 | }
39 | }
40 |
41 | export const getCookieByFanya = (
42 | username: string,
43 | password: string,
44 | ): Promise => {
45 | const wordArray = crypto.enc.Utf8.parse('u2oh6Vu^HWe40fj');
46 | const encryptedPassword = crypto.DES.encrypt(password, wordArray, {
47 | mode: crypto.mode.ECB,
48 | padding: crypto.pad.Pkcs7,
49 | });
50 | const encryptedPasswordString = encryptedPassword.ciphertext.toString();
51 |
52 | return new Promise((resolve, reject) => wx.request({
53 | method: 'POST',
54 | header: {
55 | 'Content-Type': 'application/x-www-form-urlencoded',
56 | 'X-Requested-With': 'XMLHttpRequest',
57 | },
58 | url: 'https://passport2.chaoxing.com/fanyalogin',
59 | data: `uname=${username}&password=${encryptedPasswordString}&fid=-1&t=true&refer=https%253A%252F%252Fi.chaoxing.com&forbidotherlogin=0&validate=`,
60 | success: ({ data, cookies }) => resolve(toCookieResult(data, cookies)),
61 | fail: (e) => reject(e),
62 | }));
63 | }
64 |
65 | // https://www.myitmx.com/123.html
66 | export const getCookieByV11 = (
67 | username: string,
68 | password: string,
69 | ): Promise => (
70 | new Promise((resolve, reject) => wx.request({
71 | url: `https://passport2-api.chaoxing.com/v11/loginregister?code=${password}&cx_xxt_passport=json&uname=${username}&loginType=1&roleSelect=true`,
72 | success: ({ data, cookies }) => resolve(toCookieResult(data, cookies)),
73 | fail: (e) => reject(e),
74 | }))
75 | )
76 |
77 | export const getName = (
78 | cookie: Cookie,
79 | ): Promise =>
80 | new Promise((resolve, reject) => wx.request({
81 | url: 'https://passport2.chaoxing.com/mooc/accountManage',
82 | header: {
83 | Cookie: toCookieString({
84 | uf: cookie.uf,
85 | _d: cookie._d,
86 | UID: cookie.id,
87 | vc3: cookie.vc3,
88 | }),
89 | },
90 | success: ({ data }) => {
91 | if (typeof data !== 'string') {
92 | resolve({
93 | status: false,
94 | data,
95 | message: 'cannot resolve data: data is not a string',
96 | cookie,
97 | name: '',
98 | });
99 | return;
100 | }
101 |
102 | // TODO: possible different data from server?
103 | const nameEndIndex = data.indexOf('姓名');
104 | if (nameEndIndex < 0) {
105 | resolve({
106 | status: false,
107 | data,
108 | message: 'cannot find the name in data',
109 | cookie,
110 | name: '',
111 | });
112 | return;
113 | }
114 |
115 | const endTagBeginingIndex = data.lastIndexOf('<', nameEndIndex);
116 | const startTagEndingIndex = data.lastIndexOf('>', endTagBeginingIndex);
117 | const name = data.slice(startTagEndingIndex + 1, endTagBeginingIndex);
118 | // https://stackoverflow.com/a/16369725
119 | const trimedName = name.replace(/^\s*$(?:\r\n?|\n)/gm, '').trim();
120 |
121 | resolve({
122 | status: true,
123 | data,
124 | message: '',
125 | cookie,
126 | name: trimedName,
127 | });
128 | },
129 | fail: (e) => reject(e),
130 | }));
131 |
132 | export const toLoginResult = (
133 | result: GetCookieSuccessResult | GetCookieFailedResult
134 | ): Promise =>
135 | new Promise((resolve) => {
136 | if (!result.status) {
137 | resolve(result);
138 | return;
139 | }
140 |
141 | getName(result.cookie).then((result) => resolve(
142 | result.status
143 | ? {
144 | ...result,
145 | user: { ...result.cookie, name: result.name },
146 | }
147 | : result,
148 | ))
149 | })
150 |
151 | export const loginByFanya = (
152 | username: string,
153 | password: string,
154 | ): Promise =>
155 | new Promise((resolve, reject) =>
156 | getCookieByFanya(username, password).then((result) =>
157 | toLoginResult(result).then((r) => resolve(r))
158 | ).catch((error) => reject(error))
159 | );
160 |
161 | export const loginByV11 = (
162 | username: string,
163 | password: string,
164 | ): Promise =>
165 | new Promise((resolve, reject) =>
166 | getCookieByV11(username, password).then((result) =>
167 | toLoginResult(result).then((r) => resolve(r))
168 | ).catch((error) => reject(error))
169 | );
170 |
--------------------------------------------------------------------------------
/miniprogram/services/course.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Cookie,
3 | CourseInfo,
4 | RawActivityWithKnownOtherId,
5 | CourseInfoArrayResult,
6 | ActivitiesResult,
7 | } from '../utils/types';
8 | import {
9 | isString,
10 | toActiveListObject,
11 | toActivity,
12 | toCookieString,
13 | toRawActivityWithKnownOtherId,
14 | } from '../utils/util';
15 |
16 | export const getCourseInfoArray = (cookie: Cookie): Promise =>
17 | new Promise((resolve, reject) =>
18 | wx.request({
19 | method: 'POST',
20 | url: 'https://mooc1-1.chaoxing.com/visit/courselistdata',
21 | header: {
22 | Accept: 'text/html, */*; q=0.01',
23 | 'Accept-Encoding': 'gzip, deflate',
24 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
25 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8;',
26 | Cookie: toCookieString({ _uid: cookie.id, _d: cookie._d, vc3: cookie.vc3 }),
27 | },
28 | data: 'courseType=1&courseFolderId=0&courseFolderSize=0',
29 | success: ({ data }) => {
30 | if (!isString(data) || !data.includes('course_')) {
31 | resolve({
32 | status: false,
33 | data,
34 | message: 'cannot found any course in data',
35 | cookie,
36 | courseInfoArray: [],
37 | });
38 | return;
39 | }
40 |
41 | let courseInfoArray: CourseInfo[] = [];
42 |
43 | const courseIdIdentifier = 'course_';
44 | const idSpliter = '_';
45 | const courseNameIdentifier = 'title="';
46 | const classNameIdentifier = '班级:';
47 | for (let courseIdBegining = 0; ; courseIdBegining++) {
48 | courseIdBegining = data.indexOf(courseIdIdentifier, courseIdBegining);
49 | if (courseIdBegining < 0) {
50 | break;
51 | }
52 |
53 | const idSpliterIndex = data.indexOf(
54 | idSpliter,
55 | courseIdBegining + courseIdIdentifier.length,
56 | );
57 | const couseIdEnding = data.indexOf('"', idSpliterIndex + idSpliter.length);
58 |
59 | const courseNameSearchBeginingIndex = data.indexOf(
60 | 'class="course-name',
61 | couseIdEnding,
62 | );
63 | const courseNameBeginingIndex = data.indexOf(
64 | courseNameIdentifier,
65 | courseNameSearchBeginingIndex,
66 | );
67 | const courseNameEndingIndex = data.indexOf(
68 | '"',
69 | courseNameBeginingIndex + courseNameIdentifier.length,
70 | );
71 |
72 | const rawCourseId = data.slice(
73 | courseIdBegining + courseIdIdentifier.length,
74 | idSpliterIndex,
75 | );
76 | const rawClassId = data.slice(
77 | idSpliterIndex + idSpliter.length,
78 | couseIdEnding,
79 | );
80 |
81 | const courseId = Number.parseInt(rawCourseId, 10);
82 | const classId = Number.parseInt(rawClassId, 10);
83 |
84 | const courseName = data.slice(
85 | courseNameBeginingIndex + courseNameIdentifier.length,
86 | courseNameEndingIndex,
87 | );
88 |
89 | const classNameBeginingIndex = data.indexOf(
90 | classNameIdentifier,
91 | courseNameEndingIndex,
92 | );
93 | const classNameEndingIndex = data.indexOf(
94 | '',
95 | classNameBeginingIndex,
96 | );
97 | const className = data.slice(
98 | classNameBeginingIndex + classNameIdentifier.length,
99 | classNameEndingIndex,
100 | );
101 |
102 | if (Number.isNaN(courseId) || Number.isNaN(classId)) {
103 | break;
104 | }
105 |
106 | courseInfoArray = courseInfoArray.concat({
107 | course: { id: courseId, name: courseName },
108 | class: { id: classId, name: className },
109 | })
110 | }
111 |
112 | resolve({
113 | status: true,
114 | data,
115 | message: '',
116 | cookie,
117 | courseInfoArray,
118 | });
119 | },
120 | fail: (e) => reject(e),
121 | }));
122 |
123 | export const getActivities = (
124 | cookie: Cookie,
125 | courseInfo: CourseInfo,
126 | ): Promise => new Promise((resolve, reject) => wx.request({
127 | url: `https://mobilelearn.chaoxing.com/v2/apis/active/student/activelist?fid=0&courseId=${courseInfo.course.id}&classId=${courseInfo.class.id}&_=${new Date().getTime()}`,
128 | header: { Cookie: toCookieString({
129 | uf: cookie.uf,
130 | _d: cookie._d,
131 | UID: cookie.id,
132 | vc3: cookie.vc3
133 | }) },
134 | success: ({ data }) => {
135 | try {
136 | resolve({
137 | status: true,
138 | data,
139 | message: '',
140 | cookie,
141 | courseInfo,
142 | activities: toActiveListObject(data).data.activeList
143 | .reduce((filtered, activity) => {
144 | try {
145 | return filtered.concat(toRawActivityWithKnownOtherId(activity));
146 | } catch (e) {
147 | // append sign event only
148 | return filtered;
149 | }
150 | }, [])
151 | .map((rawActivity) => toActivity(rawActivity)),
152 | });
153 | } catch (e) {
154 | resolve({
155 | status: false,
156 | data,
157 | message: e instanceof TypeError
158 | ? `maybe hit the Chaoxing API limit`
159 | : `unknown error: ${JSON.stringify(e)}`,
160 | cookie,
161 | courseInfo,
162 | activities: [],
163 | });
164 | }
165 | },
166 | fail: (e) => reject(e),
167 | }));
168 |
--------------------------------------------------------------------------------
/typings/types/wx/index.d.ts:
--------------------------------------------------------------------------------
1 | /*! *****************************************************************************
2 | Copyright (c) 2023 Tencent, Inc. All rights reserved.
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of
5 | this software and associated documentation files (the "Software"), to deal in
6 | the Software without restriction, including without limitation the rights to
7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8 | of the Software, and to permit persons to whom the Software is furnished to do
9 | so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 | ***************************************************************************** */
22 |
23 | ///
24 | ///
25 | ///
26 | ///
27 | ///
28 | ///
29 | ///
30 |
31 | declare namespace WechatMiniprogram {
32 | type IAnyObject = Record
33 | type Optional = F extends (arg: infer P) => infer R ? (arg?: P) => R : F
34 | type OptionalInterface = { [K in keyof T]: Optional }
35 | interface AsyncMethodOptionLike {
36 | success?: (...args: any[]) => void
37 | }
38 | type PromisifySuccessResult<
39 | P,
40 | T extends AsyncMethodOptionLike
41 | > = P extends {
42 | success: any
43 | }
44 | ? void
45 | : P extends { fail: any }
46 | ? void
47 | : P extends { complete: any }
48 | ? void
49 | : Promise>[0]>
50 |
51 | // TODO: Extract real definition from `lib.dom.d.ts` to replace this
52 | type IIRFilterNode = any
53 | type WaveShaperNode = any
54 | type ConstantSourceNode = any
55 | type OscillatorNode = any
56 | type GainNode = any
57 | type BiquadFilterNode = any
58 | type PeriodicWaveNode = any
59 | type BufferSourceNode = any
60 | type ChannelSplitterNode = any
61 | type ChannelMergerNode = any
62 | type DelayNode = any
63 | type DynamicsCompressorNode = any
64 | type ScriptProcessorNode = any
65 | type PannerNode = any
66 | type AnalyserNode = any
67 | type AudioListener = any
68 | type WebGLTexture = any
69 | type WebGLRenderingContext = any
70 |
71 | // TODO: fill worklet type
72 | type WorkletFunction = (...args: any) => any
73 | type AnimationObject = any
74 | type SharedValue = T
75 | type DerivedValue = T
76 | }
77 |
78 | declare let console: WechatMiniprogram.Console
79 |
80 | declare let wx: WechatMiniprogram.Wx
81 | /** 引入模块。返回模块通过 `module.exports` 或 `exports` 暴露的接口。 */
82 | interface Require {
83 | (
84 | /** 需要引入模块文件相对于当前文件的相对路径,或 npm 模块名,或 npm 模块路径。不支持绝对路径 */
85 | module: string,
86 | /** 用于异步获取其他分包中的模块的引用结果,详见 [分包异步化]((subpackages/async)) */
87 | callback?: (moduleExport: any) => void,
88 | /** 异步获取分包失败时的回调,详见 [分包异步化]((subpackages/async)) */
89 | errorCallback?: (err: any) => void
90 | ): any
91 | /** 以 Promise 形式异步引入模块。返回模块通过 `module.exports` 或 `exports` 暴露的接口。 */
92 | async(
93 | /** 需要引入模块文件相对于当前文件的相对路径,或 npm 模块名,或 npm 模块路径。不支持绝对路径 */
94 | module: string
95 | ): Promise
96 | }
97 | declare const require: Require
98 | /** 引入插件。返回插件通过 `main` 暴露的接口。 */
99 | interface RequirePlugin {
100 | (
101 | /** 需要引入的插件的 alias */
102 | module: string,
103 | /** 用于异步获取其他分包中的插件的引用结果,详见 [分包异步化]((subpackages/async)) */
104 | callback?: (pluginExport: any) => void
105 | ): any
106 | /** 以 Promise 形式异步引入插件。返回插件通过 `main` 暴露的接口。 */
107 | async(
108 | /** 需要引入的插件的 alias */
109 | module: string
110 | ): Promise
111 | }
112 | declare const requirePlugin: RequirePlugin
113 | /** 插件引入当前使用者小程序。返回使用者小程序通过 [插件配置中 `export` 暴露的接口](https://developers.weixin.qq.com/miniprogram/dev/framework/plugin/using.html#%E5%AF%BC%E5%87%BA%E5%88%B0%E6%8F%92%E4%BB%B6)。
114 | *
115 | * 该接口只在插件中存在
116 | *
117 | * 最低基础库: `2.11.1` */
118 | declare function requireMiniProgram(): any
119 | /** 当前模块对象 */
120 | declare let module: {
121 | /** 模块向外暴露的对象,使用 `require` 引用该模块时可以获取 */
122 | exports: any
123 | }
124 | /** `module.exports` 的引用 */
125 | declare let exports: any
126 |
127 | /** [clearInterval(number intervalID)](https://developers.weixin.qq.com/miniprogram/dev/api/base/timer/clearInterval.html)
128 | *
129 | * 取消由 setInterval 设置的定时器。 */
130 | declare function clearInterval(
131 | /** 要取消的定时器的 ID */
132 | intervalID: number
133 | ): void
134 | /** [clearTimeout(number timeoutID)](https://developers.weixin.qq.com/miniprogram/dev/api/base/timer/clearTimeout.html)
135 | *
136 | * 取消由 setTimeout 设置的定时器。 */
137 | declare function clearTimeout(
138 | /** 要取消的定时器的 ID */
139 | timeoutID: number
140 | ): void
141 | /** [number setInterval(function callback, number delay, any rest)](https://developers.weixin.qq.com/miniprogram/dev/api/base/timer/setInterval.html)
142 | *
143 | * 设定一个定时器。按照指定的周期(以毫秒计)来执行注册的回调函数 */
144 | declare function setInterval(
145 | /** 回调函数 */
146 | callback: (...args: any[]) => any,
147 | /** 执行回调函数之间的时间间隔,单位 ms。 */
148 | delay?: number,
149 | /** param1, param2, ..., paramN 等附加参数,它们会作为参数传递给回调函数。 */
150 | rest?: any
151 | ): number
152 | /** [number setTimeout(function callback, number delay, any rest)](https://developers.weixin.qq.com/miniprogram/dev/api/base/timer/setTimeout.html)
153 | *
154 | * 设定一个定时器。在定时到期以后执行注册的回调函数 */
155 | declare function setTimeout(
156 | /** 回调函数 */
157 | callback: (...args: any[]) => any,
158 | /** 延迟的时间,函数的调用会在该延迟之后发生,单位 ms。 */
159 | delay?: number,
160 | /** param1, param2, ..., paramN 等附加参数,它们会作为参数传递给回调函数。 */
161 | rest?: any
162 | ): number
163 |
--------------------------------------------------------------------------------
/miniprogram/pages/userInfo/userInfo.ts:
--------------------------------------------------------------------------------
1 | import { loginByFanya, loginByV11 } from "../../services/login";
2 | import {
3 | Credential,
4 | GetCookieFailedResult,
5 | LoginResult,
6 | NameFailedResult,
7 | LoginType,
8 | } from "../../utils/types";
9 | import { isString } from "../../utils/util";
10 |
11 | // pages/userInfo/userInfo.ts
12 | Page({
13 |
14 | /**
15 | * 页面的初始数据
16 | */
17 | data: {
18 | id: -1,
19 | name: '',
20 | username: '',
21 | password: '',
22 | totalOfCourse: 0,
23 | remeber: false,
24 | firstTimeRemeber: true,
25 | hideUserManagements: true,
26 | errorMessage: null as null | string,
27 | },
28 | emptyMethod() {},
29 | /**
30 | * Remove user by channel
31 | */
32 | removeUser() {
33 | const { id } = this.data;
34 |
35 | console.debug(`remove user ${id}`);
36 |
37 | this.getOpenerEventChannel().emit('removeUser', id);
38 | this.getOpenerEventChannel().emit('removeCredential', id);
39 | },
40 | /**
41 | * Add user by channel
42 | */
43 | addUser(
44 | loginType: LoginType,
45 | result: LoginResult | GetCookieFailedResult | NameFailedResult
46 | ) {
47 | if (!result.status) {
48 | console.error("failed to login user", result);
49 | this.setData({ errorMessage: JSON.stringify(result.data) });
50 | return;
51 | }
52 |
53 | const { user } = result;
54 |
55 | this.getOpenerEventChannel().emit('addUser', user);
56 | if (this.data.remeber) {
57 | console.debug(`cache username and password for user ${user.id}`);
58 |
59 | const credential: Credential = {
60 | id: user.id,
61 | loginType,
62 | username: this.data.username,
63 | password: this.data.password,
64 | };
65 | this.getOpenerEventChannel().emit('addCredential', credential);
66 | } else {
67 | // maybe user is updating cookie
68 | this.getOpenerEventChannel().emit('removeCredential', user.id);
69 | }
70 |
71 | this.setData({ errorMessage: null });
72 | wx.navigateBack();
73 | },
74 | loginFanya() {
75 | const { username, password } = this.data;
76 | loginByFanya(username, password).then((result) =>
77 | this.addUser('fanya', result)
78 | ).catch((e) => {
79 | console.error(e);
80 | this.setData({ errorMessage: JSON.stringify(e) });
81 | });
82 | },
83 | loginV11() {
84 | const { username, password } = this.data;
85 | loginByV11(username, password).then((result) =>
86 | this.addUser('v11', result)
87 | ).catch((e) => {
88 | console.error(e);
89 | this.setData({ errorMessage: JSON.stringify(e) });
90 | });
91 | },
92 | /**
93 | * Jump to privacyTips if no credential saved
94 | */
95 | toPrivacyTips() {
96 | if (this.data.firstTimeRemeber && this.data.remeber) {
97 | wx.navigateTo({ url: '../privacyTips/privacyTips' });
98 | }
99 | },
100 | /**
101 | * Refresh courseInfoArray by channel
102 | */
103 | refreshCourseInfoArray() {
104 | console.debug(`refresh courseInfoArray of user ${this.data.id}`);
105 | this.getOpenerEventChannel().emit('refreshCourseInfoArray', this.data.id);
106 | wx.showToast({ title: '已请求刷新', icon: 'none' });
107 | },
108 |
109 | /**
110 | * 生命周期函数--监听页面加载
111 | */
112 | onLoad(options) {
113 | if (!isString(options.channelOpened) || !this.getOpenerEventChannel()) {
114 | wx.navigateBack();
115 | }
116 |
117 | const rawChannelOpened = options.channelOpened as string;
118 | try {
119 | const channelOpened = JSON.parse(rawChannelOpened);
120 |
121 | console.debug(`channelOpened =`, channelOpened);
122 | if (!channelOpened) {
123 | wx.navigateBack();
124 | }
125 | } catch (e) {
126 | if (e instanceof TypeError) {
127 | console.warn('failed to parse channelOpened from opener');
128 | wx.navigateBack();
129 | } else {
130 | wx.navigateBack();
131 | throw e;
132 | }
133 | }
134 |
135 | if (options.id) {
136 | try {
137 | const id = JSON.parse(options.id);
138 |
139 | console.debug(`id =`, id);
140 |
141 | const totalsOfCourse = getApp().globalData.totalsOfCourse;
142 | console.debug(`totalsOfCourse =`, totalsOfCourse);
143 | let totalOfCourse = totalsOfCourse[id];
144 |
145 | if (typeof totalOfCourse !== 'number' || Number.isNaN(totalOfCourse)) {
146 | console.warn('failed to read totalsOfCourse from globalData');
147 | totalOfCourse = 0;
148 | }
149 |
150 | this.setData({
151 | id,
152 | totalOfCourse,
153 | hideUserManagements: false,
154 | });
155 | } catch (e) {
156 | if (e instanceof TypeError) {
157 | console.warn('failed to parse id from opener');
158 | wx.navigateBack();
159 | } else {
160 | wx.navigateBack();
161 | throw e;
162 | }
163 | }
164 | }
165 |
166 | if (options.name) {
167 | try {
168 | const name = JSON.parse(options.name);
169 |
170 | this.setData({
171 | name,
172 | });
173 | } catch (e) {
174 | if (e instanceof TypeError) {
175 | console.warn('failed to parse name from opener');
176 | wx.navigateBack();
177 | } else {
178 | wx.navigateBack();
179 | throw e;
180 | }
181 | }
182 | }
183 |
184 | if (options.credential) {
185 | try {
186 | const credential: Credential = JSON.parse(options.credential);
187 |
188 | const totalsOfCourse = getApp().globalData.totalsOfCourse;
189 | console.debug(`totalsOfCourse =`, totalsOfCourse);
190 | let totalOfCourse = totalsOfCourse[credential.id];
191 |
192 | if (typeof totalOfCourse !== 'number' || Number.isNaN(totalOfCourse)) {
193 | console.warn('failed to read totalsOfCourse from globalData');
194 | totalOfCourse = 0;
195 | }
196 |
197 | this.setData({
198 | remeber: true,
199 | id: credential.id,
200 | username: credential.username,
201 | password: credential.password,
202 | totalOfCourse,
203 | hideUserManagements: false,
204 | });
205 | } catch (e) {
206 | if (e instanceof TypeError) {
207 | console.warn('failed to parse credential from opener');
208 | wx.navigateBack();
209 | } else {
210 | wx.navigateBack();
211 | throw e;
212 | }
213 | }
214 | }
215 |
216 | if (options.firstTimeRemeber) {
217 | try {
218 | const firstTimeRemeber: boolean = JSON.parse(options.firstTimeRemeber);
219 | this.setData({ firstTimeRemeber });
220 | } catch (e) {
221 | if (e instanceof TypeError) {
222 | console.warn('failed to parse firstTimeRemeber from opener');
223 | wx.navigateBack();
224 | } else {
225 | wx.navigateBack();
226 | throw e;
227 | }
228 | }
229 | }
230 | },
231 |
232 | /**
233 | * 生命周期函数--监听页面初次渲染完成
234 | */
235 | onReady() {
236 |
237 | },
238 |
239 | /**
240 | * 生命周期函数--监听页面显示
241 | */
242 | onShow() {
243 |
244 | },
245 |
246 | /**
247 | * 生命周期函数--监听页面隐藏
248 | */
249 | onHide() {
250 |
251 | },
252 |
253 | /**
254 | * 生命周期函数--监听页面卸载
255 | */
256 | onUnload() {
257 |
258 | },
259 |
260 | /**
261 | * 页面相关事件处理函数--监听用户下拉动作
262 | */
263 | onPullDownRefresh() {
264 |
265 | },
266 |
267 | /**
268 | * 页面上拉触底事件的处理函数
269 | */
270 | onReachBottom() {
271 |
272 | },
273 |
274 | /**
275 | * 用户点击右上角分享
276 | */
277 | onShareAppMessage() {
278 |
279 | }
280 | });
281 |
--------------------------------------------------------------------------------
/miniprogram/utils/util.ts:
--------------------------------------------------------------------------------
1 | import {
2 | User, Cookie, RawActivity, RawActivityListObject, Activity, SignMethod,
3 | RawActivityWithKnownOtherId, Credential, CookieWithCourseInfo, CourseInfo,
4 | } from './types';
5 |
6 | export const isString = (text: unknown): text is string =>
7 | typeof text === 'string' || text instanceof String;
8 |
9 | const isCookie = (obj: any): obj is Cookie =>
10 | typeof obj.expire === 'number' && typeof obj.id === 'number' && isString(obj.uf)
11 | && isString(obj.vc3) && isString(obj._d) && isString(obj.fid);
12 |
13 | const isCourseInfo = (obj: any): obj is CourseInfo =>
14 | obj.course && typeof obj.course.id === 'number' && isString(obj.course.name)
15 | && obj.class && typeof obj.class.id === 'number' && isString(obj.class.name);
16 |
17 | const isUser = (obj: any): obj is User => isString(obj.name) && isCookie(obj);
18 |
19 | const isCredential = (obj: any): obj is Credential => typeof obj.id === 'number'
20 | && isString(obj.username) && isString(obj.password);
21 |
22 | const isRawActivity = (obj: any): obj is RawActivity =>
23 | typeof obj.status === 'number' && typeof obj.startTime === 'number'
24 | && isString(obj.nameFour)
25 | && (typeof obj.endTime === 'number' || obj.endTime === '')
26 | && typeof obj.id === 'number' && isString(obj.nameOne);
27 |
28 | const isRawActivityListObject = (obj: any): obj is RawActivityListObject => {
29 | if (!(
30 | typeof obj.data === 'object' && obj.data !== null
31 | && Array.isArray(obj.data.activeList)
32 | )) {
33 | return false;
34 | }
35 |
36 | for (const active of obj.data.activeList) {
37 | if (!isRawActivity(active)) {
38 | console.log('active problem', active);
39 | return false;
40 | }
41 | }
42 |
43 | return true;
44 | }
45 |
46 | const toCookie = (obj: any): Cookie => {
47 | if (!isCookie(obj)) {
48 | throw TypeError('passed object is not a cookie');
49 | }
50 |
51 | return obj;
52 | }
53 |
54 | export const toCache = (obj: any): CookieWithCourseInfo => {
55 | if (!Array.isArray(obj.courseInfoArray)) {
56 | throw TypeError('courseInfoArray in passed object is not a array');
57 | }
58 |
59 | for (const courseInfo of obj.courseInfoArray) {
60 | if (!isCourseInfo(courseInfo)) {
61 | throw TypeError(
62 | 'courseInfo in courseInfoArray of passed object is not a array',
63 | );
64 | }
65 | }
66 |
67 | return { ...obj, cookie: toCookie(obj.cookie) };
68 | }
69 |
70 | export const toUser = (obj: any): User => {
71 | if (!isUser(obj)) {
72 | throw TypeError('passed object is not a User');
73 | }
74 |
75 | return obj;
76 | }
77 |
78 | export const toCredential = (obj: any): Credential => {
79 | if (!isCredential(obj)) {
80 | throw TypeError('passed object is not a Credential');
81 | }
82 |
83 | return obj;
84 | }
85 |
86 | export const toActiveListObject = (obj: any): RawActivityListObject => {
87 | if (!isRawActivityListObject(obj)) {
88 | throw TypeError('passed object is not a RawActivityListObject');
89 | }
90 |
91 | return obj;
92 | }
93 |
94 | const isRawActivityWithKnownOtherId = (rawActivity: any):
95 | rawActivity is RawActivityWithKnownOtherId => isString(rawActivity.otherId)
96 | && ['0', '2', '3', '4', '5'].includes(rawActivity.otherId)
97 | && isRawActivity(rawActivity);
98 |
99 | export const toRawActivityWithKnownOtherId = (rawActivity: any):
100 | RawActivityWithKnownOtherId => {
101 | if (!isRawActivityWithKnownOtherId(rawActivity)) {
102 | throw TypeError('passed rawActivity have no otherId');
103 | }
104 |
105 | return rawActivity;
106 | }
107 |
108 | export const isSignMethod = (text: string): text is SignMethod => [
109 | 'clickOrPhoto', 'qrCode', 'gesture', 'location', 'code',
110 | ].includes(text);
111 |
112 | export const toActivity = (rawActivity: RawActivityWithKnownOtherId): Activity => {
113 | let signMethod: SignMethod = 'unknown';
114 | switch (rawActivity.otherId) {
115 | case '0':
116 | signMethod = 'clickOrPhoto';
117 | break;
118 | case '2':
119 | signMethod = 'qrCode';
120 | break;
121 | case '3':
122 | signMethod = 'gesture';
123 | break;
124 | case '4':
125 | signMethod = 'location';
126 | break;
127 | case '5':
128 | signMethod = 'code';
129 | break;
130 | }
131 |
132 | return {
133 | id: rawActivity.id,
134 | name: rawActivity.nameOne,
135 | signMethod,
136 | startTime: rawActivity.startTime,
137 | endTime: rawActivity.endTime === '' ? -1 : rawActivity.endTime,
138 | endTimeForHuman: rawActivity.nameFour,
139 | raw: rawActivity,
140 | };
141 | };
142 |
143 | export const parseCookiesFromWx = (cookiesFromWx: Array): Cookie => {
144 | let cookie: Cookie = {
145 | // I belive that Cookie will expire within 4 years
146 | expire: (new Date()).getTime() + 1000 * 60 * 60 * 24 * 365 * 4,
147 | id: -1,
148 | uf: '',
149 | vc3: '',
150 | _d: '',
151 | fid: '',
152 | };
153 | // Do not check expire
154 | const keys = Object.keys(cookie)
155 | .filter((key) => key !== 'expire' && key !== 'id') as Array;
156 |
157 | const now = (new Date()).getTime();
158 | cookiesFromWx.forEach((c) => {
159 | const pairs = c.split('; ');
160 | const important = pairs[0];
161 | const equalIndex = important.indexOf('=');
162 | const key = important.slice(0, equalIndex);
163 | const value = important.slice(equalIndex + 1);
164 |
165 | if (key === '_uid') {
166 | const id = Number.parseInt(value, 10);
167 | cookie = { ...cookie, id };
168 | }
169 | cookie = { ...cookie, [key]: value };
170 |
171 | for (const pair of pairs) {
172 | if (pair.startsWith('Expires=')) {
173 | const date = pair.slice('Expires='.length);
174 | const parsedDate = Date.parse(date);
175 | if (!Number.isNaN(parsedDate)) {
176 | if (parsedDate < cookie.expire && parsedDate > now) {
177 | cookie.expire = parsedDate;
178 | }
179 | }
180 |
181 | break;
182 | }
183 | }
184 | });
185 |
186 | keys.forEach((key) => {
187 | const value = cookie[key];
188 |
189 | if (isString(value)) {
190 | if (!((cookie[key] as string).length > 0)) {
191 | throw TypeError(`Failed to parse cookie ${key}: ${value} to string`);
192 | }
193 | } else if (typeof value === 'number') {
194 | if (Number.isNaN(value)) {
195 | throw TypeError(`failed to parse cookie ${key}: ${value} to number`);
196 | }
197 | } else {
198 | throw TypeError(`failed to parse cookie ${key}: ${value}`);
199 | }
200 | });
201 | return cookie;
202 | }
203 |
204 | export const userToCookie = (user: User): Cookie => {
205 | const keys = Object.keys(user) as (keyof User)[];
206 |
207 | return keys.reduce((existedCookie, key) => key !== 'name'
208 | ? { ...existedCookie, [key]: user[key] }
209 | : existedCookie, {} as any);
210 | }
211 |
212 | export const toCookieString = (cookie: { [key: string]: number | string }): string =>
213 | Object.entries(cookie).map(([key, value]) => `${key}=${value}; `)
214 | .concat('SameSite=Strict; ').join('');
215 |
216 | export const parametersToString = (
217 | parameters: { [key: string]: number | string }
218 | ): string => Object.entries(parameters)
219 | .map(([key, value]) => `${key}=${value}`).join('&');
220 |
221 | export const parametersToStringifyString = (
222 | parameters: { [key: string]: any }
223 | ): string => Object.entries(parameters)
224 | .map(([key, value]) => `${key}=${JSON.stringify(value)}`).join('&');
225 |
226 | export const cookieForSign = (cookie: Cookie): { [key: string]: any } => ({
227 | uf: cookie.uf, _d: cookie._d, UID: cookie.id, vc3: cookie.vc3
228 | });
229 |
--------------------------------------------------------------------------------
/miniprogram/pages/userManager/userManager.ts:
--------------------------------------------------------------------------------
1 | import { loginByFanya } from "../../services/login";
2 | import { Credential, GetCookieFailedResult, LoginResult, NameFailedResult, User } from "../../utils/types";
3 | import { isString, parametersToStringifyString, toCredential } from "../../utils/util";
4 |
5 | // pages/userManager/userManager.ts
6 | Page({
7 |
8 | /**
9 | * 页面的初始数据
10 | */
11 | data: {
12 | users: [] as User[],
13 | credentials: [] as Credential[],
14 | idToCredentials: {} as { [id: number]: Credential },
15 | },
16 | /**
17 | * Convert credentials to Object for wxml
18 | * @param credentials credentials from storage
19 | */
20 | toIdToCredentials(credentials: Credential[]) {
21 | return credentials.reduce((c, credential) => ({
22 | ...c,
23 | [credential.id]: credential,
24 | }), {});
25 | },
26 | /**
27 | * Open userInfo from wxml
28 | * @param e id, name and credential from wxml
29 | */
30 | openUserInfo(e: WechatMiniprogram.BaseEvent) {
31 | const { id, name, credential } = e.currentTarget.dataset as {
32 | [key: string]: string
33 | };
34 |
35 | let existedParameters = {};
36 | try {
37 | existedParameters = JSON.parse(e.target.dataset.parameters);
38 | } catch (e) {
39 | if (!(e instanceof SyntaxError)) {
40 | console.warn(
41 | 'unknown error occurred during parsing parameters for openUserInfo',
42 | e,
43 | );
44 | }
45 | }
46 |
47 | const parameters = {
48 | ...existedParameters,
49 | channelOpened: this.getOpenerEventChannel() !== null,
50 | firstTimeRemeber: !(this.data.credentials.length > 0),
51 | ...(credential ? { credential } : {}),
52 | ...(id ? { id } : {}),
53 | ...(name ? { name } : {}),
54 | };
55 |
56 | console.debug('parsed parameters =', parameters);
57 |
58 | wx.navigateTo({
59 | url: `../userInfo/userInfo?${parametersToStringifyString(parameters)}`,
60 | events: {
61 | /**
62 | * Add user from channel
63 | * @param user user to be added
64 | */
65 | addUser: (user: User) => {
66 | const filteredUsers = this.data.users.filter((u) => u.id !== user.id);
67 | const newUsers = filteredUsers.concat(user);
68 |
69 | console.debug('newUsers =', newUsers);
70 |
71 | this.setData({ users: newUsers });
72 | this.getOpenerEventChannel().emit('addUser', user);
73 | },
74 | /**
75 | * Add credential from channel
76 | * @param credential credential to be added
77 | */
78 | addCredential: (credential: Credential) => {
79 | const filteredCredentials = this.data.credentials
80 | .filter((c) => c.id !== credential.id);
81 | const newCredentials = filteredCredentials.concat(credential);
82 |
83 | this.setData({
84 | credentials: newCredentials,
85 | idToCredentials: this.toIdToCredentials(newCredentials),
86 | });
87 | wx.setStorage({ key: 'credentials', encrypt: true, data: newCredentials });
88 | },
89 | /**
90 | * Remove user from channel
91 | * @param id id of user to be removed from caches, users and nameOfUsers
92 | */
93 | removeUser: (id: number) => {
94 | console.debug(`remove user ${id}`);
95 |
96 | const users = this.data.users.filter((u) => u.id !== id);
97 | this.setData({ users });
98 | this.getOpenerEventChannel().emit('removeUser', id);
99 | },
100 | /**
101 | * Remove credential from channel
102 | * @param id id of credential to be removed from credentials
103 | */
104 | removeCredential: (id: number) => {
105 | console.debug(`remove credential ${id}`);
106 |
107 | const credentials = this.data.credentials.filter((c) => c.id !== id);
108 | wx.setStorage({ key: 'credentials', encrypt: true, data: credentials })
109 | .then(() =>
110 | this.setData({
111 | credentials: credentials,
112 | idToCredentials: this.toIdToCredentials(credentials),
113 | })
114 | );
115 | },
116 | /**
117 | * Refresh courseInfoArray by ID from channel
118 | * @param id ID of user to be refreshed
119 | */
120 | refreshCourseInfoArray: (id: number) => this.getOpenerEventChannel()
121 | .emit('refreshCourseInfoArray', id),
122 | }
123 | });
124 | },
125 |
126 | copyGitHubLink() {
127 | wx.setClipboardData({
128 | data: 'https://github.com/DroppingOutSpeedrun/Dropping-Out-Speedrun',
129 | });
130 | },
131 |
132 | copyBitbucketLink() {
133 | wx.setClipboardData({
134 | data: 'https://bitbucket.org/dropping-out-speedrun/dropping-out-speedrun',
135 | });
136 | },
137 |
138 | /**
139 | * 生命周期函数--监听页面加载
140 | */
141 | onLoad(options) {
142 | if (!this.getOpenerEventChannel()) {
143 | console.warn('openerEventChannel not found');
144 | wx.navigateBack();
145 | }
146 |
147 | wx.getStorage({ key: 'credentials', encrypt: true }).then((result) => {
148 | console.debug('get credentials from storage', result);
149 |
150 | const credentials = result.data;
151 | if (!Array.isArray(credentials)) {
152 | console.warn('failed to parse credentials from storage', result);
153 | wx.showToast({ title: '读取账号密码信息时出错', icon: 'error' });
154 | return;
155 | }
156 |
157 | try {
158 | const prasedCredentials = credentials.map((credential) =>
159 | toCredential(credential)
160 | );
161 | this.setData({
162 | credentials: prasedCredentials,
163 | idToCredentials: this.toIdToCredentials(prasedCredentials),
164 | });
165 | } catch (e) {
166 | if (e instanceof TypeError) {
167 | console.warn('failed to parse credentials');
168 | wx.removeStorage({ key: 'credentials' });
169 | } else {
170 | throw e;
171 | }
172 | }
173 | }).catch((e) => {
174 | if (isString(e.errMsg) && e.errMsg.includes('data not found')) {
175 | console.debug('credentials is empty yet');
176 | } else {
177 | throw e;
178 | }
179 | });
180 |
181 | if (options.users) {
182 | try {
183 | const users = JSON.parse(options.users);
184 | console.debug('users from opener', users);
185 | this.setData({ users });
186 | } catch (e) {
187 | if (e instanceof TypeError) {
188 | console.warn('failed to parse users from opener');
189 | } else {
190 | throw e;
191 | }
192 | }
193 | }
194 | },
195 |
196 | /**
197 | * 生命周期函数--监听页面初次渲染完成
198 | */
199 | onReady() {
200 |
201 | },
202 |
203 | /**
204 | * 生命周期函数--监听页面显示
205 | */
206 | onShow() {
207 | },
208 |
209 | /**
210 | * 生命周期函数--监听页面隐藏
211 | */
212 | onHide() {
213 |
214 | },
215 |
216 | /**
217 | * 生命周期函数--监听页面卸载
218 | */
219 | onUnload() {
220 |
221 | },
222 |
223 | /**
224 | * 页面相关事件处理函数--监听用户下拉动作
225 | */
226 | onPullDownRefresh() {
227 | console.debug('start refreshing cookies');
228 | let promises: Promise[] = [];
229 | this.data.credentials.forEach(({ loginType, username, password }) => {
230 | switch (loginType) {
231 | case 'v11':
232 | promises = promises.concat(loginByFanya(username, password));
233 | break;
234 | case 'fanya':
235 | default:
236 | if (loginType !== 'fanya') {
237 | console.warn('unknown `loginType` detected, use `fanya` in default');
238 | }
239 | promises = promises.concat(loginByFanya(username, password));
240 | break;
241 | }
242 | });
243 |
244 | Promise.allSettled(promises).then((results) => results.forEach((result) => {
245 | if (result.status !== 'fulfilled') {
246 | console.error('failed to process this promise', result);
247 | wx.showToast({
248 | title: `部分用户的登录信息刷新失败:${result.reason}`,
249 | icon: 'error',
250 | });
251 | return;
252 | }
253 |
254 | const { status, message, data } = result.value;
255 |
256 | if (!status) {
257 | console.error(message, data);
258 | wx.showToast({ title: message, icon: 'error' });
259 | return;
260 | }
261 |
262 | const user: User = (result.value as any).user;
263 | this.getOpenerEventChannel().emit('addUser', user);
264 | })).finally(() => wx.stopPullDownRefresh());
265 | },
266 |
267 | /**
268 | * 页面上拉触底事件的处理函数
269 | */
270 | onReachBottom() {
271 |
272 | },
273 |
274 | /**
275 | * 用户点击右上角分享
276 | */
277 | onShareAppMessage() {
278 |
279 | }
280 | });
281 |
--------------------------------------------------------------------------------
/typings/types/wx/lib.wx.page.d.ts:
--------------------------------------------------------------------------------
1 | /*! *****************************************************************************
2 | Copyright (c) 2023 Tencent, Inc. All rights reserved.
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of
5 | this software and associated documentation files (the "Software"), to deal in
6 | the Software without restriction, including without limitation the rights to
7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8 | of the Software, and to permit persons to whom the Software is furnished to do
9 | so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 | ***************************************************************************** */
22 |
23 | declare namespace WechatMiniprogram.Page {
24 | type Instance<
25 | TData extends DataOption,
26 | TCustom extends CustomOption
27 | > = OptionalInterface &
28 | InstanceProperties &
29 | InstanceMethods &
30 | Data &
31 | TCustom
32 | type Options<
33 | TData extends DataOption,
34 | TCustom extends CustomOption
35 | > = (TCustom &
36 | Partial> &
37 | Partial & {
38 | options?: Component.ComponentOptions
39 | }) &
40 | ThisType>
41 | type TrivialInstance = Instance
42 | interface Constructor {
43 | (
44 | options: Options
45 | ): void
46 | }
47 | interface ILifetime {
48 | /** 生命周期回调—监听页面加载
49 | *
50 | * 页面加载时触发。一个页面只会调用一次,可以在 onLoad 的参数中获取打开当前页面路径中的参数。
51 | */
52 | onLoad(
53 | /** 打开当前页面路径中的参数 */
54 | query: Record
55 | ): void | Promise
56 | /** 生命周期回调—监听页面显示
57 | *
58 | * 页面显示/切入前台时触发。
59 | */
60 | onShow(): void | Promise
61 | /** 生命周期回调—监听页面初次渲染完成
62 | *
63 | * 页面初次渲染完成时触发。一个页面只会调用一次,代表页面已经准备妥当,可以和视图层进行交互。
64 | *
65 |
66 | * 注意:对界面内容进行设置的 API 如`wx.setNavigationBarTitle`,请在`onReady`之后进行。
67 | */
68 | onReady(): void | Promise
69 | /** 生命周期回调—监听页面隐藏
70 | *
71 | * 页面隐藏/切入后台时触发。 如 `navigateTo` 或底部 `tab` 切换到其他页面,小程序切入后台等。
72 | */
73 | onHide(): void | Promise
74 | /** 生命周期回调—监听页面卸载
75 | *
76 | * 页面卸载时触发。如`redirectTo`或`navigateBack`到其他页面时。
77 | */
78 | onUnload(): void | Promise
79 | /** 监听用户下拉动作
80 | *
81 | * 监听用户下拉刷新事件。
82 | * - 需要在`app.json`的`window`选项中或页面配置中开启`enablePullDownRefresh`。
83 | * - 可以通过`wx.startPullDownRefresh`触发下拉刷新,调用后触发下拉刷新动画,效果与用户手动下拉刷新一致。
84 | * - 当处理完数据刷新后,`wx.stopPullDownRefresh`可以停止当前页面的下拉刷新。
85 | */
86 | onPullDownRefresh(): void | Promise
87 | /** 页面上拉触底事件的处理函数
88 | *
89 | * 监听用户上拉触底事件。
90 | * - 可以在`app.json`的`window`选项中或页面配置中设置触发距离`onReachBottomDistance`。
91 | * - 在触发距离内滑动期间,本事件只会被触发一次。
92 | */
93 | onReachBottom(): void | Promise
94 | /** 用户点击右上角转发
95 | *
96 | * 监听用户点击页面内转发按钮(`