0%

技术杂谈

今天这篇文章是要告诉你,业内多名实力强劲的开发者组建了一个服务于广大开发者的团队。现在,你可能会有下面这些疑问:

  • 为什么要组成团队呢?
  • 这个团队将会给广大开发者带来什么?
  • 团队成员有哪些?

好了,接下来用 3 分钟时间去了解这个团队吧!

夜幕团队简介

夜幕团队 NightTeam 于 2019 年 9 月 9 日正式成立,团队由爬虫领域中实力强劲的多名开发者组成:崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。 NightTeam 涉猎的编程语言包括但不限于 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发和对象存储等,团队技术实力十分雄厚。

为什么要组建一个团队?

据以往经验来看,产出一篇优质的技术文章所耗费的时间是相当长的,读者很难从单个作者那里获得成体系且覆盖面较广的知识。 对于作者而言,粉丝的积累、文章的持续编写都是一个慢跑的过程,很多优秀的作者会因为没有粉丝、投入产出比差距太大等因素暂停写作。 更有甚者,文章内容质量越来越低、标题越来越唬、广告越来越多,一门心思放在如何涨粉和变现上。如此循环,就会影响技术圈和技术生态的发展。 如果将那些优秀的作者联合起来(即合纵连横),共同运营一系列的技术媒体号,在不增加负担的情况下还能保证优质文章的产出,会怎么样呢?

我们也不知道,但是我们已经意识到了这个问题,也尝试着寻找答案。这就是夜幕团队 NightTeam 组建的大环境背景。

团队希望做什么事?

互联网是开放的,技术也是开放的。适当的共享可以加速我们的进步,无论是作者还是读者,都能够从分享的过程中获得一些东西。

在开发中,我们会用到一些库和框架。当我刚编程入门的时候,就想着有一天也要编写开源项目,为其他开发者提供帮助,这正是夜幕团队想要做的事。 当然,我们想要做的不仅仅是编写一个开源项目,我们更希望将知识分享出去,在技术圈中传播开来,让更多的人得到帮助。

团队能够为开发者带来什么?

读者的能力和阶段各不相同,每个人收获的内容也不同。宽泛来说,读者可以从夜幕团队 NightTeam 输出的内容中获得对待问题的处理方式、分析问题的思路、解决问题的技巧、问题背后的逻辑等。 假如你是一名中级爬虫工程师,那么夜幕团队 NightTeam 输出的内容可能会为你解决工作中遇到的一些反爬虫问题带来思路。

如果你是一名后端开发者,你也许会从我们发布的类似于【动图演示 - Redis 持久化 RDB/AOF 详解与实践】这样的文章中了解到 Redis 持久化的两种选择的差异和具体操作过程。

团队以什么形式输出内容?

大多数情况下,我们会用文章的形式输出内容。但有时候也会采用直播或者视频教程的方式,兴许还会有现场交流的机会。 为了增加传播广度和影响力,我们不仅仅在微信公众号上发布文章,还会将文章同步到业内著名的几个平台,例如掘金社区、CSDN、SF、V2EX、知乎、今日头条等。

团队成员介绍

团队的成员都是爬虫领域比较活跃的作者,同时也拥有非常强的实力,不会是那些只负责回答小白问题骗钱的盗名之辈。以下将不分先后的列出团队成员姓名、昵称和各自的介绍。 周子淇

昵称 Loco 前微信公众号「小周码字」号主、知乎专栏「手把手教你写爬虫」作者,幂度爬虫工程师。啥方向都搞,除了爬虫相关的文章以外还会写一些灰黑产操作研究、机器学习、造轮子、物联网设备研究、脑洞分享等各种奇奇怪怪的东西。

韦世东

昵称 Asyncins 图灵签约作者、电子工业出版社约稿作者、华为云认证云享专家、掘金社区优秀作者、GitChat 认证作者、搜狐产品技术约稿作者、开源项目 aiowebsocket 作者、微信公众号「Rust之禅」号主、「进击的Coder」运营者之一,有着丰富的爬虫经验,擅长反爬虫的绕过技巧。

崔庆才

昵称 静觅 畅销书《Python3网络爬虫开发实战》作者、微信公众号「进击的Coder」号主,微软中国工程师。主要研究网络爬虫、机器学习、Web 开发相关内容。

陈祥安

昵称 CXA 微信公众号「Python学习开发」号主、CSDN 线下沙龙特邀讲师、华为云享社区专家、阿里云栖社区专家、哔哩哔哩《陈祥安分析Python面试题》系列 UP 主、GitChat 热门文章《Python 常见的 170 道面试题全解析:2019 版》作者,马蜂窝高级爬虫工程师。

唐轶飞

昵称 大鱼 BruceDone 微信公众号「大鱼鱼塘」号主、「http://brucedone.com」站长,腾讯后端工程师。多年 Code 经验,擅长后端开发,语言了解但不限于 .NET、Python、Golang、SQL,兴趣包含但不限于爬虫,后端,数据库,深度学习。

冯 威

昵称 妄为 微信公众号「妄为写代码」号主,爬虫 Coder,佛系程序员。专注 JavaScript、Android 逆向以及验证码破解,对逆向有着丰富的经验。

蔡 晋

昵称 悦来客栈的老板 微信公众号「菜鸟学Python编程」号主,平时喜欢研究各大网站的反爬,熟悉常见网站的反爬操作,对反爬有着独到的见解。

戴煌金

昵称 咸鱼 微信公众号「咸鱼学Python」号主、华为云享专家。专注Python爬虫、JavaScript逆向,立志做一条最咸的咸鱼。

张冶青

昵称:MarvinZ 微信公众号「Crawlab漫游指南」号主、爬虫管理平台 Crawlab 作者、文章发布平台 ArtiPub 作者,知名外企前端开发工程师。专注前端、爬虫和数据分析。

如何与夜幕取得联系?

你可能想跟夜幕团队交流一些技术方面的问题,你可以发送消息“夜幕读者群”到我们的公众号「NightTeam」加入读者群,团队成员都在群里等你。 一些重要的事可以通过邮件与夜幕取得联系,夜幕团队的邮箱为 contact@nightteam.cn。 GitHub 也准备好了,我们会逐渐将开源项目迁移到团队的仓库中,地址为 https://github.com/nightteam

技术杂谈

舆情爬虫是网络爬虫一个比较重要的分支,舆情爬虫往往需要爬虫工程师爬取几百几千个新闻站点。比如一个新闻页面我们需要爬取其标题、正文、时间、作者等信息,如果用传统的方式来实现,每一个站点都要配置非常多的规则,如果要维护一个几百上千的站点,那人力成本简直太高了。 如果有一种方式可以在保证差不多的准确率的前提下,大幅提高提取效率的话,就需要用到智能文本提取了。 本文首先介绍一下智能文本提取的基本原理,让大家对智能提取有基本的了解。然后介绍几个比较基础的工具包,准确率并不是很高,可以尝试一用。最后再介绍几篇比较前沿的技术供大家参考。

智能文本提取

目前来说,智能文本提取可以分为三类:

  • 基于网页文档内容的提取方法
  • 基于 DOM 结构信息的提取方法
  • 基于视觉信息的提取方法

基于网页文档的提取方法将 HTML 文档视为文本进行处理,适用于处理含有大量文本信息且结构简单易于处理的单记录网页,或者具有实时要求的在线分析网页应用。 这种方式主要利用自然语言处理相关技术实现,通过理解 文本语义、分析上下文、设定提取规则等,实现对大段网页文档的快速处理。其中,较为知名的方法有 TSIMMIS、Web-OQL、Serrano、FAR-SW 和 FOREST,但这些方法由于通常需要人工的参与,且存在耗时长、效率低的弊端。 基于 DOM 结构信息的方法将 HTML 文档解析为相应的 DOM 树,然后根据 DOM 树的语法结构创建提取规则, 相对于以前的方法而言有了更高的性能和准确率。 W4F 和 XWRAP 将 HTML 文档解析成 DOM 树,然后通过组件化引导用户通过人工选择或者标记生成目标包装器代码。Omini、IEPAD 和 ITE 提取 DOM 树上的关键路径, 获取其中存在的重复模式。MDR 和 DEPTA 挖掘了页面中的数据区域,得到数据记录的模式。CECWS 通过聚类算法从数据库中提取出自同一网站的一组页面,并进行 DOM 树结构的对比,删除其中的静态部分,保留动态内容作为信息提取的结果。虽然此类方法相对于上一类方法 具有较高的提取精度,且克服了对大段连续文本的依赖, 但由于网页的 DOM 树通常较深,含有大量 DOM 节点, 因此基于 DOM 结构信息的方法具有较高的时间和空间消耗。目前来说,大部分原理还是基于 DOM 节点的文本密度、标点符号密度等计算的,其准确率还是比较可观的。今天所介绍的 Readability 和 Newspaper 的库的实现原理就是类似。 目前比较先进的是基于视觉信息的网页信息提取方法,通过浏览器接口或者内核对目标网页预渲染,然后基于网页的视觉规律提取网页数据记录。经典的 VIPS 算法首先从 DOM 树中提取出所有合适的页面区域,然后根据这些页面和分割条重新构建 Web 页面的语义结构。作为对 VIPS 的拓展,ViNT、ViPER、ViDE 也成功利用了网页的视觉特征来实现数据提取。CMDR 为通过神经网络学习多记录型页面中的特征,结合基于 DOM 结构信息的 MDR 方法,挖掘社区论坛页面的数据区域。与上述方法不同,VIBS 将图像领域的 CNN 卷积神经网络运用于网页的截图,同时通过类 VIPS 算法生成视觉块,最后结合两个阶段的结果识别网页的正文区域。另外还有最新的国内提出的 VBIE 方法,基于网页视觉的基础上改进,可以实现无监督的网页信息提取。

以上内容主要参考自论文:《王卫红等:基于可视块的多记录型复杂网页信息提取算法》,算法可从该论文参考文献查阅。

下面我们来介绍两个比较基础的工具包 Readability 和 Newspaper 的用法,这两个包经我测试其实准确率并不是很好,主要是让大家大致对智能解析有初步的理解。后面还会介绍一些更加强大的智能化解析算法。

Readability

Readability 实际上是一个算法,并不是一个针对某个语言的库。其主要原理就是计算了 DOM 的文本密度,另外根据一些常见的 DOM 属性如 id、class 等计算了一些 DOM 的权重,最后分析得到了对应的 DOM 区块,进而提取出具体的文本内容。 现在搜索 Readability 其实已经找不到了,取而代之的是一个 JavaScript 工具包,叫做 mercury-parser,据我所知应该是 Readability 不维护了,换成了 mercury-parser。后者现在也做成了一个 Chrome 插件,大家可以下载使用一下。 回归正题,这次主要介绍的是 Python 的 Readability 实现,现在其实有很多开源版本,本文选取的是 https://github.com/buriy/python-readability,是基于最早的 Python 版本的 Readability 库 https://github.com/timbertson/python-readability 二次开发的,现在已经发布到了 PyPi,大家可以直接下载安装使用。 安装很简单,通过 pip 安装即可:

1
pip3 install readability-lxml

安装好了之后便可以通过导入 readability 使用了,下面我们随便从网上找一个新闻页面,比如:https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html,其页面截图如下页面示例 我们的目的就是它的正文、标题等内容。下面我们用 Readability 试一下,示例如下:

1
2
3
4
5
6
7
8
import requests
from readability import Document

url = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'
html = requests.get(url).content
doc = Document(html)
print('title:', doc.title())
print('content:', doc.summary(html_partial=True))

在这里我们直接用 requests 库对网页进行了请求,获取了其 HTML 页面内容,赋值为 html。 然后引入了 readability 里的 Document 类,使用 html 变量对其进行初始化,然后我们分别调用了 title 方法和 summary 方法获得了其标题和正文内容。 这里 title 方法就是获取文章标题的,summary 就是获取文章正文的,但是它获取的正文可能包含一些 HTML 标签。这个 summary 方法可以接收一个 html_partial 参数,如果设置为 True,返回的结果则不会再带有 <html><body> 标签。 看下运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
title: 今年iPhone只有小改进?分析师:还有其他亮点_网易科技
content: <div><div class="post_text" id="endText">
<p class="otitle">
(原标题:Apple Bets More Cameras Can Keep iPhone Humming)
</p>
<p class="f_center"><img alt="今年iPhone只有小改进?分析师:还有其他亮点" src="http://cms-bucket.ws.126.net/2019/09/09/d65ba32672934045a5bfadd27f704bc1.jpeg"/><span>图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。</span></p><p>网易科技讯 9月9日消息,据国外媒体报道,和过去的12个年头一样,新款
... 中间省略 ...
<p>苹果还即将推出包括电视节目和视频游戏等内容的新订阅服务。分析师表示,该公司最早可能在本周宣布TV+和Arcade等服务的价格和上线时间。</p><p>Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)</p><p><b>相关报道:</b></p><p><a href="https://tech.163.com/19/0908/09/EOHS53RK000999LD.html" target="_self" urlmacroreplace="false">iPhone 11背部苹果Logo改为居中:为反向无线充电</a></p><p><a href="https://tech.163.com/19/0907/08/EOF60CBC00097U7S.html" target="_self" urlmacroreplace="false">2019年新iPhone传言汇总,你觉得哪些能成真</a>  </p><p/>
<p/>
<div class="ep-source cDGray">
<span class="left"><a href="http://tech.163.com/"><img src="https://static.ws.126.net/cnews/css13/img/end_tech.png" alt="王凤枝" class="icon"/></a> 本文来源:网易科技报道 </span>
<span class="ep-editor">责任编辑:王凤枝_NT2541</span>
</div>
</div>
</div>

可以看到,标题提取是正确的。正文其实也是正确的,不过这里还包含了一些 HTML 标签,比如 <img><p> 等,我们可以进一步通过一些解析库来解析。 看下源码吧,比如提取标题的方法:

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 normalize_entities(cur_title):
entities = {
u'u2014':'-',
u'u2013':'-',
u'&mdash;': '-',
u'&ndash;': '-',
u'u00A0': ' ',
u'u00AB': '"',
u'u00BB': '"',
u'&quot;': '"',
}
for c, r in entities.items():
if c in cur_title:
cur_title = cur_title.replace(c, r)

return cur_title

def norm_title(title):
return normalize_entities(normalize_spaces(title))

def get_title(doc):
title = doc.find('.//title')
if title is None or title.text is None or len(title.text) == 0:
return '[no-title]'

return norm_title(title.text)

def title(self):
"""Returns document title"""
return get_title(self._html(True))

title 方法实际上就是调用了一个 get_title 方法,它怎么做的?实际上就是用了一个 XPath 只解析了 <title> 标签里面的内容,别的没了。如果没有,那就返回 [no-title]

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
def summary(self, html_partial=False):
ruthless = True
while True:
self._html(True)
for i in self.tags(self.html, 'script', 'style'):
i.drop_tree()
for i in self.tags(self.html, 'body'):
i.set('id', 'readabilityBody')
if ruthless:
self.remove_unlikely_candidates()
self.transform_misused_divs_into_paragraphs()
candidates = self.score_paragraphs()

best_candidate = self.select_best_candidate(candidates)

if best_candidate:
article = self.get_article(candidates, best_candidate,
html_partial=html_partial)
else:
if ruthless:
ruthless = False
continue
else:
article = self.html.find('body')
if article is None:
article = self.html
cleaned_article = self.sanitize(article, candidates)
article_length = len(cleaned_article or '')
retry_length = self.retry_length
of_acceptable_length = article_length >= retry_length
if ruthless and not of_acceptable_length:
ruthless = False
continue
else:
return cleaned_article

这里我删除了一些冗余的调试的代码,只保留了核心的代码,其核心实现就是先去除一些干扰内容,然后找出一些疑似正文的 candidates,然后再去寻找最佳匹配的 candidates 最后提取其内容返回即可。 然后再找到获取 candidates 方法里面的 score_paragraphs 方法,又追踪到一个 score_node 方法,就是为每一个节点打分的,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def score_node(self, elem):
content_score = self.class_weight(elem)
name = elem.tag.lower()
if name in ["div", "article"]:
content_score += 5
elif name in ["pre", "td", "blockquote"]:
content_score += 3
elif name in ["address", "ol", "ul", "dl", "dd", "dt", "li", "form", "aside"]:
content_score -= 3
elif name in ["h1", "h2", "h3", "h4", "h5", "h6", "th", "header", "footer", "nav"]:
content_score -= 5
return {
'content_score': content_score,
'elem': elem
}

这什么意思呢?你看如果这个节点标签是 div 或者 article 等可能表征正文区块的话,就加 5 分,如果是 aside 等表示侧栏的内容就减 3 分。这些打分也没有什么非常标准的依据,可能是根据经验累积的规则。 另外还有一些方法里面引用了一些正则匹配来进行打分或者替换,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 REGEXES = {
'unlikelyCandidatesRe': re.compile('combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter', re.I),
'okMaybeItsACandidateRe': re.compile('and|article|body|column|main|shadow', re.I),
'positiveRe': re.compile('article|body|content|entry|hentry|main|page|pagination|post|text|blog|story', re.I),
'negativeRe': re.compile('combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget', re.I),
'divToPElementsRe': re.compile('<(a|blockquote|dl|div|img|ol|p|pre|table|ul)', re.I),
#'replaceBrsRe': re.compile('(<br[^>]*>[ nrt]*){2,}',re.I),
#'replaceFontsRe': re.compile('<(/?)font[^>]*>',re.I),
#'trimRe': re.compile('^s+|s+$/'),
#'normalizeRe': re.compile('s{2,}/'),
#'killBreaksRe': re.compile('(<brs*/?>(s|&nbsp;?)*){1,}/'),
'videoRe': re.compile('https?://(www.)?(youtube|vimeo).com', re.I),
#skipFootnoteLink: /^s*([?[a-z0-9]{1,2}]?|^|edit|citation needed)s*$/i,
}

比如这里定义了 unlikelyCandidatesRe,就是不像 candidates 的 pattern,比如 foot、comment 等等,碰到这样的标签或 pattern 的话,在计算分数的时候都会减分,另外还有其他的 positiveRe、negativeRe 也是一样的原理,分别对匹配到的内容进行加分或者减分。 这就是 Readability 的原理,是基于一些规则匹配的打分模型,很多规则其实来源于经验的累积,分数的计算规则应该也是不断地调优得出来的。 另外其他的就没了,Readability 并没有提供提取时间、作者的方法,另外此种方法的准确率也是有限的,但多少还是省去了一些人工成本。

Newspaper

另外还有一个智能解析的库,叫做 Newspaper,提供的功能更强一些,但是准确率上个人感觉和 Readability 差不太多。 这个库分为 Python2 和 Python3 两个版本,Python2 下的版本叫做 newspaper,Python3 下的版本叫做 newspaper3k,这里我们使用 Python3 版本来进行测试。 其 GitHub 地址是:https://github.com/codelucas/newspaper,官方文档地址是:[https://newspaper.readthedocs.io](https://newspaper.readthedocs.io/>)。 在安装之前需要安装一些依赖库,可以参考官方的说明:https://github.com/codelucas/newspaper#get-it-now。 安装好必要的依赖库之后,就可以使用 pip 安装了:

1
pip3 install newspaper3k

安装成功之后便可以导入使用了。 下面我们先用官方提供的实例来过一遍它的用法,官方提供的示例是使用了这个链接:https://fox13now.com/2013/12/30/new-year-new-laws-obamacare-pot-guns-and-drones/,其页面截图如下官方示例 下面用一个实例来感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from newspaper import Article

url = 'https://fox13now.com/2013/12/30/new-year-new-laws-obamacare-pot-guns-and-drones/'
article = Article(url)
article.download()
# print('html:', article.html)

article.parse()
print('authors:', article.authors)
print('date:', article.publish_date)
print('text:', article.text)
print('top image:', article.top_image)
print('movies:', article.movies)

article.nlp()
print('keywords:', article.keywords)
print('summary:', article.summary)

这里从 newspaper 库里面先导入了 Article 这个类,然后直接传入 url 即可,首先需要调用它的 download 方法,将网页爬取下来,否则直接进行解析会抛出错误的。

但我总感觉这个设计挺不友好的,parse 方法不能判断下,如果没执行 download 就自动执行 download 方法吗?如果不 download 其他的不什么都干不了吗?

好的,然后我们再执行 parse 方法进行网页的智能解析,这个功能就比较全了,能解析 authors、publish_date、text 等等,除了正文还能解析作者、发布时间等等。 另外这个库还提供了一些 NLP 的方法,比如获取关键词、获取文本摘要等等,在使用前需要先执行以下 nlp 方法。 最后运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
authors: ['Cnn Wire']
date: 2013-12-30 00:00:00
text: By Leigh Ann Caldwell

WASHINGTON (CNN) — Not everyone subscribes to a New Year’s resolution, but Americans will be required to follow new laws in 2014.

Some 40,000 measures taking effect range from sweeping, national mandates under Obamacare to marijuana legalization in Colorado, drone prohibition in Illinois and transgender protections in California.

Although many new laws are controversial, they made it through legislatures, public referendum or city councils and represent the shifting composition of American beliefs.
...
...
Colorado: Marijuana becomes legal in the state for buyers over 21 at a licensed retail dispensary.

(Sourcing: much of this list was obtained from the National Conference of State Legislatures).
top image: https://localtvkstu.files.wordpress.com/2012/04/national-news-e1486938949489.jpg?quality=85&strip=all
movies: []
keywords: ['drones', 'national', 'guns', 'wage', 'law', 'pot', 'leave', 'family', 'states', 'state', 'latest', 'obamacare', 'minimum', 'laws']
summary: Oregon: Family leave in Oregon has been expanded to allow eligible employees two weeks of paid leave to handle the death of a family member.
Arkansas: The state becomes the latest state requiring voters show a picture ID at the voting booth.
Minimum wage and former felon employmentWorkers in 13 states and four cities will see increases to the minimum wage.
New Jersey residents voted to raise the state’s minimum wage by $1 to $8.25 per hour.
California is also raising its minimum wage to $9 per hour, but workers must wait until July to see the addition.

这里省略了一些输出结果。 可以看到作者、日期、正文、关键词、标签、缩略图等信息都被打印出来了,还算是不错的。 但这个毕竟是官方的实例,肯定是好的,我们再测试一下刚才的例子,看看效果如何,网址还是:https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html,改写代码如下

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

url = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'
article = Article(url, language='zh')
article.download()
# print('html:', article.html)

article.parse()
print('authors:', article.authors)
print('title:', article.title)
print('date:', article.publish_date)
print('text:', article.text)
print('top image:', article.top_image)
print('movies:', article.movies)

article.nlp()
print('keywords:', article.keywords)
print('summary:', article.summary)

这里我们将链接换成了新闻的链接,另外在 Article 初始化的时候还加了一个参数 language,其值为 zh,代表中文。 然后我们看下运行结果:

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
Building prefix dict from /usr/local/lib/python3.7/site-packages/jieba/dict.txt ...
Dumping model to file cache /var/folders/1g/l2xlw12x6rncs2p9kh5swpmw0000gn/T/jieba.cache
Loading model cost 1.7178938388824463 seconds.
Prefix dict has been built succesfully.
authors: []
title: 今年iPhone只有小改进?分析师:还有其他亮点
date: 2019-09-09 08:10:26+08:00
text: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)

图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。

网易科技讯 99日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行年度宣传活动的主角。但人们的注意力正转向需要推动增长的其他苹果产品和服务。
...
...
Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)

相关报道:

iPhone 11背部苹果Logo改为居中:为反向无线充电

2019年新iPhone传言汇总,你觉得哪些能成真
top image: https://www.163.com/favicon.ico
movies: []
keywords: ['trust高级投资组合经理丹摩根dan', 'iphone', 'mawston表示可穿戴设备和服务的结合将是苹果业务超越iphone的关键他说上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中辰辰相关报道iphone', 'xs的销售疲软状况迫使苹果在1月份下调了业绩预期这是逾15年来的第一次据贸易公司susquehanna', 'xs机型发布后那种令人失望的业绩重演iphone', '今年iphone只有小改进分析师还有其他亮点', 'more', 'xr和iphone', 'morgan说他们现在没有任何真正深入的进展只是想继续让iphone这款业务继续转下去他乐观地认为今年发布的新款手机将有足够多的新功能为一个非常成熟的产品增加额外的功能让火车继续前进这种仅限于此的态度说明了苹果自2007年发布首款iphone以来所面临的挑战iphone销售占苹果公司总营收的一半以上这让苹果陷入了一个尴尬的境地既要维持核心产品的销量另一方面又需要减少对它的依赖瑞银ubs今年5月份对8000名智能手机用户进行了相关调查其发布的年度全球调查报告显示最近iphone在人脸识别技术等方面的进步并没有引起一些消费者的共鸣他们基本上都认为苹果产品没有过去几年那么独特或者惊艳品牌也没有过去几年那么有吸引力很多人使用老款手机的时间更长自己认为也没有必要升级到平均售价949美元的新款iphone苹果需要在明年销售足够多的iphone以避免像去年9月份iphone', 'keep', '原标题apple']
summary: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。网易科技讯 99日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行...亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)相关报道:iPhone 11背部苹果Logo改为居中:为反向无线充电2019年新iPhone传言汇总,你觉得哪些能成真

中间正文很长省略了一部分,可以看到运行时首先加载了一些中文的库包,比如 jieba 所依赖的词表等等。 解析结果中,日期的确是解析对了,因为这个日期格式的的确比较规整,但这里还自动给我们加了东八区的时区,贴心了。作者没有提取出来,可能是没匹配到 来源 两个字吧,或者词库里面没有,标题、正文的提取还算比较正确,也或许这个案例的确是比较简单。 另外对于 NLP 部分,获取的关键词比较迷,长度有点太长了。summary 也有点冗余。 另外 Newspaper 还提供了一个较为强大的功能,就是 build 构建信息源。官方的介绍其功能就是构建一个新闻源,可以根据传入的 URL 来提取相关文章、分类、RSS 订阅信息等等。 我们用实例感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
import newspaper

