0%

技术杂谈

如果大家对 Python 爬虫有所了解的话,想必你应该听说过 Selenium 这个库,这实际上是一个自动化测试工具,现在已经被广泛用于网络爬虫中来应对 JavaScript 渲染的页面的抓取。 但 Selenium 用的时候有个麻烦事,就是环境的相关配置,得安装好相关浏览器,比如 Chrome、Firefox 等等,然后还要到官方网站去下载对应的驱动,最重要的还需要安装对应的 Python Selenium 库,确实是不是很方便,另外如果要做大规模部署的话,环境配置的一些问题也是个头疼的事情。 那么本节就介绍另一个类似的替代品,叫做 Pyppeteer。注意,是叫做 Pyppeteer,不是 Puppeteer。Puppeteer 是 Google 基于 Node.js 开发的一个工具,有了它我们可以通过 JavaScript 来控制 Chrome 浏览器的一些操作,当然也可以用作网络爬虫上,其 API 极其完善,功能非常强大。 而 Pyppeteer 又是什么呢?它实际上是 Puppeteer 的 Python 版本的实现,但他不是 Google 开发的,是一位来自于日本的工程师依据 Puppeteer 的一些功能开发出来的非官方版本。 在 Pyppetter 中,实际上它背后也是有一个类似 Chrome 浏览器的 Chromium 浏览器在执行一些动作进行网页渲染,首先说下 Chrome 浏览器和 Chromium 浏览器的渊源。

Chromium 是谷歌为了研发 Chrome 而启动的项目,是完全开源的。二者基于相同的源代码构建,Chrome 所有的新功能都会先在 Chromium 上实现,待验证稳定后才会移植,因此 Chromium 的版本更新频率更高,也会包含很多新的功能,但作为一款独立的浏览器,Chromium 的用户群体要小众得多。两款浏览器“同根同源”,它们有着同样的 Logo,但配色不同,Chrome 由蓝红绿黄四种颜色组成,而 Chromium 由不同深度的蓝色构成。

总的来说,两款浏览器的内核是一样的,实现方式也是一样的,可以认为是开发版和正式版的区别,功能上基本是没有太大区别的。 Pyppeteer 就是依赖于 Chromium 这个浏览器来运行的。那么有了 Pyppeteer 之后,我们就可以免去那些繁琐的环境配置等问题。如果第一次运行的时候,Chromium 浏览器没有安全,那么程序会帮我们自动安装和配置,就免去了繁琐的环境配置等工作。另外 Pyppeteer 是基于 Python 的新特性 async 实现的,所以它的一些执行也支持异步操作,效率相对于 Selenium 来说也提高了。 那么下面就让我们来一起了解下 Pyppeteer 的相关用法吧。

安装

首先就是安装问题了,由于 Pyppeteer 采用了 Python 的 async 机制,所以其运行要求的 Python 版本为 3.5 及以上。 安装方式非常简单:

1
pip3 install pyppeteer

好了,安装完成之后我们命令行下测试下:

1
\>>> import pyppeteer

如果没有报错,那么就证明安装成功了。

快速上手

接下来我们测试下基本的页面渲染操作,这里我们选用的网址为:http://quotes.toscrape.com/js/,这个页面是 JavaScript 渲染而成的,用基本的 requests 库请求得到的 HTML 结果里面是不包含页面中所见的条目内容的。 为了证明 requests 无法完成正常的抓取,我们可以先用如下代码来测试一下:

1
2
3
4
5
6
7
import requests
from pyquery import PyQuery as pq

url = 'http://quotes.toscrape.com/js/'
response = requests.get(url)
doc = pq(response.text)
print('Quotes:', doc('.quote').length)

这里首先使用 requests 来请求网页内容,然后使用 pyquery 来解析页面中的每一个条目。观察源码之后我们发现每个条目的 class 名为 quote,所以这里选用了 .quote 这个 CSS 选择器来选择,最后输出条目数量。 运行结果:

1
Quotes: 0

结果是 0,这就证明使用 requests 是无法正常抓取到相关数据的。因为什么?因为这个页面是 JavaScript 渲染而成的,我们所看到的内容都是网页加载后又执行了 JavaScript 之后才呈现出来的,因此这些条目数据并不存在于原始 HTML 代码中,而 requests 仅仅抓取的是原始 HTML 代码。 好的,所以遇到这种类型的网站我们应该怎么办呢? 其实答案有很多:

  • 分析网页源代码数据,如果数据是隐藏在 HTML 中的其他地方,以 JavaScript 变量的形式存在,直接提取就好了。
  • 分析 Ajax,很多数据可能是经过 Ajax 请求时候获取的,所以可以分析其接口。
  • 模拟 JavaScript 渲染过程,直接抓取渲染后的结果。

而 Pyppeteer 和 Selenium 就是用的第三种方法,下面我们再用 Pyppeteer 来试试,如果用 Pyppeteer 实现如上页面的抓取的话,代码就可以写为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio
from pyppeteer import launch
from pyquery import PyQuery as pq

async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('http://quotes.toscrape.com/js/')
doc = pq(await page.content())
print('Quotes:', doc('.quote').length)
await browser.close()

asyncio.get_event_loop().run_until_complete(main())

运行结果:

1
Quotes: 10

看运行结果,这说明我们就成功匹配出来了 class 为 quote 的条目,总数为 10 条,具体的内容可以进一步使用 pyquery 解析查看。 那么这里面的过程发生了什么? 实际上,Pyppeteer 整个流程就完成了浏览器的开启、新建页面、页面加载等操作。另外 Pyppeteer 里面进行了异步操作,所以需要配合 async/await 关键词来实现。 首先, launch 方法会新建一个 Browser 对象,然后赋值给 browser,然后调用 newPage 方法相当于浏览器中新建了一个选项卡,同时新建了一个 Page 对象。然后 Page 对象调用了 goto 方法就相当于在浏览器中输入了这个 URL,浏览器跳转到了对应的页面进行加载,加载完成之后再调用 content 方法,返回当前浏览器页面的源代码。然后进一步地,我们用 pyquery 进行同样地解析,就可以得到 JavaScript 渲染的结果了。 另外其他的一些方法如调用 asyncio 的 get_event_loop 等方法的相关操作则属于 Python 异步 async 相关的内容了,大家如果不熟悉可以了解下 Python 的 async/await 的相关知识。 好,通过上面的代码,我们就可以完成 JavaScript 渲染页面的爬取了。 在这个过程中,我们没有配置 Chrome 浏览器,没有配置浏览器驱动,免去了一些繁琐的步骤,同样达到了 Selenium 的效果,还实现了异步抓取,爽歪歪! 接下来我们再看看另外一个例子,这个例子可以模拟网页截图,保存 PDF,另外还可以执行自定义的 JavaScript 获得特定的内容,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
from pyppeteer import launch

async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('http://quotes.toscrape.com/js/')
await page.screenshot(path='example.png')
await page.pdf(path='example.pdf')
dimensions = await page.evaluate('''() => {
return {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
deviceScaleFactor: window.devicePixelRatio,
}
}''')

print(dimensions)
# >>> {'width': 800, 'height': 600, 'deviceScaleFactor': 1}
await browser.close()

asyncio.get_event_loop().run_until_complete(main())

这里我们又用到了几个新的 API,完成了网页截图保存、网页导出 PDF 保存、执行 JavaScript 并返回对应数据。 首先 screenshot 方法可以传入保存的图片路径,另外还可以指定保存格式 type、清晰度 quality、是否全屏 fullPage、裁切 clip 等各个参数实现截图。 截图的样例如下: 可以看到它返回的就是 JavaScript 渲染后的页面。 pdf 方法也是类似的,只不过页面保存格式不一样,最后得到一个多页的 pdf 文件,样例如下: 可见其内容也是 JavaScript 渲染后的内容,另外这个方法还可以指定放缩大小 scale、页码范围 pageRanges、宽高 width 和 height、方向 landscape 等等参数,导出定制化的 pdf 用这个方法就十分方便。 最后我们又调用了 evaluate 方法执行了一些 JavaScript,JavaScript 传入的是一个函数,使用 return 方法返回了网页的宽高、像素大小比率三个值,最后得到的是一个 JSON 格式的对象,内容如下:

1
{'width': 800, 'height': 600, 'deviceScaleFactor': 1}

OK,实例就先感受到这里,还有太多太多的功能还没提及。 总之利用 Pyppeteer 我们可以控制浏览器执行几乎所有动作,想要的操作和功能基本都可以实现,用它来自由地控制爬虫当然就不在话下了。

详细用法

了解了基本的实例之后,我们再来梳理一下 Pyppeteer 的一些基本和常用操作。Pyppeteer 的几乎所有功能都能在其官方文档的 API Reference 里面找到,链接为:https://miyakogi.github.io/pyppeteer/reference.html,用到哪个方法就来这里查询就好了,参数不必死记硬背,即用即查就好

开启浏览器

使用 Pyppeteer 的第一步便是启动浏览器,首先我们看下怎样启动一个浏览器,其实就相当于我们点击桌面上的浏览器图标一样,把它开起来。用 Pyppeteer 完成同样的操作,只需要调用 launch 方法即可。 我们先看下 launch 方法的 API,链接为:https://miyakogi.github.io/pyppeteer/reference.html#pyppeteer.launcher.launch,其方法定义如下:

1
pyppeteer.launcher.launch(options: dict = None, **kwargs) → pyppeteer.browser.Browser

可以看到它处于 launcher 模块中,参数没有在声明中特别指定,返回类型是 browser 模块中的 Browser 对象,另外观察源码发现这是一个 async 修饰的方法,所以调用它的时候需要使用 await。 接下来看看它的参数:

  • ignoreHTTPSErrors (bool): 是否要忽略 HTTPS 的错误,默认是 False。
  • headless (bool): 是否启用 Headless 模式,即无界面模式,如果 devtools 这个参数是 True 的话,那么该参数就会被设置为 False,否则为 True,即默认是开启无界面模式的。
  • executablePath (str): 可执行文件的路径,如果指定之后就不需要使用默认的 Chromium 了,可以指定为已有的 Chrome 或 Chromium。
  • slowMo (int|float): 通过传入指定的时间,可以减缓 Pyppeteer 的一些模拟操作。
  • args (List[str]): 在执行过程中可以传入的额外参数。
  • ignoreDefaultArgs (bool): 不使用 Pyppeteer 的默认参数,如果使用了这个参数,那么最好通过 args 参数来设定一些参数,否则可能会出现一些意想不到的问题。这个参数相对比较危险,慎用。
  • handleSIGINT (bool): 是否响应 SIGINT 信号,也就是可以使用 Ctrl + C 来终止浏览器程序,默认是 True。
  • handleSIGTERM (bool): 是否响应 SIGTERM 信号,一般是 kill 命令,默认是 True。
  • handleSIGHUP (bool): 是否响应 SIGHUP 信号,即挂起信号,比如终端退出操作,默认是 True。
  • dumpio (bool): 是否将 Pyppeteer 的输出内容传给 process.stdout 和 process.stderr 对象,默认是 False。
  • userDataDir (str): 即用户数据文件夹,即可以保留一些个性化配置和操作记录。
  • env (dict): 环境变量,可以通过字典形式传入。
  • devtools (bool): 是否为每一个页面自动开启调试工具,默认是 False。如果这个参数设置为 True,那么 headless 参数就会无效,会被强制设置为 False。
  • logLevel (int|str): 日志级别,默认和 root logger 对象的级别相同。
  • autoClose (bool): 当一些命令执行完之后,是否自动关闭浏览器,默认是 True。
  • loop (asyncio.AbstractEventLoop): 时间循环对象。

好了,知道这些参数之后,我们可以先试试看。 首先可以试用下最常用的参数 headless,如果我们将它设置为 True 或者默认不设置它,在启动的时候我们是看不到任何界面的,如果把它设置为 False,那么在启动的时候就可以看到界面了,一般我们在调试的时候会把它设置为 False,在生产环境上就可以设置为 True,我们先尝试一下关闭 headless 模式:

1
2
3
4
5
6
7
8
import asyncio
from pyppeteer import launch

async def main():
await launch(headless=False)
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

运行之后看不到任何控制台输出,但是这时候就会出现一个空白的 Chromium 界面了: 但是可以看到这就是一个光秃秃的浏览器而已,看一下相关信息: 看到了,这就是 Chromium,上面还写了开发者内部版本,可以认为是开发版的 Chrome 浏览器就好。 另外我们还可以开启调试模式,比如在写爬虫的时候会经常需要分析网页结构还有网络请求,所以开启调试工具还是很有必要的,我们可以将 devtools 参数设置为 True,这样每开启一个界面就会弹出一个调试窗口,非常方便,示例如下:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(devtools=True)
page = await browser.newPage()
await page.goto('https://www.baidu.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

刚才说过 devtools 这个参数如果设置为了 True,那么 headless 就会被关闭了,界面始终会显现出来。在这里我们新建了一个页面,打开了百度,界面运行效果如下: 这时候我们可以看到上面的一条提示:”Chrome 正受到自动测试软件的控制”,这个提示条有点烦,那咋关闭呢?这时候就需要用到 args 参数了,禁用操作如下:

1
browser = await launch(headless=False, args=['--disable-infobars'])

这里就不再写完整代码了,就是在 launch 方法中,args 参数通过 list 形式传入即可,这里使用的是 —disable-infobars 的参数。 另外有人就说了,这里你只是把提示关闭了,有些网站还是会检测到是 webdriver 吧,比如淘宝检测到是 webdriver 就会禁止登录了,我们可以试试:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False)
page = await browser.newPage()
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

运行时候进行一下登录,然后就会弹出滑块,自己手动拖动一下,然后就报错了,界面如下: 爬虫的时候看到这界面是很让人崩溃的吧,而且这时候我们还发现了页面的 bug,整个浏览器窗口比显示的内容窗口要大,这个是某些页面会出现的情况,让人看起来很不爽。 我们可以先解决一下这个显示的 bug,需要设置下 window-size 还有 viewport,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from pyppeteer import launch

width, height = 1366, 768

async def main():
browser = await launch(headless=False,
args=[f'--window-size={width},{height}'])
page = await browser.newPage()
await page.setViewport({'width': width, 'height': height})
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

这样整个界面就正常了: OK,那刚才所说的 webdriver 检测问题怎样来解决呢?其实淘宝主要通过 window.navigator.webdriver 来对 webdriver 进行检测,所以我们只需要使用 JavaScript 将它设置为 false 即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False, args=['--disable-infobars'])
page = await browser.newPage()
await page.goto('https://login.taobao.com/member/login.jhtml?redirectURL=https://www.taobao.com/')
await page.evaluate(
'''() =>{ Object.defineProperties(navigator,{ webdriver:{ get: () => false } }) }''')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

这里没加输入用户名密码的代码,当然后面可以自行添加,下面打开之后,我们点击输入用户名密码,然后这时候会出现一个滑动条,这里滑动的话,就可以通过了,如图所示: OK,这样的话我们就成功规避了 webdriver 的检测,使用鼠标拖动模拟就可以完成淘宝的登录了。 还有另一种方法可以进一步免去淘宝登录的烦恼,那就是设置用户目录。平时我们已经注意到,当我们登录淘宝之后,如果下次再次打开浏览器发现还是登录的状态。这是因为淘宝的一些关键 Cookies 已经保存到本地了,下次登录的时候可以直接读取并保持登录状态。 那么这些信息保存在哪里了呢?其实就是保存在用户目录下了,里面不仅包含了浏览器的基本配置信息,还有一些 Cache、Cookies 等各种信息都在里面,如果我们能在浏览器启动的时候读取这些信息,那么启动的时候就可以恢复一些历史记录甚至一些登录状态信息了。 这也就解决了一个问题:很多朋友在每次启动 Selenium 或 Pyppeteer 的时候总是是一个全新的浏览器,那就是没有设置用户目录,如果设置了它,每次打开就不再是一个全新的浏览器了,它可以恢复之前的历史记录,也可以恢复很多网站的登录信息。 那么这个怎么来做呢?很简单,在启动的时候设置 userDataDir 就好了,示例如下:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False, userDataDir='./userdata', args=['--disable-infobars'])
page = await browser.newPage()
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

好,这里就是加了一个 userDataDir 的属性,值为 userdata,即当前目录的 userdata 文件夹。我们可以首先运行一下,然后登录一次淘宝,这时候我们同时可以观察到在当前运行目录下又多了一个 userdata 的文件夹,里面的结构是这样子的: 具体的介绍可以看官方的一些说明,如:https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md,这里面介绍了 userdatadir 的相关内容。 再次运行上面的代码,这时候可以发现现在就已经是登录状态了,不需要再次登录了,这样就成功跳过了登录的流程。当然可能时间太久了,Cookies 都过期了,那还是需要登录的。 好了,本想把 Pyppeteer 的用法详细介绍完的,结果只 launch 的方法就介绍这么多了,后面的内容放到其他文章来介绍了,其他的内容后续文章会陆续放出,谢谢。

本节代码获取

公众号”进击的 Coder”回复”Pyppeteer”即可获取本节全部代码。

技术杂谈

正则表达式 30 分钟入门教程(https://deerchao.net/tutorials/regex/regex.htm

本教程目标:30 分钟内让你明白正则表达式是什么,并对它有一些基本的了解,让你可以在自己的程序或网页里使用它。

正则表达式 必知必会(https://www.zybuluo.com/Yano/note/475174

Zjmainstay 学习笔记 | 正则表达式(http://www.zjmainstay.cn/regexp

《精通正则表达式 第三版》

  1. 最后,推荐一本动物书《精通正则表达式 第三版》

【顺手提供】精通正则表达式:第三版 PDF (高清-中文-带标签)

关注本公众号【离不开的网】,后台回复 “ 正则 pdf ” 即可。

相关好文推荐

  1. 想精通正则表达式 这几个正则表达式学习资料及工具你必须有 :https://www.cnblogs.com/3rocks/p/11212724.html
  2. 菜鸟教程-正则表达式 :https://www.runoob.com/regexp/regexp-tutorial.html
  3. 正则表达式速查表 :https://www.jb51.net/article/67634.htm
  4. 细说 python 正则表达式 :https://www.jianshu.com/p/147fab022566
  5. 路人甲的关于正则表达式 :https://zhuanlan.zhihu.com/p/21341872?refer=passer
  6. 最全的常用正则表达式大全——包括校验数字、字符、一些特殊的需求等等 :http://www.cnblogs.com/zxin/archive/2013/01/26/2877765.html
  7. 深入理解正则表达式 :https://www.cnblogs.com/China3S/archive/2013/11/30/3451971.html

原文链接:https://mp.weixin.qq.com/s/CGSUJntKtvOrV1o-R2GrRw 来源公众号:离不开的网

Python

日志概述

百度百科的日志概述: Windows网络操作系统都设计有各种各样的日志文件,如应用程序日志,安全日志、系统日志、Scheduler服务日志、FTP日志、WWW日志、DNS服务器日志等等,这些根据你的系统开启的服务的不同而有所不同。我们在系统上进行一些操作时,这些日志文件通常会记录下我们操作的一些相关内容,这些内容对系统安全工作人员相当有用。比如说有人对系统进行了IPC探测,系统就会在安全日志里迅速地记下探测者探测时所用的IP、时间、用户名等,用FTP探测后,就会在FTP日志中记下IP、时间、探测所用的用户名等。 我映像中的日志: 查看日志是开发人员日常获取信息、排查异常、发现问题的最好途径,日志记录中通常会标记有异常产生的原因、发生时间、具体错误行数等信息,这极大的节省了我们的排查时间,无形中提高了编码效率。

日志分类

我们可以按照输出终端进行分类,也可以按照日志级别进行分类。输出终端指的是将日志在控制台输出显示和将日志存入文件;日志级别指的是 Debug、Info、WARNING、ERROR以及CRITICAL等严重等级进行划分。

Python 的 logging

logging提供了一组便利的日志函数,它们分别是:debug()、 info()、 warning()、 error() 和 critical()。logging函数根据它们用来跟踪的事件的级别或严重程度来命名。标准级别及其适用性描述如下(以严重程度递增排序): 每个级别对应的数字值为 CRITICAL:50,ERROR:40,WARNING:30,INFO:20,DEBUG:10,NOTSET:0。 Python 中日志的默认等级是 WARNING,DEBUG 和 INFO 级别的日志将不会得到显示,在 logging 中更改设置。

日志输出

输出到控制台

使用 logging 在控制台打印日志,这里我们用 Pycharm 编辑器来观察:

1
2
3
4
5
import logging

logging.debug('崔庆才丨静觅、韦世东丨奎因')
logging.warning('邀请你关注微信公众号【进击的 Coder】')
logging.info('和大佬一起coding、共同进步')

从上图运行的结果来看,的确只显示了 WARNING 级别的信息,验证了上面的观点。同时也在控制台输出了日志内容,默认情况下 Python 中使用 logging 模块中的函数打印日志,日志只会在控制台输出,而不会保存到日文件。 有什么办法可以改变默认的日志级别呢? 当然是有的,logging 中提供了 basicConfig 让使用者可以适时调节默认日志级别,我们可以将上面的代码改为:

1
2
3
4
5
6
import logging

logging.basicConfig(level=logging.DEBUG)
logging.debug('崔庆才丨静觅、韦世东丨奎因')
logging.warning('邀请你关注微信公众号【进击的 Coder】')
logging.info('和大佬一起coding、共同进步')

在 basicConfig 中设定 level 参数的级别即可。 思考:如果设定级别为 logging.INFO,那 DEBUG 信息能够显示么?

保存到文件

刚才演示了如何在控制台输出日志内容,并且自由设定日志的级别,那现在就来看看如何将日志保存到文件。依旧是强大的 basicConfig,我们再将上面的代码改为:

1
2
3
4
5
6
import logging

logging.basicConfig(level=logging.DEBUG, filename='coder.log', filemode='a')
logging.debug('崔庆才丨静觅、韦世东丨奎因')
logging.warning('邀请你关注微信公众号【进击的 Coder】')
logging.info('和大佬一起coding、共同进步')

在配置中填写 filename (指定文件名) 和 filemode (文件写入方式),控制台的日志输出就不见了,那么 coder.log 会生成么? 在 .py 文件的同级目录生成了名为 coder.log 的日志。 通过简单的代码设置,我们就完成了日志文件在控制台和文件中的输出。那既在控制台显示又能保存到文件中呢?

强大的 logging

logging所提供的模块级别的日志记录函数是对logging日志系统相关类的封装 logging 模块提供了两种记录日志的方式:

  • 使用logging提供的模块级别的函数
  • 使用Logging日志系统的四大组件

这里提到的级别函数就是上面所用的 DEBGE、ERROR 等级别,而四大组件则是指 loggers、handlers、filters 和 formatters 这几个组件,下图简单明了的阐述了它们各自的作用: 日志器(logger)是入口,真正工作的是处理器(handler),处理器(handler)还可以通过过滤器(filter)和格式器(formatter)对要输出的日志内容做过滤和格式化等处理操作。

四大组件

下面介绍下与logging四大组件相关的类:Logger, Handler, Filter, Formatter。 Logger类 Logger 对象有3个工作要做:

1
2
3
1)向应用程序代码暴露几个方法,使应用程序可以在运行时记录日志消息;
2)基于日志严重等级(默认的过滤设施)或filter对象来决定要对哪些日志进行后续处理;
3)将日志消息传送给所有感兴趣的日志handlers。

Logger对象最常用的方法分为两类:配置方法 和 消息发送方法 最常用的配置方法如下: 关于Logger.setLevel()方法的说明: 内建等级中,级别最低的是DEBUG,级别最高的是CRITICAL。例如setLevel(logging.INFO),此时函数参数为INFO,那么该logger将只会处理INFO、WARNING、ERROR和CRITICAL级别的日志,而DEBUG级别的消息将会被忽略/丢弃。 logger对象配置完成后,可以使用下面的方法来创建日志记录: 那么,怎样得到一个Logger对象呢?一种方式是通过Logger类的实例化方法创建一个Logger类的实例,但是我们通常都是用第二种方式—logging.getLogger()方法。 logging.getLogger()方法有一个可选参数name,该参数表示将要返回的日志器的名称标识,如果不提供该参数,则其值为’root’。若以相同的name参数值多次调用getLogger()方法,将会返回指向同一个logger对象的引用。

1
2
3
4
5
关于logger的层级结构与有效等级的说明:

logger的名称是一个以'.'分割的层级结构,每个'.'后面的logger都是'.'前面的logger的children,例如,有一个名称为 foo 的logger,其它名称分别为 foo.bar, foo.bar.baz 和 foo.bam都是 foo 的后代。
logger有一个"有效等级(effective level)"的概念。如果一个logger上没有被明确设置一个level,那么该logger就是使用它parent的level;如果它的parent也没有明确设置level则继续向上查找parent的parent的有效level,依次类推,直到找到个一个明确设置了level的祖先为止。需要说明的是,root logger总是会有一个明确的level设置(默认为 WARNING)。当决定是否去处理一个已发生的事件时,logger的有效等级将会被用来决定是否将该事件传递给该logger的handlers进行处理。
child loggers在完成对日志消息的处理后,默认会将日志消息传递给与它们的祖先loggers相关的handlers。因此,我们不必为一个应用程序中所使用的所有loggers定义和配置handlers,只需要为一个顶层的logger配置handlers,然后按照需要创建child loggers就可足够了。我们也可以通过将一个logger的propagate属性设置为False来关闭这种传递机制。

Handler Handler对象的作用是(基于日志消息的level)将消息分发到handler指定的位置(文件、网络、邮件等)。Logger对象可以通过addHandler()方法为自己添加0个或者更多个handler对象。比如,一个应用程序可能想要实现以下几个日志需求:

1
2
3
4
1)把所有日志都发送到一个日志文件中;
2)把所有严重级别大于等于error的日志发送到stdout(标准输出);
3)把所有严重级别为critical的日志发送到一个email邮件地址。
这种场景就需要3个不同的handlers,每个handler复杂发送一个特定严重级别的日志到一个特定的位置。

一个handler中只有非常少数的方法是需要应用开发人员去关心的。对于使用内建handler对象的应用开发人员来说,似乎唯一相关的handler方法就是下面这几个配置方法: 需要说明的是,应用程序代码不应该直接实例化和使用Handler实例。因为Handler是一个基类,它只定义了素有handlers都应该有的接口,同时提供了一些子类可以直接使用或覆盖的默认行为。下面是一些常用的Handler: Formater Formater对象用于配置日志信息的最终顺序、结构和内容。与logging.Handler基类不同的是,应用代码可以直接实例化Formatter类。另外,如果你的应用程序需要一些特殊的处理行为,也可以实现一个Formatter的子类来完成。 Formatter类的构造方法定义如下:

1
logging.Formatter.__init__(fmt=None, datefmt=None, style='%')

该构造方法接收3个可选参数:

  • fmt:指定消息格式化字符串,如果不指定该参数则默认使用message的原始值
  • datefmt:指定日期格式字符串,如果不指定该参数则默认使用”%Y-%m-%d %H:%M:%S”
  • style:Python 3.2新增的参数,可取值为 ‘%’, ‘{‘和 ‘$’,如果不指定该参数则默认使用’%’

Filter Filter可以被Handler和Logger用来做比level更细粒度的、更复杂的过滤功能。Filter是一个过滤器基类,它只允许某个logger层级下的日志事件通过过滤。该类定义如下:

1
2
class logging.Filter(name='')
filter(record)

比如,一个filter实例化时传递的name参数值为’A.B’,那么该filter实例将只允许名称为类似如下规则的loggers产生的日志记录通过过滤:’A.B’,’A.B,C’,’A.B.C.D’,’A.B.D’,而名称为’A.BB’, ‘B.A.B’的loggers产生的日志则会被过滤掉。如果name的值为空字符串,则允许所有的日志事件通过过滤。 filter方法用于具体控制传递的record记录是否能通过过滤,如果该方法返回值为0表示不能通过过滤,返回值为非0表示可以通过过滤。

1
2
3
4
说明:

如果有需要,也可以在filter(record)方法内部改变该record,比如添加、删除或修改一些属性。
我们还可以通过filter做一些统计工作,比如可以计算下被一个特殊的logger或handler所处理的record数量等。

实战演练

上面文绉绉的说了(复制/粘贴)那么多,现在应该动手实践了。 现在我需要既将日志输出到控制台、又能将日志保存到文件,我应该怎么办? 利用刚才所学的知识,我们可以构思一下: 看起来好像也不难,挺简单的样子,但是实际如此吗? 在实际的工作或应用中,我们或许还需要指定文件存放路径、用随机数作为日志文件名、显示具体的信息输出代码行数、日志信息输出日期和日志写入方式等内容。再构思一下: 具体代码如下:

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
import os
import logging
import uuid
from logging import Handler, FileHandler, StreamHandler

class PathFileHandler(FileHandler):
def __init__(self, path, filename, mode='a', encoding=None, delay=False):

filename = os.fspath(filename)
if not os.path.exists(path):
os.mkdir(path)
self.baseFilename = os.path.join(path, filename)
self.mode = mode
self.encoding = encoding
self.delay = delay
if delay:
Handler.__init__(self)
self.stream = None
else:
StreamHandler.__init__(self, self._open())

class Loggers(object):
# 日志级别关系映射
level_relations = {
'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING,
'error': logging.ERROR, 'critical': logging.CRITICAL
}

def __init__(self, filename='{uid}.log'.format(uid=uuid.uuid4()), level='info', log_dir='log',
fmt='%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s: %(message)s'):
self.logger = logging.getLogger(filename)
abspath = os.path.dirname(os.path.abspath(__file__))
self.directory = os.path.join(abspath, log_dir)
format_str = logging.Formatter(fmt) # 设置日志格式
self.logger.setLevel(self.level_relations.get(level)) # 设置日志级别
stream_handler = logging.StreamHandler() # 往屏幕上输出
stream_handler.setFormatter(format_str)
file_handler = PathFileHandler(path=self.directory, filename=filename, mode='a')
file_handler.setFormatter(format_str)
self.logger.addHandler(stream_handler)
self.logger.addHandler(file_handler)

if __name__ == "__main__":
txt = "关注公众号【进击的 Coder】,回复『日志代码』可以领取文章中完整的代码以及流程图"
log = Loggers(level='debug')
log.logger.info(4)
log.logger.info(5)
log.logger.info(txt)

文件保存后运行,运行结果如下图所示: 日志确实在控制台输出了,再来看一下目录内是否生成有指定的文件和文件夹: 文件打开后可以看到里面输出的内容: 正确的学习方式是什么 是一步步的看着文章介绍,等待博主结论? 是拿着代码运行,跑一遍? 都不是,应该是一边看着文章,一边拿着示例代码琢磨和研究,到底哪里可以改进、哪里可以设计得更好。如果你需要文章中所用到的示例代码和流程图,那么关注微信公众号【进击的 Coder】,回复『日志代码』就可以领取文章中完整的代码以及流程图。毕竟,学习是一件勤劳的事。 参考资料: 云游道士博文 nancy05博文

技术杂谈

位运算是我们在编程中常会遇到的操作,但仍然有很多开发者并不了解位运算,这就导致在遇到位运算时会“打退堂鼓”。实际上,位运算并没有那么复杂,只要我们了解其运算基础和运算符的运算规则,就能够掌握位运算的知识。接下来,我们一起学习位运算的相关知识。 程序中的数在计算机内存中都是以二进制的形式存在的,位运算就是直接对整数在内存中对应的二进制位进行操作。

注意:本文只讨论整数运算,小数运算不在本文研究之列

位运算的基础

我们常用的 35 等数字是十进制表示,而位运算的基础是二进制。即人类采用十进制,机器采用的是二进制,要深入了解位运算,就需要了解十进制和二进制的转换方法和对应关系。

二进制

十进制转二进制时,采用“除 2 取余,逆序排列”法:

  1. 用 2 整除十进制数,得到商和余数;
  2. 再用 2 整除商,得到新的商和余数;
  3. 重复第 1 和第 2 步,直到商为 0;
  4. 将先得到的余数作为二进制数的高位,后得到的余数作为二进制数的低位,依次排序;

排序结果就是该十进制数的二进制表示。例如十进制数 101 转换为二进制数的计算过程如下:

1
2
3
4
5
6
7
101 % 2 = 501
50 % 2 = 250
25 % 2 = 121
12 % 2 = 60
6 % 2 = 30
3 % 2 = 11
1 % 2 = 01

逆序排列即二进制中的从高位到低位排序,得到 7 位二进制数为 1100101,如果要转换为 8 位二进制数,就需要在最高位补 0。即十进制数的 8 位二进制数为 01100101。 其完整过程如下图所示: 有网友整理了常见的进制与 ASCII 码对照表,表内容如下: ASCII 控制字符 ASCII 可显示字符

补码

现在,我们已经了解到二进制与十进制的换算方法,并拥有了进制对照表。但在开始学习位运算符之前,我们还需要了解补码的知识。 数值有正负之分,那么仅有 01 的二进制如何表示正负呢? 人们设定,二进制中最高位为 0 代表正,为 1 则代表负。例如 0000 1100 对应的十进制为 12,而 1000 1100 对应的十进制为 \-12。这种表示被称作原码。但新的问题出现了,原本二进制的最高位始终为 0,为了表示正负又多出了 1,在执行运算时就会出错。举个例子,1 + (-2) 的二进制运算如下:

1
2
3
0000 0001 + 1000 0010 
= 1000 0011
= -3

这显然是有问题的,问题就处在这个代表正负的最高位。接着,人们又弄出了反码(二进制各位置的 01 互换,例如 0000 1100 的反码为 1111 0011)。此时,运算就会变成这样:

1
2
3
4
5
0000 0001 + 1111 1101
= 1111 1110
# 在转换成十进制前,需要再次反码
= 1000 0001
= -1

这次好像正确了。但它仍然有例外,我们来看一下 1 + (-1)

1
2
3
4
0000 0001 + 1111 + 1110
= 1111 1111
= 1000 0000
= -0

零是没有正负之分的,为了解决这个问题,就搞出了补码的概念。补码是为了让负数变成能够加的正数,所以 负数的补码= 负数的绝对值取反 + 1,例如 \-1 的补码为:

1
2
3
4
\-1 的绝对值 1
= 0000 0001 # 1 的二进制原码
= 1111 1110 # 原码取反
= 1111 1111 # +1 后得到补码

\-1 补码推导的完整过程如下图所示: 反过来,由补码推导原码的过程为 原码 = 补码 - 1,再求反。要注意的是,反码过程中,最高位的值不变,这样才能够保证结果的正负不会出错。例如 1 + (-6)1 + (-9) 的运算过程如下:

1
2
3
4
5
6
# 1 的补码 + -6 的补码
0000 0001 + 1111 1010
= 1111 1011 # 补码运算结果
= 1111 1010 # 对补码减 1,得到反码
= 1000 0101 # 反码取反,得到原码
= -5 # 对应的十进制
1
2
3
4
5
6
# 1 的补码 + -9 的补码
0000 0001 + 1111 0111
= 1111 1000 # 补码运算结果
= 1111 0111 # 对补码减 1,得到反码
= 1000 1000 # 反码取反,得到原码
= -8 # 对应的十进制

要注意的是,正数的补码与原码相同,不需要额外运算。也可以说,补码的出现就是为了解决负数运算时的符号问题。

人生苦短 我用 Python。 崔庆才|静觅 邀请你关注微信公众号:进击的Coder

运算符介绍

位运算分为 6 种,它们是:

名称

符号

按位与

&

按位或

|

按位异或

^

按位取反

~

左移运算

<<

右移运算

>>

按位与

按位与运算将参与运算的两数对应的二进制位相与,当对应的二进制位均为 1 时,结果位为 1,否则结果位为 0。按位与运算的运算符为 &,参与运算的数以补码方式出现。举个例子,将数字 5 和数字 8 进行按位与运算,其实是将数字 5 对应的二进制 0000 0101 和数字 8 对应的二进制 0000 1000 进行按位与运算,即:

1
2
3
0000 0101
&
0000 1000

根据按位与的规则,将各个位置的数进行比对。运算过程如下:

1
2
3
4
5
0000 0101
&
0000 1000
---- ----
0000 0000

由于它们对应位置中没有“均为 1 ”的情况,所以得到的结果是 0000 0000。数字 58 按位与运算的完整过程如下图: 将结果换算成十进制,得到 0,即 5&8 = 0

按位或

按位或运算将参与运算的两数对应的二进制位相或,只要对应的二进制位中有 1,结果位为 1,否则结果位为 0。按位或运算的运算符为 |,参与运算的数以补码方式出现。举个例子,将数字 3 和数字 7 进行按位或运算,其实是将数字 3 对应的二进制 0000 0011和数字 7 对应的二进制 0000 0111 进行按位或运算,即:

1
2
3
0000 0011
|
0000 0111

根据按位或的规则,将各个位置的数进行比对。运算过程如下:

1
2
3
4
5
0000 0011
|
0000 0111
---- ----
0000 0111

最终得到的结果为 0000 0111。将结果换算成十进制,得到 7,即 3|7 = 7

按位异或

按位异或运算将参与运算的两数对应的二进制位相异或,当对应的二进制位值不同时,结果位为 1,否则结果位为 0。按位异或的运算符为 ^,参与运算的数以补码方式出现。举个例子,将数字 12 和数字 7 进行按位异或运算,其实是将数字 12 对应的二进制 0000 1100 和数字 7 对应的二进制 0000 0111 进行按位异或运算,即:

1
2
3
0000 1100
^
0000 0111

根据按位异或的规则,将各个位置的数进行比对。运算过程如下:

1
2
3
4
5
0000 1100
^
0000 0111
---- ----
0000 1011

最终得到的结果为 0000 1011。将结果换算成十进制,得到 11,即 12^7 = 11

按位取反

按位取反运算将二进制数的每一个位上面的 0 换成 11 换成 0。按位取反的运算符为 ~,参与运算的数以补码方式出现。举个例子,对数字 9 进行按位取反运算,其实是将数字 9 对应的二进制 0000 1001 进行按位取反运算,即:

1
2
3
4
~0000 1001
= 0000 1001 # 补码,正数补码即原码
= 1111 1010 # 取反
= -10

最终得到的结果为 \-10。再来看一个例子,\-20 按位取反的过程如下:

1
2
3
4
~0001 0100
= 1110 1100 # 补码
= 0001 0011 # 取反
= 19

最终得到的结果为 19。我们从示例中找到了规律,按位取反的结果用数学公式表示: 我们可以将其套用在 9\-20 上:

1
2
9 = -(9 + 1) = -10
~(-20) = -((-20) + 1) = 19

这个规律也可以作用于数字 0 上,即 ~0 = -(0 + 1) = -1

左移运算

左移运算将数对应的二进位全部向左移动若干位,高位丢弃,低位补 0。左移运算的运算符为 <<。举个例子,将数字 5 左移 4 位,其实是将数字 5 对应的二进制 0000 0101 中的二进位向左移动 4 位,即:

1
2
3
4
5 << 4
= 0000 0101 << 4
= 0101 0000 # 高位丢弃,低位补 0
= 80

数字 5 左移 4 位的完整运算过程如下图: 最终结果为 80。这等效于: 也就是说,左移运算的规律为:

右移运算

右移运算将数对应的二进位全部向右移动若干位。对于左边的空位,如果是正数则补 0,负数可能补 01 (Turbo C 和很多编译器选择补 1)。右移运算的运算符为 \>>。举个例子,将数字 80 右移 4 位,其实是将数字 80 对应的二进制 0101 0000 中的二进位向右移动 4 位,即:

1
2
3
4
80 >> 4
= 0101 0000 >> 4
= 0000 0101 # 正数补0,负数补1
= 5

最终结果为 5。这等效于: 也就是说,右移运算的规律为: 要注意的是,不能整除时,取整数。这中除法取整的规则类似于 PYTHON 语言中的地板除。

超酷人生 我用 Rust 韦世东|奎因 邀请你关注微信公众号:Rust之禅

位运算的应用

在掌握了位运算的知识后,我们可以在开发中尝试使用它。坊间一直流传着位运算的效率高,速度快,但从未见过文献证明,所以本文不讨论效率和速度的问题。如果正在阅读文章的你有相关文献,请留言告知,谢谢。 判断数字奇偶 通常,我们会通过取余来判断数字是奇数还是偶数。例如判断 101 的奇偶用的方法是:

1
2
3
4
5
# python
if 101 % 2:
print('偶数')
else:
print('奇数')

我们也可以通过位运算中的按位与来实现奇偶判断,例如:

1
2
3
4
5
# python
if 101 & 1:
print('奇数')
else:
print('偶数')

这是因为奇数的二进制最低位始终为 1,而偶数的二进制最低为始终为 0。所以,无论任何奇数与 10000 0001 相与得到的都是 1,任何偶数与其相与得到的都是 0变量交换 在 C 语言中,两个变量的交换必须通过第三个变量来实现。伪代码如下:

1
2
3
4
5
6
7
# 伪代码
a = 3, b = 5
c = a
a = b
b = a
--------
a = 5, b = 3

在 PYTHON 语言中并没有这么麻烦,可以直接交换。对应的 PYTHON 代码如下:

1
2
3
4
# python
a, b = 3, 5
a, b = b, a
print(a, b)

代码运行结果为 5 3。但大部分编程语言都不支持 PYTHON 这种写法,在这种情况下我们可以通过位运算中的按位异或来实现变量的交换。对应的伪代码如下:

1
2
3
4
5
# 伪代码
a = 3, b = 5
a = a ^ b
b = a ^ b
a = a ^ b

最后,a = 5, b = 3。我们可以用 C 语言和 PYTHON 语言进行验证,对应的 PYTHON 代码如下:

1
2
3
4
5
6
# python
a, b = 3, 5
a = a ^ b
b = a ^ b
a = a ^ b
print(a, b)

代码运行结果为 5 3,说明变量交换成功。对应的 C 代码如下:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
void main()
{
int a = 3, b = 5;
printf("交换前:a=%d , b=%dn",a,b);
a = a^b;
b = a^b;
a = a^b;
printf("交换后:a=%d , b=%dn",a, b);
}

代码运行结果如下:

1
2
交换前:a=3 , b=5
交换后:a=5 , b=3

这说明变量交换成功。 求 x 与 2 的 n 次方乘积 设一个数为 x,求 x2n 次方乘积。这用数学来计算都是非常简单的: 在位运算中,要实现这个需求只需要用到左移运算,即 x << n取 x 的第 k 位 即取数字 x 对应的二进制的第 k 位上的二进制值。假设数字为 5,其对应的二进制为 0000 0101,取第 k 位二进制值的位运算为 x >> k & 1。我们可以用 PYTHON 代码进行验证:

1
2
3
4
# python
x = 5 # 0000 0101
for i in range(8):
print(x >> i & 1)

代码运行结果如下:

1
2
3
4
5
6
7
8
1
0
1
0
0
0
0
0

这说明位运算的算法是正确的,可以满足我们的需求。 判断赋值

1
2
3
4
if a == x:
x = b
else:
x = a

等效于 x = a ^ b ^ x。我们可以通过 PYTHON 代码来验证:

1
2
3
4
5
6
7
# python
a, b, x = 6, 9, 6
if a == x:
x = b
else:
x = a
print(a, b, x)

代码运行结果为 699,与之等效的代码如下:

1
2
3
4
# python
a, b, x = 6, 9, 6
x = a ^ b ^ x
print(a, b, x)

这样就省去了 if else 的判断语句。 代替地板除 二分查找是最常用的算法之一,但它有一定的前提条件:二分查找的目标必须采用顺序存储结构,且元素有序排列。例如 PYTHON 中的有序列表。二分查找的最优复杂度为 O(1),最差时间复杂度为 O(log n)。举个例子,假设我们需要从列表 [1, 3, 5, 6, 7, 8, 12, 22, 23, 43, 65, 76, 90, 543] 中找到指定元素的下标,对应的 PYTHON 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# python
def search(lis: list, x: int) -> int:
"""非递归二分查找
返回指定元素在列表中的索引
-1 代表不存在"""
mix_index = 0
max_index = len(lis) - 1
while mix_index <= max_index:
midpoint = (mix_index + max_index) // 2
if lis[midpoint] < x:
mix_index = mix_index + 1
elif lis[midpoint] > x:
max_index = max_index - 1
else:
return midpoint
return -1

lists = [1, 3, 5, 6, 7, 8, 12, 22, 23, 43, 65, 76, 90, 543]
res = search(lists, 76)
print(res)

在取列表中间值时使用的语句是 midpoint = (mix_index + max_index) // 2,即地板除,我们可以将其替换为 midpoint = (mix_index + max_index) >> 1 最终得到的结果是相同的。这是因为左移 1位 等效于乘以 2,而右移 1 位等效于除以 2。这样的案例还有很多,此处不再赘述。 至此,我们已经对位运算有了一定的了解,希望你在工作中使用位运算。更多 Saoperation 和知识请扫描下方二维码。

Python

本节主要内容有:

  • 通过 requests 库模拟表单提交
  • 通过 pandas 库提取网页表格

上周五,大师兄发给我一个网址,哭哭啼啼地求我:“去!把这个网页上所有年所有县所有作物的数据全爬下来,存到 Access 里!” 我看他可怜,勉为其难地挥挥手说:“好嘞,马上就开始!”

目标分析

大师兄给我的网址是这个:https://www.ctic.org/crm?tdsourcetag=s_pctim_aiomsg 打开长这样: 根据我学爬虫并不久的经验,通常只要把年月日之类的参数附加到 url 里面去,然后用requests.get拿到response解析 html 就完了,所以这次应该也差不多——除了要先想办法获得具体有哪些年份、地名、作物名称,其他部分拿以前的代码稍微改改就能用了,毫无挑战性工作,生活真是太无聊了 点击 View Summary 后出现目标网页长这样 那个大表格的数据就是目标数据了,好像没什么了不起的—— 有点不对劲 目标数据所在网页的网址是这样的:https://www.ctic.org/crm/?action=result ,刚刚选择的那些参数并没有作为 url 的参数啊!网址网页都变了,所以也不是 ajax 这和我想象的情况有巨大差别啊

尝试获取目标页面

让我来康康点击View Summary这个按钮时到底发生了啥:右键View Summary检查是这样: 实话说,这是我第一次遇到要提交表单的活儿。以前可能是上天眷顾我,统统get就能搞定,今天终于让我碰上一个post了。 点击View Summary,到 DevTools 里找 network 第一条: 不管三七二十一,post一下试试看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = 'https://www.ctic.org/crm?tdsourcetag=s_pctim_aiomsg'
headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/74.0.3729.131 Safari/537.36',
'Host': 'www.ctic.org'}
data = {'_csrf': 'SjFKLWxVVkkaSRBYQWYYCA1TMG8iYR8ReUYcSj04Jh4EBzIdBGwmLw==',
'CRMSearchForm[year]': '2011',
'CRMSearchForm[format]': 'Acres',
'CRMSearchForm[area]': 'County',
'CRMSearchForm[region]': 'Midwest',
'CRMSearchForm[state]': 'IL',
'CRMSearchForm[county]': 'Adams',
'CRMSearchForm[crop_type]': 'All',
'summary': 'county'}
response = requests.post(url, data=data, headers=headers)
print(response.status_code)

果不其然,输出400……我猜这就是传说中的cookies在搞鬼吗?《Python3 网络爬虫实战》只看到第 6 章的我不禁有些心虚跃跃欲试呢! 首先,我搞不清cookies具体是啥,只知道它是用来维持会话的,应该来自于第一次get,搞出来看看先:

1
2
3
4
response1 = requests.get(url, headers=headers)
if response1.status_code == 200:
cookies = response1.cookies
print(cookies)

输出:

1
<RequestsCookieJar[<Cookie PHPSESSID=52asgghnqsntitqd7c8dqesgh6 for www.ctic.org/>, <Cookie _csrf=2571c72a4ca9699915ea4037b967827150715252de98ea2173b162fa376bad33s%3A32%3A%22TAhjwgNo5ElZzV55k3DMeFoc5TWrEmXj%22%3B for www.ctic.org/>]>

Nah,看不懂,不看不管,直接把它放到post里试试

1
2
response2 = requests.post(url, data=data, headers=headers, cookies=cookies)
print(response2.status_code)

还是400,气氛突然变得有些焦灼,我给你cookies了啊,你还想要啥?! 突然,我发现一件事:post请求所带的data中那个一开始就显得很可疑的_csrf我仿佛在哪儿见过? 那个我完全看不懂的cookies里好像就有一个_csrf啊!但是两个_csrf的值很明显结构不一样,试了一下把data里的_csrf换成cookies里的_csrf确实也不行。 但是我逐渐有了一个想法:这个两个_csrf虽然不相等,但是应该是匹配的,我刚刚的data来自浏览器,cookies来自 python 程序,所以不匹配! 于是我又点开浏览器的 DevTools,Ctrl+F 搜索了一下,嘿嘿,发现了: 这三处。 第一处那里的下一行的csrf_token很明显就是post请求所带的data里的_csrf,另外两个是 js 里的函数,虽然 js 没好好学但也能看出来这俩是通过post请求获得州名和县名的,Binggo!一下子解决两个问题。 为了验证我的猜想,我打算先直接用 requests 获取点击View Summary前的页面的 HTML 和cookies,将从 HTML 中提取的csrf_token值作为点击View Summarypost请求的data里的_csrf值,同时附上cookies,这样两处_csrf就应该是匹配的了:

1
2
3
4
5
6
7
8
from lxml import etree
response1 = requests.get(url, headers=headers)
cookies = response1.cookies
html = etree.HTML(response1.text)
csrf_token = html.xpath('/html/head/meta[3]/@content')[0]
data.update({'_csrf': csrf_token})
response2 = requests.post(url, data=data, headers=headers, cookies=cookies)
print(response2.status_code)

输出200,虽然和 Chrome 显示的302不一样,但是也表示成功,那就不管了。把response2.text写入 html 文件打开看是这样: Yeah,数据都在!说明我的猜想是对的!那一会再试试我从没用过的requests.Session()维持会话,自动处理cookies

尝试 pandas 库提取网页表格

现在既然已经拿到了目标页面的 HTML,那在获取所有年、地区、州名、县名之前,先测试一下pandas.read_html提取网页表格的功能。 pandas.read_html这个函数时在写代码时 IDE 自动补全下拉列表里瞄到的,一直想试试来着,今天乘机拉出来溜溜:

1
2
3
import pandas as pd
df = pd.read_html(response2.text)[0]
print(df)

输出: Yeah!拿到了,确实比自己手写提取方便,而且数值字符串自动转成数值,优秀!

准备所有参数

接下来要获取所有年、地区、州名、县名。年份和地区是写死在 HTML 里的,直接 xpath 获取: 州名、县名根据之前发现的两个 js 函数,要用post请求来获得,其中州名要根据地区名获取,县名要根据州名获取,套两层循环就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def new():
session = requests.Session()
response = session.get(url=url, headers=headers)
html = etree.HTML(response.text)
return session, html

session, html = new()
years = html.xpath('//*[@id="crmsearchform-year"]/option/text()')
regions = html.xpath('//*[@id="crmsearchform-region"]/option/text()')
_csrf = html.xpath('/html/head/meta[3]/@content')[0]
region_state = {}
state_county = {}
for region in regions:
data = {'region': region, '_csrf': _csrf}
response = session.post(url_state, data=data)
html = etree.HTML(response.json())
region_state[region] = {x: y for x, y in
zip(html.xpath('//option/@value'),
html.xpath('//option/text()'))}
for state in region_state[region]:
data = {'state': state, '_csrf': _csrf}
response = session.post(url_county, data=data)
html = etree.HTML(response.json())
state_county[state] = html.xpath('//option/@value')

啧啧,使用requests.Session就完全不需要自己管理cookies了,方便!具体获得的州名县名就不放出来了,实在太多了。然后把所有年、地区、州名、县名的可能组合先整理成 csv 文件,一会直接从 csv 里读取并构造post请求的data字典:

1
2
3
4
5
6
7
8
9
10
11
12
remain = [[str(year), str(region), str(state), str(county)]
for year in years for region in regions
for state in region_state[region] for county in state_county[state]]
remain = pd.DataFrame(remain, columns=['CRMSearchForm[year]',
'CRMSearchForm[region]',
'CRMSearchForm[state]',
'CRMSearchForm[county]'])
remain.to_csv('remain.csv', index=False)
# 由于州名有缩写和全称,也本地保存一份
import json
with open('region_state.json', 'w') as json_file:
json.dump(region_state, json_file, indent=4)

我看了一下,一共 49473 行——也就是说至少要发送 49473 个post请求才能爬完全部数据,纯手工获取的话大概要点击十倍这个数字的次数……

正式开始

那么开始爬咯

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
import pyodbc
with open("region_state.json") as json_file:
region_state = json.load(json_file)
data = pd.read_csv('remain.csv')
# 读取已经爬取的
cnxn = pyodbc.connect('DRIVER={Microsoft Access Driver (*.mdb, *.accdb)};'
'DBQ=./ctic_crm.accdb')
crsr = cnxn.cursor()
crsr.execute('select Year_, Region, State, County from ctic_crm')
done = crsr.fetchall()
done = [list(x) for x in done]
done = pd.DataFrame([list(x) for x in done], columns=['CRMSearchForm[year]',
'CRMSearchForm[region]',
'CRMSearchForm[state]',
'CRMSearchForm[county]'])
done['CRMSearchForm[year]'] = done['CRMSearchForm[year]'].astype('int64')
state2st = {y: x for z in region_state.values() for x, y in z.items()}
done['CRMSearchForm[state]'] = [state2st[x]
for x in done['CRMSearchForm[state]']]
# 排除已经爬取的
remain = data.append(done)
remain = remain.drop_duplicates(keep=False)
total = len(remain)
print(f'{total} left.n')
del data

# %%
remain['CRMSearchForm[year]'] = remain['CRMSearchForm[year]'].astype('str')
columns = ['Crop',
'Total_Planted_Acres',
'Conservation_Tillage_No_Till',
'Conservation_Tillage_Ridge_Till',
'Conservation_Tillage_Mulch_Till',
'Conservation_Tillage_Total',
'Other_Tillage_Practices_Reduced_Till15_30_Residue',
'Other_Tillage_Practices_Conventional_Till0_15_Residue']
fields = ['Year_', 'Units', 'Area', 'Region', 'State', 'County'] + columns
data = {'CRMSearchForm[format]': 'Acres',
'CRMSearchForm[area]': 'County',
'CRMSearchForm[crop_type]': 'All',
'summary': 'county'}
headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/74.0.3729.131 Safari/537.36',
'Host': 'www.ctic.org',
'Upgrade-Insecure-Requests': '1',
'DNT': '1',
'Connection': 'keep-alive'}
url = 'https://www.ctic.org/crm?tdsourcetag=s_pctim_aiomsg'
headers2 = headers.copy()
headers2 = headers2.update({'Referer': url,
'Origin': 'https://www.ctic.org'})
def new():
session = requests.Session()
response = session.get(url=url, headers=headers)
html = etree.HTML(response.text)
_csrf = html.xpath('/html/head/meta[3]/@content')[0]
return session, _csrf
session, _csrf = new()
for _, row in remain.iterrows():
temp = dict(row)
data.update(temp)
data.update({'_csrf': _csrf})
while True:
try:
response = session.post(url, data=data, headers=headers2, timeout=15)
break
except Exception as e:
session.close()
print(e)
print('nSleep 30s.n')
time.sleep(30)
session, _csrf = new()
data.update({'_csrf': _csrf})

df = pd.read_html(response.text)[0].dropna(how='all')
df.columns = columns
df['Year_'] = int(temp['CRMSearchForm[year]'])
df['Units'] = 'Acres'
df['Area'] = 'County'
df['Region'] = temp['CRMSearchForm[region]']
df['State'] = region_state[temp['CRMSearchForm[region]']][temp['CRMSearchForm[state]']]
df['County'] = temp['CRMSearchForm[county]']
df = df.reindex(columns=fields)
for record in df.itertuples(index=False):
tuple_record = tuple(record)
sql_insert = f'INSERT INTO ctic_crm VALUES {tuple_record}'
sql_insert = sql_insert.replace(', nan,', ', null,')
crsr.execute(sql_insert)
crsr.commit()
print(total, row.to_list())
total -= 1
else:
print('Done!')
crsr.close()
cnxn.close()

注意中间有个try...except..语句,是因为不定时会发生Connection aborted的错误,有时 9000 次才断一次,有时一次就断,这也是我加上了读取已经爬取的排除已经爬取的原因,而且担心被识别出爬虫,把headers写的丰富了一些(好像并没有什么卵用),并且每次断开都暂停个 30s 并重新开一个会话 然后把程序开着过了一个周末,命令行里终于打出了Done!,到 Access 里一看有 816288 条记录,心想:下次试试多线程(进程)和代理池。


周一,我把跑出来的数据发给大师兄,大师兄回我:“好的”。 隔着屏幕我都能感受到滔滔不绝的敬仰和感激之情, 一直到现在,大师兄都感动地说不出话来。

JavaScript

前言

本篇博文来自一次公司内部的前端分享,从多个方面讨论了在设计接口时遵循的原则,总共包含了七个大块。系卤煮自己总结的一些经验和教训。本篇博文同时也参考了其他一些文章,相关地址会在后面贴出来。很难做到详尽充实,如果有好的建议或者不对的地方,还望不吝赐教斧正。

一、接口的流畅性

好的接口是流畅易懂的,他主要体现如下几个方面: 1.简单 操作某个元素的css属性,下面是原生的方法:

1
document.querySelector('#id').style.color = 'red';

封装之后

1
2
3
4
function a(selector, color) {
document.querySelector(selector).style.color = color
}
a('#a', 'red');

从几十个字母长长的一行到简简单单的一个函数调用,体现了api设计原则之一:简单易用。 2.可阅读性 a(‘#a’, ‘red’)是个好函数,帮助我们简单实用地改变某个元素,但问题来了,如果第一次使用该函数的人来说会比较困惑,a函数是啥函数,没有人告诉他。开发接口有必要知道一点,大多数人都是懒惰的(包括卤煮自己),从颜色赋值这个函数来说,虽然少写了代码,但是增加了单词字母的个数,使得它不再好记。每次做这件事情的时候都需要有映射关系: a—->color. 如果是简单的几个api倒是无所谓,但是通常一套框架都有几十甚至上百的api,映射成本增加会使得程序员哥哥崩溃。 我们需要的就是使得接口名称有意义,下面我们改写一下a函数:

1
2
function letSomeElementChangeColor(selector, color) {
document.querySelectorAll(selector, color).style.color = color; }

letSomeElementChangeColor相对于a来说被赋予了现实语言上的意义,任何人都不需要看说明也能知道它的功能。 3.减少记忆成本 我们刚刚的函数太长了,letSomeElementChangeColor虽然减少了映射成本,有了语言上的意义,但是毫无疑问增加了记忆成本。要知道,包括学霸在内,任何人都不喜欢背单词。不仅仅在此处,原生获取dom的api也同样有这个问题: document.getElementsByClassName; document.getElementsByName; document.querySelectorAll;这些api给人的感觉就是单词太长了,虽然他给出的意义是很清晰,然而这种做法是建立在牺牲简易性和简忆性的基础上进行的。于是我们又再次改写这个之前函数

1
2
3
function setColor(selector, color) {
xxxxxxxxxxxx
}

在语言意义不做大的变化前提下,缩减函数名称。使得它易读易记易用。 4.可延伸 所谓延伸就是指函数的使用像流水一样按照书写的顺序执行形成执行链条:

1
2
3
document.getElementById('id').style.color = 'red';
document.getElementById('id').style.fontSize = '12px';
document.getElementById('id').style.backgourdColor = 'pink';

如果我们需要实现像以上有强关联性的业务时,用我们之前的之前的方法是再次封装两个函数 setFontSize, setbackgroundColor; 然后执行它们 setColor(‘id’, ‘red’);setFontSiez(‘id’, ’12px’); setbackgroundColor(‘id’, ‘pink’); 显然,这样的做法没有懒出境界来;id元素每次都需要重新获取,影响性能,失败;每次都需要添加新的方法,失败; 每次还要调用这些方法,还是失败。下面我们将其改写为可以延伸的函数 首先将获取id方法封装成对象,然后再对象的每个方法中返回这个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function getElement(selector) {
this.style = document.querySelecotrAll(selector).style;
}

getElement.prototype.color = function(color) {
this.style.color = color;
return this;
}
getElement.prototype.background = function(bg) {
this.style.backgroundColor = bg;
return this;
}
getElement.prototype.fontSize = function(size) {
this.style.fontSize = size;
return this;
}

//调用
var el = new getElement('#id')
el.color('red').background('pink').fontSize('12px');

简单、流畅、易读,它们看起来就像行云流水一样,即在代码性能上得到了提升优化,又在视觉上悦目。后面我们会在参数里面讲到如何继续优化。 所以,大家都比较喜欢用jquery的api,虽然一个$符号并不代表任何现实意义,但简单的符号有利于我们的使用。它体现了以上的多种原则,简单,易读,易记,链式写法,多参处理。 nightmare:

1
2
3
document.getElementById('id').style.color = 'red';
document.getElementById('id').style.fontSize = '12px';
document.getElementById('id').style.backgourdColor = 'pink';

dream:

1
$('id').css({color:'red', fontSize:'12px', backgroundColor:'pink'})

二、一致性

1.接口的一致性 相关的接口保持一致的风格,一整套 API 如果传递一种熟悉和舒适的感觉,会大大减轻开发者对新工具的适应性。 命名这点事:既要短,又要自描述,最重要的是保持一致性 “在计算机科学界只有两件头疼的事:缓存失效和命名问题” — Phil Karlton 选择一个你喜欢的措辞,然后持续使用。选择一种风格,然后保持这种风格。 Nightmare:

1
2
3
4
setColor,
letBackGround
changefontSize
makedisplay

dream:

1
2
3
4
setColor;
setBackground;
setFontSize
set.........

尽量地保持代码风格和命名风格,使别人读你的代码像是阅读同一个人写的文章一样。

三、参数的处理

1.参数的类型 判断参数的类型为你的程序提供稳定的保障

1
2
3
4
5
//我们规定,color接受字符串类型
function setColor(color) {
if(typeof color !== 'string') return;
dosomething
}

2.使用json方式传参 使用json的方式传值很多好处,它可以给参数命名,可以忽略参数的具体位置,可以给参数默认值等等 比如下面这种糟糕的情况:

1
function fn(param1, param2...............paramN)

你必须对应地把每一个参数按照顺序传入,否则你的方法就会偏离你预期去执行,正确的方法是下面的做法。

1
2
3
4
5
6
7
8
function fn(json) {
//为必须的参数设置默认值
var default = extend({
param: 'default',
param1: 'default'
......
},json)
}

这段函数代码,即便你不传任何参数进来,他也会预期运行。因为在声明的时候,你会根据具体的业务预先决定参数的缺省值。

四、可扩展性

软件设计最重要的原则之一:永远不修改接口,而是去扩展它!可扩展性同时会要求接口的职责单一,多职责的接口很难扩展。 举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//需要同时改变某个元素的字体和背景
// Nightmare:
function set(selector, color) {
document.querySelectroAll(selector).style.color = color;
document.querySelectroAll(selector).style.backgroundColor = color;
}

//无法扩展改函数,如果需要再次改变字体的大小的话,只能修改此函数,在函数后面填加改变字体大小的代码

//Dream
function set(selector, color) {
var el = document.querySelectroAll(selector);
el.style.color = color;
el.style.backgroundColor = color;
return el;
}

//需要设置字体、背景颜色和大小
function setAgain (selector, color, px) {
var el = set(selector, color)
el.style.fontSize = px;
return el;
}

以上只是简单的添加颜色,业务复杂而代码又不是你写的时候,你就必须去阅读之前的代码再修改它,显然是不符合开放-封闭原则的。修改后的function是返回了元素对象,使得下次需要改变时再次得到返回值做处理。 2.this的运用 可扩展性还包括对this的以及call和apply方法的灵活运用:

1
2
3
4
5
6
7
8
9
function sayBonjour() {
alert(this.a)
}

obj.a = 1;
obj.say = sayBonjour;
obj.say();//1
//or
sayBonjour.call||apply(obj);//1

五、对错误的处理

1.预见错误 可以用 类型检测 typeof 或者try…catch。 typeof 会强制检测对象不抛出错误,对于未定义的变量尤其有用。 2.抛出错误 大多数开发者不希望出错了还需要自己去找带对应得代码,最好方式是直接在console中输出,告诉用户发生了什么事情。我们可以用到浏览器为我们提供的api输出这些信息:console.log/warn/error。你还可以为自己的程序留些后路: try…catch。

1
2
3
4
5
6
7
8
9
10
11
12
13
function error (a) {
if(typeof a !== 'string') {
console.error('param a must be type of string')
}
}

function error() {
try {
// some code excucete here maybe throw wrong
}catch(ex) {
console.wran(ex);
}
}

六、可预见性

可预见性味程序接口提供健壮性,为保证你的代码顺利执行,必须为它考虑到非正常预期的情况。我们看下不可以预见的代码和可预见的代码的区别用之前的setColor

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
//nighware
function set(selector, color) {
document.getElementById(selector).style.color = color;
}

//dream
zepto.init = function(selector, context) {
var dom
// If nothing given, return an empty Zepto collection
if (!selector) return zepto.Z()
// Optimize for string selectors
else if (typeof selector == 'string') {
selector = selector.trim()
// If it's a html fragment, create nodes from it
// Note: In both Chrome 21 and Firefox 15, DOM error 12
// is thrown if the fragment doesn't begin with <
if (selector[0] == '<' && fragmentRE.test(selector))
dom = zepto.fragment(selector, RegExp.$1, context), selector = null
// If there's a context, create a collection on that context first, and select
// nodes from there
else if (context !== undefined) return $(context).find(selector)
// If it's a CSS selector, use it to select nodes.
else dom = zepto.qsa(document, selector)
}
// If a function is given, call it when the DOM is ready
else if (isFunction(selector)) return $(document).ready(selector)
// If a Zepto collection is given, just return it
else if (zepto.isZ(selector)) return selector
else {
// normalize array if an array of nodes is given
if (isArray(selector)) dom = compact(selector)
// Wrap DOM nodes.
else if (isObject(selector))
dom = [selector], selector = null
// If it's a html fragment, create nodes from it
else if (fragmentRE.test(selector))
dom = zepto.fragment(selector.trim(), RegExp.$1, context), selector = null
// If there's a context, create a collection on that context first, and select
// nodes from there
else if (context !== undefined) return $(context).find(selector)
// And last but no least, if it's a CSS selector, use it to select nodes.
else dom = zepto.qsa(document, selector)
}
// create a new Zepto collection from the nodes found
return zepto.Z(dom, selector)
}

以上是zepto的源码,可以看见,作者在预见传入的参数时做了很多的处理。其实可预见性是为程序提供了若干的入口,无非是一些逻辑判断而已。zepto在这里使用了很多的是非判断,这样做的好处当然是代码比之前更健壮,但同时导致了代码的冗长,不适合阅读。总之,可预见性真正需要你做的事多写一些对位置实物的参数。把外部的检测改为内部检测。是的使用的人用起来舒心放心开心。呐!做人嘛最重要的就是海森啦。

七、注释和文档的可读性

一个最好的接口是不需要文档我们也会使用它,但是往往接口量一多和业务增加,接口使用起来也会有些费劲。所以接口文档和注释是需要认真书写的。注释遵循简单扼要地原则,给多年后的自己也给后来者看:

1
2
3
4
5
6
7
8
9
10
//注释接口,为了演示PPT用
function commentary() {
//如果你定义一个没有字面意义的变量时,最好为它写上注释:a:没用的变量,可以删除
var a;

//在关键和有歧义的地方写上注释,犹如画龙点睛:路由到hash界面后将所有的数据清空结束函数
return go.Navigate('hash', function(){
data.clear();
});
}

最后

推荐markdown语法书写API文档,github御用文档编写语法。简单、快速,代码高亮、话不多说上图 卤煮在此也推荐几个在线编辑的网站。诸君可自行前往练习使用。 https://www.zybuluo.com/mdeditor http://mahua.jser.me/

参考博文

前端头条-javascript的api设计原则 原文:http://www.cnblogs.com/constantince/p/5580003.html

JavaScript

在这篇文章中,我们将介绍一些用于AJAX调用的最好的JS库,包括jQuery,Axios和Fetch。欢迎查看代码示例! AJAX是用来对服务器进行异步HTTP调用的一系列web开发技术客户端框架。 AJAX即Asynchronous JavaScript and XML(异步JavaScript和XML)。AJAX曾是web开发界的一个常见名称,许多流行的JavaScript小部件都是使用AJAX构建的。例如,有些特定的用户交互(如按下按钮)会异步调用到服务器,服务器会检索数据并将其返回给客户端——所有这些都不需要重新加载网页。

AJAX的现代化重新引入

JavaScript已经进化了,现在我们使用前端库和/或如React、Angular、Vue等框架构建了动态的网站。AJAX的概念也经历了重大变化,因为现代异步JavaScript调用涉及检索JSON而不是XML。有很多库允许你从客户端应用程序对服务器进行异步调用。有些进入到浏览器标准,有些则有很大的用户基础,因为它们不但灵活而且易于使用。有些支持promises,有些则使用回调。在本文中,我将介绍用于从服务器获取数据的前5个AJAX库。

Fetch API

Fetch API是XMLHttpRequest的现代替代品,用于从服务器检索资源。与XMLHttpRequest不同的是,它具有更强大的功能集和更有意义的命名。基于其语法和结构,Fetch不但灵活而且易于使用。但是,与其他AJAX HTTP库区别开来的是,它具有所有现代Web浏览器的支持。Fetch遵循请求-响应的方法,也就是说,Fetch提出请求并返回解析到Response对象的promise。 你可以传递Request对象来获取,或者,也可以仅传递要获取的资源的URL。下面的示例演示了使用Fetch创建简单的GET请求。

1
2
3
4
5
6
7
8
fetch('https://www.example.com', {
method: 'get'
})
.then(response => response.json())
.then(jsonData => console.log(jsonData))
.catch(err => {
//error block
})

正如你所看到的,Fetch的then方法返回了一个响应对象,你可以使用一系列的then 进行进一步的操作。我使用.json() 方法将响应转换为JSON并将其输出到控制台。 假如你需要POST表单数据或使用Fetch创建AJAX文件上传,将会怎么样?此时,除了Fetch之外,你还需要一个输入表单,并使用FormData库来存储表单对象。

1
2
3
4
5
6
7
8
var input = document.querySelector('input[type="file"]')
var data = new FormData()
data.append('file', input.files[0])
data.append('user', 'blizzerand')
fetch('/avatars', {
method: 'POST',
body: data
})

你可以在官方的Mozilla web文档中阅读更多关于Fetch API的信息。

Axios

Axios是一个基于XMLHttpRequest而构建的现代JavaScript库,用于进行AJAX调用。它允许你从浏览器和服务器发出HTTP请求。此外,它还支持ES6原生的Promise API。Axios的其他突出特点包括:

  • 拦截请求和响应。
  • 使用promise转换请求和响应数据。
  • 自动转换JSON数据。
  • 取消实时请求。

要使用Axios,你需要先安装它。

1
npm install axios

下面是一个演示Axios行动的基本例子。

1
2
3
4
5
6
7
8
// Make a request for a user with a given ID
axios.get('/user?ID=12345')
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});

与Fetch相比,Axios的语法更简单。让我们做一些更复杂的事情,比如我们之前使用Fetch创建的AJAX文件上传器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var data = new FormData();
data.append('foo', 'bar');
data.append('file', document.getElementById('file').files[0]);
var config = {
onUploadProgress: function(progressEvent) {
var percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total );
}
};
axios.put('/upload/server', data, config)
.then(function (res) {
output.className = 'container';
output.innerHTML = res.data;
})
.catch(function (err) {
output.className = 'container text-danger';
output.innerHTML = err.message;
});

Axios更具可读性。Axios也非常受React和Vue等现代库的欢迎。

jQuery

jQuery曾经是JavaScript中的一个前线库,用于处理从AJAX调用到操纵DOM内容的所有事情。虽然随着其他前端库的“冲击”,其相关性有所降低,但你仍然可以使用jQuery来进行异步调用。 如果你之前使用过jQuery,那么这可能是最简单的解决方案。但是,你将不得不导入整个jQuery库以使用$.ajax方法。虽然这个库有特定于域的方法,例如$.getJSON,$.get和$.post,但是其语法并不像其他的AJAX库那么简单。以下代码用于编写基本的GET请求。

1
2
3
4
5
6
7
8
9
10
11
$.ajax({
url: '/users',
type: "GET",
dataType: "json",
success: function (data) {
console.log(data);
}
fail: function () {
console.log("Encountered an error")
}
});

jQuery好的地方在于,如果你有疑问,那么你可以找到大量的支持和文档。我发现了很多使用FormData()和jQuery进行AJAX文件上传的例子。下面是最简单的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
var formData = new FormData();
formData.append('file', $('#file')[0].files[0]);
$.ajax({
url : 'upload.php',
type : 'POST',
data : formData,
processData: false, // tell jQuery not to process the data
contentType: false, // tell jQuery not to set contentType
success : function(data) {
console.log(data);
alert(data);
}
});

SuperAgent

SuperAgent是一个轻量级和渐进式的AJAX库,更侧重于可读性和灵活性。SuperAgent还拥有一个温和的学习曲线,不像其他库。它有一个针对Node.js API相同的模块。SuperAgent有一个接受GET、POST、PUT、DELETE和HEAD等方法的请求对象。然后你可以调用.then(),.end()或新的.await()方法来处理响应。例如,以下代码为使用SuperAgent的简单GET请求。

1
2
3
4
5
6
7
8
request
.post('/api/pet')
.send({ name: 'Manny', species: 'cat' })
.set('X-API-Key', 'foobar')
.set('Accept', 'application/json')
.then(function(res) {
alert('yay got ' + JSON.stringify(res.body));
});

如果你想要做更多的事情,比如使用此AJAX库上传文件,那该怎么做呢? 同样超级easy。

1
2
3
4
5
6
7
request
.post('/upload')
.field('user[name]', 'Tobi')
.field('user[email]', 'tobi@learnboost.com')
.field('friends[]', ['loki', 'jane'])
.attach('image', 'path/to/tobi.png')
.then(callback);

如果你有兴趣了解更多关于SuperAgent的信息,那么它们有一系列很不错的文档来帮助你开始这个旅程。

Request——简化的HTTP客户端

Request库是进行HTTP调用最简单的方法之一。结构和语法与在Node.js中处理请求的方式非常相似。目前,该项目在GitHub上有18K个星,值得一提的是,它是可用的最流行的HTTP库之一。 下面是一个例子:

1
2
3
4
5
6
var request = require('request');
request('http://www.google.com', function (error, response, body) {
console.log('error:', error); // Print the error if one occurred
console.log('statusCode:', response && response.statusCode); // Print the response status code if a response was received
console.log('body:', body); // Print the HTML for the Google homepage.
});

结论

从客户端进行HTTP调用在十年前可不是一件容易的事。前端开发人员不得不依赖于难以使用和实现的XMLHttpRequest。现代的库和HTTP客户端使得用户交互、动画、异步文件上传等前端功能变得更加简单。 我个人最喜欢的是Axios,因为我觉得它更易读更赏心悦目。你也可以忠于Fetch,因为它文档化良好且有标准化的解决方案。 你个人最喜欢的AJAX库是哪个? 欢迎各位分享你的看法。

JavaScript

简评:一开始 JavaScript 只是为网页增添一些实时动画效果,现在 JS 已经能做到前后端通吃了,而且还是年度流行语言。本文分享几则 JS 小窍门,可以让你事半功倍 ~

1. 删除数组尾部元素

一个简单方法就是改变数组的length值:

1
2
3
4
5
6
7
8
const arr = [11, 22, 33, 44, 55, 66];
// truncanting
arr.length = 3;
console.log(arr); //=> [11, 22, 33]
// clearing
arr.length = 0;
console.log(arr); //=> []
console.log(arr[2]); //=> undefined

2. 使用对象解构(object destructuring)来模拟命名参数

如果需要将一系列可选项作为参数传入函数,你很可能会使用对象(Object)来定义配置(Config)。

1
2
3
4
5
6
7
doSomething({ foo: 'Hello', bar: 'Hey!', baz: 42 });
function doSomething(config) {
const foo = config.foo !== undefined ? config.foo : 'Hi';
const bar = config.bar !== undefined ? config.bar : 'Yo!';
const baz = config.baz !== undefined ? config.baz : 13;
// ...
}

不过这是一个比较老的方法了,它模拟了 JavaScript 中的命名参数。 在 ES 2015 中,你可以直接使用对象解构:

1
2
3
function doSomething({ foo = 'Hi', bar = 'Yo!', baz = 13 }) {
// ...
}

让参数可选也很简单:

1
2
3
function doSomething({ foo = 'Hi', bar = 'Yo!', baz = 13 } = {}) {
// ...
}

3. 使用对象解构来处理数组

可以使用对象解构的语法来获取数组的元素:

1
2
const csvFileLine = '1997,John Doe,US,john@doe.com,New York';
const { 2: country, 4: state } = csvFileLine.split(',');

4. 在 Switch 语句中使用范围值

可以这样写满足范围值的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getWaterState(tempInCelsius) {
let state;

switch (true) {
case (tempInCelsius <= 0):
state = 'Solid';
break;
case (tempInCelsius > 0 && tempInCelsius < 100):
state = 'Liquid';
break;
default:
state = 'Gas';
}
return state;
}

5. await 多个 async 函数

在使用 async/await 的时候,可以使用 Promise.all 来 await 多个 async 函数

1
await Promise.all([anAsyncCall(), thisIsAlsoAsync(), oneMore()])

6. 创建 Pure objects

你可以创建一个 100% pure object,它不从Object中继承任何属性或则方法(比如constructor, toString()等)

1
2
3
4
5
const pureObject = Object.create(null);
console.log(pureObject); //=> {}
console.log(pureObject.constructor); //=> undefined
console.log(pureObject.toString); //=> undefined
console.log(pureObject.hasOwnProperty); //=> undefined

7. 格式化 JSON 代码

JSON.stringify除了可以将一个对象字符化,还可以格式化输出 JSON 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const obj = { 
foo: { bar: [11, 22, 33, 44], baz: { bing: true, boom: 'Hello' } }
};
// The third parameter is the number of spaces used to
// beautify the JSON output.
JSON.stringify(obj, null, 4);
// =>"{
// => "foo": {
// => "bar": [
// => 11,
// => 22,
// => 33,
// => 44
// => ],
// => "baz": {
// => "bing": true,
// => "boom": "Hello"
// => }
// => }
// =>}"

8. 从数组中移除重复元素

通过使用集合语法和 Spread 操作,可以很容易将重复的元素移除:

1
2
3
const removeDuplicateItems = arr => [...new Set(arr)];
removeDuplicateItems([42, 'foo', 42, 'foo', true, true]);
//=> [42, "foo", true]

9. 平铺多维数组

使用 Spread 操作平铺嵌套多维数组:

1
2
const arr = [11, [22, 33], [44, 55], 66];
const flatArr = [].concat(...arr); //=> [11, 22, 33, 44, 55, 66]

不过上面的方法仅适用于二维数组,但是通过递归,就可以平铺任意维度的嵌套数组了:

1
2
3
4
5
6
7
8
9
function flattenArray(arr) {
const flattened = [].concat(...arr);
return flattened.some(item => Array.isArray(item)) ?
flattenArray(flattened) : flattened;
}

const arr = [11, [22, 33], [44, [55, 66, [77, [88]], 99]]];
const flatArr = flattenArray(arr);
//=> [11, 22, 33, 44, 55, 66, 77, 88, 99]

希望这些小技巧能帮助你写好 JavaScript ~ 原文:https://zhuanlan.zhihu.com/p/37493249

技术杂谈

现代人都普遍比较焦虑,越来越多的人感到生活压力很大,在一二线城市不少白领人士更是已是患上“知识焦虑症”:这年头知识信息迭代太快,学习跟不上,别人懂的自己都不懂,总感觉自己欠缺的很多,没有技术和专长不知未来靠什么生存,担心自己长期落后他人从而被社会淘汰,从而产生了一种对未来不确定的心理恐惧。 于是,他们就急于渴求,希望能在短时间内学习大量知识希望从根本上解决自己的实际问题,拼命利用碎片化时间学习补充欠缺知识,不断完善自己的知识体系。这时知识付费的出现,正好满足了他们这些刚性需求。 再加上2018年前以逻辑思维为主一批内容供应商到处鼓吹和贩卖知识焦虑,在这种风气影响下连身边的很多小伙伴都购买了大量知识付费产品,但一年下来却发现这些知识根本没有学会几个,内容良莠不齐,服务跟不上,就算能坚持学完下来还是什么都不会,焦虑依然存在 。甚至更多人觉得其实知识付费就是一场骗局,从本质上来说知识付费并没有解决任何的问题,表面上只是缓解了一些焦虑,在短时间内让一些人自我感觉良好,实则上只是自欺欺人而已。 于是,一些感觉被欺骗的用户纷纷发声,对某些知识付费大号和机构更是愤怒抨击,在这些负面影响下这两年知识付费其实已逐渐降温并遭遇爆冷,甚至还有一些负面声音在说: 在线知识付费的寒冬是不是要来临了? 其实不然,纵观当下市场发展态势知识付费的前景仍然是广阔的,竞争激烈程度仍方兴未艾,当人们对知识的盲目消费退烧后,知识付费更多的是回归服务价值, 核心需求有两方面 : 一方面是为了提升欠缺的知识让自己具有更多的市场竞争力,另一方面也希望自己花钱买的知识付费产品是一些真正有帮助的东西,可帮自己迎来一个不错的未来。所以,知识付费的下一阶段必然聚焦于如何让学习用户获得真正有效的学习效果。 那么,知识付费下半场风口到底会吹去哪? 好学豚认为这一领域的大方向以后的发展趋势大致有如下特征:

趋势一:内容下沉,回归有价值的真实内容

1、职场技能

在知识付费领域和在整个教育市场体系里,职场技能都是最刚需的内容板块,从最早的电算会计化到后来的英语类培训,再到如今一些以工具为代表的设计、办公技能、IT编程等等培训,无一不彰显着其旺盛的生命力。 究其根本原因,这是因为它能够切实解决职场人士的实际需求,所以职场技能内容才会一直火爆,繁荣至今仍在蓬勃发展。

2、知识拓展

知识拓展是近年来兴起的版块内容,也是互联网上传播特别广泛的内容。以得到为代表,邀请行业细分领域的专业人士,按照标准的生产流程来生产较高质量的内容,帮助拓广知识的视野从而实现专家指路的效果。不容忽视的是,知识拓展的内容从今天来看存在较大的局限,很难解决用户的实际问题,学习效果也很难得到保证;如何打磨出更加直击用户痛点的产品,需要更多的探索和尝试。

3、兴趣爱好

在线教育领域,兴趣爱好的内容已经有了长足发展。用户的痛点正在逐渐从追求高大上、财务自由这些虚伪的概念转型到享受生活:提 升生活趣味让自己活得更舒服。兴趣爱好内容之所以能生命力如此旺盛,最大因素要得益于其以兴趣为导向,学习者本身并不会有太高的学习结果诉求,更多的是在学习之中获得的乐趣以及对授课老师的认同。

趋势二:后续服务提升,注重全流程效果

今后知识付费需要做好三个关键点的平衡:碎片化学习、学习规模和学习效果,这三者缺一不可。根据业内行家的实践和推断,下一阶段知识付费的标配服务模式将会是1+1+N模式,能较好地实现以上三个关键点的平衡。 第一个“1”代表的是标准化的课程: 这个课程将会以“图文”、“视频”或“音频”形式进行展现,让学习者完成自学。这个“1”满足了碎片化学习的随时随地性,给予了学习者最大限度的自由度,同时也满足了由于学习规模日益扩大而带来的各种问题。 第二个“1”代表的是在线训练营: 就是把线下的集中学习模式搬到线上,开展一段时间的在线集中训练,通过学习流程、学习氛围、学习评估的三位一体运作,从而使学习效果得以达成。总体做法是把所有参与训练营的学员,分成不同的小组,然后配定相应老师。每个学员在学习过程中都需要完成相应作业,然后交给老师进行批改和点评。整个训练营阶段会有实战练习,根据学习者的自身情况或使用真实的案例来进行实战,进而帮助学习者实现学以致用。同时,老师需要对每个学习者进行阶段性评分,在学习完成时要进行总体评估;老师还需要针对小组学习效果进行展示和相应氛围的营造。 最后一个“N”则代表通过科技的力量,实现多种教学辅助手段 :比如老师和学员之间的交互,老师批量布置和批改作业,对学员进行评分和评估,给积极学习者一些奖励等等,所有这些辅助手段,将会让老师和学员之间的交互更紧密,让学习效率更高、让学习流程更完整、学习模式更人性,从而最终达到提升学习效果的目的。

趋势三:更多垂直领域的小KOL将通过工具类平台输出产品

垂直领域的小KOL,他们标签清晰、价值点明确,用户深度认可,反倒更容易突围,甚至可以脱离平台,通过好学豚这类工具就可以完成知识店铺的搭建。而且因为他们有很强的用户沉淀能力,所以也更有动力参与。长期来看,小KOL通过线上虚拟产品输出知识将会成为一个新的趋势。

趋势四:一线城市受众有限,二、三线城市女性用户价值开始受到重视

一线城市对于知识付费产品的承载能力是有限的,据观察主流付费用户都不是一线白领,而是二三线城市的宝妈。她们更愿意在知识上付钱,也愿意分享课程,赚取一定分成。 互联网独角兽的发展规律一再地证明二三线城市的女性是主力消费群体,知识付费也不会逃离这个规律,现在很多母婴类课程、女性个人提升课程、陪伴式励志课程、情商人际关系类课程销量都很不错。这是一个巨大的市场空窗,目前市场上的多数知识付费平台还忽视了二三线城市的用户,电商平台和垂直大号也还没有真正介入这一行业,这个空窗很有可能下一家知识付费独角兽企业崛起的机会。 针对以上趋势,好学豚知识付费平台也正在进行多方面的尝试,希望能把握住这些新的市场机会迎来爆发性增长,对于当下的知识付费从业者来说既是一个巨大的挑战,也是突围而出成为新独角兽企业的机会。 原文:https://www.iyiou.com/p/104850.html

技术杂谈

首先,这篇文章不是一个广告。 但你可能已经要被这个时代无处不在的洗脑广告逼疯了。 大街小巷贴满的纸皮广告、商场和电梯里循环播放的电子屏广告、电脑上避不开关不掉停不了的网页广告…… 某招聘软件和某旅拍公司的循环怒吼式视频让人生理性地记住了它,但心理上可能想把相关人员关电梯里让它们把这条广告看一万遍。 ▲ 图片来自:「伯爵旅拍」广告截图 广告的形式不断在变化,从广播、平面、视频,到 H5、直播,部分广告会以新颖的形式和优质的内容取胜,但人们看广告一直都是被动接受的过程。 上周蜘蛛侠上映,索尼在 Snapchat 上 推出了 AR 活动宣传 ,为了贴合「英雄远征」的主题,索尼让蜘蛛侠「出现」在世界各地的地标处,比如纽约的熨斗大厦、伦敦的白金汉宫、巴黎的艾菲尔铁塔,只要你将自拍镜头对准建筑,就可以看到蜘蛛侠在屏幕上摆动。 ▲图片来自:mobilemarketingmagazine 它让人们愿意看蜘蛛侠的广告,同时也让广告成了一种主动参与的过程。 接下来要讲的,就是未来这一种我们会主动点开的——VR 广告。

当广告变成了一种「真实」的体验

我们看现在的广告,有一种潜意识默认的原则,就是注意力只给它们 3 秒钟。 但现在的 VR 广告,是给你无限的时间,让你自己去体验。 AR 火起来,还是被 2015 年 Snapchat 的搞怪 AR 滤镜,以及 2016 年的神奇宝贝 GO 带动,这也改变了市场营销对 AR 的看法,让 AR 开始在广告界生根发芽。 宜家是首批尝试购买 AR 技术并以此宣传的公司之一。 在 IKEA Place app 中 ,人们可以看到新款家具实际摆放在家里的样子,以判断它们是不是有足够空间布局放置、颜色风格是否和周围环境搭调,这可以帮宜家提高客户满意度以及降低产品退回率。 随后苹果发布了 ARkit 技术,Google、Facebook、亚马逊等大公司也在 2017 年和 2018 年期间发布了自己 AR 软件开发套件,以支持日后 AR 技术的发展。 Facebook 去年就为广告商提供了 展示产品的新方式 ,让人们在新闻流中看到品牌广告时,能够通过前置摄像头对比自己戴上太阳镜等配饰、甚至穿各种衣服的样子。现在 Facebook 也一直在通过各个平台包括 Instagram 扩大购物范围和增值产品。 除了家居服饰,美容时尚等行业也开始尝试利用 AR 营销,主要是为了弥补线上和线下的差距。 两周前,人们已经能在 YouTube 上一边看美妆博主的化妆教程,一边在分屏之下跟着博主一起涂口红试色,这个名为 AR Beauty Try-On 的功能 由 Google 推出,M・A・C 是第一个跟上这项 AR 广告的品牌。测试结果发现,人们愿意花费超过 80 秒的时间去体验虚拟唇膏色调。 甚至当你逛淘宝店,在前几周 天猫旗舰店升级的 2.0 版本,能直接让你像逛线下实体店一样逛线上 3D 商店、参观漫游,每家店铺展示内容也因人而异。 而且,你还可以实地进入 AR 广告之中。 在汽车行业,宝马、保时捷、丰田等汽车品牌都成为了 AR 广告的追随者。 丰田上个月给 2020 年卡罗拉 推出了 AR 移动广告 ,人们可以 360 度全方位观看车内视图和功能,以及固定在一个地方的 3D 数字模型,甚至还可以在车内行走。这能让购买者更简单地了解车辆的技术特性,也有助于在购买车辆前对其预先认证。 这种自然而直观的方式,不仅让人们和品牌建立起更真实的关系,与以往广告最大的不同就是,人们还能随时参与、持续互动,并响应内容。

我们可以拿回看广告的主动权

现在,我们自己可以掌控广告。 目前我们 能看到的 AR 广告 ,主要包括被赞助的拍照滤镜镜头、应用软件和游戏内的 AR 广告单元,信息流内的 AR 广告单元等。 之前是广告「邀请」我们看,没有任何协商。但现在我们「侵入」广告,去满足自己的好奇心。广告变成了一个工具,我们通过它与我们喜欢的品牌获得联系。 这种形式现在也让 AR 广告成为当今市场上破坏性最小的广告单元之一。 ▲ 图片来自:unsplash 另外,AR 广告已经不再是单独的一个内容,而是进入我们的真实生活,或者进入其它我们沉浸的内容之中——广告本身就是商品的展示位,不会再有其它如美化特效、夸张视觉等因素插入和干扰。 而且这些 AR 广告最初打开的阀门,就掌握在消费者手上。 因为 AR 广告现在都需要申请对其相机/设备的访问,在非相机优先的应用中,也需要申请特定权限,因此你完全可以选择你需要的内容,并与之开始进一步互动。 ▲ 图片来自: Cyber Infrastructure 可以说,AR 就是有史以来最强大的故事叙述媒体。它的互动带来的「响应式特色」,就是它最大的优势。 Unity 技术的 AR / VR 广告创新 负责人 Tony Parisi 说 : 响应性 AR 广告是一种更加友好,不那么令人生畏的方法,这些广告能够提高客户的选择率,同时通过控制权来提高用户的舒适度。 尽管 AR 广告现在还不足以吸引大多数用户,人们似乎更觉得这只是消费者的一种自我意淫。 但实际上幸运的是,现在它背后有一批最大的科技公司在支持和推动,这就让更多设计师有机会去创造更好的 AR 广告环境,更多品牌有空间去施放更大的营销活动。 Google、苹果、Facebook 这样的大平台仍然主导着 AR 领域及其营销的发展。这些科技公司还和零售商 组成了一个小组 ,为用于增强现实购物的 3D 图像创建一套通用标准。 ▲ Google 推出名为 Swirl 的沉浸式显示,消费者可以 360 度查看、旋转、放大、缩小商品 在这个背景之下,市场研究公司 Markets and Markets 的数据 预计,到明年 AR 将拥有 10 亿用户,到 2022 年将达到 22 亿美元的广告支出。 AR 广告进入我们的生活将会更加快速,而我们掌握的主动权也会越来越多,正如以色列最大的互联网公司 IronSource 首席设计官 Dan Greenberg 所认为的 : 我们不断研究如何在广告中为用户提供尽可能多的选择的方法。因为给用户提供更多控制权,才是创建真正个性化移动体验的关键。

AR 会是下一个广告形态

数字广告总体上正朝着更丰富、更身临其境、更多选择的方向发展,消费者正在被置于互动体验的中心。 根据 Gartner 的估计 ,随着 5G 高速移动服务的推出,明年它将推动 AR 购物增长到 1 亿消费者。 ▲ 图片来自:unsplash 因为在高速运行的网络下,开发者不仅能够创造更高质量的 3D 可视化技术,消费者也能获得更多形式的沉浸式体验,品牌也能将其购物平台更多扩展到商店和传统网站之外的移动设备上,而且广告也不会再只限于移动设备的小屏幕。 还可以预见的是,5G 网络之下数据信息密度也会迅速加大,除了文本,还有图像、视频,人们接受的购物信息无疑更广、更深、更繁杂,而 AR 能够降低广告的感知成本,让人们消费数字内容将更简便和直观。 ▲ 图片来自:unsplash 但现在 AR 广告还未普及的主要问题,就在于它的成本和技术。 比起其他广告形式来说,其实它的投入成本并不低。Digiday 指出 单一的广告体验 可能需要花费 5,000 到 30,000 美元甚至更多才能正常开发,投资回报率也很难衡量。 而近阶段虽然 AR 技术发展很快,但 5G 手机还刚推出第一批,5G 网络也并未稳定普及,这也是影响 AR 广告现在扩大规模的关键因素——没有与之匹配的设备。另外,比起其它开发多年的其它形式的广告,AR 广告迭代和优化也还不太灵活,并非广告商们信手就可捏来。 ▲ 图片来自:unsplash AR 热潮刚刚开始真正起飞,我们现在看到的只是关于 AR 能带来什么以及它能做些什么的一瞥。不过苹果 2017 年推出 ARKit 时,首席执行官蒂姆・库克在 财报电话会议上就指出 AR 将成为「主流」: 虽然我们现在还只触及冰山一角,但它将永久改变我们使用技术的方式。 而这个未来正在靠近。Digi-Capital 预计在未来五年内 AR 广告的市场价值将达到 830 亿美元。而且下一代移动设备,还会为我们带来更多新的可能性。 折叠手机的柔性屏幕将能让 AR 广告拥有更多形式;内置投影仪的手机,能让 AR 广告用更大的位置呈现,当广告变成了一种看电影的方式,这对广告商也意味着有更多的空间来宣传。 ▲ 图片来自: satzuma 但无论何种新的形式,接下来,当我们沉迷于自己所选的 AR 广告体验中无法自拔,会不会更加停不下来「买买买」呢? 我开始觉得这是一篇关于 AR 的沉浸式广告了。 原文:https://www.ifanr.com/1230440

技术杂谈

阿里作为中国互联网企业的标杆,不仅一直在为广大人民群众提供快捷便利的服务。也不断引领行业,在各个专业领域,给专业人员提供了优质的工具与服务。对设计师而言,无论是每年一度的 UCAN大会,还是 Kitchen、语雀、Ant Design 等设计开发工具,都给广大设计师带来很大的便利。 而最近,阿里又推出了一款基于 Lottie 的动效设计平台 ── 犸良。能够快速生成设计师想要的动态效果,并交付给开发,极大地提高了设计效率和设计还原度。

犸良是什么?

它是一个积累了很多通用动效素材的平台,让不会动效的同学们也能基于动效库和素材库快速生成一个通用动效创意,你只需要简单地编辑图片、颜色或者文字即可。同时平台集成了以 Lottie 为代表的动效技术,让曾经令人苦恼的安装包大小和性能问题一并解决。 △ 官网: https://design.alipay.com/emotion

犸良的应用场景

犸良支持全平台 iOS、Android、H5、小程序。无论是营销展位、活动页面、空状态还是产品icon。犸良编辑器对接投放平台,一站式完成动效创意制作和投放。 丰富的动效素材能够满足多种场景需要。

犸良怎么用?

方式一:基于模版直接制作(适用于设计师进行动态banner制作)

  1. 选择模版
  2. 从动画仓库选择动画进行当前动画的替换
  3. 通过替换图片或修改颜色来自定义动画
  4. 自定义模板文字内容
  5. 选择模板背景图片
  6. 完成编辑选择是否带背景(banner模版默认带背景)
  7. 导出成功下载 json

方式二:自定义画布制作(适用于设计师进行模板覆盖不到的动效应用场景)

使用犸良一分钟做出来的效果,大家感受下:

体验感受

综合体验了一下犸良,相对于鹿班人工智能随意撸图。猛良其实是一款基于人工的动效模板网站。所有的动态效果都是设计师提前做好的。基于 Lottie 的特性,将可编辑的属性进行模块化。 说到这里有些设计师可能有疑问了,Lottie 究竟是什么?在优设已经有相关文章介绍的比较全面了,这里就不赘述了,阅读链接: http://www.uisdc.com/tag/lottie/ 。 简言之 Lottie 就是一个快速将 AE 中设计的动效快速调出,并且开发可以直接调用的动效库。 Lottie 的原理是将矢量图形和动态效果通过代码实现,复杂的图形导出图片资源。最终生成 json 文件+图片资源。犸良所做的就是,将 json 里的颜色属性提取出来,用户可以从外部修改。同样只需要替换图片资源,就可以让用户自己设计的图形根据 json 写好的运动方式来展示。如果你把一只移动的猪替换成猫了,那么你看到的效果就是一只移动的猫。 而它的文字编辑功能,我觉得就比较神奇了。因为 Lottie 貌似不能从外部修改字体,应该是阿里自己写的。如果有知道的同学也欢迎解惑。因为我们在实际工作中经常遇到这样的问题,就是如果动效包含文本了,需要适配国际化,就没法从外部修改语言。如果能解决这个问题,将会减少很多麻烦。 从现有的素材来看,其主要应用场景还是电商和运营方面。相信未来会增加更多的素材和应用场景。希望以后能够开放出让设计师可以将自己做的动效分享出来供大家使用,或者提供优质付费内容也是不错的选择。 就个人的工作经验来看,Lottie 还是有丰富的应用场景的。比如可以做动态图标、动态闪屏页、表情贴纸、直播礼物、空白页、动态banner…… 其实 Lottie 官方社区也有很多的动效资源,都是来自全世界的设计师上传的,大家可以去下载参考,如果要商用,可能需要联系作者。 △ 附上链接: https://lottiefiles.com/popular 本文资料,很多都是来自语雀的分享。再次感谢相关人员的辛勤付出。大家可以在此处查看犸良的完整介绍: https://www.yuque.com/dapolloali/news/ui43vg 。 再次附上官网: https://design.alipay.com/emotion 因为犸良是基于 Lottie 的工具,所以有相关基础的设计师很容易上手。还不了解的同学也可以在各大设计网站搜索到。 欢迎关注作者的微信公众号:「懿凡设计」 本文原文:https://www.uisdc.com/alibaba-emotion

个人随笔

前几天,公众号「人工智能爱好者社区」的负责人书豪对我进行了一次采访,问了我一些问题,其中有些问题确实有深度,我在此一一进行了作答,这里将其记录下来。

当前觉得自己最不可替代性或最有优势的劳动价值在哪里?

有一种不断追求更优的心吧。 作为一名程序员,就拿写代码来说,比如做一个项目,我会把自己看作是负责人,以负责人的心态来做这件事情,自己主动有这份心去把这件事做好,而不是别人分配给我我做完就应付完事了。 在做项目过程中我会一直想着自己怎么实现是更优化的,如架构怎样设计扩展性更好,算法模块怎样效率更高,用户怎样使用会更方便。另外我一般会从多个角度来优化自己的项目,比如站在产品角度思考这么实现的用户体验,站在程序员角度思考更好的技术方案,站在设计角度思考布局交互。 此外就是感觉自己有点强迫症,如果某件事做不完,会一直在心里挂念着,直到把这件事完成。

觉得自己最牛逼的能力是什么?

不能说是牛逼的能力,因为我觉得我并没有什么牛逼的能力。我只能说有一些习惯或特性会对完成一件事情或达成一个目标更有帮助。 比如在学习的时候我会有做记录的习惯,学习的时候我会把我理解到的东西记录下来,然后学习完毕之后会把记录和理解的知识点归纳和总结一下,在这个过程中确实会需要对整个知识点进一步的思考,确实是非常有帮助的。 另外一个或许就是自控能力吧,我不敢说自己的自控能力是多么强,因为我自己确实也有时候是什么都不想做的。不过当自己一旦开始做一件事的时候,我会控制自己不去做的无关的事情,比如编写一个程序我可能一天到晚都会专注于这件事情,在没有完成的时候总觉得心里有件事没完成,于是乎就会继续干下去,可能熬个通宵也会想把它做完 ,这可以总结为一旦开始了就不容易停下。但自己也有个缺点,就是有时候就是觉得一件事麻烦不想开始做,不容易开始。所以我也在改正这个缺点。如果做事情的时候能够容易开始、持续专注,那就更好了。 另外就是上文提到的,自己有个追求完美的心。比如做一件事的时候会想追求完美和极致,一旦某处觉得不是很切合自己的想法,我会想办法把它变得更好一点。但我也不知道这样是不是好的,因为可能有些时候会把自己搞得比较累,但是事情的结果总归是相对好一点的。

工作以来,经历的困难是什么?如何面对的,是如何爬出这段艰难的处境的?

我正式工作只有三个月,不过工作之前已经在微软实习了一年多的时间了。 我自己有个特点就是不想给别人添麻烦,即使自己的事情压得太多,承担太多,也不想给别人添什么麻烦。所以很多时候我会把一些事情扛在自己身上,也不容易去拒绝一些事情,所以之前有时候我会同时并行着非常多的事情。 比如某个同学让我帮忙做点事情,我会答应并把它加到自己的待做清单里面;然后导师、领导又同时安排了一些事情;然后同事又让我帮忙做点事情。好,我基本都会答应然后去处理,即使真的自己压的喘不过气来了,也不想去再找另外的人帮忙或者推脱掉。直到有一天,我累得生病了,发了好几天烧也不舒服,很多事情也没有完成。 有一次我去找我当时在微软的宋老师交流的时候,她告诉我了一些方法。其实我这种做法是不对的,一些事情没有必要一定会去答应和接受,并把所有的责任都扛在自己身上,一些事情要学会拒绝,并去主动和别人确定好事情的重要程度、紧急程度,以及你真的有没有必要去帮某些人做某些事情。时间是自己的,要学会合理安排自己的时间,如果遇到新来的事情,要跟对方说清楚,比如我正在忙,你的事情我可能得晚几天才能帮你。另外遇到突发的情况或者实在让自己为难的事情,就多去跟对方商量,确定下事情的紧急程度、重要程度,然后再去合理分配自己的时间。后来我尝试做出了一些改变,一些事情不再无条件强加到自己身上,并会主动与对方沟通情况以把握好处理每件事情的时机,我的心态和生活规律也就慢慢地调整过来了。

你经历过的至暗时刻大致是什么样的,为什么说它是你的至暗时刻,主观能力和客观环境当时是什么样的?

每个人都会经历过很多挫折,并且会在经历挫折的时候由于当时的心智和世界观觉得那个挫折就是无法抵抗的“至暗时刻”,我大学时候被劈腿,高考失利,初中脑炎,甚至小学的时候摔断胳膊和食物中毒昏迷一周,都觉得那是在我能力范围内无法解决的事情了,但实际上我并没有把它们称为是”至暗时刻“。 我在遇到挫折的时候,我会想,两年之后或者十年之后,这个事情对我有没有影响。很多情况下是没有的,所以,它仅仅是我成长历程上的一个小坎,迈过去就好了。很多人在经历所谓“至暗时刻”的时候,总以为自己处在这个黑暗圆圈的最中央最无助最绝望的地方,但至暗时刻其实是一个圆环,你只需要从直径 8 走到直径 10 的地方就可以看到明天和未来,如果这么想的话,大多数的至暗时刻都不值一提了。

你印象中对你影响最深刻的人是哪一位,他给了你什么样的启发和力量?

我小时候其实成长条件还算不错的,父母几乎从来没有打过我骂过我,都是以鼓励的心态来培养我的,其实父母是对我影响最深刻的人,没有他们就没有我的现在。 不过除了父母以外就再提一位让我印象比较深刻的老师吧。之所以印象深刻可能就是因为他是惟一一位打过我的老师,因为人总是能对和平常状态反差强烈的事情印象最为深刻。 当时是高中上晚自习的时候,我没有好好写作业,而是拿着我的学习机在看游戏的图片,正好被老师抓到了,他把我拽出教室,狠狠地打了我的胸口两拳,当时他问过我一句话:“你知道什么是‘慎独’吗?”,我当时沉默了,他接着说:“‘慎独’就是谨慎独行,就是在别人没有监督你的时候,自己能够管住自己做正确的事情。” 从那以后,我便把这两个字一直记在心里,知道什么时候应该去做什么,当我向偷懒或者被某些事情诱惑的时候,我会尽力地控制自己。到现在为止,我个人觉得自己的自控力或专注力还算是比较强了。 真的非常感谢老师当年给我上的这一课。

技术成长最快的一段时间当时面对的是什么样的环境下,为什么说这段时间成长环境最快?

我认为不论是做什么,成长最快的时间基本上就是刚刚接触这个领域的时刻,成长的速度会慢慢地放缓。所以技术上来说,成长最快的时间可以说是我大学刚刚接触技术领域各个方向的时候了。 大学那会儿初步了解了一些编程理念之后,我加入了一个实验室,那会儿实验室分了很多方向,可以说是学校中技术方向最为全面的实验室了,包括前端、后台、安卓、iOS、美工、游戏等等,那会儿还有几个非常厉害的学长带着我们,带我们去学习和探索很多技术上的东西,那会儿就接触了 Git、Web 开发、移动开发、游戏开发,参加了非常多的比赛,做了一系列的外包,搭建自己的博客,分享自己的技术。总的来说学到了很多,真的特别感谢当时带我的几位学长,有了平台,有人带飞,的确是可以飞速成长的。

未来 3 到 5 年的打算是什么?打算如何突破这个打算,量化来说,困难程度有多大?需要的运气成分有多大?

现在我自己来看,我还是一个初出茅庐的小喽啰,不论是技术、金钱、人脉都还是比较薄弱的。我其中一个目标便是能够在生活上立稳脚跟,不为生活上的各种条件所困,娶到我的女朋友并一起过上富足快乐的日子。再长远的目标就是实现各种自由,如财务自由、时间自由。 要达成这个目标的确还是比较有挑战性的,我还需要非常的多的努力。当前初步的打算一定是努力工作,自己的主业上能够稳扎稳打,自己的主要工作必须要做好,从技术等各个层面上提升自己。另外其他的时间我会想办法累积自己的一些软实力,比如提升自己的知名度、积累自己的人脉等等。这个困难没法量化,也几乎不会靠什么运气成分,需要靠自己稳扎稳打地来一步步地做。

如果有天你失业了,会如何面对这个处境,当前的危机在哪里?

其实这个对于来说倒不怕,我本是技术出身,某个时代总是会有当前时代所最流行的技术的。如果但从技术方面看话,我可以不断地去学习和输出,即使是我四十多岁了,我一样可以做一个学习者,那会儿可能拼精力比不过,但那会儿构思能力、总结能力还是不会差的,至于知识的变现,方式就太多了。 但肯定不能仅靠这个来生存,多少还是有一定的压力的。其他的副业一定也是需要发展的,比如理财、投资、写作、服务等多种形式都可以成为经济的来源。所以我个人建议不要把所有的精力都完全压在主业上,推荐适当发展一下副业。

你拿到过最好的工作机会的这段经历自己是如何准备这个过程的?

我拿到的最好的工作机会就是我当前选择的微软了,这边工作氛围我非常喜欢,各种待遇都算不错。 由于我当时是在微软实习,所以最后是通过转正的途径来参加面试的。转正面试我非常非常重视,在工作的同时我提前准备了面试可能问的各个方面的问题。这边面试首先要求基本的算法能力是过关的,这是一个硬性要求,如果这都写不出来可能会被直接 Pass 的,所以当时一直在刷题、看数据结构、算法题等等,确保一些 LeetCode 上面简单和中等难度的题目都能比较稳地做出来。由于我面试的岗位对算法、工程能力都比较看重,所以那会儿还准备了很多机器学习算法、熟悉了相关的机器学习模型,比如逻辑回归、SVM、BP 算法的具体推导过程。对于工程方面这个可能真的要看平时的积累了,由于我之前做过很多的工程类项目,包括网络爬虫、前端开发、后端开发、框架搭建等等,所以简历上的项目还算比较说得过去,面试的时候聊一聊其中的一些理念和架构就好了。 当时面试真的特别重视,所以准备面试的过程也是非常焦虑,整个准备时间一个多月,所以当时可以算是焦虑了一个多月吧,当时也同时在忙公司和学校的项目,可以说压力是非常大的,不过好在取得了不错的结果,得知面试结果的一刻也是舒了一口气,感谢自己曾经的努力。

你觉得自己拥有什么样的缺点,这些缺点,是什么原因和经历造成的,未来的你,会选择什么样的行业和岗位进行跟进突击?

我觉得我个人有个缺点,就是挺多事情不是特别主动。这确实就是自己的性格导致的,我挺多事情并不想去麻烦别人,也不想去占用别人的时间,很多事情愿意靠自己的力量去探索去达成。 在我的成长历程上,挺多事情好像都是别人找到我,比如加好友、比如合作项目,基本都是别人找到我,然后我觉得 OK,就去答应然后做了,目前看来这并不是一件坏事,也对我的生活没有产生一些负面影响。 但是这样其实我无形中失去了一些可能我能力上能触及的更好的机会,比如结交更多朋友,合作更好的项目。所以,以后我也会去求变,去主动结实更优秀的人,去主动找寻更好的机会。 至于行业和岗位的话,我对我目前的行业和岗位是非常满意的,我在目前我的计划里面也会从事当前的行业和岗位的。

对于投资风险你有什么样的理解?你如何打理自己的资金?理财策略是什么?

投资会伴随着风险,这是毋庸置疑的,对于投资的回报,虽然说存在非常多的不可控因素,但风险的大小是可以通过一定的观察、计算、经验来的出来的。不同的投资和操作,其对结果的影响是不同的。 投资我通常和理财挂钩,因为广义上的大额的投资对我来说还是不现实的,目前的我是会在我现有的资金基础上进行适当的理财的。 我会把我的资产划分为四个部分。第一部分是危急时刻可以救命的,这部分是完全不能动的,就存起来,当自己危机的情况下可以取出来保证自己正常的生存的,量的大小的话就按照能满足自己正常生活一年的水平来存就好。第二部分是作为自己的固有资产来存的,比如为了自己将来买房子、车子来做准备用的,这部分基本上就是只增不减的,平时也不会动用这部分的资产。第三部分是满足自己日常花销和生活的,这部分基本上我就放在了支付宝和微信里面,平时买点东西或者正常开支来使用。最后一部分就是用来做理财的,由于我是一个相对比较保守型的,这部分占比不算多,我会拿这些钱去购买一些比较高风险高收益的基金等,观察某个情形出售,然后短期捞利收手再去出手下一个,当然赚的还不多,也有些情况下由于判断失误就亏了不少。不过这部分的盈亏我自己都是可以接受的,不会影响我的正常生活。 总的来说,我个人建议适当划分一下自己的资产及分配,然后在自己可承受的范围内适当去理财和投资。当然如果钱多的话当我什么都没有说哈。

庆才是个有感情经历的人,我想问你,对于男人寻找结婚伴侣或者找女朋友,你觉得最需要对方什么样的特质,有哪些变量是必须要的,有哪些变量是可以剔除的?

对于伴侣的选择,我个人觉得性格三观合得来、脾气好是非常重要的。因为性格和脾气基本上就是与生俱来的东西,这个是非常难改的。如果两个人性格上合不来,比如很多事情上处事态度不同、三观不一致,每次产生冲突的时候是会感觉非常累的,另外如果脾气上两个人都控制不好,事情可能会进一步变得艰难。 另外相貌其实也挺重要的,我不得不承认自己确实也是一个看脸的人,而且看得还蛮重的。这不仅仅是为了自己觉得开心和舒服,也有一部分会为后代考虑吧。 所以有一句名言说的好,相貌决定了你愿不愿意去了解一个人内心,而内心决定了你会不会一票否决这个人的样貌。

你的第一桶金是如何获得的,是自己,还是团队一起,这件事的主观能力方面当时的难度有多大,客观上,你创造了哪些条件达成它?

第一桶金如果不论大小的话,那就是大学时候和实验室的同学一起做外包赚到的,几千到上万不等,做过不少项目,具体记不太清了。 完成这些项目,大部分是技术上的突破,当时我更多是以技术开发的身份参与到其中的,由于当时对相关的技术栈还算比较了解,所以当时并没有觉得实现难度上有多大,不过当时也非常感谢实验室和学校能提供对接的平台,让我们的技术能力得以更好地发挥。

当下,你最担心什么,最害怕什么,为何会有这个恐惧,你会如何破局?

思来想去没想到有什么担心和害怕的,我目前对自己的生活还是比较喜欢的,另外我也对将来的生活充满信心。不过我现在并没有什么心思会考虑将来会出现什么让我担心和害怕的事,因为可能这些事情的出现都是不可预料到的吧。 所以,与其去担心这些事情,还不如着眼当下,去努力拼搏。

你觉得自己在商业能力上与技术能力上,哪个更有优势,为什么?

现在肯定还是技术能力上更有优势,因为自己目前的定位就是技术为主的工程师,我目前赖以生存的能力就是自己的技术。商业能力上,我并没有什么经验,人脉也不算广,当然这肯定也是我想扩展的一个方向之一。

平时工作中会用到哪些算法,在算法方面的能力你是如何突破的?

现在工作做的事情涉及的方向比较多,包括前端、后台、机器学习、图像处理、自然语言处理等技术,爬虫是我自己的附加职责了。 算法方面,自然语言处理方向涉及较多。由于我更加专注于产品和落地,所以会更偏向于使用一些实用和更有效率的算法,保证上线和实际使用的真实效果。另外对于一些前沿的模型,可能和大多数搞科研的同学一样,我也会去搜索当前前沿的论文来看,找找开源代码,然后尝试对接实现,不断迭代调整自己的算法。

你做过最成功最有成就感的一件事是什么?

应该就是写出来了一本爬虫相关的书籍,并帮助了很多人。 我研究爬虫时间不短了,当时图灵的王编辑找我约稿的时候我还是非常激动和欣喜的,当时正好也想借机把自己学过的爬虫知识系统地做一个梳理,写书的过程很辛苦,不过后来顺利出版了,现在销量也已经远超我的预期。 现在还有不少读者加我,说看了我的书收获很大,有的读者还是看了我的书顺利转行爬虫并找到了工作,听到这些消息,真的非常非常有成就感,希望我的书对读者有帮助。

你做股票投资亏损最严重的经历是什么样的,亏损的原因可否总结?

我没做过股票,只做过一些高风险高收益的基金投资,亏损倒不严重,都在我自己的可控范围之内。 亏损的一些原因可以稍微归结下,一个就是自己对市场的把握程度不够,有时候听别人说好,或者看着短期的势头不错就直接出手买了。另外就是自己平时工作忙,无暇分配那么多的时间来关注市场行情的变化,结果导致下次看到的时候,已经跌到没法看了。

你对于人性是什么样的看法,可以结合自己的工作经历或者投资经历谈谈。

人性这个东西非常复杂,这个看法就太宽泛了。 体会最深的一点就是,不要试图去改变一个人,很多都是天生的,难以更改的。改变可能仅仅发生在某些重大变化和突发情况下,或者从小的教育。所以,对于我们日常生活和工作来看,一个人三观不合,或者性格不合,还是不要去修正了,这太难了。所以,我的选择是对于这样的人,少去接触、少去交流、少去合作。不过好在我现在几乎没有碰到多少难以交流和合作的人,但一旦有,我还是会持有那样的态度的。毕竟让一个自己不认可的人来浪费自己的时间是非常亏的。

人生中有无数的风险,为规避人生的种种风险,你做了什么?

风险我主要就考虑两个方面了,一个是健康风险,一个是资金风险。 健康风险,对于我这一行,可能听得最多的就是程序员过劳猝死了。其实我也挺害怕的,有时候我也会熬夜,有时候工作强度也很大,有时候也会久坐不动。所以我会控制自己的时间,比如坐的时间长了就起来活动一下,注意波保护自己的颈椎腰椎等等,另外定期体检,看看自己身体有什么不良状况及时整治。佛祖保佑,我是不会猝死的好不好。 资金风险,这个是上文所提到的,主业技术方向,需要保持一个终身学习能力,靠自己的技术能力通过多种方式变现。另外也得注意发展一下副业,比如投资、理财、写作等等方向,并且要培养一个可以累积和增长的能力,比如写作,其水平就是慢慢累积的,而且是持续可增长的,这种就比较稳了。总之建议大家多个方向都去尝试一下,不要把鸡蛋放在同一个篮子里。

你是如何追到这么优秀的女朋友的?

关于这个问题我是比较困惑的啊,为什么一定是我追的不能是她追的我或者是互相喜欢呢?当然,事实上,确实算得上我“追”她,不过此追非彼追。我觉得很多人之所以觉得追女孩子困难,是因为大家追的不是这个女孩子跟自己脾气性格符合,而是追的漂亮,这样一来自然会容易觉得聊天总陷入死路,如果换个想法,只是寻找和自己脾气性格符合能够开心的在一起的(当然最好还能漂亮一点),在一起就非常顺其自然啦。所以我女朋友经常说我们之间不存在我追她或者她追我,因为两个人接触之后就觉得应该在一起啦~ 最后欢迎大家关注「崔庆才」的个人公众号「进击的 Coder」

Python

本文转载自:陈文管的博客-微信公众号文章爬取之:微信自动化 本文内容详细介绍微信公众号历史文章自动化浏览脚本的实现,配合服务端对公众号文章数据爬取来实现微信公众号文章数据的采集。服务端爬取实现见:微信公众号文章爬取之:服务端数据采集。 背景:在团队的学习方面需要每周收集开发方面的博客文章,汇总输出每周的技术周报。周报小组成员收集的文章大多数是来自微信公众号,公众号的内容相对网页博客内容质量还是比较高的。既然数据的来源是确定的,收集汇总的流程是确定的,那么就把这个流程自动化,把人工成本降低到0。

一、方案选取

1、数据源选取

主要是爬取的数据来源选取,网上资料看的较多是爬取搜狗微信的内容,但是第三方平台(包括新榜、清博等 )的公众号文章数据更新做不到实时,而且数据也不全,还要和各种反爬措施斗智斗勇,浪费时间精力的事情划不来。最直接的方式当然是直接爬取微信公众号历史文章里面的内容。 在前期预研主要参考的资料是知乎专栏:微信公众号内容的批量采集与应用 。 上面的方案是借助阿里巴巴开源的AnyProxy工具,AnyProxy作为一个中间人在微信客户端和服务器之间的交互过程中做数据截获和转发。获取到公众号文章的实际链接地址之后转发到自己的服务器进行保存,整个数据采集的自动化程度较大取决于微信客户端的自动化浏览实现。

2、自动化方案选取

如果是比较简单的安卓应用自动化操作的实现,一般直接使用AccessibilityService就行,UIAutomator也是基于AccessibilityService来实现的,但是AccessibilityService不支持WebView的操作,因为微信公众号历史文章页面是用WebView来加载的,要实现自动化必须同时支持安卓原生和WebView两个上下文环境的操作。 经过现有的几个自动化方案实现对比,最便利又具备极佳扩展性的方案就是使用Appium

  • Appium是开源的移动端自动化测试框架;
  • 支持Native App、Hybird App、Web App;
  • 支持Android、iOS、Firefox OS;
  • 跨平台,可以在Mac,Windows以及Linux系统上;
  • 用Appium自动化测试不需要重新编译App;
  • 支持Java、python、ruby、C#、Objective C、PHP等主流语言;

更多资料参考:Android自动化测试框架

公众号文章爬取系统架构图

公众号文章爬取系统架构图

二、Appium安装配置(Mac)

Appium程序的安装,我这边不是使用brew命令安装的方式,直接从BitBucket下载Appium安装包,也可以从Github上下载。这边使用BitBucket 1.5.3版本。 Appium1.5.0之后的版本,需要在终端安装doctor,在终端输入命令:npm install -g appium-doctor,安装完毕之后,在终端输入命令:appium-doctor,查看所需的各个配置是否都已经安装配置完毕。下面是我这边在终端输出得到的结果:

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
info AppiumDoctor Appium Doctor v.1.4.3
info AppiumDoctor ### Diagnostic starting ###
info AppiumDoctor  ✔ The Node.js binary was found at: /Users/chenwenguan/.nvm/versions/node/v8.9.3/bin/node
info AppiumDoctor  ✔ Node version is 8.9.3
info AppiumDoctor  ✔ Xcode is installed at: /Library/Developer/CommandLineTools
info AppiumDoctor  ✔ Xcode Command Line Tools are installed.
info AppiumDoctor  ✔ DevToolsSecurity is enabled.
info AppiumDoctor  ✔ The Authorization DB is set up properly.
WARN AppiumDoctor  ✖ Carthage was NOT found!
info AppiumDoctor  ✔ HOME is set to: /Users/chenwenguan
WARN AppiumDoctor  ✖ ANDROID_HOME is NOT set!
WARN AppiumDoctor  ✖ JAVA_HOME is NOT set!
WARN AppiumDoctor  ✖ adb could not be found because ANDROID_HOME is NOT set!
WARN AppiumDoctor  ✖ android could not be found because ANDROID_HOME is NOT set!
WARN AppiumDoctor  ✖ emulator could not be found because ANDROID_HOME is NOT set!
WARN AppiumDoctor  ✖ Bin directory for $JAVA_HOME is not set
info AppiumDoctor ### Diagnostic completed, 7 fixes needed. ###
info AppiumDoctor 
info AppiumDoctor ### Manual Fixes Needed ###
info AppiumDoctor The configuration cannot be automatically fixed, please do the following first:
WARN AppiumDoctor - Please install Carthage. Visit https://github.com/Carthage/Carthage#installing-carthage for more information.
WARN AppiumDoctor - Manually configure ANDROID_HOME.
WARN AppiumDoctor - Manually configure JAVA_HOME.
WARN AppiumDoctor - Manually configure ANDROID_HOME and run appium-doctor again.
WARN AppiumDoctor - Add '$JAVA_HOME/bin' to your PATH environment
info AppiumDoctor ###
info AppiumDoctor 
info AppiumDoctor Bye! Run appium-doctor again when all manual fixes have been applied!
info AppiumDoctor

上面打叉的都是没配置好的,在终端输入命令安装Carthage :brew install carthage

输入命令查看JDK安装路径:/usr/libexec/java_home -V

1
2
1.8.0_60, x86_64: "Java SE 8" /Library/Java/JavaVirtualMachines/jdk1.8.0_60.jdk/Contents/Home
/Library/Java/JavaVirtualMachines/jdk1.8.0_60.jdk/Contents/Home

需要把上面的路径配置到环境变量中,ANDROID_HOME就是Android SDK的安装路径。

输入命令打开配置文件: open ~/.bash_profile,在文件中添加如下内容:

1
2
3
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_60.jdk/Contents/Home  
export PATH=$JAVA_HOME/bin:$PATH  
export ANDROID_HOME=/Users/chenwenguan/Library/Android/sdk

输入命令让配置立即生效:source ~/.bash_profile

更多安装配置资料可参考:Mac上搭建Appium环境过程以及遇到的问题

TIPS

在首次使用Appium时可能会出现一个错误:

1
Could not detect Mac OS X Version from sw_vers output: '10.13.2

在终端输入命令:

1
grep -rl "Could not detect Mac OS X Version from sw_vers output" /Applications/Appium.app/

得到如下结果:

1
2
3
4
/Applications/Appium.app//Contents/Resources/node_modules/appium-support/lib/system.js
/Applications/Appium.app//Contents/Resources/node_modules/appium-support/build/lib/system.js
/Applications/Appium.app//Contents/Resources/node_modules/appium/node_modules/appium-support/lib/system.js
/Applications/Appium.app//Contents/Resources/node_modules/appium/node_modules/appium-support/build/lib/system.js

打开上面四个路径下的文件,添加当前的Appium版本参数,具体内容可参考:在Mac OS 10.12 上安装配置appium

三、具体代码实现

预研资料主要参考这篇博文:Appium 微信 webview 的自动化技术 自动化实现的原理就是通过ID或者模糊匹配找到相应的控件,之后对这个控件做点击、滑动等操作。如果要对微信WebView做自动化,必须能够获取到WebView里面的对象,如果是Android原生的控件可以通过AndroidStudio里面的Android Device Monitor来查看控件的id、类名等各种属性。

1、Android原生控件属性参数值的获取

在AndroidStudio打开Monitor工具:Tools->Android->Android Device Monitor 按照下图的步骤查看控件的ID等属性,后续在代码实现中会用到。

Android Device Monitor

Android Device Monitor

2、WebView属性参数值的获取

如果是在安卓真机上,需要打开WebView的调试模式才能读取到WebView的各个属性,在微信里面可以在任意聊天窗口输入debugx5.qq.com,这是微信x5内核调试页面,在信息模块中勾选打开TBS内核Inspector调试功能。

微信X5内核调试页面

微信X5内核调试页面

之后还要在真机上安装Chrome浏览器,如果是在虚拟机上无需做此操作。 接下来在Chrome浏览器中输入:chrome://inspect ,我这边使用的是虚拟机,真机上也一样,进入到公众号历史文章页面,这边就会显示相应可检视的WebView页面,点击inspect,进入到Developer Tools页面。

chrome inspect页面

chrome inspect页面

如果进入到Developer Tools页面显示一片空白,是因为chrome inspect需要加载 https://chrome-devtools-frontend.appspot.com 上的资源,所以需要翻墙,把appstop.com 加入翻墙代理白名单,或者直接全局应用翻墙VPN,具体可参考:使用chrome remote debug时打开inspect时出现一片空白 下面是美团技术团队历史文章列表的详细结构信息,具体的文章列表项在weui-panel->weui-panel__bd appmsg_history_container->js_profile_history_container->weui_msg_card_list路径下。

Chrome inspect查看WebView详细内容

Chrome inspect查看WebView详细内容

继续展开节点查看文章详细结构信息,这边可以看到每篇文章的ID都是以“WXAPPMSG100″开头的,类名都是“weui_media_box”开头,一开始的实现是通过模糊匹配ID来查找历史文章列表项数据,但在测试过程中出现来一个异常,后来发现,如果是纯文本类型的文章,也就是只有一段话的文章,它是没有ID的,所以不能通过ID来模糊匹配。

公众号历史文章列表项详细结构

公众号历史文章列表项详细结构

之后就把现有的四种公众号文章类型都找来出来,找它们的共性,虽然ID不一定有,但是class类型值一定有,四种类型值如下,这样就可以通过class类型值来匹配查找数据了。

1
2
3
4
 * weui_media_box appmsg js_appmsg : 文章
* weui_media_box text js_appmsg : 文本
* weui_media_box img js_appmsg : 图片
* weui_media_box appmsg audio_msg_primary js_appmsg playing : 语音

3、具体代码实现

整体自动化是按照如下顺序:通讯录页面->点击公众号进入公众号列表页面->公众号列表项选择一个点击->公众号页面->公众号消息页面->点击“全部消息”进入公众号历史文章页面->根据设置的时间类型(一周之内、一个月之内、一年之内或者全部)逐个点击历史文章列表项,完毕之后返回公众号列表页面,继续下一个公众号浏览的操作;

1)初始化
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
private static AndroidDriver getDriver() throws MalformedURLException {
        DesiredCapabilities capability = new DesiredCapabilities();
        capability.setCapability("platformName", "emulator-5554");
        capability.setCapability("platformVersion", "4.4.4");
        capability.setCapability("deviceName", "MuMu");
        /**
         * 真机上platformName使用"Android"
         */
        /*
        capability.setCapability("platformName", "Android");
        capability.setCapability("platformVersion", "6.0");
        capability.setCapability("deviceName", "FRD-AL00");
        */
        capability.setCapability("unicodeKeyboard","True");
        capability.setCapability("resetKeyboard","True");
        capability.setCapability("app", "");
        capability.setCapability("appPackage", "com.tencent.mm");
        capability.setCapability("appActivity", ".ui.LauncherUI");
        capability.setCapability("fastReset", false);
        capability.setCapability("fullReset", false);
        capability.setCapability("noReset", true);
        capability.setCapability("newCommandTimeout", 2000);
        /**
         * 必须加这句,否则webView和native来回切换会有问题
         */
        capability.setCapability("recreateChromeDriverSessions", true);
        /**
         * 关键是加上这段
         */
        ChromeOptions options = new ChromeOptions();
        options.setExperimentalOption("androidProcess", "com.tencent.mm:tools");
        capability.setCapability(ChromeOptions.CAPABILITY, options);
        String url = "http://127.0.0.1:4723/wd/hub";
        mDriver = new AndroidDriver<>(new URL(url), capability);
        return mDriver;
    }

如果是虚拟机则platformName使用具体的虚拟机名称,如果是真机使用“Android”,platformVersion和deviceName可以使用工程安装APK之后查看详细信息,对应的参数就是显示的系统版本和设备名称。

设备信息

设备信息

URL参数是在Appium里面设置的,确保”http://127.0.0.1:4723/wd/hub”字符串中的服务器地址和端口与Appium设置一致。

Appium URL参数设置

Appium URL参数设置

2)列表滑动和元素获取

不管是WebView还是Android原生ListView的滑动都需要在Android原生上下文环境下操作driver.context(“NATIVE_APP”); 滑动操作都可以通过如下代码实现,通过滑动前后的PageSource对比可以知道列表是否已经滑动到底部。

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
     /**
     * 滑动列表加载下一页数据
     *
     * @param driver
     * @return
     * @throws InterruptedException
     */
    private static boolean isScrollToBottom(AndroidDriver driver) throws InterruptedException {
        int width = driver.manage().window().getSize().width;
        int height = driver.manage().window().getSize().height;
        String beforeswipe = driver.getPageSource();
        driver.swipe(width / 2, height * 3 / 4, width / 2, height / 4, 1000);
        /**
         * 设置8s超时,网络较差情况下时间过短加载不出内容
         */
        mDriver.manage().timeouts().implicitlyWait(8000, TimeUnit.MILLISECONDS);
        String afterswipe = driver.getPageSource();
        /**
         * 已经到底部
         */
        if (beforeswipe.equals(afterswipe)) {
            return true;
        }
        return false;
    }

TIPS: 如果是Android原生的ListView读取到的数据是在屏幕上显示的数据,超过屏幕的数据是获取不到的,如果是WebView的列表获取的数据是所有已加载的数据,不管是否在屏幕显示范围内。 获取公众号列表数据逻辑代码如下,”com.tencent.mm:id/a0y”是具体的公众号名称TextView的ID。

1
List<WebElement> elementList = mDriver.findElementsById("com.tencent.mm:id/a0y");

获取历史文章列表数据逻辑代码如下,div是节点,上面说到公众号四种类型的文章都是以’weui_media_box’类名开头的,通过模糊匹配class类名以’weui_media_box’开始的元素来过滤出所有的公众号文章列表项。

1
List<WebElement> msgHistory = driver.findElements(By.xpath("//div[starts-with(@class,'weui_media_box')]"));
3)元素定位方式

如果一定需要模糊匹配就使用By.xpath()的方式,因为Android APK应用如果有增加或减少了布局字符串资源或者控件,编译之后生成的ID可能会不一样,这边说的ID是指通过Android Device Monitor查看的布局ID,不是实际的布局代码控件id,布局控件id除非命名改动,否则不会变化。所以不同版本的微信客户端生成的ID很可能会不一样,如果要批量实现自动化最好使用模糊匹配的方式,但By.xpath()方式查找定位元素是遍历页面的所有元素,会比较耗时,也容易出现异常。 在测试过程中执行

1
driver.findElement(By.xpath("//android.widget.ImageView[@content-desc='返回']")).click();

时候经常出现如下错误,改为

1
driver.findElementById("com.tencent.mm:id/ht").click();

异常消失,猜测原因就是因为By.xpath()方法查找比较耗时导致。

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
org.openqa.selenium.WebDriverException: An unknown server-side error occurred while processing the command. (WARNING: The server did not provide any stacktrace information)
Command duration or timeout: 1.41 seconds
Build info: version: '2.44.0', revision: '76d78cf', time: '2014-10-23 20:02:37'
System info: host: 'wenguanchen-MacBook-Pro.local', ip: '30.85.214.6', os.name: 'Mac OS X', os.arch: 'x86_64', os.version: '10.13.2', java.version: '1.8.0_112-release'
Driver info: io.appium.java_client.android.AndroidDriver
Capabilities [{appPackage=com.tencent.mm, noReset=true, dontStopAppOnReset=true, deviceName=emulator-5554, fullReset=false, platform=LINUX, deviceUDID=emulator-5554, desired={app=, appPackage=com.tencent.mm, recreateChromeDriverSessions=true, noReset=true, dontStopAppOnReset=true, deviceName=MuMu, fullReset=false, appActivity=.ui.LauncherUI, platformVersion=4.4.4, automationName=Appium, unicodeKeyboard=true, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}, platformName=Android, resetKeyboard=true}, platformVersion=4.4.4, webStorageEnabled=false, automationName=Appium, takesScreenshot=true, javascriptEnabled=true, unicodeKeyboard=true, platformName=Android, resetKeyboard=true, app=, networkConnectionEnabled=true, recreateChromeDriverSessions=true, warnings={}, databaseEnabled=false, appActivity=.ui.LauncherUI, locationContextEnabled=false, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}}]
Session ID: 592813d6-7c6e-4a3c-8183-e5f93d1d3bf0
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at org.openqa.selenium.remote.ErrorHandler.createThrowable(ErrorHandler.java:204)
at org.openqa.selenium.remote.ErrorHandler.throwIfResponseFailed(ErrorHandler.java:156)
at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:599)
at io.appium.java_client.DefaultGenericMobileDriver.execute(DefaultGenericMobileDriver.java:27)
at io.appium.java_client.AppiumDriver.execute(AppiumDriver.java:1)
at io.appium.java_client.android.AndroidDriver.execute(AndroidDriver.java:1)
at org.openqa.selenium.remote.RemoteWebDriver.findElement(RemoteWebDriver.java:352)
at org.openqa.selenium.remote.RemoteWebDriver.findElementByXPath(RemoteWebDriver.java:449)
at io.appium.java_client.DefaultGenericMobileDriver.findElementByXPath(DefaultGenericMobileDriver.java:99)
at io.appium.java_client.AppiumDriver.findElementByXPath(AppiumDriver.java:1)
at io.appium.java_client.android.AndroidDriver.findElementByXPath(AndroidDriver.java:1)
at org.openqa.selenium.By$ByXPath.findElement(By.java:357)
at org.openqa.selenium.remote.RemoteWebDriver.findElement(RemoteWebDriver.java:344)
at io.appium.java_client.DefaultGenericMobileDriver.findElement(DefaultGenericMobileDriver.java:37)
at io.appium.java_client.AppiumDriver.findElement(AppiumDriver.java:1)
at io.appium.java_client.android.AndroidDriver.findElement(AndroidDriver.java:1)
at com.example.AppiumAutoScan.getArticleDetail(AppiumAutoScan.java:335)
at com.example.AppiumAutoScan.launchBrowser(AppiumAutoScan.java:96)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
at org.junit.runner.JUnitCore.run(JUnitCore.java:157)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:117)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:262)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:84)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

如果容易出现如下异常,则是因为页面的内容还未加载完毕,可以通过

1
mDriver.manage().timeouts().implicitlyWait(8000, TimeUnit.MILLISECONDS);

方法设置下超时等待时间,等待页面内容加载完毕,具体超时时间可自己调试看看设置一个合适的值。

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
org.openqa.selenium.StaleElementReferenceException: stale element reference: element is not attached to the page document
  (Session info: webview=33.0.0.0)
  (Driver info: chromedriver=2.20.353124 (035346203162d32c80f1dce587c8154a1efa0c3b),platform=Mac OS X 10.13.2 x86_64) (WARNING: The server did not provide any stacktrace information)
Command duration or timeout: 2.41 seconds
For documentation on this error, please visit: http://seleniumhq.org/exceptions/stale_element_reference.html
Build info: version: '2.44.0', revision: '76d78cf', time: '2014-10-23 20:02:37'
System info: host: 'wenguanchen-MacBook-Pro.local', ip: '30.85.214.81', os.name: 'Mac OS X', os.arch: 'x86_64', os.version: '10.13.2', java.version: '1.8.0_112-release'
Driver info: io.appium.java_client.android.AndroidDriver
Capabilities [{appPackage=com.tencent.mm, noReset=true, dontStopAppOnReset=true, deviceName=emulator-5554, fullReset=false, platform=LINUX, deviceUDID=emulator-5554, desired={app=, appPackage=com.tencent.mm, recreateChromeDriverSessions=true, noReset=true, dontStopAppOnReset=true, deviceName=MuMu, fullReset=false, appActivity=.ui.LauncherUI, platformVersion=4.4.4, automationName=Appium, unicodeKeyboard=true, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}, platformName=Android, resetKeyboard=true}, platformVersion=4.4.4, webStorageEnabled=false, automationName=Appium, takesScreenshot=true, javascriptEnabled=true, unicodeKeyboard=true, platformName=Android, resetKeyboard=true, app=, networkConnectionEnabled=true, recreateChromeDriverSessions=true, warnings={}, databaseEnabled=false, appActivity=.ui.LauncherUI, locationContextEnabled=false, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}}]
Session ID: b5e933e1-0ddf-421d-9144-e423a7bb25b1
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at org.openqa.selenium.remote.ErrorHandler.createThrowable(ErrorHandler.java:204)
at org.openqa.selenium.remote.ErrorHandler.throwIfResponseFailed(ErrorHandler.java:156)
at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:599)
at io.appium.java_client.DefaultGenericMobileDriver.execute(DefaultGenericMobileDriver.java:27)
at io.appium.java_client.AppiumDriver.execute(AppiumDriver.java:1)
at io.appium.java_client.android.AndroidDriver.execute(AndroidDriver.java:1)
at org.openqa.selenium.remote.RemoteWebElement.execute(RemoteWebElement.java:268)
at io.appium.java_client.DefaultGenericMobileElement.execute(DefaultGenericMobileElement.java:27)
at io.appium.java_client.MobileElement.execute(MobileElement.java:1)
at io.appium.java_client.android.AndroidElement.execute(AndroidElement.java:1)
at org.openqa.selenium.remote.RemoteWebElement.getText(RemoteWebElement.java:152)
at com.example.AppiumAutoScan.getArticleDetail(AppiumAutoScan.java:294)
at com.example.AppiumAutoScan.launchBrowser(AppiumAutoScan.java:110)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
at org.junit.runner.JUnitCore.run(JUnitCore.java:157)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:117)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:262)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:84)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

更多元素定位方法可参考官网:

1
[http://selenium-python.readthedocs.io/locating-elements.html#locating-by-id](http://selenium-python.readthedocs.io/locating-elements.html#locating-by-id)
4)chromedriver相关问题

在2017年6月微信热更新升级了X5内核之后,真机上切换到WebView上下文环境就出问题了,具体见这篇博文的评论Appium 微信 webview 的自动化技术Appium 微信小程序,driver.context (“WEBVIEW_com.tencent.mm:tools”) 切换 webview 报错 看评论是通过降低chromedriver版本的方式来避免异常,但是在试过降低版本到20之后还是不行,更新到最新的版本也不行,于是放弃在真机上实现自动化,在模拟器中跑起来的速度也还可以接受。 在真机上跑的时候,切换到WebView上下文环境,程序控制台输出no such session异常,异常信息如下:

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
org.openqa.selenium.remote.SessionNotFoundException: no such session
  (Driver info: chromedriver=2.21.371459 (36d3d07f660ff2bc1bf28a75d1cdabed0983e7c4),platform=Mac OS X 10.13.2 x86_64) (WARNING: The server did not provide any stacktrace information)
Command duration or timeout: 14 milliseconds
Build info: version: '2.44.0', revision: '76d78cf', time: '2014-10-23 20:02:37'
System info: host: 'wenguanchen-MacBook-Pro.local', ip: '192.168.1.102', os.name: 'Mac OS X', os.arch: 'x86_64', os.version: '10.13.2', java.version: '1.8.0_112-release'
Driver info: io.appium.java_client.android.AndroidDriver
Capabilities [{appPackage=com.tencent.mm, noReset=true, dontStopAppOnReset=true, deviceName=55CDU16C07009329, fullReset=false, platform=LINUX, deviceUDID=55CDU16C07009329, desired={app=, appPackage=com.tencent.mm, recreateChromeDriverSessions=True, noReset=true, dontStopAppOnReset=true, deviceName=FRD-AL00, fullReset=false, appActivity=.ui.LauncherUI, platformVersion=6.0, automationName=Appium, unicodeKeyboard=true, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}, platformName=Android, resetKeyboard=true}, platformVersion=6.0, webStorageEnabled=false, automationName=Appium, takesScreenshot=true, javascriptEnabled=true, unicodeKeyboard=true, platformName=Android, resetKeyboard=true, app=, networkConnectionEnabled=true, recreateChromeDriverSessions=True, warnings={}, databaseEnabled=false, appActivity=.ui.LauncherUI, locationContextEnabled=false, fastReset=false, chromeOptions={args=[], extensions=[], androidProcess=com.tencent.mm:tools}}]
Session ID: e2e50190-398b-4fa2-bc66-db1097201e3f
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at org.openqa.selenium.remote.ErrorHandler.createThrowable(ErrorHandler.java:204)
at org.openqa.selenium.remote.ErrorHandler.throwIfResponseFailed(ErrorHandler.java:162)
at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:599)
at io.appium.java_client.DefaultGenericMobileDriver.execute(DefaultGenericMobileDriver.java:27)
at io.appium.java_client.AppiumDriver.execute(AppiumDriver.java:272)
at org.openqa.selenium.remote.RemoteWebDriver.getPageSource(RemoteWebDriver.java:459)
at com.example.AppiumAutoScan.getArticleDetail(AppiumAutoScan.java:238)
at com.example.AppiumAutoScan.launchBrowser(AppiumAutoScan.java:78)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
at org.junit.runner.JUnitCore.run(JUnitCore.java:157)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:117)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:262)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:84)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

在Appium端输出的异常信息如下:

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
[debug] [AndroidDriver] Found webviews: ["WEBVIEW_com.tencent.mm:tools","WEBVIEW_com.tencent.mm"]
[debug] [AndroidDriver] Available contexts: ["NATIVE_APP","WEBVIEW_com.tencent.mm:tools","WEBVIEW_com.tencent.mm"]
[debug] [AndroidDriver] Connecting to chrome-backed webview context 'WEBVIEW_com.tencent.mm:tools'
[debug] [Chromedriver] Changed state to 'starting'
[Chromedriver] Set chromedriver binary as: /Applications/Appium.app/Contents/Resources/node_modules/appium/node_modules/appium-android-driver/node_modules/appium-chromedriver/chromedriver/mac/chromedriver
[Chromedriver] Killing any old chromedrivers, running: pkill -15 -f "/Applications/Appium.app/Contents/Resources/node_modules/appium/node_modules/appium-android-driver/node_modules/appium-chromedriver/chromedriver/mac/chromedriver.*--port=9515"
[Chromedriver] No old chromedrivers seemed to exist
[Chromedriver] Spawning chromedriver with: /Applications/Appium.app/Contents/Resources/node_modules/appium/node_modules/appium-android-driver/node_modules/appium-chromedriver/chromedriver/mac/chromedriver --url-base=wd/hub --port=9515 --adb-port=5037
[Chromedriver] [STDOUT] Starting ChromeDriver 2.21.371459 (36d3d07f660ff2bc1bf28a75d1cdabed0983e7c4) on port 9515
Only local connections are allowed.
[JSONWP Proxy] Proxying [GET /status] to [GET http://127.0.0.1:9515/wd/hub/status] with no body
[Chromedriver] [STDERR] [warn] kq_init: detected broken kqueue; not using.: Undefined error: 0
[JSONWP Proxy] Got response with status 200: "{\"sessionId\":\"\",\"stat...
[JSONWP Proxy] Proxying [POST /session] to [POST http://127.0.0.1:9515/wd/hub/session] with body: {"desiredCapabilities":{"ch...
[JSONWP Proxy] Got response with status 200: {"sessionId":"166cee263fc87...
[debug] [Chromedriver] Changed state to 'online'
[MJSONWP] Responding to client with driver.setContext() result: null
[HTTP] <-- POST /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/context 200 903 ms - 76 
[HTTP] --> GET /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/context {}
[MJSONWP] Calling AppiumDriver.getCurrentContext() with args: ["82b9d81c-f725-473d-8d55-d...
[MJSONWP] Responding to client with driver.getCurrentContext() result: "WEBVIEW_com.tencent.mm:tools"
[HTTP] <-- GET /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/context 200 2 ms - 102 
[HTTP] --> GET /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/source {}
[MJSONWP] Driver proxy active, passing request on via HTTP proxy
[JSONWP Proxy] Proxying [GET /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/source] to [GET http://127.0.0.1:9515/wd/hub/session/166cee263fc8757cbcb5576a52f7229e/source] with body: {}
[JSONWP Proxy] Got response with status 200: "{\"sessionId\":\"166cee263...
[JSONWP Proxy] Replacing sessionId 166cee263fc8757cbcb5576a52f7229e with 82b9d81c-f725-473d-8d55-ddbc1f92c100
[HTTP] <-- GET /wd/hub/session/82b9d81c-f725-473d-8d55-ddbc1f92c100/source 200 8 ms - 220

如果要替换chromedriver的版本,可以从Appium上输出的Log信息找到chromedriver的路径,在终端依次执行如下命令打开chromedriver所在的文件夹。

1
2
cd /Applications/Appium.app/Contents/Resources/node_modules/appium/node_modules/appium-android-driver/node_modules/appium-chromedriver/chromedriver/mac/
open .

相应的chromedriver和Chrome版本对应信息和下载地址可以参考: selenium之 chromedriver与chrome版本映射表

5)程序使用的JAR包

自动化脚本程序要跑起来需要两个压缩包,java-client-3.1.0.jar 和 selenium-server-standalone-2.44.0.jar ,试过使用这两个JAR包的最新版本,会有一些奇奇怪怪的问题,这两个版本的JAR包够用了。 java-client-3.1.0.jar 可以从Appium官网下载:

1
[http://appium.io/downloads.html](http://appium.io/downloads.html)

selenium-server-standalone-2.44.0.jar 可以从selenium官网下载:

1
[http://selenium-release.storage.googleapis.com/index.html](http://selenium-release.storage.googleapis.com/index.html)
6)虚拟机

我这边使用的是网易MuMu虚拟机,基于Android 4.4.4平台,在我自己的Mac上跑着没问题,同一个版本安装到公司的Mac上就跑不起来,一打开就崩。后面虚拟机自动升级到了Android6.0.1,脚本跑了就有异常,而且每次打开的时候经常卡死在加载页面,system so库报异常。所以最好还是基于Android4.X的版本上运行脚本,Mac上没有一个通用稳定的虚拟机,自己下几个看看是否能用,个人测试各类型的虚拟机结果如下: 1)网易MuMu:在Mac上还是比较好用的,但是最新的版本是6.0.1,初始化经常卡死,无法回退到4.4.4平台版本,脚本在Android6.0平台上切换到WebView的上下文环境异常,升级ChromeDriver版本和Appium版本也无法解决此问题。 2)GenyMotion:微信安装之后无法打开,一直闪退,页面滑动在Mac上巨难操作。 3)天天模拟器:下载的DMG安装文件根本无法打开。 4)夜神模拟器:还是比较好用的,但是Appium adb无法连上虚拟机,从Log来看一直在重启adb, 最后程序中断。 5)逍遥安卓:没有Mac版本。 6)BlueStack:无法安装,安装过程中异常退出,多次重试还是一样。 综上,如果是在Mac上运行虚拟机,目前测试有效的是网易MuMu 基于Android 4.4.4 平台的版本,其他版本和虚拟机都有各种问题。 另:附上Android WebView 历史版本下载地址(需要翻墙):

1
[https://cn.apkhere.com/app/com.google.android.webview](https://cn.apkhere.com/app/com.google.android.webview)

WebView 和对应的ChromeDriver版本见Appium GitHub chromedriver说明文档:

1
[https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/web/chromedriver.md](https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/web/chromedriver.md)
7)编译IDE

不做Android开发的可以下载Eclipse IDE,在Eclipse下运行Java程序还比较方便,拷贝工程源码中的三份文件即可

1
2
3
java-client-3.1.0.jar 
selenium-server-standalone-2.44.0.jar  
AppiumWeChatAuto/appiumauto/src/main/java/com/example/AppiumAutoScan.java

Eclipse IDE下载地址:

1
[http://www.eclipse.org/downloads/packages/](http://www.eclipse.org/downloads/packages/)

Java版本和对应的Eclipse IDE版本参考:

1
[http://wiki.eclipse.org/Eclipse/Installation](http://wiki.eclipse.org/Eclipse/Installation)
8)GitHub工程源码

源码GitHub地址:

1
[https://github.com/wenguan0927/AppiumWeChatAuto](https://github.com/wenguan0927/AppiumWeChatAuto)

运行Android工程查看设备信息的时候Edit Configurations切换到app,运行自动化脚本的时候切换到AppiumAutoScan。支持按最近一周,一个月,一年或爬取所有历史文章,checkTimeLimit()传入不同限制时间类型的参数即可。

四、参考资料

Appium 官方文档:http://appium.io/docs/cn/about-appium/intro/

Appium 常用API

Appium自动化测试–使用Chrome调试模式获取App混合应用H5界面元素

Appium 微信 webview 的自动化技术

Appium Girls 学习手册

Appium:轻松玩转app+webview混合应用自动化测试

Appium 微信小程序,driver.context (“WEBVIEW_com.tencent.mm:tools”) 切换 webview 报错

Appium 事件监听

妙用AccessibilityService黑科技实现微信自动加好友拉人进群聊

Appium自动化测试Android

Windows下部署Appium教程(Android App自动化测试框架搭建)

微信、手Q、Qzone之x5内核inspect调试解决方案

selenium之 chromedriver与chrome版本映射表

(Android开发自测)在Mac OS 10.12 上安装配置appium

辅助功能 AccessibilityService笔记

Python

本文转载自:陈文管的博客-微信公众号文章爬取之:服务端数据采集 本篇内容介绍微信公众号文章服务端数据爬取的实现,配合上一篇微信公众号文章采集之:微信自动化,构成完整的微信公众号文章数据采集系统。

公众号文章爬取系统架构图

公众号文章爬取系统架构图

一、AnyProxy 配置(Mac)

AnyProxy是一个开放式的HTTP代理服务器,官方文档:http://anyproxy.io/cn/ Github主页:https://github.com/alibaba/anyproxy 主要特性包括: 基于Node.js,开放二次开发能力,允许自定义请求处理逻辑 支持Https的解析 提供GUI界面,用以观察请求

1、安装NodeJS

在安装Anyproxy之前,需要先安装Nodejs。Nodejs下载地址:http://nodejs.cn/download/。 下载安装完之后可以在终端执行以下命令查看所安装的版本:

1
2
 node --version       查看node安装版本
npm -v               查看npm安装版本

2、AnyProxy安装配置

1) Mac端的安装配置

AnyProxy 不要安装最新的版本,因为接口变动较大,不便于在原来的基础上重写接口,如果已经安装最新的版本,先执行以下命令卸载:

1
sudo npm uninstall -g anyproxy

之后安装3.X版本:

1
sudo npm install  anyproxy@3.x  -g

接着安装相应的证书:

1
anyproxy --root
2) AnyProxy rule_default.js 文件的配置

直接拷贝如下的配置覆盖AnyProxy rule_default.js配置文件即可,具体可参考知乎大神的文章:微信公众号内容的批量采集与应用 ,其中关于图片的优化,配置的fs.readFileSync()参数替换成自己的图片放置路径。将公众号里面的所有图片替换成本地图片的目的是减轻网络传输压力和浏览器占用的内存,有效的提高运行效率,可以自己制作一张1×1像素的png透明图片。 这边跟知乎文章不同的是,在replaceServerResDataAsync中只需要把拦截的微信文章URL地址转发到自己的服务器,因为自动化浏览脚本是直接进入到公众号文章的详情页面,就不需要像知乎文章介绍的那样那么麻烦。 TIPS: 在2019.5.6-2019.5.12时间段之间,微信公众号更新了公众号文章的请求加载方式。 在replaceServerResDataAsync接口中拦截URL的方式已经行不通, 通过AnyProxy拦截的URL参数可以看到已经没有了”/s?__biz=”开头的URL, 但是从

1
/mp/getappmsgext?”和“/mp/getappmsgad?“

开头的请求链接点击进去还是可以看到文章的请求链接地址。 如果是2019.5.12号之前的时间点,拦截URL接口在replaceServerResDataAsync,对应的AnyProxy rule_default.js配置文件为:rule_default_before20190512.js 在2019.5.12号之后的时间点,拦截URL的接口变动到shouldUseLocalResponse : function(req,reqBody),只要把request body发送到后台服务器,再加上”https://mp.weixin.qq.com/s?”前缀进行拼接就行,对应的AnyProxy rule_default.js配置文件应该改为:rule_default_after20190512.js 如果忘记了AnyProxy的安装路径,用命令查找rule_default.js文件即可:

1
find ~ -iname "rule_default.js"
3)AnyProxy启动

在终端执行命令启动AnyProxy:

1
anyproxy -i

如果遇到如下的异常说明缺少写文件夹的权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
the default rule for AnyProxy.
Anyproxy rules initialize finished, have fun!
The WebSocket will not work properly in the https intercept mode :(
fs.js:885
  return binding.mkdir(pathModule._makeLong(path),
                 ^
Error: EACCES: permission denied, mkdir '/Users/chenwenguan/.anyproxy/cache_r929590'
    at Object.fs.mkdirSync (fs.js:885:18)
    at Object.module.exports.generateCacheDir (/Users/chenwenguan/.nvm/versions/node/v8.9.3/lib/node_modules/anyproxy/lib/util.js:54:8)
    at new Recorder (/Users/chenwenguan/.nvm/versions/node/v8.9.3/lib/node_modules/anyproxy/lib/recorder.js:16:31)
    at /Users/chenwenguan/.nvm/versions/node/v8.9.3/lib/node_modules/anyproxy/proxy.js:116:43
    at ChildProcess.exithandler (child_process.js:282:5)
    at emitTwo (events.js:126:13)
    at ChildProcess.emit (events.js:214:7)
    at maybeClose (internal/child_process.js:925:16)
    at Socket.stream.socket.on (internal/child_process.js:346:11)
    at emitOne (events.js:116:13)

用以下命令修改下文件夹权限即可:

1
sudo chown -R `whoami` /Users/chenwenguan/.anyproxy
4)Android虚拟机上的配置

AnyProxy启动完成后,访问GUI地址:http://192.168.1.101:8002

下载AnyProxy证书文件

下载AnyProxy证书文件

点击下载rootCA.crt文件,可以在虚拟机SD卡根目录下新建一个rootCA文件夹,把文件用adb命令的方式Push到虚拟机的sdcard目录下:

1
adb push rootCA.crt /sdcard/rootCA/

之后进入Android虚拟机系统设置界面,进入安全设置项,选择从SD卡安装(从SD卡安装证书)设置项,选择Push到sd卡下的证书文件安装,如果没有做这个操作,在微信加载WebView的时候会不断地弹出警告弹窗。 如果没有在模拟器找到系统设置或WI-FI网络设置的入口,可用adb命令调用进入,直接进入网络设置页面命令如下:

1
adb shell am start -a android.intent.action.MAIN -n com.android.settings/.wifi.WifiSettings

进入模拟器系统设置页面命令:

1
adb shell am start com.android.settings/com.android.settings.Settings

在Android模拟器上还要设置网络代理,长按WIFI网络设置项,弹窗选择修改网络选项,IP地址就写电脑的IP,端口填8001。

安卓虚拟机网络代理设置

安卓虚拟机网络代理设置

在以上都配置完毕之后,进入微信应用查看公众号文章,就可以在GUI界面上看到AnyProxy拦截到的所有请求URL地址信息。 如文章前面的说明,在2019.5.12时间点之前还可以看到”/s?__biz=”开头的URL请求参数。

AnyProxy 拦截的URL信息

AnyProxy 拦截的URL信息

上面/s?__biz=开头的URL就是微信公众号文章详细的URL地址,可以点击查看具体的详细信息:

微信公众号文章URL详细信息

微信公众号文章URL详细信息

页面往下滑动,查看请求到的公众号文章详细字段信息,服务端爬虫就是从这些字段参数定义的值来截取需要的信息。

AnyProxy解析的公众号文章详细信息

AnyProxy解析的公众号文章详细信息

目前在服务端实现保存的字段只是一些基本的信息,如标题、作者、文章发布时间等,如果需要其他信息可以参考上图的一些字段做正则匹配。 在2015.5.12时间点,微信变动公众号文章加载方式之后,文章的实际地址参数在“/mp/getappmsgext?”开头的请求链接里面,包括点赞和阅读数据也在这个请求返回的结构体里面。在“ /mp/getappmsgad?“开头的请求链接request body也是文章的链接地址,但选择“/mp/getappmsgext?”开头的URL来拦截处理比较好。

拦截getappmsgext的请求结构体就是文章实际地址

拦截getappmsgext的请求结构体就是文章实际地址

在getappmsgext拦截的页面往下滑动到response body就可以看到文章的阅读和点赞数据,因为这边没有阅读和点赞的数据解析需求,有需要的自行研究下从rule_default.js配置文件哪个接口拦截转发数据。

拦截getappmsgext的请求返回的数据包括阅读和点赞数

拦截getappmsgext的请求返回的数据包括阅读和点赞数

二、JavaWeb 服务端实现

1、运行环境配置

Intellij IDEA 官网下载地址:https://www.jetbrains.com/idea/ 破解方法参考:IntelliJ IDEA 2017 完美注册方法 TIPS:要先打开IDEA之后再做如下配置,否则会被识别为文件已损坏

1
-javaagent:/Applications/IntelliJ IDEA.app/Contents/bin/JetbrainsCrack-2.7-release-str.jar

2、服务端实现

爬虫服务端实现GitHub源码地址:

1
[https://github.com/wenguan0927/WechatSpider](https://github.com/wenguan0927/WechatSpider)
1)实现类说明

公众号爬虫服务端实现源码类说明

公众号爬虫服务端实现源码类说明

WechatController类做AnyProxy转发的文章链接接收和JSP页面显示的逻辑处理。 mapper文件夹下的两个类是数据库操作的映射操作类,通过配置文件自动生成,只是手动加了几个数据查询方法的实现,PostKeyWordMapper用来操作存储公众号文章关键词的数据,WechatPostMapper用来操作存储公众号文章详细数据。 model文件夹下PostJSP只是用来JSP页面显示数据的一个中间类,在JSP页面中去拼接包含较多特殊字符的文本内容容易出问题,我这边的实现是要直接生成MarkDown文档的格式,所以做了一层转化处理。PostKeyWord是公众号关键字的类,WechatPost是公众号文章详细数据类。 spider文件夹下的类就是公众号文章关键字和公众号文章详细信息的爬取解析处理类。 util文件夹下放的是工具类,SimHash只是用来测试通过关键字计算公众号文章关联性实现类,有兴趣可以自己做下挖掘。

2)配置文件说明

公众号爬虫服务端实现配置文件说明

公众号爬虫服务端实现配置文件说明

mybatis-mapper文件夹下的两个文件是数据库映射XML资源文件,通过generator.properties和generatorConfig.xml两个配置文件自动生成,具体可参考:数据库表反向生成(一) MyBatis-generator与IDEA的集成 。 这边需要注意的是,如果要在反向生成的数据库映射操作文件中增加方法实现,不要在Mapper.xml文件里面添加方法,要加的话在Mapper.java的类中加,可参考WechatPostMapper.java 类中末尾几个方法,通过在函数上添加注解的方式实现。 generator.properties文件中的jdbc.driverLocation改成自己电脑的connector实际路径,jdbc.userId和jdbc.password改成自己数据库的用户名和密码。 jdbc.properties文件中的数据库参数也改成自己配置的值。 其他文件只是常规的Web实现配置,此处不做多余赘述。

3)实现过程中遇到的问题

1)@Autowired注解的Mapper类报NullPointException异常

1
2
3
4
    @Autowired
    private WechatPostMapper wechatPostMapper;
    @Autowired
    private PostKeywordMapper postKeywordMapper;

这边需要注意的是通过@Autowired注解声明的类不能在一个new出来的类中使用,@Autowired只能在通过框架注解生成的类中使用,在一个new出来的类中使用注解在框架生成的类中是找不到的,所以会报空指针异常。其他异常可参考:@Autowired注解和静态方法 2)Intellj(IDEA) warning no artifacts configured 异常 参考文章:【错误解决】Intellj(IDEA) warning no artifacts configured 3)Intellij 代理端口占用异常

1
2
3
错误: 代理抛出异常错误: 
java.rmi.server.ExportException: Port already in use: 1099; nested exception is: 
java.net.BindException: Address already in use

终端输入命令查看端口所在进程:

1
sudo lsof -i :1099

之后可看到如下类似的结果:

1
2
COMMAND PID        USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    582 chenwenguan   23u  IPv6 0x38b6c6251709a7d3      0t0  TCP *:rmiregistry (LISTEN)

终端输命令杀进程:kill 582 4)http://java.sun.com/jsp/jstl/core cannot be resolved 如果配置的jstl版本是1.2, 不需要通过导入jstl.jar和standard.jar包的方式,如果配置的是1.2以下的版本,可参考文章: core cannot be resolved。 jar包的下载地址:

1
[http://archive.apache.org/dist/jakarta/taglibs/standard/binaries/](http://archive.apache.org/dist/jakarta/taglibs/standard/binaries/)

5) Warning:The/usr/local/mysql/data directory is not owned by the ‘mysql’ or ‘_mysql’

如果因Mac系统更新导致MySQL提示以上异常,执行以下命令解决:

1
sudo chown -R  _mysql:wheel  /usr/local/mysql/data

参考博文:Mac在偏好设置启动MySQL失败 6)注解中的数据库IN查询语句实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Select({"<script>",
         "select",
         "id, biz, appmsgid, title, digest, contenturl, sourceurl, cover, datetime, readnum, ",
         "likenum, isspider, author, nickname, weight, posttype, content",
         "from postTable where nickname in ",
         "<foreach item='item' collection='nickname' open='(' close=')' separator=','>",
         "#{item}",
         "</foreach>",
         " and datetime >=#{datetime,jdbcType=TIMESTAMP}",
         "order by weight DESC",
         "</script>"
})
@ResultMap("ResultMapWithBLOBs")
List<WechatPost> getATAPosts(@Param("nickname") List<String> nickname, @Param("datetime") Date time);

如果是要在注解中实现IN多条件查询,需要如上面的方式去实现,直接按照原生SQL语句的方式实现是行不通的。 参考博文:SpringBoot使用Mybatis注解开发教程-分页-动态sql

4) 数据库实现

公众号文章详情数据表实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE `postTable` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `biz` tinytext,
  `appmsgid` tinytext,
  `title` tinytext,
  `digest` longtext,
  `contenturl` longtext,
  `sourceurl` longtext,
  `cover` longtext,
  `datetime` datetime DEFAULT NULL,
  `readnum` int(11) DEFAULT NULL,
  `likenum` int(11) DEFAULT NULL,
  `isspider` int(11) DEFAULT NULL,
  `author` tinytext,
  `nickname` tinytext,
  `weight` int(11) DEFAULT NULL,
  `posttype` int(11) DEFAULT NULL,
  `content` longtext,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=199 DEFAULT CHARSET=utf8

公众号关键字数据表实现:

1
2
3
4
5
6
7
CREATE TABLE `keywordTable` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `wordtext` varchar(45) DEFAULT NULL,
  `wordfrequency` int(11) DEFAULT NULL,
  `wordtype` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3525 DEFAULT CHARSET=utf8
5)遗留问题

公众号文章的分类目前没有很好地实现,也就是,目前爬取的公众号文章我要分为三大类,新闻类、Android开发、技术扩展,一开始的想法是根据以往发布的每周技术周报文章内容,提取每个类别文章的关键词数据,生成一个关键词数据库,之后爬取的文章,可以通过提取文章的关键词跟历史记录文章的关键词词库进行对比,计算它们的相关性来进行归类。 目前用HanLP开源代码来做测试,提取的关键词都是中文的关键词,在做关联性计算的时候并不能达到理想的效果,因为开发类的文章有很多英文的词汇,HanLP里面并不包括英文词汇的词库,所以下一步要做的是做一个技术类文章的分词词库来实现文章的归类处理。 这边给出一些参考文章的链接资源,有兴趣可以自己做一下深挖。 TextRank算法提取关键词的Java实现 TextRank算法提取关键词和摘要 计算两组标签/关键词相似度算法 HanLP自然语言处理包开源 文本关键词提取算法解析 NLP点滴——文本相似度 文本相似性计算总结(余弦定理,simhash)及代码 如何实现一个基本的微信文章分类器 HanLP GitHub开源代码

三、其他参考资料

Mac OS X上IntelliJ IDEA 13与Tomcat 8的Java Web开发环境搭建 IntelliJ IDEA 15 创建maven项目 MyBatis官网 Intellij IDEA 使用教程 HTML语言中括号(尖括号)的字符编码 mac下mongodb的安装与配置 mac下mongodb的安装和使用(使用终端操作) Intellij Mongo配置 Java连接MongoDB进行增删改查 IntelliJ IDEA手动配置连接MySQL数据库 MongoDB中文教程 MongoDB官方文档 WebMagic 爬虫框架

技术杂谈

本文转载自:陈文管的博客-2019 WordPress必备插件推荐 推荐下 WordPress 必备的插件,插件的使用主要从 SEO、安全、营销、UI设计、访问数据分析几个方面去考虑,当然并不是插件安装越多越好,过多的插件会拖慢网站加载速度,只在满足需求的前提下安装必要的一些插件。 此外在安装插件启用之前最好先备份下网站数据,很可能因为版本不兼容的原因导致数据库读写异常,这样整个网站就挂了,以防万一,每次安装或者更新插件之前先备份下网站数据。

1、Yoast SEO

Yoast SEO插件

Yoast SEO插件

做网站SEO必不可少的插件, 可以设置谷歌、Bing、百度、Yandex 的验证码,可以检测文章可读性和SEO的好坏等等,但要记得把分类和TAG的索引关闭,避免内部链接的内容重复。

Yoast SEO分类目录声明成noindex,nofollow

Yoast SEO分类目录声明成noindex,nofollow

Yoast SEO 标签目录声明成noindex,nofollow

Yoast SEO 标签目录声明成noindex,nofollow

如果是要编辑robot.txt,入口在工具=>文件编辑器里面,记得在一个网站没有建好之前要robots.txt设置成:

User-agent: *

Disallow: /

2、WP Fastest Cache

WP Fastest Cache插件

WP Fastest Cache插件

压缩HTML,合并CSS,提高网站加载速度,可以直接在插件里面设置CDN加速等等,缓存的设置按照下面截图照搬就行。

WP Fastest Cache 缓存设置

WP Fastest Cache 缓存设置

3、Smush Image Compression and Optimization

Smush Image Compression and Optimization插件

Smush Image Compression and Optimization插件

帮助自动压缩网站的图片,避免图片过大拖慢网站的加载速度,配合TinyPNG(https://tinypng.com/)一起使用更好,每次上传图片之前最好先经过TinyPNG压缩一遍之后再上传,不得不说目前在图片的压缩方面,TinyPNG无疑是最好的。

4、Google XML Sitemaps

Google XML Sitemaps插件

Google XML Sitemaps插件

虽然Yoast SEO插件自带网站地图的设置,但是如果要提高配置的可控性,使用Google XML Sitemaps会好点,在提交到Google Search Console收录之后出现一些链接地址无法索引的情况,后面就把HTML网站地图功能关闭掉就好了。

5、Clicky for WordPress

Clicky by Yoast插件

Clicky by Yoast插件

虽然在网站访问数据分析工具上已经有Google Search ConsoleGoogle Analytics,但是Clicky(https://clicky.com/)工具更能实时地记录反馈网站的访问数据。在Clicky注册账号之后,把使用偏好里面的Site ID、Site Key和Admin site key参数填到Clicky插件设置里面,之后就可以记录网站的数据。

6、Shortcodes Ultimate

Shortcodes Ultimate插件

Shortcodes Ultimate插件

对于WordPress里面自定义的样式,简码插件提供了很多样例,省去了手动编辑的麻烦。

终极简码样例

终极简码样例

7、Genesis Simple EditsGenesis Super Customizer

Genesis Simple Edits插件

Genesis Simple Edits插件

如果是使用Genesis Framework主题 ,Genesis Simple Edits是必不可少的插件,如果手动修改Genesis Framework主题代码会出异常,甚至会导致WordPress账户后台无法登陆,之前尝试过在主题类中增加几行代码修改样式,后面导致整个服务异常,无法登陆,所以,最好不要手动修改Genesis Framework主题的代码,通过插件去修改保险点。

Simple Edits实现的自定义底部栏

Simple Edits实现的自定义底部栏

如果是要实现上面的底部栏效果,可以在插件Footer Output输入框中贴上以下代码来实现,xxxxxxx替换成自己网站的名称。

Genesis Simple Edits自定义底部栏HTML代码

Genesis Simple Edits自定义底部栏HTML代码

Genesis Super Customizer插件

Genesis Super Customizer插件

使用Genesis Framework主题之后发现评论的字体偏大了,通过Super Customizer插件可以修改文章和评论默认的字体大小,还有主题的各种自定义样式修改。 另:如果是做境外网站,文章和评论的字体一般选Georgia Font。Genesis主题的分享插件推荐Genesis Simple Share

8、Really Simple SSL

Really Simple SSL插件

Really Simple SSL插件

网站从HTTP切换成HTTPS必备的插件,切换的过程中涉及到301重定向,URL参数的修改等等,如果手动修改难免出错,这个在阿里云免费SSL证书申请和WordPress服务器配置 文章中已经有提及过。

9、WP Mail SMTPWPForms Lite

WP Mail SMTP by WPForms插件

WP Mail SMTP by WPForms插件

WPForms Lite插件

WPForms Lite插件

博客中的联系页面功能可以使用上面两个插件配合实现。 WPForms Lite插件用来自定义联系人表格、注册表格等,WP Mail SMTP 插件用来设置接收邮件的参数,比如设置QQ邮箱作为博客的接收邮箱,可以参考这篇文章:WordPress如何发送邮件?

Wordpress博客联系表格页面

WordPress博客联系表格页面

如果需要弹窗邮件注册功能可以使用OptinMonster插件。

Popups by OptinMonster插件

Popups by OptinMonster插件

10、Akismet

Akismet Anti-Spam插件

Akismet Anti-Spam插件

垃圾评论拦截插件,避免恶意的评论。

11、Easyazon

EasyAzon 插件

EasyAzon 插件

做境外Affiliate必备插件,EasyaAzon一个很重要的功能是可以根据地理位置设置不同的跳转链接,比如在澳大利亚可以跳转到澳大利亚区域的亚马逊店铺,加拿大可以跳转到加拿大区域的亚马逊店铺。比如网站默认的地理位置设置在美国,如果加拿大或者澳大利亚区域的用户点击链接跳转到的是美国区域的店铺,很容易就放弃购买,这样会损失一部分转化率。 10Beasts的博主曾使用geniuslink (https://www.geni.us/)来本地化购买链接,后面出现一个Bug,导致返利成了geniuslink的收益,后面他就弃用了。

EasyAzon设置对应区域的链接地址

12、TinyMCE Advanced

TinyMCE Advanced插件

TinyMCE Advanced插件

编辑插件推荐在WordPress自带的编辑工具上,安装TinyMCE Advanced插件,WordPress自带的插件功能太简陋了,覆盖安装第三方插件多多少少会有点问题。TinyMCE Advanced插件可以提供字体大小设置功能,可以在插件设置页面拖动在编辑栏上要显示的功能模块。 一定不要安装Kindeditor For WordPress插件,这个插件很久没更新了,而且是覆盖安装,最大的一个问题是在上下滑动编辑区域的时候无法悬浮编辑工具栏,这样要编辑设置文本属性的时候每次都要滑动到文章头部设置,非常麻烦。而且插入媒体内容的时候每次都是把内容加到文章末尾,不是在输入光标的位置插入内容,每次都要从文章末尾剪切黏贴到插入的位置。

Kindeditor For WordPress插件

Kindeditor For WordPress插件

13、Rel Nofollow Checkbox

Rel Nofollow Checkbox插件

Rel Nofollow Checkbox插件

文章中的出站链接一般都要设置成nofollow属性,避免权重传递。其他的Nofollow插件会有这样那样的问题,试了几个插件只有这个没有Bug。

文章链接nofollow属性设置

文章链接nofollow属性设置

14、Google Analytics for WordPress by MonsterInsights

Google Analytics Dashboard Plugin for WordPress by MonsterInsights插件

Google Analytics Dashboard Plugin for WordPress by MonsterInsights插件

为了避免每次单独登陆Google Analytics的麻烦,可以集成Google Analytics到WordPress操作面板中,但似乎在阿里云服务器上的防火墙会屏蔽Google Analytics链接的访问,境外网站的使用没问题。 这边不建议使用WP Statistics插件,虽然用这个插件可以看到很多详细的访问数据,但在集成之后发现网站页面的加载速度明显变长了,而且会产生大量的Log记录。

WP Statistics插件

WP Statistics插件

15、WP Downgrade

WordPress WP Downgrade插件

WordPress WP Downgrade插件

如果WordPress版本被服务器自动升级到5.0,之后就容易出现很多WordPress插件不兼容的情况,这就需要对WordPress版本进行降级,使用这个插件输入要降级或升级的版本号操作即可。使用完之后可以关闭此插件,需要的时候再激活。

TIPS:

1、怎么下载旧版本WordPress插件?

有的时候会出现升级WordPress版本或者插件自更新到高版本出现插件不兼容的异常,这个时候就需要回退安装旧版本的插件。首先进入WordPress网站主页https://wordpress.org/,比如要下载Jetpack旧版本插件,先进入Jetpack插件主页,在插件页面右侧有一个Advanced View入口。

插件历史版本下载入口

插件历史版本下载入口

点击进入Advanced View页面,在PREVIOUS VERSIONS模块就可以选择历史插件版本,之后进行下载。

插件历史版本下载

插件历史版本下载

JavaScript

做网络爬虫的同学肯定见过各种各样的验证码,比较高级的有滑动、点选等样式,看起来好像挺复杂的,但实际上它们的核心原理还是还是很清晰的,本文章大致说明下这些验证码的原理以及带大家实现一个滑动验证码。 我之前做过 Web 相关开发,尝试对接过 Lavavel 的极验验证,当时还开发了一个 Lavavel 包:https://github.com/Germey/LaravelGeetest,在开发包的过程中了解到了验证码的两步校验规则。 实际上这类验证码的校验是分为两个步骤的:

  1. 第一步就是前端的校验。一般来说,登录注册页面在点击提交的时候都会伴随着一个表单提交,在表单提交的时候会有 JavaScript 事件的触发。如果加入了验证码,那么在表单提交的时候会多加一个额外的验证,判断这个验证码是否已经成功完成了操作。如果没有的话,那就直接取消表单的提交,然后顺便提示说”您的验证没通过,请重新验证“,诸如此类的话。所以这一步就能防范”君子“只为了。
  2. 第二步就是服务端的校验。意思就是说表单提交之后,会有请求发送到服务器,这个请求中包含了很多数据,比如用户名、密码,如果对接了验证码的话,还会有额外的验证码的值,或者更复杂的加密后的 Token 值,服务器会对发过来的信息进行校验,如果验证通过,那么整个请求就成功了,返回正常的响应,否则返回错误的响应。所以如果想要通过程序来直接构造表单提交的时候,服务端就可以做进一步的校验,由于提交的验证码相关的信息都是和服务端的 Session 相关联的,另外再加上一些 CSRF 等的校验,所以这一步就能防范”小人“之为了。

上面就是验证码校验的两个阶段,一般来说为了安全性,在开发一个网站时需要客户端和服务端都加上校验,这样才能保证安全性。 本文章主要来介绍一下第一个阶段,也就是前端校验的验证码的实现,下面来介绍一下拖动验证码的具体实现。

需求

那么前端完成一个合格的验证码,究竟需要做成什么样子呢?

  1. 首先验证码有个大体的雏形,既然是拖动验证码,那就要拖动块和目标块,我们需要把拖动块拖动到目标块上就算校验成功。
  2. 验证码的一个功能就是来规避机器的自动操作,所以我们需要通过轨迹来判断这个拖动过程是真实的人还是机器,因此我们需要记录拖动的路径,路径经过计算之后可以发送到后端进行进一步的分类,比如对接深度学习模型来分类拖动轨迹是否是人。

以上就是验证码的两个基本要求,所以我们这里就来实现一下看看。

结果

这里就先给大家看看结果吧: 拖动验证码示例 可以看到图中有一个初始滑块,有一个目标滑块,如果把初始滑块拖动到目标滑块上才能校验成功,然后下方再打印拖动的轨迹,包含它的 x、y 坐标。 有了这些内容之后,就可以放到表单里面进行提交了,轨迹数据可以自行加密处理并校验来判断其是否合法。

具体实现

下面就具体讲解下这个是怎么实现的,实际上核心代码只有 200 行,下面对整个核心流程进行说明。 既然 Vue 这么火,那我这里就用 Vue 来实现啦,具体的环境配置这里就不再赘述了,需要安装的有:

安装完成之后便可以使用 vue 命令了,新建个项目:

1
vue create drag-captcha

然后找一张不错的风景图,放到 public 目录下,后面我们会引用它。 另外这里需要一个核心的包叫做 vue-drag-drop,其 GitHub 地址为:https://github.com/cameronhimself/vue-drag-drop,在目录下使用此命令安装:

1
npm install --save vue-drag-drop

安装好了之后我们就可以利用它来实现验证码了。 首先 vue-drag-drop 提供了两个组件,一个叫做 Drag,一个叫做 Drop。前者是被拖动对象,后者是放置目标,我们利用这两个组件构建两个滑块,将 Drag 滑块拖动到 Drop 滑块上就成功了。因此,我们要做的仅仅是把它们两个声明出来并添加几个检测方法就好了,至于拖动的功能,vue-drag-drop 这个组件已经给我们封装好了。 这里我们就直接在 App.vue 里面修改内容就好了,在 <template> 里面先声明一下两个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div id="app">
<div id="wrapper" :style="wrapperStyle">
<drop class="drop" id="target"
:class="{ 'over': state.over }"
@dragover="onDragOver"
@dragleave="onDragLeave"
:style="targetStyle">
</drop>
<drag class="drag" id="source"
:transfer-data="true"
@dragstart="onDragStart"
@dragend="onDragEnd"
@drag="onDrag" v-if="!state.dragged"
:style="sourceStyle">
<div slot="image" id="float" :style="sourceStyle">
</div>
</drag>
</div>
</div>
</template>

很清晰了,一个 <drop> 和一个 <drag> 组件,里面绑定了一些属性,下面对这两个组件的常用属性作一下说明。

Drop

对于 Drop 组件来说,它是一个被放置的对象,被拖动滑块会放到这个 Drop 滑块上,这就代表拖动成功了。它有两个主要的事件需要监听,一个叫做 dragover,一个叫做 dragleave,分别用来监听 Drag 对象拖上和拖开的事件。 在这里,分别对两个事件设置了 onDragOver 和 onDragLeave 的回调函数,当 Drag 对象放到 Drop 对象上面的时候,就会触发 onDragOver 对象,当拖开的时候就会触发 onDragLeave 事件。 那这样的话我们只需要一个全局变量来记录是否已经将滑块拖动到目标位置即可,比如可以定一个全局变量 state,我们用 over 属性来代表是否拖动到目标位置。 因此 onDragOver 和 onDragLeave 事件可以这么实现:

1
2
3
4
5
6
onDragOver() {
this.state.over = true
},
onDragLeave() {
this.state.over = false
}

Drag

对于 Drag 组件来说,它是一个被拖动的对象,我们需要将这个 Drag 滑块拖动到 Drop 滑块上,就代表拖动成功了。它有三个主要的时间需要监听:dragstart、drag、dragend,分别代表拖动开始、拖动中、拖动结束三个事件,我们这里也分别设置了三个回调方法 onDragStart、onDrag、onDragEnd。 对于 onDragStart 方法来说,应该怎么实现呢?这里应该处理刚拖动的一瞬间的动作,由于我们需要记录拖动的轨迹,所以声明一个 trace 全局变量来保存轨迹信息,onDragStart 要做的就是初始化 trace 对象为空,另外记录一下初始的拖动位置,以便后续计算拖动路径,所以可以实现如下:

1
2
3
4
5
6
7
onDragStart(data, event) {
this.init = {
x: event.offsetX,
y: event.offsetY,
}
this.trace = []
}

对于 onDrag 方法来说,就是处理拖动过程中的一系列拖动动作,这里其实就是计算当前拖动的偏移位置,然后把它保存到 trace 变量里面,所以可以实现如下:

1
2
3
4
5
6
7
8
onDrag(data, event) {
let offsetX = event.offsetX - this.init.x
let offsetY = event.offsetY - this.init.y
this.trace.push({
x: offsetX,
y: offsetY,
})
}

对于 onDragEnd 方法来说,其实就是检测最后的结果了,刚才我们用 state 变量里面的 over 属性来代表是否拖动到目标位置上,这里我们也定义了另外的 dragged 属性来代表是否已经拖动完成,dragging 属性来代表是否正在拖动,所以整个方法的逻辑上是检测 over 属性,然后对 dragging、dragged 属性做赋值,然后做一些相应的提示,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
onDragEnd() {
if (this.state.over) {
this.state.dragging = false
this.state.dragged = true
this.$message.success('拖动成功')
}
else {
this.state.dragging = false
this.state.dragged = false
this.$message.error('拖动失败')
}
this.state.over = false
}

OK 了,以上便是主要的逻辑实现,这样我们就可以完成拖动滑块的定义以及拖动的监听了。 接下来就是一些样式上的问题了,对于图片的呈现,这里直接使用 CSS 的 background-image 样式来设置的,如果想显示图片的某一个范围,那就用 background-position 来设置,这是几个核心的要点。 好,这里的样式设置其实也可以用 JavaScript 来实现,我们把它们定义为一些计算属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
wrapperStyle() {
return {
width: this.size.width + 'px',
height: this.size.height + 'px',
backgroundImage: 'url(' + this.image + ')',
backgroundSize: 'cover'
}
},
targetStyle() {
return {
left: this.block.x + 'px',
top: this.block.y + 'px'
}
},
sourceStyle() {
return {
backgroundImage: 'url(' + this.image + ')',
backgroundSize: this.size.width + 'px ' + this.size.height + 'px',
backgroundPosition: -this.block.x + 'px ' + -this.block.y + 'px'
}
}

另外这里还有一个值得注意的地方,就是 Drag 组件的 slot 部分:

1
<div slot="image" id="float" :style="sourceStyle"></div>

这部分定义了在拖动过程中随鼠标移动的图片样式,这里也和 Drag 滑块一样定义了一样的样式,这样在拖动的过程中,就会显示一个和 Drag 滑块一样的滑块随鼠标移动。 最后,就是拖拽完成之后,将滑动轨迹输出出来,这里我就直接呈现在页面上了,<template> 区域加入如下定义即可:

1
2
3
4
5
<div>
<p v-if="state.dragged" id="trace">
拖动轨迹:{{ trace }}
</p>
</div>

好,以上就是一些核心代码的介绍,还有一些细节的问题可以完善下,比如滑块随机初始化位置,以及拖动样式设置。 最后再看一遍效果: 拖动验证码示例 可以看到我们首先拖动了 Drag 滑块,当 Drag 滑块拖动到 Drop 滑块上时,出现了白色描边,证明已经拖动到目标位置了。然后松手之后,触发 onDragEnd 方法,呈现拖动轨迹,整个验证码就验证成功了。 当然这只是前端部分,如果在这个基础上添加表单验证,然后再添加后端校验,并加上轨迹识别,那可谓是一个完整的验证码系统了,在这里就点到为止啦。 最后如果大家想也仿照实现一下的话,可以参考这个组件:https://github.com/cameronhimself/vue-drag-drop,里面也介绍了其他的一些属性和事件,在某些情况下还是很有用的。 最后如果想获取本节代码,可以在「进击的 Coder」公众号回复「验证码」获取。

Python

想必大家都或多或少听过 TensorFlow 的大名,这是 Google 开源的一个深度学习框架,里面的模型和 API 可以说基本是一应俱全,但 TensorFlow 其实有很多让人吐槽的地方,比如 TensorFlow 早期是只支持静态图的,你要调试和查看变量的值的话就得一个个变量运行查看它的结果,这是极其不友好的,而 PyTorch、Chainer 等框架就天生支持动态图,可以直接进行调试输出,非常方便。另外 TensorFlow 的 API 各个版本之间经常会出现不兼容的情况,比如 1.4 升到 1.7,里面有相当一部分 API 都被改了,里面有的是 API 名,有的直接改参数名,有的还给你改参数的顺序,如果要做版本兼容升级,非常痛苦。还有就是用 TensorFlow 写个模型,其实相对还是比较繁琐的,需要定义模型图,配置 Loss Function,配置 Optimizer,配置模型保存位置,配置 Tensor Summary 等等,其实并没有那么简洁。 然而为啥这么多人用 TensorFlow?因为它是 Google 家的,社区庞大,还有一个原因就是 API 你别看比较杂,但是确实比较全,contrib 模块里面你几乎能找到你想要的所有实现,而且更新确实快,一些前沿论文现在基本都已经在新版本里面实现了,所以,它的确是有它自己的优势。 然后再说说 Keras,这应该是除了 TensorFlow 之外,用的第二广泛的框架了,如果你用过 TensorFlow,再用上 Keras,你会发现用 Keras 搭模型实在是太方便了,而且如果你仔细研究下它的 API 设计,你会发现真的封装的非常科学,我感觉如果要搭建一个简易版的模型,Keras 起码得节省一半时间吧。 一个好消息是 TensorFlow 现在已经把 Keras 包进来了,也就是说如果你装了 TensorFlow,那就能同时拥有 TensorFlow 和 Keras 两个框架,哈哈,所以你最后还是装个 TensorFlow 就够了。 还有另一个好消息,刚才我不是吐槽了 TensorFlow 的静态图嘛?这的确是个麻烦的东西,不过现在的 TensorFlow 不一样了,它支持了 Eager 模式,也就是支持了动态图,有了它,我们可以就像写 Numpy 操作一样来搭建模型了,要看某个变量的值,很简单,直接 print 就 OK 了,不需要再去调用各种 run 方法了,可以直接抛弃 Session 这些繁琐的东西,所以基本上和 PyTorch 是一个套路的了,而且这个 Eager 模式在后续的 TensorFlow 2.0 版本将成为主打模式。简而言之,TensorFlow 比之前好用多了! 好,以上说了这么多,我今天的要说的正题是什么呢?嗯,就是我基于 TensorFlow Eager 模式和 Keras 写了一个深度学习的框架。说框架也不能说框架,更准确地说应该叫脚手架,项目名字叫做 ModelZoo,中文名字可以理解成模型动物园。 有了这个脚手架,我们可以更加方便地实现一个深度学习模型,进一步提升模型开发的效率。 另外,既然是 ModelZoo,模型必不可少,我也打算以后把一些常用的模型来基于这个脚手架的架构实现出来,开源供大家使用。

动机

有人说,你这不是闲的蛋疼吗?人家 Keras 已经封装得很好了,你还写个啥子哦?嗯,没错,它的确是封装得很好了,但是我觉得某些地方是可以写得更精炼的。比如说,Keras 里面在模型训练的时候可以自定义 Callback,比如可以实现 Tensor Summary 的记录,可以保存 Checkpoint,可以配置 Early Stop 等等,但基本上,你写一个模型就要配一次吧,即使没几行代码,但这些很多情况都是需要配置的,所以何必每个项目都要再去写一次呢?所以,这时候就可以把一些公共的部分抽离出来,做成默认的配置,省去不必要的麻烦。 另外,我在使用过程中发现 Keras 的某些类并没有提供我想要的某些功能,所以很多情况下我需要重写某个功能,然后自己做封装,这其实也是一个可抽离出来的组件。 另外还有一个比较重要的一点就是,Keras 里面默认也不支持 Eager 模式,而 TensorFlow 新的版本恰恰又有了这一点,所以二者的兼并必然是一个绝佳的组合。 所以我写这个框架的目的是什么呢?

  • 第一,模型存在很多默认配置且可复用的地方,可以将默认的一些配置在框架中进行定义,这样我们只需要关注模型本身就好了。
  • 第二,TensorFlow 的 Eager 模式便于 TensorFlow 的调试,Keras 的高层封装 API 便于快速搭建模型,取二者之精华。
  • 第三,现在你可以看到要搜一个模型,会有各种花式的实现,有的用的这个框架,有的用的那个框架,而且参数、数据输入输出方式五花八门,实在是让人头大,定义这个脚手架可以稍微提供一些规范化的编写模式。
  • 第四,框架名称叫做 ModelZoo,但我的理想也并不仅仅于实现一个简单的脚手架,我的愿景是把当前业界流行的模型都用这个框架实现出来,格式规范,API 统一,开源之后分享给所有人用,给他人提供便利。

所以,ModelZoo 诞生了!

开发过程

开发的时候,我自己首先先实现了一些基本的模型,使用的是 TensorFlow Eager 和 Keras,然后试着抽离出来一些公共部分,将其封装成基础类,同时把模型独有的实现放开,供子类复写。然后在使用过程中自己还封装和改写过一些工具类,这部分也集成进来。另外就是把一些配置都规范化,将一些常用参数配置成默认参数,同时放开重写开关,在外部可以重定义。 秉承着上面的思想,我大约是在 10 月 6 日 那天完成了框架的搭建,然后在后续的几天基于这个框架实现了几个基础模型,最终打磨成了现在的样子。

框架介绍

GitHub 地址:https://github.com/ModelZoo/ModelZoo 框架我已经发布到 PyPi,直接使用 pip 安装即可,目前支持 Python3,Python 2 尚未做测试,安装方式:

1
pip3 install model-zoo

其实我是很震惊,这个名字居然没有被注册!GitHub 和 PyPi 都没有!不过现在已经被我注册了。 OK,接下来让我们看看用了它能怎样快速搭建一个模型吧! 我们就以基本的线性回归模型为例来说明吧,这里有一组数据,是波士顿房价预测数据,输入是影响房价的各个因素,输出是房价本身,具体的数据集可以搜 Boston housing price regression dataset 了解一下。 总之,我们只需要知道这是一个回归模型就好了,输入 x 是一堆 Feature,输出 y 是一个数值,房价。好,那么我们就开始定义模型吧,模型的定义我们继承 ModelZoo 里面的 BaseModel 就好了,实现 model.py 如下:

1
2
3
4
5
6
7
8
9
10
11
from model_zoo.model import BaseModel
import tensorflow as tf

class BostonHousingModel(BaseModel):
def __init__(self, config):
super(BostonHousingModel, self).__init__(config)
self.dense = tf.keras.layers.Dense(1)

def call(self, inputs, training=None, mask=None):
o = self.dense(inputs)
return o

好了,这就定义完了!有人会说,你的 Loss Function 呢?你的 Optimizer 呢?你的 Checkpoint 保存呢?你的 Tensor Summary 呢?不需要!因为我已经把这些配置封装到 BaseModel 了,有默认的 Loss Function、Optimizer、Checkpoint、Early Stop、Tensor Summary,这里只需要关注模型本身即可。 有人说,要是想自定义 Loss Function 咋办呢?自定义 Optimizer 咋办呢?很简单,只需要复写一些基本的配置或复写某个方法就好了。 如改写 Optimizer,只需要重写 optimizer 方法即可:

1
2
def optimizer(self):
return tf.train.AdamOptimizer(0.001)

好,定义了模型之后怎么办?那当然是拿数据训练了,又要写数据加载,数据标准化,数据切分等等操作了吧,写到什么方法里?定义成什么样比较科学?现在,我们只需要实现一个 Trainer 就好了,然后复写 prepare_data 方法就好了,实现 train.py 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import tensorflow as tf
from model_zoo.trainer import BaseTrainer
from model_zoo.preprocess import standardize

tf.flags.DEFINE_integer('epochs', 100, 'Max epochs')
tf.flags.DEFINE_string('model_class', 'BostonHousingModel', 'Model class name')

class Trainer(BaseTrainer):

def prepare_data(self):
from tensorflow.python.keras.datasets import boston_housing
(x_train, y_train), (x_eval, y_eval) = boston_housing.load_data()
x_train, x_eval = standardize(x_train, x_eval)
train_data, eval_data = (x_train, y_train), (x_eval, y_eval)
return train_data, eval_data

if __name__ == '__main__':
Trainer().run()

好了,完事了,模型现在已经全部搭建完成!在这里只需要实现 prepare_data 方法,返回训练集和验证集即可,其他的什么都不需要! 数据标准化在哪做的?这里我也封装好了方法。 运行在哪运行的?这里我也做好了封装。 模型保存在哪里做的?同样做好了封装。 Batch 切分怎么做的?这里也做好了封装。 我们只需要按照格式,返回这两组数据就好了,其他的什么都不用管! 那同样的,模型保存位置,模型名称,Batch Size 多大,怎么设置?还是简单改下配置就好了。 如要修改模型保存位置,只需要复写一个 Flag 就好了:

1
tf.flags.DEFINE_string('checkpoint_dir', 'checkpoints', help='Data source dir')

好了,现在模型可以训练了!直接运行上面的代码就好了:

1
python3 train.py

结果是这样子的:

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
Epoch 1/100
1/13 [=>............................] - ETA: 0s - loss: 816.1798
13/13 [==============================] - 0s 4ms/step - loss: 457.9925 - val_loss: 343.2489

Epoch 2/100
1/13 [=>............................] - ETA: 0s - loss: 361.5632
13/13 [==============================] - 0s 3ms/step - loss: 274.7090 - val_loss: 206.7015
Epoch 00002: saving model to checkpoints/model.ckpt

Epoch 3/100
1/13 [=>............................] - ETA: 0s - loss: 163.5308
13/13 [==============================] - 0s 3ms/step - loss: 172.4033 - val_loss: 128.0830

Epoch 4/100
1/13 [=>............................] - ETA: 0s - loss: 115.4743
13/13 [==============================] - 0s 3ms/step - loss: 112.6434 - val_loss: 85.0848
Epoch 00004: saving model to checkpoints/model.ckpt

Epoch 5/100
1/13 [=>............................] - ETA: 0s - loss: 149.8252
13/13 [==============================] - 0s 3ms/step - loss: 77.0281 - val_loss: 57.9716
....

Epoch 42/100
7/13 [===============>..............] - ETA: 0s - loss: 20.5911
13/13 [==============================] - 0s 8ms/step - loss: 22.4666 - val_loss: 23.7161
Epoch 00042: saving model to checkpoints/model.ckpt

可以看到模型每次运行都会实时输出训练集和验证集的 Loss 的变化,另外还会自动保存模型,自动进行 Early Stop,自动保存 Tensor Summary。 可以看到这里运行了 42 个 Epoch 就完了,为什么?因为 Early Stop 的存在,当验证集经过了一定的 Epoch 一直不见下降,就直接停了,继续训练下去也没什么意义了。Early Stop 哪里配置的?框架也封装好了。 然后我们还可以看到当前目录下还生成了 events 和 checkpoints 文件夹,这一个是 TensorFlow Summary,供 TensorBoard 看的,另一个是保存的模型文件。 现在可以打开 TensorBoard 看看有什么情况,运行命令:

1
2
cd events
tensorboard --logdir=.

可以看到训练和验证的 Loss 都被记录下来,并化成了图表展示。而这些东西我们配置过吗?没有,因为框架封装好了。 好,现在模型有了,我们要拿来做预测咋做呢?又得构建一边图,又得重新加载模型,又得准备数据,又得切分数据等等,还是麻烦,并没有,这里只需要这么定义就好了,定义 infer.py 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from model_zoo.inferer import BaseInferer
from model_zoo.preprocess import standardize
import tensorflow as tf

tf.flags.DEFINE_string('checkpoint_name', 'model.ckpt-20', help='Model name')

class Inferer(BaseInferer):

def prepare_data(self):
from tensorflow.python.keras.datasets import boston_housing
(x_train, y_train), (x_test, y_test) = boston_housing.load_data()
_, x_test = standardize(x_train, x_test)
return x_test

if __name__ == '__main__':
result = Inferer().run()
print(result)

这里只需要继承 BaseInferer,实现 prepare_data 方法就好了,返回的就是 test 数据集的 x 部分,其他的还是什么都不用干! 另外这里额外定义了一个 Flag,就是 checkpoint_name,这个是必不可少的,毕竟要用哪个 Checkpoint 需要指定一下。 这里我们还是那数据集中的数据当测试数据,来看下它的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[[ 9.637125 ]
[21.368305 ]
[20.898445 ]
[33.832504 ]
[25.756516 ]
[21.264557 ]
[29.069794 ]
[24.968184 ]
...
[36.027283 ]
[39.06852 ]
[25.728745 ]
[41.62165 ]
[34.340042 ]
[24.821484 ]]

就这样,预测房价结果就计算出来了,这个和输入的 x 内容都是一一对应的。 那有人又说了,我如果想拿到模型中的某个变量结果怎么办?还是很简单,因为有了 Eager 模式,直接输出就好。我要自定义预测函数怎么办?也很简单,复写 infer 方法就好了。 好,到现在为止,我们通过几十行代码就完成了这些内容:

  • 数据加载和预处理
  • 模型图的搭建
  • Optimizer 的配置
  • 运行结果的保存
  • Early Stop 的配置
  • Checkpoint 的保存
  • Summary 的生成
  • 预测流程的实现

总而言之,用了这个框架可以省去很多不必要的麻烦,同时相对来说比较规范,另外灵活可扩展。 以上就是 ModelZoo 的一些简单介绍。

愿景

现在这个框架刚开发出来几天,肯定存在很多不成熟的地方,另外文档也还没有来得及写,不过我肯定是准备长期优化和维护下去的。另外既然取名叫做 ModelZoo,我后面也会把一些常用的深度学习模型基于该框架实现出来并发布,包括 NLP、CV 等各大领域,同时在实现过程中,也会发现框架本身的一些问题,并不断迭代优化。 比如基于该框架实现的人脸情绪识别的项目:https://github.com/ModelZoo/EmotionRecognition 其识别准确率还是可以的,比如输入这些图片: 模型便可以输出对应的情绪类型和情绪分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Image Path: test1.png
Predict Result: Happy
Emotion Distribution: {'Angry': 0.0, 'Disgust': 0.0, 'Fear': 0.0, 'Happy': 1.0, 'Sad': 0.0, 'Surprise': 0.0, 'Neutral': 0.0}
====================
Image Path: test2.png
Predict Result: Happy
Emotion Distribution: {'Angry': 0.0, 'Disgust': 0.0, 'Fear': 0.0, 'Happy': 0.998, 'Sad': 0.0, 'Surprise': 0.0, 'Neutral': 0.002}
====================
Image Path: test3.png
Predict Result: Surprise
Emotion Distribution: {'Angry': 0.0, 'Disgust': 0.0, 'Fear': 0.0, 'Happy': 0.0, 'Sad': 0.0, 'Surprise': 1.0, 'Neutral': 0.0}
====================
Image Path: test4.png
Predict Result: Angry
Emotion Distribution: {'Angry': 1.0, 'Disgust': 0.0, 'Fear': 0.0, 'Happy': 0.0, 'Sad': 0.0, 'Surprise': 0.0, 'Neutral': 0.0}
====================
Image Path: test5.png
Predict Result: Fear
Emotion Distribution: {'Angry': 0.04, 'Disgust': 0.002, 'Fear': 0.544, 'Happy': 0.03, 'Sad': 0.036, 'Surprise': 0.31, 'Neutral': 0.039}
====================
Image Path: test6.png
Predict Result: Sad
Emotion Distribution: {'Angry': 0.005, 'Disgust': 0.0, 'Fear': 0.027, 'Happy': 0.002, 'Sad': 0.956, 'Surprise': 0.0, 'Neutral': 0.009}

如果大家对这个框架感兴趣,或者也想加入实现一些有趣的模型的话,可以在框架主页提 Issue 留言,我非常欢迎你的加入!另外如果大家感觉框架有不足的地方,也非常欢迎提 Issue 或发 PR,非常非常感谢! 最后,如果你喜欢的话,还望能赠予它一个 Star,这样我也更有动力去维护下去。 项目的 GitHub 地址:https://github.com/ModelZoo/ModelZoo。 谢谢!

Python

在线性回归模型中,我们实际上是建立了一个模型来拟合自变量和因变量之间的线性关系,但是在某些时候,我们要做的可能是一个分类模型,那么这里就可能用到线性回归模型的变种——逻辑回归,本节我们就逻辑回归来做一个详细的说明。

实例引入

我们还是以上一节的例子为例,张三、李四、王五、赵六都要贷款了,贷款时银行调查了他们的月薪和住房面积等因素,两个因素决定了贷款款项是否可以立即到账,下面列出来了他们四个人的工资情况、住房面积和到账的具体情况:

姓名

工资(元)

房屋面积(平方)

是否可立即到账

张三

6000

58

李四

9000

77

王五

11000

89

赵六

15000

54

看到了这样的数据,又来了一位孙七,他工资是 12000 元,房屋面积是 60 平,那的贷款可以立即到账吗?

思路探索

在这个例子中,我们不再是预测贷款金额具体是多少了,而是要对款项是否可以立即到账做一个分类,分类的结果要么是“否”要么是“是”,所以输出结果我们需要将其映射到一个区间内,让 0 代表“否”,1 代表“是”,这里我们就需要用一个函数来帮助我们实现这个功能,它的名字叫做 Sigmoid 函数,它的表达式是这样的: 它的函数图像是这样的: 它的定义域是全体实数,值域是 (0, 1),当自变量小于 -5 的时候,其值就趋近于 0,当自变量大于 5 的时候,其值就趋近于 1,当自变量为 0 的时候,其值就等于 1/2。 所以我们如果在线性回归模型的基础上加一层 Sigmoid 函数,结果不就可以被转化为 0 到 1 了吗?这就很自然而然地转化为了一个分类模型。 我们知道,线性回归模型的表达形式是这样的: 如果我们在它的基础上加一层 Sigmoid 函数,其表达式就变成了: 好,现在我们来考虑下这个表达式表达的什么意思。 我们举例来说吧,如果这个函数的输出结果为 1,那么我们肯定认为结果为“是”。如果输出结果为 0 呢?我们就当然认为结果为 “否”了,但如果输出结果为 0.5 呢?我们只能模棱两可,此时我们既可以判断为“是”,也可以判断为“否”,但恰好为 0.5 的概率太小了,多多少少也有点偏差吧。所以如果输出结果为 0.51,那么我们怎么认为?我们认为“是”的概率会更大,否的概率更小,概率是多少?0.49。如果输出结果是 0.95 呢?“是”的概率是多少?不用多说,必然是 0.95。 因此,我们可以看出来,函数输出的结果就代表了预测为“是”的概率,也就是真实结果为 1 的概率。我们用公式表达下,这里我们用 $ h{theta}(x) $ 表示预测结果,因此对于输入 x,分类类别为 1 和 0 的概率分别为: $$ \begin{cases} P(y = 1|x;\theta) = h{\theta}(x) \\\\ P(y = 0|x;\theta) = 1 - h{\theta}(x) \end{cases} J(\theta) = \dfrac{1}{2m}\sum{i=1}^{m}(h\theta(x^{(i)}) - y^{(i)})^2 Cost(h\theta(x), y) = \begin{cases} -log(h\theta(x)), y = 1 \\\\ -log(1- h\theta(x)), y = 0 \end{cases} Cost(h\theta(x), y) = -ylog(h\theta(x)) - (1-y)log(1-h\theta(x)) J(\theta) = \dfrac{1}{m}\sum{i=1}^{m}Cost(h\theta(x^{(i)}), y^{(i)})\\\\ = -\dfrac{1}{m}\sum{i=1}^{m}[y^{(i)}log(h*\theta(x^{(i)})) + (1-y^{(i)})log(1-h_\theta(x^{(i)}))] $$ 和线性回归类似,这次我们的目的就是找到 $ theta $,使得 $ J(theta) $ 最小。

推导过程

这次我们就不能向上次一样用直接求偏导数,然后将偏导数置 0 的方法来求解 $ theta $ 了,这样是无法求解出具体的值的。 我们可以使用梯度下降法来进行求解,也就是一步步地求出 $theta$ 的更新过程,公式如下: 这里面最主要的就是求偏导,过程如下: 因此: 这时候我们发现,其梯度下降的推导结果和线性回归是一样的!

编程实现

下面我们还是用 Sklearn 中的 API 来实现逻辑回归模型,使用的库为 LogisticRegression,其 API 如下:

1
class sklearn.linear_model.LogisticRegression(penalty=’l2’, dual=False, tol=0.0001, C=1.0, fit_intercept=True, intercept_scaling=1, class_weight=None, random_state=None, solver=’liblinear’, max_iter=100, multi_class=’ovr’, verbose=0, warm_start=False, n_jobs=1)

参数说明如下:

  • penalty:惩罚项,str 类型,可选参数为 l1 和 l2,默认为 l2。用于指定惩罚项中使用的规范。newton-cg、sag 和 lbfgs 求解算法只支持 L2 规范。L1G 规范假设的是模型的参数满足拉普拉斯分布,L2 假设的模型参数满足高斯分布,所谓的范式就是加上对参数的约束,使得模型更不会过拟合(overfit),但是如果要说是不是加了约束就会好,这个没有人能回答,只能说,加约束的情况下,理论上应该可以获得泛化能力更强的结果。
  • dual:对偶或原始方法,bool 类型,默认为 False。对偶方法只用在求解线性多核(liblinear)的 L2 惩 罚项上。当样本数量 > 样本特征的时候,dual 通常设置为 False。
  • tol:停止求解的标准,float 类型,默认为 1e-4。就是求解到多少的时候,停止,认为已经求出最优解。
  • c:正则化系数 λ 的倒数,float 类型,默认为 1.0。必须是正浮点型数。像 SVM 一样,越小的数值表示越强的正则化。
  • fit_intercept:是否存在截距或偏差,bool 类型,默认为 True。
  • intercept_scaling:仅在正则化项为”liblinear”,且 fit_intercept 设置为 True 时有用。float 类型,默认为 1。
  • class_weight:用于标示分类模型中各种类型的权重,可以是一个字典或者’balanced’字符串,默认为不输入,也就是不考虑权重,即为 None。如果选择输入的话,可以选择 balanced 让类库自己计算类型权重,或者自己输入各个类型的权重。举个例子,比如对于 0,1 的二元模型,我们可以定义 class_weight={0:0.9,1:0.1},这样类型 0 的权重为 90%,而类型 1 的权重为 10%。如果 class_weight 选择 balanced,那么类库会根据训练样本量来计算权重。某种类型样本量越多,则权重越低,样本量越少,则权重越高。
  • random_state:随机数种子,int 类型,可选参数,默认为无,仅在正则化优化算法为 sag,liblinear 时有用。
  • solver:优化算法选择参数,只有五个可选参数,即 newton-cg, lbfgs, liblinear, sag, saga。默认为 liblinear。solver 参数决定了我们对逻辑回归损失函数的优化方法,有四种算法可以选择,分别是:
    • liblinear:使用了开源的 liblinear 库实现,内部使用了坐标轴下降法来迭代优化损失函数。
    • lbfgs:拟牛顿法的一种,利用损失函数二阶导数矩阵即海森矩阵来迭代优化损失函数。
    • newton-cg:也是牛顿法家族的一种,利用损失函数二阶导数矩阵即海森矩阵来迭代优化损失函数。
    • sag:即随机平均梯度下降,是梯度下降法的变种,和普通梯度下降法的区别是每次迭代仅仅用一部分的样本来计算梯度,适合于样本数据多的时候。
    • saga:线性收敛的随机优化算法的的变重。
  • max_iter:算法收敛最大迭代次数,int 类型,默认为 10。仅在正则化优化算法为 newton-cg, sag 和 lbfgs 才有用,算法收敛的最大迭代次数。
  • multi_class:分类方式选择参数,str 类型,可选参数为 ovr 和 multinomial,默认为 ovr。ovr 即前面提到的 one-vs-rest(OvR),而 multinomial 即前面提到的 many-vs-many(MvM)。如果是二元逻辑回归,ovr 和 multinomial 并没有任何区别,区别主要在多元逻辑回归上。
  • verbose:日志冗长度,int 类型。默认为 0。就是不输出训练过程,1 的时候偶尔输出结果,大于 1,对于每个子模型都输出。
  • warm_start:热启动参数,bool 类型。默认为 False。如果为 True,则下一次训练是以追加树的形式进行(重新使用上一次的调用作为初始化)。
  • n_jobs:并行数。int 类型,默认为 1。1 的时候,用 CPU 的一个内核运行程序,2 的时候,用 CPU 的 2 个内核运行程序。为 -1 的时候,用所有 CPU 的内核运行程序。

属性说明如下:

  • coef_:斜率
  • intercept_:截距项

我们现在来解决上面的示例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from sklearn.linear_model import LogisticRegression

x_data = [
[6000, 58],
[9000, 77],
[11000, 89],
[15000, 54]
]
y_data = [
0, 0, 1, 1
]

lr = LogisticRegression()
lr.fit(x_data, y_data)
x_test = [[12000, 60]]
print('Intercept', lr.intercept_)
print('Coef', lr.coef_)
print('款项是否可以立即到账', lr.predict(x_test)[0])

这里我们的 y_data 数据就变了,变成了是非判断,这里 0 代表“否”,1 代表“是”。这里做逻辑回归时使用了 LogisticRegression 对象,然后调用 fit() 方法进行拟合,拟合完毕之后,使用 [12000, 60] 这条数据作为输入,让模型输出它的预测值。 运行结果:

1
2
3
Intercept [-0.03142387]
Coef [[ 0.00603919 -0.72587703]]
款项是否可以立即到账 1

结果中输出了截距项和系数项,然后预测了是否可以立即到账,结果为 1,这表明孙七进行贷款,款项可以立即到账。 以上就是逻辑回归的基本推导和代码应用实现。

Python

六一八来了,现在各大平台都开始促销了,作为一名程序员,除了自己买一些大件和帮女朋友疯狂抢购,最好的选择就是买书好好学习技术了。 关注我的朋友可能很多都是学习 Python、爬虫、Web、数据分析、机器学习相关的。当然大家可能接触某个方向的时间不一样,可能有的同学已经对某个方向特别精通,有的同学在某个方向还处于入门阶段。 所以我花时间调研了一些大佬的推荐书单,同时结合我个人所看的一些书籍,另外参考了豆瓣、京东等排行榜和一些书评,选取了一些经典好评书籍和近期上市且反响不错的书籍,推荐给大家,内容涉及 Python 基础、**数据分析、机器学习、Web 开发等方向。 另外当当这边也有相当大力度的折扣,我也联系了当当获取了两个优惠码,大家可以用起来!这里也没有推销的意思,也没有用官方推荐的书单,是单纯根据书的内容来推荐的。 现在当当的书是每满 100 减 50,下面给大家两个优惠码,可以直接在满减的基础上继续折扣。 全品类优惠码: Y6NDPP 这个码买什么书都可以享受优惠,可享受满 200 减 40 优惠,叠加后相当于满 400 减 240,即 160 元可以购买 400 元的书籍,在当当 App 或小程序上下单有效。 计算机类: P6HFCE 这个码只能买计算机类的图书,可享受满 100 减 20 优惠,叠加后相当于满 200 减 120,在当当 App 或小程序上下单有效。 注意这两个优惠码有效期到 2019.06.20,过期就不能用了,而且数量是有限**的,多人共享,到了一定的使用次数就不能用了,后面我也没有办法补了,所以还是先到先得。 好,下面是我挑选的一些书籍,大家可以自行选购。也由于个人水平有限,很可能大家觉得优秀的书籍没有列出,如果大家有觉得不错的书籍,欢迎大家留言,大家也可以参考留言区的书籍来购买,谢谢大家支持。

Python 编程

这一部分书籍是单纯讲解 Python 编程的,有入门的有进阶的,大家可以参考评分和出版时间选购。

Python 入门

《Python 编程:从入门到实践》/ 豆瓣 9.1 / 2016-7-1 出版 / [美] 埃里克·马瑟斯 《Python 基础教程(第 3 版)》/ 豆瓣 8.0 / 2018-2-1 出版 / [挪] 海特兰德 《Python 编程快速上手》/ 豆瓣 9.0 / 2016-7-1 出版 / [美] 思维加特 《笨办法学 Python3》/ 豆瓣 8.4 / 2018-6-1 出版 / [美] 泽德

Python 进阶

《流畅的 Python》/ 豆瓣 9.4 / 2017-5-15 出版 / [巴西] 拉马略 《Python Cookbook 中文版》/ 豆瓣 9.2 / 2015-5-1 出版 / [美] 比斯利 《Effective Python》/ 豆瓣 9.0 / 2016-1-18 出版 / [美] 斯拉特金 《Python 编程之美》/ 豆瓣 8.4 / 2018-8-1 出版 / [美] 肯尼思·赖茨 Kenneth Reitz 《Python 高性能编程》/ 豆瓣 7.2 / 2017-7-1 出版 / [美] 戈雷利克

数据及算法

网络爬虫

《Python 网络爬虫权威指南(第 2 版)》/ 新书 / 2019-4-1 出版 / [美] 米切尔 《Python3 网络爬虫开发实战》/ 豆瓣 9.0 / 2018-4-1 出版 / 崔庆才 数据分析 《利用 Python 进行数据分析(第 2 版)》/ 豆瓣 8.5 / 2018-7-30 出版 / [美] 麦金尼 《对比 Excel,轻松学习 Python 数据分析》/ 豆瓣 7.8 / 2019-2-1 出版 / 张俊红 《Python 数据分析与挖掘实战》/ 豆瓣 7.8 / 2016-1-1 出版 / 张良均

机器学习

《统计学习方法(第 2 版)》/ 豆瓣 9.5 / 2018-5-5 出版 / 李航 《白话深度学习与 TensorFlow》/ 豆瓣 7.1 / 2017-7-31 出版 / 高扬 《机器学习》/ 豆瓣 8.7 / 2016-1-1 出版 / 周志华 《Python 机器学习基础教程》/ 豆瓣 8.2 / 2018-1-1 出版 / [德] 穆勒 《Python 深度学习》/ 豆瓣 9.6 / 2018-8-1 出版 / [美] 肖莱 《Python 神经网络编程》/ 豆瓣 9.0 / 2018-4-1 出版 / [英] 拉希德

前后端技术

前端

《你不知道的 JavaScript》/ 豆瓣 9.3 / 2018-1-1 出版 / [美] 辛普森 《深入浅出 Vue.js》/ 新书 / 2018-3-1 出版 / 刘博文

后端

《Django 企业开发实战》/ 豆瓣 7.7 / 2019-2-1 出版 / 胡阳 《Flask Web 开发(第 2 版)》/ 豆瓣 9.4 / 2018-8-1 出版 / [美] 格雷贝格 《深入浅出 Docker》/ 豆瓣 8.4 / 2019-4-1 出版 / [英] 波尔顿 《深入理解 Kafka》/ 新书 / 2019-1-1 出版 / 朱忠华 《Kubernetes 权威指南(第 4 版)》/ 新书 / 2018-5-1 出版 / 龚正

程序员经典

这是一些程序员通用的技能书,非常经典而且有用,几乎是必备书籍,强烈推荐。 《集体智慧编程》/ 豆瓣 9.0 / 2015-3-1 出版 / 西格兰 《程序员的数学》/ 豆瓣 8.6 / 2016-6-25 出版 / [日] 结城浩 《程序员修炼之道》/ 豆瓣 8.6 / 2011-1-1 出版 / [美] 亨特 《代码整洁之道》/ 豆瓣 8.6 / 2016-9-1 出版 / [美] 罗伯特 《持续交付 2.0》/ 豆瓣 9.4 / 2019-1-1 出版 / 乔梁 《鸟哥的 Linux 私房菜(第 4 版)》/ 豆瓣 8.0 / 2018-11-1 出版 / 鸟哥 《颈椎病康复指南》/ 豆瓣 8.6 / 2012-4-1 出版 / 陈选宁 以上便是我所推荐的书籍,数量有限,如果大家还有推荐的书籍,欢迎留言~

Python

线性回归是机器学习中最基本的算法了,一般要学习机器学习都要从线性回归开始讲起,本节就对线性回归做一个详细的解释。

实例引入

在讲解线性回归之前,我们首先引入一个实例,张三、李四、王五、赵六都要贷款了,贷款时银行调查了他们的月薪和住房面积等因素,月薪越高,住房面积越大,可贷款金额越多,下面列出来了他们四个人的工资情况、住房面积和可贷款金额的具体情况:

姓名

工资(元)

房屋面积(平方)

可贷款金额(元)

张三

6000

58

30000

李四

9000

77

55010

王五

11000

89

73542

赵六

15000

54

63201

看到了这样的数据,又来了一位孙七,他工资是 12000 元,房屋面积是 60 平,那他大约能贷款多少呢?

思路探索

那这时候应该往哪方面考虑呢?如果我们假定可贷款金额和工资、房屋面积都是线性相关的,要解决这个问题,首先我们想到的应该就是初高中所学的一次函数吧,它的一般表达方式是 $ y = wx + b $,$ x $ 就是自变量,$ y $ 就是因变量,$ w $ 是自变量的系数,$ b $ 是偏移量,这个式子表明 $ y $ 和 $ x $ 是线性相关的,$ y $ 会随着 $ x $ 的变化而呈现线性变化。 现在回到我们的问题中,情况稍微不太一样,这个例子中是可贷款金额会随着工资和房屋面积而呈现线性变化,此时如果我们将工资定义为 $ x1 $,房屋面积定义为 $ x_2 $,可贷款金额定义为 $ y $,那么它们三者的关系就可以表示为: $ y = w_1x_1 + w_2x_2 + b $,这里的自变量就不再是一个了,而是两个,分别是 $ x_1 $ 和 $ x_2 $,自变量系数就表示为了 $ w_1 $ 和 $ w_2 $,我们将其转化为表达的形式,同时将变量的名字换一下,就成了这个样子: $$ h{\theta}(x) = \theta_0 + \theta_1x_1 + \theta_2x_2 $$ 这里只不过是将原表达式转为函数形式,换了个表示名字,另外参数名称从 $ w $ 换成了 $ \theta $,$ b $ 换成了 $ \theta_0 $,为什么要换?因为在机器学习算法中 $ \theta $ 用的更广泛一些,约定俗成。 然后这个问题怎么解?我们只需要求得一组近似的 $ \theta $ 参数使得我们的函数可以拟合已有的数据,然后整个函数表达式就可以表示出来了,然后再将孙七的工资和房屋面积代入进去,就求出来他可以贷款的金额了。

思路拓展

那假如此时情景变一变,变得更复杂一些,可贷款金额不仅仅和工资、房屋面积有关,还有当前存款数、年龄等等因素有关,那我们的表达式应该怎么写?不用担心,我们有几个影响因素,就写定义几个变量,比如我们可以将存款数定义为 $ x3 $,年龄定义为 $ x_4 $,如果还有其他影响因素我们可以继续接着定义,如果一共有 $ n $ 个影响因素,我们就定义到 $ x_n $,这时候函数表达式就可以变成这样子了: $$ h{\theta}(x) = \theta0 + \theta_1x_1 + \theta_2x_2 + … + \theta_nx_n h{\theta}(x) = \sum_{i=0}^{n}\theta_ix_i = \theta^Tx $$ 如果要使得这个公式成立,这里需要满足一个条件就是 $ x_0 = 1 $,其实在实际场景中 $ x_0 $ 是不存在的,因为第一个影响因素我们用 $ x_1 $ 来表示了,第二个影响因素我们用 $ x_2 $ 来表示了,依次类推。所以这里我们直接指定 $ x_0 = 1 $ 即可。 后来我们又将公式简化为线性代数的向量表示,这里 $ \theta^T $ 是 $ \theta $ 向量转置的结果,而 $ \theta $ 向量又表示为 $ (\theta_0, \theta_1, …, \theta_n) $,同样地,$ x $ 向量可以表示为 $ (x_0, x_1, …, x_n) $,总之,表达成后面的式子,看起来更简洁明了。 好了,这就是最基本的线性判别解析函数的写法,是不是很简单。

实际求解

那接下来我们怎样实际求解这个问题呢?比如拿张三的数据代入到这个函数表达式中,这里还是假设有两个影响因素,张三的数据我们可以表示为 $ x1^{(1)} = 6000, x_2^{(1)} = 58, y^{(1)} = 30000 $,注意这里我们在数据的右上角加了一个小括号,里面带有数字,如果我们把张三的数据看成一个条目,那么这个数字就代表了这个条目的序号,1 就代表第一条数据,2 就代表第二条数据,为啥这么写?也是约定俗成,以后也会经常采用这样的写法,记住就好了。 所以,我们的愿景是要使得我们的函数能够拟合当前的这条数据,所以我们希望是这样的情况: $$ y^{(1)} = \sum{i=0}^{n}\theta{i}x{i}^{(1)} = \theta^Tx^{(1)} h{\theta}(x^{(1)}) = \sum{i=0}^{n}\theta{i}x{i}^{(1)} = \theta^Tx^{(1)} (h{\theta}(x^{(1)}) - y^{(1)})^2 (h{\theta}(x^{(2)}) - y^{(2)})^2 (h{\theta}(x^{(i)}) - y^{(i)})^2 J(\theta) = \dfrac{1}{2m}\sum{i=1}^{m}(h*\theta(x^{(i)}) - y^{(i)})^2 $$ 注意这里 $ i $ 指的是第几条数据,是从 1 开始的,一直到 $ m $ 为止,然后使用了求和公式对每一条数据的误差进行累加和,最后除以了 $ 2m $,我们的最终目的就是找出合适的 $ \theta $,使得这个 $ J(\theta ) $ 的值最小,即误差最小,在机器学习中,我们就把 $ J(\theta) $ 称为损失函数(Loss Function),即我们要使得损失值最小。 有的小伙伴可能好奇损失函数前面为什么是 $ 2m $,而不是 $ m $?因为我们后面要用到这个算式的导数,所以这里多了个 2 是为了便于求导计算。况且一个表达式要求最小值,前面乘一个常数是对结果没影响的。

求解过程

由于我们求解的是线性回归问题,所以整个损失函数的图像非常简单清晰,如果只有 $ \theta1 $ 和 $ \theta_2 $ 两个参数,我们甚至可以直接画出其图像,整个损失函数大小随 $ \theta_1 $ 和 $ \theta_2 $ 的变化实际上类似于这样子: 可以看到这是一个凸函数,竖轴代表损失函数的大小,横纵两轴代表 $ \theta_1 $ 和 $ \theta_2 $ 的变化,可见在中间的最低谷损失函数取得最小值,这时候损失函数在 $ \theta_1 $ 和 $ \theta_2 $ 上的导数都是 0,因此我们可以一步到位,直接用偏导置零的方式来求解损失函数取得最小值时的 $ \theta $ 值的大小。 所以我们可以先对每个 $ \theta $ 求解其偏导结果,这里 $ \theta $ 表示为 $ \theta_j $,代表 $ \theta $ 中的某一维: $$ \dfrac{\partial{J(\theta)}}{\partial{\theta_j}} = \dfrac{1}{2m} \dfrac{\partial({\sum{i=1}^{m}{(y^{(i)} - h{\theta}(x^{(i)}))^2}})}{\partial{\theta_j}} \\\\ =\dfrac{1}{m}\sum{i=1}^{m}((h{\theta}(x^{(i)}) - y^{(i)})x_j^{(i)}) \sum{i=1}^{m} {h{\theta}(x^{(i)})x_j^{(i)}} - \sum{i=1}^{m}y^{(i)}xj^{(i)} = 0 \theta_j = \theta_j - \alpha\dfrac{\partial{J(\theta)}}{\partial{\theta_j}} \\\\ = \theta_j - \dfrac{\alpha}{m}\sum{i=1}^{m}((h_{\theta}(x^{(i)}) - y^{(i)})x_j^{(i)}) $$ 这里 $ \alpha $ 就是学习率,$ \theta_j $ 每经过一步都会进行一次更新,得到新的结果,经过梯度下降过程,$ \theta_j $ 都会更新为使得梯度最小化的数值,最后就完成了 $ \theta $ 的求解。 以上便是线性回归的整个推导和求解过程。

实战操作

现在呢,我们想要根据前面的数据来求解这个真实的问题,为了解决这个问题,我们在这里用 Python 的 Sklearn 库来实现。 对于线性回归来说,Sklearn 已经做好了封装,直接使用 LinearRegression 即可。 它的 API 如下:

1
class sklearn.linear_model.LinearRegression(fit_intercept=True, normalize=False, copy_X=True, n_jobs=None)

参数解释如下:

  • fit_intercept : 布尔值,是否使用偏置项,默认是 True。
  • normalize : 布尔值,是否启用归一化,默认是 False。当 fit_intercept 被置为 False 的时候,这个参数会被忽略。当该参数为 True 时,数据会被归一化处理。
  • copy_X : 布尔值,默认是 True,如果为 True,x 参数会被拷贝不会影响原来的值,否则会被复写。
  • n_jobs:数值或者布尔,如果设置了,则多核并行处理。

属性如下:

  • coef_:x 的权重系数大小
  • intercept_:偏置项大小

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from sklearn.linear_model import LinearRegression

x_data = [
[6000, 58],
[9000, 77],
[11000, 89],
[15000, 54]
]
y_data = [
30000, 55010, 73542, 63201
]

lr = LinearRegression()
lr.fit(x_data, y_data)
print('方程为:y={w1}x1+{w2}x2+{b}'.format(w1=round(lr.coef_[0], 2),
w2=round(lr.coef_[1], 2),
b=lr.intercept_))
x_test = [[12000, 60]]
print('贷款金额为:', lr.predict(x_test)[0])

运行结果:

1
2
方程为:y=4.06x1+743.15x2+-37831.862532707615
贷款金额为:55484.33779181102

在这里我们首先声明了 LinearRegression 对象,然后将数据整合成 xdata 和 y_data 的形式,然后通过调用 fit() 方法来对数据进行拟合。 拟合完毕之后,LinearRegression 的 coef 对象就是各个 x 变量的权重大小,即对应着 $ \theta1, \theta_2 $,intercept 则是偏移量,对应着 $ \theta_0 $,这样我们就可以得到一个线性回归表达式了。 然后我们再调用 predict() 方法,将新的测试数据传入,便可以得到其预测结果,最终结果为 55484.34,即孙七的可贷款额度为 55484.34 元。 以上便是机器学习中线性回归算法的推导解析和相关调用实现。

Python

Python 是支持面向对象的,很多情况下使用面向对象编程会使得代码更加容易扩展,并且可维护性更高,但是如果你写的多了或者某一对象非常复杂了,其中的一些写法会相当相当繁琐,而且我们会经常碰到对象和 JSON 序列化及反序列化的问题,原生的 Python 转起来还是很费劲的。 可能这么说大家会觉得有点抽象,那么这里举几个例子来感受一下。 首先让我们定义一个对象吧,比如颜色。我们常用 RGB 三个原色来表示颜色,R、G、B 分别代表红、绿、蓝三个颜色的数值,范围是 0-255,也就是每个原色有 256 个取值。如 RGB(0, 0, 0) 就代表黑色,RGB(255, 255, 255) 就代表白色,RGB(255, 0, 0) 就代表红色,如果不太明白可以具体看看 RGB 颜色的定义哈。 好,那么我们现在如果想定义一个颜色对象,那么正常的写法就是这样了,创建这个对象的时候需要三个参数,就是 R、G、B 三个数值,定义如下:

1
2
3
4
5
6
7
8
class Color(object):
"""
Color Object of RGB
"""
def __init__(self, r, g, b):
self.r = r
self.g = g
self.b = b

其实对象一般就是这么定义的,初始化方法里面传入各个参数,然后定义全局变量并赋值这些值。其实挺多常用语言比如 Java、PHP 里面都是这么定义的。但其实这种写法是比较冗余的,比如 r、g、b 这三个变量一写就写了三遍。 好,那么我们初始化一下这个对象,然后打印输出下,看看什么结果:

1
2
color = Color(255, 255, 255)
print(color)

结果是什么样的呢?或许我们也就能看懂一个 Color 吧,别的都没有什么有效信息,像这样子:

1
<__main__.Color object at 0x103436f60>

我们知道,在 Python 里面想要定义某个对象本身的打印输出结果的时候,需要实现它的 __repr__ 方法,所以我们比如我们添加这么一个方法:

1
2
def __repr__(self):
return f'{self.__class__.__name__}(r={self.r}, g={self.g}, b={self.b})'

这里使用了 Python 中的 fstring 来实现了 __repr__ 方法,在这里我们构造了一个字符串并返回,字符串中包含了这个 Color 类中的 r、g、b 属性,这个返回的结果就是 print 的打印结果,我们再重新执行一下,结果就变成这样子了:

1
Color(r=255, g=255, b=255)

改完之后,这样打印的对象就会变成这样的字符串形式了,感觉看起来清楚多了吧? 再继续,如果我们要想实现这个对象里面的 __eq____lt__ 等各种方法来实现对象之间的比较呢?照样需要继续定义成类似这样子的形式:

1
2
3
def __lt__(self, other):
if not isinstance(other, self.__class__): return NotImplemented
return (self.r, self.g, self.b) < (other.r, other.g, other.b)

这里是 __lt__ 方法,有了这个方法就可以使用比较符来对两个 Color 对象进行比较了,但这里又把这几个属性写了两遍。 最后再考虑考虑,如果我要把 JSON 转成 Color 对象,难道我要读完 JSON 然后一个个属性赋值吗?如果我想把 Color 对象转化为 JSON,又得把这几个属性写几遍呢?如果我突然又加了一个属性比如透明度 a 参数,那么整个类的方法和参数都要修改,这是极其难以扩展的。不知道你能不能忍,反正我不能忍! 如果你用过 Scrapy、Django 等框架,你会发现 Scrapy 里面有一个 Item 的定义,只需要定义一些 Field 就可以了,Django 里面的 Model 也类似这样,只需要定义其中的几个字段属性就可以完成整个类的定义了,非常方便。 说到这里,我们能不能把 Scrapy 或 Django 里面的定义模式直接拿过来呢?能是能,但是没必要,因为我们还有专门为 Python 面向对象而专门诞生的库,没错,就是 attrs 和 cattrs 这两个库。 有了 attrs 库,我们就可以非常方便地定义各个对象了,另外对于 JSON 的转化,可以进一步借助 cattrs 这个库,非常有帮助。 说了这么多,还是没有介绍这两个库的具体用法,下面我们来详细介绍下。

安装

安装这两个库非常简单,使用 pip 就好了,命令如下:

1
pip3 install attrs cattrs

安装好了之后我们就可以导入并使用这两个库了。

简介与特性

首先我们来介绍下 attrs 这个库,其官方的介绍如下:

attrs 是这样的一个 Python 工具包,它能将你从繁综复杂的实现上解脱出来,享受编写 Python 类的快乐。它的目标就是在不减慢你编程速度的前提下,帮助你来编写简洁而又正确的代码。

其实意思就是用了它,定义和实现 Python 类变得更加简洁和高效。

基本用法

首先明确一点,我们现在是装了 attrs 和 cattrs 这两个库,但是实际导入的时候是使用 attr 和 cattr 这两个包,是不带 s 的。 在 attr 这个库里面有两个比较常用的组件叫做 attrs 和 attr,前者是主要用来修饰一个自定义类的,后者是定义类里面的一个字段的。有了它们,我们就可以将上文中的定义改写成下面的样子:

1
2
3
4
5
6
7
8
9
10
11
from attr import attrs, attrib

@attrs
class Color(object):
r = attrib(type=int, default=0)
g = attrib(type=int, default=0)
b = attrib(type=int, default=0)

if __name__ == '__main__':
color = Color(255, 255, 255)
print(color)

看我们操作的,首先我们导入了刚才所说的两个组件,然后用 attrs 里面修饰了 Color 这个自定义类,然后用 attrib 来定义一个个属性,同时可以指定属性的类型和默认值。最后打印输出,结果如下:

1
Color(r=255, g=255, b=255)

怎么样,达成了一样的输出效果! 观察一下有什么变化,是不是变得更简洁了?r、g、b 三个属性都只写了一次,同时还指定了各个字段的类型和默认值,另外也不需要再定义 __init__ 方法和 __repr__ 方法了,一切都显得那么简洁。一个字,爽! 实际上,主要是 attrs 这个修饰符起了作用,然后根据定义的 attrib 属性自动帮我们实现了 __init____repr____eq____ne____lt____le____gt____ge____hash__ 这几个方法。 如使用 attrs 修饰的类定义是这样子:

1
2
3
4
5
6
from attr import attrs, attrib

@attrs
class SmartClass(object):
a = attrib()
b = attrib()

其实就相当于已经实现了这些方法:

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
class RoughClass(object):
def __init__(self, a, b):
self.a = a
self.b = b

def __repr__(self):
return "RoughClass(a={}, b={})".format(self.a, self.b)

def __eq__(self, other):
if other.__class__ is self.__class__:
return (self.a, self.b) == (other.a, other.b)
else:
return NotImplemented

def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return NotImplemented
else:
return not result

def __lt__(self, other):
if other.__class__ is self.__class__:
return (self.a, self.b) < (other.a, other.b)
else:
return NotImplemented

def __le__(self, other):
if other.__class__ is self.__class__:
return (self.a, self.b) <= (other.a, other.b)
else:
return NotImplemented

def __gt__(self, other):
if other.__class__ is self.__class__:
return (self.a, self.b) > (other.a, other.b)
else:
return NotImplemented

def __ge__(self, other):
if other.__class__ is self.__class__:
return (self.a, self.b) >= (other.a, other.b)
else:
return NotImplemented

def __hash__(self):
return hash((self.__class__, self.a, self.b))

所以说,如果我们用了 attrs 的话,就可以不用再写这些冗余又复杂的代码了。 翻看源码可以发现,其内部新建了一个 ClassBuilder,通过一些属性操作来动态添加了上面的这些方法,如果想深入研究,建议可以看下 attrs 库的源码。

别名使用

这时候大家可能有个小小的疑问,感觉里面的定义好乱啊,库名叫做 attrs,包名叫做 attr,然后又导入了 attrs 和 attrib,这太奇怪了。为了帮大家解除疑虑,我们来梳理一下它们的名字。 首先库的名字就叫做 attrs,这个就是装 Python 包的时候这么装就行了。但是库的名字和导入的包的名字确实是不一样的,我们用的时候就导入 attr 这个包就行了,里面包含了各种各样的模块和组件,这是完全固定的。 好,然后接下来看看 attr 包里面包含了什么,刚才我们引入了 attrs 和 attrib。 首先是 attrs,它主要是用来修饰 class 类的,而 attrib 主要是用来做属性定义的,这个就记住它们两个的用法就好了。 翻了一下源代码,发现其实它还有一些别名:

1
2
s = attributes = attrs
ib = attr = attrib

也就是说,attrs 可以用 s 或 attributes 来代替,attrib 可以用 attr 或 ib 来代替。 既然是别名,那么上面的类就可以改写成下面的样子:

1
2
3
4
5
6
7
8
9
10
11
from attr import s, ib

@s
class Color(object):
r = ib(type=int, default=0)
g = ib(type=int, default=0)
b = ib(type=int, default=0)

if __name__ == '__main__':
color = Color(255, 255, 255)
print(color)

是不是更加简洁了,当然你也可以把 s 改写为 attributes,ib 改写为 attr,随你怎么用啦。 不过我觉得比较舒服的是 attrs 和 attrib 的搭配,感觉可读性更好一些,当然这个看个人喜好。 所以总结一下:

  • 库名:attrs
  • 导入包名:attr
  • 修饰类:s 或 attributes 或 attrs
  • 定义属性:ib 或 attr 或 attrib

OK,理清了这几部分内容,我们继续往下深入了解它的用法吧。

声明和比较

在这里我们再声明一个简单一点的数据结构,比如叫做 Point,包含 x、y 的坐标,定义如下:

1
2
3
4
5
6
from attr import attrs, attrib

@attrs
class Point(object):
x = attrib()
y = attrib()

其中 attrib 里面什么参数都没有,如果我们要使用的话,参数可以顺次指定,也可以根据名字指定,如:

1
2
3
4
p1 = Point(1, 2)
print(p1)
p2 = Point(x=1, y=2)
print(p2)

其效果都是一样的,打印输出结果如下:

1
2
Point(x=1, y=2)
Point(x=1, y=2)

OK,接下来让我们再验证下类之间的比较方法,由于使用了 attrs,相当于我们定义的类已经有了 __eq____ne____lt____le____gt____ge__ 这几个方法,所以我们可以直接使用比较符来对类和类之间进行比较,下面我们用实例来感受一下:

1
2
3
4
5
6
print('Equal:', Point(1, 2) == Point(1, 2))
print('Not Equal(ne):', Point(1, 2) != Point(3, 4))
print('Less Than(lt):', Point(1, 2) < Point(3, 4))
print('Less or Equal(le):', Point(1, 2) <= Point(1, 4), Point(1, 2) <= Point(1, 2))
print('Greater Than(gt):', Point(4, 2) > Point(3, 2), Point(4, 2) > Point(3, 1))
print('Greater or Equal(ge):', Point(4, 2) >= Point(4, 1))

运行结果如下:

1
2
3
4
5
6
7
Same: False
Equal: True
Not Equal(ne): True
Less Than(lt): True
Less or Equal(le): True True
Greater Than(gt): True True
Greater or Equal(ge): True

可能有的朋友不知道 ne、lt、le 什么的是什么意思,不过看到这里你应该明白啦,ne 就是 Not Equal 的意思,就是不相等,le 就是 Less or Equal 的意思,就是小于或等于。 其内部怎么实现的呢,就是把类的各个属性转成元组来比较了,比如 Point(1, 2) < Point(3, 4) 实际上就是比较了 (1, 2)(3, 4) 两个元组,那么元组之间的比较逻辑又是怎样的呢,这里就不展开了,如果不明白的话可以参考官方文档:https://docs.python.org/3/library/stdtypes.html#comparisons

属性定义

现在看来,对于这个类的定义莫过于每个属性的定义了,也就是 attrib 的定义。对于 attrib 的定义,我们可以传入各种参数,不同的参数对于这个类的定义有非常大的影响。 下面我们就来详细了解一下每个属性的具体参数和用法吧。 首先让我们概览一下总共可能有多少可以控制一个属性的参数,我们用 attrs 里面的 fields 方法可以查看一下:

1
2
3
4
5
6
7
8
from attr import attrs, attrib, fields

@attrs
class Point(object):
x = attrib()
y = attrib()

print(fields(Point))

这就可以输出 Point 的所有属性和对应的参数,结果如下:

1
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False))

输出出来了,可以看到结果是一个元组,元组每一个元素都其实是一个 Attribute 对象,包含了各个参数,下面详细解释下几个参数的含义:

  • name:属性的名字,是一个字符串类型。
  • default:属性的默认值,如果没有传入初始化数据,那么就会使用默认值。如果没有默认值定义,那么就是 NOTHING,即没有默认值。
  • validator:验证器,检查传入的参数是否合法。
  • init:是否参与初始化,如果为 False,那么这个参数不能当做类的初始化参数,默认是 True。
  • metadata:元数据,只读性的附加数据。
  • type:类型,比如 int、str 等各种类型,默认为 None。
  • converter:转换器,进行一些值的处理和转换器,增加容错性。
  • kw_only:是否为强制关键字参数,默认为 False。

属性名

对于属性名,非常清楚了,我们定义什么属性,属性名就是什么,例如上面的例子,定义了:

1
x = attrib()

那么其属性名就是 x。

默认值

对于默认值,如果在初始化的时候没有指定,那么就会默认使用默认值进行初始化,我们看下面的一个实例:

1
2
3
4
5
6
7
8
9
10
from attr import attrs, attrib, fields

@attrs
class Point(object):
x = attrib()
y = attrib(default=100)

if __name__ == '__main__':
print(Point(x=1, y=3))
print(Point(x=1))

在这里我们将 y 属性的默认值设置为了 100,在初始化的时候,第一次都传入了 x、y 两个参数,第二次只传入了 x 这个参数,看下运行结果:

1
2
Point(x=1, y=3)
Point(x=1, y=100)

可以看到结果,当设置了默认参数的属性没有被传入值时,他就会使用设置的默认值进行初始化。 那假如没有设置默认值但是也没有初始化呢?比如执行下:

1
Point()

那么就会报错了,错误如下:

1
TypeError: __init__() missing 1 required positional argument: 'x'

所以说,如果一个属性,我们一旦没有设置默认值同时没有传入的话,就会引起错误。所以,一般来说,为了稳妥起见,设置一个默认值比较好,即使是 None 也可以的。

初始化

如果一个类的某些属性不想参与初始化,比如想直接设置一个初始值,一直固定不变,我们可以将属性的 init 参数设置为 False,看一个实例:

1
2
3
4
5
6
7
8
9
from attr import attrs, attrib

@attrs
class Point(object):
x = attrib(init=False, default=10)
y = attrib()

if __name__ == '__main__':
print(Point(3))

比如 x 我们只想在初始化的时候设置固定值,不想初始化的时候被改变和设定,我们将其设置了 init 参数为 False,同时设置了一个默认值,如果不设置默认值,默认为 NOTHING。然后初始化的时候我们只传入了一个值,其实也就是为 y 这个属性赋值。 这样的话,看下运行结果:

1
Point(x=10, y=3)

没什么问题,y 被赋值为了我们设置的值 3。 那假如我们非要设置 x 呢?会发生什么,比如改写成这样子:

1
Point(1, 2)

报错了,错误如下:

1
TypeError: __init__() takes 2 positional arguments but 3 were given

参数过多,也就是说,已经将 init 设置为 False 的属性就不再被算作可以被初始化的属性了。

强制关键字

强制关键字是 Python 里面的一个特性,在传入的时候必须使用关键字的名字来传入,如果不太理解可以再了解下 Python 的基础。 设置了强制关键字参数的属性必须要放在后面,其后面不能再有非强制关键字参数的属性,否则会报这样的错误:

1
ValueError: Non keyword-only attributes are not allowed after a keyword-only attribute (unless they are init=False)

好,我们来看一个例子,我们将最后一个属性设置 kw_only 参数为 True:

1
2
3
4
5
6
7
8
9
from attr import attrs, attrib, fields

@attrs
class Point(object):
x = attrib(default=0)
y = attrib(kw_only=True)

if __name__ == '__main__':
print(Point(1, y=3))

如果设置了 kw_only 参数为 True,那么在初始化的时候必须传入关键字的名字,这里就必须指定 y 这个名字,运行结果如下:

1
Point(x=1, y=3)

如果没有指定 y 这个名字,像这样调用:

1
Point(1, 3)

那么就会报错:

1
TypeError: __init__() takes from 1 to 2 positional arguments but 3 were given

所以,这个参数就是设置初始化传参必须要用名字来传,否则会出现错误。 注意,如果我们将一个属性设置了 init 为 False,那么 kw_only 这个参数会被忽略。

验证器

有时候在设置一个属性的时候必须要满足某个条件,比如性别必须要是男或者女,否则就不合法。对于这种情况,我们就需要有条件来控制某些属性不能为非法值。 下面我们看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from attr import attrs, attrib

def is_valid_gender(instance, attribute, value):
if value not in ['male', 'female']:
raise ValueError(f'gender {value} is not valid')

@attrs
class Person(object):
name = attrib()
gender = attrib(validator=is_valid_gender)

if __name__ == '__main__':
print(Person(name='Mike', gender='male'))
print(Person(name='Mike', gender='mlae'))

在这里我们定义了一个验证器 Validator 方法,叫做 is_valid_gender。然后定义了一个类 Person 还有它的两个属性 name 和 gender,其中 gender 定义的时候传入了一个参数 validator,其值就是我们定义的 Validator 方法。 这个 Validator 定义的时候有几个固定的参数:

  • instance:类对象
  • attribute:属性名
  • value:属性值

这是三个参数是固定的,在类初始化的时候,其内部会将这三个参数传递给这个 Validator,因此 Validator 里面就可以接受到这三个值,然后进行判断即可。在 Validator 里面,我们判断如果不是男性或女性,那么就直接抛出错误。 下面做了两个实验,一个就是正常传入 male,另一个写错了,写的是 mlae,观察下运行结果:

1
2
Person(name='Mike', gender='male')
TypeError: __init__() missing 1 required positional argument: 'gender'

OK,结果显而易见了,第二个报错了,因为其值不是正常的性别,所以程序直接报错终止。 注意在 Validator 里面返回 True 或 False 是没用的,错误的值还会被照常复制。所以,一定要在 Validator 里面 raise 某个错误。 另外 attrs 库里面还给我们内置了好多 Validator,比如判断类型,这里我们再增加一个属性 age,必须为 int 类型:

1
age = attrib(validator=validators.instance_of(int))

这时候初始化的时候就必须传入 int 类型,如果为其他类型,则直接抛错:

1
TypeError: ("'age' must be <class 'int'> (got 'x' that is a <class 'str'>).

另外还有其他的一些 Validator,比如与或运算、可执行判断、可迭代判断等等,可以参考官方文档:https://www.attrs.org/en/stable/api.html#validators。 另外 validator 参数还支持多个 Validator,比如我们要设置既要是数字,又要小于 100,那么可以把几个 Validator 放到一个列表里面并传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from attr import attrs, attrib, validators

def is_less_than_100(instance, attribute, value):
if value > 100:
raise ValueError(f'age {value} must less than 100')

@attrs
class Person(object):
name = attrib()
gender = attrib(validator=is_valid_gender)
age = attrib(validator=[validators.instance_of(int), is_less_than_100])

if __name__ == '__main__':
print(Person(name='Mike', gender='male', age=500))

这样就会将所有的 Validator 都执行一遍,必须每个 Validator 都满足才可以。这里 age 传入了 500,那么不符合第二个 Validator,直接抛错:

1
ValueError: age 500 must less than 100

转换器

其实很多时候我们会不小心传入一些形式不太标准的结果,比如本来是 int 类型的 100,我们传入了字符串类型的 100,那这时候直接抛错应该不好吧,所以我们可以设置一些转换器来增强容错机制,比如将字符串自动转为数字等等,看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from attr import attrs, attrib

def to_int(value):
try:
return int(value)
except:
return None

@attrs
class Point(object):
x = attrib(converter=to_int)
y = attrib()

if __name__ == '__main__':
print(Point('100', 3))

看这里,我们定义了一个方法,可以将值转化为数字类型,如果不能转,那么就返回 None,这样保证了任何可以被转数字的值都被转为数字,否则就留空,容错性非常高。 运行结果如下:

1
Point(x=100, y=3)

类型

为什么把这个放到最后来讲呢,因为 Python 中的类型是非常复杂的,有原生类型,有 typing 类型,有自定义类的类型。 首先我们来看看原生类型是怎样的,这个很容易理解了,就是普通的 int、float、str 等类型,其定义如下:

1
2
3
4
5
6
7
8
9
10
from attr import attrs, attrib

@attrs
class Point(object):
x = attrib(type=int)
y = attrib()

if __name__ == '__main__':
print(Point(100, 3))
print(Point('100', 3))

这里我们将 x 属性定义为 int 类型了,初始化的时候传入了数值型 100 和字符串型 100,结果如下:

1
2
Point(x=100, y=3)
Point(x='100', y=3)

但我们发现,虽然定义了,但是不会被自动转类型的。 另外我们还可以自定义 typing 里面的类型,比如 List,另外 attrs 里面也提供了类型的定义:

1
2
3
4
5
6
7
8
from attr import attrs, attrib, Factory
import typing

@attrs
class Point(object):
x = attrib(type=int)
y = attrib(type=typing.List[int])
z = attrib(type=Factory(list))

这里我们引入了 typing 这个包,定义了 y 为 int 数字组成的列表,z 使用了 attrs 里面定义的 Factory 定义了同样为列表类型。 另外我们也可以进行类型的嵌套,比如像这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from attr import attrs, attrib, Factory
import typing

@attrs
class Point(object):
x = attrib(type=int, default=0)
y = attrib(type=int, default=0)

@attrs
class Line(object):
name = attrib()
points = attrib(type=typing.List[Point])

if __name__ == '__main__':
points = [Point(i, i) for i in range(5)]
print(points)
line = Line(name='line1', points=points)
print(line)

在这里我们定义了 Point 类代表离散点,随后定义了线,其拥有 points 属性是 Point 组成的列表。在初始化的时候我们声明了五个点,然后用这五个点组成的列表声明了一条线,逻辑没什么问题。 运行结果:

1
2
[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)]
Line(name='line1', points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到这里我们得到了一个嵌套类型的 Line 对象,其值是 Point 类型组成的列表。 以上便是一些属性的定义,把握好这些属性的定义,我们就可以非常方便地定义一个类了。

序列转换

在很多情况下,我们经常会遇到 JSON 等字符串序列和对象互相转换的需求,尤其是在写 REST API、数据库交互的时候。 attrs 库的存在让我们可以非常方便地定义 Python 类,但是它对于序列字符串的转换功能还是比较薄弱的,cattrs 这个库就是用来弥补这个缺陷的,下面我们再来看看 cattrs 这个库。 cattrs 导入的时候名字也不太一样,叫做 cattr,它里面提供了两个主要的方法,叫做 structure 和 unstructure,两个方法是相反的,对于类的序列化和反序列化支持非常好。

基本转换

首先我们来看看基本的转换方法的用法,看一个基本的转换实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from attr import attrs, attrib
from cattr import unstructure, structure

@attrs
class Point(object):
x = attrib(type=int, default=0)
y = attrib(type=int, default=0)

if __name__ == '__main__':
point = Point(x=1, y=2)
json = unstructure(point)
print('json:', json)
obj = structure(json, Point)
print('obj:', obj)

在这里我们定义了一个 Point 对象,然后调用 unstructure 方法即可直接转换为 JSON 字符串。如果我们再想把它转回来,那就需要调用 structure 方法,这样就成功转回了一个 Point 对象。 看下运行结果:

1
2
json: {'x': 1, 'y': 2}
obj: Point(x=1, y=2)

当然这种基本的来回转用的多了就轻车熟路了。

多类型转换

另外 structure 也支持一些其他的类型转换,看下实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
\>>> cattr.structure(1, str)
'1'
>>> cattr.structure("1", float)
1.0
>>> cattr.structure([1.0, 2, "3"], Tuple[int, int, int])
(1, 2, 3)
>>> cattr.structure((1, 2, 3), MutableSequence[int])
[1, 2, 3]
>>> cattr.structure((1, None, 3), List[Optional[str]])
['1', None, '3']
>>> cattr.structure([1, 2, 3, 4], Set)
{1, 2, 3, 4}
>>> cattr.structure([[1, 2], [3, 4]], Set[FrozenSet[str]])
{frozenset({'4', '3'}), frozenset({'1', '2'})}
>>> cattr.structure(OrderedDict([(1, 2), (3, 4)]), Dict)
{1: 2, 3: 4}
>>> cattr.structure([1, 2, 3], Tuple[int, str, float])
(1, '2', 3.0)

这里面用到了 Tuple、MutableSequence、Optional、Set 等类,都属于 typing 这个模块,后面我会写内容详细介绍这个库的用法。 不过总的来说,大部分情况下,JSON 和对象的互转是用的最多的。

属性处理

上面的例子都是理想情况下使用的,但在实际情况下,很容易遇到 JSON 和对象不对应的情况,比如 JSON 多个字段,或者对象多个字段。 我们先看看下面的例子:

1
2
3
4
5
6
7
8
9
10
from attr import attrs, attrib
from cattr import structure

@attrs
class Point(object):
x = attrib(type=int, default=0)
y = attrib(type=int, default=0)

json = {'x': 1, 'y': 2, 'z': 3}
print(structure(json, Point))

在这里,JSON 多了一个字段 z,而 Point 类只有 x、y 两个字段,那么直接执行 structure 会出现什么情况呢?

1
TypeError: __init__() got an unexpected keyword argument 'z'

不出所料,报错了。意思是多了一个参数,这个参数并没有被定义。 这时候一般的解决方法的直接忽略这个参数,可以重写一下 structure 方法,定义如下:

1
2
3
4
5
6
7
8
9
10
def drop_nonattrs(d, type):
if not isinstance(d, dict): return d
attrs_attrs = getattr(type, '__attrs_attrs__', None)
if attrs_attrs is None:
raise ValueError(f'type {type} is not an attrs class')
attrs: Set[str] = {attr.name for attr in attrs_attrs}
return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
return cattr.structure(drop_nonattrs(d, type), type)

这里定义了一个 drop_nonattrs 方法,用于从 JSON 里面删除对象里面不存在的属性,然后调用新的 structure 方法即可,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from typing import Set
from attr import attrs, attrib
import cattr

@attrs
class Point(object):
x = attrib(type=int, default=0)
y = attrib(type=int, default=0)

def drop_nonattrs(d, type):
if not isinstance(d, dict): return d
attrs_attrs = getattr(type, '__attrs_attrs__', None)
if attrs_attrs is None:
raise ValueError(f'type {type} is not an attrs class')
attrs: Set[str] = {attr.name for attr in attrs_attrs}
return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
return cattr.structure(drop_nonattrs(d, type), type)

json = {'x': 1, 'y': 2, 'z': 3}
print(structure(json, Point))

这样我们就可以避免 JSON 字段冗余导致的转换问题了。 另外还有一个常见的问题,那就是数据对象转换,比如对于时间来说,在对象里面声明我们一般会声明为 datetime 类型,但在序列化的时候却需要序列化为字符串。 所以,对于一些特殊类型的属性,我们往往需要进行特殊处理,这时候就需要我们针对某种特定的类型定义特定的 hook 处理方法,这里就需要用到 register_unstructure_hook 和 register_structure_hook 方法了。 下面这个例子是时间 datetime 转换的时候进行的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import datetime
from attr import attrs, attrib
import cattr

TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'

@attrs
class Event(object):
happened_at = attrib(type=datetime.datetime)

cattr.register_unstructure_hook(datetime.datetime, lambda dt: dt.strftime(TIME_FORMAT))
cattr.register_structure_hook(datetime.datetime,
lambda string, _: datetime.datetime.strptime(string, TIME_FORMAT))

event = Event(happened_at=datetime.datetime(2019, 6, 1))
print('event:', event)
json = cattr.unstructure(event)
print('json:', json)
event = cattr.structure(json, Event)
print('Event:', event)

在这里我们对 datetime 这个类型注册了两个 hook,当序列化的时候,就调用 strftime 方法转回字符串,当反序列化的时候,就调用 strptime 将其转回 datetime 类型。 看下运行结果:

1
2
3
event: Event(happened_at=datetime.datetime(2019, 6, 1, 0, 0))
json: {'happened_at': '2019-06-01T00:00:00.000000Z'}
Event: Event(happened_at=datetime.datetime(2019, 6, 1, 0, 0))

这样对于一些特殊类型的属性处理也得心应手了。

嵌套处理

最后我们再来看看嵌套类型的处理,比如类里面有个属性是另一个类的类型,如果遇到这种嵌套类的话,怎样类转转换呢?我们用一个实例感受下:

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
from attr import attrs, attrib
from typing import List
from cattr import structure, unstructure

@attrs
class Point(object):
x = attrib(type=int, default=0)
y = attrib(type=int, default=0)

@attrs
class Color(object):
r = attrib(default=0)
g = attrib(default=0)
b = attrib(default=0)

@attrs
class Line(object):
color = attrib(type=Color)
points = attrib(type=List[Point])

if __name__ == '__main__':
line = Line(color=Color(), points=[Point(i, i) for i in range(5)])
print('Object:', line)
json = unstructure(line)
print('JSON:', json)
line = structure(json, Line)
print('Object:', line)

这里我们定义了两个 Class,一个是 Point,一个是 Color,然后定义了 Line 对象,其属性类型一个是 Color 类型,一个是 Point 类型组成的列表,下面我们进行序列化和反序列化操作,转成 JSON 然后再由 JSON 转回来,运行结果如下:

1
2
3
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])
JSON: {'color': {'r': 0, 'g': 0, 'b': 0}, 'points': [{'x': 0, 'y': 0}, {'x': 1, 'y': 1}, {'x': 2, 'y': 2}, {'x': 3, 'y': 3}, {'x': 4, 'y': 4}]}
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到,我们非常方便地将对象转化为了 JSON 对象,然后也非常方便地转回了对象。 这样我们就成功实现了嵌套对象的序列化和反序列化,所有问题成功解决!

结语

本节介绍了利用 attrs 和 cattrs 两个库实现 Python 面向对象编程的实践,有了它们两个的加持,Python 面向对象编程不再是难事。

Python

很多新手在开始学一门新的语言的时候,往往会忽视一些不应该忽视的细节,比如变量命名和函数命名以及注释等一些内容的规范性,久而久之养成了一种习惯。对此呢,我特意收集了一些适合所有学习 Python 的人,代码整洁之道。

写出 Pythonic 代码

谈到规范首先想到就是 Python 有名的 PEP8 代码规范文档,它定义了编写 Pythonic 代码的最佳实践。可以在 https://www.python.org/dev/peps/pep-0008/ 上查看。但是真正去仔细研究学习这些规范的朋友并不是很多,对此呢这篇文章摘选一些比较常用的代码整洁和规范的技巧和方法,下面让我们一起来学习吧!

命名

所有的编程语言都有变量、函数、类等的命名约定,以美之称的 Python 当然更建议使用命名约定。 接下来就针对类、函数、方法等等内容进行学习。

变量和函数

使用小写字母命名函数和变量,并用下划线分隔单词,提高代码可读性。

变量的声明

1
2
3
names = "Python" #变量名
namejob_title = "Software Engineer" #带有下划线的变量名
populated_countries_list = [] #带有下划线的变量名

还应该考虑在代码中使用非 Python 内置方法名,如果使用 Python 中内置方法名请使用一个或两个下划线()。

1
2
_books = {}# 变量名私有化
__dict = []# 防止python内置库中的名称混淆

那如何选择是用还是__呢? 如果不希望外部类访问该变量,应该使用一个下划线()作为类的内部变量的前缀。如果要定义的私有变量名称是 Python 中的关键字如 dict 就要使用(__)。

函数的声明

1
2
3
4
def get_data():
pass
def calculate_tax_data():
pass

函数的声明和变量一样也是通过小写字母和单下划线进行连接。 当然对于函数私有化也是和声明变量类似。

1
2
def _get_data():
pass

函数的开头使用单下划线,将其进行私有化。对于使用 Pyton 中的关键字来进行命名的函数 要使用双下划线。

1
2
def __path():
pass

除了遵循这些命名规则之外,使用清晰易懂的变量名和很重要。

函数名规范

1
2
3
4
5
6
7
8
9
10
11
# Wrong Way
def get_user_info(id):
db = get_db_connection()
user = execute_query_for_user(id)
return user

# Right way
def get_user_by(user_id):
db = get_db_connection()
user = execute_user_query(user_id)
return user

这里,第二个函数 get_user_by 确保使用相同的参数来传递变量,从而为函数提供正确的上下文。 第一个函数 get_user_info 就不怎么不明确了,因为参数 id 意味着什么这里我们不能确定,它是用户 ID,还是用户付款 ID 或任何其他 ID? 这种代码可能会对使用你的 API 的其他开发人员造成混淆。为了解决这个问题,我在第二个函数中更改了两个东西; 我更改了函数名称以及传递的参数名称,这使代码可读性更高。 作为开发人员,你有责任在命名变量和函数时仔细考虑,要写让人能够清晰易懂的代码。 当然也方便自己以后去维护。

类的命名规范

类的名称应该像大多数其他语言一样使用驼峰大小写。

1
2
3
4
5
class UserInformation:
def get_user(id):
db = get_db_connection()
user = execute_query_for_user(id)
return user

常量的命名规范

通常应该用大写字母定义常量名称。

1
2
3
TOTAL = 56
TIMOUT = 6
MAX_OVERFLOW = 7

函数和方法的参数

函数和方法的参数命名应遵循与变量和方法名称相同的规则。因为类方法将 self 作为第一个关键字参数。所以在函数中就不要使用 self 作为关键字作为参数,以免造成混淆 .

1
2
3
4
5
6
def calculate_tax(amount, yearly_tax):
passs

class Player:
def get_total_score(self, player_name):
pass

关于命名大概就强调这些,下面让我们看看表达式和语句中需要的问题。

代码中的表达式和语句

1
2
3
4
5
6
users = [
{"first_name":"Helen", "age":39},
{"first_name":"Buck", "age":10},
{"first_name":"anni", "age":9}
]
users = sorted(users, key=lambda user: user["first_name"].lower())

这段代码有什么问题? 乍一看并不容易理解这段代码,尤其是对于新开发人员来说,因为 lambdas 的语法很古怪,所以不容易理解。虽然这里使用 lambda 可以节省行,然而,这并不能保证代码的正确性和可读性。同时这段代码无法解决字典缺少键出现异常的问题。 让我们使用函数重写此代码,使代码更具可读性和正确性; 该函数将判断异常情况,编写起来要简单得多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
users = [
{"first_name":"Helen", "age":39},
{"first_name":"Buck", "age":10},
{"name":"anni", "age":9}
]
def get_user_name(users):
"""Get name of the user in lower case"""
return users["first_name"].lower()
def get_sorted_dictionary(users):
"""Sort the nested dictionary"""
if not isinstance(users, dict):
raise ValueError("Not a correct dictionary")
if not len(users):
raise ValueError("Empty dictionary")
users_by_name = sorted(users, key=get_user_name)
return users_by_name

如您所见,此代码检查了所有可能的意外值,并且比起以前的单行代码更具可读性。 单行代码虽然看起来很酷节省了行,但是会给代码添加很多复杂性。 但是这并不意味着单行代码就不好 这里提出的一点是,如果你的单行代码使代码变得更难阅读,那么就请避免使用它,记住写代码不是为了炫酷的,尤其在项目组中。 让我们再考虑一个例子,你试图读取 CSV 文件并计算 CSV 文件处理的行数。下面的代码展示使代码可读的重要性,以及命名如何在使代码可读中发挥重要作用。

1
2
3
4
5
6
7
8
9
10
11
12
import csv
with open("employee.csv", mode="r") as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
for row in csv_reader:
if line_count == 0:
print(f'Column names are {", ".join(row)}')
line_count += 1
print(f'\\t{row["name"]} salary: {row["salary"]}'
f'and was born in {row["birthday month"]}.')
line_count += 1
print(f'Processed {line_count} lines.')

将代码分解为函数有助于使复杂的代码变的易于阅读和调试。 这里的代码在 with 语句中执行多项操作。为了提高可读性,您可以将带有 process salary 的代码从 CSV 文件中提取到另一个函数中,以降低出错的可能性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import csv
with open("employee.csv", mode="r") as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
process_salary(csv_reader)


def process_salary(csv_reader):
"""Process salary of user from csv file."""
for row in csv_reader:
if line_count == 0:
print(f'Column names are {", ".join(row)}')
line_count += 1
print(f'\\t{row["name"]} salary: {row["salary"]}'
f'and was born in {row["birthday month"]}.')
line_count += 1
print(f'Processed {line_count} lines.')

代码是不是变得容易理解了不少呢。 在这里,创建了一个帮助函数,而不是在 with 语句中编写所有内容。这使读者清楚地了解了函数的实际作用。如果想处理一个特定的异常或者想从 CSV 文件中读取更多的数据,可以进一步分解这个函数,以遵循单一职责原则,一个函数一做一件事。这个很重要

return 语句的类型尽量一致

如果希望函数返回一个值,请确保该函数的所有执行路径都返回该值。但是,如果期望函数只是在不返回值的情况下执行操作,则 Python 会隐式返回 None 作为函数的默认值。 先看一个错误示范

1
2
3
4
5
6
7
def calculate_interest(principle, time rate):
if principle > 0:
return (principle * time * rate) / 100
def calculate_interest(principle, time rate):
if principle < 0:
return
return (principle * time * rate) / 100ChaPTER 1 PyThonIC ThInkIng

正确的示范应该是下面这样

1
2
3
4
5
6
7
8
9
def calculate_interest(principle, time rate):
if principle > 0:
return (principle * time * rate) / 100
else:
return None
def calculate_interest(principle, time rate):
if principle < 0:
return None
return (principle * time * rate) / 100ChaPTER 1 PyThonIC ThInkIng

还是那句话写易读的代码,代码多写点没关系,可读性很重要。

使用 isinstance() 方法而不是 type() 进行比较

当比较两个对象类型时,请考虑使用 isinstance() 而不是 type,因为 isinstance() 判断一个对象是否为另一个对象的子类是 true。考虑这样一个场景:如果传递的数据结构是 dict 的子类,比如 orderdict。type() 对于特定类型的数据结构将失败;然而,isinstance() 可以将其识别出它是 dict 的子类。 错误示范

1
2
user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
type(user_ages) == dict:

正确选择

1
2
user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
if isinstance(user_ages, dict):

比较布尔值

在 Python 中有多种方法可以比较布尔值。 错误示范

1
2
3
if is_empty = False
if is_empty == False:
if is_empty is False:

正确示范

1
2
is_empty = False
if is_empty

使用文档字符串

Docstrings 可以在 Python 中声明代码的功能的。通常在方法,类和模块的开头使用。 docstring 是该对象的doc特殊属性。 Python 官方语言建议使用“”三重双引号“”来编写文档字符串。 你可以在 PEP8 官方文档中找到这些实践。 下面让我们简要介绍一下在 Python 代码中编写 docstrings 的一些最佳实践 。

方法中使用 docstring

1
2
def get_prime_number():
"""Get list of prime numbers between 1 to 100.""""

关于 docstring 的格式的写法,目前存在多种风格,但是这几种风格都有一些统一的标准。

  • 即使字符串符合一行,也会使用三重引号。当你想要扩展时,这种注释非常有用。‘
  • 三重引号中的字符串前后不应有任何空行
  • 使用句点(.)结束 docstring 中的语句 类似地,可以应用 Python 多行 docstring 规则来编写多行 docstring。在多行上编写文档字符串是用更具描述性的方式记录代码的一种方法。你可以利用 Python 多行文档字符串在 Python 代码中编写描述性文档字符串,而不是在每一行上编写注释。 多行的 docstring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def call_weather_api(url, location):
"""Get the weather of specific location.

Calling weather api to check for weather by using weather api and
location. Make sure you provide city name only, country and county
names won't be accepted and will throw exception if not found the
city name.

:param url:URL of the api to get weather.
:type url: str
:param location:Location of the city to get the weather.
:type location: str
:return: Give the weather information of given location.
:rtype: str"""

说一下上面代码的注意点

  • 第一行是函数或类的简要描述
  • 每一行语句的末尾有一个句号
  • 文档字符串中的简要描述和摘要之间有一行空白

如果使用 Python3.6 可以使用类型注解对上面的 docstring 以及参数的声明进行修改。

1
2
3
4
5
6
7
8
def call_weather_api(url: str, location: str) -> str:
"""Get the weather of specific location.

Calling weather api to check for weather by using weather api and
location. Make sure you provide city name only, country and county
names won't be accepted and will throw exception if not found the
city name.
"""

怎么样是不是简洁了不少,如果使用 Python 代码中的类型注解,则不需要再编写参数信息。 关于类型注解(type hint)的具体用法可以参考我之前写的http://mp.weixin.qq.com/s?__biz=MzU0NDQ2OTkzNw==&mid=2247484258&idx=1&sn=b13db84dad8eccaec9cd4af618e812e4&chksm=fb7ae5bccc0d6caa8e145e9fd22d0395e68d618672ebbfe99794926c0b9e782974fb16321e32&scene=21#wechat_redirect

模块级别的 docstring

一般在文件的顶部放置一个模块级的 docstring 来简要描述模块的使用。 这些注释应该放在在导包之前,模块文档字符串应该表明模块的使用方法和功能。 如果觉得在使用模块之前客户端需要明确地知道方法或类,你还可以简要地指定特定方法或类。

1
2
3
4
5
6
7
8
9
10
11
"""This module contains all of the network related requests.
This module will check for all the exceptions while making the network
calls and raise exceptions for any unknown exception.
Make sure that when you use this module,
you handle these exceptions in client code as:
NetworkError exception for network calls.
NetworkNotFound exception if network not found.
"""

import urllib3
import json

在为模块编写文档字符串时,应考虑执行以下操作:

  • 对当前模块写一个简要的说明
  • 如果想指定某些对读者有用的模块,如上面的代码,还可以添加异常信息,但是注意不要太详细。
1
2
NetworkError exception for network calls.
NetworkNotFound exception if network not found.
  • 将模块的 docstring 看作是提供关于模块的描述性信息的一种方法,而不需要详细讨论每个函数或类具体操作方法。 类级别的 docstring 类 docstring 主要用于简要描述类的使用及其总体目标。 让我们看一些示例,看看如何编写类文档字符串 单行类 docstring
1
2
3
4
class Student:
"""This class handle actions performed by a student."""
def __init__(self):
pass

这个类有一个一行的 docstring,它简要地讨论了学生类。如前所述,遵守了所以一行 docstring 的编码规范。

多行类 docstring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Student:
"""Student class information.

This class handle actions performed by a student.
This class provides information about student full name, age,
roll-number and other information.
Usage:
import student
student = student.Student()
student.get_name()
>>> 678998
"""
def __init__(self):
pass

这个类 docstring 是多行的; 我们写了很多关于 Student 类的用法以及如何使用它。

函数的 docstring

函数文档字符串可以写在函数之后,也可以写在函数的顶部。

1
2
3
4
5
6
7
8
9
10
11
12
def is_prime_number(number):
"""Check for prime number.

Check the given number is prime number
or not by checking against all the numbers
less the square root of given number.

:param number:Given number to check for prime
:type number: int
:return: True if number is prime otherwise False.
:rtype: boolean
"""

如果我们使用类型注解对其进一步优化。

1
2
3
4
5
6
7
def is_prime_number(number: int)->bool:
"""Check for prime number.

Check the given number is prime number
or not by checking against all the numbers
less the square root of given number.
"""

结语

当然关于 Python 中的规范还有很多很多,建议大家参考 Python 之禅和 Pep8 对代码进行优化,养成编写 Pythonic 代码的良好习惯。 [caption id=”attachment_6600” align=”alignnone” width=”258”] 扫码关注[/caption] 更多精彩内容关注微信公众号:python 学习开发

Python

在本教程中,你将了解如何使用 pathlib 模块操作目录和文件的名称。 学习如何读取和写入文件,拼接路径和操作底层文件系统的新方法,以及如何列出文件并迭代它们的一些示例。 大多人处理文件用的最多的还是 os 模快吧,比如下面这样的操作

1
>>> path.rsplit('\\', maxsplit=1)[0]

或者写出下面这样长长的代码

1
>>> os.path.isfile(os.path.join(os.path.expanduser('~'), 'realpython.txt'))

使用 pathlib 模块,可以使代码使用优雅,可读和 Pythonic 代码重写上面的两个示例,如:

1
2
>>> path.parent
>>> (pathlib.Path.home() / 'realpython.txt').is_file()

Python 文件路径处理问题

由于许多不同的原因,使用文件和与文件系统交互很重要。 最简单的情况可能只涉及读取或写入文件,但有时候会有更复杂的任务。 也许你需要列出给定类型的目录中的所有文件,查找给定文件的父目录,或者创建一个尚不存在的唯一文件名。 一般情况,Python 使用常规文本字符串表示文件路径。 一般在使用 os,glob 和 shutil 等库的时候会使用到路径拼接的操作,使用os模块拼接起来显得略显复杂,以下示例仅需要三个 import 语句来将所有文本文件移动到归档目录:

1
2
3
4
5
6
7
import glob
import os
import shutil

for file_name in glob.glob('*.txt'):
new_path = os.path.join('archive', file_name)
shutil.move(file_name, new_path)

使用常规的字符串去拼接路径是可以的,但是由于不同的操作系统使用的分隔符不同,这样就容易出现问题,所以一般我们使用最多的还是使用 os.path.join()。 Python 3.4 中引入了 pathlib 模块(PEP 428)再一次的优化了路径的拼接。使用 pathlib 库的 Path 方法,可以将一个普通的字符串转换为 pathlib.Path 对象类型的路径。 早期,其他软件包仍然使用字符串作为文件路径,但从 Python 3.6 开始,pathlib 模块在整个标准库中得到支持,部分原因是由于增加了文件系统路径协议。 如果你坚持使用传统的 Python,那么 Python 2 也有一个可用的向后移植。 ok,说了那么多下面让我们看看 pathlib 如何在实践中发挥作用。

创建路径

这里我们首先要知道两个用法,先看代码:

1
from pathlib import Path

你真正需要知道的是 pathlib.Path 类。 创建路径有几种不同的方式。 首先,有类方法,如 .cwd(当前工作目录)和 .home(用户的主目录):

1
2
3
4
5
6
7
from pathlib import Path

now_path = Path.cwd()
home_path = Path.home()

print("当前工作目录",now_path,type(now_path))
print("home目录",home_path,type(home_path))

输出内容

1
2
当前工作目录 /Users/chennan/pythonproject/demo <class 'pathlib.PosixPath'>
home目录 /Users/chennan <class 'pathlib.PosixPath'>

可以发现路径格式为 pathlib.PosixPath 这是在 unix 系统下的显示。在不同的系统上显示的格式也是不一样,在 windows 系统会显示为 WindowsPath。但是不管什么显示类型,都不影响后面的操作。 前面我们提到过可以通过把字符串类型的路径,转换为 Pathlib.Path 类型的路径,经过测试发现在 Python3.4 以后很多模块以及支持该格式的路径。不用转为成字符串使用了。比起 os.path.join 拼接路径的方式, pathlib 使用起来更加的方便,使用示例如下:

1
2
3
import pathlib
DIR_PATH = pathlib.Path("/Users/chennan/CDM")
print(DIR_PATH,type(DIR_PATH))

输出内容:

1
/Users/chennan/CDM <class 'pathlib.PosixPath'>

通过 “/“ 我们就可以对路径进行拼接了,怎么样是不是很方便呢。

读文件和写文件

在我们使用 open 来操作文件读写操作的时候,不仅可以使用字符串格式的路径,对于 pathlib 生成的路径完全可以直接使用:

1
2
3
4
path = pathlib.Path.cwd() / 'test.md'
with open(path, mode='r') as fid:
headers = [line.strip() for line in fid if line.startswith('#')]
print('\n'.join(headers))

或者在 pathlib 的基础使用 open,我们推荐使用下面的方式

1
2
3
4
5
import pathlib
DIR_PATH = pathlib.Path("/Users/chennan/CDM") / "2000" / "hehe.txt"
with DIR_PATH.open("r") as fs:
data = fs.read()
print(data)

这样写的好处就是 open 里面我们不需要再去传入路径了,直接指定文件读写模式即可。实际上这里的 open 方法,底层也是调用了 os.open 的方法。使用哪种方式看个人的喜好。 pathlib 还提供几种文件的读写方式: 可以不用再使用 with open 的形式即可以进行读写。

1
2
3
4
.read_text(): 找到对应的路径然后打开文件,读成str格式。等同open操作文件的"r"格式。
.read_bytes(): 读取字节流的方式。等同open操作文件的"rb"格式。
.write_text(): 文件的写的操作,等同open操作文件的"w"格式。
.write_bytes(): 文件的写的操作,等同open操作文件的"wb"格式。

使用 resolve 可以通过传入文件名,来返回文件的完整路径,使用方式如下

1
2
3
import pathlib
py_path =pathlib.Path("superdemo.py")
print(py_path.resolve())

输出

1
/Users/chennan/pythonproject/demo/superdemo.py

需要注意的是 “superdemo.py” 文件要和我当前的程序文件在同一级目录。

选择路径的不同组成部分

pathlib 还提供了很多路径操作的属性,这些属性可以选择路径的不用部位,如 .name: 可以获取文件的名字,包含拓展名。 .parent: 返回上级文件夹的名字 .stem: 获取文件名不包含拓展名 .suffix: 获取文件的拓展名 .anchor: 类似盘符的一个东西,

1
2
3
4
5
6
7
8
import pathlib

now_path = pathlib.Path.cwd() / "demo.txt"
print("name",now_path.name)
print("stem",now_path.stem)
print("suffix",now_path.suffix)
print("parent",now_path.parent)
print("anchor",now_path.anchor)

输出内容如下

1
2
3
4
5
name demo.txt
stem demo
suffix .txt
parent /Users/chennan/pythonproject/demo
anchor /

移动和删除文件

当然 pathlib 还可以支持文件其他操作,像移动,更新,甚至删除文件,但是使用这些方法的时候要小心因为,使用过程不用有任何的错误提示即使文件不存在也不会出现等待的情况。 使用 replace 方法可以移动文件,如果文件存在则会覆盖。为避免文件可能被覆盖,最简单的方法是在替换之前测试目标是否存在。

1
2
3
4
5
6
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
if not destination.exists():
source.replace(destination)

但是上面的方法存在问题就是,在多个进程多 destination 进行的操作的时候就会现问题,可以使用下面的方法避免这个问题。也就是说上面的方法适合单个文件的操作。

1
2
3
4
5
6
7
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
with destination.open(mode='xb') as fid:
#xb表示文件不存在才操作
fid.write(source.read_bytes())

当 destination文件存在的时候上面的代码就会出现 FileExistsError 异常。 从技术上讲,这会复制一个文件。 要执行移动,只需在复制完成后删除源即可。 使用 with_name 和 with.shuffix 可以修改文件名字或者后缀。

1
2
3
import pathlib
source = pathlib.Path.cwd() / "demo.py"
source.replace(source.with_suffix(".txt")) #修改后缀并移动文件,即重命名

可以使用 .rmdir() 和 .unlink() 来删除文件。

1
2
3
4
5
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
source.unlink()

几个 pathlib 的使用例子

统计文件个数

我们可以使用.iterdir方法获取当前文件下的所以文件.

1
2
3
4
5
import pathlib
from collections import Counter
now_path = pathlib.Path.cwd()
gen = (i.suffix for i in now_path.iterdir())
print(Counter(gen))

输出内容

1
Counter({'.py': 16, '': 11, '.txt': 1, '.png': 1, '.csv': 1})

通过配合使用 collections 模块的 Counter 方法,我们获取了当文件夹下文件类型情况。 前面我们说过 glob 模块点这里了解【https://www.cnblogs.com/c-x-a/p/9261832.html】,同样的 pathlib 也有 glob 方法和 rglob 方法,不同的是 glob 模块里的 glob 方法结果是列表形式的,iglob 是生成器类型,在这里 pathlib 的 glob 模块返回的是生成器类型,然后 pathlib 还有一个支持递归操作的 rglob 方法。 下面的这个操作我通过使用 glob 方法,设定规则进行文件的匹配。

1
2
3
4
import pathlib
from collections import Counter
gen =(p.suffix for p in pathlib.Path.cwd().glob('*.py'))
print(Counter(gen))

展示目录树

下一个示例定义了一个函数 tree(),该函数的作用是打印一个表示文件层次结构的可视树,该树以一个给定目录为根。因为想列出其子目录,所以我们要使用 .rglob() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pathlib
from collections import Counter
def tree(directory):
print(f'+ {directory}')
for path in sorted(directory.rglob('*')):
depth = len(path.relative_to(directory).parts)
spacer = ' ' * depth
print(f'{spacer}+ {path.name}')

now_path = pathlib.Path.cwd()

if __name__ == '__main__':
tree(now_path)

其中 relative_to 的方法的作用是返回 path 相对于 directory 的路径。 parts 方法可以返回路径的各部分。例如

1
2
3
4
import pathlib
now_path = pathlib.Path.cwd()
if __name__ == '__main__':
print(now_path.parts)

返回

1
('/', 'Users', 'chennan', 'pythonproject', 'demo')

获取文件最后一次修改时间

iterdir(),.glob()和.rglob()方法非常适合于生成器表达式和列表理解。 使用stat()方法可以获取文件的一些基本信息,使用.stat().st_mtime可以获取文件最后一次修改的信息

1
2
3
4
5
import pathlib
now_path = pathlib.Path.cwd()
from datetime import datetime
time, file_path = max((f.stat().st_mtime, f) for f in now_path.iterdir())
print(datetime.fromtimestamp(time), file_path)

甚至可以使用类似的表达式获取上次修改的文件内容

1
2
3
4
5
import pathlib
from datetime import datetime
now_path =pathlib.Path.cwd()
result = max((f.stat().st_mtime, f) for f in now_path.iterdir())[1]
print(result.read_text())

.stat().st_mtime 会返回文件的时间戳,可以使用 datetime 或者 time 模块对时间格式进行进一步转换。

其他内容

关于 pathlib.Path 格式路径转换为字符串类型

因为通过 pathlib 模块操作生成的路径,不能直接应用字符串的一些操作,所以需要转换成字符串,虽然可以使用 str() 函数进行转换,但是安全性不高,建议使用 os.fspath() 方法,因为如果路径格式非法的,可以抛出一个异常。str()就不能做到这一点。

拼接符号”/“背后的秘密

/ 运算符由 truediv 方法定义。 实际上,如果你看一下 pathlib 的源代码,你会看到类似的东西。

1
2
3
4
class PurePath(object):

def __truediv__(self, key):
return self._make_child((key,))

后记

从 Python 3.4 开始,pathlib 已在标准库中提供。 使用 pathlib,文件路径可以由适当的 Path 对象表示,而不是像以前一样用纯字符串表示。 这些对象使代码处理文件路径:

  • 更容易阅读,特别是可以使用“/”将路径连接在一起
  • 更强大,直接在对象上提供最必要的方法和属性
  • 在操作系统中更加一致,因为Path对象隐藏了不同系统的特性

在本教程中,你已经了解了如何创建 Path 对象、读取和写入文件、操作路径和底层文件系统,以及如何遍历多个文件路径等一系列实例。 最后,建议下去自己多加练习,我对文章中的代码都进行了验证,不会出现运行错误的情况。 ————————————————————————————————————————————— 原文: https://realpython.com/python-pathlib/ 译者: 陈祥安 [gallery ids=”6600”] 更多精彩内容,请关注微信公众号: python学习开发。

JavaScript

想写这篇文章很久了,也想做这件事很久了,我个人感觉自己是有强迫症的,所以一直有什么事让我看着不太舒服就想把它纠正过来。 文字,也不例外。 现在大家看各种新闻啊、文章啊,几乎每篇文章都会有点数字和英文的吧,比如就拿 Python 来说,看下面两句话:

  • 卧槽 Python 真牛逼啊排名第 1 了。
  • 卧槽 Python 真牛逼啊排名第 1 了。

Python 是不是第一先不说,就看看上面两句话的排版,哪个看起来更舒服?说实话我是真觉得第一句话太别扭了。因为我们大部分的文本编辑器和浏览器是没有对中文和外文的混排做排版优化的,所以如果写的时候如果二者之间不加个空格,二者就会紧紧贴在一起,然后就变成了上面第一句的样子。 当然如果你觉得第一句的排版更好看,好吧,那么本文后面的内容其实可以不必看了。OK,如果你觉得第二个好看,那不妨接着看下去哈。

出发点

首先有一点需要明确的是,中英文排版的美学是在于 Readability,易读性。而为了易读性,中英文之间是需要留有”间距”的,注意这里是间距,不是说的”空格”。”空格”会造成间距,但是间距不一定非得需要”空格”。 好,所以,其实我们只需要留有适当的间距,就会显得美观易读,这个间距大约是一个半角空格的距离。 好明确了这一点,我们只要能留有间距,不一定非得加空格。 现在很多专业的排版软件,比如 Adobe InDesign、Microsoft Word 对中英文混排支持非常好,他们会有这么一个功能:可以设置中文西文之间留适当的间距。 所以,如果如果我们使用了这些软件,本身就可以做到 Readability,这就够了。 但是,为什么还会说空格的问题呢?这是因为现在绝大多数软件,不管是文本编辑器还是网页,都没有这个机制。 几乎所有的文本编辑器和浏览器中,只要我们中文和英文连续输入,它们之间是不会出现间距的,就像文章开头所示的样例中的第一句话,显得很别扭。但比如 Adobe InDesign、Microsoft Word、IE 浏览器会有这方面的支持。 所以,怎么解决?手动加空格。 因此,总结下:

  • 间距要有,但不一定是空格。
  • 部分软件能自动呈现间距,那就不必加空格。
  • 绝大多数软件不能自动呈现间距,那就需要手动加空格。

所以,作为强迫症的我,一定是会为了这个间距而去敲下一个空格的。 「有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。与大家共勉之。」 盘古之白 所以,求求你加个空格吧(逃。

规范

好,下面就说到规范的问题了,到底什么时候应该加空格什么时候不加,这也是有讲究的。下面的内容摘自 GitHub 上的一个中英文混排规范,网址为:https://github.com/mzlogin/chinese-copywriting-guidelines,下面转述一下。

1. 中英文之间需要增加空格

中英文之间是需要添加空格的,不论是普通英文还是引用的英文,下面给个示例: 正确:

  • 在 LeanCloud 上,数据存储是围绕 AVObject 进行的。

错误:

  • 在 LeanCloud 上,数据存储是围绕AVObject进行的。
  • 在 LeanCloud 上,数据存储是围绕AVObject 进行的。

完整的正确用法:

  • 在 LeanCloud 上,数据存储是围绕 AVObject 进行的。每个 AVObject 都包含了与 JSON 兼容的 key-value 对应的数据。数据是 schema-free 的,你不需要在每个 AVObject 上提前指定存在哪些键,只要直接设定对应的 key-value 即可。

但有例外,比如「豆瓣 FM」等产品名词,按照官方所定义的格式书写。 再比如,我的公众号为「进击的 Coder」,那么这里面就不要加空格,按照其本身的形式书写即可。

中文与数字之间需要增加空格

中文和数字之间也是需要的,下面给个示例: 正确:

  • 今天出去买菜花了 5000 元。

错误:

  • 今天出去买菜花了 5000 元。
  • 今天出去买菜花了 5000 元。

数字与单位之间无需增加空格

但是数字和单位之间不需要再加额外的空格了,下面给个 正确:

  • 我家的光纤入户宽带有 10Gbps,SSD 一共有 10TB。

错误:

  • 我家的光纤入户宽带有 10 Gbps,SSD 一共有 20 TB。

另外,度/百分比与数字之间不需要增加空格: 正确:

  • 今天是 233° 的高温。
  • 新 MacBook Pro 有 15% 的 CPU 性能提升。

错误:

  • 今天是 233 ° 的高温。
  • 新 MacBook Pro 有 15 % 的 CPU 性能提升。

全角标点与其他字符之间不加空格

标点是分全角和半角的,全角标点一般是在中文状态下输出来的,比如 ,半角标点一般是在英文状态下输出来的,比如 ,.!,两个看起来不一样吧?所以,如果是中文标点,即全角标点,那不需要加空格。 正确:

  • 刚刚买了一部 iPhone,好开心!

错误:

  • 刚刚买了一部 iPhone ,好开心!

嗯,基本就是以上的几个规范,只要明白了这些规范,中英文混排就 OK 了!

网页

有人说,我就是不想打空格,在网页中,我能像 Microsoft Word 一样不打空格而直接显示间距吗? 也就是说,我能不能设置一个 CSS 样式,就能使得中英文之间自动留有间距呢? 其实,只有 IE 有这样的支持。这个 CSS 样式叫做 \-ms-text-autospace ,可以在这里了解下:https://msdn.microsoft.com/library/ms531164(v=vs.85).aspx.aspx>)。 但是很遗憾的是,几乎所有其他的浏览器都不支持这个,Chrome、Firefox 统统都不支持这个特性。放弃吧。 image-20190507220822252 这里提供一些手动的解决方案,比如使用 JavaScript 添加标记,然后 CSS 控制标记的间距,解决方案可以参考:http://mastermay.github.io/text-autospace.js/

编辑器

那么有编辑器支持这个吗?有,Microsoft Word,用它我们不用加空格,会自动给我们加好间距。 有人说,我平时不想用 Word,我就想用 Markdown,有编辑器吗?有,叫做 MarkEditor,它的 2.0 Pro 版本可以在打字的时候自动给我们添加空格。注意,这里是自动添加空格,不是自动留间距,是用空格的方式实现了间距。但是这个只能在你一个个打字的时候自动添加空格,如果把一个不带空格的话粘贴进去是不行的。另外 MarkEditor 解锁这个功能需要付费,所以我个人感觉其实不太划算的。 所以,平时还是自己手动加空格吧,经济实惠方便。 其他的编辑器如有好用的欢迎大家推荐哈。

类库

好吧,看到现在,你是不是现在都想把自己的中英文笔记加上空格了?难道要手调吗?不需要。 有现成的工具了,名字叫做 pangu,它支持各种语言,另外还有浏览器插件可以用,列表如下:

浏览器插件

开发工具包

比如 Python 的话,就可以使用 pangu.py 这个包,GitHub 地址为:https://github.com/vinta/pangu.py,安装方式如下

1
pip3 install -U pangu

这么用就好了:

1
2
import pangu
print(pangu.spacing_text('當你凝視著bug,bug也凝視著你'))

运行结果如下:

1
當你凝視著 bug,bug 也凝視著你

嗯,它自动给我们添加好了空格,非常不错。 不过这有点费劲,有简单一点的工具吗? 有,我为此专门做了一个网页,功能很简单。 在左侧输入源文本,右侧就会显示添加空格之后的文本,页面如下: image-20190507222427295 这个是我用 Vue.js 开发的,实际上就是用了 pangu.js 这个库实现的,原理非常简单,主要目的就是为了方便空格排版。 另外这个网站我也部署了一下,叫做:http://space.cuiqingcai.com/,大家以后也可以直接访问使用,以后我有想调整的文本,直接就用它了。 如果大家想获取源码,可以在公众号「进击的 Coder」回复”空格”。 希望对大家有所帮助。 最后,为了世界的美好与和平,加个空格吧!

Python

最近碰到了一个问题,项目中很多文件都是接手过来的中文命名的一些素材,结果在部署的时候文件名全都乱码了,导致项目无法正常运行。 后来请教了一位大佬怎么解决文件名乱码的问题,他说这个需要正面解决吗?不需要,把文件名全部改掉,文件名永远不要用中文,永远不要。 我想他这么说的话,一定也是凭经验得出来的。 这里也友情提示大家,项目里面文件永远不要用中文,永远不要! 好,那不用中文用啥?平时来看,一般我们都会用英文来命名,一般也不会出现中文,比如 resource, controller, result, view, spider 等等,所以绝大多数情况下,是不会出现什么问题的。但是也有个别的情况,比如一些素材、资源文件可能的中文命名的,那么这时候该咋办呢? 首先像,因为是中文资源文件,我们要改成非中文命名的,无非两种,一种是英文,一种是拼音。 如果改英文,当然可以翻译、我们想翻译的话,逐个人工翻译成本太高,机器翻译的话,翻译完可能有些文不对题了,而且我们自己也不知道一些奇怪的资源英语应该叫什么,所以到时候真的找起来都找不到了。 所以第二种解决方案,那就是拼音了。中文转拼音,很自然,而且一个字就对应一串拼音,而且也非常容易从拼音看懂是什么意思,所以这确实是一个不错的方案。 那么问题就来了,怎样把一批中文文件转拼音命名呢?下面就让我们来了解 Python 的一个库 PyPinyin 吧!

概述

Python 中提供了汉字转拼音的库,名字叫做 PyPinyin,可以用于汉字注音、排序、检索等等场合,是基于 hotto/pinyin 这个库开发的,一些站点链接如下:

它有这么几个特性:

  • 根据词组智能匹配最正确的拼音。
  • 支持多音字。
  • 简单的繁体支持, 注音支持。
  • 支持多种不同拼音/注音风格。

是不是等不及了呢?那就让我们来了解一下它的用法吧!

安装

首先就是这个库的安装了,通过 pip 安装即可:

1
pip3 install pypinyin

安装完成之后导入一下这个库,如果不报错,那就说明安装成功了。

1
\>>> import pypinyin

好,接下来我们看下它的具体功能。

基本拼音

首先我们进行一下基本的拼音转换,方法非常简单,直接调用 pinyin 方法即可:

1
2
from pypinyin import pinyin
print(pinyin('中心'))

运行结果:

1
[['zhōng'], ['xīn']]

可以看到结果会是一个二维的列表,每个元素都另外成了一个列表,其中包含了每个字的读音。 那么如果这个词是多音字咋办呢?比如 “朝阳”,它有两个读音,我们拿来试下:

1
2
from pypinyin import pinyin
print(pinyin('朝阳'))

运行结果:

1
[['zhāo'], ['yáng']]

好吧,它只给出来了一个读音,但是如果我们想要另外一种读音咋办呢? 其实很简单,只需添加 heteronym 参数并设置为 True 就好了,我们试下:

1
2
from pypinyin import pinyin
print(pinyin('朝阳', heteronym=True))

运行结果:

1
[['zhāo', 'cháo'], ['yáng']]

OK 了,这下子就显示出来了两个读音了,而且我们也明白了结果为什么是一个二维列表,因为里面的一维的结果可能是多个,比如多音字的情况就是这样。 但这个多少解析起来有点麻烦,很多情况下我们是不需要管多音字的,我们只是用它来转换一下名字而已,而处理上面的二维数组又比较麻烦。 所以有没有一个方法直接给我们一个一维列表呢?有! 我们可以使用 lazy_pinyin 这个方法来生成,尝试一下:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('聪明的小兔子'))

运行结果:

1
['cong', 'ming', 'de', 'xiao', 'tu', 'zi']

这时候观察到得到的是一个列表,并且不再包含音调了。 这里我们就有一个疑问了,为啥 pinyin 方法返回的结果默认是带音调的,而 lazy_pinyin 是不带的,这里面就涉及到一个风格转换的问题了。

风格转换

我们可以对结果进行一些风格转换,比如不带声调风格、标准声调风格、声调在拼音之后、声调在韵母之后、注音风格等等,比如我们想要声调放在拼音后面,可以这么来实现:

1
2
3
4
from pypinyin import lazy_pinyin, Style

style = Style.TONE3
print(lazy_pinyin('聪明的小兔子', style=style))

运行结果:

1
['cong1', 'ming2', 'de', 'xiao3', 'tu4', 'zi']

可以看到运行结果每个拼音后面就多了一个声调,这就是其中的一个风格,叫做 TONE3,其实还有很多风格,下面是我从源码里面找出来的定义:

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
#: 普通风格,不带声调。如: 中国 -> ``zhong guo``
NORMAL = 0
#: 标准声调风格,拼音声调在韵母第一个字母上(默认风格)。如: 中国 -> ``zhōng guó``
TONE = 1
#: 声调风格2,即拼音声调在各个韵母之后,用数字 [1-4] 进行表示。如: 中国 -> ``zho1ng guo2``
TONE2 = 2
#: 声调风格3,即拼音声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``zhong1 guo2``
TONE3 = 8
#: 声母风格,只返回各个拼音的声母部分(注:有的拼音没有声母,详见 `#27`_)。如: 中国 -> ``zh g``
INITIALS = 3
#: 首字母风格,只返回拼音的首字母部分。如: 中国 -> ``z g``
FIRST_LETTER = 4
#: 韵母风格,只返回各个拼音的韵母部分,不带声调。如: 中国 -> ``ong uo``
FINALS = 5
#: 标准韵母风格,带声调,声调在韵母第一个字母上。如:中国 -> ``ōng uó``
FINALS_TONE = 6
#: 韵母风格2,带声调,声调在各个韵母之后,用数字 [1-4] 进行表示。如: 中国 -> ``o1ng uo2``
FINALS_TONE2 = 7
#: 韵母风格3,带声调,声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``ong1 uo2``
FINALS_TONE3 = 9
#: 注音风格,带声调,阴平(第一声)不标。如: 中国 -> ``ㄓㄨㄥ ㄍㄨㄛˊ``
BOPOMOFO = 10
#: 注音风格,仅首字母。如: 中国 -> ``ㄓ ㄍ``
BOPOMOFO_FIRST = 11
#: 汉语拼音与俄语字母对照风格,声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``чжун1 го2``
CYRILLIC = 12
#: 汉语拼音与俄语字母对照风格,仅首字母。如: 中国 -> ``ч г``
CYRILLIC_FIRST = 13

有了这些,我们就可以轻松地实现风格转换了。 好,再回到原来的问题,为什么 pinyin 的方法默认带声调,而 lazy_pinyin 方法不带声调,答案就是:它们二者使用的默认风格不同,我们看下它的函数定义就知道了: pinyin 方法的定义如下:

1
def pinyin(hans, style=Style.TONE, heteronym=False, errors='default', strict=True)

lazy_pinyin 方法的定义如下:

1
def lazy_pinyin(hans, style=Style.NORMAL, errors='default', strict=True)

这下懂了吧,因为 pinyin 方法默认使用了 TONE 的风格,而 lazy_pinyin 方法默认使用了 NORMAL 的风格,所以就导致二者返回风格不同了。 好了,有了这两个函数的定义,我们再来研究下其他的参数,比如定义里面的 errors 和 strict 参数又怎么用呢?

错误处理

在这里我们先做一个测试,比如我们传入无法转拼音的字,比如:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('你好☆☆,我是xxx'))

其中包含了星号两个,还有标点一个,另外还包含了一个 xxx 英文字符,结果会是什么呢?

1
['ni', 'hao', '☆☆,', 'wo', 'shi', 'xxx']

可以看到结果中星号和英文字符都作为一个整体并原模原样返回了。 那么这种特殊字符可以单独进行处理吗?当然可以,这里就用到刚才提到的 errors 参数了。 errors 参数是有几种模式的:

  • default:默认行为,不处理,原木原样返回
  • ignore:忽略字符,直接抛掉
  • replace:直接替换为去掉 u 的 unicode 编码
  • callable 对象:当传入一个可调用的对象的时候,则可以自定义处理方式。

下面是 errors 这个参数的源码实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _handle_nopinyin_char(chars, errors='default'):
"""处理没有拼音的字符"""
if callable_check(errors):
return errors(chars)

if errors == 'default':
return chars
elif errors == 'ignore':
return None
elif errors == 'replace':
if len(chars) > 1:
return ''.join(text_type('%x' % ord(x)) for x in chars)
else:
return text_type('%x' % ord(chars))

当处理没有拼音的字符的时候,errors 的不同参数会有不同的处理结果,更详细的逻辑可以翻看源码。 好了,下面我们来尝试一下,比如我们想将不能转拼音的字符去掉,则可以这么设置:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('你好☆☆,我是xxx', errors='ignore'))

运行结果:

1
['ni', 'hao', 'wo', 'shi']

如果我们想要自定义处理,比如把 转化为 ,则可以这么设置:

1
print(lazy_pinyin('你好☆☆,我是xxx', errors=lambda item: ''.join(['※' if c == '☆' else c for c in item])))

运行结果:

1
['ni', 'hao', '※※,', 'wo', 'shi', 'xxx']

如上便是一些相关异常处理的操作,我们可以随心所欲地处理自己想处理的字符了。

严格模式

最后再看下 strict 模式,这个参数用于控制处理声母和韵母时是否严格遵循 《汉语拼音方案》 标准。 下面的一些说明来源于官方文档: 当 strict 参数为 True 时根据 《汉语拼音方案》 的如下规则处理声母、在韵母相关风格下还原正确的韵母:

  • 21 个声母: b p m f d t n l g k h j q x zh ch sh r z c sy, w 不是声母
  • i行的韵母,前面没有声母的时候,写成yi(衣),ya(呀),ye(耶),yao(腰),you(忧),yan(烟), yin(因),yang(央),ying(英),yong(雍)。(y 不是声母
  • u行的韵母,前面没有声母的时候,写成wu(乌),wa(蛙),wo(窝),wai(歪),wei(威),wan(弯), wen(温),wang(汪),weng(翁)。(w 不是声母
  • ü行的韵母,前面没有声母的时候,写成yu(迂),yue(约),yuan(冤),yun(晕);ü上两点省略。 (韵母相关风格下还原正确的韵母 ü
  • ü行的韵跟声母j,q,x拼的时候,写成ju(居),qu(区),xu(虚),ü上两点也省略; 但是跟声母n,l拼的时候,仍然写成nü(女),lü(吕)。(韵母相关风格下还原正确的韵母 ü
  • iou,uei,uen前面加声母的时候,写成iu,ui,un。例如niu(牛),gui(归),lun(论)。 (韵母相关风格下还原正确的韵母 iou,uei,uen

当 strict 为 False 时就是不遵守上面的规则来处理声母和韵母, 比如:y, w 会被当做声母,yu(迂) 的韵母就是一般认为的 u 等。 具体差异可以查看源码中 tests/test_standard.py 中的对比结果测试用例。

自定义拼音

如果对库返回的结果不满意,我们还可以自定义自己的拼音库,这里用到的方法就有 load_single_dict 和 load_phrases_dict 方法了。 比如刚才我们看到 “朝阳” 两个字的发音默认返回的是 zhao yang,我们想默认返回 chao yang,那可以这么做:

1
2
3
4
5
6
7
8
from pypinyin import lazy_pinyin, load_phrases_dict

print(lazy_pinyin('朝阳'))
personalized_dict = {
'朝阳': [['cháo'], ['yáng']]
}
load_phrases_dict(personalized_dict)
print(lazy_pinyin('朝阳'))

这里我们自定义了一个词典,然后使用 load_phrases_dict 方法设置了一下就可以了。 运行结果:

1
2
['zhao', 'yang']
['chao', 'yang']

这样就可以完成自定义的设置了。 在一些项目里面我们可以自定义很多拼音库,然后加载就可以了。 另外我们还可以注册样式实现自定义,比如将某个拼音前面加上 Emoji 表情,样例:

1
2
3
4
5
6
7
8
9
10
from pypinyin.style import register
from pypinyin import lazy_pinyin

@register('kiss')
def kiss(pinyin, **kwargs):
if pinyin == 'me':
return f'?{pinyin}'
return pinyin

print(lazy_pinyin('么么哒', style='kiss'))

运行结果:

1
['?me', '?me', 'dá']

这里我们调用 register 方法注册了一个样式 style,然后转换的时候指定即可,通过观察运行结果我们可以发现,这样我们就可以将 me 字的拼音前面加上 ? 这个 Emoji 表情了。 以上就是 PyPinyin 这个库的基本用法,更多的用法建议大家看看源码或者看 API 文档:https://pypinyin.readthedocs.io/zh_CN/master/api.html

个人随笔

正式入职微软,提交了第一个 PR 之后,我坐在椅子上思考人生。终于我也变成了一名正式的企业员工,变成了一名正式的踏入社会的职业人士,从此我的学生生涯也算是画上了一个句号,不,更确切的说应该是画上了一个引号。 和同事租了房子,生活条件算是还不错,有了属于自己的房间,有了专属自己的衣柜、书橱、办公桌,想要的硬件、软件、日常用品想配就配,算是应有尽有了。首先日常生活上最大的感受就是自己的生活条件变得更好、更自由,不再像学校一样有各种限制了,虽然日常花销变多了,但总体上来说我更喜欢现在的生活环境。

回顾

先回想一下自己的学生生活吧,初高中就不说了,就天天上课为了高考,后来在大学基本上就是三点一线,宿舍——食堂——教室/实验室,然后读研,研究生模式也差不多,宿舍——食堂——实验室/公司。

编程入门

我大学的时候选的就是计算机科学与技术这个专业,当时学校开的第一门入门课就是 Java,当时可以说是对编程一窍不通,什么 print?打印机吗?控制台是什么?控制谁?什么面向对象?我对象在哪里?都是些什么玩意。就这样,随着老师的课堂洗脑和一些并不怎么感兴趣的编程作业,我慢慢理解了原来 print 是这么个玩意,对象原来不是那个对象,就慢慢对编程建立了一个概念。插句话,其实我感觉编程的一些概念和思维还是很重要的,有人说编程学不会,可能就是脑中没有形成一个比较清晰的概念,想清楚它能做到什么,怎样从用双手解决一件事的思维转换成用编程解决一件事的思维。 扯远了,但那时候仅仅 Java 是一门课而已,虽然最后考试考得还不错,但是还是不是很懂它能为我带来些什么东西。后来大学就开了一个叫做课程设计的课,意思就是说让自己动手编程实现一个可以操作的项目。我们当时学校要求的就是实现一个黑白棋在线对战系统,当时可把我为难坏了。后来了解到了这里面还挺复杂的,又得编写界面又得搞一些算法,还得搞一个在线 WebSocket 通信,当时可以说是毫无思路,然后就去网上搜一些 Java 的教程,当时是搜到了马士兵的 Java 课就顺着看了起来,可以说马士兵是我的 Java 最重要的启蒙老师了,慢慢地把一些原理和基础学会了之后就有了基本的编程思路了,开始上手编,做界面,做服务器等等。另外还有一些小插曲,有的界面还得抠图,当时为了打造一个完美的棋盘效果还学了 PS 来花了一个木质棋盘和黑白棋子,估计得花了好几个月的时间终于做出来了一个像样点的系统,虽然现在源码已经找不到了,但真的说这个课程设计真的让我理解了编程的一些思维和理念以及它能为我带来些什么,我能够用它做到什么,脑中的一些概念变得更加清晰了,收获非常大。 后来学校开了数据结构和算法的一些课,慢慢地我又对一些基础的算法和 C++ 的一些编程语言有了一定的了解。再后来就是一些基础专业课了,比如操作系统,计算机组成原理,计算机网络等等,总体来说其实我没太感觉出具体有多大作用,但你要假设我没学过,我可能有很多东西都不知其理。人就是这样,有些东西在拥有的时候觉不出有什么好的,但一旦没有才会有明显的感觉。

加实验室

好吧又扯远了,然后就可以说迈入了我人生中一个比较重要的点了,那就是加入学校的一个实验室。之前许多东西我和室友自己瞎倒腾,比如当时进行版本控制的话,就是自己手动压缩一下,命名项目的时候添加一个版本号并用下划线分割,后来进了实验室才知道还有 Git 这么牛逼的东西,于是乎就跟着学习了 Git,了解了 GitHub,觉得整个代码世界都光明了。当时我加入的是后台组,一开始是从 PHP 开始学起的,从原生的 PHP,到普通的 CodeIgniter 框架,到高级的 Laravel 框架,当时写的时候主要用是 MVC 模式,所以前端的东西也难免需要用到的,所以那会儿又学习了前端的一些知识,慢慢地就变成了 Web 前后端通吃,自己也可以逐渐完成一些大型项目的开发工作。当时还自己开通了博客来记录自己学习的一些经验,然后跟着实验室一起接外包做外包,做了不下十个门户及商业网站的开发。

学习爬虫

再往后可能就是临近大学毕业的时候了,那会儿实验室的一位学长写了一些爬虫的入门文章,当时也跟着学了起来,边学边记录,学的整理的一些知识点都放在了自己的博客上。后来又探索了一些新的爬取方案,也一并整理到博客上了,形成了一个入门到进阶的一套简易版教程,后来随着写的越来越多,来看的人也越来越多了,后来访问量也逐渐上来了,现在的话日均访问量可以达到 15000+。 再往后可能就差不多大学毕业了,当时由于是保研到北航的,所以就提前来到了北京开始了研究生的预备工作,也节省了不少时间。那会儿就有充足的时间来做自己的事情,比如学习一些网络课程,继续做一些关于爬虫的研究工作。当时随着我的博客访问量越来越大,图灵便联系我看能不能写一本关于爬虫的书,当时想的一个是可以把自己学习的知识好好整理一下,还可以作为自己的一部个人作品出版出来,的确是一件非常不错的事情,所以当时就答应下来了。不得不说写书的过程是非常艰辛的,舍去了好多平时的休息时间,同时还发现了自己的很多不足的地方去查漏补缺,最终也不得不延期了好几个月才交上稿。后来又审校了非常久的时间,到去年五月份才出版出来,定名字叫《Python3网络爬虫开发实战》。不过后来的销量还算不错,现在已经重印了 10 次,50000 本了。现在还在继续撰写第二版中,把一些过期的案例和知识点更新,再把一些新的技术加进去。

研究生生活

OK,当然研究生阶段也不是都写书了。研究生阶段其实一开始是比较迷茫的,其实当时并不知道自己毕业之后要做什么方面的工作以及想去哪里。最初读的时候是选了网络安全的方向,做一些 Web 渗透方面的研究,后来觉得研究得差不多了,就又转了自然语言处理的方向,从吴恩达的机器学习开始学起,然后了解了深度学习的一些模型,又了解了自然语言处理的一些知识。与此同时,我也在微软这边当实习生,从最初的爬虫、 Platform 再到 Science NLP 研究,慢慢地也认识了一些大佬,和他们一起交流的确让我学习到了不少。 由于我在微软这边实习时间不短,所以当时也参加了实习生转正的面试。微软整个的面试流程还是很规范和严格的,包括多面技术面,另外每一面的要求也都不低的。首先最基础的要求就是算法,给你几道题目,来白纸上把这道题的代码写出来,面试官会非常注重边界处理和细节把握,如果要写不出来,基本上也离凉凉不远了。接下来还有一些基本的公式推导,比如如果要面试机器学习算法工程师的话,可能会让手写推导 SVM、LR 等算法。另外还有一些系统设计题,来看看你的思维和架构是不是能达到要求。最后我记得还有一些智力题,看看反应得快不快。再往后的面试也是谈谈自己对行业的一些理解和看法,谈得还是比较深入的。总之考察得非常综合,当时准备面试的期间真的是无比焦虑,感觉人心惶惶的,当时在疯狂地刷题,复习各种算法推导,准备了也差不多有一两个月。最后得知 Offer 的那一刻,一块石头终于落地了。最后我也如愿入职了微软小冰,今年三月刚刚入职,也希望能为小冰带来更多的贡献,也希望大家可以多多关注微软小冰。 其实我已经在微软这边实习了一年多的时间了,平时很多时间也都呆在公司里,自己也算提前一步迈入职场了,经过我个人实习的体验和感受,同时也结合自己平时的了解,总结出来了一些经验。当然这仅仅是我个人的一些看法,仁者见仁智者见智,在这里仅仅做一些经验总结和分享,总结一些从学生迈入职场之后,我会注重的一些地方。如果对你有些启发,那是再好不过了。

工作相关

首先就是工作相关的一些东西了,由学生到工作,我个人觉得还是有一些需要调整的地方的,下面稍微这里说一下。

转换思维

学生到职场的转换,第一个重要的就是转变自己看待事情的思维。迈入职场,就别再有一种”我是学生“、”我刚刚毕业“ 这样的想法,在职场里别人才不管我们是不是刚毕业的学生,他们看的基本上都是我们能不能完成工作或者配合他们完成工作。 另外也别总有一种”努力必有回报“这样的思维。学生时代,可能一道题解不出来,一个项目做不出来,努努力很大程度上还是可以能解的。但是到了职场,这个很不一样,很多事情并不是一定存在因果这样的线性关系的。比如说某个项目你辛辛苦苦做了很久,可能就因为领导不想要这个功能而直接砍掉了。比如说你辛辛苦苦写了稿子,可能因为和某个评阅人的想法不一致而被直接拒掉了。想开点,有时候就是这么操蛋。 还有一个就是别把一件事想的太简单,在工作中,其实很多事上牵扯的东西是很多的。学生的一件事可能就是一件事,一道题可能就是一套题。工作中一件事可能不仅仅是一件事,它所关系到的东西要复杂的多。我们可能会考虑到对公司、对领导、对同事、对绩效、对家人的很多事情,多考虑考虑。 总之,思维的转换是第一步,别再像之前当学生的时候一样了。

靠真本事

首先得考虑清楚啊,公司把我们招来是为了让我们来发挥价值的。 而我们的价值在哪里发挥?当然是体现在工作成果中。那成果哪里来的?那当然是把自己的能力转化过来的。 不同职位有不同的要求,首先确保的是要利用自己的能力把本职工作做好。当然我是做技术的,我所专注的就是技术这个领域了,技术能力是必不可少的,当然做这份工作需要的其实也不仅仅是技术能力,还有一些非表面意义上的能力,如学习探索能力、沟通合作能力等等。 而这些吧,归根结底还是要靠自己的真本事的。别想着投机取巧,别想着耍点小聪明,虽然一时方便了,但是长远来看,吃亏的还是自己。 当然这例子很多,比如明明不是自己做的,却要为了某些目的非要伪装自己,被看穿之后,不少人其实是看破不说破的,慢慢地自己就会知道后果了。比如为了某些目标,背地里各种跟领导各种小恩小惠等,其实领导基本不会 Care 你这种小聪明,甚至还觉得这个人有点靠不住,自己的受信任度也会大打折扣,另外自己不会感到心虚吗? 最好的办法是什么,其实就是把该做完的工作保质保量完成,靠自己的真本事,实打实做好要求的每一件事就好了,领导看重的就是这个。

利益为先

职场上面啊,你说人情,当然也是会有的,但是更多的人关注的其实是工作本身以及自己的利益。这和学校的差距还是很大的。比如你要是个学生的话,有些人可能觉得你的身份而稍微谅解一下,而踏入社会之后,基本就不会了。 比如你要跟某个人交流和合作,就别总是寒暄以此来套近乎,不是说这个一点用都没有,多少可能还是有点用的,如果你有价值,靠这个可能能快速拉近他与你的距离。但很多情况下,这个的效果可能真的作用不大。 到了职场,大家都很忙的,比如找人谈事情,稍微介绍几句,开口直接谈正事、谈利益就好了。如果不能为对方提供有价值的信息,或者提供的价值并不是对方想要的,对方会觉得比较浪费时间。这种时候就别掺杂什么别的什么交情之类的东西了,虽说我认为成年人只看利益不够准确,但基本上是没错的。 比如跟人谈个合作的时候,讲清楚两点,第一是我们能提供什么价值和服务,第二是他能得到什么利益。当然第二点有的情况下是不需要说的,因为我们本身提供的价值就是给对方的利益,对方觉得值,自然会跟我们合作的。其中也不乏一些沟通的小技巧,还需要在这个过程中慢慢摸索。

保持交流

领导给了一个要求,如果有疑问,要多问多交流,别偏离了方向,别是自作主张。 之前的时候,我接到了一个任务,了解了基本的需求时候就开始开工干。在做的过程中其实有不少不确定的地方,但是当时我也不知道是不好意思还是什么原因,就没有跟领导交流,按照自己的想法做了出来。最后演示的时候,领导说你怎么做成这样了?我本意不是这样的,你有疑问怎么不跟我讨论呢? 我就意识到了,这其实也可以多少印证”选择比努力更重要“,方向性或方案性的东西是很重要的。 所以,遇到什么不确定的点,要多多沟通交流。当然这并不是说突然想到了某个点就去找对方讨论,把自己的疑问以及可能解决方案和结果梳理清楚再好好交流一下,效果一定好上太多。

工作日志

做好工作日志,时间长了,会发现这个非常有用,它的有用不仅仅是体现在它仅仅是做了总结,它也会潜移默化地影响我们自己。 比如每天可以找个时间简单记录一下自己今天做了什么事情,收获了什么,还需要做什么。这个记录的过程就是一个思考的过程,它会让我们反思自己的一些不足和需要做的更好的地方,会变成一种激励的。 另外工作日志,等某天你打开的时候,会发现成就感满满,同时写什么总计和汇报也就不用愁了。

个人相关

刚才所说的挺多都是和工作相关的,另外还有一些和个人相关的我觉得也应该好好注意下。

迈出第一步

我曾经尝试过很多坚持每天做点什么的事情,发现有的挺难坚持下来的,一而再再而三地累积,就变得越来越多,后来整个坚持的事情就失败了,这就是拖延症的一种现象。 拖延症应该很多人都会有,当然我也是。我平时分析拖延症的一个很大的原因就是迈不出第一步,进而一件事就搁置了。就比如说要去健身房,我觉得最难的就是出家门;比如说要每天刷一道算法题,最难的可能就是打开LeetCode界面。不知道你们什么意见,我至少是这么认为的。 因此,迈出第一步非常重要,迈出了第一步,可以说就成功了一半。

言多必失

的确是,言多必失,这话没毛病。 可能我们仅仅是刚入职的新人,很多情况下不该说的就别说。你永远也不知道你说了某些话之后,你在别人耳中会变成什么样的版本。另外如果我们突然说错了什么话,被人揪住了把柄,那可是很难的。所以,一些场景中,我们一些没必要说的就不要去说,不知道该说不该说的也不要说。 另外我个人比较反感的是明一套暗一套。我们不可能让所有人都认同我们自己,不认同我们的,他们表现出来,我们道不同不相为谋。认同我们的支持我们的,我们可以与他们成为朋友。但表面上显得非常友好,然而在背地里面却说坏话,这是非常令人反感的。

终身学习

虽然表面上看学生生涯结束了,但实际上迈入职场恰恰是一个新的开始,其实人与人之间的差距就是因为工作后的这些年逐渐拉开的,所以不论什么时候都不能放弃学习。 当然迈入职场以后,学习的一些侧重点可能就不太一样了。工作后学习的第一肯定是能用在岗位之上的专业知识,我们所掌握的知识一定至少要能够让我们顺利地专业地完成自己的任务。其次可以扩宽一下知识面,比如可以关注下经济、理财、交际等知识。 总之还是一句话,学习到的本事是别人所偷不走的,做一个终身学习者。

做好记录

很多事情是确实很有必要记录下来的。首先不瞒说,随着事情的增多,工作的忙碌,我发现比原来更加”忘事“了。后来我看了一篇报道,说其实并不是脑子记忆力下降了多少,而是集中的注意力变得更少了。 一件事如果注意力分配得少了,自然很多事情就不容易被我们记住。想想确实是这样的,现在我每天都把自己的计划安排得满满当当,现在连拿出时间好好读一本书的时间都不多了,因此就别提那些日常小事了。那咋办?随手记录下来,比如记到手机的备忘录或者自己的 TodoList 软件里面,然后再进一步安排如何执行就好。 当然记录并不仅仅局限于这个记录下来平时的闪念,平时的一些工作总结、学习笔记也可以时刻记录下来。我有记录学习笔记的习惯,当然肯定也有的时候有一些内容没有及时记录下来。过了一段时间,我发现唯一记得的就是自己曾经整理过笔记甚至发表博客的那些内容,没写过的或者没发过的基本都忘干净了。 记录,成为更好的自己。

作息调整

良好的作息还是非常有必要的。现在肯定非常多的朋友会倾向于熬夜,十二点之后才睡。我之前实习的时候,比这个更狠,经常一两点钟睡觉,睡到将近中午,就直接吃中午饭得了,然后一上午就没有了,当时就有一种半天已经被我浪费的感觉,会有一种负罪感。 后来我开始逐步调整我的作息,尽量早睡早起,从之前的九点多慢慢地调整到八点,甚至是七点多,醒来之后整理一些东西,开始全新的一天,整体的体验确实是比之前睡到中午好太多了。另外我个人也参加了一个早起打卡活动,现在已经坚持每天打卡将近一个月了,基本上也养成了早起的习惯。 另外工作之后,下班的时间也可以好好利用起来,比如晚上稍微拉伸一下,睡前记得喝水等等,由于我平时也每天对着电脑,所以也需要定期地起来活动一下。我把它们设置到滴答清单里面,每天都有软件的定时提醒,到了时间就做,一天天坚持下来。总体来说,感觉也比之前好多了。 所以从现在起,拿起笔好好规划下自己的目标和平时的作息吧。 另外可能大家听说过,某个老板每天只睡五个多小时,晚上工作到很晚才走,早上一大早就在了。我当时也比较好奇这到底是怎么做到的?难道有什么特殊的睡眠技巧?经过我的一阵搜罗,结果是,没有。BOSS 们并没有我们这么忙,可能早上开完会,我们去干活了,而人家去补觉了。所以,保证充足的睡眠还是很有必要的。

合理分配时间

从学生到工作,其实就相当于我们的事情又多了一部分,工作日大部分为工作时间,非工作日则大部分是自己的时间。另外工作之后,相比学生时代来说,事情可能也会变得更多更繁琐。那么如何合理分配自己的时间呢? 我这边采用了目前比较流行的四象限时间管理法,他就是把处理的事按“重要”和“紧急”两个维度划分,并对应到四种待处理状态中,分别叫做”重要不紧急“,”重要紧急“,”不重要紧急“,”不重要不紧急“四个类别。 通常来说,我们需要马上执行“急事”,确保它们不会延期。但长远来看,我们最好将重心放在“要事”上。可能有一些紧急的小事,它确实是不重要的,但我们能比较快的做完,然后把它勾掉,所以现在很多人可能更加优先处理的是紧急的事情而不是重要事情,最后紧急的事情做完了,剩下重要的事情没做完,所以要么加班熬夜,要么就延期。回想起来,其实这是很得不偿失的,忙活了一天,重要的事情还是没完成。 所以,我们需要尽量减少”不重要紧急“事件的忙碌,尽早处理”重要不紧急“的事情,合理分配时间,去做对我们而言重要的事情,才是四象限理论的核心。 我自己使用了滴答清单这款软件,之前使用的是 Todoist,这两款软件都非常不错,但是由于滴答清单多了番茄时钟的功能,我就改用了滴答清单,我利用它建立了四个智能清单,分别叫做”重要不紧急“,”重要紧急“,”不重要紧急“,”不重要不紧急“,然后建立筛选条件,软件就会根据任务的优先级和到期日期自动更新。然后就可以每天查看、处理并调整任务——做完的事情及时打勾,有变更的事情及时移动,可以遵循这样的一些准则:

  • 优先执行重要且紧急的事件
  • 尽量提前规划重要不紧急事件,在它们变得紧急前就完成
  • 如果可以的话,试着将不重要但紧急的事情交由他人处理,或者学会对别人的请求说 Sorry
  • 需要控制去做不重要不紧急事件的时间,不要过度放松

有了这几条原则,我们就可以很好的分配我们的时间了,如果大家觉得有用的话也可以试一下。 以上就是我的成长历程以及我所想到的一些需要调整和坚持的一些做法,如果能为大家带来一些帮助,那就再好不过了。

技术杂谈

入职微软之后,这边大多数是使用 Windows 进行开发的,比如我的台式机是 Windows 的,还有一部分服务器是 Windows 的,当然 Linux 是也非常多。 很多情况下我是使用自己的 Mac 笔记本来远程连接我的 Windows 机器来开发的。比如如果我在工位上,我会用我的 Mac 连接两块显示屏,然后一种一块用来远程桌面连接我的 Windows 开发机,这样另外一块屏幕和 Mac 自带的屏幕就用来看文档或者使用 Teams 通讯等等。如果我回家了,我家里也是有两块屏,开上 VPN,照样用一块屏使用远程桌面,另外一块屏幕和 Mac 自带屏幕就可以做其他事情了。 这样就解决了一个问题:我的 Windows 基本上都是仅用作开发的,一块屏幕就开着一个 Visual Studio,其他的操作都会在 Mac 进行,比如查文档,发消息等等。这样我下班之后照样使用远程连接的方式来操作,和在公司就是一样的。这样就避免了一些软件的来回登录,比如如果我上班只用公司机器,下班了之后换了 Mac 还得切 Teams、切微信、切浏览器等等,还是很麻烦的,而且上班期间 Mac 就闲置了也不好。所以我就采取了这样的开发方案。

需求分析

有了这个情景,就引入了一个问题。开了一个远程桌面之后,我几乎一个屏幕都是被 Visual Studio 占据的,而远程桌面貌似只能开一个屏幕?如果我要再开一个终端窗口的话,那可能屏幕就不太够用了,或者它就得覆盖我的全屏 Visual Stuido。 另外我平时 Mac 终端软件都是使用 SSH 的,基本都是用来连 Linux 的,Windows 一般都是开远程桌面。但命令行这个情形的确让我头疼,让我感到不够爽,因为毕竟远程桌面之后,Windows 里面的操作都得挤在一个桌面里面操作了。当然可能能设置多个桌面,如果可以的话,麻烦大家告知一下谢谢。 所以解决的痛点在于:我要把一些操作尽量从 Windows 里面分离出来,例如终端软件,我能否在远程桌面外面操作,能否使用 SSH 来控制我的 Windows 机器。 好,有需求才有动力,说干就干。

配置

查了一下,Windows 上其实也是有 SSH 服务器的,只不过默认是没有装的,这里只需要安装一个 OpenSSH 服务器就好了。 Win10 的话,就在设置里面可以安装,从开始菜单打开“设置”,然后选择应用和功能,这里就有一个“管理可选功能”的选项。 image-20190319093941643 点击之后便可以看到一个可选功能,选择 OpenSSH 服务器即可,一般情况下是没有安装的。如果没有安装的话它会提示一个安装按钮,这里我已经安装好了,就提示了一个卸载按钮。 image-20190319094113033 OK,有了它,直接点击安装即可完成 OpenSSH 服务器的安装。 当然如果你是想批量部署 Windows 服务器的话,当然是推荐使用 PowerShell 来自动化部署了。 首先需要用管理员身份启动 PowerShell,使用如下命令看一下,要确保 OpenSSH 可用于安装:

1
Get-WindowsCapability -Online | ? Name -like 'OpenSSH*'

输出应该是类似的结果:

1
2
3
4
Name  : OpenSSH.Client~~~~0.0.1.0
State : NotPresent
Name : OpenSSH.Server~~~~0.0.1.0
State : NotPresent

然后使用 PowerShell 安装服务器即可:

1
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

输出结果类似:

1
2
3
Path          :
Online : True
RestartNeeded : False

这样也可以完成 OpenSSH 的安装。 安装完成之后,就需要进行一些初始化配置了,还是以管理员身份,使用 PowerShell 执行即可。 首先需要开启 SSHD 服务:

1
Start-Service sshd

然后设置服务的自动启动:

1
Set-Service -Name sshd -StartupType 'Automatic'

最后确认一下防火墙是否是放开的:

1
Get-NetFirewallRule -Name *ssh*

如果是放开的,那么结果会提示 OpenSSH-Server-In-TCP这个状态是 enabled。 好了,完成如上操作之后我们就可以使用 SSH 来连接我们的 Windows 服务器了。

连接

连接非常简单了,用户名密码就是 Windows 的用户名和密码,使用 IP 地址链接即可。 比如我的 Windows 开发机的局域网 IP 为:10.172.134.88,那么就可以使用如下命令完成链接:

1
ssh user@10.172.134.88

然后输入密码,就连接成功了,和 Linux 的是一样的。 另外我自己现在 Mac 常用的 SSH 客户端工具有 Termius,可以多终端同步使用,非常方便,这里我只需要添加我的 Windows 机器就好了,如图所示: image-20190319101812208 OK,以后就可以非常轻松地用 SSH 连接我的 Windows 服务器了,爽歪歪,上面的需求也成功解决。 以上便是使用 SSH 来连接 Windows 服务器的方法,如果大家有需求可以试试。

Python

都说程序猿是一类不解风情的生物,“赚的多,花的少,死的早”已经成为了程序猿的标志,“眼镜、格子衫、垢面蓬头、拖鞋裤衩”已然也成了程序猿的代表形象,“代码、游戏、老湿”也已经快要成了程序猿的生命。 但!有的时候,比如情人节,我们就可以发挥我们的特长了,我们程序猿也可以有自己的浪漫! 不过这个第一步是,你得有一个女朋友(哦哦,是不是可以不用往下看了? 那么有了第一步之后,下面我们应该怎么办呢? 下面介绍一个比较实用的可以送给女朋友的礼物(这其实也是我今天送给女朋友的礼物嘿嘿。 首先想想,作为程序猿,我们的专长是什么?废话,当然是代码。 有了代码,还需要送口红吗?还需要送包包吗?还需要送鲜花吗?废话,都有了代码了,这些当然就….还是要送的。万一写的代码你女朋友看不懂那岂不是死翘翘了。 好那送完了口红或包包或鲜花之后,确保已经平安无事了,我们就可以再发挥我们的光和热了(听起来咋这么奇怪呢? 进入正题,那我们可以利用代码做点什么呢?想想可以做文章的地方有什么,你们的纪念日,你们曾经做过的事情,你们在一起的时间,这些都是属于你们的独一无二的,我们可以想方设法把它们和代码联系起来。 那怎么发给女朋友看呢?做个 App、小程序、网页什么的都是可以的吧,其中网页可能是做起来最快最方便的了,然后配上一个专属域名,简直美滋滋。 好,那一想,基本方向就确定了,直接开干,接下来就描述一下我准备这个礼物的历程吧。 对于我来说,我就计划做一个网页,同时用代码的形式把和女朋友在一起的时间呈现出来,通过网页的动效来呈现我们在一起的时间,另外还计划把我们之间的故事用代码关联表示出来。 本来我打算是从零开始手撸一个的,但是一些组件比如动画特效,还有一些倒计时的组件是相对比较难做的,于是我就在 GitHub 上逛了一下,看了几个示例,找到了一个和我理想作品差不多的项目,然后在它的基础上做了一些改动,就成了最终的效果。 主要功能如下:

  • 第一是通过代码来表述出来和女朋友之间的故事。由于我和女朋友是因为 Python 认识的,而且我们两个平时都会写一些 Python,所以我决定用 Python 来写出我们之间的故事,加上 Python 的注释来辅助描述每一行代码的意义。
  • 第二是通过代码来呈现我和女朋友在一起的时间。这里就用上了一些动画特效和秒数计时方案,实时地呈现出来我和女朋友在一起已经有多久了。

最终完成之后的效果是这样子的: 预览图 然后由于我自己有一个域名,叫做 cuiqingcai.com,然后我就把它设置了二级域名解析,二级域名名称就叫做 love,域名最终就是 love.cuiqingcai.com。 最终的效果大家可以扫码或者复制链接查看一下最终的效果:http://love.cuiqingcai.com/,二维码如下: 二维码 感觉还可以吧?如果你也想送这样的礼物的话,可以根据我现有的代码来进行修改,我已经将源码放到 GitHub 了,地址为:https://github.com/Germey/ValentinesDay,大家可以修改源码,把它变成属于你和你女朋友的专属页面,然后送给女朋友。 下面说一些关键的技术和需要修改的点。

代码动画

打开页面之后,我们可以看到页面的代码是一个字一个字敲出来的,这实际上是利用了一个定时器来实现的。 首先我们可以预定义好所有的文本,然后动画播放的时候,首先把所有的文本隐藏,然后每隔几十毫秒读取一个字符,然后将其呈现出来。由于文本本身就是换行的,所以在呈现的时候就会一行一行地像打字机一样呈现出来。 另外为了模拟打字的效果,在呈现的时候可以在最后的字符后面添加一个下划线符号,模拟打字的效果。 其关键的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function (a) {
a.fn.typewriter = function () {
this.each(function () {
var d = a(this), c = d.html(), b = 0;
d.html("");
var e = setInterval(function () {
var f = c.substr(b, 1);
if (f == "<") {
b = c.indexOf(">", b) + 1
} else {
b++
}
d.html(c.substring(0, b) + (b & 1 ? "_" : ""));
if (b >= c.length) {
clearInterval(e)
}
}, 75)
});
return this
}
})(jQuery);

这里可以看到,首先获取了页面代码区域的内容,然后通过 DOM 操作将代码先清空,然后利用 setInterval 方法设置一个定时器,定时间隔 75 毫秒,也就是说 75 毫秒循环调用一次。每调用一次,就会从原来的字符上多取一个字符,然后尾部拼接一个下划线就好了。

代码内容

接下来就是代码内容了,这里面要想好怎样把一些关键时间来表示出来。比如和女朋友怎样认识的,后来什么时间在一起的,一起做过什么事情,将来有什么计划和打算,都可以来描述出来,另外编程语言可以选择你喜欢的语言,然后配以一定的注释来描述代码所代表的含义。 我和女朋友是在 PyCon 认识的,也算是因为 Python 结缘,然后平时我们都会写一些 Python,所以我就选用 Python 作为编程语言了。 然后我又加上了我们认识的时间、在一起的时间、一起做过的事情,然后再配以一段代码来表达自己的想法,其中的一些灵感也是我看了一些案例想出来的,在表述过程中我使用了面向对象的思维声明了两个对象,一个代表我,一个代表我女朋友,然后一起做过的事情就可以通过对象调用方法的形式来表述出来了,另外一些动作和标志可以通过自定义方法或者代码的参数来表示出来,其中每一行代码的动作我都配以一条 Python 的注释来完成,注释当然是用英文,一些话我还用了翻译软件一句句查的。 然后最后我用了一段 Python 代码来表达了自己的感情,内容如下:

1
2
3
4
5
6
# You are the greatest love of my life.
while True:
if u.with(i):
you = everything
else:
everything = u

这个代码的含义叫做“无论天涯海角,你都是我的一切。“,一个 while True 循环代表了永久。 这些代码其实都是在 HTML 代码中预定义好的,其中注释需要用 span 标签配以 comments 的 class 来修饰,缩进需要用 span 标签配以 placeholder 的 class 来修饰,例如:

1
2
3
4
5
6
<span class="comments"># You are the greatest love of my life.</span><br/>
while <span class="keyword">True</span>:<br/>
<span class="placeholder"></span><span class="keyword">if</span> u.with(i):<br/>
<span class="placeholder"></span><span class="placeholder"></span>you = everything<br/>
<span class="placeholder"></span><span class="keyword">else</span>:<br/>
<span class="placeholder"></span><span class="placeholder"></span>everything = u<br/>

这里不同的格式用 span 的不同 class 来标识,空格缩进一个 placeholder 是两个空格,comments 代表注释格式,关键词使用 keyword 来标识。如果你需要自定义自己的内容,通过控制这些内容穿插写入就好了。

纪念日计时

关于纪念日,这个实现起来其实挺简单的,就是首先定义好你们的纪念日,然后获取当前系统时间,然后计算秒数差值,然后将其转化为天数、小时数即可,关键核心代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function timeElapse(c) {
var e = Date();
var f = (Date.parse(e) - Date.parse(c)) / 1000;
var g = Math.floor(f / (3600 * 24));
f = f % (3600 * 24);
var b = Math.floor(f / 3600);
if (b < 10) {
b = "0" + b
}
f = f % 3600;
var d = Math.floor(f / 60);
if (d < 10) {
d = "0" + d
}
f = f % 60;
if (f < 10) {
f = "0" + f
}
}

另外它也是通过一个定时器来实现的时间刷新,每隔 500 毫秒调用一次:

1
2
3
setInterval(function () {
timeElapse(together);
}, 500);

动画心形

动画心形,其实这个实现起来是很巧妙的。这里在画的时候实际上是利用了贝塞尔曲线来绘制一个心形,同时在在画的过程中还加了花开的效果,花开的效果使用了随机数和随机颜色生成。 其中动画画心形的核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Petal.prototype = {
draw: function () {
var a = this.bloom.garden.ctx;
var e, d, c, b;
e = new Vector(0, this.r).rotate(Garden.degrad(this.startAngle));
d = e.clone().rotate(Garden.degrad(this.angle));
c = e.clone().mult(this.stretchA);
b = d.clone().mult(this.stretchB);
a.strokeStyle = this.bloom.c;
a.beginPath();
a.moveTo(e.x, e.y);
a.bezierCurveTo(c.x, c.y, b.x, b.y, d.x, d.y);
a.stroke()
}, render: function () {
if (this.r <= this.bloom.r) {
this.r += this.growFactor;
this.draw()
} else {
this.isfinished = true
}
}
};

这里最关键的一个部分就是 bezierCurveTo,这里传入的是绘制贝塞尔曲线的参数坐标,那这些坐标怎么生成的呢,这里是利用了数学上的一个桃心线方程,如图所示: 贝塞尔曲线 其中心形线的解析方程为: 这个公式代表了绘制坐标点的 x、y 的解析方程,用代码表示出来就是:

1
2
3
4
5
6
function getHeartPoint(c) {
var b = c / Math.PI;
var a = 19.5 * (16 * Math.pow(Math.sin(b), 3));
var d = -20 * (13 * Math.cos(b) - 5 * Math.cos(2 * b) - 2 * Math.cos(3 * b) - Math.cos(4 * b));
return new Array(offsetX + a, offsetY + d)
}

这里是生产了心形线方程的 x、y 坐标,然后再以此绘制出带有动画效果的心形。 最终呈现的效果就是现在你看到的样子。 不过这些在改代码的时候实际上不用关心,只需要修改你们在一起的时间就好了,就是代码中的这一行:

1
2
together.setFullYear(2018, 10, 5);
together.setHours(15);

这里修改你们在一起的时间和小时就可以了,然后页面就会自动更新你们在一起多久了,并动态呈现出来了。

域名解析

对于域名解析,这个建议大家可以申请一个域名,比如我的域名就是 cuiqingcai.com,我可以设置一个二级域名解析,叫做 love.cuiqingcai.com。 如果没有域名的话可以现买一个,比如阿里云、腾讯云购买,然后设置解析即可。 如果没有域名,也可以使用一些虚拟云服务器,他们会帮你设置二级域名,当然也可以使用 GitHub Pages,甚至你使用 IP 地址来访问也是没问题的。

项目代码

项目的代码我都放在了:https://github.com/Germey/ValentinesDay,大家可以自行修改成想要的样子送给女朋友,只能帮你到这里啦。 嘿嘿,这就是我今天送给女朋友的礼物,女朋友收到了开心得不得了,开心。

我的礼物

其实我今天也收到了女朋友送的特殊的礼物,可以说她确实花了不少心思啊,她送了我什么呢?令我没想到的是,她居然刚申请了一个微店,然后她微店上架了好多商品,我看到时候惊呆了,店铺如图所示: image-20190214182451019 里面上架了什么商品?洗水果服务?做饭刷碗服务?捏肩膀服务?还有自动哄老婆机?我惊了。 她把商品发给我,我好奇问她这是干嘛的。 她说:要获得我的洗水果服务,捏肩膀服务,只需要在我的小店里购买使用就好了(作掐腰状)!还有自动哄老婆机,你要惹我生气了,只需要购买一个自动哄老婆机,我就会不生气了!嘿嘿合不合算? 我说:多少钱?999!这么贵的吗! 她说:当然不是啦,亲亲我们店里有活动的,使用优惠券满 999 减 998 呢,您是我的 VIP 唯一专属客户,我会给您发优惠券的呀,使用优惠券只需要一块钱就可以购买了。购买之后,您每次使用一张,我就可以给您洗水果、捏肩膀了!这个情人节的话呢,我要送亲亲 10 张!可省着点话,不能累到店长我啊! 哦哦,卧槽真牛逼啊!于是乎我就快快乐乐领取到了十张优惠券购买了女朋友的这些服务,等着时不时用一张,享受一下帝王级的待遇,美滋滋!哈哈~ 最后,祝大家情人节快乐!幸福!

技术杂谈

作为一名程序员,能够利用好工具提高开发和工作效率是非常重要的。我个人使用的都是苹果系列产品,电脑为 MacBook Pro 15 寸,手机 iPhone 7P,另外还有一个 iPad Pro 和一副 Apple Watch。我一直觉得 Mac 是非常适合做程序开发的,它既有比较不错的页面,也有类 Unix 的操作系统,使得日常使用和程序开发都极其便利,另外由于苹果本身自有的 iCloud 机制,使用 Mac、iPhone、iPad 跨平台开发和工作也变得十分便利。

近期我又对自己的一些工具进行了整理,弃用了一些工具,新启用了一些工具。目的也只有一个,就是提高自己的工作和开发效率,让生活变得更美好。如果你也在用 Mac 开发,或者你也有使用 iPad、iPhone,下面我所总结的个人的一些工具或许能给你带来帮助。

快速导航

这是 Mac 上的一个工具,要说到提高效率,首推 Alfred,可以说是 Mac 必备软件,利用它我们可以快速地进行各种操作,大幅提高工作效率,如快速打开某个软件、快速打开某个链接、快速搜索某个文档,快速定位某个文件,快速查看本机 IP,快速定义某个色值,几乎我们能想到的都能对接实现。

其实 Mac 本身已经自带了软件搜索还有 Spotlight,但是其功能还是远远比不上 Alfred,有了它,所有的快捷操作几乎都能实现。

这些快速功能是怎么实现的呢?实际上是 Alfred 对接了很多 Workflow,我们可以使用 Workflow 方便地进行功能扩展,一些比较优秀的 Workflow 已经有人专门做过整理了,可以参见:https://github.com/zenorocha/alfred-workflows,大家可以安装自己所需要的 Workflow,大大提高效率。

复制粘贴

Mac 上默认只有一个粘贴板,当我们新复制了一段文字之后,如果我们想再找寻之前复制的历史记录就找不到了,这其实是很反人类的。

好在 Paste 这款软件帮我们解决了这个问题,它可以保存我们粘贴板的历史记录,等需要粘贴某个内容的时候只需要呼出 Paste 历史粘贴板,然后选择某个特定的内容粘贴就好了,另外它还支持文本格式调整粘贴板分类和搜索,还可以支持快速便捷粘贴。有了它,再也不用担心粘贴板丢失了!

另外使用 Mac 和 iPhone、iPad 之间也可以相互之间复制粘贴,可以在一台 Apple 设备上拷贝文本、图像、照片和视频,然后在另一台 Apple 设备上粘贴该内容。例如,可以拷贝在 Mac 上浏览网页时发现的食谱,然后将其粘贴到附近 iPhone 上“备忘录”中的购物清单。这是在 macOS Sierra 版本之后出来的功能,若要使用需要确保 Mac 的版本是 Sierra 及以后。若要使用,几个设备必须满足“连续互通”系统要求。它们还必须在“系统偏好设置”(在 Mac 上)和“设置”(在 iOS 设备上)中打开 Wi-Fi、蓝牙和 Handoff,另外必须在所有设备上使用同一 Apple ID登录 iCloud。

具体的操作流程可以参见苹果的官网说明:https://support.apple.com/kb/PH25168,有了这个功能,日常的一些操作便可以直接同步了,甚至不再需要 AirDrop,更不需要微信和邮件。

时间管理

现在这个时候,时间比什么都重要,每个人的时间都是公平的,如果我们能够合理规划好自己的时间和工作,这就跨出了成功的一步。

我曾经尝试用手写的方式来记录自己的一些任务,但总感觉它有一些并不方便的地方。比如某时某刻突然想起来,想要添加一件事情或者完成了一件事情,或者想要修改截止时间,或者想要划分优先级,其实都不怎么方便。最好的方式还是通过一些专用的时间管理软件来分配分配和管理自己的时间。

我曾经使用过非常多款时间管理工具,最终我选择的是 Todoist,这个是我感觉体验非常不错的一款。这个软件里面基本的任务添加与勾划功能当然必不可少,它也支持优先级管理,分类管理,时间设置,另外还有几个我觉得非常加分的几个点,比如:

  • 添加时间时可以直接通过一句话来添加,比如”每隔两天晚上九点运动”,它会自动识别并设置为循环任务,并能在相应的时刻提醒你。

  • 支持全部平台,不论是网页还是 Windows、Mac、Android、iPhone、iPad、Apple Watch、Chrome、Firefox,你能想到的平台,应有尽有。

  • 它还支持事件同步,可以在 Mac 或者 iPhone 的日历中添加 Todoist 的同步,这样你所有的事情都会被定时同步到日历软件中,这样日历中就既包含了节日、生日等信息,又可以把每天我们需要做的事同步进来,日程信息一目了然。这样你就可以把日历变成一个提醒器,设置什么时候提醒就好了,现在我就在用 Mac 或者 iPhone 上的日历来提醒我什么时间该做什么事情了。

  • 它还支持多人写作,就类似于 Worktile、Teambition、Trello,我曾经使用这款软件完成了多个项目的任务分配和多人协作开发,还是非常方便的。

  • 另外它还支持过期智能重新安排任务,比如有一些任务没有完成,它还可以根据优先级来重新进行时间规划和安排,同时也有任务评分和目标评价机制,来反映我们任务完成情况。

另外关于时间管理还有一个非常重要的四象限法则,大家也可以了解一下。有了这个法则,大家可以合理安排优先级,合理分配每个任务的时间。有关于我的时间管理经验我后面还会详细写一篇相关的文章,介绍一下我平时会怎样进行时间规划和学习的。

另外我还尝试过番茄土豆这个软件,这个软件的缺点在于整体的功能还比较简陋,而且不能和我已有的 Todo List 进行同步。好处就是可以自己设置番茄,保持专注工作。但目前我尚未发现满意的产品,如有还希望大家可以留言推荐一下,谢谢。

笔记记录

在学习的时候来进行记录是非常非常重要的,强烈建议一边学习一边把所做所想记录下来,最后做一下整理成文。一方面方便查阅,另一方面加深印象和理解。

Markdown 想必大家都已经很耳熟了,现在我写文章或者笔记几乎全都用 Markdown 来写,现在很多云笔记也慢慢逐步支持 Markdown 的语法了,我的博客后台也自己配置了 Markdown 的支持。不过也有某些平台尚未支持 Markdown,比如知乎,忍不住吐槽一句,知乎的编辑器实在用得是心累,当然可以使用插件来解决,也当然也有所好,我就不再说什么了。不过我还是强烈推荐 Markdown 来进行写作和记录的,用过之后你可能就不再想用 Word 了。

言归正传,既然谈到笔记和写作。我的笔记本是 Mac,之前几乎所有的笔记,包括写书,都几乎是在 Mac 上完成的,但是确实有的时候是不方便的。比如 Mac 不在身边或者想用 iPhone 或者 iPad 来写点东西的时候,一个需要解决的问题就是云同步问题。有了云同步,我们如果在电脑上写了一部分内容,接着切换了另一台台式机,或者切换了手机的时候,照样能够接着在原来的基础上写,非常方便。

这时候可能就有小伙伴推荐有道云笔记、印象笔记等软件,它们支持 Markdown,但这并不是它们的主打支持方向,对 Makrdown 的支持当然没有一些专业的 Makrdown 编辑工具专业。对我个人而言,我不想因为它们自带了云同步而抛弃了纯粹的 Makrdown 写作环境,我只想要一个纯粹的 Makrdown 写作环境,而不想引入比如有道云里面的普通笔记、签到等冗余的功能,也不想看到里面的广告推荐等内容。所以对于云同步,我使用了另外的解决方案。对于写作软件,我也摸索出了自己的一套方案。

对于电脑端的 Markdown 写作软件,推荐两款,一款是 Typora,另一款是 MarkEditor。

对于 Makrdown 编辑器来说,我觉得有几个比较重要的点:

  • 不能纯写 Makrdown,要实时地能够看到自己写完 Makrdown 之后最终呈现的效果是怎样的。

  • 插入图片要方便,很多编辑器需要先将图片挪到某个文件夹下或者上传图片才可以插入图片,这是很不友好的,如果能够直接通过复制粘贴的形式插入图片,甚至能够自动将图片上传到云端,那就再好不过了。

  • 能够打开一整个 Makrdown 文件夹,左侧显示文件列表,右侧进行写作编辑,不能仅仅支持一个 Markdown 文件的编辑。

  • 如果需要用到公司,那么编辑器需要对 Markdown 公式支持比较好。

以上介绍的这两款软件都可以做到。

  • Typora 是免费的,更加轻量级,而且支持即写即得,界面支持和公式支持都比较好,图片的话可以结合 iPic 软件直接上传到图床,同时也可以直接将复制的图片直接粘贴到编辑器中,非常友好,目前我正在使用。

  • MarkEditor 是收费的,功能更为丰富,支持左右分栏模式、阅读模式等,它也支持直接复制和粘贴图片,另外还有强大的导出功能,还可以直接将文件发布为一个网站等等,也十分推荐。

不过目前由于 Typora 更轻量级,并且能和 iPic 而且功能配合使用,粘贴后的图片可以点击直接上传到云端,非常方便,我目前已经由 MarkEditor 切换到 Typora 了。

写作界面如下:

如图这是打开了一个文件夹,这个文件夹里面有好多 Makrdown 文件,都是我在研究和学习过程中所写的笔记。

然后需要解决的就是云同步问题,云同步其实使用网盘就足够了。由于我使用 Mac,所以我选用了 iCloud,开了 200G 的空间,足够了。这样我所记录的内容能够秒级同步到 iCloud Drive 中,这样我再使用 iPhone、iPad 就可以直接看到最新的内容了。当然还有一些推荐的,比如 OneDrive、谷歌云等多种云盘同步工具,哪个方便用哪个。Mac 和 iPhone 的好处就是已经内置了 iCloud Drive,所以不用再去在各个终端上配置了。

接下来就是在其他的电脑以及 iPhone、iPad 上进行写作的解决方案了。由于我的文件都已经存放在了 iCloud Drive,所以就需要一款 Makrdown 编辑软件可以直接读写 iCloud Drive 里面的内容,同时界面还要友好,功能完善一点。在这里我最终选择了 Markdown Pro,它的功能简洁但是又比较完善,打开之后直接选取 iCloud Drive 里面的 Makrdown 文件即可开始编辑,并且它是左右分栏的,即左侧编辑,右侧预览,非常方便简洁,另外它对公式的支持也很好,下图是我在 iPad 上对本文进行编辑的效果预览图。

对于图片的插入,在 iPhone 和 iPad 上我借助了另外一个工具,叫做 SM.MS,这个软件可以直接选取图片,然后上传到云端,点击复制即可得到链接和 Makrdown 图片链接,所以插图也不是什么问题了。

如图所示,上传照片之后,便会出现各种各样的图片链接形式,有纯链接、HTML、Markdown 等等,直接点击复制按钮即可复制,然后粘贴到文档中。

另外如果你用了 Windows 的话,只要下载一个 iCloud 云盘软件即可同步。如果使用的是其他的云盘软件,也只需要配置一下就好了。

有了这套,我们就可以实现随时随地写笔记,Mac、iPhone、iPad 无缝切换。

思维导图

很多时候我们在构思方案或者流程的时候需要对思维做梳理,或者在列方案呈现的时候也需要分门别类地进行呈现。这时候大多数情况下就需要用到一个工具,思维导图。

思维导图工具我个人使用的是 MindNode,在 Mac 上用它可以通过各种快捷键快速的增删思维导图节点,另外界面也非常绚丽多彩。

对于思维导图软件来说,我也希望能全平台同步,其实 MindNode 也有对应的移动端软件,同样是 MindNode,二者可以通过 iCloud Drive 进行同步,同样可以做到无缝衔接。

另外还有很多朋友也在用 XMind,功能同样很强大,大家也可以试试。

远程控制

我们经常会和各种服务器打交道,例如我们经常使用 SSH 来远程连接某台 Linux 服务器,原生 Terminal 是支持 SSH 的,但你会发现原生带的这个太难用了。可能很多小伙伴使用 iTerm,不得不说这确实是个神器,大大方便了远程管理流程。但我在这里还要推荐一个我经常使用的 SSH Shell,没错,它的名字就是 SSH Shell,它的页面操作简洁,同时管理和记录远程主机十分方便,另外还支持秘钥管理、自动重连、自定义主题等等功能,个人用起来十分顺手,强烈推荐!

当然除了电脑,当我们出去在外的时候,紧急情况也可能需要使用 SSH 来连接和管理我们的服务器,所以我也在 iPhone 和 iPad 上装了远程管理软件,叫做 Termius,同样功能十分强大,快捷操作十分便捷,有免费的试用期限,我觉得非常好用就订购了,推荐给大家。

代码记录

作为一名程序员,我们会经常写或者使用一些关键代码。

比如有一天我写了一些方法,这些方法可以完成非常重要的功能,后面的项目也会经常遇到,那么怎么办呢?很多情况下我们想把它保存起来,放到某个收藏夹里面备用,等到用的时候重新把它复制出来。或者有一些繁琐的命令,我实在是记不住,或许我们也想把它记录下来。

很多情况下,我们可能简单地使用文本文件,但并不方便同步和查找。或者云笔记保存下来,但这些并不是专门用来保存代码的。更高级一点,我们会联想到使用 GitHub Gists,但每次记录的这个流程也比较麻烦。

这里推荐一个专门用来记录代码片段的软件,叫做 SnippetsLab,适用于 Mac 系统,可以专门用来管理代码片段,还支持多种代码格式。比如我就将代码按照编程语言划分,划分为 Python、JavaScript 等等,分文件夹存储,有不错的代码就随手贴过来,另外它也支持搜索,管理代码非常方便。如果某一天想查某个代码了,直接打开它一搜就有了,可以大大提高开发效率。

以上就暂且总结这么多,其实还有不少好用的用具,后面再一一为大家总结分享。

另外再问下大家,你们买 iPad 了吗?是不是觉得比较鸡肋,或者平时都用不上,那这样就没有发挥 iPad 的最大效用,如果利用好了,它可以进一步方便我们的生活,后面我也会专门写一下 iPad 方面的一些用途。

由于水平和见识有限,如果大家有更好的软件或者方案推荐,欢迎大家留言!也希望我的一些方案对大家有所帮助,谢谢!

Python

爬虫是做什么的?是帮助我们来快速获取有效信息的。然而做过爬虫的人都知道,解析是个麻烦事。 比如一篇新闻吧,链接是这个:https://news.ifeng.com/c/7kQcQG2peWU,页面预览图如下: 预览图 我们需要从页面中提取出标题、发布人、发布时间、发布内容、图片等内容。一般情况下我们需要怎么办?写规则。 那么规则都有什么呢?怼正则,怼 CSS 选择器,怼 XPath。我们需要对标题、发布时间、来源等内容做规则匹配,更有甚者再需要正则表达式来辅助一下。我们可能就需要用 re、BeautifulSoup、pyquery 等库来实现内容的提取和解析。 但如果我们有成千上万个不同样式的页面怎么办呢?它们来自成千上万个站点,难道我们还需要对他们一一写规则来匹配吗?这得要多大的工作量啊。另外这些万一弄不好还会解析有问题。比如正则表达式在某些情况下匹配不了了,CSS、XPath 选择器选错位了也会出现问题。 想必大家可能见过现在的浏览器有阅读模式,比如我们把这个页面用 Safari 浏览器打开,然后开启阅读模式,看看什么效果: Safari预览 页面一下子变得非常清爽,只保留了标题和需要读的内容。原先页面多余的导航栏、侧栏、评论等等的统统都被去除了。它怎么做到的?难道是有人在里面写好规则了?那当然不可能的事。其实,这里面就用到了智能化解析了。 那么本篇文章,我们就来了解一下页面的智能化解析的相关知识。

智能化解析

所谓爬虫的智能化解析,顾名思义就是不再需要我们针对某一些页面来专门写提取规则了,我们可以利用一些算法来计算出来页面特定元素的位置和提取路径。比如一个页面中的一篇文章,我们可以通过算法计算出来,它的标题应该是什么,正文应该是哪部分区域,发布时间是什么等等。 其实智能化解析是非常难的一项任务,比如说你给人看一个网页的一篇文章,人可以迅速找到这篇文章的标题是什么,发布时间是什么,正文是哪一块,或者哪一块是广告位,哪一块是导航栏。但给机器来识别的话,它面临的是什么?仅仅是一系列的 HTML 代码而已。那究竟机器是怎么做到智能化提取的呢?其实这里面融合了多方面的信息。

  • 比如标题。一般它的字号是比较大的,而且长度不长,位置一般都在页面上方,而且大部分情况下它应该和 title 标签里的内容是一致的。
  • 比如正文。它的内容一般是最多的,而且会包含多个段落 p 或者图片 img 标签,另外它的宽度一般可能会占用到页面的三分之二区域,并且密度(字数除以标签数量)会比较大。
  • 比如时间。不同语言的页面可能不同,但时间的格式是有限的,如 2019-02-20 或者 2019/02/20 等等,也有的可能是美式的记法,顺序不同,这些也有特定的模式可以识别。
  • 比如广告。它的标签一般可能会带有 ads 这样的字样,另外大多数可能会处于文章底部、页面侧栏,并可能包含一些特定的外链内容。

另外还有一些特点就不再一一赘述了,这其中包含了区块位置、区块大小、区块标签、区块内容、区块疏密度等等多种特征,另外很多情况下还需要借助于视觉的特征,所以说这里面其实结合了算法计算、视觉处理、自然语言处理等各个方面的内容。如果能把这些特征综合运用起来,再经过大量的数据训练,是可以得到一个非常不错的效果的。

业界进展

未来的话,页面也会越来越多,页面的渲染方式也会发生很大的变化,爬虫也会越来越难做,智能化爬虫也将会变得越来越重要。 目前工业界,其实已经有落地的算法应用了。经过我的一番调研,目前发现有这么几种算法或者服务对页面的智能化解析做的比较好:

那么这几种算法或者服务到底哪些好呢,Driffbot 官方曾做过一个对比评测,使用 Google 新闻的一些文章,使用不同的算法依次摘出其中的标题和文本,然后与真实标注的内容进行比较,比较的指标就是文字的准确率和召回率,以及根据二者计算出的 F1 分数。 其结果对比如下:

Service/Software

Precision

Recall

F1-Score

Diffbot

0.968

0.978

0.971

Boilerpipe

0.893

0.924

0.893

Readability

0.819

0.911

0.854

AlchemyAPI

0.876

0.892

0.850

Embedly

0.786

0.880

0.822

Goose

0.498

0.815

0.608

经过对比我们可以发现,Diffbot 的准确率和召回率都独占鳌头,其中的 F1 值达到了 0.97,可以说准确率非常高了。另外接下来比较厉害的就是 Boilerpipe 和 Readability,Goose 的表现则非常差,F1 跟其他的算法差了一大截。下面是几个算法的 F1 分数对比情况: F1分数对比 有人可能好奇为什么 Diffbot 这么厉害?我也查询了一番。Diffbot 自 2010 年以来就致力于提取 Web 页面数据,并提供许多 API 来自动解析各种页面。其中他们的算法依赖于自然语言技术、机器学习、计算机视觉、标记检查等多种算法,并且所有的页面都会考虑到当前页面的样式以及可视化布局,另外还会分析其中包含的图像内容、CSS 甚至 Ajax 请求。另外在计算一个区块的置信度时还考虑到了和其他区块的关联关系,基于周围的标记来计算每个区块的置信度。 总之,Diffbot 也是一直致力于这一方面的服务,整个 Diffbot 就是页面解析起家的,现在也一直专注于页面解析服务,准确率高也就不足为怪了。 但它们的算法开源了吗?很遗憾,并没有,而且我也没有找到相关的论文介绍它们自己的具体算法。 所以,如果想实现这么好的效果,那就使用它们家的服务就好了。 接下来的内容,我们就来说说如何使用 Diffbot 来进行页面的智能解析。另外还有 Readability 算法也非常值得研究,我会写专门的文章来介绍 Readability 及其与 Python 的对接使用。

Diffbot 页面解析

首先我们需要注册一个账号,它有 15 天的免费试用,注册之后会获得一个 Developer Token,这就是使用 Diffbot 接口服务的凭证。 接下来切换到它的测试页面中,链接为:https://www.diffbot.com/dev/home/,我们来测试一下它的解析效果到底是怎样的。 这里我们选择的测试页面就是上文所述的页面,链接为:https://news.ifeng.com/c/7kQcQG2peWU,API 类型选择 Article API,然后点击 Test Drive 按钮,接下来它就会出现当前页面的解析结果: 结果 这时候我们可以看到,它帮我们提取出来了标题、发布时间、发布机构、发布机构链接、正文内容等等各种结果。而且目前来看都十分正确,时间也自动识别之后做了转码,是一个标准的时间格式。 接下来我们继续下滑,查看还有什么其他的字段,这里我们还可以看到有 html 字段,它和 text 不同的是,它包含了文章内容的真实 HTML 代码,因此图片也会包含在里面,如图所示: 结果 另外最后面还有 images 字段,他以列表形式返回了文章套图及每一张图的链接,另外还有文章的站点名称、页面所用语言等等结果,如图所示: 结果 当然我们也可以选择 JSON 格式的返回结果,其内容会更加丰富,例如图片还返回了其宽度、高度、图片描述等等内容,另外还有各种其他的结果如面包屑导航等等结果,如图所示: 结果 经过手工核对,发现其返回的结果都是完全正确的,准确率相当之高! 所以说,如果你对准确率要求没有那么非常非常严苛的情况下,使用 Diffbot 的服务可以帮助我们快速地提取页面中所需的结果,省去了我们绝大多数的手工劳动,可以说是非常赞了。 但是,我们也不能总在网页上这么试吧。其实 Diffbot 也提供了官方的 API 文档,让我们来一探究竟。

Diffbot API

Driffbot 提供了多种 API,如 Analyze API、Article API、Disscussion API 等。 下面我们以 Article API 为例来说明一下它的用法,其官方文档地址为:https://www.diffbot.com/dev/docs/article/,API 调用地址为:

1
https://api.diffbot.com/v3/article

我们可以用 GET 方式来进行请求,其中的 Token 和 URL 都可以以参数形式传递给这个 API,其必备的参数有:

  • token:即 Developer Token
  • url:即要解析的 URL 链接

另外它还有几个可选参数:

  • fields:用来指定返回哪些字段,默认已经有了一些固定字段,这个参数可以指定还可以额外返回哪些可选字段
  • paging:如果是多页文章的话,如果将这个参数设置为 false 则可以禁止多页内容拼接
  • maxTags:可以设置返回的 Tag 最大数量,默认是 10 个
  • tagConfidence:设置置信度的阈值,超过这个值的 Tag 才会被返回,默认是 0.5
  • discussion:如果将这个参数设置为 false,那么就不会解析评论内容
  • timeout:在解析的时候等待的最长时间,默认是 30 秒
  • callback:为 JSONP 类型的请求而设计的回调

这里大家可能关注的就是 fields 字段了,在这里我专门做了一下梳理,首先是一些固定字段:

  • type:文本的类型,这里就是 article 了
  • title:文章的标题
  • text:文章的纯文本内容,如果是分段内容,那么其中会以换行符来分隔
  • html:提取结果的 HTML 内容
  • date:文章的发布时间,其格式为 RFC 1123
  • estimatedDate:如果日期时间不太明确,会返回一个预估的时间,如果文章超过两天或者没有发布日期,那么这个字段就不会返回
  • author:作者
  • authorUrl:作者的链接
  • discussion:评论内容,和 Disscussion API 返回结果一样
  • humanLanguage:语言类型,如英文还是中文等
  • numPages:如果文章是多页的,这个参数会控制最大的翻页拼接数目
  • nextPages:如果文章是多页的,这个参数可以指定文章后续链接
  • siteName:站点名称
  • publisherRegion:文章发布地区
  • publisherCountry:文章发布国家
  • pageUrl:文章链接
  • resolvedPageUrl:如果文章是从 pageUrl 重定向过来的,则返回此内容
  • tags:文章的标签或者文章包含的实体,根据自然语言处理技术和 DBpedia 计算生成,是一个列表,里面又包含了子字段:
    • label:标签名
    • count:标签出现的次数
    • score:标签置信度
    • rdfTypes:如果实体可以由多个资源表示,那么则返回相关的 URL
    • type:类型
    • uri:Diffbot Knowledge Graph 中的实体链接
  • images:文章中包含的图片
  • videos:文章中包含的视频
  • breadcrumb:面包屑导航信息
  • diffbotUri:Diffbot 内部的 URL 链接

以上的预定字段就是如果可以返回那就会返回的字段,是不能定制化配置的,另外我们还可以通过 fields 参数来指定扩展如下可选字段:

  • quotes:引用信息
  • sentiment:文章的情感值,-1 到 1 之间
  • links:所有超链接的顶级链接
  • querystring:请求的参数列表

好,以上便是这个 API 的用法,大家可以申请之后使用这个 API 来做智能化解析了。 下面我们用一个实例来看一下这个 API 的用法,代码如下:

1
2
3
4
5
6
7
8
9
10
import requests, json

url = 'https://api.diffbot.com/v3/article'
params = {
'token': '77b41f6fbb24496d5113d528306528fa',
'url': 'https://news.ifeng.com/c/7kQcQG2peWU',
'fields': 'meta'
}
response = requests.get(url, params=params)
print(json.dumps(response.json(), indent=2, ensure_ascii=False))

这里首先定义了 API 的链接,然后指定了 params 参数,即 GET 请求参数。 参数中包含了必选的 token、url 字段,也设置了可选的 fields 字段,其中 fields 为可选的扩展字段 meta 标签。 我们来看下运行结果,结果如下:

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
{
"request": {
"pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU",
"api": "article",
"fields": "sentiment, meta",
"version": 3
},
"objects": [
{
"date": "Wed, 20 Feb 2019 02:26:00 GMT",
"images": [
{
"naturalHeight": 460,
"width": 640,
"diffbotUri": "image|3|-1139316034",
"url": "http://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png",
"naturalWidth": 690,
"primary": true,
"height": 426
},
// ...
],
"author": "中国新闻网",
"estimatedDate": "Wed, 20 Feb 2019 06:47:52 GMT",
"diffbotUri": "article|3|1591137208",
"siteName": "ifeng.com",
"type": "article",
"title": "故宫,你低调点!故宫:不,实力已不允许我继续低调",
"breadcrumb": [
{
"link": "https://news.ifeng.com/",
"name": "资讯"
},
{
"link": "https://news.ifeng.com/shanklist/3-35197-/",
"name": "大陆"
}
],
"humanLanguage": "zh",
"meta": {
"og": {
"og:time ": "2019-02-20 02:26:00",
"og:image": "https://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png",
"og:category ": "凤凰资讯",
"og: webtype": "news",
"og:title": "故宫,你低调点!故宫:不,实力已不允许我继续低调",
"og:url": "https://news.ifeng.com/c/7kQcQG2peWU",
"og:description": "  “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”   “重"
},
"referrer": "always",
"description": "  “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”   “重",
"keywords": "故宫 紫禁城 故宫博物院 灯光 元宵节 博物馆 一票难求 元之 中新社 午门 杜洋 藏品 文化 皇帝 清明上河图 元宵 千里江山图卷 中英北京条约 中法北京条约 天津条约",
"title": "故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰资讯"
},
"authorUrl": "https://feng.ifeng.com/author/308904",
"pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU",
"html": "<p>“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。...</blockquote> </blockquote>",
"text": "“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”\\n“...",
"authors": [
{
"name": "中国新闻网",
"link": "https://feng.ifeng.com/author/308904"
}
]
}
]
}

可见其返回了如上的内容,是一个完整的 JSON 格式,其中包含了标题、正文、发布时间等等各种内容。 可见,不需要我们配置任何提取规则,我们就可以完成页面的分析和抓取,得来全不费功夫。

Diffbot SDK

另外 Diffbot 还提供了几乎所有语言的 SDK 支持,我们也可以使用 SDK 来实现如上功能,链接为:https://www.diffbot.com/dev/docs/libraries/,如果大家使用 Python 的话,可以直接使用 Python 的 SDK 即可,Python 的 SDK 链接为:https://github.com/diffbot/diffbot-python-client。 这个库并没有发布到 PyPi,需要自己下载并导入使用,另外这个库是使用 Python 2 写的,其实本质上就是调用了 requests 库,如果大家感兴趣的话可以看一下。 下面是一个调用示例:

1
2
3
4
5
6
7
from client import DiffbotClient,DiffbotCrawl

diffbot = DiffbotClient()
token = 'your_token'
url = 'http://shichuan.github.io/javascript-patterns/'
api = 'article'
response = diffbot.request(url, token, api)

通过这行代码我们就可以通过调用 Article API 来分析我们想要的 URL 链接了,返回结果是类似的。 具体的用法大家直接看下它的源码注释就一目了然了,还是很清楚的。 好,以上便是对智能化提取页面原理的基本介绍以及对 Diffbot 的用法的讲解,后面我会继续介绍其他的智能化解析方法以及一些相关实战,希望大家可以多多关注。

Python

大家好,我是四毛,欢迎关注我的公众号。

有什么想要交流的可以在后台第一时间私我。

今天的文章内容主要是关于字体反爬。

目前已知的几个字体反爬的网站是猫眼,汽车之家,天眼查,起点中文网等等。 以前也看过这方面的文章,今天跟个老哥在交流的时候,终于实操了一把,弄懂了字体反爬是个啥玩意。下面听我慢慢道来。

本文用到的第三方库

fontTools

1、目标网站

url = “https://su.58.com/qztech/

2、反爬虫机制

网页上看见的 后台源代码里面的 从上面可以看出,生这个字变成了乱码,请大家特别注意箭头所指的数字。

3、解决

1、确定反爬方法

在看了别人的解析文章之后,确定采取的是字体反爬机制,即网站定义了字体文件,然后进行相应的查找替换,在前端看起来,是没有任何差异的。其实从审查元素的也是可以看到的: 和大众点评的反爬差不多,都是通过 css 搞得。

2、寻找字体文件

以上面方框里的”customfont“为关键词搜了一下,发现就在源代码里面: 而且还有 base64,直接进行解密,但是解密出来的其实是乱码,这个时候其实要做的很简单,把解密后的内容保存为.ttf格式即可。

ttf 文件: *.ttf 是字体文件格式。TTF(TrueTypeFont)是 Apple 公司和 Microsoft 公司共同推出的字体文件格式,随着 windows 的流行,已经变成最常用的一种字体文件表示方式。 @font-face 是 CSS3 中的一个模块,主要是实现将自定义的 Web 字体嵌入到指定网页中去。

因为我们要对字体进行研究,所以必须将它打开,这里我是用的是FontCreator,打开以后是这个样子(其实很多字,在这里为了看的清楚,所以只截了下面的图): 很明显,每个字可以看到字形和字形编码。 观察现在箭头指的地方和前面箭头指的地方的数字是不是一样啊,没错,就是通过这种方法进行映射的。 所以我们现在的思路似乎就是在源代码里找到箭头指的数字,然后再来字体里找到后替换就行了。 恭喜你,如果你也是这么想的,那你就掉坑里了。 因为每次访问,字体字形是不变的,但字符的编码确是变化的。因此,我们需要根据每次访问,动态解析字体文件。 字体 1: 字体 2: 所以想通过写死的方式也是行不通的。 这个时候我们就要对字体文件进行更深一步的研究了。 3、研究字体文件 刚刚的.ttf 文件我们是看不到内部的东西的,所以这个时候我们要对字体文件进行转换格式,将其转换为 xml 格式,然后来查看: 具体操作如下:

1
2
3
from fontTools.ttLib import TTFont
font_1 = TTFont('58_font_1.ttf')
font_base.saveXML('font_1.xml')

xml 的格式如下: 文件很长,我只截取了一部分。 仔细的观察一下,你会发现~这俩下面的 x,y,on 值都是一毛一样的。所以我们的思路就是以一个已知的字体文件为基本,然后将获取到的新的字体文件的每个文字对应的 x,y,on 值进行比较,如果相同,那么说明新的文字对就 可以在基础字体那里找到对应的文字,有点绕,下面举个小例子。 假设: “我” 在基本字体中的名为 uni1,对应的 x=1,y=1,n=1 新的字体文件中,一个名为 uni2 对应的 x,y, n 分别于上面的相等,那么这个时候就可以确定 uni2 对应的文字为”我”。 查资料的时候,发现在特殊情况下,有时候两个字体中的文字对应的 x,y 不相等,但是差距都是在某一个阈值之内,处理方法差不多,只不过上面是相等,这种情况下就是要比较一下。 其实,如果你用画图工具按照上面的 x 与 y 值把点给连起来,你会发现,就是汉字的字形~ 所以,到此总结一下:

一、将某次请求获取到的字体文件保存到本地[基本字体]; 二、用软件打开后,人工的找出每一个数字对应的编码[ 一定要保证顺序的正确,要不然会出事]; 三、我们以后访问网页时,需要保存新字体文件; 四、用 Fonttools 库对基本字体与新字体进行处理,找 到新的字体与基本字体之间的映射; 五、替换;

4、上代码

微信里上代码真的太丑了, 还是算了吧,微信后台关键词“字体加密” 即可获取 github 地址。 看一下成果

总结

其实这个流程最大的问题就是我们人工录入的基本字体的字典数据有可能是会发生变化的,这就导致我们后面还要手动去改。 现在,如果你已经看懂了本文,那么还不快去其他几个网站试试? 如果有任何问题,欢迎交流。

Python

大家好,我是四毛,欢迎大家关注我的公众号。

今天在工作中,碰到了第一次碰见的反爬虫机制,感觉很有意思,在这里记录一下,希望对大家有帮助。

今天用到的库

requests [请求] lzstring [解压数据] pyexecjs [执行 JS]

简单粗暴,直接上网站部分源代码,因为这个网站应该不太希望别人来爬,所以就不上网站了。为什么这么说,因为刚开始请求的时候,老是给我返回 GO TO HELL ,哈哈。 这个网站点击鼠标右键审查元素,查看网页源代码是无法用的,但是这个好像只能防住小白啊,简单的按 F12 审查元素,CTRL+u 直接查看源代码(谷歌浏览器)。 这次的目的主要是为了获取下面的链接(重度打码)

xxxxxxxxxxx/xxxxx-003-a5f7xxxxxx?cid=xxxxx&xxx=siOE_q4XxBtwdoXqD0xxxx

其中,红色加粗的就是我们要找的变量了。

一、观察与抓包

首先,我观察到了网页源代码中的一部分 js 代码:

type=”text/javascript”>window“\x65\x76\x61\x6c”{e=function(c){return(c35?String.fromCharCode(c+29):c.toString(36))};if(!’’.replace(/^/,String)){while(c—)d[e(c)]=k[c]||e(c);k=[function(e){return d[e]}];e=function(){return’\\w+’};c=1;};while(c—)if(k[c])p=p.replace(new RegExp(‘\\b’+e(c)+’\\b’,’g’),k[c]);return p;}(‘29.2a({“2c”:1k,”26”:”21”,”22”:”1k.24”,”2o”:2n,”2s”:”2r”,”2l”:[“0-2f-2e.3.2”,”0-2j-2i.3.2”,”0-1r-1L.3.2”,”0-1r-1K.3.2”,”0-1e-1X.3.2”,”0-1e-1Q.3.2”,”0-1x-1W.3.2”,”0-1x-1M.3.2”,”0-1B-33.3.2”,”0-1B-36.3.2”,”0-1F-30.3.2”,”0-1F-2U.3.2”,”0-1g-2Y.3.2”,”0-1g-3e.3.2”,”0-1q-3i.3.2”,”0-1q-38.3.2”,”0-1o-37.3.2”,”0-1o-3a.3.2”,”0-1s-2B.3.2”,”0-1s-2E.3.2”,”0-1i-2v.3.2”,”0-1i-2y.3.2”,”0-1G-2P.3.2”,”0-1G-2S.3.2”,”0-1E-2R.3.2”,”0-1E-2H.3.2”,”0-1I-2L.3.2”,”0-1I-2J.3.2”,”0-t-2K.3.2”,”0-t-2G.3.2”,”0-p-2I.3.2”,”0-p-2M.3.2”,”0-o-2Q.3.2”,”0-o-2N.3.2”,”0-u-2O.3.2”,”0-u-2w.3.2”,”0-D-2x.3.2”,”0-D-2t.3.2”,”0-z-2u.3.2”,”0-z-2z.3.2”,”0-y-2D.3.2”,”0-y-2F.3.2”,”0-7-2A.3.2”,”0-7-2C.3.2”,”0-5-2T.3.2”,”0-5-3b.3.2”,”0-4-3c.3.2”,”0-4-39.3.2”,”0-g-3d.3.2”,”0-g-3h.3.2”,”0-i-3j.3.2”,”0-i-3f.3.2”,”0-b-3g.3.2”,”0-b-2X.3.2”,”0-d-2Z.3.2”,”0-d-2V.3.2”,”0-11-2W.3.2”,”0-11-34.3.2”,”0-13-35.3.2”,”0-13-31.3.2”,”0-10-32.3.2”,”0-10-1J.3.2”,”0-15-1O.3.2”,”0-15-1P.3.2”,”0-1d-1R.3.2”,……………………………………,’M4UxFsAsB9odxAIwA7WQOwObQAwCYBWXPAFlwHYBmY6ncousuvYgDlwDZ38BOT8zn3wcKARmICKOXK1qsmrFjlHdRAnKxEb1rBqK2ih5dSSbkteJXnEbpGm+SF47HLZSEElBOwRsEmlOqU3AS0JHYkWiRKJAwkYTYkQiQmIQwcShy0HDaUSpQuDJQ2HAG0BEFFWgTVQhw+3JRMOEw8Shq4PK……………………………………..GHXTzLjxjzN5CziB+3ODSAhicwuCyeBJ3YJD+eKhEi83fHm76PmK3Y/htPJFzw8LzOCqKTMcXGHr7Jm/nlPtuSUfPBAA===’‘\x73\x70\x6c\x69\x63’,0,{})) </script>

为了节省篇幅,我把一些替换成…….了。如果你对这些数据的解压有兴趣,请后台联系我。 我第一眼看见时,做了 2 件事: 1、把[\x65\x76\x61\x6c],[ \x73\x70\x6c\x69\x63],[\x7c]分别解码,解码出的结果为 eval 和 splic,| ; 注:其实这里看到 eval 时就应该想到可以试试直接用 pyexecjs 执行后面的那段 js 匿名函数,但是当时确实啥都没有想起来,很惭愧,后来复盘时才发现这一点。虽然执行了也会报错。 2、把那一长串的字母试着用 base64 解码了,但是解不出来; 然后,就只能再去其他 js 文件里找了,也找到了 js 代码,打了断点,但是看着还是很烦,于是这个时候我就没有在 js 上面死抠。 接着,我调转了方向,在 GITHUB,Google 运用了我祖传的高级搜索技巧,Finally,终于可以盘它了。

二、解密

下面开始解密: 1、数据解压 包含 A===的字符串到底时啥呢,其实这个是 js 的一种数据的压缩方式,知道了这种方式你就可以立即破解这种反爬虫机制了,反正我以前是不知道的,第一次见到,学习了。 在这里,lzstring 闪亮登场,运用这个库,执行下面的代码:

lzstring.LZString().decompressFromBase64(string)

这样就可以把上面的那串字符给解码了,解出来的数据像下面这样:

webp|png|025|024|073

没错,数据解压就是这么的简单; 2、看懂 JS 我们在上面解码出来了一个 splic 和一个[ | ],再看看我们上面解压出来的数据,是不是很有感觉;但是,查了好一会资料,也没有发现 js 里面有这个方法,可能是在别的地方定义了;在我找到的资料里面,那个作者是直接用了 split,最后的结果是对的,走逻辑上也说得过去,不知道为什么这么设置成 splic,还希望有大神可以指教; 3、执行 JS 所以,到这里就很明显了,把解码以后以及解压以后的数据在替换会原 js 数据中,我们前面解码出来的 eval 就有用了,我找到的资料里面作者使用 node 做的,我没有安装 node 环境,所以我就直接用了 pyexecjs.eval 直接执行了,结果也正确; 4、梳理流程: 匹配长字符串==>lzstring 解压+解码后的字段==>拼凑成新的 js 代码==>pyexecjs 执行==>出结果 5、部分代码 执行的主要步骤和结果

execjs.eval(res)

没错,就是这么简单,res 就是替换后的 js,然后就可以直接出来我们上面网址中需要的两个字段了。 截个图吧

三、结语

到这里,今天的文章就结束了,总结一下就是知道了一种 JS 数据的压缩方式,并且学习了解压的方式,JS 的执行,同时写代码的时候**多观察,多想,多试**。 如果觉得还不错,欢迎动动手指关注我。

Python

大家好,我是四毛,好久没有写东西了,欢迎大家关注我的公众号。

今天的文章是关于如何使用 requests 来爬取大众点评的数据。 看完本文,你可以:

1、了解大众点评的 CSS 反爬虫机制 2、破解反爬虫机制 3、使用 requests 即可正确获取到评论数,平均价格,服务,味道,环境数据,评论文本数据;

如果你想跟我继续交流的话,欢迎加我的个人微信,二维码在最后。 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。 同时,代码我并没有做太多的优化,因为没有大量的代理,爬不了太多的内容。 这里只是跟大家分享一下处理的流程。欢迎来公众号留言探讨。

正文开始。

1.前言

在工作生活中,发现越来越多的人对大众点评的数据感兴趣,而大众点评的反爬又是比较严格的。采取的策略差不多是宁可错杀一万,也不放过一个。有的时候正常浏览都会跳出验证码。 另外,在 PC 端的展示数据是通过 CSS 来控制的,从网页上看不出来太大的区别,但是用普通的脚本取获取时,会发现数据是获取不到的,具体的源代码是下面这样的: 然,在搜资料的时候,你会发现,很多教程都是用的 selenium 之类的方法,效率太低,没有啥技术含量。 所以,这篇文章的面向的对象就是 PC 端的大众点评;目标是解决这种反爬虫措施,使用 requests 获取到干净正确的数据; 跟着我,绝不会让你失望。

2.正文开始

相信搞过大众点评网站的同学都应该知道上面的这种是一个 css 反爬的方法,具体的解析操作,即将开始。

找到藏着秘密的 css

当我们的鼠标在上面框框内的 span 上面点击时,会发现右边部分会相应的发生变化: 这张图片很重要,很重要,很重要,我们要的值,几乎都从这里匹配出来。 这里我们看到了“vxt20”这个变量对应的两个像素值,前面的是控制用哪个数字,后面的是控制用哪一段的数字集合,先记下,后面要用,同时这里的值应该是 6; 这里其实就是整个破解流程最关键的一步了。在这里我们看到了一个链接。 瞎猫当死耗子吧,点进去看看。 https://s3plus.meituan.net/v1/mss_0a06a471f9514fc79c981b5466f56b91/svgtextcss/f556c0559161832a4c6192e097db3dc2.svg 你会发现,返回的是一些数字,我一开始是不知道这是啥玩意的,看过一些大神的解析才知道,其实这里就是我们看到的数字的来源,也就是整个破解的源头,知道了这些数字的含义,也就可以直接破解了整个反爬的流程。 现在直接看源代码: 可以看到这里面的几个关键数字:font-size:字体大小;还有几个 y 的值,我到后面才知道原来这个 y 是个阈值,起的是个控制的作用。 所以,这一反爬的原理就是:

获取属性值与偏移量和阈值映射,然后从 svg 文件中找到真数据。

现在我们就要用到上面的像素值了。

1.把所有的值取绝对值; 2.用后面的值来选择用哪个段的数字,这里的值是 103,所以使用第三个段的数字集合; 3.因为每个字体是 12 个像素,所以用 163/12=13.58,约等于 14,那么我们数一下第 14 个数字是啥,没错,是 6,和预期一样。你可以多试验几次。

以上,就是整个破解的逻辑过程。 画个流程图,装个逼:

3.Show Code

下面开始晒代码,俗话说得好,天下代码一大抄。 这里对主要的步骤代码进行解释, 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。1.获取 css_url 及 span 对应的 TAG 值;

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
def get_tag(_list, offset=1):
# 从第一个开始查
_new_list = [data[0:offset] for data in _list]

if len(set(_new_list)) == 1:
# 如果set后只有一个值,说明全部重复,这个时候就把offset加1
offset += 1
return get_tag(_list, offset)
else:
_return_data = [data[0:offset - 1] for data in _list][0]

return _return_data

def get_css_and_tag(content):
"""
:param url: 待爬链接
:return: css链接,该span对应的tag
"""
find_css_url = re.search(r'href="([^"]+svgtextcss[^"]+)"', content, re.M)
if not find_css_url:
raise Exception("cannot find css_url ,check")
css_url = find_css_url.group(1)

css_url = "https:" + css_url
# 这个网页上不同的字段是由不同的css段来进行控制的,所以要找到这个评论数据对应的tag,在这里返回的值为vx;而在获取评论数据时,tag就是fu-;
# 具体可以观察上面截图的3个span对应的属性值,相等的最长部分为vx
class_tag = re.findall("<b class=\"(.*?)\"></b>", content)
_tag = get_tag(class_tag)

return css_url, _tag

2.获取属性与像素值的对应关系

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_css_and_px_dict(css_url):
con = requests.get(css_url, headers=headers).content.decode("utf-8")
find_datas = re.findall(r'(\.[a-zA-Z0-9-]+)\{background:(\-\d+\.\d+)px (\-\d+\.\d+)px', con)
css_name_and_px = {}
for data in find_datas:
# 属性对应的值
span_class_attr_name= data[0][1:]
# 偏移量
offset = data[1]
# 阈值
position = data[2]
css_name_and_px[span_class_attr_name] = [offset, position]
return css_name_and_px

3.获取 svg 文件的 url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_svg_threshold_and_int_dict(css_url, _tag):
con = requests.get(css_url, headers=headers).content.decode("utf-8")
index_and_word_dict = {}
# 根据tag值匹配到相应的svg的网址

find_svg_url = re.search(r'span[class\^="%s"].*?background\-image: url\((.*?)\);' % _tag, con)
if not find_svg_url:
raise Exception("cannot find svg file, check")
svg_url = find_svg_url.group(1)
svg_url = "https:" + svg_url
svg_content = requests.get(svg_url, headers=headers).content
root = H.document_fromstring(svg_content)
datas = root.xpath("//text")
# 把阈值和对应的数字集合放入一个字典中
last = 0
for index, data in enumerate(datas):
y = int(data.xpath('@y')[0])
int_data = data.xpath('text()')[0]
index_and_word_dict[int_data] = range(last, y+1)
last = y
return index_and_word_dict

4. 获取最终的值

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
def get_data(url ):
"""
:param page_url: 待获取url
:return:
"""
con = requests.get(url, headers=headers).content.decode("utf-8")
# 获取css url,及tag
css_url, _tag = get_css(con)
# 获取css对应名与像素的映射
css_and_px_dict = get_css_and_px_dict(css_url)
# 获取svg的阈值与数字集合的映射
svg_threshold_and_int_dict = get_svg_threshold_and_int_dict(css_url, _tag)

doc = etree.HTML(con)
shops = doc.xpath('//div[@id="shop-all-list"]/ul/li')
for shop in shops:
# 店名
name = shop.xpath('.//div[@class="tit"]/a')[0].attrib["title"]
print name
comment_num = 0

comment_and_price_datas = shop.xpath('.//div[@class="comment"]')
for comment_and_price_data in comment_and_price_datas:
_comment_data = comment_and_price_data.xpath('a[@class="review-num"]/b/node()')
# 遍历每一个node,这里node的类型不同,分别有etree._ElementStringResult(字符),etree._Element(元素),etree._ElementUnicodeResult(字符)
for _node in _comment_data:
# 如果是字符,则直接取出
if isinstance(_node, etree._ElementStringResult):
comment_num = comment_num * 10 + int(_node)
else:
# 如果是span类型,则要去找数据
# span class的attr
span_class_attr_name = _node.attrib["class"]
# 偏移量,以及所处的段
offset, position = css_and_px_dict[span_class_attr_name]
index = abs(int(float(offset) ))
position = abs(int(float(position)))
# 判断
for key, value in svg_threshold_and_int_dict.iteritems():
if position in value:
threshold = int(math.ceil(index/12))
number = int(key[threshold])
comment_num = comment_num * 10 + number
print comment_num

4.结果展示

评论条数数据

其实,其他的我都写好了,就不贴了

评论具体数据

5.结语

以上就是大众点评 Css 反爬破解的全部步骤和部分代码。 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。

个人日记

最近一段时间没有更新原创文章了,主要是因为最近整个在忙硕士毕业的各种事情,毕业答辩完了以后休假了一小段时间,整个十二月就这么过去了。 转眼已经 2019 年了,其实去年我并没有写年终总结,现在到头来还是蛮后悔的,说实话总结其实还是蛮有必要的,现在就趁着这个时间来对自己的 2018 做一下总结,并立一下 2019 的一些 Flag,再等到 2020 年翻出来打脸吧。 世界上最了解自己的人当然是自己,一句话总结我的 2018 可以说是: 成就不算少,进步不算多 的一年。为什么成就不算少呢?因为今年可能发生的事相比我之前几年,自己取得了一些从未有的成就,或者完成了一些比较重要的事情。下面就先总结一下自己取得的一些成就吧: 1. 2018 年中出版了自己人生中第一本书籍作品《Python3网络爬虫开发实战》,半年多时间现在已经累计印刷九次,共计 45000 册,上市初长期处在京东科技新书榜第一位的位置,豆瓣评分 9.2。 2. 2018 年 1 月 20 日正式开始运营自己的个人公众号“进击的Coder”,发布了两百多篇文章,其中大约一半为原创,粉丝现在是 25000 多人。 3. 发布了自己开发的一款分布式网络爬虫管理框架 Gerapy,可以完成爬虫项目的配置、部署、管理、监控等功能,目前 GitHub 上 Star 数目将近 1000。 4. 在 GitHub 上创建和贡献了近 100 个项目,目前个人 Followers 达到 3000 人,收获 Star 数目在 2000 左右。 5. 开始从事机器学习、深度学习方向的研究和开发,并发布了自己开发的一套深度学习脚手架 ModelZoo,并不断对接各种模型,持续完善中。 6. 比较顺利地完成秋招,并最终拿到了微软的 Offer,达成理想的结果。 7. 完成了多次大型公开演讲并结识了许多优秀的业界大佬。 8. 顺利地完成了毕业论文和毕业答辩,成功取得北航硕士学位。 9. 凭借自己赚得人生第二桶金,实现收入翻番。 10. Last but not least,她。 当然还有一些其他的就不再一一列举了。这些可能有的跟一些大佬比起来可能真的是小巫见大巫,不过对我来说,这些成就我总体还是比较满意的。 在这些成就里,有些是真的改变我人生轨迹的事情,有些可能只是我人生路顺理成章踏过去的事情,有的可能只是昙花一现几年后毫无价值的东西。但拿掉其中一个,不管是看似有用或者没用的,也可能会产生意想不到的影响,他们确实是我真实经历过,并出现在我生命旅途中的一部分。 本来把成就一个个地展开来说,但后来想想没这个必要。昨日取得的成就就让它过去吧,不要念过去,把更多的精力着眼在未来吧。重要的在于,我在这一年里收获了多少,这些收获的东西能够为我未来的成长带来多少价值。以及另外重要的是,对过去的反思,我还有哪些做的不好的地方,我需要在未来做怎样的调整。 所以为什么说进步不算多呢?虽然自己达成了某些目标,但我真的没怎么感觉到自己相比去年年初的自己有特别大的成长,或许一切都是潜移默化的,或许真的把我现在放回到 2018年初,和现在的自己相比,我才会感觉到差别,但现在自己仿佛真的感觉自己本身和之前没有太多不同。但总的来说,我还有非常多需要反思和改进的地方。 下面再好好反思下自己吧,我总觉得自己的这一年,自己的有些方面做得不够好,总体体现在这么几个方面吧: 1. 拖延症。还是觉得自己的拖延症太严重,执行力还不够好。有些事总是会拖个好几天才完成,没太有恒心。或者有的时候这件事到了要做的时间,却又不想做。之前也会有,但是感觉今年犯的很严重。其实我挺佩服那些持之以恒的人的,自制力和执行力真的很重要。 2. 记录少。挺多自己觉得会的东西,由于懒或者忙,没有记下来,其实还是因为懒。到头来发现真正学会的其实仅仅就是自己记录的部分,因为当时没做记录的,又忘得一干二净了。所以我总体感觉,今年输出的文章并不多。 3. 白忙活。这一年来,其实回想起来,感觉做的无用功是很多了。许多事情感觉做完了之后,真正的价值并不大,所以这个真的需要我来好好权衡。 4. 虎头蛇尾。由于自己的很多事情安排的满满当当,所以有的项目可能就容易被搁置了,导致有一些项目做得虎头蛇尾,草草收场,想想还是很可惜的,但有时候就是缺乏那重新捡起来的一步动力,这个必须得改。 5. 时间规划。我使用 Todoist 已经好多年了,感谢它帮我完成了大部分的时间规划,但有些时候发现还是有些不够好。我多少还会有一些小事优先,大事拖延的倾向,但总体来说已经把握得还算可以了,还得继续加油。 6. 缺乏锻炼。平时事情安排的挺多,有时候也懒得去健身房锻炼,唯一锻炼的时间就是睡前的几分钟了,但是效果不大。可能偶尔一时兴起会去几次健身房,但是还是难以坚持下来。虽然还没感觉到身体有啥不适,但也得加强锻炼了。 所以总体感觉下来,感觉到自己做的不够好的地方还有很多,或许真的要改正对我来说真的挺难,但我也要尽力去做的更好。 这一年中,领悟或贯彻的一些理念也不少,其实太多太多,在这里也不能一一列举了,这里就把现在想到的一些说一下吧: 1. 做一件事前,想想这件事的意义和成本有多大,别把时间浪费在无意义的事情上面。 2. 记录和总结真的很重要。别以为自己当时记住了,等回过头来,你有时候会发现自己的收获就是你所记录的那一部分,没有记录的你想也想不起来了。 3. 如果自己没本事,认识很多牛人,没什么太大意义,重要的还是自己要牛逼,多投资自己。自己牛逼了,别人就都会看到你了。 4. 成年人、社会人的世界,有太多大家都已经默认的规矩,自己要去懂,没几个人会教你。 5. 钱,不能保证能买到所有东西,但是它的确可以提供便利或者免去不必要的麻烦。它是可以增加幸福感的。 6. 如果想要赚钱,最好不要选和时间或精力成正比的事情,多考虑复利。 7. 如果一件东西,花了钱,买到的又不是达到自己预期的,这是双亏。如果买到的是自己所满意或喜欢的的,这就是值的。 8. 选择比努力很重要,眼界比本事更重要。 9. 所有的一切,如果有能力,就不要将就,可能一时由于自己的能力还无法改变,但一定不要丢了改变下去的动力。 10. 如果一件事是自己不喜欢的但是非要做,那就用最短的时间去完成。 11. 健康第一。 还有很多,就暂且写这些吧。这是我现阶段所领悟到的一些东西,也是我成长路上的一种认知转变过程吧。也可能再过几年我就不会这么想了。不过现阶段我会秉承这些理念继续前行。 最后就立几个 2019 年的 Flag 吧(等明年翻出来打脸: 1. 踏入社会和职场的第一年,好好工作,把握好节奏。 2. 完成《Python3网络爬虫开发实战(第二版)》以及现在初步规划的另外的两部作品(名称尚未完全确定暂不透露。 3. 微信公众号读者粉丝在 2019 年底达到 10 万。 4. 自己的 Gerapy、ModelZoo 两个项目持续运营维护,力求完善。 5. 坚持记录,持续输出(内容规划了挺多),达到自己内心中设定的基本目标。 6. 2019 年年收入比 2018 年继续翻番。 7. 结识更多的人,扩展更广的人脉。 8. Last but not least,和她好好地在一起,过我们的向往的生活,做喜欢的事情。 好啦,感谢大家一直以来的支持!新的一年,继续努力!愿我们都成为更好的自己!

Linux

Hello 各位小伙伴 雷门吼!

在教程之前首先申明!此教程适合土豪不缺钱的玩家

潜水了许久了,今天来更新点东西~ 今天说点啥呢? 那就是代理!! 代理在爬虫界的重要作用相信各位应该清楚吧!毕竟绝大部分反爬可以靠代理解决;不能被代理解决的也得要代理配合解决。 市面上各种代理也是琳琅满目的说··· 相信大家最喜欢用的之一应该就是 某布云。 根据官网的显示他他家的代理是这个样子的:

  • 无须切换 IP,每一个请求一个随机 IP。

哇!感觉很爽的样子今天我们就来实现一个类似的代理! 其实 So Easy! 我们需要借助 公有云 来实现。 下面我以 AWS 举例(其它公有云操作类似,唯一的区别的就是:各个服务的名字不同而已)

  1. 首先我们需要需要使用 EC2 来建立一个代理(Google Could 叫 GCE)
    1. 安装 Squid(当然你可以使用其它的代理)
      1. 无认证安装参考这儿(点我)设置代理服务器那一段
      2. MySQL 认证安装(点我)
      3. Note: 请注意检查!!!务必设置 Ipv4 转发
  2. 安装完成之后我们制作启动模板(毕竟一个 EC2 一个 IP 你总不能安装很多很多台吧!会死人的)
    1. 注意设置你的安全组!正常情况下 入站规则只应该有你需要的端口(squid 使用的端口一定要放心!嫌麻烦的小伙伴儿 可以进出都放行全部流量!)出站则是全部流量!
    2. 启动 EC2 的时候选择安全组一定要看清是否是设置过放行的规则! 不要选错了!
    3. 好了现在就可以批量启动了!
    4. 实例数量就是需要同时有多少个 IP 就启动多少个了。
  3. 设置前端负载均衡(提供一个固定地址,这个地址负责随机将请求转发到后端代理服务器上)
    1. 必须使用 TCP 四层负载!原因为啥大家自己百度一哈
    2. 等待负载均衡器启动完成!
    3. 启动完成后获取负载地址
  4. 下面来测试一下效果!

以上完毕!你可以不停的重启 Ec2 实例!你就有百万 IP 池啦!!(前提是你有钱啊) 下面是重启 Ec2 的示例:

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
import boto3
from boto3 import setup_default_session


setup_default_session(aws_access_key_id='XXXXXX',
aws_secret_access_key='XXXXXX',
region_name='区域')

ec2 = boto3.client('ec2')


def get_public_ip_address():
"""
获取IP和实例ID
:return: {实例ID: ip}
"""
response = ec2.describe_instances()
reservations = response.get('Reservations')
instances = [i.get('Instances')[0] for i in reservations]
instance_id_public_ip_address = {i.get('InstanceId'): i.get('PublicIpAddress') for i in instances}
return instance_id_public_ip_address


def reboot_ec2(ip):
"""重启实例
:param ip:
:return:
"""
instance_id_public_ip_address = get_public_ip_address()
instance_id = instance_id_public_ip_address.get(ip)
try:
ec2.reboot_instances(InstanceIds=[instance_id], DryRun=True)
except ClientError as e:
if 'DryRunOperation' not in str(e):
print("You don't have permission to reboot instances.")
raise

try:
response = ec2.reboot_instances(InstanceIds=[instance_id], DryRun=False)
print('Success', response)
except ClientError as e:
print('Error', e)

Python

前言

随着大数据时代的到来,爬虫已经成了获取数据的必不可少的方式,做过爬虫的想必都深有体会,爬取的时候莫名其妙 IP 就被网站封掉了,毕竟各大网站也不想自己的数据被轻易地爬走。 对于爬虫来说,为了解决封禁 IP 的问题,一个有效的方式就是使用代理,使用代理之后可以让爬虫伪装自己的真实 IP,如果使用大量的随机的代理进行爬取,那么网站就不知道是我们的爬虫一直在爬取了,这样就有效地解决了反爬的问题。 那么问题来了,使用什么代理好呢?这里指的代理一般是 HTTP 代理,主要用于数据爬取。现在打开搜索引擎一搜 HTTP 代理,免费的、付费的太多太多品牌,我们该如何选择呢?看完这一篇文章,想必你心中就有了答案。 对于免费代理,其实想都不用想了,可用率能超过 10% 就已经是谢天谢地了。真正靠谱的代理还是需要花钱买的,那这么多家到底哪家可用率高?哪家响应速度快?哪家比较稳定?哪家性价比比较高?为此,我对市面上比较流行的多家付费代理针对可用率、爬取速度、爬取稳定性、价格、安全性、请求限制等做了详细的评测,让我们来一起看一下到底哪家更强!

测评范围

免费代理

在这里我主要测试的是付费代理,免费代理可用率太低,几乎不会超过 10%,但为了作为对比,我选取了西刺免费代理进行了测试。

付费代理

付费代理我选取了站大爷、芝麻 HTTP 代理、太阳 HTTP 代理、讯代理、快代理、蘑菇代理、阿布云代理、全网代理、云代理、大象代理、多贝云进行了对比评测,购买了他们的各个不同级别的套餐使用同样的网络环境进行了测评,详情如下:

代理商家 套餐类型 官方网站
芝麻 HTTP 代理 默认版 http://h.zhimaruanjian.com
阿布云代理 专业版 https://www.abuyun.com
动态版
经典版
大象代理 个人版 http://www.daxiangdaili.com
专业版
企业版
全网代理 普通版 http://www.goubanjia.com
动态版
快代理 开放代理 https://www.kuaidaili.com
私密代理
独享代理
蘑菇代理 默认版 http://www.mogumiao.com
太阳 HTTP 代理 默认版 http://http.taiyangruanjian.com
讯代理 优质代理 http://www.xdaili.cn
混播代理
独享代理
云代理 VIP 套餐 http://www.ip3366.net
站大爷代理 普通代理 http://ip.zdaye.com
短效优质代理

注:其中蘑菇代理、太阳 HTTP 代理、芝麻 HTTP 代理的默认版表示此网站只有这一种代理,不同套餐仅是时长区别,代理质量没有差别。 嗯,我把上面的套餐全部买了一遍,以供下面的评测使用。

测评目标

本次测评主要分析代理的可用率、响应速度、稳定性、价格、安全性、使用频率等因素,下面我们来一一进行说明。

可用率

可用率就是提取的这些代理中可以正常使用的比率。假如我们无法使用这个代理请求某个网站或者访问超时,那么就代表这个代理不可用,在这里我的测试样本大小为 500,即提取 500 个代理,看看里面可用的比率多少。

响应速度

响应速度可以用耗费时间来衡量,即计算使用这个代理请求网站一直到得到响应所耗费的时间。时间越短,证明代理的响应速度越快,这里同样是 500 个样本,计算时只对正常可用的代理做统计,计算耗费时间的平均值。

稳定性

由于爬虫时我们需要使用大量代理,如果一个代理响应速度特别快,很快就能得到响应,而下一次请求使用的代理响应速度特别慢,等了三十秒才得到响应,那势必会影响爬取效率,所以我们需要看下商家提供的这些代理稳定性怎样,总不能这一个特别快,下一个又慢的不行。所以这里我们需要统计一下耗费时间的方差,方差越大,证明稳定性越差。

价格

价格,这个当然是需要考虑的内容,如果一个代理不论是响应速度还是稳定性都特别不错,但是价格非常非常高,这也是不可接受的。

安全性

这的确也是需要考虑的因素,比如一旦不小心把代理提取的 API 泄露出去了,别人就肆意使用我们的 API 提取代理使用,而一直耗费的是我们的套餐。另外一旦别人通过某些手段获取了我们的代理列表,而这些代理是没有安全验证的,这也会导致别人偷偷使用我们的代理。在生产环境上,这方面尤其需要注意。

使用频率

有些代理套餐在 API 调用提取代理时有频率限制,有的代理套餐则会限制请求频率,这些因素都会或多或少影响爬虫的效率,这部分因素我们也需要考虑进来。

测评标准

要做标准的测评,那就必须在标准的测评环境下进行,且尽可能排除一些杂项的干扰,如网络波动、传输延迟等一系列的影响。

主机选取

由于我的个人笔记本是使用 WiFi 上网的,所以可能会有网络波动,而且实际带宽其实并不太好把控,因此它并不适合来做标准评测使用。评测需要在一个网络稳定的条件下进行,而且多个代理的评测环境必须相同,在此我选择了一台腾讯云主机作为测试,主机配置如下:

参数名 参数值
操作系统 Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-53-generic x86_64)
带宽 5 Mbps
核心数 2
内存 4GB
Python 版本 3.5.2

这样我们就可以保证一个标准统一的测试环境了。

现取现测

另外在评测时还需要遵循一个原则,那就是现取现测,即取一个测一个。现在很多付费代理网站都提供了 API 接口,我们可以一次性提取多个代理,但是这样会导致一个问题,每个代理在提取出来的时候,商家是会尽量保证它的可用性的,但过一段时间,这个代理可能就不好用了,所以假如我们一次性提取出来了 100 个代理,但是这 100 个代理并没有同时参与测试,后面的代理就会经历一个的等待期,过一段时间再测这些代理的话,肯定会影响后半部分代理的有效性,所以这里我们将提取的数量统一设置成 1,即请求一次接口获取一个代理,然后立即进行测试,这样可以保证测试的公平性,排除了不同代理有效期的干扰。

时间计算

由于我们有一项是测试代理的响应速度,所以我们需要计算程序请求之前和得到响应之后的时间差,这里我们使用的测试 Python 库是 requests,所以我们就计算发起请求和得到响应之间的时间差即可,时间计算方法如下所示:

1
2
3
4
start_time = time.time()
requests.get(test_url, timeout=timeout, proxies=proxies)
end_time = time.time()
used_time = end_time - start_time

这里 used_time 就是使用代理请求的耗时,这样测试的就仅仅是发起请求到得到响应的时间。

测试链接

测试时我们也需要使用一个稳定的且没有反爬虫的链接,这样可以排除服务器的干扰,这里我们使用百度来作为测试目标。

超时限制

在测试时免不了的会遇到代理请求超时的问题,所以这里我们也需要统一一个超时时间,这里设置为 60 秒,如果使用代理请求百度,60 秒还没有得到响应,那就视为该代理无效。

测试数量

要做测评,那么样本不能太小,如只有十几次测试是不能轻易下结论的,这里我选取了一个适中的测评数量 500,即每个套餐获取 500 个代理进行测试。

测评过程

嗯,测评过程这边主要说一下测评的代码逻辑,首先测的时候是取一个测一个的,所以这里定义了一个 test_proxy() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
test_url = 'https://www.baidu.com/'
timeout = 60

def test_proxy(proxy):
try:
proxies = {
'https': 'http://' + proxy
}
start_time = time.time()
requests.get(test_url, timeout=timeout, proxies=proxies)
end_time = time.time()
used_time = end_time - start_time
print('Proxy Valid', 'Used Time:', used_time)
return True, used_time
except (ProxyError, ConnectTimeout, SSLError, ReadTimeout, ConnectionError):
print('Proxy Invalid:', proxy)
return False, None

这里需要传入一个参数 proxy,代表一个代理,即 IP 加端口组成的代理,然后这里使用了 requests 的 proxies 参数传递给 get() 方法。对于代理无效的检测,这里判断了 | ProxyError, ConnectTimeout, SSLError, ReadTimeout, ConnectionError 这几种异常,如果发生了这些异常统统视为代理无效,返回错误。如果在 timeout 60 秒内得到了响应,那么就计算其耗费时间并返回。 在主程序里,就是获取 API 然后统计结果了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
max = 500

def main():
print('Testing')
used_time_list = []
valid_count = 0
total_count = 0
while True:
flag, result = get_page(api_url)
if flag:
proxy = result.strip()
if is_proxy(proxy):
total_count += 1
print('Testing proxy', proxy)
test_flag, test_result = test_proxy(proxy=proxy)
if test_flag:
valid_count += 1
used_time_list.append(test_result)
stats_result(used_time_list, valid_count, total_count)
time.sleep(wait)
if total_count == max:
break

这里加了一些判断,如 is_proxy() 方法判断了获取的是不是符合有效的代理规则,即判断它是不是 IP 加端口的形式,这样可以排除 API 返回一些错误信息的干扰。另外这里设置了 total_count 和 valid_count 变量,只有符合代理规则的代理参与了测试,这样才算一次有效测试,total_count 加一,如果测试可用,那么 valid_count 加一并记录耗费时间。最后调用了 stats_results 方法进行了统计:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np

def stats_result(used_time_list, valid_count, total_count):
if not used_time_list or not total_count:
return
used_time_array = np.asarray(used_time_list, np.float32)
print('Total Count:', total_count,
'Valid Count:', valid_count,
'Valid Percent: %.2f%%' % (valid_count * 100.0 / total_count),
'Used Time Mean:', used_time_array.mean(),
'Used Time Var', used_time_array.var())

这里使用了 Numpy 来统计了耗费时间的均值和方差,分别反映代理的响应速度和稳定性。 嗯,就这样,利用这个方法我对各个不同的代理套餐逐一进行了测试。

测评结果

经过测评,初步得到如下统计结果:

代理商家 套餐类型 测试次数 有效次数 可用率 响应时间均值 响应时间方差
芝麻 HTTP 代理 默认版 500 495 99.00% 0.916853 1.331989
阿布云代理 专业版 500 452 90.40% 0.68770707 1.1477163
动态版 500 494 98.80% 1.83994 6.0491614
经典版 500 499 99.80% 0.49301904 0.25724468
大象代理 个人版 500 238 47.60% 5.340489 78.56444
专业版 500 284 56.80% 6.87078 105.7984
企业版 500 259 51.80% 6.3081837 121.08402
全网代理 普通版 500 220 44.00% 5.584057 47.442596
动态版 500 485 97.00% 2.776973 17.568045
快代理 开放代理 500 178 35.60% 16.636587 221.69661
私密代理 500 495 99.00% 1.2044522 3.72582
独享代理 500 497 99.40% 0.5435687 2.27832
蘑菇代理 默认版 500 497 99.40% 1.0985725 9.532586
太阳 HTTP 代理 默认版 500 400 80.00% 1.2522483 12.662229
讯代理 优质代理 500 495 99.00% 1.0512681 6.4247565
混播代理 500 494 98.80% 1.0664985 6.451699
独享代理 500 500 100% 0.7056521 0.35416448
云代理 VIP 套餐 500 489 97.80% 3.4216988 38.120296
站大爷代理 普通代理 500 92 18.40% 5.067193 66.12128
短效优质代理 500 488 97.60% 1.5625348 8.121197
西刺代理 免费 500 31 6.2% 9.712833 95.09569

注:

  • 表中的响应时间方差越大,代表稳定性越低。
  • 阿布云代理经典版方差较小是因为它是长时间锁定了同一个 IP,因此极其稳定,但每秒最大请求默认 5 次。
  • 多贝云代理套餐一二方差较小是因为它是长时间锁定了同一个 IP,因此极其稳定,但每秒最大请求默认 20 次。

测评分析

下面我们将从各个方面分析一下各个套餐的优劣。

可用率

通过可用率统计,我们可以发现可用率较高的代理套餐有:

级别 套餐 描述
第一梯队 讯代理独享代理、阿布云代理经典版、快代理私密代理、蘑菇代理、芝麻 HTTP 代理、快代理独享代理、讯代理优质代理 可用率 99% 以上
第二梯队 阿布云代理动态版、讯代理混播代理、云代理、站大爷短效优质代理、全网代理动态版、阿布云代理专业版 可用率 99% 以下,90% 以上
第三梯队 太阳 HTTP 代理、大象代理专业版、大象代理企业版 可用率 90% 以下,50% 以上
第四梯队 大象代理个人版、全网代理普通版、快代理开放代理 可用率 50% 以下,20% 以上
第五梯队 站大爷普通代理、西刺代理 可用率 20% 以下

响应速度

通过平均响应速度判别,我们可以发现响应速度较快的代理套餐有:

级别 套餐 描述
第一梯队 阿布云代理经典版、阿布云代理专业版、快代理私密代理、讯代理独享代理、芝麻 HTTP 代理 响应时间 1s 以内
第二梯队 讯代理优质代理、快代理独享代理、讯代理混播代理、蘑菇代理、太阳代理、站大爷短效优质代理、阿布云代理动态版 响应时间 1s 以上,2s 以内
第三梯队 全网代理动态版、云代理 响应时间 2s 以上,5s 以内
第四梯队 站大爷普通代理、大象代理个人版、全网代理普通版、大象代理企业版、大象代理专业版、西刺代理 响应时间 5s 以上,10s 以内
第五梯队 快代理开放代理 响应时间 10s 以上

稳定性

通过平均响应速度方差分析,我们可以发现稳定性较高的代理套餐有:

级别 套餐 描述
第一梯队 阿布云代理经典版、讯代理独享代理、快代理私密代理、阿布云代理专业版、芝麻 HTTP 代理 方差 3 以内
第二梯队 快代理独享代理、阿布云代理动态版、讯代理优质代理、讯代理混播代理、站大爷短效优质代理、蘑菇代理 方差 10 以内,3 以上
第三梯队 太阳HTTP代理、全网代理动态版、云代理、全网代理普通版、站大爷普通代理、大象代理个人版、西刺代理 方差 100 以内,10 以上
第四梯队 大象代理专业版、大象代理企业版、快代理开放代理 方差 100 以上

价格

我们可以看一下各个套餐的价格:

代理商家 套餐类型 价格描述 价格 URL 备注
芝麻 HTTP 代理 默认版 ¥98/周 ¥360/月 http://h.zhimaruanjian.com/newrecharge/ 另有包量套餐、长效 IP 套餐可选购,定期有优惠活动,可领免费 IP,可免费试用
阿布云代理 专业版 ¥1/时 ¥16/天 ¥108/周 ¥429/月 https://www.abuyun.com/ 每秒请求只有5个,多加每秒请求1个需要 1¥0.5/月,¥90 /年
动态版 ¥1/时 ¥16/天 ¥108/周 ¥429/月
经典版 ¥1/时 ¥16/天 ¥108/周 ¥429/月
大象代理 个人版 ¥9/天 ¥98/月 http://www.daxiangdaili.com/ 好评可送时长
专业版 ¥19/天 ¥198/月
企业版 ¥49/天 ¥498/月
全网代理 普通版 ¥9/天 ¥35/周 ¥93/月 ¥500/年 http://www.goubanjia.com/buy/high.shtml
动态版 ¥10/天 ¥160/月 ¥1250/年 http://www.goubanjia.com/buy/dynamic.shtml
快代理 开放代理 ¥20/天 ¥60/周 ¥200/月 ¥2000/年 https://www.kuaidaili.com/pricing 有普通、VIP、SVIP、专业版可选
独享代理 ¥8/天 ¥32/周 ¥80/月
私密代理 ¥48/天 ¥240/周 ¥720/月
蘑菇代理 默认版 ¥6/天 ¥169/月 ¥1699/年 http://www.mogumiao.com/buy 另有包量套餐可选购,可免费试用
太阳 HTTP 代理 默认版 ¥60/周 ¥198/月 ¥498/季 ¥1590/年 http://http.taiyangruanjian.com/newrecharge/ 另有保量套餐可选购,可领免费 IP,可免费试用
讯代理 优质代理 ¥9/天 ¥210/月 ¥2100/年 http://www.xdaili.cn/buyproxy 可免费试用
混播代理 ¥29/天 ¥729/月 ¥6999/年
独享代理 ¥9/天 ¥210/月 ¥2100/年
云代理 VIP 套餐 ¥10/天 ¥120/月 ¥599/年 http://www.ip3366.net/pricing/ 另有普通套餐可选
站大爷代理 普通代理 ¥8/天 ¥80/月 ¥720/年 http://ip.zdaye.com/buy.html 另有私密代理可选
短效优质代理 ¥17/天 ¥475/月 ¥4569/年 http://ip.zdaye.com/ShortProxy.html

安全性

对于安全性,此处主要考虑提取 API 是否有访问验证,使用代理时是否有访问验证,即可以通过设置白名单来控制哪些可以使用。 其中只有芝麻 HTTP 代理、太阳 HTTP 代理默认使用了白名单限制,即只有将使用 IP 添加到白名单才可以使用,可以有效控制使用权限。 另外阿布云代理提供了隧道代理验证,只有成功配置了用户名和密码才可以正常使用。 所以在此归纳如下:

级别 套餐 描述
第一梯队 快代理、芝麻 HTTP 代理、太阳 HTTP 代理、阿布云代理、多贝云代理 默认使用了白名单控制或隧道代理验证
第二梯队 其他 可直接使用

调取频率

不同的接口具有不同的 API 调用频率限制,归纳如下:

代理商家 套餐类型 调取频率限制
芝麻 HTTP 代理 默认版 1秒
阿布云代理 专业版 无需获取
动态版 无需获取
经典版 无需获取
大象代理 个人版 1秒
专业版 1秒
企业版 无限制
全网代理 普通版 无限制
动态版 100毫秒
快代理 开放代理 200毫秒
独享代理 100毫秒
私密代理 100毫秒
蘑菇代理 默认版 5秒
太阳 HTTP 代理 默认版 1秒
讯代理 优质代理 5秒
混播代理 10秒
独享代理 15秒
云代理 VIP 套餐 无限制
站大爷代理 普通代理 3秒
短效优质代理 10秒
西刺代理 免费 无限制

在此可以简单总结如下:

级别

级别 套餐 描述
第一梯队 云代理、全网代理普通版、大象代理企业版、西刺代理、阿布云(调取无限制,请求默认最大 1 秒 5 请求) 无限制
第二梯队 全网代理动态版、快代理(所有套餐) 小于 1s
第三梯队 大象代理个人版、大象代理专业版、芝麻 HTTP 代理、太阳 HTTP 代理、站大爷普通代理、蘑菇代理、讯代理优质代理 1s – 5s
第四梯队 讯代理混播代理、讯代理独享代理、站大爷短效优质代理 大于 5s

特色功能

除了常规的测试之外,我这边还选取了某些套餐的与众不同之处进行说明,这些特点有的算是缺点,有的算是优点,现列举如下:

代理 描述
阿布云代理 多贝云代理 快代理 使用隧道技术实现,代理不能直接拿到,必须配置访问认证,默认 1 秒只能支持 5/20 个请求,如需更多需要付费。
讯代理 独享代理拨号时间略长,可用主机少,容易出现拨号失败现象,单个代理有效时长可控。
芝麻 HTTP 代理、快代理 必须要设置白名单才可以使用,后台可控,使用 API 提取代理不扣费,使用时才扣费。

测评综合

分项了解了各个代理套餐的可用率、响应速度、稳定性、性价比、安全性等内容之后,最后做一下总结:

代理商家 套餐类型 可用率 可用率评价 响应时间均值 响应速度评价 响应时间方差 稳定性 包月价格 价格评价 安全性 访问频率限制 调取频率限制 推荐指数
芝麻 HTTP 代理 默认版 99% 极高 0.916853 极快 1.331989 极好 360 较高 1 秒 ★★★★★
阿布云代理 专业版 90.4% 0.68770707 极快 1.1477163 极好 429 无需获取 ★★★☆
动态版 98.8% 1.83994 6.0491614 429 无需获取 ★★★★
经典版 99.8% 极高 0.49301904 极快 0.25724468 极好 429 无需获取 ★★★★
大象代理 个人版 47.6% 5.340489 78.56444 一般 98 1 秒 ★★
专业版 56.8% 一般 6.87078 105.7984 198 较低 1 秒 ★☆
企业版 51.8% 一般 6.3081837 121.08402 498 无限制
全网代理 普通版 44% 5.584057 47.442596 一般 93 无限制 ★★
动态版 97% 2.776973 一般 17.568045 一般 160 较低 100毫秒 ★★★
快代理 开放代理 35.6% 一般 16.636587 极慢 221.69661 200 200毫秒
独享代理 99.00% 极高 1.2044522 3.72582 64 100毫秒 ★★★★★
私密代理 99.40% 极高 0.5435687 极快 2.27832 720 100毫秒 ★★★★☆
蘑菇代理 默认版 99.4% 极高 1.0985725 9.532586 169 较低 5秒 ★★★★☆
太阳 HTTP 代理 默认版 80% 一般 1.2522483 12.662229 一般 198 较低 1秒 ★★★★
讯代理 优质代理 99% 极高 1.0512681 6.4247565 210 5秒 ★★★★☆
混播代理 98.8% 1.0664985 6.451699 729 10秒 ★★★☆
独享代理 100% 极高 0.7056521 极快 0.35416448 极好 210 15秒 ★★★★☆
云代理 VIP 套餐 97.8% 3.4216988 一般 38.120296 一般 120 较低 无限制 ★★★☆
站大爷代理 普通代理 18.4% 极低 5.067193 66.12128 一般 80 3秒 ★☆
短效优质代理 97.6% 1.5625348 8.121197 475 10秒 ★★★☆
西刺代理 免费 6.2% 极低 9.712833 95.09569 一般 0 免费 无限制

所以在综合来看比较推荐的有:芝麻代理、快代理、讯代理、阿布云、多贝云代理,详细的对比结果可以参照表格。 以上便是各家代理的详细对比测评情况,希望此文能够在大家选购代理的时候有所帮助。

Python

大家好,我是四毛,下面的是我的公众号,欢迎关注。

今天的内容主要讲的是破解一个网站的 rsa 加密,当然肯定不是破解这个算法,而是找到加密的参数,正确模拟这个算法即可。

1. 什么是 rsa 算法

下面的资料摘抄自阮一峰老师的文章, 点这里了解更多 1976 年,两位美国计算机学家 Whitfield Diffie 和 Martin Hellman,提出了一种崭新构思,可以在不直接传递密钥的情况下,完成解密。这被称为“Diffie-Hellman 密钥交换算法”。这个算法启发了其他科学家。人们认识到,加密和解密可以使用不同的规则,只要这两种规则之间存在某种对应关系即可,这样就避免了直接传递密钥。 这种新的加密模式被称为”非对称加密算法”。

(1)乙方生成两把密钥(公钥和私钥)。公钥是公开的,任何人都可以获得,私钥则是保密的。 (2)甲方获取乙方的公钥,然后用它对信息加密。 (3)乙方得到加密后的信息,用私钥解密。

如果公钥加密的信息只有私钥解得开,那么只要私钥不泄漏,通信就是安全的。

2. 研究目标

从我要研究的网站来说,就是根据参数得到正确的公钥,加密以后返回给服务器,让服务器使用私钥可以解密出正确的数据即可。 同时,本文不会将具体的网站说出来,只是给大家提供一个解决问题的思路。

3. 开始

3.1 抓包找参数

首先,打开某个网站的登录页面,输入用户名,密码,验证码之类的参数, 抓包看到了下面这个页面: 我实际输入的值全是 1, 然后都被加密了, 没办法,只能去找加密的方法了。 经过一番搜索过后,才发现,原来加密的算法就在源代码里面,这里截个图: 从这里就可以看到具体的算法名以及相关的参数了,你会说,这是什么算法我都不知道啊?搜啊,用关键词搜一下就能知道了。 同时,是不是觉得这个网站好傻逼,这不太简单了吗? 肯定不是!!! 这么简单,说明此处也是必有玄机!!! 至于什么玄机,到后面说,都是泪。

3.2 分析加密流程

首先, 我们知道了公钥以后,解析这个公钥,就可以得到相关的参数,给大家找了示例代码

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
# /usr/bin/python
# encoding: utf-8

import base64

def str2key(s):
# 对字符串解码
b_str = base64.b64decode(s)

if len(b_str) < 162:
return False

hex_str = ''

# 按位转换成16进制
for x in b_str:
h = hex(ord(x))[2:]
h = h.rjust(2, '0')
hex_str += h

# 找到模数和指数的开头结束位置
m_start = 29 * 2
e_start = 159 * 2
m_len = 128 * 2
e_len = 3 * 2

modulus = hex_str[m_start:m_start + m_len]
exponent = hex_str[e_start:e_start + e_len]

return modulus,exponent

if __name__ == "__main__":

pubkey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDC7kw8r6tq43pwApYvkJ5laljaN9BZb21TAIfT/vexbobzH7Q8SUdP5uDPXEBKzOjx2L28y7Xs1d9v3tdPfKI2LR7PAzWBmDMn8riHrDDNpUpJnlAGUqJG9ooPn8j7YNpcxCa1iybOlc2kEhmJn5uwoanQq+CA6agNkqly2H4j6wIDAQAB"
key = str2key(pubkey)
print key

相应的输出

1
2
('c2ee4c3cafab6ae37a7002962f909e656a58da37d0596f6d530087d3fef7b16e86f31fb43c49474fe6e0cf5c404acce8f1d8bdbccbb5ecd5df6fded74f7ca2362d1ecf033581983327f2b887ac30cda54a499e500652a246f68a0f9fc8fb60da5cc426b58b26ce95cda41219899f9bb0a1a9d0abe080e9a80d92a972d87e23eb',
'010001')

从代码中可以看出,解析了公钥之后得到了两个值,一个就是 010001,和我们在网站源代码里面找到的值是一样的。所以,源代码里面的参数我们应该就是可以直接使用的,是不是有种找到组织的赶脚。 接下来,利用下面的代码,来对数据进行加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import rsa
import binascii
def en_test():
param_1 = "010001"
# 某次我找到的
param_2 = "955120AB9334B7CD52FCDB422DBF564AFD46DEBDC706F33502BBFAD9DD216A22E4D5012CB70F28473B46FB7190D08C31B4B8E76B5112ACE1C5552408961530B1C932DEEA8FC38A9A624AD22073F56F02BF453DD2C1FEA0164106D6B099CC9E5EC88C356FC164FCA47C766DD565D3D11048D27F2DD4221A0B26AB59BD7D09841F"
message = 'nihao'
modulus = int(param_2, 16)
exponent = int(param_1, 16)
rsa_pubkey = rsa.PublicKey(modulus, exponent)
crypto = rsa.encrypt(message, rsa_pubkey)
data = binascii.b2a_hex(crypto)

print data

if __name__ == '__main__':
en_test()

但是当我这样做完,进行模拟登录,还以为自己很牛逼的时候,服务器却给我返回了这样的结果, 目瞪狗呆啊:

1
{"Status":false,"ResultValue":"","StatusCode":"REFRESH","StatusMessage":"请尝试重新登录","RecordCount":0,"Data":null}

可以看到信息提示要刷新,但是当时是百思不得其解,为毛线要刷新? 困惑了一会之后,我再次从头走了一遍流程,这下我才发现,原来源代码里面的那个长长的数据是会改变的,直到这个时候,我才意识到为什么要我刷新。。。。。。 服务器啊,你就不能直接说参数错误吗?刷新你大爷啊。 果然,我还是太年轻啊。

果然,天上掉下的绝不是馅饼,绝逼是个陷阱。

知道这个坑以后就好办了,用个正则匹配一下就行了,而结果也是对的:

1
{"Status":true,"ResultValue":"","StatusCode":"OK","StatusMessage":"成功","RecordCount":0,"Data":{"LoginUrl":"/System/Welcome"}}

4 总结

到这里这篇文章就结束了,这个案例相对于来说很简单,而且为了保护网站的隐私,所以没办法展开说。 有些网站的加密方式是很变态的,比如网易云音乐,知晓常见的加密方法,就可以处理大部分的情况了。 其实,网易云音乐并不是一定要加密, 有想知道非加密的方法的,可以关注我,私聊我。有点敏感,就不写文章了。 反正,我爬了 1000W+的网易云音乐都是不加密的~~ 如果你有类似的问题待解决或者想了解的更清楚的细节的,欢迎关注我的公众号以后,后台私我一下。

Linux

最近和几个朋友开发项目,期间使用了一台服务器跑模型,这台服务器是多人公用的,很多人都在上面有自己的账号,互不干涉内政,一切看起来十分井然有序。近期,这个服务器上刚挂载了一块新硬盘,是一位朋友使用 root 账号挂载的,然后将磁盘映射到某个文件夹下。然而挂载好了之后发现使用普通账号没有权限在文件夹下操作,无法创建文件,于是他干脆就直接把文件夹权限改成 777 了。我心想,这还了得,改成 777 了,其他人在里面乱改咋办?会出人命的!所以,我就这件事详细梳理了一下 Linux 下的用户、用户组、文件权限等基本知识,看完这些,以后不要动不动就把文件夹改成 777 权限了。

基本操作

首选我们梳理一下 Linux 下的用户、用户组、文件权限等基本知识,然后后面通过一个案例来实际演示一下权限设置的一些操作。 首先 Linux 系统中,是有用户和用户组的概念的,用户就是身份的象征,我们必须以某一个用户身份来操作一个系统,实际上这就对应着我们登录系统时的账号。而用户组就是一些用户的集合,我们可以通过用户组来划分和统一管理某些用户。 比如我要在微信发一条朋友圈,我只想给我的亲人们看,难道我发的时候还要一个个去勾选所有的人?这未免太麻烦了。为了解决这问题,微信里面就有了标签的概念,我们可以提前给好友以标签的方式分类,发的时候直接勾选某个标签就好了,简单高效。实际上这就是用户组的概念,我们可以将某些人进行分组和归类,到时候只需要指定类别或组别就可以了,而不用一个个人去对号入座,从而节省了大量时间。 在 Linux 中,一个用户是可以属于多个组的,一个组也是可以包含多个用户的,下面我以一台 Ubuntu Linux 为例来演示一下相关的命令和操作。

用户和用户组

首先查看所有用户,命令如下:

1
cut -d':' -f 1 /etc/passwd

结果:

1
2
3
4
5
6
7
root
daemon
bin
sys
...
ubuntu
mysql

这里一行就是一个用户名,由于太多,部分就省略了,实际上这个命令就是从密码文件中把用户名单独列出来了。 然后查看所有用户组,命令也是类似的:

1
cut -d':' -f 1 /etc/group

结果:

1
2
3
4
5
6
7
root
daemon
bin
sys
...
ubuntu
mysql

结果基本是类似的,因为每个用户在被创建的时候都会自动创建一个同名的组作为其默认的用户组。 这里我是使用 ubuntu 这个账号来登录的,下面我来看下 ubuntu 这个账号是属于哪些组。 查看一个用户所属组的命令格式如下:

1
gorups <username>

这里就是 groups 命令加上用户名就能查看该用户名所属的组了,如果不加用户名的话就默认是当前用户。 例如查看 ubuntu 这个用户所属于的组,命令如下:

1
groups ubuntu

结果:

1
ubuntu : ubuntu adm cdrom sudo dip plugdev lxd lpadmin sambashare

还不少,这个用户被分配到了很多组下,比如同名的组 ubuntu,还有 sudo 组,另外还有一些其他的组。 其中 sudo 组比较特殊,如果被分到了这个组里面就代表该账号拥有 root 权限,可以使用 sudo 命令。 了解了怎样查看用户所属的组,我们也应该反过来了解如何查看一个用户组里面包含哪些用户啊。 查看某个用户组下所有用户命令如下:

1
members <group>

不过这个命令不是自带的,需要额外安装 members 包,命令如下:

1
sudo apt-get install members

例如查看 sudo 用户组下的所有用户,即拥有 root 权限的用户:

1
members sudo

结果:

1
ubuntu hadoop

可以看到拥有 root 权限的用户有两个,ubuntu 和 hadoop,当然不同的机器结果肯定是不一样的。 接下来介绍一个比较有用的命令,就是 id 命令,它可以用来查看用户的所属组别,格式如下:

1
id <username>

例如查看 ubuntu 用户的信息,就是这样:

1
id ubuntu

结果:

1
uid=500(ubuntu) gid=500(ubuntu) groups=500(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),110(lxd),115(lpadmin),116(sambashare)

这里有一个 gid,作为主工作组,后面还有个 groups,它列出了用户所在的所有组。主工作组只有一个,而后者的数量则不限。可以看到用户组的结果和使用 groups 命令看到的结果是一致的。 接下来我们再来了解一下如何创建一个用户和怎样为用户分配组别。 添加一个用户命令格式如下:

1
sudo adduser <username>

比如我要添加一个用户 cqc,命令就可以这么写:

1
sudo adduser cqc

这里使用的命令前面都带有 sudo,因为毕竟是系统级别的操作。 添加一个组的命令格式如下:

1
sudo groupadd <group>

格式是类似的,后面跟一个组的名称就可以了,例如我要为我的实验室创建一个用户组,那么就可以使用如下命令:

1
sudo groupadd lab

创建完了用户和组,那得把它们关联起来吧,关联的意思就是把某个用户加入到某个组里面,命令格式如下:

1
sudo adduser <username> <group>

或者使用 usermod 命令:

1
sudo usermod -G <group> <username>

如果要添加多个组的话,可以通过 -a 选项指定多个名称:

1
sudo usermod -aG <group1,group2,group3..> <username>

例如我要将 cqc 用户添加到 sudo 用户组中,命令就是:

1
sudo adduser cqc sudo

或:

1
sudo usermod -G sudo cqc

这样就为用户和用户组做好关联了。

文件权限管理

了解了这些之后,我们再来了解一下文件权限的相关知识,下面我们先随便找一个目录,查看一下文件的列表。 列出某个目录下文件详细信息的命令如下:

1
ll

或者使用:

1
ls -l

比如我这里列出了 /etc/nginx 目录下的文件列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
total 80
drwxr-xr-x   7 root root  4096 Jun 21 22:16 ./
drwxr-xr-x 103 root root  4096 Sep  4 18:04 ../
drwxr-xr-x   2 root root  4096 Jul 12  2017 conf.d/
-rw-r--r--   1 root root  1077 Feb 12  2017 fastcgi.conf
-rw-r--r--   1 root root  1007 Feb 12  2017 fastcgi_params
-rw-r--r--   1 root root  2837 Feb 12  2017 koi-utf
-rw-r--r--   1 root root  2223 Feb 12  2017 koi-win
-rw-r--r--   1 root root  3957 Feb 12  2017 mime.types
-rw-r--r--   1 root root  1505 Jun 21 20:24 nginx.conf
-rw-r--r--   1 root root 12288 Jun 21 20:44 .nginx.conf.swp
-rw-r--r--   1 root root   180 Feb 12  2017 proxy_params
-rw-r--r--   1 root root   636 Feb 12  2017 scgi_params
drwxr-xr-x   2 root root  4096 Jun 21 22:42 sites-available/
drwxr-xr-x   2 root root  4096 Jun 21 19:08 sites-enabled/
drwxr-xr-x   2 root root  4096 Jun 21 19:08 snippets/
-rw-r--r--   1 root root   664 Feb 12  2017 uwsgi_params
drwxr-xr-x   2 root root  4096 Jun 22 02:44 vhosts/
-rw-r--r--   1 root root  3071 Feb 12  2017 win-utf

我们注意到了每一行都是一个文件或文件夹的信息,一共包括七列:

  • 第一列是文件的权限信息
  • 第二列表示该文件夹连接的文件数
  • 第三列表示文件所属用户
  • 第四列表示文件所属用户组
  • 第五列表示文件大小(字节)
  • 第六列表示最后修改日期
  • 第七列表示文件名

其中第一列的文件权限信息是非常重要的,它由十个字符组成:

  • 第一个字符代表文件的类型,有三种,- 代表这是一个文件,d 代表这是一个文件夹,l 代表这是一个链接。
  • 第 2-4 个字符代表文件所有者对该文件的权限,r 就是读,w 就是写,x 就是执行,如果是文件夹的话,执行就意味着查看文件夹下的内容,例如 rw- 就代表文件所有者可以对该文件进行读取和写入。
  • 第 5-7 个字符代表文件所属组对该文件的权限,含义是一样的,如 r-x 就代表该文件所属组内的所有用户对该文件有读取和执行的权限。
  • 第 8-10 个字符代表是其他用户对该文件的权限,含义也是一样的,如 r— 就代表非所有者,非用户组的用户只拥有对该文件的读取权限。

我们可以使用 chmod 命令来改变文件或目录的权限,有这么几种用法。 一种是数字权限命名,rwx 对应一个二进制数字,如 101 就代表拥有读取和执行的权限,而转为十进制的话,r 就代表 4,w 就代表 2,x 就代表 1,然后三个数字加起来就和二进制数字对应起来了。如 7=4+2+1,这就对应着 rwx;5=4+1,这就对应着 r-x。所以,相应地 777 就代表了 rwxrwxrwx,即所有者、所属用户组、其他用户对该文件都拥有读取、写入、执行的权限,这是相当危险的! 赋予权限的命令如下:

1
sudo chmod <permission> <file>

例如我要为一个 file.txt 赋予 777 权限,就写成:

1
sudo chmod 777 file.txt

另外我们也可以使用代号来赋予权限,代号有 u、g、o、a 四中,分别代表所有者权限,用户组权限,其他用户权限和所有用户权限,这些代号后面通过 + 和 - 符号来控制权限的添加和移除,再后面跟上权限类型就好,例如:

1
sudo chmod u-x file.txt

就是给所有者移除 x 权限,也就是执行权限。

1
sudo chmod g+w file.txt

就是为用户组添加 w 权限,即写入权限。 另外如果是文件夹的话还可以对文件夹进行递归赋权限操作,如:

1
sudo chmod -R 777 share

就是将 share 文件夹和其内所有内容都赋予 777 权限。 好,有了权限的标识,那我们还得把用户和用户组与文件关联起来啊,这里使用的命令就是 chown 和 chgrp 命令。 命令格式如下:

1
2
sudo chown <username> <file>
sudo chgrp <group> <file>

例如我要将 file.txt 的所有者换成 cqc,那就可以使用如下命令:

1
sudo chown cqc file.txt

如果我要将 file.txt 所属用户组换成 lab,那就可以使用如下命令:

1
sudo chgrp lab file.txt

另外同样可以使用 -R 来进行递归操作,如将 share 文件夹及其内所有内容的所有者都换成 cqc,命令如下:

1
sudo chown -R cqc share/

好,了解了 chown、chgrp、chmod 之后,我们就可以灵活地对文件权限进行控制了。

实战演示

可能上面说起来有点抽象,下面我们以一个实例来演示一下权限控制的流程,通过这个流程,相信理解以上的命令都不在话下了。 首先情况是这样的,我要在某台主机上共享一些文件给我实验室的人,但这台主机上还有其他非实验室的人在使用,我只想让实验室的人查看和修改这些文件,其他人不行。 另外我自己的账号要有最高权限来管理这些文件的共享权限,即要有 root 权限。 现在我已经登录了一个 ubuntu 的账号,是系统初始化的,拥有 root 权限。 下面我就模拟创建三个账号和一个用户组,来得到如下效果:

  • 账号 cqc 是我自己使用的账号,拥有最高权限,可以自由调整文件权限信息,可以自由为某个用户分配用户组。
  • 账号 lbd 是我实验室的人员,没有 root 权限,但能查看和修改我共享的文件。
  • 账号 slb 不是我实验室的人员,没有 root 权限,也不能修改我共享的文件。

创建自己的账户

首先我先为自己创建一个账户,添加一个 cqc 的用户:

1
sudo adduser cqc

运行之后会提示输入密码和其他信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Adding user `cqc' ...
Adding new group `cqc' (1002) ...
Adding new user `cqc' (1002) with group `cqc' ...
Creating home directory `/home/cqc' ...
Copying files from `/etc/skel' ...
Enter new UNIX password: 
Retype new UNIX password: 
passwd: password updated successfully
Changing the user information for cqc
Enter the new value, or press ENTER for the default
        Full Name []: 
        Room Number []: 
        Work Phone []: 
        Home Phone []: 
        Other []: 
Is the information correct? [Y/n]

这时候发现一个同名的组就被创建了,查看下 cqc 所在的组:

1
groups cqc

结果如下:

1
cqc : cqc

再用 id 命令查看下信息:

1
id cqc

结果如下:

1
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc)

可以看到当前 cqc 只属于 cqc 用户组。 接下来我们创建一个用户组,叫做 lab,来标明我的实验室,命令如下:

1
sudo groupadd lab

然后查看下用户组里面的成员:

1
members lab

没有任何结果,说明我们创建了一个空的组,没有任何成员。 然后我们将刚才创建的 cqc 加入到该组中,因为我自己也属于该实验室,肯定也要加进来,命令如下:

1
sudo adduser cqc lab

结果:

1
2
3
Adding user `cqc' to group `lab' ...
Adding user cqc to group lab
Done.

然后查看下组内成员:

1
members lab

结果:

1
cqc

这样 lab 组内就有了 cqc 这个用户了。 别忘了 cqc 还需要拥有 root 权限,所以我们还需要将 cqc 添加到 sudo 组内,命令如下:

1
sudo adduser cqc sudo

结果:

1
2
3
Adding user `cqc' to group `sudo' ...
Adding user cqc to group sudo
Done.

这样就成功加入到 sudo 组了,cqc 也就是我的账户就可以使用 sudo 命令了。 查看下用户状态:

1
id cqc

结果如下:

1
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc),27(sudo),1003(lab)

这样 cqc 就属于三个用户组了,既是实验室成员,又拥有 root 权限。 上面的分配用户组的命令我们也可以使用 usermod 来实现:

1
sudo usermod -aG sudo,lab cqc

这样就添加到多个组了。

添加实验室用户

接下来,再添加实验室的另外一个人员 lbd,然后将其添加到 lab 组中,流程是类似的,命令如下:

1
2
sudo adduser lbd
sudo adduser lbd lab

运行完毕之后,id 命令查看其信息:

1
id lbd

结果如下:

1
uid=1004(lbd) gid=1005(lbd) groups=1005(lbd),1003(lab)

这样就成功创建 lbd,并将其添加到实验室 lab 组了。

添加非实验室用户

最后另外添加一个用户 slb,非实验室成员,只创建账户就好了,命令如下:

1
sudo adduser slb

但是我们不把他加入 lab 组中。 查看他的状态:

1
id slb

结果如下:

1
uid=1003(slb) gid=1004(slb) groups=1004(slb)

所以三人的状态是这样的:

1
2
3
4
5
6
id cqc
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc),27(sudo),1003(lab)
id lbd
uid=1004(lbd) gid=1005(lbd) groups=1005(lbd),1003(lab)
id slb
uid=1003(slb) gid=1004(slb) groups=1004(slb)

文件权限分配

接下来我们创建一个文件夹来共享实验室数据,放在 /srv 目录下。然后调用 mkdir 命令创建名称为 share 的文件夹,命令如下:

1
2
cd /srv
sudo mkdir share

注意这里我还是使用 ubuntu 账户来创建的。 先看下当前目录权限:

1
ls -l

结果如下:

1
2
3
4
total 12
drwxr-xr-x  3 root root 4096 Sep  4 18:17 ./
drwxr-xr-x 24 root root 4096 Sep  4 18:17 ../
drwxr-xr-x  2 root root 4096 Sep  4 18:17 share/

可以看到 share 文件的所有者是 root,用户组也是 root,权限是 755,即只有 root 拥有修改权限,其他的只有读取和执行权限。 然后进入 share 文件夹创建一个 names.txt:

1
2
cd share
sudo vi names.txt

编辑内容如下:

1
2
cqc
lbd

保存完毕之后,这时查看一下文件权限,如下:

1
-rw-r----- 1 root root    8 Sep  4 20:00 names.txt

权限是 640,这表明只有所有者 root 拥有写入的权限,所在组只有读的权限。 这时开启另外一个终端,登录 cqc 账号,实际上是不能查看和修改任何该文件的内容的,下面的修改和读取命令都会提示权限不够:

1
2
vi names.txt
cat names.txt

为什么呢?因为该文件是刚才由 ubuntu 账号使用 sudo 命令创建的,因此文件的所有者是 root,并不是 cqc,因此即使文件的权限是 640,那也就不能使用文件所有者的权限,而且 cqc 也不属于 root 组,所以也不能使用文件组的权限了,因此什么都看不了,什么都改不了。 但 cqc 属于 sudo 组啊,可以利用 sudo 命令临时获取 root 权限,临时以 root 的身份来操作该文件,这样就可以来查看和修改文件了,因此下面的命令是有效的:

1
2
sudo vi names.txt
sudo cat names.txt

但这样还是需要使用 sudo 才能修改,很不方便。 这时如果我们把文件的所有者改成 cqc,情况那就不一样了。 使用 ubuntu 账号,对 names.txt 更改其所有者为 cqc,改的命令如下:

1
sudo chown cqc names.txt

这时查看下文件信息:

1
-rw-r----- 1 cqc  root    8 Sep  4 20:29 names.txt

可以看到所有者信息已经变成了 cqc,这样 cqc 账号再直接查看和修改,那就可以了,不再需要 sudo 命令:

1
2
vi names.txt
cat names.txt

这样就不会有权限提示,当然加上 sudo 更是没问题。 好,接下来 lbd 呢?我们登录试试修改。 首先当前的文件状态是这样的:

1
-rw-r----- 1 cqc  root    8 Sep  4 20:31 names.txt

lbd 不是所有者了,因此前面的 rw- 权限是没什么用的,但他属于 lab 组,而该文件对于用户组的权限是 r—,也就是读取权限。 我们使用 lbd 账号来尝试看下文件的内容:

1
2
cat names.txt 
cat: names.txt: Permission denied

很遗憾,又没有权限。因为什么?因为这个文件的用户组并不是 lab 啊,而 lbd 这个用户又不属于 root 组,所以没有任何权限。 那咋办?将文件的用户组改成 lab 就好了,使用 ubuntu 账号或 cqc 账号来操作:

1
sudo chgrp lab names.txt

这样就成功将文件所属用户组改成 lab 了,接下来再使用 lbd 账号查看下文件内容:

1
cat names.txt

就成功读取了。 然而 lbd 现在是没有写入权限的,因为对于用户组来说,该文件的权限是 r—,如果要获取写入权限,我们可以使用如下命令:

1
sudo chmod g+w names.txt

或:

1
sudo chmod 660 names.txt

这样就相当于赋予了 rw- 权限,下面我们再使用 lbd 账号尝试修改这个文件:

1
vi names.txt

就没问题了。 那么对于非实验室同学 slb 呢?它没有任何权限,我们登录 slb 账号尝试修改和读取该文件:

1
2
cat names.txt
vi names.txt

均无权限。 所以说,这样我们就成功为实验室的人员赋予了权限,而非实验室的人则没有任何权限。 如果我要为 slb 赋予读取权限咋办呢?很简单,添加一下就好了:

1
sudo chmod o+r names.txt

这就是为其他用户添加了读取权限。这时 slb 就可以读取文件,但不能修改文件,也是比较安全的。 好,如果我的文件非常多呢?比如十几二十个,都放在 share 文件夹内,那不能一个个进行权限设置吧? 这时候我们只需要针对文件夹进行操作即可,下面的命令就可以为 share 文件夹赋予 775 权限,即所有者 cqc 和所在组 lab 可对其进行查看和修改,其他的人只能看不能改:

1
2
3
sudo chmod -R 775 share/
sudo chown -R cqc share/
sudo chgrp -R lab share/

注意文件夹一般都会赋予 x 权限,不然连进入文件夹的权限都没有。这也就是文件夹一般会赋予 775、755,而文件会赋予 664、600、644、640 的原因了。 赋予 775 权限之后,share 的权限就变成了:

1
drwxrwxr-x  2 cqc  lab  4096 Sep  4 20:31 share/

这样其他用户就只能看,不能改,这样普通文件就没什么问题了。 如文件夹内包含了可执行文件,还可以单独为其他用户针对可执行文件去除 x 权限,如去除 Python 文件的可执行权限:

1
sudo chmod o-x *.py

好了,到现在为止,我们就得心应手地完成了权限控制了! 相信如果你耐心看完的话,什么用户管理、权限管理,都不在话下!

Linux

本文介绍一下如何给 Azure 的云服务器增加一块磁盘。

页面操作

首先切换到磁盘页面,然后点击添加数据磁盘按钮: 然后选定存储容器,这里使用的是存储账户 Blob,然后点击确定按钮: 主机缓存切换为“读/写”,然后点击保存: 这样就添加好了。

挂载磁盘

接下来回到 Linux 服务器下,我们需要将磁盘进行挂载。 首先 SSH 连接到服务器,然后使用 dmesg 命令来查找磁盘:

1
dmesg | grep SCSI

输出类似如下:

1
2
3
4
5
6
[    0.728389] SCSI subsystem initialized
[ 2.139341] Block layer SCSI generic (bsg) driver version 0.4 loaded (major 244)
[ 2.978928] sd 1:0:1:0: [sdb] Attached SCSI disk
[ 3.341183] sd 0:0:0:0: [sda] Attached SCSI disk
[ 18.397942] Loading iSCSI transport class v2.0-870.
[ 6641.364794] sd 3:0:0:0: [sdc] Attached SCSI disk

这里 sdc 就是我们新添加的一块硬盘。 然后我们使用 fdisk 对其进行分区,将其设置为分区 1 中的主磁盘,并接受其他的默认值,命令如下:

1
sudo fdisk /dev/sdc

使用 n 命令添加新分区,然后 p 选择主分区,其他的默认:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Welcome to fdisk (util-linux 2.27.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.


Device does not contain a recognized partition table.
Created a new DOS disklabel with disk identifier 0xc305fe54.

Command (m for help): n
Partition type
p primary (0 primary, 0 extended, 4 free)
e extended (container for logical partitions)
Select (default p): p
Partition number (1-4, default 1):
First sector (2048-2145386495, default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-2145386495, default 2145386495):

Created a new partition 1 of type 'Linux' and of size 1023 GiB.

然后使用 p 打印分区表并使用 w 将表写入磁盘,然后退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Command (m for help): p
Disk /dev/sdc: 1023 GiB, 1098437885952 bytes, 2145386496 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xc305fe54

Device Boot Start End Sectors Size Id Type
/dev/sdc1 2048 2145386495 2145384448 1023G 83 Linux

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.

接下来使用 mkfs 命令将文件系统写入分区,指定文件系统的类型和设备名称:

1
sudo mkfs -t ext4 /dev/sdc1

输出类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
mke2fs 1.42.13 (17-May-2015)
Creating filesystem with 268173056 4k blocks and 67043328 inodes
Filesystem UUID: d744c5d7-f4d1-4f81-9f56-59dfab956782
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
4096000, 7962624, 11239424, 20480000, 23887872, 71663616, 78675968,
102400000, 214990848

Allocating group tables: done
Writing inode tables: done
Creating journal (32768 blocks): done
Writing superblocks and filesystem accounting information: done

然后使用 mkdir 创建一个目录来装载该文件系统,然后挂载:

1
2
sudo mkdir /datadrive
sudo mount /dev/sdc1 /datadrive

这样就挂载成功了。

添加引导信息

若要确保在重新引导后自动重新装载驱动器,必须将其添加到 /etc/fstab 文件。 此外,强烈建议在 /etc/fstab 中使用 UUID(全局唯一标识符)来引用驱动器而不是只使用设备名称(例如 /dev/sdc1)。 如果 OS 在启动过程中检测到磁盘错误,使用 UUID 可以避免将错误的磁盘装载到给定位置。 然后,为剩余的数据磁盘分配这些设备 ID。 若要查找新驱动器的 UUID,请使用 blkid 实用工具:

1
sudo -i blkid

输入类似如下:

1
2
3
/dev/sdb1: UUID="d5b61f40-4129-4b39-b861-c2d3b09cee69" TYPE="ext4" PARTUUID="4927b944-01"
/dev/sda1: LABEL="cloudimg-rootfs" UUID="b2e62f4f-d338-470e-9ae7-4fc0e014858c" TYPE="ext4" PARTUUID="577c3e7c-01"
/dev/sdc1: UUID="d744c5d7-f4d1-4f81-9f56-59dfab956782" TYPE="ext4" PARTUUID="c305fe54-01"

然后编辑 /etc/fstab,添加下面一行:

1
UUID=d744c5d7-f4d1-4f81-9f56-59dfab956782       /datadrive      ext4    defaults,nofail 1      2

然后保存退出即可。 这样就成功添加了一块外部磁盘。

Other

为何要搭建 Elasticsearch 集群

凡事都要讲究个为什么。在搭建集群之前,我们首先先问一句,为什么我们需要搭建集群?它有什么优势呢?

高可用性

Elasticsearch 作为一个搜索引擎,我们对它的基本要求就是存储海量数据并且可以在非常短的时间内查询到我们想要的信息。所以第一步我们需要保证的就是 Elasticsearch 的高可用性,什么是高可用性呢?它通常是指,通过设计减少系统不能提供服务的时间。假设系统一直能够提供服务,我们说系统的可用性是 100%。如果系统在某个时刻宕掉了,比如某个网站在某个时间挂掉了,那么就可以它临时是不可用的。所以,为了保证 Elasticsearch 的高可用性,我们就应该尽量减少 Elasticsearch 的不可用时间。 那么怎样提高 Elasticsearch 的高可用性呢?这时集群的作用就体现出来了。假如 Elasticsearch 只放在一台服务器上,即单机运行,假如这台主机突然断网了或者被攻击了,那么整个 Elasticsearch 的服务就不可用了。但如果改成 Elasticsearch 集群的话,有一台主机宕机了,还有其他的主机可以支撑,这样就仍然可以保证服务是可用的。 那可能有的小伙伴就会说了,那假如一台主机宕机了,那么不就无法访问这台主机的数据了吗?那假如我要访问的数据正好存在这台主机上,那不就获取不到了吗?难道其他的主机里面也存了一份一模一样的数据?那这岂不是很浪费吗? 为了解答这个问题,这里就引出了 Elasticsearch 的信息存储机制了。首先解答上面的问题,一台主机宕机了,这台主机里面存的数据依然是可以被访问到的,因为在其他的主机上也有备份,但备份的时候也不是整台主机备份,是分片备份的,那这里就又引出了一个概念——分片。 分片,英文叫做 Shard,顾名思义,分片就是对数据切分成了多个部分。我们知道 Elasticsearch 中一个索引(Index)相当于是一个数据库,如存某网站的用户信息,我们就建一个名为 user 的索引。但索引存储的时候并不是整个存一起的,它是被分片存储的,Elasticsearch 默认会把一个索引分成五个分片,当然这个数字是可以自定义的。分片是数据的容器,数据保存在分片内,分片又被分配到集群内的各个节点里。当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里,所以相当于一份数据被分成了多份并保存在不同的主机上。 那这还是没解决问题啊,如果一台主机挂掉了,那么这个分片里面的数据不就无法访问了?别的主机都是存储的其他的分片。其实是可以访问的,因为其他主机存储了这个分片的备份,叫做副本,这里就引出了另外一个概念——副本。 副本,英文叫做 Replica,同样顾名思义,副本就是对原分片的复制,和原分片的内容是一样的,Elasticsearch 默认会生成一份副本,所以相当于是五个原分片和五个分片副本,相当于一份数据存了两份,并分了十个分片,当然副本的数量也是可以自定义的。这时我们只需要将某个分片的副本存在另外一台主机上,这样当某台主机宕机了,我们依然还可以从另外一台主机的副本中找到对应的数据。所以从外部来看,数据结果是没有任何区别的。 一般来说,Elasticsearch 会尽量把一个索引的不同分片存储在不同的主机上,分片的副本也尽可能存在不同的主机上,这样可以提高容错率,从而提高高可用性。 但这时假如你只有一台主机,那不就没办法了吗?分片和副本其实是没意义的,一台主机挂掉了,就全挂掉了。

健康状态

针对一个索引,Elasticsearch 中其实有专门的衡量索引健康状况的标志,分为三个等级:

  • green,绿色。这代表所有的主分片和副本分片都已分配。你的集群是 100% 可用的。
  • yellow,黄色。所有的主分片已经分片了,但至少还有一个副本是缺失的。不会有数据丢失,所以搜索结果依然是完整的。不过,你的高可用性在某种程度上被弱化。如果更多的分片消失,你就会丢数据了。所以可把 yellow 想象成一个需要及时调查的警告。
  • red,红色。至少一个主分片以及它的全部副本都在缺失中。这意味着你在缺少数据:搜索只能返回部分数据,而分配到这个分片上的写入请求会返回一个异常。

如果你只有一台主机的话,其实索引的健康状况也是 yellow,因为一台主机,集群没有其他的主机可以防止副本,所以说,这就是一个不健康的状态,因此集群也是十分有必要的。

存储空间

另外,既然是群集,那么存储空间肯定也是联合起来的,假如一台主机的存储空间是固定的,那么集群它相对于单个主机也有更多的存储空间,可存储的数据量也更大。 所以综上所述,我们需要一个集群!

详细了解 Elasticsearch 集群

接下来我们再来了解下集群的结构是怎样的。 首先我们应该清楚多台主机构成了一个集群,每台主机称作一个节点(Node)。 如图就是一个三节点的集群:

在图中,每个 Node 都有三个分片,其中 P 开头的代表 Primary 分片,即主分片,R 开头的代表 Replica 分片,即副本分片。所以图中主分片 1、2,副本分片 0 储存在 1 号节点,副本分片 0、1、2 储存在 2 号节点,主分片 0 和副本分片 1、2 储存在 3 号节点,一共是 3 个主分片和 6 个副本分片。同时我们还注意到 1 号节点还有个 MASTER 的标识,这代表它是一个主节点,它相比其他的节点更加特殊,它有权限控制整个集群,比如资源的分配、节点的修改等等。 这里就引出了一个概念就是节点的类型,我们可以将节点分为这么四个类型:

  • 主节点:即 Master 节点。主节点的主要职责是和集群操作相关的内容,如创建或删除索引,跟踪哪些节点是群集的一部分,并决定哪些分片分配给相关的节点。稳定的主节点对集群的健康是非常重要的。默认情况下任何一个集群中的节点都有可能被选为主节点。索引数据和搜索查询等操作会占用大量的 cpu,内存,io 资源,为了确保一个集群的稳定,分离主节点和数据节点是一个比较好的选择。虽然主节点也可以协调节点,路由搜索和从客户端新增数据到数据节点,但最好不要使用这些专用的主节点。一个重要的原则是,尽可能做尽量少的工作。
  • 数据节点:即 Data 节点。数据节点主要是存储索引数据的节点,主要对文档进行增删改查操作,聚合操作等。数据节点对 CPU、内存、IO 要求较高,在优化的时候需要监控数据节点的状态,当资源不够的时候,需要在集群中添加新的节点。
  • 负载均衡节点:也称作 Client 节点,也称作客户端节点。当一个节点既不配置为主节点,也不配置为数据节点时,该节点只能处理路由请求,处理搜索,分发索引操作等,从本质上来说该客户节点表现为智能负载平衡器。独立的客户端节点在一个比较大的集群中是非常有用的,他协调主节点和数据节点,客户端节点加入集群可以得到集群的状态,根据集群的状态可以直接路由请求。
  • 预处理节点:也称作 Ingest 节点,在索引数据之前可以先对数据做预处理操作,所有节点其实默认都是支持 Ingest 操作的,也可以专门将某个节点配置为 Ingest 节点。

以上就是节点几种类型,一个节点其实可以对应不同的类型,如一个节点可以同时成为主节点和数据节点和预处理节点,但如果一个节点既不是主节点也不是数据节点,那么它就是负载均衡节点。具体的类型可以通过具体的配置文件来设置。

怎样搭建 Elasticsearch 集群

好,接下来我们就来动手搭建一个集群吧。 这里我一共拥有七台 Linux 主机,系统是 Ubuntu 16.04,都连接在一个内网中,IP 地址为:

1
2
3
4
5
6
7
10.0.0.4
10.0.0.5
10.0.0.6
10.0.0.7
10.0.0.8
10.0.0.9
10.0.0.10

每台主机的存储空间是 1TB,内存是 13GB。 下面我们来一步步介绍如何用这几台主机搭建一个 Elasticsearch 集群,这里使用的 Elasticsearch 版本是 6.3.2,另外我们还需要安装 Kibana 用来可视化监控和管理 Elasticsearch 的相关配置和数据,使得集群的管理更加方便。 环境配置如下所示:

名称

内容

主机台数

7

主机内存

13GB

主机系统

Ubuntu 16.04

存储空间

1TB

Elasticsearch 版本

6.3.2

Java 版本

1.8

Kibana 版本

6.3.2

安装 Java

Elasticsearch 是基于 Lucene 的,而 Lucene 又是基于 Java 的。所以第一步我们就需要在每台主机上安装 Java。 首先更新 Apt 源:

1
sudo apt-get update

然后安装 Java:

1
sudo apt-get install default-jre

安装好了之后可以检查下 Java 的版本:

1
java -version

这里的版本是 1.8,类似输出如下:

1
2
3
openjdk version "1.8.0_171"
OpenJDK Runtime Environment (build 1.8.0_171-8u171-b11-0ubuntu0.16.04.1-b11)
OpenJDK 64-Bit Server VM (build 25.171-b11, mixed mode)

如果看到上面的内容就说明安装成功了。 注意一定要每台主机都要安装。

安装 Elasticsearch

接下来我们来安装 Elasticsearch,同样是每台主机都需要安装。 首先需要添加 Apt-key:

1
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -

然后添加 Elasticsearch 的 Repository 定义:

1
echo "deb https://artifacts.elastic.co/packages/6.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-6.x.list

接下来安装 Elasticsearch 即可:

1
2
sudo apt-get update 
sudo apt-get install elasticsearch

运行完毕之后我们就完成了 Elasticsearch 的安装,注意还是要每台主机都要安装。

配置 Elasticsearch

这时我们只是每台主机都安装好了 Elasticsearch,接下来我们还需要将它们联系在一起构成一个集群。 安装完之后,Elasticsearch 的配置文件是 /etc/elasticsearch/elasticsearch.yml,接下来让我们编辑一下配置文件:

  • 集群的名称

通过 cluster.name 可以配置集群的名称,集群是一个整体,因此名称都要一致,所有主机都配置成相同的名称,配置示例:

1
cluster.name: germey-es-clusters
  • 节点的名称

通过 node.name 可以配置每个节点的名称,每个节点都是集群的一部分,每个节点名称都不要相同,可以按照顺序编号,配置示例:

1
node.name: es-node-1

其他的主机可以配置为 es-node-2、es-node-3 等。

  • 是否有资格成为主节点

通过 node.master 可以配置该节点是否有资格成为主节点,如果配置为 true,则主机有资格成为主节点,配置为 false 则主机就不会成为主节点,可以去当数据节点或负载均衡节点。注意这里是有资格成为主节点,不是一定会成为主节点,主节点需要集群经过选举产生。这里我配置所有主机都可以成为主节点,因此都配置为 true,配置示例: node.master: true

  • 是否是数据节点

通过 node.data 可以配置该节点是否为数据节点,如果配置为 true,则主机就会作为数据节点,注意主节点也可以作为数据节点,当 node.master 和 node.data 均为 false,则该主机会作为负载均衡节点。这里我配置所有主机都是数据节点,因此都配置为 true,配置示例: node.data: true

  • 数据和日志路径

通过 path.data 和 path.logs 可以配置 Elasticsearch 的数据存储路径和日志存储路径,可以指定任意位置,这里我指定存储到 1T 硬盘对应的路径下,另外注意一下写入权限问题,配置示例: path.data: /datadrive/elasticsearch/data path.logs: /datadrive/elasticsearch/logs

  • ​ 设置访问的地址和端口

我们需要设定 Elasticsearch 运行绑定的 Host,默认是无法公开访问的,如果设置为主机的公网 IP 或 0.0.0.0 就是可以公开访问的,这里我们可以都设置为公开访问或者部分主机公开访问,如果是公开访问就配置为:

1
network.host: 0.0.0.0

如果不想被公开访问就不用配置。 另外还可以配置访问的端口,默认是 9200: http.port: 9200

  • 集群地址设置

通过 discovery.zen.ping.unicast.hosts 可以配置集群的主机地址,配置之后集群的主机之间可以自动发现,这里我配置的是内网地址,配置示例:

1
discovery.zen.ping.unicast.hosts["10.0.0.4""10.0.0.5""10.0.0.6""10.0.0.7""10.0.0.8""10.0.0.9""10.0.0.10"]

这里请改成你的主机对应的 IP 地址。

  • 节点数目相关配置

为了防止集群发生“脑裂”,即一个集群分裂成多个,通常需要配置集群最少主节点数目,通常为 (可成为主节点的主机数目 / 2) + 1,例如我这边可以成为主节点的主机数目为 7,那么结果就是 4,配置示例:

1
discovery.zen.minimum_master_nodes: 4

另外还可以配置当最少几个节点回复之后,集群就正常工作,这里我设置为 4,可以酌情修改,配置示例:

1
gateway.recover_after_nodes: 4

其他的暂时先不需要配置,保存即可。注意每台主机都需要配置。

启动 Elasticsearch

配置完成之后就可以在每台主机上分别启动 Elasticsearch 服务了,命令如下:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable elasticsearch.service
sudo systemctl start elasticsearch.service

所有主机都启动之后,我们在任意主机上就可以查看到集群状态了,命令行如下:

1
curl -XGET 'http://localhost:9200/_cluster/state?pretty'

类似的输出如下:

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
{
    "cluster_name""germey-es-clusters",
    "compressed_size_in_bytes"20799,
    "version"658,
    "state_uuid""a64wCwPnSueKRtVuKx8xRw",
    "master_node""73BQvOC2TpSXcr-IXBcDdg",
    "blocks": {},
    "nodes": {
        "I2M80AP-T7yVP_AZPA0bpA": {
            "name""es-node-1",
            "ephemeral_id""KpCG4jNvTUGKNHNwKKoMrA",
            "transport_address""10.0.0.4:9300",
            "attributes": {
                "ml.machine_memory""7308464128",
                "ml.max_open_jobs""20",
                "xpack.installed""true",
                "ml.enabled""true"
            }
        },
        "73BQvOC2TpSXcr-IXBcDdg": {
            "name""es-node-7",
            "ephemeral_id""Fs9v2XTASnGbqrM8g7IhAQ",
            "transport_address""10.0.0.10:9300",
            "attributes": {
                "ml.machine_memory""14695202816",
                "ml.max_open_jobs""20",
                "xpack.installed""true",
                "ml.enabled""true"
            }
        },
....

可以看到这里输出了集群的相关信息,同时 nodes 字段里面包含了每个节点的详细信息,这样一个基本的集群就构建完成了。

安装 Kibana

接下来我们需要安装一个 Kibana 来帮助可视化管理 Elasticsearch,依然还是通过 Apt 安装,只需要任意一台主机安装即可,因为集群是一体的,所以 Kibana 在任意一台主机只要能连接到 Elasticsearch 即可,安装命令如下:

1
sudo apt-get install kibana

安装之后修改 /etc/kibana/kibana.yml,设置公开访问和绑定的端口:

1
2
server.port: 5601
server.host: "0.0.0.0"

然后启动服务:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable kibana.service
sudo systemctl start kibana.service

这样我们可以在浏览器输入该台主机的 IP 加端口,查看 Kibana 管理页面了,类似如下:

这样 Kibana 可视化管理就配置成功了。

配置认证

现在集群已经初步搭建完成了,但是现在集群很危险,如果我们配置了可公网访问,那么它是可以被任何人操作的,比如储存数据,增删节点等,这是非常危险的,所以我们必须要设置访问权限。 在 Elasticsearch 中,配置认证是通过 X-Pack 插件实现的,幸运的是,我们不需要额外安装了,在 Elasticsearch 6.3.2 版本中,该插件是默认集成到 Elasticsearch 中的,所以我们只需要更改一部分设置就可以了。 首先我们需要升级 License,只有修改了高级版 License 才能使用 X-Pack 的权限认证功能。 在 Kibana 中访问 Management -> Elasticsearch -> License Management,点击右侧的升级 License 按钮,可以免费试用 30 天的高级 License,升级完成之后页面会显示如下:

另外还可以使用 API 来更新 License,详情可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/update-license.html。 然后每台主机需要修改 /etc/elasticsearch/elasticsearch.yml 文件,开启 Auth 认证功能:

1
xpack.security.enabledtrue

随后设置 elastic、kibana、logstash_system 三个用户的密码,任意一台主机修改之后,一台修改,多台生效,命令如下:

1
/usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive

运行之后会依次提示设置这三个用户的密码并确认,一共需要输入六次密码,完成之后就成功设置好了密码了。 修改完成之后重启 Elasticsearch 和 Kibana 服务:

1
2
sudo systemctl restart elasticsearch.service
sudo systemctl restart kibana.service

这时再访问 Kibana 就会跳转到登录页面了:

可以使用 elastic 用户登录,它的角色是超级管理员,登录之后就可以重新进入 Kibana 的管理页面。 我们还可以自行修改和添加账户,在 Management -> Security -> User/Roles 里面:

例如这里添加一个超级管理员的账户:

这样以后我们就可以使用新添加的用户来登录和访问了。 另外修改权限认证之后,Elasticsearch 也不能直接访问了,我们也必须输入用户密码才可以访问和调用其 API,保证了安全性。

开启内存锁定

系统默认会进行内存交换,这样会导致 Elasticsearch 的性能变差,我们查看下内存锁定状态,在任意一台主机上的访问 http://ip:port/\_nodes?filter_path=\*\*.mlockall: 可以看到如下结果:

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
{
    "nodes": {
        "73BQvOC2TpSXcr-IXBcDdg": {
            "process": {
                "mlockall"false
            }
        },
        "9tRr4nFDT_2rErLLQB2dIQ": {
            "process": {
                "mlockall"false
            }
        },
        "hskSDv_JQlCUnjp_INI8Kg": {
            "process": {
                "mlockall"false
            }
        },
        "LgaRuqXBTZaBdDGAktFWJA": {
            "process": {
                "mlockall"false
            }
        },
        "ZcsZgowERzuvpqVbYOgOEA": {
            "process": {
                "mlockall"false
            }
        },
        "I2M80AP-T7yVP_AZPA0bpA": {
            "process": {
                "mlockall"false
            }
        },
        "_mSmfhUtQiqhzTKZ7u75Dw": {
            "process": {
                "mlockall"true
            }
        }
    }
}

这代表内存交换没有开启,会影响 Elasticsearch 的性能,所以我们需要开启内存物理地址锁定,每台主机需要修改 /etc/elasticsearch/elasticsearch.yml 文件,修改如下配置:

1
bootstrap.memory_lock: true

但这样修改之后重新启动是会报错的,Elasticsearch 无法正常启动,查看日志,报错如下:

1
2
[1] bootstrap checks failed
[1]: memory locking requested for elasticsearch process but memory is not locked

这里需要修改两个地方,第一个是 /etc/security/limits.conf,添加如下内容:

1
2
3
4
5
6
soft nofile 65536
hard nofile 65536
soft nproc 32000
hard nproc 32000
hard memlock unlimited
soft memlock unlimited

另外还需要修改 /etc/systemd/system.conf,修改如下内容:

1
2
3
DefaultLimitNOFILE=65536
DefaultLimitNPROC=32000
DefaultLimitMEMLOCK=infinity

详细的解释可以参考:https://segmentfault.com/a/1190000014891856。 修改之后重启 Elasticsearch 服务:

1
sudo systemctl restart elasticsearch.service

重新访问刚才的地址,即可发现每台主机的物理地址锁定都被打开了:

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
{
    "nodes": {
        "73BQvOC2TpSXcr-IXBcDdg": {
            "process": {
                "mlockall"true
            }
        },
        "9tRr4nFDT_2rErLLQB2dIQ": {
            "process": {
                "mlockall"true
            }
        },
        "hskSDv_JQlCUnjp_INI8Kg": {
            "process": {
                "mlockall"true
            }
        },
        "LgaRuqXBTZaBdDGAktFWJA": {
            "process": {
                "mlockall"true
            }
        },
        "ZcsZgowERzuvpqVbYOgOEA": {
            "process": {
                "mlockall"true
            }
        },
        "I2M80AP-T7yVP_AZPA0bpA": {
            "process": {
                "mlockall"true
            }
        },
        "_mSmfhUtQiqhzTKZ7u75Dw": {
            "process": {
                "mlockall"true
            }
        }
    }
}

这样我们就又解决了性能的问题。

安装分词插件

另外还推荐安装中文分词插件,这样可以对中文进行全文索引,安装命令如下:

1
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v6.3.2/elasticsearch-analysis-ik-6.3.2.zip

安装完之后需要重启 Elasticsearch 服务:

1
sudo systemctl restart elasticsearch.service

主机监控

到此为止,我们的 Elasticsearch 集群就搭建完成了。 最后我们看下 Kibana 的部分功能,看下整个 Elasticsearch 有没有在正常工作。 访问 Kibana,打开 Management -> Elasticsearch ->Index Management,即可看到当前有的一些索引和状态:

打开 Monitoring,可以查看 Elasticsearch 和 Kibana 的状态:

进一步点击 Nodes,可以查看各个节点的状态:

打开任意节点,可以查看当前资源状况变化:

另外还有一些其他的功能如可视化、图表、搜索等等,这里就不再一一列举了,更多功能可以详细了解 Kibana。 以上都是自己在安装过程中趟过的坑,如有疏漏,还望指正。 还有更多的 Elasticsearch 相关的内容可以参考官方文档:https://www.elastic.co/guide/index.html

参考资料