cron任务的locale问题

原文:Linux - cron での locale の挙動! - mk-mode BLOG

こんばんは。
Linux で、自分が作成したスクリプトがコンソール上では正常に動作するのに、 cron で定時起動させようとすると文字コードの関係でうまく日本語出力ができないことがあります。
以下、それについての備忘録です。

晚上好。
在Linux下,自己编写的(shell)脚本,在终端下手动运行是一切正常的。但是,由于字符编码的关系,当cron在试图以定时任务来执行该脚本时,日语文字却不能被正常输出。
以下是解决这一问题的备忘录。

0. 前提条件
CentOS 6.4 (32bit) での作業を想定。
cron は crontab -e ではなく、 /etc/cron.d/ ディレクトリ配下にファイルを設置する方法。
文字化けが起こるスクリプトは “UTF-8” でエンコードされていて、日本語出力を伴うことを想定。
(当然、日本語出力を伴わないのならロケールの心配もない)

0. 条件

  • 假定操作系统是CentOS 6.4 (32bit) (译者注:6.X, 64位同样适用)
  • 不使用cron的crontab -e,而是在/etc/cron.d/目录下建立配置文件来设置cron任务(译者注:同样适用于通过crontab -e设置的任务)
  • 脚本使用UTF-8编码,并假定脚本的执行将伴随有日语文字输出,且(由cron执行时)出现了乱码。
    (当然,如果日语输出不受locale影响,则无需担心。)

1. cron 外(コンソール)でのロケール
普通にコンソールで locale コマンドでロケールを確認してみる。

1. 在cron外部(用户终端)的locale

在一般的用户终端(console)中,尝试通过locale命令来确认当前环境的locale。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# locale
LANG=ja_JP.UTF-8
LC_CTYPE="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_PAPER="ja_JP.UTF-8"
LC_NAME="ja_JP.UTF-8"
LC_ADDRESS="ja_JP.UTF-8"
LC_TELEPHONE="ja_JP.UTF-8"
LC_MEASUREMENT="ja_JP.UTF-8"
LC_IDENTIFICATION="ja_JP.UTF-8"
LC_ALL=

2. cron 内でのロケール
次に cron 内で locale コマンドを実行させてみる。
例えば、以下のようなファイル /etc/cron.d/locale_test を作成してみる。

2. cron内的locale

接下来,我们尝试在cron内执行locale命令。(译者注:其实就是在cron job中运行locale命令)
如下例,尝试创建一个文件/home/hoge/work/locale.log

1
* * * * * root locale > /home/hoge/work/locale.log

毎分 “/home/hoge/work/” ディレクトリ内に “locale.log” というファイルが作成されるので、内容を確認してみる。

每分钟,/home/hoge/work/下的locale.log文件都会被写入新数据,我们来尝试确认该文件内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LANG=
LC_CTYPE="POSIX"
LC_NUMERIC="POSIX"
LC_TIME="POSIX"
LC_COLLATE="POSIX"
LC_MONETARY="POSIX"
LC_MESSAGES="POSIX"
LC_PAPER="POSIX"
LC_NAME="POSIX"
LC_ADDRESS="POSIX"
LC_TELEPHONE="POSIX"
LC_MEASUREMENT="POSIX"
LC_IDENTIFICATION="POSIX"
LC_ALL=

“ja_JP.UTF-8” でなく “POSIX” となっている。
これでは、UTF-8 でエンコードされているスクリプトは日本語表示で不具合を起こすでしょう。

ja_JP.UTF-8并不在POSIX集合内。
因此,使用UTF-8编码的脚本在遇到日语输出时会出错。

3. 対処方法
cron 内で UTF-8 でデンコードされたスクリプトを実行させる場合は、以下のように LC_CTYPE, LANG を設定してやる。

3. 解决方法

要在cron中运行通过UTF-8编码的脚本,需要设定LC_CTYPELANG。如下:

1
2
3
4
LC_CTYPE="ja_JP.utf8"
LANG="ja_JP.utf8"

* * * * * root locale > /home/hoge/work/locale.log

再度 “/home/hoge/work/” ディレクトリ内の “locale.log” の内容を確認してみる。

再次确认/home/hoge/work/目录下的locale.log文件的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LANG="ja_JP.utf8"
LC_CTYPE="ja_JP.utf8"
LC_NUMERIC="ja_JP.utf8"
LC_TIME="ja_JP.utf8"
LC_COLLATE="ja_JP.utf8"
LC_MONETARY="ja_JP.utf8"
LC_MESSAGES="ja_JP.utf8"
LC_PAPER="ja_JP.utf8"
LC_NAME="ja_JP.utf8"
LC_ADDRESS="ja_JP.utf8"
LC_TELEPHONE="ja_JP.utf8"
LC_MEASUREMENT="ja_JP.utf8"
LC_IDENTIFICATION="ja_JP.utf8"
LC_ALL=

“ja_JP.utf8” になりました。(UTF-8 と utf8 の違いはあるが問題ない)
これで、日本語出力で文字化けすることがなくなります。

现在是ja_JP.utf8了。(UTF-8和utf8的区别并不是个问题)
现在,(cron job任务的)的日语输出不会再乱码了。

4. 参考
上記では任意のスクリプトについて話したが、UTF-8 エンコードの Ruby スクリプト(日本語出力を伴うもの)を cron 起動させるには以下のように -Ku オプションで文字コードを指定することでも対処可能である。

4. 参考

上面的记录是针对任意的脚本。若需通过cron运行含有日语输出的Ruby脚本,可以通过-Ku选项指定字符编码。如下:

1
* * * * * root /usr/local/bin/ruby -Ku test_script.rb

5. 後始末
当然、テストで作成した cron スクリプトは不要なので削除しておく。

以上。

5. 后续清理

当然,在刚才的测试中添加的cron任务脚本(locale命令)是不再需要的,请删除它。

译者注