source = newspaper.build('http://www.sina.com.cn/', language='zh')
for category in source.category_urls():
print(category)

for article in source.articles:
print(article.url)
print(article.title)

for feed_url in source.feed_urls():
print(feed_url)

在这里我们传入了新浪的官网,调用了 build 方法,构建了一个 source,然后输出了相关的分类、文章、RSS 订阅等内容,运行结果如下:

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
http://cul.news.sina.com.cn
http://www.sina.com.cn/
http://sc.sina.com.cn
http://jiangsu.sina.com.cn
http://gif.sina.com.cn
....
http://tj.sina.com.cn
http://travel.sina.com.cn
http://jiaoyi.sina.com.cn
http://cul.sina.com.cn
https://finance.sina.com.cn/roll/2019-06-12/doc-ihvhiqay5022316.shtml
经参头版:激发微观主体活力加速国企改革
http://eladies.sina.com.cn/feel/xinli/2018-01-25/0722/doc-ifyqwiqk0463751.shtml
我们别再联系了
http://finance.sina.com.cn/roll/2018-05-13/doc-ihamfahx2958233.shtml
新违约时代到来!违约“常态化”下的市场出清与换血
http://sports.sina.com.cn/basketball/2019worldcup/2019-09-08/doc-iicezzrq4390554.shtml
罗健儿26分韩国收首胜
...
http://travel.sina.com.cn/outbound/pages/2019-09-05/detail-iicezzrq3622449.shtml
菲律宾海滨大道 夜晚让人迷离
http://travel.sina.com.cn/outbound/pages/2016-08-19/detail-ifxvcnrv0334779.shtml
关岛 用双脚尽情享受阳光与海滩
http://travel.sina.com.cn/domestic/pages/2019-09-04/detail-iicezzrq3325092.shtml
秋行查干浩特草原
http://travel.sina.com.cn/outbound/pages/2019-09-03/detail-iicezueu3050710.shtml
白羊座的土豪之城迪拜
http://travel.sina.com.cn/video/baidang/2019-08-29/detail-ihytcitn2747327.shtml
肯辛顿宫藏着维多利亚的秘密
http://cd.auto.sina.com.cn/bdcs/2017-08-15/detail-ifyixias1051586.shtml

可以看到它输出了非常多的类别链接,另外还有很多文章列表,由于没有 RSS 订阅内容,这里没有显示。 下面把站点换成我的博客:https://cuiqingcai.com,博客截图如下博客截图 看看运行结果:

1
2
https://cuiqingcai.com
https://cuiqingcai.com

似乎不太行啊,一篇文章都没有,RSS 也没有,可见其功能还有待优化。 Newspaper 的基本用法介绍到这里,更加详细的用法可以参考官方文档:https://newspaper.readthedocs.io。个人感觉其中的智能解析可以用用,不过据我的个人经验,感觉还是很多解析不对或者解析不全的, 以上便是 Readability 和 Newspaper 的介绍。

其他方案

另外除了这两个库其实还有一些比较优秀的算法,由于我们处理的大多为中文文档,所以一些在中文上面的研究是比较有效的,在这里列几个值得借鉴的中文论文供大家参考:

  • 洪鸿辉等,基于文本及符号密度的网页正文提取方法
  • 梁东等,基于支持向量机的网页正文内容提取方法
  • 王卫红等,基于可视块的多记录型复杂网页信息提取算法

今天还看到一位大佬「青南」根据上面第一篇论文所实现的 GeneralNewsExtractor,GitHub 地址为:https://github.com/kingname/GeneralNewsExtractor,经测试准确率还不错,比 Readability 和 Newspaper 的解析效果要好。我也跟作者进行了交流,后续可能还会基于其他的 Feature 或依赖于视觉化的方法进行优化,大家可以关注下,谢谢!

技术杂谈

这两天想必大家应该被一个软件刷屏了,它的名字叫做 Zao,中文音译就叫“造”。它为什么这么火呢?是因为我们可以上传自己的一张照片,他就能把我们的脸替换成一些热门视频的男主或女主的脸,也就是视频换脸。 比如有人尝试了把尼古拉斯赵四的脸换到美国队长的脸上,美队的气质简直就是被垄断了,大家可以扫码看看: 美队变赵四 视频换脸技术大家应该早有耳闻,但这个软件有点意思,它抓住了几个点使得它一炮而红。 第一是这个软件的效果确实不错,我拿自己也做了实验,发现确实它渲染的一些结果几乎毫无违和感,毕竟这个软件核心拼的就是技术。 第二这个软件贴近于日常生活,我们可以把自己的照片上传,让我们真正成为视频里的主角。另外视频选材很有讲究,都是一些剪辑过的明星精彩镜头,这样我们生成的视频镜头会让我们有变成明星的感觉,非常有代入感。

技术实现

作为一名程序员,当然最关心的可能就是它的技术实现了,毋庸置疑它肯定是利用了深度学习的一些技术。我看了一些文章和调研,大体了解了一下,下面稍微分析一下里面用到的一些技术。 整体而言呢,这个过程分为三步,他们分别是:

  • 人脸定位
  • 人脸转换
  • 人脸融合

人脸定位

现在深度学习对于人脸识别和定位的研究技术已经非常成熟和精准了,其核心就是使用了卷积神经网络,即 CNN,不同的模型架构对于识别的准确率有不用的表现。 对于人脸的定位,一般是使用脸部的关键点定位的,这些点叫做 Landmarks。在一张人脸图像上,每张脸的轮廓和五官的位置都会被打上点,比如整个脸部的轮廓用一些点描出来,鼻子、眼睛、唇形同样用一些点描出来。 Facial Feature Detection 一般来说一张脸会用 68 个点来标记出来,每识别的模型接收一张人脸图像,输出这 68 个点的坐标,这样我们就可以实现人脸定位了。 现在现成的模型也很多了,比如 dlib,opencv 等开源工具包可以直接拿来使用了,如果要更精准地话可以使用更复杂的卷积神经网络模型来实现,大家可以了解下相关论文。

人脸生成

有了标记点以后,这个软件就可以把我们的人脸提取出来了,但是这有个问题,我们上传的是一张静态图片,总不能直接生硬地替换进去吧,比如我们上传的是一张正脸照片,那视频里的一些侧脸画面直接贴上那不就没法看了吗? 这时候就要用到另外一个核心技术叫做人脸生成技术,有了它我们就可以对人脸进行生成了,比如根据一张正脸图生成一张侧脸图。目前人脸生成技术主要有两种,有 GAN(生成对抗网络)和 VAE(变分自编码器),下面简单介绍一下它们的原理。 对于 GAN 来说,它叫做生成对抗网络,为什么叫对抗网络呢?是因为模型在训练的过程中一直有两个东西在做对抗,这俩东西分别叫 Generator(生成器)和 Discriminator(判别器)。前者主要负责生成一张人脸,越像越牛逼。后者主要负责判断分辨前者生成的人脸是不是真的,判定越准越牛逼。二者在这个过程中为了变得越来越牛逼,前者就会尽力去生成更像的人脸来欺骗后者,后者也会尽力去判别生成的人脸是不是真的来打击前者。这样二者在不断地训练和对抗过程中,前者生成的结果就会越来越好了。 对于 VAE 呢,它是通过一些无监督学习的方式将人脸信息进行压缩,由编码器把它表示成一个短向量,这些向量里就包含了人脸的基本信息,比如肤色、唇形等信息,这样整个模型就可以学习到人脸的共性。然后,解码器将向量解码,将其转换为某一特定的人脸。这样就等于经过一层中间向量完成了从一张人脸到另一张人脸的转换。

图像融合

最后的阶段就是图像融合了,也就是把生成的新的人脸和原来图像的背景融合,使之不会产生违和感。 在这个软件中,视频是由一帧一帧组成的,那么在转换的时候也需要一帧一帧处理,最后处理完成后再合成整个视频。 以上也就是我所了解到的变脸的一些方法。

安全性

有人说,这个技术不是什么好技术。万一有人拿着我们的照片一变脸,就能够把我们任意的表情和头部动作模拟出来,拿着去做认证,比如刷脸支付什么的咋办,那我们的钱不就被盗刷了吗? 对于这个问题,支付宝官方也做了回应,支付宝称刷脸支付实际上会通过软硬件结合的方式进行检测,其会判断被刷物体是否是照片、视频或者软件模拟的方式生成的,可以有效避免身份冒用情况。其中有一个核心技术就是通过 3D 结构光摄像头来进行信息采集和识别,如果被拍摄物体是平面的,也就是说如果是照片或者视频,是无法通过检测的。 支付宝回应 这时候我自然而然想到,既然用的是 3D 结构光摄像头,那么如果用了 3D 打印技术把一个人的肖像打印出来,或者用一个非常逼真的蜡像来进行刷脸识别,能不能通过呢?我看了一些报道,发现不少案例的确通过了刷脸测试,比如解开了 iPhone 面部识别锁等等。但要通过 3D 打印技术来模拟一个人的肖像成本还是蛮高的,所以基本上也不太会有人来搞这些。 如果对此还心有余悸的话,支付宝还回应称,即便是真的被盗刷了,支付宝也会通过保险公司进行全额赔付。 所以基本上是不用担心其安全性的,尤其是 Zao 这个软件的出现是没有对刷脸支付的风险造成大的影响的,其就是增加了一个活体视频模拟的实现,对刷脸支付的安全性没有出现大的突破性威胁。

隐私性

这个就要好好说一下了,这个软件的出现同时引起了另一个轩然大波,那就是其中的隐私条款。 其隐私条款有一条是这样的:

用户上传发布内容后,意味着同意授予 ZAO 及其关联公司以及 ZAO 用户在“全球范围内完全免费、不可撤销、永久、可转授权和可再许可的权利”,“包括但不限于可以对用户内容进行全部或部分的修改与编辑(如将短视频中的人脸或者声音换成另一个人的人脸或者声音等)以及对修改前后的用户内容进行信息网络传播以及《著作权法》规定的由著作权人享有的全部著作财产权利及邻接权利”。

这条款没人说还真没注意到,因为一般咱用一个软件,一般不会去仔细看它的条款,那么密密麻麻的一坨,有几个人会去仔细看呢?但要不同意,这个软件还没法用,所以用过这个软件的人,这个条款一定是已经同意了。 这条条款其实是很过分的,同意授予 Zao 及其关联公司以及 Zao 用户在“全球范围内完全免费、不可撤销、永久、可转授权和可再许可的权利。注意这里有几个字,完全免费、不可撤销、永久、可转授权、可再许可,这几个词就代表我们已经把我们的肖像权永久授予了 Zao 及其关联公司了,而且不能撤销,账号注销了也不能撤销,也就是以后它们可以有权利永久滥用我们的肖像。更可怕的是,其中还有一个词叫可转授权,那也就是说,Zao 可以对我们的肖像权进行转授权,你懂得,给点钱,啥办不到呢?这就更无法控制了,这可能就意味着,世界上任何一个人可能都能获得我们的肖像权。 所以说,如果你还没用的话,一定要谨慎谨慎再谨慎! 哎,反正我已经同意了,貌似我现在也没什么办法了。

社会影响

这个软件的出现,更深一点想,其实它所隐含的影响还是蛮大的。 有了这个变脸技术,如果有人获得了我们在条款里面所”捐出“的肖像权,拿着我们的照片去生产那种你懂得的影片,把视频里面的男主或者女主换成我们的人脸,然后到处传播,或者以此作为敲诈勒索的工具。即便我们有理,那也说不清了,首先这个条款已经说了它们可以有权利随意使用我们的肖像,所以告侵犯肖像权已经行不通了,而且即使我们有证据证明这是假的,但这种视频的传播也一定会带来非常大的影响。 按照现在大众们的观念,比如说一张图,我们如果不信的话可以说它是 P 的,但如果换做是视频的话,很多人可能就会相信了,因为很多人不知道视频中的肖像也可以伪造得这么真了,毕竟很多人并不知道这种技术。因此,有了这种技术的出现,以后视频类的证据,可能也不可信了。因此这个软件的出现,可以说从另一个侧面昭示,以后视频也不能作为犯案的证据和验证人的真伪的依据了。 所以以后可能是这样子的:

  • 坐在电脑面前的网络女主播,即便不开美颜和滤镜,你所看到的她也不是真的她了。
  • 你要给人打个钱,说开个视频吧,我看看是不是真的你,即便看到的是他,你也不能信了。
  • 有人要 Qiao Zha 你,把你的人脸换成 Zuo An 分子的脸,你到哪里说理去?
  • 某一天,你作为男女主角,出现在了 P 站和 91….

我一开始想的还没这么深,边想边写,写到这,我自己都开始后怕了… 怎么甚至感觉,以后的社会可能会乱套了呢?这可能就是 AI 发展的一个隐患吧。 所以写到最后,虽然这个软件很有意思,但还是劝大家还没有用的就不要用了吧,真的很可怕。同时我也不知道这个软件这样的条款和做法会不会有什么问题,但还是希望能引起有关部门的注意。 以后,也希望大家也可以在使用软件的时候,要更加谨慎和小心,有条款就稍微看一看,尤其是对于这种和用户隐私相关的软件,要更加心存戒备。

参考文章

本文参考来源:

  • 机器之心:刷屏的 ZAO 换脸 APP 你玩了吗?
  • 支付宝推出的刷脸支付是基于“活体检测”技术做支撑

技术杂谈

开发者如何学好 MongoDB

作为一名研发,数据库是或多或少都会接触到的技术。MongoDB 是热门的 NoSQL 之一,我们怎样才能学好 MongoDB 呢? 本篇文章,我们将从以下几方面讨论这个话题:

  1. MongoDB 是什么
  2. 我如何确定我需要学习 MongoDB
  3. 开发者应该掌握 MongoDB 的哪些知识
  4. 学习的选择和困境

我们先来了解一下,MongoDB 为何物。 NoSQL 泛指非关系型数据库,该词是关系型数据库(即 SQL)的相对称呼。MongoDB 是非关系型数据库(NoSQL)中较为人熟知的一种。它拥有很多优秀特性,例如高性能、高可用、支持丰富的查询语句、无需预定义数据模型和水平可伸缩等,适合存储结构化、半结构化的文档和特定格式的文档,这些特性使它受到众多开发者的青睐。 我们通过几个例子来看看 MySQL 与 MongoDB 的差异。 与 MySQL 数据库不同的是,MongoDB 不需要预先定义表和字段,这正是它灵活性的体现。MongoDB 可以拥有多个数据库,每个数据库可以拥有多个集合,每个集合可以存储多份文档,这种关系与 SQL 数据库中的“数据库、表、数据”相当。下图描述了 MongoDB 中数据库、集合和文档的关系: 数据库 fotoo 中有两个集合,它们分别是 playerbooks。每个集合中都包含了许多文档,例如集合 books 中关于书籍《红楼梦》的文档,集合 player 中关于球员 James 的文档。 在查询方面,一个简单的 MySQL 查询语句为 SELECT * FROM tablename,对应的 MongoDB 查询语句为 db.tablename.find()。在面对多步骤的查询条件时,MongoDB 更游刃有余。例如: “统计数据库 articscore 大于 70 且小于 90 的文档数量” 这样的需求,用 MongoDB 的聚合操作就可以轻松完成,对应示例如下:

1
2
3
4
\> db.artic.aggregate([
... {$match: {score: {$gt: 70, $lt: 90}}},
... {$group: {_id: null, number: {$sum: 1}}}
... ])

这个例子或许简单了些,在 MySQL 中我们可以用 countwhere 完成,但如果复杂度再提高四五个等级呢?例如在此基础上增加对某个字段的运算、替换、排序、分组计数、增删字段,用 MySQL 来实现就会很头疼,而 MongoDB 的聚合可以让我们轻松地完成这类复杂需求。

我如何确定我需要学习 MongoDB

MongoDB 是近些年涌现的几十种 NoSQL 中第一梯队的成员,另外一个为人熟知的是 Redis。你可能会有”我如何确定我需要学习 MongoDB 呢?“ 这样的疑问,面对这个问题,我们可以通过 MongoDB 的特点和应用场景着手。

  • MongoDB 适合存储结构确定或不确定的文档。例如爬虫爬取的信息常缺失字段的情况或字段参差不齐的情况;
  • 对数据库可用性要求较高的情况。MySQL 这类数据库要做到负载均衡、自动容灾和数据同步需要借助外部工具,而 MongoDB 的复制集可以让我们轻松完成这一系列的工作。相对接借助第三方工具来说,复制集的稳定性更高。
  • 分库分表是 WEB 开发中常用到的数据库优化手段,MySQL 的分库分表要考虑的问题非常多,例如字段冗余、数据组装跨节点分页、排序和数据迁移等,而 MongoDB 的分片可以让我们轻松完成“分库分表”的工作。MongoDB 的分片机制使我们不必将心思放在由“分库分表”带来的问题,而是专注于具体需求。
  • 同样的,MySQL 的权限控制、定义数据模型、数据库备份和恢复等功能在 MongoDB 上也有。
  • MongoDB 中支持地理位置的存储和查询,这意味着 MongoDB 可以用于共享单车、共享雨伞、汽车定位等业务中。

我们常用的关系型数据库无法满足 WEB2.0 时代的需求,在实际应用中暴露了很多难以克服的问题。NoSQL 的产生就是为了解决例如海量数据的存储弹性可伸缩灵活性等方面的挑战,所以作为一名合格的开发者,应该抽空学习 SQL 以外的数据库知识。

开发者应该掌握 MongoDB 的哪些知识

学习前,我们需要明白自身定位:专业 DBA 或者日常开发使用。MongoDB 有完善的培训体系和对应的认证考试,对于希望成为专业 DBA 的朋友我建议到 MongoDB 官方网站了解。而对于仅需要满足日常开发需求的朋友,我建议学习的内容如下:

  • MongoDB 在各个平台的安装方法
  • MongoDB 数据库和集合的基本操作
  • MongoDB 文档 CRUD 操作,包括能够丰富 CRUD 的投影和修饰符等
  • MongoDB 流式聚合操作,这能够在数据库层面轻松完成复杂数据的处理,而不是用编程语言来处理
  • MongoDB 的数据模型,虽然它可以存储不规则的文档,但有些情况下定义数据模型可以提高查询效率

当然,除了这些基本操作之外我们还可以学习更多的知识提高个人竞争力,这些知识是:

  • MongoDB 执行计划和索引,执行计划可以让我们清楚的了解到查询语句的效率,而索引则是优化查询效率的常用手段
  • MongoDB 的复制集,这是提高 MongoDB 可用性,保证数据服务不停机的最佳手段
  • MongoDB 的分片,分片能够在数据量变得庞大之后保证效率
  • MongoDB 的事物,如果你将 MongoDB 用于 WEB 网站,那么事物是你必须学习的知识
  • MongoDB 数据库备份和还原,有了复制集后,备份就显得不是那么重要了,但并不是没有这个需求。而且 MongoDB 的备份可以精细到文档,这就非常有意义了。

学习的选择和困境

有一定工作经验的开发者,大多数情况下都会选择自学。有些在网上搜索对应的文章,有些则直接翻阅官方文档。我推荐的方式是翻阅官方文档,在遇到难以理解的观点时通过搜索引擎查找网友分享的文章。 自学的优点很多,缺点也很明显。例如:

  • 断断续续的学习,难以保持专注导致知识吸收不好
  • 耗费时间很长,虽然知道应该学习哪些方面的知识,但文档并不是按你所想而规划的,所以翻阅文档要费很多功夫
  • 知识不成体系,东看看西看看,没有归纳容易忘记
  • 学习就需要记笔记,这又是一件很费时间的事情
  • 官方文档有些观点难以理解,卡在半路很难受
  • 零星学了一两个月,也不确定学会了没有,内心毫无把握

如果不自学,就得找一些成体系的课程来帮助自己快速进步,少走弯路。知识付费时代,在条件允许的情况下适当地投入也是很好的选择。但面对动辄几百块的视频课程,不少开发者还是感觉略有压力,毕竟我们搬砖的经济压力也非常大。培训班就更不用说了,很少有专业教授单个数据库知识的,而且费用比视频课程更贵。 考虑到这些问题,这里推荐韦世东的 GitChat 文章 《超高性价比的 MongoDB 零基础快速入门实战教程》,这也是一个收费教程,但它售价不到 10 块钱。文章作者韦世东是:图灵签约作者、电子工业出版社约稿作者,华为云认证云享专家、掘金社区优秀作者、GitChat 认证作者,开源项目 aiowebsocket 作者。所以在文章质量上,大家可以放心。 这篇文章的内容几乎囊括了上面我们提到的所有知识点,看完这篇仅 5 万词的文章,你将收获:

  • 文档的 CRUD 操作和 Cursor 对象
  • 掌握流式聚合操作,轻松面对任何数据处理需求
  • 了解 MongoDB 的查询效率和优化
  • 如何提高 MongoDB 的可用性
  • 如何应对数据服务故障
  • 理解 MongoDB 的访问控制
  • 学会用数据模型降低数据冗余,提高效率
  • 掌握 mongodump 数据备份与还原方法

这样就可以胜任日常开发中对数据库操作能力的要求了。这篇文章适合对 MongoDB 感兴趣的零基础开发者或者有一定基础,想要继续巩固和加深学习的开发者。文章篇幅很长,内容详尽,不乏优质配图,例如描述复制集节点关系的图: 描述节主点掉线,重新选举主节点的图 如果你觉得有学习 MongoDB 的需要,且这篇文章规划的内容是你想要的内容,那么请长按下方图片识别二维码,前往订阅文章吧!

Python

在做程序开发的时候,我们经常会用到一些测试数据,相信大多数同学是这么来造测试数据的:

1
2
3
4
5
6
7
8
test1
test01
test02
测试1
测试2
测试数据1
这是一段测试文本
这是一段很长很长很长的测试文本...

中枪的请举手。 不仅要自己手动敲这些测试数据,还敲的这么假。那有啥办法呢?难不成有什么东西能自动给我造点以假乱真的数据啊?你别说,还真有! 在 Python 中有个神库,叫做 Faker,它可以自动帮我们来生成各种各样的看起来很真的”假“数据,让我们来看看吧!

安装

首先让我们来看看这个库的安装方法,实际上装起来非常简单,使用 pip 安装即可,Python3 版本的安装命令如下:

1
pip3 install faker

安装好了之后,我们使用最简单的例子来生成几个假数据试试:

1
2
3
4
5
6
from faker import Faker

faker = Faker()
print('name:', faker.name())
print('address:', faker.address())
print('text:', faker.text())

首先我们从 faker 这个包里面导入一个 Faker 类,然后将其实例化为 faker 对象,依次调用它的 name、address、text 方法,看下运行效果:

1
2
3
4
5
6
name: Nicholas Wilson
address: 70561 Simmons Road Apt. 893
Lake Raymondville, HI 35240
text: Both begin bring federal space.
Official start idea specific. Able under young fire.
Who show line traditional easy people. Until economic lead event case. Technology college his director style.

看到这里给我们生成了看起来很真的英文姓名、地址、长文本。 但我们是中国人,我们肯定想要生成中文的吧,不用担心,这个库对非常多的语言都有支持,当然也包括中文了,具体的支持的语言列表可以见:https://faker.readthedocs.io/en/master/locales.html。 这里几个比较常见的语言代号列一下:

  • 简体中文:zh_CN
  • 繁体中文:zh_TW
  • 美国英文:en_US
  • 英国英文:en_GB
  • 德文:de_DE
  • 日文:ja_JP
  • 韩文:ko_KR
  • 法文:fr_FR

那么如果要生成中文,只需要在 Faker 类的第一个参数传入对应的语言代号即可,例如简体中文就传入 zh_CN,所以上面的代码改写如下:

1
2
3
4
5
6
from faker import Faker

faker = Faker('zh_CN')
print('name:', faker.name())
print('address:', faker.address())
print('text:', faker.text())

运行结果如下:

1
2
3
4
5
6
7
name: 何琳
address: 宁夏回族自治区六盘水县南溪北镇街f座 912311
text: 经营软件积分开始次数专业.美国留言一种管理人民解决两个.支持只有地方一切.
文化目前东西的是不过所以.系统觉得这种为什一下他们.时候以及这样继续是一状态威望.
网站密码情况.问题一点那个还是.其实过程详细.
中国历史环境电话规定.经验上海控制不要生活.朋友运行项目我们.
以后今天那些使用免费国家加入但是.内容简介空间次数最大一个.日期通过得到日本北京.

可以看到一段中文的姓名、地址、长文本便生成了。看起来地址是省份、地级市、县级市、街道是随机组合的,文本也是一些随机的词组合而成的,但其实这样已经比文章一开头列的测试数据强太多了。 上面的代码每次运行得到的结果都是不同的,因为生成的结果都是随机组合而成的。

Provider

接下来让我们详细看下 faker 可以都生成什么类型的数据,具体的可用 API 可以看 https://faker.readthedocs.io/en/master/locales/zh_CN.html,这里面列出来了可用的所有方法。 但打开之后可以发现,这里面多了一个 Provider 对象,那么这个 Provider 是怎么一回事呢? 实际上这个 faker 库在设计上,为了解耦,将 Provider 对象做成了 Faker 对象的”插件“。Faker 可以添加一个个 Provider 对象,Provider 对象为 Faker 对象提供了生成某项数据的核心实现。就相当于 Faker 对象是一个生成器,它的生成功能依赖于什么呢?依赖于 Provider,是 Provider 提供给了 Faker 对象生成某项数据的能力。 正是因为 Faker 对象内置了一些 Provider 对象,Faker 对象才可以生成刚才所要求的姓名、地址和文本。 那么这时候我们肯定就很好奇了,既然 Faker 对象有生成数据的能力,那么它一定内置了一些默认的 Provider 对象,下面我们来打印看一下:

1
2
3
4
from faker import Faker

faker = Faker('zh_CN')
print(faker.providers)

