青年大学习API分析+自动打卡姬

事不过三,确实是被好几次“10分钟倒计时”折磨到了。把手机扔一边放个视频不是问题,问题是中间还得手动点个地方,还可能答几道题,被烦到了,于是有了此文。(适用于广东共青团,其他地方未知)

抓包分析#

从电脑端的“12355青年之声”开始,用Fiddler抓包,如图:

第一次登录需要微信的授权,紫线以上都是微信的授权请求,而蓝线就是个人主页了,右下角可以看到纯文本形式的样子。不过没想到微信接入居然还是用http,真不怕中间人劫持吗(虽然也没啥好劫持就是了,最多拿拿个人资料)

微信授权的作用范围是tuanapi.12355.net开头的请求,内部用Java的Session Cookie进行追踪,在点开始学习后,会有这么一个请求,然后跳转到最新一期的学习页面。(下面的youthstudy.12355.net/h5/就是开始学习的页面了)

一波胡乱分析,保持登录状态无非就两个要素,一个是Session Cookie(微信授权给tuanapi请求的那个set-cookie),另外一个是url中的mid参数了。万幸的是,这个接口只验证了mid,没检查cookie是否存在(即使没有cookie,mid合理时请求依然会返回成功,而mid没给的时候会提示bad request)。如果真要这个Session Cookie,可能还真一时半会不能解决,毕竟这需要伪造微信授权了,已经超出目前能力范围了……

那么mid从何而来,Ctrl+F查找一番,之前一个请求被高亮标出了:

它包含了获取当前用户的所有数据,其中有一栏就是mid=xxxx,直接搬过来用就好了。这个请求就要用到头疼的Session Cookie了。

在正常情况下,输入完来自……之后,就会跳转到当前最新的一期,这时候的请求是这样的,章节ID和url就拿到手了:

由于session cookie是在tuanapi.12355.net域名下有效的,因此青年大学习(youthstudy.12355.net开头的API请求)需要另外一个认证机制,这里它用的是token,就是上图红色框出来的那一段。它从何来,就在前面一点的一个post请求,post的内容就是前面用mid请求的那个API返回的URL后面的那一坨sign,token就在响应JSON的data下的entity里面,就叫token。注意这里的header也有X-Litemall-IdentiFicationX-Litemall-Token,只不过token留空了而已。

获取完最新一期的数据后,反手就是POST保存学习的进度,内容很简单,就一个章节ID,前面获取最新章节的id就是了(那个pid不是,别搞混了): (真良心啊,是我我就写播完视频之后用一段js触发保存了,什么没保存?那麻烦请重新看一遍)

由于我们还要学完截个图,所以还得整个 假装已经 学完的页面出来,不过既然有URL,翻翻图片就好了,就这个以..../end.jpg结尾的文件,然后自己写个网页,套点css,用微信打开,截个图走人就好了。

实现代码#

整个代码如下:

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
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#! /usr/bin/python3
import requests
import pickle
import argparse
import logging
import sys
from typing import *
import re
import os

wechat_user_agent = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116' \
' Safari/537.36 QBCore/4.0.1326.400 QQBrowser/9.0.2524.400 Mozilla/5.0 (Windows NT 6.1; WOW64) ' \
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2875.116 Safari/537.36 NetType/WIFI ' \
'MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63010200)'
root = logging.root
global_session = requests.session()
host_schemas = re.compile(r'^(https?://[^/]+)/.*$')


def configure_logging(verbose: bool = False) -> logging.Logger:
class ScreenOutputFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> int:
return record.levelno in (logging.DEBUG, logging.INFO)
fmt_str = '[%(asctime)s] [%(levelname)s] [%(name)s] (%(filename)s:%(lineno)d) %(message)s'
fmt = logging.Formatter(fmt_str)

# ROOT logging config
stdout_handler = logging.StreamHandler(sys.stdout)
if verbose:
stdout_handler.setLevel(logging.DEBUG)
else:
stdout_handler.setLevel(logging.INFO)
stdout_handler.setFormatter(fmt)
stdout_handler.addFilter(ScreenOutputFilter())
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.WARNING)
stderr_handler.setFormatter(fmt)
root.addHandler(stdout_handler)
root.addHandler(stderr_handler)
root.setLevel(logging.DEBUG)
return root