本文locale问题的解决方案对于简体中文也是同样适用的,只需将本文中的ja_JP替换成zh_CN即可。

WeChatMomentStat:微信朋友圈导出工具开发记录

GitHub repo

https://github.com/Chion82/WeChatMomentStat-Android

关于WeChatMomentStat-Android

博主之前开发过WeChatMomentExport,借助Xposed实现了导出微信朋友圈数据。该项目在GitHub上获得了不少Star,被应用平台收录之后也有几千的下载量,可见这个需求是存在的。但是,对于WeChatMomentExport,还存在以下问题:

  • 作为Xposed模块,必需依赖Xposed才能运行
  • 因为数据抓取方式为hook,故用户需要在微信朋友圈页面手动下滑加载
  • 微信版本每更新一次会导致源码被重新混淆,相应的本项目也需要更新钩子逻辑
  • 项目的定位是将导出数据作为开发者二次开发所需的数据源,但从酷安网的用户评论看,普通用户不能理解需求

对于上述问题,博主考虑了以下相应对策:

  • 上次的逆向分析结果看,只要想办法调用到这几个类(以下称为parser),就可以解析微信SQLite缓存中的blob数据,这样就不需要借助Xposed的hook了,也能实现一键导出
  • 考虑到blob格式不会经常变更,因此可在项目中整合parser,这样本项目就无需经常更新
  • 博主在开发WeChatMomentExport之后随手写的朋友圈数据统计脚本也获得了少量star,因此认为,对于普通用户,生成这样的简易统计数据更有吸引性

于是,决定整合WeChatMomentExport和统计脚本,做一个功能稍完善的工具。

几个技术难点

要做这样的一个独立的APP,而不是一个Xposed模块,需要解决以下问题:

  1. 如何在APP中整合parser?parser的逻辑代码被混淆在微信的dex中,直接分析其算法难度太大。
  2. 如何越权获得微信的SQLite缓存数据?
  3. 如何确保从SQLite缓存中取得的朋友圈数据足够齐全?

经过查阅各种文档和亲自实验,还是找到了解决方案。

使用DexClassLoader直接加载微信apk中的parser

DexClassLoader可直接解析apk中的classes.dex,并从中取得所需类,通过java反射,可以获得所需的parser方法。因此,无需再分析parser算法,而是直接调用就可以了。
通过DexClassLoader取得parser方法的关键代码如下:

1
2
3
4
5
6
7
8
9
10
DexClassLoader cl = new DexClassLoader(
apkFile.getAbsolutePath(), //apkFile为微信apk文件
context.getDir("outdex", 0).getAbsolutePath(),
null,
ClassLoader.getSystemClassLoader());

Class SnsDetailParser = cl.loadClass("com.tencent.mm.plugin.sns.f.i");
Class SnsDetail = cl.loadClass("com.tencent.mm.protocal.b.atp");
Class SnsObject = cl.loadClass("com.tencent.mm.protocal.b.aqi");
//之后只需使用java反射即可取得所需方法

还需要提供一个微信的apk文件。因此将微信apk放在assets中,首次运行本工具的时候释放到外部存储中。

通过su调用,拷贝微信的SQLite数据库文件

需要越权操作的话,获取root权限是很难避免的。通过调用su,可以复制出微信的SQLite数据库文件到本工具可读写的目录下。
微信朋友圈的SQLite文件在/data/data/com.tencent.mm/MicroMsg/XXXXXXXXXXXXX/SnsMicroMsg.db。其中,XXXXXXXXXXXXX是微信生成的hash值,每台设备上都可能不一样。由于在Android的shell中没有find或类似的命令,需要复制出这个SnsMicroMsg.db还得费一点功夫。最终,博主采用ls列目录并循环尝试cp的方法强行取得SnsMicroMsg.db

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void copySnsDB() throws Throwable {
String dataDir = Environment.getDataDirectory().getAbsolutePath();
String destDir = Config.EXT_DIR;
Process su = Runtime.getRuntime().exec("su");
DataOutputStream outputStream = new DataOutputStream(su.getOutputStream());
outputStream.writeBytes("mount -o remount,rw " + dataDir + "\n");
outputStream.writeBytes("cd " + dataDir + "/data/" + Config.WECHAT_PACKAGE + "/MicroMsg\n");
outputStream.writeBytes("ls | while read line; do cp ${line}/SnsMicroMsg.db " + destDir + "/ ; done \n");
outputStream.writeBytes("sleep 1\n");
outputStream.writeBytes("chmod 777 " + destDir + "/SnsMicroMsg.db\n");
outputStream.writeBytes("exit\n");
outputStream.flush();
outputStream.close();
Thread.sleep(1000);
}

其中,还需要修改db文件的权限为777,否则工具无权读取数据库。另外,sleep是为了避免稍后偶然性出现的读取数据库失败的情况(可能文件复制不完整或未被去锁?)。

关于SQLite中数据完整性的问题

经过测试,微信的SQLite数据库中缓存了几乎所有加载过的朋友圈,理论上应当不会漏数据。

题外话

本来这个app计划于2月中旬就写出来的,由于博主不是安卓开发者,没有系统地学过安卓开发,当时还不知道有DexClassLoader,写的第一个demo用的依然是Xposed,但是不同于WeChatMomentExport,这里用Xposed仅仅是为了取得那几个parser的类而已。2月底开学后,通过各种渠道了解到了DexClassLoader,才有现在的这个思路。
博主现在读大二,这学期开学后课程比较紧张,再者在工作室外包项目的压力下(团队管理问题,还有涉及的利益问题出现冲突的时候,处理起来非常棘手),一时失去了搞开源轮子的动力,甚至连续一个月都没有更新博客,于是才导致了这个项目拖到现在才基本完成。
看到了GitHub上的项目star和follower每隔几天就多一个,本站也陆续有网友来评论,每日UV也保持在100以上,就重拾了动力去继续折腾。
非常感谢前来光临本站和GitHub profile的各位!