运行结果如下:

1
[<faker.providers.user_agent.Provider object at 0x10249de48>, <faker.providers.ssn.zh_CN.Provider object at 0x10249dc18>, <faker.providers.python.Provider object at 0x10249dd68>, <faker.providers.profile.Provider object at 0x10249dcc0>, <faker.providers.phone_number.zh_CN.Provider object at 0x10249dc88>, <faker.providers.person.zh_CN.Provider object at 0x10249de80>, <faker.providers.misc.Provider object at 0x10249df60>, <faker.providers.lorem.zh_CN.Provider object at 0x10249dc50>, <faker.providers.job.zh_CN.Provider object at 0x10249de10>, <faker.providers.isbn.Provider object at 0x10249c6d8>, <faker.providers.internet.zh_CN.Provider object at 0x10249c828>, <faker.providers.geo.en_US.Provider object at 0x102484748>, <faker.providers.file.Provider object at 0x102484828>, <faker.providers.date_time.en_US.Provider object at 0x1023789e8>, <faker.providers.currency.Provider object at 0x102484780>, <faker.providers.credit_card.Provider object at 0x1024845f8>, <faker.providers.company.zh_CN.Provider object at 0x102499ef0>, <faker.providers.color.en_US.Provider object at 0x1023532e8>, <faker.providers.barcode.Provider object at 0x101cb6d30>, <faker.providers.bank.en_GB.Provider object at 0x102378f98>, <faker.providers.automotive.en_US.Provider object at 0x1017a5c50>, <faker.providers.address.zh_CN.Provider object at 0x101787c18>]

还真不少,通过名字可以看到有 user_agent、phone_number、isbn、credit_card 等 Provider,其中具有语言差异化的 Provider 还单独区分了语言,比如 phone_number 代表电话号码,这个不同语言的不同,所以这里就又分了一层 zh_CN,作了语言的区分。 这样一来,通用的 Provider 就直接处在某个 Provider 类别的模块中,具有语言差异的 Provider 就又根据不同的语言进一步划分了模块,设计上非常科学,易扩展又不冗余。 知道了 Faker 具有这么多 Provider 之后,我们来看看刚才调用的 name、address 等方法又和 Provider 有什么关系呢? 我们将 name、address、text 等方法打印一下看看:

1
2
3
4
5
6
from faker import Faker

faker = Faker('zh_CN')
print('name:', faker.name)
print('address:', faker.address)
print('text:', faker.text)

注意这里没有调用,而是直接打印了这三个方法,这样可以直接输出方法的对象形式的描述,结果如下:

1
2
3
name: <bound method Provider.name of <faker.providers.person.zh_CN.Provider object at 0x10f6dea58>>
address: <bound method Provider.address of <faker.providers.address.zh_CN.Provider object at 0x10e9e6cf8>>
text: <bound method Provider.text of <faker.providers.lorem.zh_CN.Provider object at 0x10f6dfda0>>

恍然大悟,原来我们调用的方法就是 Faker 对象调用的 Provider 里面的对应方法,比如 name 就是 faker.providers.person.zhCN.Provider 里面的 name 方法,二者是一致的,我们扒一扒源码验证下,源码在:[[https://github.com/joke2k/faker/blob/master/faker/providers/person/__init](https://github.com/joke2k/faker/blob/master/faker/providers/person/__init_)_.py]([https://github.com/joke2k/faker/blob/master/faker/providers/person/__init__.py),果不其然,里面定义了](https://github.com/joke2k/faker/blob/master/faker/providers/person/__init__.py),果不其然,里面定义了) name 方法,然后 Faker 动态地将这个方法引入进来了,就可以使用了。

方法列举

既然有这么多 Provider,下面我们再详细地看看还有哪些常用的方法吧,下面进行一部分简单的梳理,参考来源文档地址为:https://faker.readthedocs.io/en/master/providers.html

Address

Address,用于生成一些和地址相关的数据,如地址、城市、邮政编码、街道等内容, 用法如下:

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
faker.address()
# '新疆维吾尔自治区杰县南湖武汉街D座 253105'
faker.building_number()
# 'B座'
faker.city()
# '璐县'
faker.city_name()
# '贵阳'
faker.city_suffix()
# '县'
faker.country()
# '阿拉斯加'
faker.country_code(representation="alpha-2")
# 'CR'
faker.district()
# '西峰'
faker.postcode()
# '726749'
faker.province()
# '福建省'
faker.street_address()
# '余路N座'
faker.street_name()
# '李路'
faker.street_suffix()
# '路'

Color

Color,用于生成和颜色相关的数据,如 HEX、RGB、RGBA 等格式的颜色,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.color_name()
# 'DarkKhaki'
faker.hex_color()
# '#97d14e'
faker.rgb_color()
# '107,179,51'
faker.rgb_css_color()
# 'rgb(20,46,70)'
faker.safe_color_name()
# 'navy'
faker.safe_hex_color()
# '#dd2200'

Company

Company,用于生成公司相关数据,如公司名、公司前缀、公司后缀等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
faker.bs()
# 'grow rich initiatives'
faker.catch_phrase()
# 'Self-enabling encompassing function'
faker.company()
# '恒聪百汇网络有限公司'
faker.company_prefix()
# '晖来计算机'
faker.company_suffix()
# '信息有限公司'

Credit Card

Credit Card,用于生成信用卡相关数据,如过期时间、银行卡号、安全码等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
faker.credit_card_expire(start="now", end="+10y", date_format="%m/%y")
# '08/20'
faker.credit_card_full(card_type=None)
# 'Mastercardn玉兰 范n5183689713096897 01/25nCVV: 012n'
faker.credit_card_number(card_type=None)
# '4009911097184929918'
faker.credit_card_provider(card_type=None)
# 'JCB 15 digit'
faker.credit_card_security_code(card_type=None)
# '259'

Date Time

Date Time,用于生成时间相关数据,如年份、月份、星期、出生日期等内容,可以返回 datetime 类型的数据,用法如下:

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
faker.am_pm()
# 'AM'
faker.century()
# 'X'
faker.date(pattern="%Y-%m-%d", end_datetime=None)
# '1997-06-16'
faker.date_between(start_date="-30y", end_date="today")
# datetime.date(2000, 8, 30)
faker.date_between_dates(date_start=None, date_end=None)
# datetime.date(2019, 7, 30)
faker.date_object(end_datetime=None)
# datetime.date(1978, 3, 12)
faker.date_of_birth(tzinfo=None, minimum_age=0, maximum_age=115)
# datetime.date(2012, 6, 3)
faker.date_this_century(before_today=True, after_today=False)
# datetime.date(2011, 6, 12)
faker.date_this_decade(before_today=True, after_today=False)
# datetime.date(2011, 8, 22)
faker.date_this_month(before_today=True, after_today=False)
# datetime.date(2019, 7, 25)
faker.date_this_year(before_today=True, after_today=False)
# datetime.date(2019, 7, 22)
faker.date_time(tzinfo=None, end_datetime=None)
# datetime.datetime(2018, 8, 11, 22, 3, 34)
faker.date_time_ad(tzinfo=None, end_datetime=None, start_datetime=None)
# datetime.datetime(1566, 8, 26, 16, 25, 30)
faker.date_time_between(start_date="-30y", end_date="now", tzinfo=None)
# datetime.datetime(2015, 1, 31, 4, 14, 10)
faker.date_time_between_dates(datetime_start=None, datetime_end=None, tzinfo=None)
# datetime.datetime(2019, 7, 30, 17, 51, 44)
faker.date_time_this_century(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2002, 9, 25, 23, 59, 49)
faker.date_time_this_decade(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2010, 5, 25, 20, 20, 52)
faker.date_time_this_month(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2019, 7, 19, 18, 4, 6)
faker.date_time_this_year(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2019, 3, 15, 11, 4, 18)
faker.day_of_month()
# '04'
faker.day_of_week()
# 'Monday'
faker.future_date(end_date="+30d", tzinfo=None)
# datetime.date(2019, 8, 12)
faker.future_datetime(end_date="+30d", tzinfo=None)
# datetime.datetime(2019, 8, 24, 2, 59, 4)
faker.iso8601(tzinfo=None, end_datetime=None)
# '1987-07-01T18:33:56'
faker.month()
# '11'
faker.month_name()
# 'August'
faker.past_date(start_date="-30d", tzinfo=None)
# datetime.date(2019, 7, 25)
faker.past_datetime(start_date="-30d", tzinfo=None)
# datetime.datetime(2019, 7, 18, 22, 46, 51)
faker.time(pattern="%H:%M:%S", end_datetime=None)
# '16:22:30'
faker.time_delta(end_datetime=None)
# datetime.timedelta(0)
faker.time_object(end_datetime=None)
# datetime.time(22, 12, 15)
faker.time_series(start_date="-30d", end_date="now", precision=None, distrib=None, tzinfo=None)
# <generator object Provider.time_series at 0x7fcbce0604f8>
faker.timezone()
# 'Indian/Comoro'
faker.unix_time(end_datetime=None, start_datetime=None)
# 1182857626
faker.year()
# '1970'

File

File,用于生成文件和文件路径相关的数据,包括文件扩展名、文件路径、MIME_TYPE、磁盘分区等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.file_extension(category=None)
# 'flac'
faker.file_name(category=None, extension=None)
# '然后.numbers'
faker.file_path(depth=1, category=None, extension=None)
# '/关系/科技.mov'
faker.mime_type(category=None)
# 'video/ogg'
faker.unix_device(prefix=None)
# '/dev/sdd'
faker.unix_partition(prefix=None)
# '/dev/xvds3'

Geo

Geo,用于生成和地理位置相关的数据,包括经纬度,时区等等信息,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.coordinate(center=None, radius=0.001)
# Decimal('-114.420686')
faker.latitude()
# Decimal('-9.772541')
faker.latlng()
# (Decimal('-27.0730915'), Decimal('-5.919460'))
faker.local_latlng(country_code="US", coords_only=False)
# ('41.47892', '-87.45476', 'Schererville', 'US', 'America/Chicago')
faker.location_on_land(coords_only=False)
# ('12.74482', '4.52514', 'Argungu', 'NG', 'Africa/Lagos')
faker.longitude()
# Decimal('40.885895')

Internet

Internet,用于生成和互联网相关的数据,包括随机电子邮箱、域名、IP 地址、URL、用户名、后缀名等内容,用法如下:

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
faker.ascii_company_email(*args, **kwargs)
# 'xuna@xiaqian.cn'
faker.ascii_email(*args, **kwargs)
# 'min59@60.cn'
faker.ascii_free_email(*args, **kwargs)
# 'min75@gmail.com'
faker.ascii_safe_email(*args, **kwargs)
# 'cliu@example.com'
faker.company_email(*args, **kwargs)
# 'ilong@99.cn'
faker.domain_name(levels=1)
# 'xiulan.cn'
faker.domain_word(*args, **kwargs)
# 'luo'
faker.email(*args, **kwargs)
# 'maoxiulan@hotmail.com'
faker.free_email(*args, **kwargs)
# 'yanshen@gmail.com'
faker.free_email_domain(*args, **kwargs)
# 'yahoo.com'
faker.hostname(*args, **kwargs)
# 'lt-18.pan.cn'
faker.image_url(width=None, height=None)
# 'https://placekitten.com/51/201'
faker.ipv4(network=False, address_class=None, private=None)
# '192.233.68.5'
faker.ipv4_network_class()
# 'a'
faker.ipv4_private(network=False, address_class=None)
# '10.9.97.93'
faker.ipv4_public(network=False, address_class=None)
# '192.51.22.7'
faker.ipv6(network=False)
# 'de57:9c6f:a38c:9864:10ec:6442:775d:5f02'
faker.mac_address()
# '99:80:5c:ab:8c:a9'
faker.safe_email(*args, **kwargs)
# 'tangjuan@example.net'
faker.slug(*args, **kwargs)
# ''
faker.tld()
# 'cn'
faker.uri()
# 'http://fangfan.org/app/tag/post/'
faker.uri_extension()
# '.php'
faker.uri_page()
# 'about'
faker.uri_path(deep=None)
# 'app'
faker.url(schemes=None)
# 'http://mingli.cn/'
faker.user_name(*args, **kwargs)
# 'jie54'

Job

Job,用于生成和职业相关的数据,用法如下:

1
2
faker.job()
# '烫工'

Lorem

Lorem,用于生成一些假文字数据,包括句子、自然段、长文本、关键词等,另外可以传入不同的参数来控制生成的长度,用法如下:

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
faker.paragraph(nb_sentences=3, variable_nb_sentences=True, ext_word_list=None)
# '包括的是报告那些一点.图片地址基本全部.'
faker.paragraphs(nb=3, ext_word_list=None)
# [ '计划规定这样所以组织商品其中.参加成为不同发表地区.精华科技谢谢大家需要.一下手机上海中文工程.',
# '非常相关是一就是一个一种文章发生.增加那些以及之后以下你的.',
# '学生应该出来分析增加关系组织.评论来源朋友注册应该需要单位.感觉最后无法发现选择人民.']
faker.sentence(nb_words=6, variable_nb_words=True, ext_word_list=None)
# '介绍结果自己解决处理.'
faker.sentences(nb=3, ext_word_list=None)
# ['查看其实一次学习登录浏览是一他们.', '而且资源的人事情.', '科技价格免费大学教育.']
faker.text(max_nb_chars=200, ext_word_list=None)
# ('只是当前国内中文所以.威望系统在线虽然.n'
# '图片人民非常合作这种谢谢更新.名称详细直接社会一直首页完全.n'
# '重要更多只要市场.必须只是学生音乐.系统美国类别这些一切环境.n'
# '但是的话人民美国关于.n'
# '情况专业国际看到研究.音乐环境市场搜索发现.n'
# '工具还是到了今天位置人民.留言作者品牌工程项目必须.上海精华现在我们新闻应该关系.n'
# '更新经济能力全部资源如果.手机能够登录国内.')
faker.texts(nb_texts=3, max_nb_chars=200, ext_word_list=None)
# [ '成功可能推荐你的行业.地区而且推荐.n'
# '网络不断是一主要必须.开始安全服务.n'
# '应该网上通过以后通过大学.管理要求有关国际阅读当前.为了应该结果点击公司开始怎么.n'
# '成功一次最大生产网站.这种加入她的地址有限.n'
# '根据新闻汽车起来非常主题显示必须.有些建设来自作者电话支持.n'
# '只是资源还是由于经济事情喜欢.为什中文大小得到服务.网络密码是否免费参加一次社区欢迎.',
# '部门活动技术.商品影响发生行业密码完成.就是部门结果资料学习当然.或者帮助城市要求首页市场教育你们.n'
# '专业完全分析处理城市大学什么.n'
# '文件非常国际全部起来积分公司.资料的是电影没有.这是本站需要.n'
# '合作重要没有现在市场开发空间.您的会员推荐成功教育进行中国.n'
# '文件不是如果评论.因为经验设备规定.n'
# '加入一起影响网上大家运行在线如果.工程企业这种以后.',
# '空间市场出现必须基本电话.显示一个标准其他设计作品.工程不断新闻问题更多更新这么.n'
# '一起简介网上内容不会.任何知道各种两个.类别事情经营那么投资市场.n'
# '那些使用介绍公司朋友人民你们浏览.应该表示一点一般说明主要谢谢.电话回复起来经验一个来源加入.n'
# '地区法律其他表示虽然.参加社会喜欢有限论坛一般发布.类别目前文化可以.n'
# '报告质量工作主要.企业发布完全.得到名称作者等级两个论坛只要电话.']
faker.word(ext_word_list=None)
# '注意'
faker.words(nb=3, ext_word_list=None, unique=False)
# ['责任', '组织', '以后']

在这里每个方法的参数是不同的,具体的参数解释可以见源代码每个方法的注释:https://github.com/joke2k/faker/blob/master/faker/providers/lorem/init.py

Misc

Misc,用于生成生成一些混淆数据,比如密码、sha1、sha256、md5 等加密后的内容,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
faker.boolean(chance_of_getting_true=50)
# True
faker.md5(raw_output=False)
# '3166fa26ffd3f2a33e020dfe11191ac6'
faker.null_boolean()
# False
faker.password(length=10, special_chars=True, digits=True, upper_case=True, lower_case=True)
# 'W7Ln8La@%O'
faker.sha1(raw_output=False)
# 'c8301a2a79445439ee5287f38053e4b3a05eac79'
faker.sha256(raw_output=False)
# '1e909d331e20cf241aaa2da894deae5a3a75e5cdc35c053422d9b8e7ccfa0402'
faker.uuid4(cast_to=<class 'str'>)
# '6e6fe387-6877-48d9-94ea-4263c4c71aa5'

Person

Person,用于生成和人名相关的数据,包括姓氏、名字、全名、英文名等内容,还能区分男女名字,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
faker.first_name()
# '颖'
faker.first_name_female()
# '芳'
faker.first_name_male()
# '利'
faker.first_romanized_name()
# 'Jing'
faker.last_name()
# '温'
faker.last_name_female()
# '寇'
faker.last_name_male()
# '陈'
faker.last_romanized_name()
# 'Lei'
faker.name()
# '黄明'
faker.name_female()
# '张凯'
faker.name_male()
# '黄鹏'

User-Agent

User-Agent,用于生成和浏览器 User-Agent 相关的内容,可以定制各种浏览器,还可以传入版本信息来控制生成的内容,用法如下:

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
faker.chrome(version_from=13, version_to=63, build_from=800, build_to=899)
# ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/5332 (KHTML, like Gecko) '
# 'Chrome/40.0.837.0 Safari/5332')
faker.firefox()
# ('Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_8_9; rv:1.9.4.20) '
# 'Gecko/2019-05-02 05:58:44 Firefox/3.6.19')
faker.internet_explorer()
# 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/3.0)'
faker.linux_platform_token()
# 'X11; Linux i686'
faker.linux_processor()
# 'x86_64'
faker.mac_platform_token()
# 'Macintosh; U; PPC Mac OS X 10_12_5'
faker.mac_processor()
# 'U; Intel'
faker.opera()
# 'Opera/9.77.(Windows NT 4.0; vi-VN) Presto/2.9.182 Version/11.00'
faker.safari()
# ('Mozilla/5.0 (Macintosh; PPC Mac OS X 10_7_1 rv:5.0; or-IN) '
# 'AppleWebKit/535.9.4 (KHTML, like Gecko) Version/5.0.2 Safari/535.9.4')
faker.user_agent()
# 'Opera/8.69.(X11; Linux i686; ml-IN) Presto/2.9.170 Version/11.00'
faker.windows_platform_token()
# 'Windows NT 6.1'

以上仅仅列了一部分,还有更多的功能大家可以查看官方文档的内容,链接为:https://faker.readthedocs.io/en/master/locales/zh_CN.html

其他 Provider

另外还有一些社区贡献的 Provider,如 WiFi、微服务相关的,大家可以查看文档的说明,另外需要额外安装这些扩展包并自行添加 Provider,文档见:https://faker.readthedocs.io/en/master/communityproviders.html。 添加 Provider 需要调用 add_provider 方法,用法示例如下:

1
2
3
4
5
6
from faker import Faker
from faker.providers import internet

faker = Faker()
faker.add_provider(internet)
print(faker.ipv4_private())

还有更多的内容大家可以参考官方文档,链接:https://faker.readthedocs.io/

Python

实例引入

我们知道 Python 是一种动态语言,在声明一个变量时我们不需要显式地声明它的类型,例如下面的例子:

1
2
a = 2
print('1 + a =', 1 + a)

运行结果:

1
1 + a = 3

这里我们首先声明了一个变量 a,并将其赋值为了 2,然后将最后的结果打印出来,程序输出来了正确的结果。但在这个过程中,我们没有声明它到底是什么类型。 但如果这时候我们将 a 变成一个字符串类型,结果会是怎样的呢?改写如下:

1
2
a = '2'
print('1 + a =', 1 + a)

运行结果:

1
TypeError: unsupported operand type(s) for +: 'int' and 'str'

直接报错了,错误原因是我们进行了字符串类型的变量和数值类型变量的加和,两种数据类型不同,是无法进行相加的。 如果我们将上面的语句改写成一个方法定义:

1
2
def add(a):
return a + 1

这里定义了一个方法,传入一个参数,然后将其加 1 并返回。 如果这时候如果用下面的方式调用,传入的参数是一个数值类型:

1
add(2)

则可以正常输出结果 3。但如果我们传入的参数并不是我们期望的类型,比如传入一个字符类型,那么就会同样报刚才类似的错误。 但又由于 Python 的特性,很多情况下我们并不用去声明它的类型,因此从方法定义上面来看,我们实际上是不知道一个方法的参数到底应该传入什么类型的。 这样其实就造成了很多不方便的地方,在某些情况下一些复杂的方法,如果不借助于一些额外的说明,我们是不知道参数到底是什么类型的。 因此,Python 中的类型注解就显得比较重要了。

类型注解

在 Python 3.5 中,Python PEP 484 引入了类型注解(type hints),在 Python 3.6 中,PEP 526 又进一步引入了变量注解(Variable Annotations),所以上面的代码我们改写成如下写法:

1
2
3
4
5
a: int = 2
print('5 + a =', 5 + a)

def add(a: int) -> int:
return a + 1

具体的语法是可以归纳为两点:

  • 在声明变量时,变量的后面可以加一个冒号,后面再写上变量的类型,如 int、list 等等。
  • 在声明方法返回值的时候,可以在方法的后面加一个箭头,后面加上返回值的类型,如 int、list 等等。

PEP 8 中,具体的格式是这样规定的:

  • 在声明变量类型时,变量后方紧跟一个冒号,冒号后面跟一个空格,再跟上变量的类型。
  • 在声明方法返回值的时候,箭头左边是方法定义,箭头右边是返回值的类型,箭头左右两边都要留有空格。

有了这样的声明,以后我们如果看到这个方法的定义,我们就知道传入的参数类型了,如调用 add 方法的时候,我们就知道传入的需要是一个数值类型的变量,而不是字符串类型,非常直观。 但值得注意的是,这种类型和变量注解实际上只是一种类型提示,对运行实际上是没有影响的,比如调用 add 方法的时候,我们传入的不是 int 类型,而是一个 float 类型,它也不会报错,也不会对参数进行类型转换,如:

1
add(1.5)

我们传入的是一个 float 类型的数值 1.5,看下运行结果:

1
2.5

可以看到,运行结果正常输出,而且 1.5 并没有经过强制类型转换变成 1,否则结果会变成 2。 因此,类型和变量注解只是提供了一种提示,对于运行实际上没有任何影响。 不过有了类型注解,一些 IDE 是可以识别出来并提示的,比如 PyCharm 就可以识别出来在调用某个方法的时候参数类型不一致,会提示 WARNING。 比如上面的调用,如果在 PyCharm 中,就会有如下提示内容:

1
2
Expected type 'int', got 'float' instead
This inspection detects type errors in function call expressions. Due to dynamic dispatch and duck typing, this is possible in a limited but useful number of cases. Types of function parameters can be specified in docstrings or in Python 3 function annotations.

另外也有一些库是支持类型检查的,比如 mypy,安装之后,利用 mypy 即可检查出 Python 脚本中不符合类型注解的调用情况。 上面只是用一个简单的 int 类型做了实例,下面我们再看下一些相对复杂的数据结构,例如列表、元组、字典等类型怎么样来声明。 可想而知了,列表用 list 表示,元组用 tuple 表示,字典用 dict 来表示,那么很自然地,在声明的时候我们就很自然地写成这样了:

1
2
3
names: list = ['Germey', 'Guido']
version: tuple = (3, 7, 4)
operations: dict = {'show': False, 'sort': True}

这么看上去没有问题,确实声明为了对应的类型,但实际上并不能反映整个列表、元组的结构,比如我们只通过类型注解是不知道 names 里面的元素是什么类型的,只知道 names 是一个列表 list 类型,实际上里面都是字符串 str 类型。我们也不知道 version 这个元组的每一个元素是什么类型的,实际上是 int 类型。但这些信息我们都无从得知。因此说,仅仅凭借 list、tuple 这样的声明是非常“弱”的,我们需要一种更强的类型声明。 这时候我们就需要借助于 typing 模块了,它提供了非常“强“的类型支持,比如 List[str]Tuple[int, int, int] 则可以表示由 str 类型的元素组成的列表和由 int 类型的元素组成的长度为 3 的元组。所以上文的声明写法可以改写成下面的样子:

1
2
3
4
5
from typing import List, Tuple, Dict

names: List[str] = ['Germey', 'Guido']
version: Tuple[int, int, int] = (3, 7, 4)
operations: Dict[str, bool] = {'show': False, 'sort': True}

这样一来,变量的类型便可以非常直观地体现出来了。 目前 typing 模块也已经被加入到 Python 标准库中,不需要安装第三方模块,我们就可以直接使用了。

typing

下面我们再来详细看下 typing 模块的具体用法,这里主要会介绍一些常用的注解类型,如 List、Tuple、Dict、Sequence 等等,了解了每个类型的具体使用方法,我们可以得心应手的对任何变量进行声明了。 在引入的时候就直接通过 typing 模块引入就好了,例如:

1
from typing import List, Tuple

List

List、列表,是 list 的泛型,基本等同于 list,其后紧跟一个方括号,里面代表了构成这个列表的元素类型,如由数字构成的列表可以声明为:

1
var: List[int or float] = [2, 3.5]

另外还可以嵌套声明都是可以的:

1
var: List[List[int]] = [[1, 2], [2, 3]]

Tuple、NamedTuple

Tuple、元组,是 tuple 的泛型,其后紧跟一个方括号,方括号中按照顺序声明了构成本元组的元素类型,如 Tuple[X, Y] 代表了构成元组的第一个元素是 X 类型,第二个元素是 Y 类型。 比如想声明一个元组,分别代表姓名、年龄、身高,三个数据类型分别为 str、int、float,那么可以这么声明:

1
person: Tuple[str, int, float] = ('Mike', 22, 1.75)

同样地也可以使用类型嵌套。 NamedTuple,是 collections.namedtuple 的泛型,实际上就和 namedtuple 用法完全一致,但个人其实并不推荐使用 NamedTuple,推荐使用 attrs 这个库来声明一些具有表征意义的类。

Dict、Mapping、MutableMapping

Dict、字典,是 dict 的泛型;Mapping,映射,是 collections.abc.Mapping 的泛型。根据官方文档,Dict 推荐用于注解返回类型,Mapping 推荐用于注解参数。它们的使用方法都是一样的,其后跟一个中括号,中括号内分别声明键名、键值的类型,如:

1
2
def size(rect: Mapping[str, int]) -> Dict[str, int]:
return {'width': rect['width'] + 100, 'height': rect['width'] + 100}

这里将 Dict 用作了返回值类型注解,将 Mapping 用作了参数类型注解。 MutableMapping 则是 Mapping 对象的子类,在很多库中也经常用 MutableMapping 来代替 Mapping。

Set、AbstractSet

Set、集合,是 set 的泛型;AbstractSet、是 collections.abc.Set 的泛型。根据官方文档,Set 推荐用于注解返回类型,AbstractSet 用于注解参数。它们的使用方法都是一样的,其后跟一个中括号,里面声明集合中元素的类型,如:

1
2
def describe(s: AbstractSet[int]) -> Set[int]:
return set(s)

这里将 Set 用作了返回值类型注解,将 AbstractSet 用作了参数类型注解。

Sequence

Sequence,是 collections.abc.Sequence 的泛型,在某些情况下,我们可能并不需要严格区分一个变量或参数到底是列表 list 类型还是元组 tuple 类型,我们可以使用一个更为泛化的类型,叫做 Sequence,其用法类似于 List,如:

1
2
def square(elements: Sequence[float]) -> List[float]:
return [x ** 2 for x in elements]

NoReturn

NoReturn,当一个方法没有返回结果时,为了注解它的返回类型,我们可以将其注解为 NoReturn,例如:

1
2
def hello() -> NoReturn:
print('hello')

Any

Any,是一种特殊的类型,它可以代表所有类型,静态类型检查器的所有类型都与 Any 类型兼容,所有的无参数类型注解和返回类型注解的都会默认使用 Any 类型,也就是说,下面两个方法的声明是完全等价的:

1
2
3
4
5
def add(a):
return a + 1

def add(a: Any) -> Any:
return a + 1

原理类似于 object,所有的类型都是 object 的子类。但如果我们将参数声明为 object 类型,静态参数类型检查便会抛出错误,而 Any 则不会,具体可以参考官方文档的说明:https://docs.python.org/zh-cn/3/library/typing.html?highlight=typing#the-any-type

TypeVar

TypeVar,我们可以借助它来自定义兼容特定类型的变量,比如有的变量声明为 int、float、None 都是符合要求的,实际就是代表任意的数字或者空内容都可以,其他的类型则不可以,比如列表 list、字典 dict 等等,像这样的情况,我们可以使用 TypeVar 来表示。 例如一个人的身高,便可以使用 int 或 float 或 None 来表示,但不能用 dict 来表示,所以可以这么声明:

1
2
3
4
height = 1.75
Height = TypeVar('Height', int, float, None)
def get_height() -> Height:
return height

这里我们使用 TypeVar 声明了一个 Height 类型,然后将其用于注解方法的返回结果。

NewType

NewType,我们可以借助于它来声明一些具有特殊含义的类型,例如像 Tuple 的例子一样,我们需要将它表示为 Person,即一个人的含义,但但从表面上声明为 Tuple 并不直观,所以我们可以使用 NewType 为其声明一个类型,如:

1
2
Person = NewType('Person', Tuple[str, int, float])
person = Person(('Mike', 22, 1.75))

这里实际上 person 就是一个 tuple 类型,我们可以对其像 tuple 一样正常操作。

Callable

Callable,可调用类型,它通常用来注解一个方法,比如我们刚才声明了一个 add 方法,它就是一个 Callable 类型:

1
print(Callable, type(add), isinstance(add, Callable))

运行结果:

1
typing.Callable <class 'function'> True

在这里虽然二者 add 利用 type 方法得到的结果是 function,但实际上利用 isinstance 方法判断确实是 True。 Callable 在声明的时候需要使用 Callable[[Arg1Type, Arg2Type, ...], ReturnType] 这样的类型注解,将参数类型和返回值类型都要注解出来,例如:

1
2
3
4
5
def date(year: int, month: int, day: int) -> str:
return f'{year}-{month}-{day}'

def get_date_fn() -> Callable[[int, int, int], str]:
return date

这里首先声明了一个方法 date,接收三个 int 参数,返回一个 str 结果,get_date_fn 方法返回了这个方法本身,它的返回值类型就可以标记为 Callable,中括号内分别标记了返回的方法的参数类型和返回值类型。

Union

Union,联合类型,Union[X, Y] 代表要么是 X 类型,要么是 Y 类型。 联合类型的联合类型等价于展平后的类型:

1
Union[Union[int, str], float] == Union[int, str, float]

仅有一个参数的联合类型会坍缩成参数自身,比如:

1
Union[int] == int

多余的参数会被跳过,比如:

1
Union[int, str, int] == Union[int, str]

在比较联合类型的时候,参数顺序会被忽略,比如:

1
Union[int, str] == Union[str, int]

这个在一些方法参数声明的时候比较有用,比如一个方法,要么传一个字符串表示的方法名,要么直接把方法传过来:

1
2
3
4
5
6
def process(fn: Union[str, Callable]):
if isinstance(fn, str):
# str2fn and process
pass
elif isinstance(fn, Callable):
fn()

这样的声明在一些类库方法定义的时候十分常见。

Optional

Optional,意思是说这个参数可以为空或已经声明的类型,即 Optional[X] 等价于 Union[X, None]。 但值得注意的是,这个并不等价于可选参数,当它作为参数类型注解的时候,不代表这个参数可以不传递了,而是说这个参数可以传为 None。 如当一个方法执行结果,如果执行完毕就不返回错误信息, 如果发生问题就返回错误信息,则可以这么声明:

1
2
def judge(result: bool) -> Optional[str]:
if result: return 'Error Occurred'

Generator

如果想代表一个生成器类型,可以使用 Generator,它的声明比较特殊,其后的中括号紧跟着三个参数,分别代表 YieldType、SendType、ReturnType,如:

1
2
3
4
5
def echo_round() -> Generator[int, float, str]:
sent = yield 0
while sent >= 0:
sent = yield round(sent)
return 'Done'

在这里 yield 关键字后面紧跟的变量的类型就是 YieldType,yield 返回的结果的类型就是 SendType,最后生成器 return 的内容就是 ReturnType。 当然很多情况下,生成器往往只需要 yield 内容就够了,我们是不需要 SendType 和 ReturnType 的,可以将其设置为空,如:

1
2
3
4
def infinite_stream(start: int) -> Generator[int, None, None]:
while True:
yield start
start += 1

案例实战

接下来让我们看一个实际的项目,看看经常用到的类型一般是怎么使用的。 这里我们看的库是 requests-html,是由 Kenneth Reitz 所开发的,其 GitHub 地址为:https://github.com/psf/requests-html,下面我们主要看看它的源代码中一些类型是如何声明的。 这个库的源代码其实就一个文件,那就是 https://github.com/psf/requests-html/blob/master/requests_html.py,我们看一下它里面的一些 typing 的定义和方法定义。 首先 Typing 的定义部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from typing import Set, Union, List, MutableMapping, Optional

_Find = Union[List['Element'], 'Element']
_XPath = Union[List[str], List['Element'], str, 'Element']
_Result = Union[List['Result'], 'Result']
_HTML = Union[str, bytes]
_BaseHTML = str
_UserAgent = str
_DefaultEncoding = str
_URL = str
_RawHTML = bytes
_Encoding = str
_LXML = HtmlElement
_Text = str
_Search = Result
_Containing = Union[str, List[str]]
_Links = Set[str]
_Attrs = MutableMapping
_Next = Union['HTML', List[str]]
_NextSymbol = List[str]

这里可以看到主要用到的类型有 Set、Union、List、MutableMapping、Optional,这些在上文都已经做了解释,另外这里使用了多次 Union 来声明了一些新的类型,如 _Find 则要么是是 Element 对象的列表,要么是单个 Element 对象,_Result 则要么是 Result 对象的列表,要么是单个 Result 对象。另外 _Attrs 其实就是字典类型,这里用 MutableMapping 来表示了,没有用 Dict,也没有用 Mapping。 接下来再看一个 Element 类的声明:

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
class Element(BaseParser):
"""An element of HTML.
:param element: The element from which to base the parsing upon.
:param url: The URL from which the HTML originated, used for ``absolute_links``.
:param default_encoding: Which encoding to default to.
"""

__slots__ = [
'element', 'url', 'skip_anchors', 'default_encoding', '_encoding',
'_html', '_lxml', '_pq', '_attrs', 'session'
]

def __init__(self, *, element, url: _URL, default_encoding: _DefaultEncoding = None) -> None:
super(Element, self).__init__(element=element, url=url, default_encoding=default_encoding)
self.element = element
self.tag = element.tag
self.lineno = element.sourceline
self._attrs = None

def __repr__(self) -> str:
attrs = ['{}={}'.format(attr, repr(self.attrs[attr])) for attr in self.attrs]
return "<Element {} {}>".format(repr(self.element.tag), ' '.join(attrs))

@property
def attrs(self) -> _Attrs:
"""Returns a dictionary of the attributes of the :class:`Element <Element>`
(`learn more <https://www.w3schools.com/tags/ref_attributes.asp>`_).
"""
if self._attrs is None:
self._attrs = {k: v for k, v in self.element.items()}

# Split class and rel up, as there are ussually many of them:
for attr in ['class', 'rel']:
if attr in self._attrs:
self._attrs[attr] = tuple(self._attrs[attr].split())

return self._attrs

这里 __init__ 方法接收非常多的参数,同时使用 _URL_DefaultEncoding 进行了参数类型注解,另外 attrs 方法使用了 _Attrs 进行了返回结果类型注解。 整体看下来,每个参数的类型、返回值都进行了清晰地注解,代码可读性大大提高。 以上便是类型注解和 typing 模块的详细介绍。

Python

相对免费代理来说,付费代理的稳定性相对更高一点,本节介绍一下爬虫付费代理的相关使用过程。

1. 付费代理分类

在这里将付费代理分为两类:

  • 提供接口获取海量代理,按天或者按量付费,如讯代理
  • 搭建了代理隧道,直接设置固定域名代理,如阿布云

本节讲解一下这两种代理的使用方法,分别以两家代表性的代理网站为例进行讲解。

2. 讯代理

讯代理个人使用过代理有效率还是蛮高的,此处非广告,其官网为:http://www.xdaili.cn/,如图 9-5 所示: 图 9-5 讯代理官网 有多种类别的代理可供选购,摘抄其官网的各类别代理介绍如下:

  • 优质代理: 适合对代理 IP 需求量非常大,但能接受代理有效时长较短(10~30 分钟),小部分不稳定的客户
  • 独享动态: 适合对代理 IP 稳定性要求非常高,且可以自主控制的客户,支持地区筛选。
  • 独享秒切: 适合对代理 IP 稳定性要求非常高,且可以自主控制的客户,快速获取 IP,地区随机分配
  • 动态混拨: 适合对代理 IP 需求量大,代理 IP 使用时效短(3 分钟),切换快的客户
  • 优质定制: 如果优质代理的套餐不能满足您的需求,请使用定制服务

一般选择第一类别优质代理即可,代理量比较大,但是代理的稳定性没那么高,有一些代理也是不可用的,所以这种代理的使用方式就需要借助于上一节所说的代理池,我们自己再做一次筛选,确保代理可用。 可以购买一天的试一下效果,购买之后会提供一个 API 来提取代理,如图 9-6 所示: 图 9-6 提取页面 比如在这里我的提取 API 为:http://www.xdaili.cn/ipagent/greatRecharge/getGreatIp?spiderId=da289b78fec24f19b392e04106253f2a&orderno=YZ20177140586mTTnd7&returnType=2&count=20,可能已过期,在此仅做演示。 在这里指定了提取数量为 20,提取格式为 Json,直接访问链接即可提取代理,结果如图 9-7 所示: 图 9-7 提取结果 接下来我们要做的就是解析这个 Json,然后将其放入我们的代理池中。 当然如果信赖讯代理的话也可以不做代理池筛选,直接使用,不过我个人还是推荐再使用代理池筛选一遍,提高可用几率。 根据上一节代理池的写法,我们只需要在 Crawler 中再加入一个 crawl 开头的方法即可。 方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
def crawl_xdaili(self):
"""
获取讯代理
:return: 代理
"""
url = 'http://www.xdaili.cn/ipagent/greatRecharge/getGreatIp?spiderId=da289b78fec24f19b392e04106253f2a&orderno=YZ20177140586mTTnd7&returnType=2&count=20'
html = get_page(url)
if html:
result = json.loads(html)
proxies = result.get('RESULT')
for proxy in proxies:
yield proxy.get('ip') + ':' + proxy.get('port')

这样我们就在代理池中接入了讯代理,获取讯代理的结果之后,解析 Json,返回代理即可。 这样代理池运行之后就会抓取和检测该接口返回的代理了,如果可用,那么就会被设为 100,通过代理池接口即可获取到。 以上以讯代理为例说明了此种批量提取代理的使用方法。

3. 阿布云代理

阿布云代理提供了代理隧道,代理速度快而且非常稳定,此处依然非广告,其官网为:https://www.abuyun.com/,如图 9-8 所示: 图 9-8 阿布云官网 阿布云的代理主要分为两种,专业版和动态版,另外还有定制版,摘抄官网的介绍如下:

  • 专业版,多个请求锁定一个代理 IP,海量 IP 资源池需求,近 300 个区域全覆盖,代理 IP 可连续使用 1 分钟,适用于请求 IP 连续型业务
  • 动态版,每个请求一个随机代理 IP,海量 IP 资源池需求,近 300 个区域全覆盖,适用于爬虫类业务
  • 定制版,灵活按照需求定制,定制 IP 区域,定制 IP 使用时长,定制 IP 每秒请求数

关于专业版和动态版的更多介绍可以查看官网:https://www.abuyun.com/http-proxy/dyn-intro.html。 对于爬虫来说,推荐使用动态版,购买之后可以在后台看到代理隧道的用户名和密码,如图 9-9 所示: 图 9-9 阿布云代理后台 可以发现整个代理的连接域名为 proxy.abuyun.com,端口为 9020,均是固定的,但是使用之后每次的 IP 都会更改,这其实就是利用了代理隧道实现。 其官网原理介绍如下:

  • 云代理通过代理隧道的形式提供高匿名代理服务,支持 HTTP/HTTPS 协议。
  • 云代理在云端维护一个全局 IP 池供代理隧道使用,池中的 IP 会不间断更新,以保证同一时刻 IP 池中有几十到几百个可用代理 IP。
  • 需要注意的是代理 IP 池中有部分 IP 可能会在当天重复出现多次。
  • 动态版 HTTP 代理隧道会为每个请求从 IP 池中挑选一个随机代理 IP。
  • 无须切换代理 IP,每一个请求一个随机代理 IP。
  • HTTP 代理隧道有并发请求限制,默认每秒只允许 5 个请求。如果需要更多请求数,请额外购买。

注意默认套餐的并发请求是 5 个,如果需要更多需要另外购买。 使用的教程在官网也有,链接为:https://www.abuyun.com/http-proxy/dyn-manual-python.html,提供了 Requests、Urllib、Scrapy 的接入方式。 以 Requests 为例,接入示例如下:

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
import requests

url = 'http://httpbin.org/get'

# 代理服务器
proxy_host = 'proxy.abuyun.com'
proxy_port = '9020'

# 代理隧道验证信息
proxy_user = 'H01234567890123D'
proxy_pass = '0123456789012345'

proxy_meta = 'http://%(user)s:%(pass)s@%(host)s:%(port)s' % {
'host': proxy_host,
'port': proxy_port,
'user': proxy_user,
'pass': proxy_pass,
}
proxies = {
'http': proxy_meta,
'https': proxy_meta,
}
response = requests.get(url, proxies=proxies)
print(response.status_code)
print(response.text)

在这里其实就是使用了代理认证,在前面我们也提到过类似的设置方法,运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
200
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.18.1"
},
"origin": "60.207.237.111",
"url": "http://httpbin.org/get"
}