def parse_args(args=None):
parser = argparse.ArgumentParser()
parser.add_argument('--mid', required=True, type=int, help='MID from MyProfile response')
parser.add_argument('--save_chapter_file', type=str, default='chapter_id.pkl',
help='A pickle file storing the finished chapter IDs to prevent redundant request')
parser.add_argument('--deploy_webpage_path', type=str, default='/var/www/html/youth_study.html',
help='Path for deploying the finished page, used for screenshotting')
parser.add_argument('--verbose', action='store_true', default=False, help='Log verbosely')
return parser.parse_args(args)


def generate_webpage(study_page: str, title: str, deploy_path: str):
study_url_pattern = re.compile(r'^(https?://h5\.cyol\.com/special/daxuexi/[^/]+/)(?:m|index)\.html$')
page_match = re.match(study_url_pattern, study_page)
assert page_match is not None, 'Unrecognized page %s' % study_page
url_prefix = page_match.group(1)
finished_img_url = url_prefix + 'images/end.jpg'
# test img exists
root.info('Testing end image: %s', finished_img_url)
_http_get(finished_img_url, url_prefix + 'css/index.css')
root.info('Test OK')
data = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{title}</title>
</head>
<body style="margin: 0;">
<div style="background-image: url({finished_img_url}); position: absolute; background-size: 100% 100%; width: 100%;
height: 100%;">
</div>
</body>
</html>"""
with open(deploy_path, 'w') as f:
f.write(data)
root.info('HTML file generated to %s', deploy_path)


def _get_origin_from_referer(referer: str) -> str:
match = re.search(host_schemas, referer)
if match is None:
raise ValueError('%s does not match the URL scheme', referer)
return match.group(1)


def _merge_dict(origin: Dict[str, Any], default: Dict[str, Any]) -> Dict[str, Any]:
ret = origin.copy()
for key in default:
if key not in origin:
ret[key] = default[key]
return ret


def _http_ops(method, url, referer, params, headers, retries, timeout, ensure_http_ok, **kwargs):
default_header = {'User-Agent': wechat_user_agent, 'Referer': referer, 'Origin': _get_origin_from_referer(referer)}
# merge param "default_header" to "headers"
get_header = _merge_dict(headers or {}, default_header)
last_ex = None
while retries > 0:
try:
resp = method(url, params=params, headers=get_header, timeout=timeout, **kwargs)
if ensure_http_ok and not resp.ok:
raise ValueError('HTTP Request failed with status code %d' % resp.status_code)
return resp
except Exception as ex:
ex_type = type(ex)
root.warning('HTTP Request "%s" failed: Exception %s.%s: %s', url, ex_type.__module__, ex_type.__name__,
str(ex))
last_ex = ex
retries -= 1
ex_type = type(last_ex)
root.error('HTTP Request "%s" failed: Max retry exceeded. Last exception: %s.%s: %s', url, ex_type.__module__,
ex_type.__name__, str(last_ex))
raise last_ex


def _http_get(url: str, referer: str, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None,
retries: int = 5, timeout: float = 20, ensure_http_ok: bool = True) -> requests.Response:
root.debug('HTTP GET: %s with Referer: %s', url, referer)
return _http_ops(global_session.get, url, referer, params, headers, retries, timeout, ensure_http_ok)


def _http_post_url_encoded(url: str, referer: str, data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, Any]] = None,
retries: int = 5, timeout: float = 20, ensure_http_ok: bool = True) -> requests.Response:
root.debug('HTTP POST: %s with Referer: %s', url, referer)
data = data or {}
return _http_ops(global_session.post, url, referer, params, headers, retries, timeout, ensure_http_ok, data=data)


def do_study(mid: int, chapter_file: str) -> Tuple[str, str]:
root.info('Do the fucking study')
root.info('Current mid: %d', mid)
resp = _http_get('https://tuanapi.12355.net/questionnaire/getYouthLearningUrl?mid=%d' % mid,
'https://tuan.12355.net/wechat/index.html')
root.debug(resp.text)
url = resp.json()['youthLearningUrl']
sign_pattern = re.compile(r'sign=([^&]*)')
sign_match = re.search(sign_pattern, url)
assert sign_match is not None, 'Failed to match sign from url %s' % url
sign = sign_match.group(1)
litemall_header = {'X-Litemall-Token': '', 'X-Litemall-IdentiFication': 'young'}
resp = _http_post_url_encoded('https://youthstudy.12355.net/apih5/api/user/get', 'https://youtustudy.12355.net/h5/',
headers=litemall_header, data={'sign': sign})
root.debug(resp.text)
token = resp.json()['data']['entity']['token']
litemall_header['X-Litemall-Token'] = token
resp = _http_get('https://youthstudy.12355.net/apih5/api/young/chapter/new', 'https://youtustudy.12355.net/h5/',
headers=litemall_header)
root.debug(resp.text)
new_chapter = resp.json()
if os.path.isfile(chapter_file):
with open(chapter_file, 'rb') as f:
chapters = pickle.load(f)
else:
chapters = dict()
chapter_id = new_chapter['data']['entity']['id']
chapter_name = new_chapter['data']['entity']['name']
chapter_url = new_chapter['data']['entity']['url']
if chapter_id in chapters:
chapter_info = chapters[chapter_id]
root.info('Returned cached chapter, ID: %s, Name: %s, URL: %s', chapter_id, chapter_info[0], chapter_info[1])
return chapter_info
chapters[chapter_id] = chapter_name, chapter_url
resp = _http_post_url_encoded('https://youthstudy.12355.net/apih5/api/young/course/chapter/saveHistory',
'https://youthstudy.12355.net/h5/', data={'chapterId': chapter_id},
headers=litemall_header)
root.debug(resp.text)
with open(chapter_file, 'wb') as f:
pickle.dump(chapters, f)
return chapter_name, chapter_url


def main():
args = parse_args()
configure_logging(args.verbose)
try:
finished_img = do_study(args.mid, args.save_chapter_file)
generate_webpage(finished_img[1], finished_img[0], args.deploy_webpage_path)
except Exception as ex:
root.critical('Process exit with exception %s', str(ex), exc_info=ex, stack_info=True)


if __name__ == '__main__':
main()

有一个int类型的必要参数:--mid,这个靠自己抓包了,爱莫能助。复制粘贴保存为xxx.py然后用python xxx.py --mid xxx差不多就这样了。其他参数--save_chapter_file用于避免多次请求保存(虽然有没有重复请求其实都差不多,但是科技玩家还是低调一点比较好),--deploy_webpage_path是生成的网页地址,这里默认是我apache2服务器的根目录下的youth_study.htmlhttps://cdn.zhouxuebin.club/youth_study.html

无root权限下hadoop与map reduce的完全分布式搭建方案

首先说用一下,用到了域名,纯IP地址理论上也是可以的,不过没试。网上搜了一大堆教程,全都要求改/etc/hosts,对于一个没有root权限的用户来说,后面的教程全部都是一纸废话罢了。

这里就记录下自己的一次搭建过程,踩过的坑。

搭建主要有两部分,一个是Hadoop的分布式文件存储系统HDFS,另外一个是基于Yarn的Map Reduce框架。

Read More

推荐系统中的常用指标计算

算是给自己整理一下吧。主要是MF和FM的对比。

Toy Matrix#

假设有那么个用户数为6,物品数为5的大小为$6\times 5$的评分矩阵:

空缺的评分用0填充。

评分预测#

MF模型#

这个是相对比较简单的,将MF的两个矩阵算一下点积,就能得到所有用户对所有物品的评分$\hat R$。

FM模型#

FM对输入数据$\bm x$(假设是行向量)的表示一般可以分为三部分:代表用户ID的one-hot向量$\bm{x}_u\in\mathbb{R}^6$,代表物品ID的one-hot向量$\bm{x}_i\in\mathbb{R}^5$,以及其他与该评分有关的上下文(context)特征向量$\bm{x}_c\in\mathbb{R}^c$($c$为特征维度数)。即:$\bm x = [\bm x_u, \bm x_i, \bm x_c]$。

如果context是跟item挂钩的,即每个item下的所有评分都共享同一个context特征的话,事情就好办多了(至少这次跑的实验是这样的)。因此,FM的预测公式可以分解为与item相关以及与user相关两部分,跟MF模型相似。

原本计算FM的实现如下,$X$为行向量,$\bm w$为列向量,而$V$为$k\times(11+c)$的交叉项权重矩阵。

1
y_hat = w0 + X * w - sum((X .^ 2) * (V' .^ 2), 2) / 2 + sum((X * V') .^ 2, 2) / 2;

由于用户ID和物品ID都是one-hot向量,而context是跟item挂钩的,可以简化成:

1
y_hat = w0 + w(u) + w(i) + Xc(i) * wc - sum((Vu(u)' .^ 2) + (Vi(i)' .^ 2) + (Xc(i) .^ 2) * (Vc' .^ 2), 2) / 2 + sum((Vu(u)' + Vi(i)' + Xc(i) * Vc') .^ 2, 2) / 2

其中$u$和$i$是每条评分的用户和物品的ID。

然后跟MF一样,拆成与item挂钩的一部分:

1
2
3
linear_i = w_item + ctx * w_ctx; % [n_item, 1]
cross_i_pos = V_item' + ctx * V_ctx'; % [n_item, k]
cross_i_neg = V_item' .^ 2 + (ctx .^ 2) * (V_ctx' .^ 2); % [n_item, k]

ctx为n_item$\times c$的context特征矩阵。

以及跟user挂钩的另一部分:

1
2
3
linear_u = w_user; % [n_user, 1]
cross_u_pos = V_user'; % [n_user, k]
cross_u_neg = V_user' .^ 2; % [n_user, k]

最后对每个用户$u$的,预测公式如下:

1
y_hat = w0 + linear_u(u) + linear_i + sum(cross_u_pos(u) + cross_i_pos, 2)/2 - sum(cross_u_neg(u) + cross_i_neg, 2)/2; % [n_item, 1]

对用户没评过分的item,把itemID对应的位置置1,然后补上这个item的context特征进行预测就行了。

指标计算#

Precision@N#

N指的是推荐列表的长度。对推荐系统而言,前N个item的预测标签为1,而在后面的均为0。而对于ground truth标签,用户为其评过分则为1,否则为0。

For个example,假如$N=3$,推荐系统为第一个user生成的推荐列表为1,3,5,4,2。

item ID 预测 GT
1 1 1
3 1 1
5 1 0
4 0 1
2 0 0

因此TP=2,FP=1,Precision@3=TP/(TP+FP)=2/3=0.667。

实际上,计算可以简化为Pre@N=TP/N,TP为true positive的item数。代表的含义是前N个item列表中出现用户评过分的item的概率。

Recall@N#

跟上面类似,FN指的是出现在第N个item后面,用户有评过分的item数,如item 4。

因此,FN=1,Recall@3=TP/(TP+FN)=2/3=0.667。

实际上,计算也可以简化为Rec@N=TP/Np,Np为当前用户评过分的item个数。代表的含义是用户评过分的item出现在前N个item列表中的概率。这个数值是随N递增而单调递增的。

Average Precision#

这个指标综合评估Precision和Recall的质量,跟N无关。顾名思义,AP是对Precision求均值,而具体做法则是以recall为横坐标,precision为纵坐标,对precision求均值。人懒,原理就不解释了。

Normalized Discounted Cumulative Gain@N (NDCG@N)#

好像是这么拼来着吧?平时都直接喊的嗯滴希鸡。

推荐系统按照所有item预测的评分分数做个倒序排序,得到一个item列表:

item ID 评分$r$ 折损因子$d$
1 4 log2(2)
3 5 log2(3)
5 0 log2(4)
4 1 log2(5)
2 0 log2(6)

Cumulative Gain,既然是cumulative,那自然就少不了sum嘛,CG@N=sum(2^r-1),r按照生成item列表的顺序来。有些实现版本则是CG@N=sum(r),没有取指数。

同样假设$N=3$,CG@3=(2^4-1)+(2^5-1)=46。

Discounted Cumulative Gain,多了个折损因子,DCG@N=CG@N/d。DCG@3=(2^4-1)/log2(2) + (2^5-1)/log2(3)=15+31/1.585=34.5588。

而Normalized则需要计算理想的DCG,即Ideal DCG(IDCG),它是按照评分倒序排的:

item ID 评分$r$ 折损因子$d$
3 5 log2(2)
1 4 log2(3)
4 1 log2(4)
2 0 log2(5)
5 0 log2(6)

根据上表算DCG得到IDCG@3=DCG@3=(2^5-1)/log2(2) + (2^4-1)/log2(3) + (2^1-1)/log2(4)=31+15/1.585+1/2=40.9639。

最后的NDCG@N=DCG@N/IDCG@N=34.5588/40.9639=0.8436。

而某些论文中(这里就不点名是谁啦),NDCG的计算只考虑了用户评过分的物品,而未评过分的物品则不参与计算,因此实际计算NDCG时的列表如下(即去掉评分为0的item):

item ID 评分$r$ 折损因子$d$
1 4 log2(2)
3 5 log2(3)
4 1 log2(4)

计算IDCG时,是否去掉评分为0的item其实不影响结果,毕竟它们全排到最后面了嘛,求sum的时候都是0。

最后算出的DCG@3=15+31/1.585+1/2=35.0588。即NDCG@3=35.0588/40.9639=0.8558。用这种计算方法得出来的NDCG是偏高的,不过嘛,作者开心就好。

Matlab中基于共享内存的矩阵 完结篇

仓库地址: https://github.com/qhgz2013/shared_matrix

Bug该修的修好了,内存泄漏,多次free啥的操作基本上是没有了,跑小的数据集已经有挺好的效果的了:

RTX ON 共享内存 ON:
独立内存使用情况:

共享内存使用情况(在/dev/shm中):

共享内存 OFF:

如果要跑大点的数据集,单用matlab自带的parallel.pool.Constant,就算是128G的内存也out of memory了,现在把15G的数据直接扔共享内存里,还算跑得动:

代码改动也不大,以前是:

1
2
3
4
x_const = parallel.pool.Constant(x);
parfor i = 1:n
x_val = x_const.Value(:, i);
end

现在则是:

1
2
3
4
5
6
7
8
x_host = shared_matrix_host(x);
parfor i = 1:n
x_dev = x_host.attach();
x_val = x_dev.get_data();
x_val = x_val(:, i);
x_dev.detach();
end
x_host.detach();

好像是繁琐了一点,但是后面优化一下加个直接套个struct一次性创建多个变量的话应该简单很多,比如:

1
2
3
4
5
6
7
8
9
shared_memory_host = create_shmat_host(x, a, b, c);
parfor i = 1:n
shared_memory_dev = attach_shmat_host(shared_memory_host);
x_val = shared_memory_dev.x(:, i);
a = shared_memory_dev.a;
b = shared_memory_dev.b;
detach_shmat_dev(shared_memory_dev);
end
detach_shmat_host(shared_memory_host);

这个就留给以后再说了。

VS2019在Matlab R2018b下的正确食用方式

参考:https://www.mathworks.com/matlabcentral/answers/454296-can-i-use-microsoft-visual-studio-2019-with-matlab-r2019a-or-r2018b

附件:MATLAB VS2019 Support.zip

步骤:

  1. 将附件里的两个xml放到...\MATLAB\R2018b\bin\win64\mexopts
  2. 在注册表\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\SxS\VS7(找不到就新建)里添加字符串值:
    名称:16.0
    数据:C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\(VS2019的目录)。

完毕,重启matlab即可。