自译:如何使用服务端渲染加速React APP首屏加载

原文:How to build React apps that load quickly using server side rendering (by Stefan Fidanov)

使用客户端框架(译者注:此处指大多数在浏览器端运行的前端MV*框架)可快速开发用户交互丰富、性能高效的web app,前端开发者都非常喜欢使用该类框架。
不幸的是,客户端框架也有缺点,其中最主要的问题是首屏加载速度。
客户端首先从服务器接收少量的HTML代码,但是之后却需要接收大量的JavaScript代码。
然后,它们(指前端框架)需要向服务器请求数据,等待收到数据,进行必要的数据处理,并最终渲染到用户的浏览器上。
相比之下,传统的web做法是,全部数据由服务端进行渲染,当服务端向用户首次递交HTML时,用户端浏览器就收到了渲染完成的页面了。
再者,大多数情况下,web服务器的渲染速度要快于客户端的渲染。所以,(传统web)的首屏渲染是非常快速的。

React的解决方案

很自然的,你会想同时拥有上述两者(分别指:使用了MV*框架的web app、传统的web站点)的全部优点。快速的首屏加载、高度的交互性和快速的响应。React可以帮助你同时做到这几点。
React是这样做到的:首先,它可以在服务端渲染任意的组件(Component),包括这些组件的数据,这样渲染得到的结果是一些HTML代码,这些HTML代码在这之后可以直接发送到浏览器。
当这些HTML在用户浏览器上被显示出来时,React会在本地(这里的本地指用户浏览器)进行计算。它的智能算法将进行判断并得出:React即将要在浏览器端动态渲染出来的结果,跟当前已经被显示出来的页面一样。
在这之后,除了添加必要的事件处理,React不会对页面做任何的修改。
那么为什么这样会更快呢?我们不是在做几乎跟客户端一样的事情吗?
是的。但仅仅是“几乎”而已。
首先,当服务器响应浏览器请求时,用户马上就能看到整个页面了。所以页面响应速度更快了。
其次,因为React能够判断出无需再对DOM做修改,它就不会再去碰DOM。修改DOM是前端渲染中最慢的部分。
再者,这样可以节省请求次数。因为所有数据已经被渲染所以React不需要再向服务器请求。

那么有没有可能:当页面加载时,页面已经显示出来但是用户不能对其进行交互,因为这时事件处理尚未被添加?
理论上这种情况是有可能发生的。但是因为用了服务端渲染,我们就避免了所有的高开销操作,而且这样不但加速了页面响应速度,添加事件处理的速度也会变得很快。
所以,你的应用将总是可交互的,并且用户不会察觉到有什么问题。

示例

光说无用,我们来看看如何在代码中实现。我们的第一个示例是非常简单的。我们要显示一个”hello”消息,并且点击后会有提示。
我们的示例将使用NodeJS作为服务端部分,不过这里的一切都可以应用在其他平台,比如PHP, Ruby, Python, Java或者.NET。

我们需要以下Node模块:

1
$ npm install babel react react-dom express jade

我们将使用expressjade来做一个示例服务器。
reactreact-dom包可提供React组件的服务端渲染。
babel包允许我们通过node直接加载JSX模块,比如require('some-component.jsx')或者require('some-component.js')
babel实际上更加强大。现在你可以用ES6支持。
我们的应用只有3个文件,文件结构如下:

1
2
3
public/components.js
views/index.jade
app.js

components.js包含了我们的React组件;index.jade是网站的基本模板文件,将会加载全部JavaScript;app.js是node服务器。
让我们来看看index.jade里面有什么内容:

1
2
3
4
5
6
7
8
9
10
11
12
doctype
html
head
title React Server Side Rendering Example
body
div(id='react-root')!= react