输出结果的 origin 即为代理 IP 的实际地址,可以多次运行测试,可以发现每次请求 origin 都会在变化,这就是动态版代理的效果。 这种效果其实跟我们之前的代理池的随机代理效果类似,都是随机取出了一个当前可用代理。 但是此服务相比于维护代理池来说,使用更加方便,配置简单,省时省力,在价格可以接受的情况下,个人推荐此种代理。

4. 结语

以上便是付费代理的相关使用方法,稳定性相比免费代理更高,可以自行选购合适的代理。

Python

我们在上一节了解了代理的设置方法,利用代理我们可以解决目标网站封 IP 的问题,而在网上又有大量公开的免费代理,其中有一部分可以拿来使用,或者我们也可以购买付费的代理 IP,价格也不贵。但是不论是免费的还是付费的,都不能保证它们每一个都是可用的,毕竟可能其他人也可能在用此 IP 爬取同样的目标站点而被封禁,或者代理服务器突然出故障或网络繁忙。一旦我们选用了一个不可用的代理,势必会影响我们爬虫的工作效率。 所以说,在用代理时,我们需要提前做一下筛选,将不可用的代理剔除掉,保留下可用代理,接下来在获取代理时从可用代理里面取出直接使用就好了。 所以本节我们来搭建一个高效易用的代理池。

1. 准备工作

要实现代理池我们首先需要成功安装好了 Redis 数据库并启动服务,另外还需要安装 Aiohttp、Requests、RedisPy、PyQuery、Flask 库,如果没有安装可以参考第一章的安装说明。

2. 代理池的目标

代理池要做到易用、高效,我们一般需要做到下面的几个目标:

  • 基本模块分为四块,获取模块、存储模块、检查模块、接口模块。
  • 获取模块需要定时去各大代理网站抓取代理,代理可以是免费公开代理也可以是付费代理,代理的形式都是 IP 加端口,尽量从不同来源获取,尽量抓取高匿代理,抓取完之后将可用代理保存到数据库中。
  • 存储模块负责存储抓取下来的代理。首先我们需要保证代理不重复,另外我们还需要标识代理的可用情况,而且需要动态实时处理每个代理,所以说,一种比较高效和方便的存储方式就是使用 Redis 的 Sorted Set,也就是有序集合。
  • 检测模块需要定时将数据库中的代理进行检测,在这里我们需要设置一个检测链接,最好是爬取哪个网站就检测哪个网站,这样更加有针对性,如果要做一个通用型的代理,那可以设置百度等链接来检测。另外我们需要标识每一个代理的状态,如设置分数标识,100 分代表可用,分数越少代表越不可用,检测一次如果可用,我们可以将其立即设置为 100 满分,也可以在原基础上加 1 分,当不可用,可以将其减 1 分,当减到一定阈值后就直接从数据库移除。通过这样的标识分数,我们就可以区分出代理的可用情况,选用的时候会更有针对性。
  • 接口模块需要用 API 来提供对外服务的接口,其实我们可以直接连数据库来取,但是这样就需要知道数据库的连接信息,不太安全,而且需要配置连接,所以一个比较安全和方便的方式就是提供一个 Web API 接口,通过访问接口即可拿到可用代理。另外由于可用代理可能有多个,我们可以提供随机返回一个可用代理的接口,这样保证每个可用代理都可以取到,实现负载均衡。

以上便是设计代理的一些基本思路,那么接下来我们就设计一下整体的架构,然后用代码该实现代理池。

3. 代理池的架构

根据上文的描述,代理池的架构可以是这样的,如图 9-1 所示: 图 9-1 代理池架构 代理池分为四个部分,获取模块、存储模块、检测模块、接口模块。

  • 存储模块使用 Redis 的有序集合,用以代理的去重和状态标识,同时它也是中心模块和基础模块,将其他模块串联起来。
  • 获取模块定时从代理网站获取代理,将获取的代理传递给存储模块,保存到数据库。
  • 检测模块定时通过存储模块获取所有代理,并对其进行检测,根据不同的检测结果对代理设置不同的标识。
  • 接口模块通过 Web API 提供服务接口,其内部还是连接存储模块,获取可用的代理。

4. 代理池的实现

接下来我们分别用代码来实现一下这四个模块。

存储模块

存储在这里我们使用 Redis 的有序集合,集合的每一个元素都是不重复的,对于代理代理池来说,集合的元素就变成了一个个代理,也就是 IP 加端口的形式,如 60.207.237.111:8888,这样的一个代理就是集合的一个元素。另外有序集合的每一个元素还都有一个分数字段,分数是可以重复的,是一个浮点数类型,也可以是整数类型。该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面,这样就可以实现集合元素的排序了。 对于代理池来说,这个分数可以作为我们判断一个代理可用不可用的标志,我们将 100 设为最高分,代表可用,0 设为最低分,代表不可用。从代理池中获取代理的时候会随机获取分数最高的代理,注意这里是随机,这样可以保证每个可用代理都会被调用到。 分数是我们判断代理稳定性的重要标准,在这里我们设置分数规则如下:

  • 分数 100 为可用,检测器会定时循环检测每个代理可用情况,一旦检测到有可用的代理就立即置为 100,检测到不可用就将分数减 1,减至 0 后移除。
  • 新获取的代理添加时将分数置为 10,当测试可行立即置 100,不可行分数减 1,减至 0 后移除。

这是一种解决方案,当然可能还有更合理的方案。此方案的设置有一定的原因,在此总结如下:

  • 当检测到代理可用时立即置为 100,这样可以保证所有可用代理有更大的机会被获取到。你可能会说为什么不直接将分数加 1 而是直接设为最高 100 呢?设想一下,我们有的代理是从各大免费公开代理网站获取的,如果一个代理并没有那么稳定,平均五次请求有两次成功,三次失败,如果按照这种方式来设置分数,那么这个代理几乎不可能达到一个高的分数,也就是说它有时是可用的,但是我们筛选是筛选的分数最高的,所以这样的代理就几乎不可能被取到,当然如果想追求代理稳定性的化可以用这种方法,这样可确保分数最高的一定是最稳定可用的。但是在这里我们采取可用即设置 100 的方法,确保只要可用的代理都可以被使用到。
  • 当检测到代理不可用时,将分数减 1,减至 0 后移除,一共 100 次机会,也就是说当一个可用代理接下来如果尝试了 100 次都失败了,就一直减分直到移除,一旦成功就重新置回 100,尝试机会越多代表将这个代理拯救回来的机会越多,这样不容易将曾经的一个可用代理丢弃,因为代理不可用的原因可能是网络繁忙或者其他人用此代理请求太过频繁,所以在这里设置为 100 级。
  • 新获取的代理分数设置为 10,检测如果不可用就减 1,减到 0 就移除,如果可用就置 100。由于我们很多代理是从免费网站获取的,所以新获取的代理无效的可能性是非常高的,可能不足 10%,所以在这里我们将其设置为 10,检测的机会没有可用代理 100 次那么多,这也可以适当减少开销。

以上便是代理分数的一个设置思路,不一定是最优思路,但个人实测实用性还是比较强的。 所以我们就需要定义一个类来操作数据库的有序集合,定义一些方法来实现分数的设置,代理的获取等等。 实现如下:

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
MAX_SCORE = 100
MIN_SCORE = 0
INITIAL_SCORE = 10
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_PASSWORD = None
REDIS_KEY = 'proxies'

import redis
from random import choice

class RedisClient(object):
def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD):
"""
初始化
:param host: Redis 地址
:param port: Redis 端口
:param password: Redis密码
"""
self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True)

def add(self, proxy, score=INITIAL_SCORE):
"""
添加代理,设置分数为最高
:param proxy: 代理
:param score: 分数
:return: 添加结果
"""
if not self.db.zscore(REDIS_KEY, proxy):
return self.db.zadd(REDIS_KEY, score, proxy)

def random(self):
"""
随机获取有效代理,首先尝试获取最高分数代理,如果不存在,按照排名获取,否则异常
:return: 随机代理
"""
result = self.db.zrangebyscore(REDIS_KEY, MAX_SCORE, MAX_SCORE)
if len(result):
return choice(result)
else:
result = self.db.zrevrange(REDIS_KEY, 0, 100)
if len(result):
return choice(result)
else:
raise PoolEmptyError

def decrease(self, proxy):
"""
代理值减一分,小于最小值则删除
:param proxy: 代理
:return: 修改后的代理分数
"""
score = self.db.zscore(REDIS_KEY, proxy)
if score and score > MIN_SCORE:
print('代理', proxy, '当前分数', score, '减1')
return self.db.zincrby(REDIS_KEY, proxy, -1)
else:
print('代理', proxy, '当前分数', score, '移除')
return self.db.zrem(REDIS_KEY, proxy)

def exists(self, proxy):
"""
判断是否存在
:param proxy: 代理
:return: 是否存在
"""
return not self.db.zscore(REDIS_KEY, proxy) == None

def max(self, proxy):
"""
将代理设置为MAX_SCORE
:param proxy: 代理
:return: 设置结果
"""
print('代理', proxy, '可用,设置为', MAX_SCORE)
return self.db.zadd(REDIS_KEY, MAX_SCORE, proxy)

def count(self):
"""
获取数量
:return: 数量
"""
return self.db.zcard(REDIS_KEY)

def all(self):
"""
获取全部代理
:return: 全部代理列表
"""
return self.db.zrangebyscore(REDIS_KEY, MIN_SCORE, MAX_SCORE)

首先定义了一些常量,如 MAX_SCORE、MIN_SCORE、INITIAL_SCORE 分别代表最大分数、最小分数、初始分数。REDIS_HOST、REDIS_PORT、REDIS_PASSWORD 分别代表了 Redis 的连接信息,即地址、端口、密码。REDIS_KEY 是有序集合的键名,可以通过它来获取代理存储所使用的有序集合。 接下来定义了一个 RedisClient 类,用以操作 Redis 的有序集合,其中定义了一些方法来对集合中的元素进行处理,主要功能如下:

  • init() 方法是初始化的方法,参数是 Redis 的连接信息,默认的连接信息已经定义为常量,在 init() 方法中初始化了一个 StrictRedis 的类,建立 Redis 连接。这样当 RedisClient 类初始化的时候就建立了 Redis 的连接。
  • add() 方法向数据库添加代理并设置分数,默认的分数是 INITIAL_SCORE 也就是 10,返回结果是添加的结果。
  • random() 方法是随机获取代理的方法,首先获取 100 分的代理,然后随机选择一个返回,如果不存在 100 分的代理,则按照排名来获取,选取前 100 名,然后随机选择一个返回,否则抛出异常。
  • decrease() 方法是在代理检测无效的时候设置分数减 1 的方法,传入代理,然后将此代理的分数减 1,如果达到最低值,那么就删除。
  • exists() 方法判断代理是否存在集合中
  • max() 方法是将代理的分数设置为 MAX_SCORE,即 100,也就是当代理有效时的设置。
  • count() 方法返回当前集合的元素个数。
  • all() 方法返回所有的代理列表,供检测使用。

定义好了这些方法,我们可以在后续的模块中调用此类来连接和操作数据库,非常方便。如我们想要获取随机可用的代理,只需要调用 random() 方法即可,得到的就是随机的可用代理。

获取模块

获取模块的逻辑相对简单,首先需要定义一个 Crawler 来从各大网站抓取代理,示例如下:

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
import json
from .utils import get_page
from pyquery import PyQuery as pq

class ProxyMetaclass(type):
def __new__(cls, name, bases, attrs):
count = 0
attrs['__CrawlFunc__'] = []
for k, v in attrs.items():
if 'crawl_' in k:
attrs['__CrawlFunc__'].append(k)
count += 1
attrs['__CrawlFuncCount__'] = count
return type.__new__(cls, name, bases, attrs)

class Crawler(object, metaclass=ProxyMetaclass):
def get_proxies(self, callback):
proxies = []
for proxy in eval("self.{}()".format(callback)):
print('成功获取到代理', proxy)
proxies.append(proxy)
return proxies

def crawl_daili66(self, page_count=4):
"""
获取代理66
:param page_count: 页码
:return: 代理
"""
start_url = 'http://www.66ip.cn/{}.html'
urls = [start_url.format(page) for page in range(1, page_count + 1)]
for url in urls:
print('Crawling', url)
html = get_page(url)
if html:
doc = pq(html)
trs = doc('.containerbox table tr:gt(0)').items()
for tr in trs:
ip = tr.find('td:nth-child(1)').text()
port = tr.find('td:nth-child(2)').text()
yield ':'.join([ip, port])

def crawl_proxy360(self):
"""
获取Proxy360
:return: 代理
"""
start_url = 'http://www.proxy360.cn/Region/China'
print('Crawling', start_url)
html = get_page(start_url)
if html:
doc = pq(html)
lines = doc('div[name="list_proxy_ip"]').items()
for line in lines:
ip = line.find('.tbBottomLine:nth-child(1)').text()
port = line.find('.tbBottomLine:nth-child(2)').text()
yield ':'.join([ip, port])

def crawl_goubanjia(self):
"""
获取Goubanjia
:return: 代理
"""
start_url = 'http://www.goubanjia.com/free/gngn/index.shtml'
html = get_page(start_url)
if html:
doc = pq(html)
tds = doc('td.ip').items()
for td in tds:
td.find('p').remove()
yield td.text().replace(' ', '')

为了实现灵活,在这里我们将获取代理的一个个方法统一定义一个规范,如统一定义以 crawl 开头,这样扩展的时候只需要添加 crawl 开头的方法即可。 在这里实现了几个示例,如抓取代理 66、Proxy360、Goubanjia 三个免费代理网站,这些方法都定义成了生成器,通过 yield 返回一个个代理。首先将网页获取,然后用 PyQuery 解析,解析出 IP 加端口的形式的代理然后返回。 然后定义了一个 get_proxies() 方法,将所有以 crawl 开头的方法调用一遍,获取每个方法返回的代理并组合成列表形式返回。 你可能会想知道是怎样获取了所有以 crawl 开头的方法名称的。其实这里借助于元类来实现,定义了一个 ProxyMetaclass,Crawl 类将它设置为元类,元类中实现了 new() 方法,这个方法有固定的几个参数,其中第四个参数 attrs 中包含了类的一些属性,这其中就包含了类中方法的一些信息,我们可以遍历 attrs 这个变量即可获取类的所有方法信息。所以在这里我们在 new() 方法中遍历了 attrs 的这个属性,就像遍历一个字典一样,键名对应的就是方法的名称,接下来判断其开头是否是 crawl,如果是,则将其加入到 CrawlFunc 属性中,这样我们就成功将所有以 crawl 开头的方法定义成了一个属性,就成功动态地获取到所有以 crawl 开头的方法列表了。 所以说,如果要做扩展的话,我们只需要添加一个以 crawl 开头的方法,例如抓取快代理,我们只需要在 Crawler 类中增加 crawl_kuaidaili() 方法,仿照其他的几个方法将其定义成生成器,抓取其网站的代理,然后通过 yield 返回代理即可,所以这样我们可以非常方便地扩展,而不用关心类其他部分的实现逻辑。 代理网站的添加非常灵活,不仅可以添加免费代理,也可以添加付费代理,一些付费代理的提取方式其实也类似,也是通过 Web 的形式获取,然后进行解析,解析方式可能更加简单,如解析纯文本或 Json,解析之后以同样的方式返回即可,在此不再添加,可以自行扩展。 既然定义了这个 Crawler 类,我们就要调用啊,所以在这里再定义一个 Getter 类,动态地调用所有以 crawl 开头的方法,然后获取抓取到的代理,将其加入到数据库存储起来。

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 db import RedisClient
from crawler import Crawler

POOL_UPPER_THRESHOLD = 10000

class Getter():
def __init__(self):
self.redis = RedisClient()
self.crawler = Crawler()

def is_over_threshold(self):
"""
判断是否达到了代理池限制
"""
if self.redis.count() >= POOL_UPPER_THRESHOLD:
return True
else:
return False

def run(self):
print('获取器开始执行')
if not self.is_over_threshold():
for callback_label in range(self.crawler.__CrawlFuncCount__):
callback = self.crawler.__CrawlFunc__[callback_label]
proxies = self.crawler.get_proxies(callback)
for proxy in proxies:
self.redis.add(proxy)

Getter 类就是获取器类,这其中定义了一个变量 POOL_UPPER_THRESHOLD 表示代理池的最大数量,这个数量可以灵活配置,然后定义了 is_over_threshold() 方法判断代理池是否已经达到了容量阈值,它就是调用了 RedisClient 的 count() 方法获取代理的数量,然后加以判断,如果数量达到阈值则返回 True,否则 False。如果不想加这个限制可以将此方法永久返回 True。 接下来定义了 run() 方法,首先判断了代理池是否达到阈值,然后在这里就调用了 Crawler 类的 CrawlFunc 属性,获取到所有以 crawl 开头的方法列表,依次通过 get_proxies() 方法调用,得到各个方法抓取到的代理,然后再利用 RedisClient 的 add() 方法加入数据库,这样获取模块的工作就完成了。

检测模块

在获取模块中,我们已经成功将各个网站的代理获取下来了,然后就需要一个检测模块来对所有的代理进行一轮轮的检测,检测可用就设置为 100,不可用就分数减 1,这样就可以实时改变每个代理的可用情况,在获取有效代理的时候只需要获取分数高的代理即可。 由于代理的数量非常多,为了提高代理的检测效率,我们在这里使用异步请求库 Aiohttp 来进行检测。 Requests 作为一个同步请求库,我们在发出一个请求之后需要等待网页加载完成之后才能继续执行程序。也就是这个过程会阻塞在等待响应这个过程,如果服务器响应非常慢,比如一个请求等待十几秒,那么我们使用 Requests 完成一个请求就会需要十几秒的时间,中间其实就是一个等待响应的过程,程序也不会继续往下执行,而这十几秒的时间其实完全可以去做其他的事情,比如调度其他的请求或者进行网页解析等等。 异步请求库就解决了这个问题,它类似 JavaScript 中的回调,意思是说在请求发出之后,程序可以继续接下去执行去做其他的事情,当响应到达时,会通知程序再去处理这个响应,这样程序就没有被阻塞,充分把时间和资源利用起来,大大提高效率。 对于响应速度比较快的网站,可能 Requests 同步请求和 Aiohttp 异步请求的效果差距没那么大,可对于检测代理这种事情,一般是需要十多秒甚至几十秒的时间,这时候使用 Aiohttp 异步请求库的优势就大大体现出来了,效率可能会提高几十倍不止。 所以在这里我们的代理检测使用异步请求库 Aiohttp,实现示例如下:

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
VALID_STATUS_CODES = [200]
TEST_URL = 'http://www.baidu.com'
BATCH_TEST_SIZE = 100

class Tester(object):
def __init__(self):
self.redis = RedisClient()

async def test_single_proxy(self, proxy):
"""
测试单个代理
:param proxy: 单个代理
:return: None
"""
conn = aiohttp.TCPConnector(verify_ssl=False)
async with aiohttp.ClientSession(connector=conn) as session:
try:
if isinstance(proxy, bytes):
proxy = proxy.decode('utf-8')
real_proxy = 'http://' + proxy
print('正在测试', proxy)
async with session.get(TEST_URL, proxy=real_proxy, timeout=15) as response:
if response.status in VALID_STATUS_CODES:
self.redis.max(proxy)
print('代理可用', proxy)
else:
self.redis.decrease(proxy)
print('请求响应码不合法', proxy)
except (ClientError, ClientConnectorError, TimeoutError, AttributeError):
self.redis.decrease(proxy)
print('代理请求失败', proxy)

def run(self):
"""
测试主函数
:return: None
"""
print('测试器开始运行')
try:
proxies = self.redis.all()
loop = asyncio.get_event_loop()
# 批量测试
for i in range(0, len(proxies), BATCH_TEST_SIZE):
test_proxies = proxies[i:i + BATCH_TEST_SIZE]
tasks = [self.test_single_proxy(proxy) for proxy in test_proxies]
loop.run_until_complete(asyncio.wait(tasks))
time.sleep(5)
except Exception as e:
print('测试器发生错误', e.args)

在这里定义了一个类 Tester,init() 方法中建立了一个 RedisClient 对象,供类中其他方法使用。接下来定义了一个 test_single_proxy() 方法,用来检测单个代理的可用情况,其参数就是被检测的代理,注意这个方法前面加了 async 关键词,代表这个方法是异步的,方法内部首先创建了 Aiohttp 的 ClientSession 对象,此对象类似于 Requests 的 Session 对象,可以直接调用该对象的 get() 方法来访问页面,在这里代理的设置方式是通过 proxy 参数传递给 get() 方法,请求方法前面也需要加上 async 关键词标明是异步请求,这也是 Aiohttp 使用时的常见写法。 测试的链接在这里定义常量为 TEST_URL,如果针对某个网站有抓取需求,建议将 TEST_URL 设置为目标网站的地址,因为在抓取的过程中,可能代理本身是可用的,但是该代理的 IP 已经被目标网站封掉了。例如,如要抓取知乎,可能其中某些代理是可以正常使用,比如访问百度等页面是完全没有问题的,但是可能对知乎来说可能就被封了,所以可以将 TEST_URL 设置为知乎的某个页面的链接,当请求失败时,当代理被封时,分数自然会减下来,就不会被取到了。 如果想做一个通用的代理池,则不需要专门设置 TEST_URL,可以设置为一个不会封 IP 的网站,也可以设置为百度这类响应稳定的网站。 另外我们还定义了 VALID_STATUS_CODES 变量,是一个列表形式,包含了正常的状态码,如可以定义成 [200],当然对于某些检测目标网站可能会出现其他的状态码也是正常的,可以自行配置。 获取 Response 后需要判断响应的状态,如果状态码在 VALID_STATUS_CODES 这个列表里,则代表代理可用,调用 RedisClient 的 max() 方法将代理分数设为 100,否则调用 decrease() 方法将代理分数减 1,如果出现异常也同样将代理分数减 1。 另外在测试的时候设置了批量测试的最大值 BATCH_TEST_SIZE 为 100,也就是一批测试最多测试 100 个,这可以避免当代理池过大时全部测试导致内存开销过大的问题。 随后在 run() 方法里面获取了所有的代理列表,使用 Aiohttp 分配任务,启动运行,这样就可以进行异步检测了,写法可以参考 Aiohttp 的官方示例:http://aiohttp.readthedocs.io/。 这样测试模块的逻辑就完成了。

接口模块

通过上述三个模块我们已经可以做到代理的获取、检测和更新了,数据库中就会以有序集合的形式存储各个代理还有对应的分数,分数 100 代表可用,分数越小代表越不可用。 但是我们怎样来方便地获取可用代理呢?用 RedisClient 类来直接连接 Redis 然后调用 random() 方法获取当然没问题,这样做效率很高,但是有这么几个弊端:

  • 需要知道 Redis 的用户名和密码,如果这个代理池是给其他人使用的就需要告诉他连接的用户名和密码信息,这样是很不安全的。
  • 代理池如果想持续运行需要部署在远程服务器上运行,如果远程服务器的 Redis 是只允许本地连接的,那么就没有办法远程直连 Redis 获取代理了。
  • 如果爬虫所在的主机没有连接 Redis 的模块,或者爬虫不是由 Python 语言编写的,那么就无法使用 RedisClient 来获取代理了。
  • 如果 RedisClient 类或者数据库结构有更新,那么在爬虫端还需要去同步这些更新。

综上考虑,为了使得代理池可以作为一个独立服务运行,我们最好增加一个接口模块,以 Web API 的形式暴露可用代理。 这样获取代理只需要请求一下接口即可,以上的几个缺点弊端可以解决。 我们在这里使用一个比较轻量级的库 Flask 来实现这个接口模块,实现示例如下:

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
from flask import Flask, g
from db import RedisClient

__all__ = ['app']
app = Flask(__name__)

def get_conn():
if not hasattr(g, 'redis'):
g.redis = RedisClient()
return g.redis

@app.route('/')
def index():
return '<h2>Welcome to Proxy Pool System</h2>'

@app.route('/random')
def get_proxy():
"""
获取随机可用代理
:return: 随机代理
"""
conn = get_conn()
return conn.random()

@app.route('/count')
def get_counts():
"""
获取代理池总量
:return: 代理池总量
"""
conn = get_conn()
return str(conn.count())

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

在这里我们声明了一个 Flask 对象,定义了三个接口,分别是首页、随机代理页、获取数量页。 运行之后 Flask 会启动一个 Web 服务,我们只需要访问对应的接口即可获取到可用代理。

调度模块

这个模块其实就是调用以上所定义的三个模块,将以上三个模块通过多进程的形式运行起来,示例如下:

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
TESTER_CYCLE = 20
GETTER_CYCLE = 20
TESTER_ENABLED = True
GETTER_ENABLED = True
API_ENABLED = True

from multiprocessing import Process
from api import app
from getter import Getter
from tester import Tester

class Scheduler():
def schedule_tester(self, cycle=TESTER_CYCLE):
"""
定时测试代理
"""
tester = Tester()
while True:
print('测试器开始运行')
tester.run()
time.sleep(cycle)

def schedule_getter(self, cycle=GETTER_CYCLE):
"""
定时获取代理
"""
getter = Getter()
while True:
print('开始抓取代理')
getter.run()
time.sleep(cycle)

def schedule_api(self):
"""
开启API
"""
app.run(API_HOST, API_PORT)

def run(self):
print('代理池开始运行')
if TESTER_ENABLED:
tester_process = Process(target=self.schedule_tester)
tester_process.start()

if GETTER_ENABLED:
getter_process = Process(target=self.schedule_getter)
getter_process.start()

if API_ENABLED:
api_process = Process(target=self.schedule_api)
api_process.start()

在这里还有三个常量,TESTER_ENABLED、GETTER_ENABLED、API_ENABLED 都是布尔类型,True 或者 False。标明了测试模块、获取模块、接口模块的开关,如果为 True,则代表模块开启。 启动入口是 run() 方法,其分别判断了三个模块的开关,如果开启的话,就新建一个 Process 进程,设置好启动目标,然后调用 start() 方法运行,这样三个进程就可以并行执行,互不干扰。 三个调度方法结构也非常清晰,比如 schedule_tester() 方法,这是用来调度测试模块的方法,首先声明一个 Tester 对象,然后进入死循环不断循环调用其 run() 方法,执行完一轮之后就休眠一段时间,休眠结束之后重新再执行。在这里休眠时间也定义为一个常量,如 20 秒,这样就会每隔 20 秒进行一次代理检测。 最后整个代理池的运行只需要调用 Scheduler 的 run() 方法即可启动。 以上便是整个代理池的架构和相应实现逻辑。

5. 运行

接下来我们将代码整合一下,将代理运行起来,运行之后的输出结果如图 9-2 所示: 图 9-2 运行结果 以上是代理池的控制台输出,可以看到可用代理设置为 100,不可用代理分数减 1。 接下来我们再打开浏览器,当前配置了运行在 5555 端口,所以打开:http://127.0.0.1:5555,即可看到其首页,如图 9-3 所示: 图 9-3 首页页面 再访问:http://127.0.0.1:5555/random,即可获取随机可用代理,如图 9-4 所示: 图 9-4 获取代理页面 所以后面我们只需要访问此接口即可获取一个随机可用代理,非常方便。 获取代理的代码如下:

1
2
3
4
5
6
7
8
9
10
11
import requests

PROXY_POOL_URL = 'http://localhost:5555/random'

def get_proxy():
try:
response = requests.get(PROXY_POOL_URL)
if response.status_code == 200:
return response.text
except ConnectionError:
return None