script(src='https://fb.me/react-0.14.0.js')
script(src='https://fb.me/react-dom-0.14.0.js')
script(src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js')

script(src='/components.js', type='text/babel')

div(id='react-root')!= react是最关键的部分。它的作用是作为React根组件的容器。另外,react变量的值是服务端渲染React组件后得到的HTML。
前两个引用进来的JavaScript文件是React本身,如果你想要在组件里面使用JSX,还需要引用一个Babel。
最后一个引用的文件是具体的组件。我们要把type设成text/babel好让Babel来处理这个文件。
这将提供一个基本的HTML结构,并加载全部的JavaScript和你需要的React组件。
来看看这个简单的服务器:

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
require('babel/register')

var express = require('express')
, app = express()
, React = require('react')
, ReactDOM = require('react-dom/server')
, components = require('./public/components.js')

var HelloMessage = React.createFactory(components.HelloMessage)


app.engine('jade', require('jade').__express)
app.set('view engine', 'jade')

app.use(express.static(__dirname + '/public'))

app.get('/', function(req, res){
res.render('index', {
react: ReactDOM.renderToString(HelloMessage({name: "John"}))
})
})

app.listen(3000, function() {
console.log('Listening on port 3000...')
})

这部分代码中,大部分和一个普通的express应用程序没有多大区别。但是其中有些行需要注意。
第一行:

1
require('babel/register')

加载Babel到你的依赖。这么做,你可以直接导入(require())由JSX组成的React组件,它们会被自动翻译为JavaScript,就像后面的两行:

1
2
3
var components = require('./public/components.js')

var HelloMessage = React.createFactory(components.HelloMessage)

在上面的代码中,第一行导入JSX编写的React组件。然后,由React.createFactory生成一个函数,该函数可以创建HelloMessage的组件。

1
2
3
4
5
app.get('/', function(req, res){
res.render('index', {
react: ReactDOM.renderToString(HelloMessage({name: "John"}))
})
})

上面这里就是渲染React组件的代码,并且渲染包含该组件的页面然后发送至浏览器。
首先,使用值为Johnname属性创建一个新的HelloMessage组件,然后使用React.renderToString将这个组件渲染为HTML。
这里需要注意的是,组件仅仅被渲染(rendered),而没有被挂载(mounted),所以 所有关于挂载的方法都不会被调用
在创建组件之后,将组件的HTML传递到index模版。
我们的组件看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var isNode = typeof module !== 'undefined' && module.exports
, React = isNode ? require('react') : window.React
, ReactDOM = isNode ? require('react') : window.ReactDOM

var HelloMessage = React.createClass({
handleClick: function () {
alert('You clicked!')
},

render: function() {
return <div onClick={this.handleClick}>Hello {this.props.name}</div>
}
})

if (isNode) {
exports.HelloMessage = HelloMessage
} else {
ReactDOM.render(<HelloMessage name="John" />, document.getElementById('react-root'))
}

你可以看见,这跟一般的由JSX编写的React组件没有什么不同,除了开头和结尾。这里就是你要让组件能同时在浏览器和Node端运行所需要注意的地方。

高级示例:加载服务端数据

真正的Web app做的事情通常远不止你看见的这些。它们经常需要跟服务器交互并从服务器加载数据。
但是,我们不希望这在服务端渲染时发生。
我们来对这个示例程序做一些小修改。首先,模版文件需要引用jQuery,在这里它的唯一作用是从服务端请求数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
doctype
html
head
title React Server Side Rendering Example
body
div(id='react-root')!= react

script(src='https://fb.me/react-0.14.0.js')
script(src='https://fb.me/react-dom-0.14.0.js')
script(src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js')
script(src='http://code.jquery.com/jquery-2.1.3.js')

script(src='/components.js', type='text/babel')

我们的服务器现在需要增加一个请求路由。

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
require('babel/register')

var express = require('express')
, app = express()
, React = require('react')
, ReactDOM = require('react-dom/server')
, components = require('./public/components.js')

var HelloMessage = React.createFactory(components.HelloMessage)


app.engine('jade', require('jade').__express)
app.set('view engine', 'jade')

app.use(express.static(__dirname + '/public'))

app.get('/', function(req, res){
res.render('index', {
react: React.renderToString(HelloMessage({name: "John"}))
})
})

app.get('/name', function(req, res){
res.send("Paul, " + new Date().toString())
})

app.listen(3000, function() {
console.log('Listening on port 3000...')
})

这里跟之前的例子唯一的不同之处在于这三行:

1
2
3
app.get('/name', function(req, res){
res.send("Paul, " + new Date().toString())
})

这三行代码的作用是,当/name被请求时,返回名字Paul和当前时间。
来看看这整个应用最有趣和最重要的部分,即React组件:

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
var isNode = typeof module !== 'undefined' && module.exports
, React = isNode ? require('react') : window.React
, ReactDOM = isNode ? require('react-dom') : window.ReactDOM

var HelloMessage = React.createClass({
getInitialState: function () {
return {}
},

loadServerData: function() {
$.get('/name', function(result) {
if (this.isMounted()) {
this.setState({name: result})
}
}.bind(this))
},

componentDidMount: function () {
this.intervalID = setInterval(this.loadServerData, 3000)
},

componentWillUnmount: function() {
clearInterval(this.intervalID)
},

handleClick: function () {
alert('You clicked!')
},

render: function() {
var name = this.state.name ? this.state.name : this.props.name
return <div onClick={this.handleClick}>Hello {name}</div>
}
})

if (isNode) {
exports.HelloMessage = HelloMessage
} else {
ReactDOM.render(<HelloMessage name="John" />, document.getElementById('react-root'))
}

我们只添加了这4个方法,其他和之前的例子相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
getInitialState: function () {
return {}
},

loadServerData: function() {
$.get('/name', function(result) {
if (this.isMounted()) {
this.setState({name: result})
}
}.bind(this))
},

componentDidMount: function () {
this.intervalID = setInterval(this.loadServerData, 3000)
},

componentWillUnmount: function() {
clearInterval(this.intervalID)
},

当组件被挂载后,每隔3秒它会向服务器请求数据/name并且显示出来。
componentDidMountcomponentWillUnmount在组件被渲染时是不会被调用的,它们只有在组件被挂载时才会被调用。
所以这两个方法在服务端渲染时不会被调用,loadServerData方法也不会被调用。
这三个方法只有当组件被挂载时才会被执行,而这只会发生在浏览器端。
由此可见,想要从整体中分离出只在浏览器运行的那部分,并且保持代码的复用是很简单的。

在这之后?

你已经学会了如何借助服务端渲染创建一个能被快速加载的React应用程序。但是,我的这个示例只是针对NodeJS服务器。
如果你在使用其他技术(比如PHP, .NET, Ruby, Python或者Java),你一样可以利用React服务端渲染的优点,这将会是你下一步要研究的方向。
另外,我直接在浏览器端使用了JSX,这将多亏于Babel,但是这也会降低性能。在生产环境中,在将JSX提供给浏览器之前先将之转换为JavaScript会更快。
我相信你一定可以找到你最喜欢的开发语言和Web框架下的类似解决方案。

你好,老司机:种子爬虫企划

在老司机的安利之下,学会了从琉璃神社找各种神奇的资源。于是萌生了造这个轮子的欲望。国内这种福利站不知还能维持多久,所以将资源大量扒下来存档是有点卵用的。

GitHub repo

https://github.com/Chion82/hello-old-driver

开发笔记

为了兼容多个站点,我的爬虫脚本并没有针对某个特定的网站进行抓取逻辑定制,而是采取递归遍历网站全部页面+正则匹配磁力链hash的方式抓取整站的磁力链资源。磁力链的hash协议大多数时候是BTIH,hash值为40位的hex字符串,匹配的正则如下:

1
[^0-9a-fA-F]([0-9a-fA-F]{40})[^0-9a-fA-F]

注意,为了保证hash串长度为40字节,在其前后应加上非hex的匹配,即[^0-9a-fA-F]

这样将可能导致一个问题,网站页面源码中可能还存在磁力链以外的SHA-1值,比如琉璃神社在每条用户评论后在一个标签属性内有40字节长的一段hash值,目前的解决方法是忽略HTML标签<>内的属性内容:

1
2
if (ignore_html_label): #为了增强扩展性,这类fix逻辑应该可控
result_text = re.sub(r'<.*?>', '', result_text)

每个磁力链资源应当要有对应的标题以方便查找,这里以网页的标题作为资源标题,匹配正则如下:

1
<title>(.+?)</title>

经测试发现,每轮抓取结束后,抓取到的资源数量可能不一样,可能的原因是网站方对访问频次做了限制或者是本地网络质量问题,就算通过连接失败重试、服务器返回5XX后重试等方法也不能解决。于是决定:每次抓取不覆盖上次抓取的结果,而是保留上次的结果,并新增本次抓取到的、上次结果中没有的新磁力链资源。

其他的一些必需属性:

1
2
3
4
5
6
cookie = '' #每次请求需要带上的Cookie。由于琉璃神社目前不需要登录,暂为空串
max_depth = 40 #递归最大深度,即从一个网页查找全部链接并依次往下递归访问,最大的深度为40
viewed_urls = [] #访问过的URL,避免重复访问
found_magnets = [] #查找出来的磁力链资源,避免重复抓取
ignore_url_param = True #是否忽略URL中的参数,比如"index.html?xxx=11"将被替换为"index.html"
ignore_html_label = True #是否忽略HTML标签内属性

需要定时执行抓取脚本以保证与原网站同步。写了一个shell脚本,sync.sh,作用如下:

  • 测试目标网站是否可访问
  • 复制上次的抓取结果magnet_outputresource_list.jsonarchives目录下存档,以当前时间重命名
  • 复制上次的抓取日志lastsync.loglasterror.loglog目录下存档,以当前时间重命名
  • 运行Python抓取脚本,这将覆盖项目根目录下的上述抓取结果文件和抓取日志文件
  • 将本次的抓取结果梗概(是否成功、新增几条记录以及一些简单统计数据)添加到README.md
  • 推送到GitHub

逆向纪录:微信朋友圈相关的几个类

本纪录针对微信安卓端版本6.3.13。本文纪录逆向微信过程中找到的几个朋友圈内容相关的数据结构类。

  1. 朋友圈详细内容
    类名: com.tencent.mm.protocal.b.atp
    [方法]添加属性(可作为hook的方法): protected final int a(int paramInt, object... objectArray)
    [方法]从BLOB数据导入:public a am(byte[])

  2. 可将com.tencent.mm.protocal.b.atp实例格式化为XML的类
    类名: com.tencent.mm.plugin.sns.f.i
    [方法]输出朋友圈内容XML: static public String a(com.tencent.mm.protocal.b.atp atpObject)

  3. 朋友圈评论和点赞数据
    类名: com.tencent.mm.protocal.b.aqi
    [方法]添加属性(可作为hook的方法): protected final int a(int paramInt, object... objectArray)
    [方法]从BLOB数据导入:public a am(byte[])
    [属性]用户ID:String iYA
    [属性]用户昵称:String jyd
    [属性]时间戳:long fpL
    [属性]评论列表:LinkedList<com.tencent.mm.protocal.b.apz> jJX
    [属性]点赞列表:LinkedList<com.tencent.mm.protocal.b.apz> jJU

  4. 评论或点赞数据详情
    类名: com.tencent.mm.protocal.b.apz
    [属性]用户ID: String iYA
    [属性]用户昵称:String jyd
    [属性]评论回复给谁(对方用户ID):String jJM
    [属性]评论内容:String fsI

使用nginx image filter动态生成缩略图

nginx提供ngx_http_image_filter_module模块,可用来动态生成图片的缩略图。当然,最好的办法是在后端进行图片压缩。但是当不方便修改后端代码时,在牺牲些许性能的代价下,使用image filter生成缩略图还是很方便的。

编译安装nginx

大部分预编译的nginx默认不带ngx_http_image_filter_module模块,这时需要手动编译nginx。
在执行configure时带上参数--with-http_image_filter_module
在水果上编译可参考OSX上编译安装nginx

在配置文件中使用image_filter生成缩略图

示例:

1
2
3
4
5
6
7
location /images {
image_filter resize 200 200;
image_filter_buffer 10M;
image_filter_jpeg_quality 90;
root /path/to/website;
index index.html;
}

其中:

  • image_filter resize 200 200表示按比例缩放图片,长和宽中较大者为200。比如,原图大小为1000x500,处理后为200x100。
  • image_filter_buffer 10M表示处理图片的缓冲区最大为10M。
  • image_filter_jpeg_quality 90设置jpeg压缩质量为90%。

经过这样的配置后,访问HOSTNAME/images/XXX.jpg|png|gif即可得到经过压缩的缩略图。

在OSX上编译安装nginx

在OSX上,一般情况下,使用brew安装nginx,再链接一个plist到/Library/LaunchDaemons即可。但是有时候brew中的nginx缺少某些模块,比如上文提到的ngx_http_image_filter_module,这时就需要重新编译nginx。
更正:homebrew中提供nginx-full包,包含了常用的绝大多数模块。当然如果需要添加第三方模块还是需要手动编译。

安装依赖

1
2
3
$ brew install pcre
$ brew install gd #image filter依赖gd
$ brew link --force openssl #避免编译openssl时报错

编译

下面的示例中,添加以下几个模块:

  • http_image_filter_module
  • http_ssl_module
  • http_gzip_static_module
  • http_sub_module
    并且其他配置和homebrew的nginx大致相同(带版本号的路径除外)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #cd到nginx源码目录
    $ ./configure --with-http_image_filter_module --with-http_ssl_module --with-http_gzip_static_module --with-http_sub_module \
    --prefix=/usr/local/Cellar/nginx/1.9.10 \
    --with-cc-opt="-I /usr/local/include" --with-ld-opt="-L /usr/local/lib" \
    --sbin-path=/usr/local/Cellar/nginx/1.9.10/bin/nginx \
    --conf-path=/usr/local/etc/nginx/nginx.conf \
    --pid-path=/usr/local/var/run/nginx.pid \
    --http-log-path=/usr/local/var/log/nginx/access.log \
    --error-log-path=/usr/local/var/log/nginx/error.log --with-pcre --with-ipv6 \
    --lock-path=/usr/local/var/run/nginx.lock \
    --http-client-body-temp-path=/usr/local/var/run/nginx/client_body_temp \
    --http-proxy-temp-path=/usr/local/var/run/nginx/proxy_temp \
    --http-fastcgi-temp-path=/usr/local/var/run/nginx/fastcgi_temp \
    --http-uwsgi-temp-path=/usr/local/var/run/nginx/uwsgi_temp \
    --http-scgi-temp-path=/usr/local/var/run/nginx/scgi_temp
    $ make

其中,--with-cc-opt="-I /usr/local/include" --with-ld-opt="-L /usr/local/lib"可避免报Undefined symbols for architecture x86_64错误。/usr/local/Cellar/nginx/1.9.10这里的1.9.10替换为将要编译的nginx版本号。

安装

如果之前未安装过nginx,运行这条命令来安装:

1
$ make install

如果已使用brew安装nginx,可以通过替换文件的方式换成刚才编译的版本:

1
2
3
4
5
6
#备份原来的binary
$ cp /usr/local/opt/nginx/bin/nginx /usr/local/opt/nginx/bin/nginx.bak
#先cd到nginx源码目录
$ sudo cp objs/nginx /usr/local/opt/nginx/bin/nginx
$ rm /usr/local/bin/nginx
$ ln -s /usr/local/opt/nginx/bin/nginx /usr/local/bin/nginx

现在,可以查看nginx版本:

1
$ nginx -V

强迫症患者可以像我这样在Cellar中建立一个新的版本目录:

1
2
3
4
5
6
7
$ cp -r /usr/local/Cellar/nginx/1.8.0 /usr/local/Cellar/nginx/1.9.10
#恢复1.8.0中的binary
$ rm /usr/local/Cellar/nginx/1.8.0/bin/nginx
$ mv /usr/local/Cellar/nginx/1.8.0/bin/nginx.bak /usr/local/Cellar/nginx/1.8.0/bin/nginx
#更新/usr/local/opt/nginx
$ rm /usr/local/opt/nginx
$ ln -s /usr/local/Cellar/nginx/1.9.10 /usr/local/opt/nginx

将nginx加入LaunchDaemons

编辑/Library/LaunchDaemons/homebrew.mxcl.nginx.plist内容如下:(brew的nginx自带的版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>homebrew.mxcl.nginx</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>ProgramArguments</key>
<array>
<string>/usr/local/opt/nginx/bin/nginx</string>
<string>-g</string>
<string>daemon off;</string>
</array>
<key>WorkingDirectory</key>
<string>/usr/local</string>
</dict>
</plist>

然后:

1
$ launchctl load -F /Library/LaunchDaemons/homebrew.mxcl.nginx.plist

OSX上pf的简单配置笔记

水果的OSX上没有iptables,在10.10以后以pf取代ipfw。相比于iptables,pf一般使用配置文件保存防火墙规则,语法规范上更严谨,但是配置也更复杂、规则冗长。本文记录pf的简单配置方法。

cat /etc/pf.conf,可看到以下已有内容:(忽略注释部分)

1
2
3
4
5
6
scrub-anchor "com.apple/*"
nat-anchor "com.apple/*"
rdr-anchor "com.apple/*"
dummynet-anchor "com.apple/*"
anchor "com.apple/*"
load anchor "com.apple" from "/etc/pf.anchors/com.apple"

anchor可理解为一组规则的集合。默认情况下,这里的几行anchor都是苹果留的place holder,实际上没有active的规则。
/etc/pf.conf在以后的OSX更新中可能会被覆盖,最好可以另外建立一个自定义的pf.conf
配置文件必须按照Macros, Tables, Options, Traffic Normalization, Queueing, Translation, Packet Filtering的顺序。
更详细的说明参考pf.conf man page

  1. 添加一个anchor。修改/etc/pf.conf如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    scrub-anchor "com.apple/*"
    nat-anchor "com.apple/*"
    nat-anchor "custom"
    rdr-anchor "com.apple/*"
    rdr-anchor "custom"
    dummynet-anchor "com.apple/*"
    anchor "com.apple/*"
    anchor "custom"
    load anchor "com.apple" from "/etc/pf.anchors/com.apple"
    load anchor "custom" from "/etc/pf.anchors/custom"
  2. 建立anchor规则文件/etc/pf.anchors/custom,内容为具体规则。
    常用的规则:

    • 屏蔽IP入站TCP连接并记录:

      1
      block in log proto tcp from 192.168.1.136 to any
    • 转发入站TCP连接到另一本地端口:

      1
      rdr inet proto tcp from any to any port 8081 -> 127.0.0.1 port 80

    经测试,rdr无法转发到另一台外部主机上(man page的示例,只可以转发到internal network),内核开启net.inet.ip.forwarding=1也无效。如需转发到另一个外网IP,需要配合mitmproxy的透明代理

    • NAT,路由vlan12接口上(192.168.168.0/24)的出口包,经由非vlan12的接口转换到外部地址(204.92.77.111),并允许vlan12之间的互相访问:
      1
      nat on ! vlan12 from 192.168.168.0/24 to any -> 204.92.77.111
  3. 使配置文件生效

    1
    pfctl -evf /etc/pf.conf

在GitHub Pages上使用CloudFlare https CDN

本站就是使用hexo搭建的静态Web站点,托管在GitHub repo,并使用GitHub Pages。
另外,阿里云最近也提供https的CDN服务,更适合用在国内链路质量要求高的站点。

GitHub Pages应用自定义域名

默认情况下,访问GitHub Pages页面的域名为username.github.io,如果需要使用自己的域名(以下简称“你的域名”),可参考官方的帮助文档,其实非常简单:

  1. 在repo根目录下创建CNAME文件,内容为你的域名。本站的CNAME文件
  2. 在你的域名管理中心,添加一条CNAME记录,指向username.github.io。(将username替换为你的GitHub用户名)

现在,访问http://你的域名 ,已经可以访问到站点首页了。而如果访问http://username.github.io (即原来的地址),将被302跳转到http://你的域名

https的问题

尝试直接访问https://你的域名,浏览器会报SSL_DOMAIN_NOT_MATCHED警告。因为GitHub Pages默认提供的SSL证书的根域名是github.io,和你的域名不相同。
而且,GitHub Pages不支持上传SSL证书。

使用CloudFlare

CloudFlare(以下简称CF)是一家CDN提供商,它的free plan里面就提供https服务(免费计划不能上传SSL)。现在可以通过CF实现:从用户到CDN服务器的连接为https,而CDN服务器到GitHub Pages服务器的连接为http。
1.注册并登录到CF。按照提示,在你的域名的管理中心,将域名的name server改为CF的name server。CF提供的NS如下:

Type Value
NS bob.ns.cloudflare.com
NS jamie.ns.cloudflare.com

2.在CF的DNS设置页中,检查对应的子域名记录。博主的DNS记录如下:

其中,右侧的橙色云图标代表该条记录将经过CF的CDN加速。
在这里设置的DNS记录,如果是CNAME记录或者A记录,若右边的STATUS为连通状态,CF都会在name server中将其设置为A记录并指向CF的CDN服务器(并根据用户所在地选择最优CDN),当用户通过该域名访问CF的CDN时(仅限http或https),CDN再转发到刚才填写的真实目的主机(即username.github.io)

CF正是通过这种动态DNS的方式实现CDN加速的。

设置https

  1. 在CF的Crypto页中,SSL设置为Flexible。这将允许CDN到github pages之间的访问为http。
  2. 现在,通过https://你的域名已经可以访问站点首页了。

强制https

CF提供Page Rules功能,可设置路由规则。通过规则中的Always use https选项,可以将用户强制跳转到https。博主的设置如下:

运维纪录:遭遇CC攻击,防御与查水表

博主之前完成了一个外包项目,最近两个月在负责这个项目的运维。这是一个web,主营不良资产催收O2O。由于可能存在竞争对手,有人试图攻击服务器。

事件回顾

24日下午3点,博主正在去拜访亲戚家的路上,这时公司的菜鸟开发者突然从QQ上发消息过来,问我服务器是不是被黑了。我认为这个可能性不大。这个项目由我亲手带领团队开发,后端使用的是Python+Flask+PostgreSQL,前端使用nodejs+express实现的midway,服务器部署也是由博主亲手完成。这类技术栈,已公布的可直接利用的漏洞十分有限,再者,博主在领队开发时已多次强调安全的重要性,具体到每个API都对用户权限进行了严格认证,编码过程中也不存在可能被注入、被远程执行等低级的危险代码,于是博主认为服务器被web渗透的可能性非常小。当然,不排除黑客从web之外的服务渗透进入,但是服务器上除了web只有ssh服务(强密码+公钥认证),除非公司的开发者部署了其他服务,否则能渗进来的可能性不大。

博主于是立即用手机访问网站,网站返回了504,这说明nginx的上游没有响应了,node midway或者Python后端,肯定有一个处于freeze状态。

到达亲戚家后,经过简短的问候,我即问道有没有能用的电脑。朋友让我使用一台08年的笔记本,运行的XP系统,只有IE8和360安全浏览器,但是已经够用了。下载Putty后ssh连接上服务器,立即killall supervisord && supervisord。因为node midway和Python后端都处于开发中状态,为了调试方便,所以直接是用supervisor作为daemon的。结果是,网站首页仍然返回504。

下意识地tail /var/log/nginx/access.log -n 100,出来的结果让我目瞪口呆.jpg

我立即就知道是怎么一回事了:黑客在flood发送短信的API。由于当时开发急促,没有对短信API加入图形验证码或者reCaptcha之类的验证,使得可以通过软件实现模拟请求,并且由于项目处于开发中,为方便调试没有使用wsgi容器调度请求和超时处理,再者,由于发送短信需要服务器向第三方短信平台请求,这个请求将比较费时,同时的大量请求使得Python后端完全被阻塞,难怪nginx报504。从log上看,flood来源自多个不同的IP,这是分布式的攻击,算得上是一场小型的CC攻击。后来发现参与这次CC的肉鸡大概有700~800台。

出于保密原则,本文以下内容中,发送短信API的URI均由[SMS_API]代替

应急防御

运行了一下cat /var/log/nginx/access.log | grep '[SMS_API]' | wc -l,返回的数字超过了30万,这时公司购买的短信平台套餐肯定已经用光了。但是现在首先要考虑恢复网站的正常访问。

对于这种小型的CC防御,除了ban ip之外我没有想到更好的解决方法。于是,我用ipset+iptables将当天访问过短信API的IP全部ban了。

1
2
3
# ipset create blacklist hash:net
# cat /var/log/nginx/access.log | grep '[SMS_API]' | awk '{print $1}' | while read line;do ipset add blacklist $line;done #将访问过短信API的IP全部加入ipset的blacklist集合
# iptables -I INPUT -m set --match-set blacklist src -j DROP

笔记: iptables -m set –match-set [SET_NAME] [src|dst]

执行后,再查看access log,flood马上就停下来了。但是现在遇到了新问题:后端跑不起来了。

修复后端

手动运行后端Python脚本,Peewee报不能连接上数据库。
跑了一下psql,发现正常读取数据,再查看PostgreSQL的log,没有发现异常。没有头绪,通知公司的后端开发者检查后端代码。
公司的开发者没有回应,我折腾了很久找不到问题所在,直到我想到会不会是刚才添加iptables过滤规则时把本机也过滤了。
试着运行了一下

1
# ipset test blacklist 127.0.0.1

返回

1
127.0.0.1 is in set blacklist.

再次目瞪口呆.jpg。突然想起来,刚才我为了测试短信接口,在服务器上跑了一下curl localhost/[SMS_API],于是nginx access log中就有了127.0.0.1,然后在跑脚本的时候就把127.0.0.1加入到blacklist中了。立即运行了一下

1
# ipset del blacklist 127.0.0.1

再次重启后端,一切正常,网站也能够访问了。

nginx中添加访问限制

目前后端是从session判断唯一用户的,并限制每个用户每分钟只能调用短信API一次。但是如果黑客手动清空cookie,服务器将允许再次请求。在nginx的文档中快速查找了一下,发现nginx支持从IP上request limit。现在需要限制1 request/min per IP,为此修改nginx配置:

  1. 添加limit_req_zone

    1
    2
    3
    4
    5
    # /etc/nginx/nginx.conf
    http {
    limit_req_zone $binary_remote_addr zone=sms:10m rate=1r/m;
    ...
    }
  2. location中应用limit_req_zone

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    server {
    ...
    location ~ ^([SMS_API]) {
    limit_req zone=sms nodelay;
    proxy_pass http://127.0.0.1:5000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    }
    ...
    }

经过这样的配置,同一IP在一分钟内只能访问该URL一次,否则返回503 server unavailable。

脚本实现自动Ban IP

之后发现源源不断地还有更多IP试图发起CC,不可能人工一个一个的ban,于是写了一个简单shell脚本实现:当天access log中,访问短信API超过30次的IP,将被加入黑名单。当然,这只是临时的,生产环境中,对于同一内网中的多个真实用户可能会出现误ban的情况,因此攻击过后要将脚本关闭。

1
2
3
4
5
6
7
#!/bin/sh

while [ True ]
do
cat /var/log/nginx/access.log | grep '[SMS_API]' | awk '{print $1}' | sort | uniq -c | awk '$1>30{print $2}' | while read line;do echo 'Blocking IP:'$line && ipset add blacklist $line;done
sleep 10
done

找出攻击发起者

由于CC分布式的特征,很难找出真正的攻击发起者。但是,往往可以找到第一个嫌疑者。通过翻看当天上午的access log,发现如下有趣的信息:

1
2
3
4
5
6
113.232.156.* - - [23/Jan/2016:11:19:13 +0800] "GET /register.html HTTP/1.1" 200 8383 "https://www.google.com/" "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1" "-" #使用Chrome进入网站注册页
#...
#下面若干行纪录均为页面静态资源请求
#...
113.232.156.* - - [23/Jan/2016:11:19:20 +0800] "GET [SMS_API]?phone=1584059XXXX HTTP/1.1" 200 46 "http://网站域名.com/register.html" "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1" "-"
#在Chrome中点击“发送短信验证码”按钮

正常的UA(Chrome 21.0.1180.89),并且有静态资源访问记录,基本可以确定是人工操作。
继续翻:

1
2
3
4
5
6
7
113.232.156.* - - [23/Jan/2016:11:19:27 +0800] "GET /register.html HTTP/1.1" 200 8383 "-" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" "-"
#注意,这个人在1分钟内使用了IE9重新进入网站注册页
#...
#下面若干行纪录均为页面静态资源请求
#...
113.232.156.* - - [23/Jan/2016:11:19:35 +0800] "GET [SMS_API]?phone=1552442XXXX HTTP/1.1" 200 46 "http://网站域名.com/register.html" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" "-"
#这个人在1分钟内使用IE9再次点击“发送短信验证码”按钮

普通用户是不会同时使用两款浏览器登录同一个网站并点击发送短信按钮的。除非——你想要验证这个网站是否根据session判断同一用户是否在一分钟内调用了多次发送短信API。再往后翻记录:

1
113.232.156.* - - [23/Jan/2016:11:19:51 +0800] "GET [SMS_API]?phone=1504032XXXX HTTP/1.1" 200 46 "-" "-" "-"

果不其然,这个人用模拟请求调用了发送短信API(因为没有正常的UA)
在这的几分钟后,来自全国各地的肉鸡就开始flood服务器了。

人肉攻击发起者

换位思考一下,如果我是黑客,在开始CC之前,是否需要测试一下这个API,然后再在肉鸡上配置随机手机号,最后再进行CC?
再次翻log,发现flood开始后,来自肉鸡的请求中,手机号码来自全国各地,但是都每个号码重复了很多次,并且每台肉鸡都有自己的手机号。据此可判断,肉鸡用的手机号码一定不是黑客本人或相关者的号码,而应该是随机生成的或者是通过非法渠道获取到的“受害者”的手机号。但是,113.232.156.* (即黑客嫌疑者)一开始在Chrome和IE9中用的号码在后面的记录中都没有找到,并且号码所属地和IP所属地吻合(都为辽宁沈阳),据此,怀疑黑客一开始在Chrome中会用真实的手机号先进行测试,然后再实施CC。
将黑客IP和他第一次在浏览器中提交的手机号码(1584059XXXX)告诉了公司,公司立即拨打了这个手机号码。
对方一开始不承认。后来对方打回来,问我们是什么网站做什么的,并且听到对面几个人在偷着乐。因此,对方很有可能就是这次攻击的发起者,并且可能是黑客团伙,专职外包。(其实博主认为,国内这种组织根本算不上真正意义上的黑客,只是非常低级的为了图利的非法技术组织,并且自身技术也是很菜…)
公司随后开始通过手机号码查询该人身份信息。由于公司本身性质的关系,有后台可以调查某些信息。
之后的事情我就没有多问了。