获取下来之后便是一个字符串类型的代理,可以按照上一节所示的方法设置代理,如 Requests 的使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = get_proxy()
proxies = {
'http': 'http://' + proxy,
'https': 'https://' + proxy,
}
try:
response = requests.get('http://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

有了代理池之后,我们再取出代理即可有效防止 IP 被封禁的情况。

6. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/ProxyPool

7. 结语

本节我们实现了一个比较高效的代理池来获取随机可用的代理,整个内容比较多,需要好好理解一下。 在后文我们会利用代理池来实现数据的抓取。

Python

在前面我们介绍了多种请求库,如 Requests、Urllib、Selenium 等。我们接下来首先贴近实战,了解一下代理怎么使用,为后面了解代理池、ADSL 拨号代理的使用打下基础。 下面我们来梳理一下这些库的代理的设置方法。

1. 获取代理

在做测试之前,我们需要先获取一个可用代理,搜索引擎搜索“代理”关键字,就可以看到有许多代理服务网站,在网站上会有很多免费代理,比如西刺:http://www.xicidaili.com/,这里列出了很多免费代理,但是这些免费代理大多数情况下都是不好用的,所以比较靠谱的方法是购买付费代理,很多网站都有售卖,数量不用多,买一个稳定可用的即可,可以自行选购。 或者如果我们本机有相关代理软件的话,软件一般会在本机创建 HTTP 或 SOCKS 代理服务,直接使用此代理也可以。 在这里我的本机安装了一部代理软件,它会在本地 9743 端口上创建 HTTP 代理服务,也就是代理为 127.0.0.1:9743,另外还会在 9742 端口创建 SOCKS 代理服务,也就是代理为 127.0.0.1:9742,我只要设置了这个代理就可以成功将本机 IP 切换到代理软件连接的服务器的 IP了。 所以本节下面的示例里我使用上述代理来演示其设置方法,你可以自行替换成自己的可用代理,设置代理后测试的网址是:http://httpbin.org/get,访问该站点可以得到请求的一些相关信息,其中 origin 字段就是客户端的 IP,我们可以根据它来判断代理是否设置成功,也就是是否成功伪装了IP。 下面我们来看下各个库的代理设置方式。

2. Urllib

首先我们以最基础的 Urllib 为例,来看一下代理的设置方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy = '127.0.0.1:9743'
proxy_handler = ProxyHandler({
'http': 'http://' + proxy,
'https': 'https://' + proxy
})
opener = build_opener(proxy_handler)
try:
response = opener.open('http://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept-Encoding": "identity",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.6"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

在这里我们需要借助于 ProxyHandler 设置代理,参数是字典类型,键名为协议类型,键值是代理,注意此处代理前面需要加上协议,即 http 或者 https,此处设置了 http 和 https 两种代理,当我们请求的链接是 http 协议的时候,它会调用 http 代理,当请求的链接是 https 协议的时候,它会调用https代理,所以此处生效的代理是:http://127.0.0.1:9743。 创建完 ProxyHandler 对象之后,我们需要利用 build_opener() 方法传入该对象来创建一个 Opener,这样就相当于此 Opener 已经设置好代理了,接下来直接调用它的 open() 方法即可使用此代理访问我们所想要的链接。 运行输出结果是一个 Json,它有一个字段 origin,标明了客户端的 IP,此处的 IP 验证一下,确实为代理的 IP,而并不是我们真实的 IP,所以这样我们就成功设置好代理,并可以隐藏真实 IP 了。 如果遇到需要认证的代理,我们可以用如下的方法设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy = 'username:password@127.0.0.1:9743'
proxy_handler = ProxyHandler({
'http': 'http://' + proxy,
'https': 'https://' + proxy
})
opener = build_opener(proxy_handler)
try:
response = opener.open('http://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

这里改变的只是 proxy 变量,只需要在代理前面加入代理认证的用户名密码即可,其中 username 就是用户名,password 为密码,例如 username 为foo,密码为 bar,那么代理就是 foo:bar@127.0.0.1:9743。 如果代理是 SOCKS5 类型,那么可以用如下方式设置代理:

1
2
3
4
5
6
7
8
9
10
11
12
import socks
import socket
from urllib import request
from urllib.error import URLError

socks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 9742)
socket.socket = socks.socksocket
try:
response = request.urlopen('http://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

此处需要一个 Socks 模块,可以通过如下命令安装:

1
pip3 install PySocks

本地我有一个 SOCKS5 代理,运行在 9742 端口,运行成功之后和上文 HTTP 代理输出结果是一样的:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept-Encoding": "identity",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.6"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

结果的 origin 字段同样为代理的 IP,设置代理成功。

3. Requests

对于 Requests 来说,代理设置更加简单,我们只需要传入 proxies 参数即可。 还是以上例中的代理为例,我们来看下 Requests 的代理的设置:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = '127.0.0.1:9743'
proxies = {
'http': 'http://' + proxy,
'https': 'https://' + proxy,
}
try:
response = requests.get('http://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.18.1"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

可以发现 Requests 的代理设置比 Urllib 简单很多,只需要构造代理字典即可,然后通过 proxies 参数即可设置代理,不需要重新构建 Opener。 可以发现其运行结果的 origin 也是代理的 IP,证明代理已经设置成功。 如果代理需要认证,同样在代理的前面加上用户名密码即可,代理的写法就变成:

1
proxy = 'username:password@127.0.0.1:9743'

和 Urllib 一样,只需要将 username 和 password 替换即可。 如果需要使用 SOCKS5 代理,则可以使用如下方式:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = '127.0.0.1:9742'
proxies = {
'http': 'socks5://' + proxy,
'https': 'socks5://' + proxy
}
try:
response = requests.get('http://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

在这里需要额外安装一个 Socks 模块,命令如下:

1
pip3 install "requests[socks]"

运行结果是完全相同的:

1
2
3
4
5
6
7
8
9
10
11
12
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.18.1"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

另外还有一种设置方式,和 Urllib 中的方法相同,使用 socks 模块,也需要像上文一样安装该库,设置方法如下:

1
2
3
4
5
6
7
8
9
10
11
import requests
import socks
import socket

socks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 9742)
socket.socket = socks.socksocket
try:
response = requests.get('http://httpbin.org/get')
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

这样也可以设置 SOCKS5 代理,运行结果完全相同,相比第一种方法,此方法是全局设置,不同情况可以选用不同的方法。

4. Selenium

Selenium 同样也可以设置代理,在这里分两种介绍,一个是有界面浏览器,以 Chrome 为例介绍,另一种是无界面浏览器,以 PhantomJS 为例介绍。

Chrome

对于 Chrome 来说,用 Selenium 设置代理的方法也非常简单,设置方法如下:

1
2
3
4
5
6
7
from selenium import webdriver

proxy = '127.0.0.1:9743'
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--proxy-server=http://' + proxy)
browser = webdriver.Chrome(chrome_options=chrome_options)
browser.get('http://httpbin.org/get')

在这里我们通过 ChromeOptions 来设置代理,在创建 Chrome 对象的时候通过 chrome_options 参数传递即可。 这样在运行之后便会弹出一个 Chrome 浏览器,访问目标链接之后输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "zh-CN,zh;q=0.8",
"Connection": "close",
"Host": "httpbin.org",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

可以看到 origin 同样为代理 IP 的地址,代理设置成功。 如果代理是认证代理,则设置方法相对比较麻烦,方法如下:

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
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import zipfile

ip = '127.0.0.1'
port = 9743
username = 'foo'
password = 'bar'

manifest_json = """
{
"version": "1.0.0",
"manifest_version": 2,
"name": "Chrome Proxy",
"permissions": [
"proxy",
"tabs",
"unlimitedStorage",
"storage",
"<all_urls>",
"webRequest",
"webRequestBlocking"
],
"background": {
"scripts": ["background.js"]
}
}
"""

background_js = """
var config = {
mode: "fixed_servers",
rules: {
singleProxy: {
scheme: "http",
host: "%(ip)s",
port: %(port)s
}
}
}

chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});

function callbackFn(details) {
return {
authCredentials: {
username: "%(username)s",
password: "%(password)s"
}
}
}

chrome.webRequest.onAuthRequired.addListener(
callbackFn,
{urls: ["<all_urls>"]},
['blocking']
)
""" % {'ip': ip, 'port': port, 'username': username, 'password': password}

plugin_file = 'proxy_auth_plugin.zip'
with zipfile.ZipFile(plugin_file, 'w') as zp:
zp.writestr("manifest.json", manifest_json)
zp.writestr("background.js", background_js)
chrome_options = Options()
chrome_options.add_argument("--start-maximized")
chrome_options.add_extension(plugin_file)
browser = webdriver.Chrome(chrome_options=chrome_options)
browser.get('http://httpbin.org/get')

在这里需要在本地创建一个 manifest.json 配置文件和 background.js 脚本来设置认证代理,运行之后本地会生成一个 proxy_auth_plugin.zip 文件保存配置。 运行结果和上例一致,origin 同样为代理 IP。

PhantomJS

对于 PhantomJS,代理设置方法可以借助于 service_args 参数,也就是命令行参数,代理设置方法如下:

1
2
3
4
5
6
7
8
9
from selenium import webdriver

service_args = [
'--proxy=127.0.0.1:9743',
'--proxy-type=http'
]
browser = webdriver.PhantomJS(service_args=service_args)
browser.get('http://httpbin.org/get')
print(browser.page_source)

在这里我们只需要使用 service_args 参数,将命令行的一些参数定义为列表,在初始化的时候传递即可。 运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "zh-CN,en,*",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.0 Safari/538.1"
},
"origin": "106.185.45.153",
"url": "http://httpbin.org/get"
}

运行结果的 origin 同样为代理的 IP,设置代理成功。 如果需要认证,那么只需要再加入 —proxy-auth 选项即可,这样参数就改为:

1
2
3
4
5
service_args = [
'--proxy=127.0.0.1:9743',
'--proxy-type=http',
'--proxy-auth=username:password'
]

将 username 和 password 替换为认证所需的用户名和密码即可。

5. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/ProxySettings

6. 结语

本节介绍了前文所介绍的请求库的代理设置方法,稍作了解即可,后面我们会使用这些方法来搭建代理池和爬取网站,进一步加深印象。

Python

我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么的美好,然而一杯茶的功夫可能就会出现错误,比如 403 Forbidden,这时候打开网页一看,可能会看到“您的 IP 访问频率太高”这样的提示,或者跳出一个验证码让我们输入,输入之后才可能解封,但是输入之后过一会儿就又这样了。 出现这样的现象的原因是网站采取了一些反爬虫的措施,比如服务器会检测某个 IP 在单位时间内的请求次数,如果超过了这个阈值,那么会直接拒绝服务,返回一些错误信息,这种情况可以称之为封 IP,于是乎就成功把我们的爬虫禁掉了。 既然服务器检测的是某个 IP 单位时间的请求次数,那么我们借助某种方式来伪装我们的 IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封 IP 了吗? 所以这时候代理就派上用场了,本章我们会详细介绍一下代理的基本知识及各种代理的使用方式,帮助爬虫脱离封 IP 的苦海。 本章接下来会介绍代理的设置、代理池的维护、付费代理的使用、ADSL拨号代理的搭建方法。

Python

本节我们来介绍一下新浪微博宫格验证码的识别,此验证码是一种新型交互式验证码,每个宫格之间会有一条指示连线,指示了我们应该的滑动轨迹,我们需要按照滑动轨迹依次从起始宫格一直滑动到终止宫格才可以完成验证,如图 8-24 所示: 图 8-24 验证码示例 鼠标滑动后的轨迹会以黄色的连线来标识,如图 8-25 所示: 图 8-25 滑动过程 我们可以访问新浪微博移动版登录页面就可以看到如上验证码,链接为:https://passport.weibo.cn/signin/login,当然也不是每次都会出现验证码,一般当频繁登录或者账号存在安全风险的时候会出现。 接下来我们就来试着识别一下此类验证码。

1. 本节目标

本节我们的目标是用程序来识别并通过微博宫格验证码的验证。

2. 准备工作

本次我们使用的 Python 库是 Selenium,使用的浏览器为 Chrome,在此之前请确保已经正确安装好了 Selenium 库、Chrome 浏览器并配置好了 ChromeDriver,相关流程可以参考第一章的说明。

3. 识别思路

要识别首先要从探寻规律入手,那么首先我们找到的规律就是此验证码的四个宫格一定是有连线经过的,而且每一条连线上都会相应的指示箭头,连线的形状多样,如 C 型、Z 型、X 型等等,如图 8-26、8-27、8-28 所示: 图 8-26 C 型 图 8-27 Z 型 图 8-28 X 型 而同时我们发现同一种类型它的连线轨迹是相同的,唯一不同的就是连线的方向,如图 8-29、8-30 所示: 图 8-29 反向连线 图 8-30 正向连线 这两种验证码的连线轨迹是相同的,但是由于连线上面的指示箭头不同导致滑动的宫格顺序就有所不同。 所以要完全识别滑动宫格顺序的话就需要具体识别出箭头的朝向,而观察一下整个验证码箭头朝向一共可能有 8 种,而且会出现在不同的位置,如果要写一个箭头方向识别算法的话需要都考虑到不同箭头所在的位置,我们需要找出各个位置的箭头的像素点坐标,同时识别算法还需要计算其像素点变化规律,这个工作量就变得比较大。 这时我们可以考虑用模板匹配的方法,模板匹配的意思就是将一些识别目标提前保存下来并做好标记,称作模板,在这里我们就可以获取验证码图片并做好拖动顺序的标记当做模板。在匹配的时候来对比要新识别的目标和每一个模板哪个是匹配的,如果找到匹配的模板,则被匹配到的模板就和新识别的目标是相同的,这样就成功识别出了要新识别的目标了。模板匹配在图像识别中也是非常常用的一种方法,实现简单而且易用性好。 模板匹配方法如果要效果好的话,我们必须要收集到足够多的模板才可以,而对于微博宫格验证码来说,宫格就 4 个,验证码的样式最多就是 4 3 2 * 1 = 24 种,所以我们可以直接将所有模板都收集下来。 所以接下来我们需要考虑的就是用何种模板来进行匹配,是只匹配箭头还是匹配整个验证码全图呢?我们来权衡一下这两种方式的匹配精度和工作量:

  • 首先是精度问题。如果要匹配箭头的话,我们比对的目标只有几个像素点范围的箭头,而且我们需要精确知道各个箭头所在的像素点,一旦像素点有所偏差,那么匹配模板的时候会直接错位,导致匹配结果大打折扣。如果匹配全图,我们无需关心箭头所在位置,同时还有连线帮助辅助匹配,所以匹配精度上显然是全图匹配精度更高。
  • 其次是工作量的问题。如果要匹配箭头的话,我们需要将所有不同朝向的箭头模板都保存下来,而相同位置箭头的朝向可能不一,相同朝向的箭头位置可能不一,这时候我们需要都算出各个箭头的位置并将其逐个截出来保存成模板,同时在匹配的时候也需要依次去探寻验证码对应位置是否有匹配模板。如果匹配全图的话,我们不需要关心每个箭头的位置和朝向,只需要将验证码全图保存下来即可,在匹配的时候也不需要再去计算箭头的位置,所以工作量上明显是匹配全图更小。

所以综上考虑,我们选用全图匹配的方式来进行识别。 所以到此为止,我们就可以使用全图模板匹配的方法来识别这个宫格验证码了,找到匹配的模板之后,我们就可以得到事先为模板定义的拖动顺序,然后模拟拖动即可。

4. 获取模板

在开始之前,我们需要做一下准备工作,先将 24 张验证码全图保存下来,保存工作难道需要手工来做吗?当然不是的,因为验证码是随机的,一共有 24 种,所以我们可以写一段程序来批量保存一些验证码图片,然后从中筛选出需要的图片就好了,代码如下:

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
import time
from io import BytesIO
from PIL import Image
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

USERNAME = ''
PASSWORD = ''

class CrackWeiboSlide():
def __init__(self):
self.url = 'https://passport.weibo.cn/signin/login'
self.browser = webdriver.Chrome()
self.wait = WebDriverWait(self.browser, 20)
self.username = USERNAME
self.password = PASSWORD

def __del__(self):
self.browser.close()

def open(self):
"""
打开网页输入用户名密码并点击
:return: None
"""
self.browser.get(self.url)
username = self.wait.until(EC.presence_of_element_located((By.ID, 'loginName')))
password = self.wait.until(EC.presence_of_element_located((By.ID, 'loginPassword')))
submit = self.wait.until(EC.element_to_be_clickable((By.ID, 'loginAction')))
username.send_keys(self.username)
password.send_keys(self.password)
submit.click()

def get_position(self):
"""
获取验证码位置
:return: 验证码位置元组
"""
try:
img = self.wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'patt-shadow')))
except TimeoutException:
print('未出现验证码')
self.open()
time.sleep(2)
location = img.location
size = img.size
top, bottom, left, right = location['y'], location['y'] + size['height'], location['x'], location['x'] + size['width']
return (top, bottom, left, right)

def get_screenshot(self):
"""
获取网页截图
:return: 截图对象
"""
screenshot = self.browser.get_screenshot_as_png()
screenshot = Image.open(BytesIO(screenshot))
return screenshot

def get_image(self, name='captcha.png'):
"""
获取验证码图片
:return: 图片对象
"""
top, bottom, left, right = self.get_position()
print('验证码位置', top, bottom, left, right)
screenshot = self.get_screenshot()
captcha = screenshot.crop((left, top, right, bottom))
captcha.save(name)
return captcha

def main(self):
"""
批量获取验证码
:return: 图片对象
"""
count = 0
while True:
self.open()
self.get_image(str(count) + '.png')
count += 1

if __name__ == '__main__':
crack = CrackWeiboSlide()
crack.main()

其中这里需要将 USERNAME 和 PASSWORD 修改为自己微博的用户名密码,运行一段时间后便可以发现在本地多了很多以数字命名的验证码,如图 8-31 所示: 图 8-31 获取结果 在这里我们只需要挑选出不同的 24 张验证码图片并命名保存就好了,名称可以直接取作宫格的滑动的顺序,如某张验证码图片如图 8-32 所示: 图 8-32 验证码示例 我们将其命名为 4132.png 即可,也就是代表滑动顺序为 4-1-3-2,按照这样的规则,我们将验证码整理为如下 24 张图,如图 8-33 所示: 图 8-33 整理结果 如上的 24 张图就是我们的模板,接下来我们在识别的时候只需要遍历模板进行匹配即可。

5. 模板匹配

上面的代码已经实现了将验证码保存下来的功能,通过调用 get_image() 方法我们便可以得到验证码图片对象,得到验证码对象之后我们就需要对其进行模板匹配了,定义如下的方法进行匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from os import listdir

def detect_image(self, image):
"""
匹配图片
:param image: 图片
:return: 拖动顺序
"""
for template_name in listdir(TEMPLATES_FOLDER):
print('正在匹配', template_name)
template = Image.open(TEMPLATES_FOLDER + template_name)
if self.same_image(image, template):
# 返回顺序
numbers = [int(number) for number in list(template_name.split('.')[0])]
print('拖动顺序', numbers)
return numbers

在这里 TEMPLATES_FOLDER 就是模板所在的文件夹,在这里我们用 listdir() 方法将所有模板的文件名称获取出来,然后对其进行遍历,通过 same_image() 方法对验证码和模板进行比对,如果成功匹配,那么就将匹配到的模板文件名转为列表,如匹配到了 3124.png,则返回结果 [3, 1, 2, 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
def is_pixel_equal(self, image1, image2, x, y):
"""
判断两个像素是否相同
:param image1: 图片1
:param image2: 图片2
:param x: 位置x
:param y: 位置y
:return: 像素是否相同
"""
# 取两个图片的像素点
pixel1 = image1.load()[x, y]
pixel2 = image2.load()[x, y]
threshold = 20
if abs(pixel1[0] - pixel2[0]) < threshold and abs(pixel1[1] - pixel2[1]) < threshold and abs(
pixel1[2] - pixel2[2]) < threshold:
return True
else:
return False

def same_image(self, image, template):
"""
识别相似验证码
:param image: 待识别验证码
:param template: 模板
:return:
"""
# 相似度阈值
threshold = 0.99
count = 0
for x in range(image.width):
for y in range(image.height):
# 判断像素是否相同
if self.is_pixel_equal(image, template, x, y):
count += 1
result = float(count) / (image.width * image.height)
if result > threshold:
print('成功匹配')
return True
return False

在这里比对图片也是利用了遍历像素的方法,same_image() 方法接收两个参数,image 为待检测的验证码图片对象,template 是模板对象,由于二者大小是完全一致的,所以在这里我们遍历了图片的所有像素点,比对二者同一位置的像素点是否相同,如果相同就计数加 1,最后计算一下相同的像素点占总像素的比例,如果该比例超过一定阈值那就判定为图片完全相同,匹配成功。在这里设定阈值为 0.99,即如果二者有 0.99 以上的相似比则代表匹配成功。 这样通过上面的方法,依次匹配 24 个模板,如果验证码图片正常,总能找到一个匹配的模板,这样最后就可以得到宫格的滑动顺序了。

6. 模拟拖动

得到了滑动顺序之后,我们接下来就是根据滑动顺序来拖动鼠标连接各个宫格了,方法实现如下:

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
def move(self, numbers):
"""
根据顺序拖动
:param numbers:
:return:
"""
# 获得四个按点
circles = self.browser.find_elements_by_css_selector('.patt-wrap .patt-circ')
dx = dy = 0
for index in range(4):
circle = circles[numbers[index] - 1]
# 如果是第一次循环
if index == 0:
# 点击第一个按点
ActionChains(self.browser)
.move_to_element_with_offset(circle, circle.size['width'] / 2, circle.size['height'] / 2)
.click_and_hold().perform()
else:
# 小幅移动次数
times = 30
# 拖动
for i in range(times):
ActionChains(self.browser).move_by_offset(dx / times, dy / times).perform()
time.sleep(1 / times)
# 如果是最后一次循环
if index == 3:
# 松开鼠标
ActionChains(self.browser).release().perform()
else:
# 计算下一次偏移
dx = circles[numbers[index + 1] - 1].location['x'] - circle.location['x']
dy = circles[numbers[index + 1] - 1].location['y'] - circle.location['y']

在这里方法接收的参数就是宫格的点按顺序,如 [3, 1, 2, 4]。首先我们利用 find_elements_by_css_selector() 方法获取到四个宫格元素,是一个列表形式,每个元素代表一个宫格,接下来我们遍历了宫格的点按顺序,再做一系列对应操作。 其中如果是第一个宫格,那就直接鼠标点击并保持动作,否则移动到下一个宫格。如果是最后一个宫格,那就松开鼠标,否则计算移动到下一个宫格的偏移量。 通过四次循环,我们便可以成功操作浏览器完成宫格验证码的拖拽填充,松开鼠标之后即可识别成功。 运行效果如图 8-34 所示: 图 8-34 运行效果 鼠标会慢慢的从起始位置移动到终止位置,最后一个宫格松开之后便完成了验证码的识别。 至此,微博宫格验证码的识别就全部完成了。 识别完成之后验证码窗口会自动关闭,接下来直接点击登录按钮即可完成微博登录。

7. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/CrackWeiboSlide

8. 结语

本节我们介绍了一种常用的模板匹配识别图片的方式来识别验证码,并模拟了鼠标拖拽动作来实现验证码的识别。如果遇到类似的验证码,可以采用同样的思路进行识别。

Python

上一节我们实现了极验验证码的识别,但是除了极验其实还有另一种常见的且应用广泛的验证码,比较有代表性的就是点触验证码。 可能你对这个名字比较陌生,但是肯定见过类似的验证码,比如 12306,这就是一种典型的点触验证码,如图 8-18 所示: 图 8-18 12306 验证码 我们需要直接点击图中符合要求的图,如果所有答案均正确才会验证成功,如果有一个答案错误,验证就会失败,这种验证码就可以称之为点触验证码。 另外还有一个专门提供点触验证码服务的站点,叫做 TouClick,其官方网站为:https://www.touclick.com/,本节就以它为例讲解一下此类验证码的识别过程。

1. 本节目标

本节我们的目标是用程序来识别并通过点触验证码的验证。

2. 准备工作

本次我们使用的 Python 库是 Selenium,使用的浏览器为 Chrome,在此之前请确保已经正确安装好了 Selenium 库、Chrome 浏览器并配置好了 ChromeDriver,相关流程可以参考第一章的说明。

3. 了解点触验证码

TouClick 官方网站的验证码样式如图 8-19 所示: 图 8-19 验证码样式 和 12306 站点有相似之处,不过这次是点击图片中的文字,不是图片了,另外还有各种形形色色的点触验证码,其交互形式可能略有不同,但基本原理都是类似的。 接下来我们就来统一实现一下此类点触验证码的识别过程。

4. 识别思路

此种验证码的如果依靠图像识别的话识别难度非常之大。 例如就 12306 来说,其识别难点有两个点,第一点是文字识别,如图 8-20 所示: 图 8-20 12306 验证码 如点击图中所有的漏斗,“漏斗”二字其实都经过变形、放缩、模糊处理了,如果要借助于前面我们讲的 OCR 技术来识别,识别的精准度会大打折扣,甚至得不到任何结果。第二点是图像的识别,我们需要将图像重新转化文字,可以借助于各种识图接口,可经我测试识别正确结果的准确率非常低,经常会出现匹配不正确或匹配不出结果的情况,而且图片本身的的清晰度也不够,所以识别难度会更大,更何况需要同时识别出八张图片的结果,且其中几个答案需要完全匹配正确才能验证通过,综合来看,此种方法基本是不可行的。 再拿 TouClick 来说,如图 8-21 所示: 图 8-21 验证码示例 我们需要从这幅图片中识别出植株二字,但是图片的背景或多或少会有干扰,导致 OCR 几乎不会识别出结果,有人会说,直接识别白色的文字不就好了吗?但是如果换一张验证码呢?如图 8-22 所示: 图 8-22 验证码示例 这张验证码图片的文字又变成了蓝色,而且还又有白色阴影,识别的难度又会大大增加。 那么此类验证码就没法解了吗?答案当然是有,靠什么?靠人。 靠人解决?那还要程序做什么?不要急,这里说的人并不是我们自己去解,在互联网上存在非常多的验证码服务平台,平台 7x24 小时提供验证码识别服务,一张图片几秒就会获得识别结果,准确率可达 90% 以上,但是就需要花点钱来购买服务了,毕竟平台都是需要盈利的,不过不用担心,识别一个验证码只需要几分钱。 在这里我个人比较推荐的一个平台是超级鹰,其官网为:https://www.chaojiying.com,非广告。 其提供的服务种类非常广泛,可识别的验证码类型非常多,其中就包括此类点触验证码。 另外超级鹰平台同样支持简单的图形验证码识别,如果 OCR 识别有难度,同样可以用本节相同的方法借助此平台来识别,下面是此平台提供的一些服务:

  • 英文数字,提供最多 20 位英文数字的混合识别
  • 中文汉字,提供最多 7 个汉字的识别
  • 纯英文,提供最多 12 位的英文的识别
  • 纯数字,提供最多 11 位的数字的识别
  • 任意特殊字符,提供不定长汉字英文数字、拼音首字母、计算题、成语混合、 集装箱号等字符的识别
  • 坐标选择识别,如复杂计算题、选择题四选一、问答题、点击相同的字、物品、动物等返回多个坐标的识别

具体如有变动以官网为准:https://www.chaojiying.com/price.html。 而本节我们需要解决的就是属于最后一类,坐标多选识别的情况,我们需要做的就是将验证码图片提交给平台,然后平台会返回识别结果在图片中的坐标位置,接下来我们再解析坐标模拟点击就好了。 原理非常简单,下面我们就来实际用程序来实验一下。

5. 注册账号

在开始之前,我们需要先注册一个超级鹰账号并申请一个软件 ID,注册页面链接为:https://www.chaojiying.com/user/reg/,注册完成之后还需要在后台开发商中心添加一个软件 ID,最后一件事就是充值一些题分,充值多少可以根据价格和识别量自行决定。

6. 获取 API

做好上面的准备工作之后我们就可以开始用程序来对接验证码的识别了。 首先我们可以到官方网站下载对应的 Python API,链接为:https://www.chaojiying.com/api-14.html,但是此 API 是 Python2 版本的,是用 Requests 库来实现的,我们可以简单更改几个地方即可将其修改为 Python3 版本。 修改之后的 API 如下:

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 requests
from hashlib import md5

class Chaojiying(object):

def __init__(self, username, password, soft_id):
self.username = username
self.password = md5(password.encode('utf-8')).hexdigest()
self.soft_id = soft_id
self.base_params = {
'user': self.username,
'pass2': self.password,
'softid': self.soft_id,
}
self.headers = {
'Connection': 'Keep-Alive',
'User-Agent': 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)',
}

def post_pic(self, im, codetype):
"""
im: 图片字节
codetype: 题目类型 参考 http://www.chaojiying.com/price.html
"""
params = {
'codetype': codetype,
}
params.update(self.base_params)
files = {'userfile': ('ccc.jpg', im)}
r = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, files=files, headers=self.headers)
return r.json()

def report_error(self, im_id):
"""
im_id:报错题目的图片ID
"""
params = {
'id': im_id,
}
params.update(self.base_params)
r = requests.post('http://upload.chaojiying.net/Upload/ReportError.php', data=params, headers=self.headers)
return r.json()

这里定义了一个 Chaojiying 类,其构造函数接收三个参数,分别是超级鹰的用户名、密码以及软件 ID,保存好以备使用。 接下来是最重要的一个方法叫做 post_pic(),这里需要传入图片对象和验证码的代号,该方法会将图片对象和相关信息发给超级鹰的后台进行识别,然后将识别成功的 Json 返回回来。 另一个方法叫做 report_error(),这个是发生错误的时候的回调,如果验证码识别错误,调用此方法会返还相应的题分。 接下来我们以 TouClick 的官网为例来进行演示点触验证码的识别过程,链接为:http://admin.touclick.com/,如果没有注册账号可以先注册一个。

7. 初始化

首先我们需要初始化一些变量,如 WebDriver、Chaojiying 对象等等,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
EMAIL = 'cqc@cuiqingcai.com'
PASSWORD = ''
# 超级鹰用户名、密码、软件ID、验证码类型
CHAOJIYING_USERNAME = 'Germey'
CHAOJIYING_PASSWORD = ''
CHAOJIYING_SOFT_ID = 893590
CHAOJIYING_KIND = 9102

class CrackTouClick():
def __init__(self):
self.url = 'http://admin.touclick.com/login.html'
self.browser = webdriver.Chrome()
self.wait = WebDriverWait(self.browser, 20)
self.email = EMAIL
self.password = PASSWORD
self.chaojiying = Chaojiying(CHAOJIYING_USERNAME, CHAOJIYING_PASSWORD, CHAOJIYING_SOFT_ID)

这里的账号和密码请自行修改。

8. 获取验证码

接下来的第一步就是完善相关表单,然后模拟点击呼出验证码,此步非常简单,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def open(self):
"""
打开网页输入用户名密码
:return: None
"""
self.browser.get(self.url)
email = self.wait.until(EC.presence_of_element_located((By.ID, 'email')))
password = self.wait.until(EC.presence_of_element_located((By.ID, 'password')))
email.send_keys(self.email)
password.send_keys(self.password)

def get_touclick_button(self):
"""
获取初始验证按钮
:return:
"""
button = self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME, 'touclick-hod-wrap')))
return button

在这里 open() 方法负责填写表单,get_touclick_button() 方法则是获取验证码按钮,随后触发点击即可。 接下来我们需要类似上一节极验验证码图像获取一样,首先获取验证码图片的位置和大小,随后从网页截图里面截取相应的验证码图片就好了。代码实现如下:

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
def get_touclick_element(self):
"""
获取验证图片对象
:return: 图片对象
"""
element = self.wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'touclick-pub-content')))
return element

def get_position(self):
"""
获取验证码位置
:return: 验证码位置元组
"""
element = self.get_touclick_element()
time.sleep(2)
location = element.location
size = element.size
top, bottom, left, right = location['y'], location['y'] + size['height'], location['x'], location['x'] + size[
'width']
return (top, bottom, left, right)

def get_screenshot(self):
"""
获取网页截图
:return: 截图对象
"""
screenshot = self.browser.get_screenshot_as_png()
screenshot = Image.open(BytesIO(screenshot))
return screenshot

def get_touclick_image(self, name='captcha.png'):
"""
获取验证码图片
:return: 图片对象
"""
top, bottom, left, right = self.get_position()
print('验证码位置', top, bottom, left, right)
screenshot = self.get_screenshot()
captcha = screenshot.crop((left, top, right, bottom))
return captcha

在这里 get_touclick_image() 方法即为从网页截图中截取对应的验证码图片,其中验证码图片的相对位置坐标由 get_position() 方法返回得到,最后我们得到的是一个 Image 对象。

9. 识别验证码

随后我们调用 Chaojiying 对象的 post_pic() 方法即可把图片发送给超级鹰后台,在这里发送的图像是字节流格式,代码实现如下:

1
2
3
4
5
6
image = self.get_touclick_image()
bytes_array = BytesIO()
image.save(bytes_array, format='PNG')
# 识别验证码
result = self.chaojiying.post_pic(bytes_array.getvalue(), CHAOJIYING_KIND)
print(result)

这样运行之后 result 变量就是超级鹰后台的识别结果,可能运行需要等待几秒,毕竟后台还有人工来完成识别。 返回的结果是一个 Json,如果识别成功后一个典型的返回结果类似如下:

1
{'err_no': 0, 'err_str': 'OK', 'pic_id': '6002001380949200001', 'pic_str': '132,127|56,77', 'md5': '1f8e1d4bef8b11484cb1f1f34299865b'}

其中 pic_str 就是识别的文字的坐标,是以字符串形式返回的,每个坐标都以 | 分隔,所以接下来我们只需要将其解析之后再模拟点击即可,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_points(self, captcha_result):
"""
解析识别结果
:param captcha_result: 识别结果
:return: 转化后的结果
"""
groups = captcha_result.get('pic_str').split('|')
locations = [[int(number) for number in group.split(',')] for group in groups]
return locations

def touch_click_words(self, locations):
"""
点击验证图片
:param locations: 点击位置
:return: None
"""
for location in locations:
print(location)
ActionChains(self.browser).move_to_element_with_offset(self.get_touclick_element(), location[0], location[1]).click().perform()
time.sleep(1)

在这里我们用 get_points() 方法将识别结果变成了列表的形式,最后 touch_click_words() 方法则通过调用 move_to_element_with_offset() 方法依次传入解析后的坐标,然后点击即可。 这样我们就可以模拟完成坐标的点选了,运行效果如图 8-23 所示: 图 8-23 点选效果 最后我们需要做的就是点击提交验证的按钮等待验证通过,再点击登录按钮即可成功登录,后续实现在此不再赘述。 这样我们就借助于在线验证码平台完成了点触验证码的识别,此种方法也是一种通用方法,用此方法来识别 12306 等验证码也是完全相同的原理。

10. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/CrackTouClick

11. 结语

本节我们通过在线打码平台辅助完成了验证码的识别,这种识别方法非常强大,几乎任意的验证码都可以识别,如果遇到难题,借助于打码平台无疑是一个极佳的选择。

Python

上节我们了解了图形验证码的识别,简单的图形验证码我们可以直接利用 Tesserocr 来识别,但是近几年又出现了一些新型验证码,如滑动验证码,比较有代表性的就是极验验证码,它需要拖动拼合滑块才可以完成验证,相对图形验证码来说识别难度上升了几个等级,本节来讲解下极验验证码的识别过程。

1. 本节目标

本节我们的目标是用程序来识别并通过极验验证码的验证,其步骤有分析识别思路、识别缺口位置、生成滑块拖动路径,最后模拟实现滑块拼合通过验证。

2. 准备工作

本次我们使用的 Python 库是 Selenium,使用的浏览器为 Chrome,在此之前请确保已经正确安装好了 Selenium 库、Chrome 浏览器并配置好了 ChromeDriver,相关流程可以参考第一章的说明。

3. 了解极验验证码

极验验证码其官网为:http://www.geetest.com/,它是一个专注于提供验证安全的系统,主要验证方式是拖动滑块拼合图像,若图像完全拼合,则验证成功,即可以成功提交表单,否则需要重新验证,样例如图 8-5 和 8-6 所示: 图 8-5 验证码示例 图 8-6 验证码示例 现在极验验证码已经更新到了 3.0 版本,截至 2017 年 7 月全球已有十六万家企业正在使用极验,每天服务响应超过四亿次,广泛应用于直播视频、金融服务、电子商务、游戏娱乐、政府企业等各大类型网站,下面是斗鱼、魅族的登录页面,可以看到其都对接了极验验证码,如图 8-7 和 8-8 所示: 图 8-7 斗鱼登录页面 图 8-8 魅族登录页面

4. 极验验证码的特点

这种验证码相较于图形验证码来说识别难度更大,极验验证码首先需要在前台验证通过,对于极验 3.0,我们首先需要点击按钮进行智能验证,如果验证不通过,则会弹出滑动验证的窗口,随后需要拖动滑块拼合图像进行验证,验证之后会生成三个加密参数,参数随后通过表单提交到后台,后台还会进行一次验证。 另外极验还增加了机器学习的方法来识别拖动轨迹,官方网站的安全防护说明如下:

  • 三角防护之防模拟

恶意程序模仿人类行为轨迹对验证码进行识别。针对模拟,极验拥有超过 4000 万人机行为样本的海量数据。利用机器学习和神经网络构建线上线下的多重静态、动态防御模型。识别模拟轨迹,界定人机边界。

  • 三角防护之防伪造

恶意程序通过伪造设备浏览器环境对验证码进行识别。针对伪造,极验利用设备基因技术。深度分析浏览器的实际性能来辨识伪造信息。同时根据伪造事件不断更新黑名单,大幅提高防伪造能力。

  • 三角防护之防暴力

恶意程序短时间内进行密集的攻击,对验证码进行暴力识别 针对暴力,极验拥有多种验证形态,每一种验证形态都有利用神经网络生成的海量图库储备,每一张图片都是独一无二的,且图库不断更新,极大程度提高了暴力识别的成本。 另外极验的验证相对于普通验证方式更加方便,体验更加友好,其官方网站说明如下:

  • 点击一下,验证只需要 0.4 秒

极验始终专注于去验证化实践,让验证环节不再打断产品本身的交互流程,最终达到优化用户体验和提高用户转化率的效果。

  • 全平台兼容,适用各种交互场景

极验兼容所有主流浏览器甚至古老的 IE6,也可以轻松应用在 iOS 和 Android 移动端平台,满足各种业务需求,保护网站资源不被滥用和盗取。

  • 面向未来,懂科技,更懂人性

极验在保障安全同时不断致力于提升用户体验,精雕细琢的验证面板,流畅顺滑的验证动画效果,让验证过程不再枯燥乏味。 因此,相较于一般验证码,极验的验证安全性和易用性有了非常大的提高。

5. 识别思路

但是对于应用了极验验证码的网站,识别并不是没有办法的。如果我们直接模拟表单提交的话,加密参数的构造是个问题,参数构造有问题服务端就会校验失败,所以在这里我们采用直接模拟浏览器动作的方式来完成验证,在 Python 中我们就可以使用 Selenium 来通过完全模拟人的行为的方式来完成验证,此验证成本相对于直接去识别加密算法容易不少。 首先我们找到一个带有极验验证的网站,最合适的当然为极验官方后台了,链接为:https://account.geetest.com/login,首先可以看到在登录按钮上方有一个极验验证按钮,如图 8-9 所示: 图 8-9 验证按钮 此按钮为智能验证按钮,点击一下即可智能验证,一般来说如果是同一个 Session,一小段时间内第二次登录便会直接通过验证,如果智能识别不通过,则会弹出滑动验证窗口,我们便需要拖动滑块来拼合图像完成二步验证,如图 8-10 所示: 图 8-10 拖动示例 验证成功后验证按钮便会变成如下状态,如图 8-11 所示: 图 8-11 验证成功结果 接下来我们便可以进行表单提交了。 所以在这里我们要识别验证需要做的有三步:

  • 模拟点击验证按钮
  • 识别滑动缺口的位置
  • 模拟拖动滑块

第一步操作是最简单的,我们可以直接用 Selenium 模拟点击按钮即可。 第二步操作识别缺口的位置比较关键,需要用到图像的相关处理方法,那缺口怎么找呢?首先来观察一下缺口的样子,如图 8-12 和 8-13 所示: 图 8-12 缺口示例 图 8-13 缺口示例 可以看到缺口的四周边缘有明显的断裂边缘,而且边缘和边缘周围有明显的区别,我们可以实现一个边缘检测算法来找出缺口的位置。对于极验来说,我们可以利用和原图对比检测的方式来识别缺口的位置,因为在没有滑动滑块之前,缺口其实是没有呈现的,如图 8-14 所示: 图 8-14 初始状态 所以我们可以同时获取两张图片,设定一个对比阈值,然后遍历两张图片找出相同位置像素 RGB 差距超过此阈值的像素点位置,那么此位置就是缺口的位置。 第三步操作看似简单,但是其中的坑比较多,极验验证码增加了机器轨迹识别,匀速移动、随机速度移动等方法都是不行的,只有完全模拟人的移动轨迹才可以通过验证,而人的移动轨迹一般是先加速后减速的,这又涉及到物理学中加速度的相关问题,我们需要模拟这个过程才能成功。 有了基本的思路之后就让我们用程序来实现一下它的识别过程吧。

6. 初始化

首先这次我们选定的链接为:https://account.geetest.com/login,也就是极验的管理后台登录页面,在这里我们首先初始化一些配置,如 Selenium 对象的初始化及一些参数的配置:

1
2
3
4
5
6
7
8
9
10
EMAIL = 'test@test.com'
PASSWORD = '123456'

class CrackGeetest():
def __init__(self):
self.url = 'https://account.geetest.com/login'
self.browser = webdriver.Chrome()
self.wait = WebDriverWait(self.browser, 20)
self.email = EMAIL
self.password = PASSWORD

其中 EMAIL 和 PASSWORD 就是登录极验需要的用户名和密码,如果没有的话可以先注册一下。

7. 模拟点击

随后我们需要实现第一步的操作,也就是模拟点击初始的验证按钮,所以我们定义一个方法来获取这个按钮,利用显式等待的方法来实现:

1
2
3
4
5
6
7
def get_geetest_button(self):
"""
获取初始验证按钮
:return: 按钮对象
"""
button = self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME, 'geetest_radar_tip')))
return button

获取之后就会获取一个 WebElement 对象,调用它的 click() 方法即可模拟点击,代码如下:

1
2
3
# 点击验证按钮
button = self.get_geetest_button()
button.click()

到这里我们第一步的工作就完成了。

8. 识别缺口

接下来我们需要识别缺口的位置,首先我们需要将前后的两张比对图片获取下来,然后比对二者的不一致的地方即为缺口。首先我们需要获取不带缺口的图片,利用 Selenium 选取图片元素,然后得到其所在位置和宽高,随后获取整个网页的截图,再从截图中裁切出来即可,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def get_position(self):
"""
获取验证码位置
:return: 验证码位置元组
"""
img = self.wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'geetest_canvas_img')))
time.sleep(2)
location = img.location
size = img.size
top, bottom, left, right = location['y'], location['y'] + size['height'], location['x'], location['x'] + size[
'width']
return (top, bottom, left, right)

def get_geetest_image(self, name='captcha.png'):
"""
获取验证码图片
:return: 图片对象
"""
top, bottom, left, right = self.get_position()
print('验证码位置', top, bottom, left, right)
screenshot = self.get_screenshot()
captcha = screenshot.crop((left, top, right, bottom))
return captcha

在这里 get_position() 函数首先获取了图片对象,然后获取了它的位置和宽高,随后返回了其左上角和右下角的坐标。而 get_geetest_image() 方法则是获取了网页截图,然后调用了 crop() 方法将图片再裁切出来,返回的是 Image 对象。 随后我们需要获取第二张图片,也就是带缺口的图片,要使得图片出现缺口,我们只需要点击一下下方的滑块即可,触发这个动作之后,图片中的缺口就会显现,实现如下:

1
2
3
4
5
6
7
def get_slider(self):
"""
获取滑块
:return: 滑块对象
"""
slider = self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME, 'geetest_slider_button')))
return slider

利用 get_slider() 方法获取滑块对象,接下来调用其 click() 方法即可触发点击,缺口图片即可呈现:

1
2
3
# 点按呼出缺口
slider = self.get_slider()
slider.click()

随后还是调用 get_geetest_image() 方法将第二张图片获取下来即可。 到现在我们就已经得到了两张图片对象了,分别赋值给变量 image1 和 image2,接下来对比图片获取缺口即可。要对比图片的不同之处,我们在这里遍历图片的每个坐标点,获取两张图片对应像素点的 RGB 数据,然后判断二者的 RGB 数据差异,如果差距超过在一定范围内,那就代表两个像素相同,继续比对下一个像素点,如果差距超过一定范围,则判断像素点不同,当前位置即为缺口位置,代码实现如下:

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
def is_pixel_equal(self, image1, image2, x, y):
"""
判断两个像素是否相同
:param image1: 图片1
:param image2: 图片2
:param x: 位置x
:param y: 位置y
:return: 像素是否相同
"""
# 取两个图片的像素点
pixel1 = image1.load()[x, y]
pixel2 = image2.load()[x, y]
threshold = 60
if abs(pixel1[0] - pixel2[0]) < threshold and abs(pixel1[1] - pixel2[1]) < threshold and abs(
pixel1[2] - pixel2[2]) < threshold:
return True
else:
return False

def get_gap(self, image1, image2):
"""
获取缺口偏移量
:param image1: 不带缺口图片
:param image2: 带缺口图片
:return:
"""
left = 60
for i in range(left, image1.size[0]):
for j in range(image1.size[1]):
if not self.is_pixel_equal(image1, image2, i, j):
left = i
return left
return left

get_gap() 方法即为获取缺口位置的方法,此方法的参数为两张图片,一张为带缺口图片,另一张为不带缺口图片,在这里遍历两张图片的每个像素,然后利用 is_pixel_equal() 方法判断两张图片同一位置的像素是否相同,比对的时候比较了两张图 RGB 的绝对值是否均小于定义的阈值 threshold,如果均在阈值之内,则像素点相同,继续遍历,否则遇到不相同的像素点就是缺口的位置。 在这里比如两张对比图片如下,如图 8-15 和 8-16 所示: 图 8-15 初始状态 图 8-16 后续状态 两张图片其实有两处明显不同的地方,一个就是待拼合的滑块,一个就是缺口,但是滑块的位置会出现在左边位置,缺口会出现在与滑块同一水平线的位置,所以缺口一般会在滑块的右侧,所以要寻找缺口的话,我们直接从滑块右侧寻找即可,所以在遍历的时候我们直接设置了遍历的起始横坐标为 60,也就是在滑块的右侧开始识别,这样识别出的结果就是缺口的位置了。 到现在为止,我们就可以获取缺口的位置了,剩下最后一步模拟拖动就可以完成验证了。

9. 模拟拖动

模拟拖动的这个过程说复杂并不复杂,只是其中的坑比较多。现在我们已经获取到了缺口的位置,接下来只需要调用拖动的相关函数将滑块拖动到对应位置不就好了吗?然而事实很残酷,如果匀速拖动,极验必然会识别出来这是程序的操作,因为人是无法做到完全匀速拖动的,极验利用机器学习模型筛选出此类数据,归类为机器操作,验证码识别失败。 随后我又尝试了分段模拟,将拖动过程划分几段,每段设置一个平均速度,同时速度围绕该平均速度小幅度随机抖动,同样无法完成验证。 最后尝试了完全模拟加速减速的过程通过了验证,在前段滑块需要做匀加速运动,后面需要做匀减速运动,在这里利用物理学的加速度公式即可完成。 设滑块滑动的加速度用 a 来表示,当前速度用 v 表示,初速度用 v0 表示,位移用 x 表示,所需时间用 t 表示,则它们之间满足如下关系:

1
2
x = v0 * t + 0.5 * a * t * t
v = v0 + a * t

接下来我们利用两个公式可以构造一个轨迹移动算法,计算出先加速后减速的运动轨迹,代码实现如下:

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
def get_track(self, distance):
"""
根据偏移量获取移动轨迹
:param distance: 偏移量
:return: 移动轨迹
"""
# 移动轨迹
track = []
# 当前位移
current = 0
# 减速阈值
mid = distance * 4 / 5
# 计算间隔
t = 0.2
# 初速度
v = 0

while current < distance:
if current < mid:
# 加速度为正2
a = 2
else:
# 加速度为负3
a = -3
# 初速度v0
v0 = v
# 当前速度v = v0 + at
v = v0 + a * t
# 移动距离x = v0t + 1/2 * a * t^2
move = v0 * t + 1 / 2 * a * t * t
# 当前位移
current += move
# 加入轨迹
track.append(round(move))
return track

在这里我们定义了 get_track() 方法,传入的参数为移动的总距离,返回的是运动轨迹,用 track 表示,它是一个列表,列表的每个元素代表每次移动多少距离。 首先定义了一个变量 mid,即减速的阈值,也就是加速到什么位置就开始减速,在这里定义为 4/5,即模拟前 4/5 路程是加速过程,后 1/5 是减速过程。 随后定义了当前位移的距离变量 current,初始为 0,随后进入 while 循环,循环的条件是当前位移小于总距离。在循环里我们分段定义了加速度,其中加速过程加速度定义为 2,减速过程加速度定义为 -3,随后再套用位移公式计算出某个时间段内的位移,同时将当前位移更新并记录到轨迹里即可。 这样直到运动轨迹达到总距离时即终止循环,最后得到的 track 即记录了每个时间间隔移动了多少位移,这样滑块的运动轨迹就得到了。 最后我们只需要按照该运动轨迹拖动滑块即可,方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
def move_to_gap(self, slider, tracks):
"""
拖动滑块到缺口处
:param slider: 滑块
:param tracks: 轨迹
:return:
"""
ActionChains(self.browser).click_and_hold(slider).perform()
for x in tracks:
ActionChains(self.browser).move_by_offset(xoffset=x, yoffset=0).perform()
time.sleep(0.5)
ActionChains(self.browser).release().perform()

在这里传入的参数为滑块对象和运动轨迹,首先调用 ActionChains 的 click_and_hold() 方法按住拖动底部滑块,随后遍历运动轨迹获取每小段位移距离,调用 move_by_offset() 方法移动此位移,最后移动完成之后调用 release() 方法松开鼠标即可。 这样再经过测试,验证就通过了,识别完成,效果图 8-17 所示: 图 8-17 识别成功结果 最后,我们只需要将表单完善,模拟点击登录按钮即可完成登录,成功登录后即跳转到后台。 至此,极验验证码的识别工作即全部完成,此识别方法同样适用于其他使用极验 3.0 的网站,原理都是相同的。

10. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/CrackGeetest

11. 结语

本节我们分析并实现了极验验证码的识别,其关键在于识别的思路,如怎样识别缺口位置,怎样生成运动轨迹等,学会了这些思路后以后我们再遇到类似原理的验证码同样可以完成识别过程。

Python

本节我们首先来尝试识别最简单的一种验证码,图形验证码,这种验证码出现的最早,现在也很常见,一般是四位字母或者数字组成的,例如中国知网的注册页面就有类似的验证码,链接为:http://my.cnki.net/elibregister/commonRegister.aspx,页面如图 8-1 所示: 图 8-1 知网注册页面 表单的最后一项就是图形验证码,我们必须完全输入正确图中的字符才可以完成注册。

1. 本节目标

本节我们就以知网的验证码为例,讲解一下利用 OCR 技术识别此种图形验证码的方法。

2. 准备工作

识别图形验证码需要的库有 Tesserocr,如果没有安装可以参考第一章的安装说明。

3. 获取验证码

为了便于实验,我们先将验证码的图片保存到本地,以供测试。 打开开发者工具,找到验证码元素,可以看到这是一张图片,它的 src 属性是 CheckCode.aspx,在这里我们直接将这个链接打开:http://my.cnki.net/elibregister/CheckCode.aspx,就可以看到一个验证码,直接右键保存下来即可,将名称命名为 code.jpg,如图 8-2 所示: 图 8-2 验证码 这样我们就可以得到一张验证码图片供下面测试识别使用了。

4. 识别测试

接下来我们新建一个项目,将验证码图片放到项目根目录下,用 Tesserocr 库来识别一下该验证码试试,代码如下:

1
2
3
4
5
6
import tesserocr
from PIL import Image

image = Image.open('code.jpg')
result = tesserocr.image_to_text(image)
print(result)

在这里我们首先新建了一个 Image 对象,然后调用了 Tesserocr 的 image_to_text() 方法,传入该 Image 对象即可完成识别,实现过程非常简单,识别结果如下:

1
JR42

另外 Tesserocr 还有一个更加简单的方法直接将图片文件转为字符串可以达到同样的效果,代码如下:

1
2
import tesserocr
print(tesserocr.file_to_text('image.png'))

不过经测试此种方法的识别效果不如上一种方法好。

5. 验证码处理

如上的图片识别基本没有难度,只是新建一个 Image 对象,然后调用 image_to_text() 方法即可得出图片的识别结果。 接下来我们换一个验证码试一下,命名为 code2.jpg,如图 8-3 所示: 图 8-3 验证码 重新用下面的代码测试一下:

1
2
3
4
5
6
import tesserocr
from PIL import Image

image = Image.open('code2.jpg')
result = tesserocr.image_to_text(image)
print(result)

这时可以看到如下输出结果:

1
FFKT

发现这次识别和实际的结果有所偏差,这是因为验证码内的多余线条干扰了图片的识别。 对于这种情况,我们还需要做一下额外的处理,如转灰度、二值化等操作。 我们可以利用 Image 对象的 convert() 方法参数传入 L 即可将图片转化为灰度图像,代码如下:

1
2
image = image.convert('L')
image.show()

传入 1 即可将图片进行二值化处理:

1
2
image = image.convert('1')
image.show()

另外我们还可以指定二值化的阈值,上面的方法采用的是默认阈值 127,不过我们不能用原图直接转化,可以先转为灰度图像,然后再指定二值化阈值转化,代码如下:

1
2
3
4
5
6
7
8
9
10
11
image = image.convert('L')
threshold = 80
table = []
for i in range(256):
if i < threshold:
table.append(0)
else:
table.append(1)

image = image.point(table, '1')
image.show()

在这里我们指定了一个变量 threshold 代表二值化阈值,阈值设置为 80,处理之后我们看一下结果,如图 8-4 所示: 图 8-4 处理结果 经过处理之后我们发现原来的验证码中的线条已经被去除了,而且整个验证码变得黑白分明,这时重新识别验证码,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import tesserocr
from PIL import Image

image = Image.open('code2.jpg')

image = image.convert('L')
threshold = 127
table = []
for i in range(256):
if i < threshold:
table.append(0)
else:
table.append(1)

image = image.point(table, '1')
result = tesserocr.image_to_text(image)
print(result)

即可发现运行结果变成了:

1
PFRT

识别正确。 可见对于一些有干扰的图片,我们做一些灰度和二值化处理,会提高其识别正确率。

6. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/CrackImageCode

7. 结语

本节我们了解了利用 Tesserocr 识别验证码的过程,对于简单的图形验证码我们可以直接用它来得到结果,如果要提高识别的准确度还可以对验证码图片做一下预处理。

Python

当今时代,许多网站为了反爬虫采用了各种各样的措施,其中之一便是使用验证码,随着技术的发展,验证码的花样也越来越多,最初可能是几个数字组合的简单的图形验证码,后来加入了英文字母和混淆曲线使得验证码更加复杂,有的网站还可能看到中文字符的验证码,使得识别愈发困难。 而后来 12306 验证码的出现又开辟了验证码的新纪元,用过 12306 的肯定多少为它的验证码头疼过,它需要我们去识别文字,然后再点击文字描述相符的图片,只有完全正确才可以验证通过。现在这种交互式验证码越来越多,如极验滑动验证码需要滑动拼合滑块才可以完成验证,点触的验证码需要完全点击正确的结果才可以完成验证,另外还有一些滑动宫格验证码,计算题验证码等等五花八门。 验证码变得越来越复杂,爬虫的工作也变得愈发艰难,有时候我们必须通过验证码的验证才可以访问页面,所以本章专门来针对验证码的识别做一下统一的讲解。 本章涉及的验证码有普通图形验证码、极验滑动验证码、点触验证码、微博宫格验证码,识别的方式和思路各有不同,了解了这几个验证码的识别方式之后,我们可以举一反三,用类似的方法识别其他类型的验证码。

生活笔记

人生一共就是由几个10年组成的,每个10年都有不同的经历,不同的心境,每个10年追求的东西都不一样。 鱼与熊掌不可兼得,在追求的过程中你总要有取舍,该取什么?该舍什么?

10岁时

应该不再计较家里给的零花钱多少,不和别人家的孩子比较穿名牌服装;少不更事,和人家比吃穿,还情有可原,年纪到了整数就该懂事了。

20岁时

不再计较自己的家庭出身,不再计较父母的职业十几岁时,会和别的孩子比较家庭出身,比爹娘官大官小,恨不得都投生帝王之家、将相之门,也是人之常情。 但如果到了“弱冠”之年,还弱不禁风,尚无自立之志,出身贫寒的还为家庭自卑,老觉得抬不起头来;出身富豪的还处处依靠父母,在家庭荫护下养尊处优,那就会一辈子都没出息。

30岁时

已成家立业,为父为母,有了几年家庭生活的经验。大丈夫该不再计较妻子的容貌,深知贤惠比美貌更重要,会过日子的媳妇比会打扮的媳妇更让人待见; 老婆该不再计较老公的身高,明白能力比身高更有作用,没有谋生能力的老公,纵然长成丈二金刚,还不如卖炊饼的大郎。

40岁时

不再计较别人的议论,谁爱说啥就说啥,自己想咋过就咋过。 人言可畏,想想上世纪30年代的阮玲玉还成如今的明星,一星期听不到他的绯闻轶事没人对他议论纷纷,他就急得火烧火燎的。 咱们虽然没有明星那高深道行,但不会再轻易被别人议论左右,这点本事应该有的,否则也对不起“不惑”这两个字啊。

50岁时

不再计较无处不在的不平之事,不再计较别人的成功对自己的压力,不再眼红他人的财。 半百之年,曾经沧海,阋人无数,见惯秋月春风,不再大惊小怪;历尽是非成败,不再愤愤不平看新贵飞扬跋扈,可不动声色;看大款挥金如土,也气定神闲,耐住性子。

60岁时

如果从政,该不再计较官大官小,退了体,官大官小一个样,都是退体干部。 如果经商,该不再计较利大利小,钱是挣不完的,再能花也是有限的,心态平和对自己身体有好处。 如果舞文弄墨,当不再计较文名大小,文坛座次。

70岁时

人到古稀,该不再计较的东西更多,看淡的事情更广。 年轻时争得你死我活的东西,现在只会淡然一笑。 中年时费尽心机格外计较的东西,如今看来已无关紧要。一生多少事,“都付笑谈中”。 这个岁数的老人,要有三样特别积极健康的身体、和谐的家庭、良好的名声。 人生在世,如果计较的东西太多,名利地位,金钱美色,样样都不肯放手,那就会如牛重负,活得很累。 反之,什么都不计较,什么都马马虎虎,什么都可以凑合,那也未免太对不起自己,活得没啥意思。 人生智慧 聪明的人,有生活智慧的人,会有所为,有所不为。他们只计较对自己最重要的东西, 并且知道什么年龄该计较什么,不该计较什么,有取有舍,收放自如。

Python

相比常用的 os.path而言,pathlib 对于目录路径的操作更简介也更贴近 Pythonic。但是它不单纯是为了简化操作,还有更大的用途

概述

pathlib 是Python内置库,Python 文档给它的定义是 Object-oriented filesystem paths(面向对象的文件系统路径)。pathlib 提供表示文件系统路径的类,其语义适用于不同的操作系统。路径类在纯路径之间划分,纯路径提供纯粹的计算操作而没有I / O,以及具体路径,它继承纯路径但也提供I / O操作。 听起来有点绕?那就对了,毕竟这是直译过来的,但这并不影响我们喜爱它。 我们通过几个例子来了解它吧

举个栗子

相对于 os 模块的 path 方法,Python3 标准库 pathlib 模块的 Path 对路径的操作会更简单。

获取当前文件路径

使用 os 模块时,有两种方法可以直接获取当前文件路径:

1
2
3
4
5
6
import os

value1 = os.path.dirname(__file__)
value2 = os.getcwd()
print(value1)
print(value2)

pathlib 获取当前文件路径应该怎么写呢? 官方文档给出了建议 插眼传送 动手试一试

1
2
3
4
import pathlib

value1 = pathlib.Path.cwd()
print(value1)

它是如何实现的

文档中有介绍,它以 os.getcwd() 的形式将路径返回。我们去源码中一探究竟(Pycharm 编辑器快捷键 ctrl+鼠标左键点击即可跟进指定对象) 原来它是对 os 模块中一些对象进行了封装,看 cwd 的注释: Return a new path pointing to the current working directory 意为:返回指向当前工作目录的新路径。 看起来也没什么特别的,但是为什么官方特意将它推出呢?

其他的封装

pathlib 封装了很多的 os path ,文档中有写明,如:

1
2
3
4
5
6
7
8
# 关系说明
os.path.expanduser() --> pathlib.Path.home()

os.path.expanduser() --> pathlib.Path.expanduser()

os.stat() --> pathlib.Path.stat()

os.chmod() --> pathlib.Path.chmod()

官网文档截图: 详细请查看官方文档:插眼传送

再举几个栗子

刚才的案例并不能说明什么,只是让我们了解到 pathlib 的构成,接下来让我们感受一下它带给我们的便捷。

获取上层/上层目录

也就是获取它爷爷的名字 os 模块的写法为:

1
2
3
import os

print(os.path.dirname(os.path.dirname(os.getcwd())))

如果用 pathlib 来实现:

1
2
3
import pathlib

print(pathlib.Path.cwd().parent.parent)

parent 就完事了,这是不是更贴近 Pythonic ? 像写英语一样写代码。 如果你只需要找到它爸爸,那就使用一次:

1
2
3
import pathlib

print(pathlib.Path.cwd().parent)

你还可以继续往祖辈上找:

1
2
3
import pathlib

print(pathlib.Path.cwd().parent.parent.parent)

相对与之前 os 模块使用的多层 os.path.dirname,使用 parent 是不是便捷很多?

路径拼接

如果你要在它爷爷辈那里拼接路径,那么你需要写这么长一串代码:

1
2
3
import os

print(os.path.join(os.path.dirname(os.path.dirname(os.getcwd())), "关注", "微信公众号", "【进击的", "Coder】"))

当你用 pathlib 的时候,你一定能够感受到快乐:

1
2
3
4
import pathlib

parts = ["关注", "微信公众号", "【进击的", "Coder】"]
print(pathlib.Path.cwd().parent.parent.joinpath(*parts))

而且你还可以通过增加或减少 parent 的数量,来实现它祖辈的调节,美哉。

PurePath

上面的操作大部分都通过 pathlib 中的 Path 实现,其实它还有另一个模块 PurePath。

PurePath 是一个纯路径对象,纯路径对象提供了实际上不访问文件系统的路径处理操作。有三种方法可以访问这些类,我们也称之为flavor。

上面这句话来自于官方文档,听起来还是有点绕,我们还是通过栗子来了解它吧

PurePath.match

让我们来判断一下,当前文件路径是否有符合 ‘*.py’ 规则的文件

1
2
3
import pathlib

print(pathlib.PurePath(__file__).match('*.py'))

很显然,我们编写代码的 coder.py 就符合规则,所以输出是 True。 为什么我要拿这个来举例呢?再深入想一下 pathlib.PurePath 后面能够跟着 match,那说明它应该是个对象,而不是一个路径字符串。为了验证这个想法,把代码改一改:

1
2
3
4
5
6
7
8
import pathlib
import os

os_path = os.path.dirname(__file__)
pure_path = pathlib.PurePath(__file__)
print(os_path, type(os_path))
print(pure_path, type(pure_path))
print(pathlib.PurePath(__file__).match('*.py'))

打印通过 os.path 获取当前路径的结果,得出一个路径字符串;而通过 pathlib.Pure 则获得的是一个 PurePosixPath 对象,并且得到的路径包括了当前文件 coder.py。 这就有点悬疑了, PurePosixPath 究竟是什么? pathlib 可以操作两种文件系统的路径,一种是 Windows 文件系统,另一种称为非 Windows 文件系统,对应的对象是 pathlib.PurePosixPathPureWindowsPath,不过不用担心,这些类并非是指定在某些操作系统上运行才能够使用,无论你运行的是哪个系统,都可以实例化所有这些类,因为它们不提供任何进行系统调用的操作。 不提供任何进行系统调用的操作,这又是什么?真是越听越深了 文档在最开始给出了这么一段描述:

Pure paths are useful in some special cases; for example: If you want to manipulate Windows paths on a Unix machine (or vice versa). You cannot instantiate a WindowsPath when running on Unix, but you can instantiate PureWindowsPath. You want to make sure that your code only manipulates paths without actually accessing the OS. In this case, instantiating one of the pure classes may be useful since those simply don’t have any OS-accessing operations. 翻译:纯路径在某些特殊情况下很有用; 例如: 如果要在Unix计算机上操作Windows路径(反之亦然)。WindowsPath在Unix上运行时无法实例化,但可以实例化PureWindowsPath。 您希望确保您的代码仅操作路径而不实际访问操作系统。在这种情况下,实例化其中一个纯类可能很有用,因为那些只是没有任何操作系统访问操作。

还附上了一张图: 一下子也不是很理解,这是什么意思。不要紧,继续往下看。

对应关系

通过以上的例子我们可以感受到,它不仅封装了 os.path 相关常用方法,还集成了 os 的其他模块,比如创建文件夹 Path.mkdir。 如果你担心记不住,没关系的,文档一直都在。并且文档给我们列出了对应关系表

基本用法

Path.iterdir()  # 遍历目录的子目录或者文件 Path.is_dir()  # 判断是否是目录 Path.glob()  # 过滤目录(返回生成器) Path.resolve()  # 返回绝对路径 Path.exists()  # 判断路径是否存在 Path.open()  # 打开文件(支持with) Path.unlink()  # 删除文件或目录(目录非空触发异常)

基本属性

Path.parts  # 分割路径 类似os.path.split(), 不过返回元组 Path.drive  # 返回驱动器名称 Path.root  # 返回路径的根目录 Path.anchor  # 自动判断返回drive或root Path.parents  # 返回所有上级目录的列表

改变路径

Path.with_name()  # 更改路径名称, 更改最后一级路径名 Path.with_suffix()  # 更改路径后缀

拼接路径

Path.joinpath()  # 拼接路径 Path.relative_to()  # 计算相对路径

测试路径

Path.match()  # 测试路径是否符合pattern Path.is_dir()  # 是否是文件 Path.is_absolute()  # 是否是绝对路径 Path.is_reserved()  # 是否是预留路径 Path.exists()  # 判断路径是否真实存在

其他方法

Path.cwd()  # 返回当前目录的路径对象 Path.home()  # 返回当前用户的home路径对象 Path.stat()  # 返回路径信息, 同os.stat() Path.chmod()  # 更改路径权限, 类似os.chmod() Path.expanduser()  # 展开~返回完整路径对象 Path.mkdir()  # 创建目录 Path.rename()  # 重命名路径 Path.rglob()  # 递归遍历所有子目录的文件

pathlib 回顾

通过上面的几个例子,我们对 pathlib 应该有一个大体的了解,接下来再回顾一下官方给 pathlib 库的定义:

This module offers classes representing filesystem paths with semantics appropriate for different operating systems. Path classes are divided between pure paths, which provide purely computational operations without I/O, and concrete paths, which inherit from pure paths but also provide I/O operations. 释义:pathlib 提供表示文件系统路径的类,其语义适用于不同的操作系统。路径类在纯路径之间划分,纯路径提供纯粹的计算操作而没有I / O,以及具体路径,它继承纯路径但也提供I / O操作。

回顾刚才这张图,重新理解 pathlib 如果你以前从未使用过这个模块,或者只是不确定哪个类适合您的任务,那么Path很可能就是您所需要的。它为代码运行的平台实例化一个具体路径。 总结:pathlib 不单纯是对 os 中一些模块或方法进行封装,而是为了兼容不同的操作系统,它为每类操作系统定义了接口。你希望在UNIX机器上操作Windows的路径,然而直接操作是做不到的,所以为你创建了一套接口 PurePath,你可以通过接口来实现你的目的(反之亦然)

技术杂谈

6月6日工信部正式向中国电信、中国移动、中国联通、中国广电发放5G商用牌照。自此,中国正式进入5G商用元年。 就中国而讲,2G时代门户网站林立、社交软件初起,3G时代智能手机大战、社交类软件成为王者,4G时代直播与短视频风光无二、信息流成热话题,那5G时代谁能独领风骚? 在5G还未全面普及之前,我们回顾一下并不遥远的昨天,在2G、3G、4G的兴起时,哪些公司应运而生,成为巨头,哪些巨头又轰然倒塌,成为历史的一部分。 2G时代,QQ崛起,阿里京东起家,门户网站林立 中国互联网的发展至今也只20年有余,自20世纪90年代末,中国人从1G时代代表性手机大哥大(摩托罗拉)手机换成了2G时代代表性手机诺基亚,彼时手机不只是接打电话,增加了发短信甚至邮件的功能。 20世纪90年代末期中国,还处在纸媒、广播和电视一统天下的传统媒体时代,但经济的迅猛发展已经造成信息饥渴,彼时媒体所提供的信息无论从丰富程度还是传播速度上都很难满足用户需求。 一个从美国麻省理工学院(MIT)攻读博士学位归来的“天之骄子”带回了一个新鲜事物,由于被美国互联网的发展深深震撼,张朝阳回国创建了中国第一家门户网站——搜狐。那是在1998年,阿里巴巴还没诞生,马化腾的QQ也未问世,李彦宏还在美国硅谷,中国互联网的舞台上,也只有张朝阳和他的搜狐。 竞争对手还未成长,搜狐独享巨大的互联网红利,2000年就上市,占据着中国门户网站头部企业的位置。 同样1998年,在搜狐成立后不到10个月,王志东的新浪网于年底成立,当时的口号是在互联网上建立全球最大的华人网站。以体育新闻传播为起点的新浪迅速扩张,进阶成为综合性新闻门户。 当时中国门户网站受资本青睐的程度,以及上市的速度堪称一个时代的奇迹,新浪网虽成立晚了搜狐10个月,但却比搜狐早了3个月在纳斯达克敲钟。 与搜狐新浪以门户网站起家不同,网易1997年成立之初以搜索引擎和免费邮箱系统立身,赶上门户网站热潮席卷中国,1998年丁磊带领网易挥师北上,落户北京,公司战略也从“系统集成商”正式转向“互联网服务提供商”。自此,网易与搜狐、新浪成为中国三大门户网站。 在张朝阳、丁磊等都借互联网东风大搞门户网站捞第一波红利的时候,在深圳创业的马化腾开始琢磨做社交。马化腾与大学同学合伙成立腾讯,当时主要做“无线网络寻呼系统”,就是将互联网和寻呼机结合,使得寻呼机可以收到互联网的传唤,并且可以收看新闻乃至电子邮件等功能。 但不可逆的是当时寻呼机业务已处于颓势,1998年,身处2G时代,手机在市场上肆意生长。马化腾当机立断,转头做IM(即时通讯软件),OICQ应运而生,OICQ1999年2月10日发布了第一个版本——OICQ 99 beta build 0210,就此,颠覆未来20年中文互联网的明星产品QQ诞生了。 此后腾讯围绕QQ延展出多款产品,包括QQ宠物、QQ空间、QQ游戏等,这为腾讯带来盈利的同时留存了海量互联网用户。QQ在中国整个2G时代都占据着社交软件第一把交椅,为腾讯后续布局游戏等可以提供天然入口,这让腾讯的产品可以轻而易举地碾压竞品。 而之后改变中国互联网格局的阿里巴巴也是在2G时代诞生的。1999年,马云带领下的18为创始人创建阿里巴巴集团。2003年,淘宝网创立,作为新生事物的淘宝网出奇制胜——没和ebay易趣争抢既有的存量市场,而是收割疯狂生长的增量市场;仅仅通过1年时间,这家“倒过来看世界”的互联网公司,就成了中国网络购物市场的领军企业。 而2004年,支付宝创立,中国第三方支付模式雏形初现。值得一提的是,同年诞生的淘宝旺旺将即时聊天工具和网络购物相联系起来,是阿里巴巴做网上零售的法宝。这时候的淘宝旺旺并没有和QQ对标,还处在阿里腾讯之间还是各自领域狂奔的时代。 在2005年淘宝的成交额就突破80亿元,甚至超越了沃尔玛。 在马云创立淘宝网,大搞电商之时,在中关村卖了几年光盘的刘强东坐不住了,2004年,京东多媒体网正式开通。或许与刘强东卖3C产品起家有关,京东电子商务平台是主打3C产品,与淘宝对标的同时又有自己清晰的定位,虽然都是做电商,但同质化并不严重。 3G时代,智能手机展露头角,巨头大战社交 整个2G时代其实属于PC端互联网,2G的手机只能打电话发短信,上网很困难。但3G通信标准将信息传输率提高了一个数量级,这是一个飞跃,3G时代真正意义上而言是移动互联网的开端,从此手机打电话的功能降到了次要的位置,而数据通信,也就是上网,成为了主要功能。 乔布斯2007年拿着一款智能手机iPhone1出现,宣布苹果主宰移动互联网时代的开始。从此手机不再以功能为主,而是以应用软件(APP)为主,APP store 更是一个划时代产品,让用户可以轻而易举地购买下载所需的应用。 中国的3G时代稍晚于美国,2009年1月,工信部为中国移动、联通、电信发布3G牌照,中国从此进入3G时代。 在智能手机初探中国市场的时间节点,三星、诺基亚、HTC等占据了巨大部分江山,而中国2G时代的功能机科健、波导、海尔等相继淡出市场,国产的智能机中兴、华为、酷派、联想等完全靠三大运营商的销售渠道生存,打着性价比旗号的合约机始终比不上价格更高的三星、HTC的中低端手机。 但2011年小米的横空出世打破了这个壁垒。3G网络在中国越来越普及,彼时在国外品牌占据中国市场,而国产智能手机厂商仅能依附三大运营商销售,而当苹果手机最具冲击力的一代iPhone4席卷全球时,一个叫雷军的人拿出了他的第一代产品——小米1,冲进中国智能手机市场。 三星与HTC主流机型定价4000元,而小米1999的超高性价比+饥饿营销,搅动了中国智能手机市场,成为3G时代崛起的手机巨头,2012年2月,“屌丝”一词横空出世,有人说它是为屌丝而生,谁能想到日后它更是成为世界最年轻的500强? 如果说QQ的问世是一个偶然,那微信出道是无数竞争的结果。 移动互联网兴起,当然PC端依然沉淀着大量用户,彼时的QQ为了顾及PC端的用户,界面、功能更照顾PC端,以至于没能及时赶上移动互联网的浪潮,但一个叫张小龙的人彻底颠覆了社交。 2011年1月,微信横空出世,这款主打IM的应用程序契合3G时代的特点,可以发送文字、语音、图片、视频等,一出现就借助QQ的天然优势,并打通通信录,迅速推广,以至于后续发展成为国民软件,腾讯也借此拿到了通向移动互联网第一张门票,腾讯能成为今天的巨头公司,而且地位难以撼动,微信功不可没。 其实在腾讯布局社交之时,2G时代崛起的门户大咖搜狐、新浪也瞄准了社交这个大蛋糕。只不过新浪瞄准的是信息即时分享,而搜狐一直在拾人牙慧。新浪微博自2009年一经问世便牢牢掌控着微博头把交椅的位置,就连腾讯微博的冲击也未动摇半分。 搜狐看QQ火爆便做了搜Q,见微博火爆便做了搜狐微博,还做了社区社交“白社会”,但无一例外遭遇失败。连张朝阳本人都说,“微信微博左右扇了我两个耳光。”最近张朝阳又推出“狐友”,硬要推着石头上山的张朝阳不知还有没有气力。 而前面提到2G时代阿里推出淘宝旺旺却忙着做电商,没有正面硬刚QQ,但此时看到微信在庞大的社交领域带来的巨大流量时,马云坐不住了。2013年,阿里巴巴推出了来往,这是阿里推出的即时通讯软件,也是阿里第一款独立于电商业务之外的社交产品,其核心功能是实现熟人之间的社交。 原因是马云认为腾讯已经“侵入”了阿里的地盘,要用来往去砸微信的场子,但结果大家都知晓了。但马云并不灰心,之后用支付宝做社交还是失败,只是钉钉的成功才稍找回点面子。 另外值得一提的是,3G时代,LBS应用于地图等会对4G时代滴滴、共享单车等的崛起起到推手的作用。 4G时代,团购直播视频手游异军突起,共享出行风口正盛,巨头布局信息流 如果说1G到2G是划时代的进步,而3G的短暂存在只是一个过渡,因为短短几年后,网速产生质的飞跃的4G时代迅速到来。 3G传播速度相对2G较快,可以较好满足手机上网等需求,只是播放高清视频还是比较困难。而4G的速度几乎满足无线用户所有需求。 彼时移动互联网光速发展,大面积吞噬PC互联网流量和用户。比PC端更加便捷的移动端生活服务类应用风靡中国,O2O模式成了风口上的猪。巨头们纷纷布局,阿里有口碑网,腾讯有微团购,百度则买来糯米网,但谁也不曾想最后的赢家不是巨头而是王兴的美团网,2015年,美团网与大众点评合并,重构了O2O模式。 美团成为4G时代崛起的巨头,而腾讯依靠着微信的社交流量巨大入口也不会败,阿里则有支付宝这个生活服务类的大平台,甚至日后提出新零售概念取代O2O成为新的风口,这场大战里,仿佛只有百度掉队了,从PC互联网时代跨到移动互联网时代,百度要做的产品是在抢夺自己PC端的用户,这让百度似乎有点手足无措。 网速加快受益最大的无疑是直播与视频。2016年前后,资本涌入直播赛道,以斗鱼、虎牙、YY、熊猫、全民等为首的游戏直播平台,纷纷宣布获得融资,疯狂烧钱抢夺主播和用户,以映客、花椒等移动端为主的直播也趁势而起。 彼时直播行业一片混乱,违法违规直播大行其道,随着监管力度加大,资本退场,直播赛道也一地鸡毛,也只是留下虎牙、斗鱼等直播巨头。 4G时代网速加快的同时,运营商提速降费,这让依靠文字和图片获取信息的用户越来越不满足,短视频有着天时地利人和,在用户需求之下诞生。其中以抖音、快手为代表的的社交媒体类短视频最为火爆,而以秒拍、西瓜为代表的的新闻资讯类也广受媒体和用户欢迎,以B站为首的BBS类更是抓住了细分市场。 今年年初Vlog的流行也只是吹了一阵,现已没有当时的热度,看起来短视频的风还会继续吹下去。 2G时代的三大门户巨头搜狐新浪和网易,如今搜狐和新浪的体量与网易已不是一个级别的,新浪尚有微博撑着场面,搜狐却实实在在地掉队了,网易又是如何始终保持盈利的呢?其中一个很重要的支撑点就是网易的游戏帝国,而4G时代更让网易如虎添翼。 曾经风靡全国的PC端回合制游戏“梦幻西游”,手机版一经上线便俘获众多老玩家,这个游戏特点之一就是烧钱。一位玩梦幻的朋友告诉盒饭财经,这个游戏里,充值几万也只是低端玩家而已,动辄砸百万的大有人在,而广受女性玩家喜爱的手游阴阳师也是一个人民币玩家的游戏。 从网易公布的财报不难看出,近几年网易游戏收入占每季度总收入都超过6成,与还靠着新闻资讯的新浪搜狐不同,网易应4G时代实现业务重心迁徙,还能算是互联网一线巨头。 守着微信这个巨大流量入口的腾讯布局游戏更显得理所当然,从PC端的QQ衍生出的众多小游戏为起点,到英雄联盟的大火,3G时代的天天酷跑,再到4G移动互联网时代手游吃鸡(和平精英)、王者荣耀,腾讯在每个时代都在游戏产业上走得很稳。 目前,腾讯游戏占中国游戏市场规模的5成以上,不过与网易不同的是,腾讯一直追求的是薄利多销策略。游戏内道具价格相比网易较为低廉,能取得超过千亿的年收入,全靠用户基数众多。 在PC端流量急剧下降,移动端用户暴涨之时,广告投放方也开始思考有的放矢,PC端时代是买广告位,而4G大数据时代就是买用户,这也称为信息流广告。 信息流广告最大的优势是不浪费资源,运用大数据针对性投放,既然有利可图,便成为各大巨头争先布局的板块,而这其中最为突出的就是今日头条和百度。 说起今日头条,这个曾经“小而美”的公司今年内发展成为TMD(头条、美团、滴滴)的首字母巨头,核心是张一鸣,但也得益于4G时代的浪潮。 今日头条的大获成功最引以为傲的是算法,虽然是新闻资讯类产品,但张一鸣的主创团队全是技术,不需要文字编辑。他们只做一件事,用纯技术算法手段从海量的内容中去搜索挖掘有价值的内容,最关键的是这些内容可以根据客户的需要进行“定制化”推送。这在信息爆炸时代,人们可以摆脱浩瀚无垠的信息海洋,只读取精准定制的有价值信息。 而依靠着强大的算法,什么火他做什么。微博火,做微头条,知乎火,做悟空问答,短视频直播时代到来,做火山小视频、西瓜视频、抖音,头条系产品依靠其强大的算法打造着一个又一个爆款。 2G时代,两大电商平台阿里和京东相争,虽然不是一个体量级,但是淘宝假货风行,一度让阿里痛下决心清理商家,而这些商家顺势被一个叫黄峥的人收走了,2015年,主打社交电商的拼多多应运而生。对了,这个黄峥还跟着别人和巴菲特吃过午餐。 而拼多多背后除了创始人黄峥,腾讯是第二大股东,而微信的天然社交入口让拼多多尝够了甜头,短短三年便上市,如今月活量已超过京东直逼淘宝。很显然,腾讯非常乐见有一家公司能威胁到阿里的主业务。 前文提到,LBS的应用为4G时代的滴滴发展起到推手的作用,2012年滴滴成立以来直至2014年都是缓慢上升期。但是自2014年以后,4G手机进一步普及,滴滴迅猛发展,短短一年便成为出行当之无愧的No.1,而滴滴2014年接入微信,这为滴滴提供了天然的流量入口。 但滴滴的竞品快的接入支付宝,此时阿里与腾讯在各个重合的领域相争已是司空见惯。但当时占领市场只有一个秘诀——烧钱,各种补贴各种红包雨,滴滴快的的烧钱战略持续了相当长一段时间,直到2015年滴滴快的的合并,让出行市场成为一家独大。 如果说滴滴是划时代的出行产品,那共享单车的出现引领了一个共享经济时代。 2004年,一个年轻人连同4个校友,提出“以共享经济+智能硬件,解决最后一公里出行问题”的经营理念,创立ofo共享单车项目,起先他们通过定制,将自行车通过车身号、机械锁绑定APP的方式,提供密码解锁用车的方式,在北京大学推出这一项目。这个项目的创始人叫戴威,北京大学光华管理学院的毕业生。 2015年1月27日,做媒体出身的胡玮炜和运营大牛王晓峰,在北京成立了一家名为“北京摩拜科技有限公司“的公司,他们的愿景是“让自行车回归生活”。 自此,共享单车风口吹起,共享单车“颜色大战”一触即发。而中国互联网创投倒贴钱抢市场,等日后一家独大再赚钱的逻辑,再一次在共享单车上体现的淋漓尽致。当时也就ofo和摩拜两家独大,但风口过得似乎有些快,风口过后,一地鸡毛。摩拜卖身美团,ofo深陷押金风波半死不活,老三哈罗单车如今倒是坐收渔翁之利。 5G时代,谁又能领风骚? 2G、3G、4G时代,随着互联网的发展,一些巨头应时代而生,而一些公司没能及时跟上时代的发展而掉队,还有像腾讯阿里等长盛不衰而又相互掣肘。2G、3G已然开始退网,4G时代方兴未艾,而5G时代紧赶着来了。 5G会直接加速万物互联的进程,改变我们与世界的交互界面。除了VR游戏、无人驾驶、智能物联等应用外,移动办公、会议直播、视频监控、智能城市等都会在5G的大网络下运行。智能手机厂商忙着推出5G手机,而互联网科技巨头在推出5G应用,虽然用户还未感觉到5G的速度,但这个赛道已经热得发烫。 阿里腾讯继续两强争霸?还是迷途的百度能否东山再起?是否还有头条、美团一样突然崛起的巨头?5G时代,一切皆有可能。 来源:https://www.chinaventure.com.cn/news/83-20190726-346438.html

技术杂谈

如果大家对 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 学习开发