0%

技术杂谈

阿里作为中国互联网企业的标杆,不仅一直在为广大人民群众提供快捷便利的服务。也不断引领行业,在各个专业领域,给专业人员提供了优质的工具与服务。对设计师而言,无论是每年一度的 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 来实现啦,具体的环境配置这里就不再赘述了,需要安装的有:

  • Node.js:https://nodejs.org/en/
  • Vue-Cli:https://cli.vuejs.org/zh/

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

1
vue create drag-captcha

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

1
npm install --save vue-drag-drop

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

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

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

Drop

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

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

Drag

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

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

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

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

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

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

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

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

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

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

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

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

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

Python

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

动机

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

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

所以,ModelZoo 诞生了!

开发过程

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

框架介绍

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

1
pip3 install model-zoo

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

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

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

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

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

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

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

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

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

class Trainer(BaseTrainer):

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

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

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

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

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

1
python3 train.py

结果是这样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Epoch 1/100
1/13 [=>............................] - ETA: 0s - loss: 816.1798
13/13 [==============================] - 0s 4ms/step - loss: 457.9925 - val_loss: 343.2489

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

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

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

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

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

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

1
2
cd events
tensorboard --logdir=.

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

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

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

class Inferer(BaseInferer):

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

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

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

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

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

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

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

愿景

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

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

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

Python

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

实例引入

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

姓名

工资(元)

房屋面积(平方)

是否可立即到账

张三

6000

58

李四

9000

77

王五

11000

89

赵六

15000

54

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

思路探索

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

推导过程

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

编程实现

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

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

参数说明如下:

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

属性说明如下:

  • coef_:斜率
  • intercept_:截距项

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

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

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

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

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

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

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

Python

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

Python 编程

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

Python 入门

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

Python 进阶

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

数据及算法

网络爬虫

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

机器学习

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

前后端技术

前端

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

后端

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

程序员经典

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

Python

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

实例引入

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

姓名

工资(元)

房屋面积(平方)

可贷款金额(元)

张三

6000

58

30000

李四

9000

77

55010

王五

11000

89

73542

赵六

15000

54

63201

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

思路探索

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

思路拓展

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

实际求解

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

求解过程

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

实战操作

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

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

参数解释如下:

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

属性如下:

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

代码实现如下:

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

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

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

运行结果:

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

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

Python

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

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

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

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

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

1
<__main__.Color object at 0x103436f60>

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

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

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

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

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

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

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

安装

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

1
pip3 install attrs cattrs

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

简介与特性

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

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

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

基本用法

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class RoughClass(object):
def __init__(self, a, b):
self.a = a
self.b = b

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

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

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

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

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

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

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

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

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

别名使用

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

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

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

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

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

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

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

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

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

声明和比较

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

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

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

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

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

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

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

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

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

运行结果如下:

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

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

属性定义

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

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

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

print(fields(Point))

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

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

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

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

属性名

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

1
x = attrib()

那么其属性名就是 x。

默认值

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

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

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

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

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

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

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

1
Point()

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

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

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

初始化

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

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

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

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

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

1
Point(x=10, y=3)

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

1
Point(1, 2)

报错了,错误如下:

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

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

强制关键字

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

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

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

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

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

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

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

1
Point(x=1, y=3)

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

1
Point(1, 3)

那么就会报错:

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

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

验证器

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1
ValueError: age 500 must less than 100

转换器

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

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

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

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

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

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

1
Point(x=100, y=3)

类型

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

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

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

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

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

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

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

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

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

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

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

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

@attrs
class Line(object):
name = attrib()
points = attrib(type=typing.List[Point])

if __name__ == '__main__':
points = [Point(i, i) for i in range(5)]
print(points)
line = Line(name='line1', points=points)
print(line)

在这里我们定义了 Point 类代表离散点,随后定义了线,其拥有 points 属性是 Point 组成的列表。在初始化的时候我们声明了五个点,然后用这五个点组成的列表声明了一条线,逻辑没什么问题。 运行结果:

1
2
[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)]
Line(name='line1', points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到这里我们得到了一个嵌套类型的 Line 对象,其值是 Point 类型组成的列表。 以上便是一些属性的定义,把握好这些属性的定义,我们就可以非常方便地定义一个类了。

序列转换

在很多情况下,我们经常会遇到 JSON 等字符串序列和对象互相转换的需求,尤其是在写 REST API、数据库交互的时候。 attrs 库的存在让我们可以非常方便地定义 Python 类,但是它对于序列字符串的转换功能还是比较薄弱的,cattrs 这个库就是用来弥补这个缺陷的,下面我们再来看看 cattrs 这个库。 cattrs 导入的时候名字也不太一样,叫做 cattr,它里面提供了两个主要的方法,叫做 structure 和 unstructure,两个方法是相反的,对于类的序列化和反序列化支持非常好。

基本转换

首先我们来看看基本的转换方法的用法,看一个基本的转换实例:

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

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

if __name__ == '__main__':
point = Point(x=1, y=2)
json = unstructure(point)
print('json:', json)
obj = structure(json, Point)
print('obj:', obj)

在这里我们定义了一个 Point 对象,然后调用 unstructure 方法即可直接转换为 JSON 字符串。如果我们再想把它转回来,那就需要调用 structure 方法,这样就成功转回了一个 Point 对象。 看下运行结果:

1
2
json: {'x': 1, 'y': 2}
obj: Point(x=1, y=2)

当然这种基本的来回转用的多了就轻车熟路了。

多类型转换

另外 structure 也支持一些其他的类型转换,看下实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
\>>> cattr.structure(1, str)
'1'
>>> cattr.structure("1", float)
1.0
>>> cattr.structure([1.0, 2, "3"], Tuple[int, int, int])
(1, 2, 3)
>>> cattr.structure((1, 2, 3), MutableSequence[int])
[1, 2, 3]
>>> cattr.structure((1, None, 3), List[Optional[str]])
['1', None, '3']
>>> cattr.structure([1, 2, 3, 4], Set)
{1, 2, 3, 4}
>>> cattr.structure([[1, 2], [3, 4]], Set[FrozenSet[str]])
{frozenset({'4', '3'}), frozenset({'1', '2'})}
>>> cattr.structure(OrderedDict([(1, 2), (3, 4)]), Dict)
{1: 2, 3: 4}
>>> cattr.structure([1, 2, 3], Tuple[int, str, float])
(1, '2', 3.0)

这里面用到了 Tuple、MutableSequence、Optional、Set 等类,都属于 typing 这个模块,后面我会写内容详细介绍这个库的用法。 不过总的来说,大部分情况下,JSON 和对象的互转是用的最多的。

属性处理

上面的例子都是理想情况下使用的,但在实际情况下,很容易遇到 JSON 和对象不对应的情况,比如 JSON 多个字段,或者对象多个字段。 我们先看看下面的例子:

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

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

json = {'x': 1, 'y': 2, 'z': 3}
print(structure(json, Point))

在这里,JSON 多了一个字段 z,而 Point 类只有 x、y 两个字段,那么直接执行 structure 会出现什么情况呢?

1
TypeError: __init__() got an unexpected keyword argument 'z'

不出所料,报错了。意思是多了一个参数,这个参数并没有被定义。 这时候一般的解决方法的直接忽略这个参数,可以重写一下 structure 方法,定义如下:

1
2
3
4
5
6
7
8
9
10
def drop_nonattrs(d, type):
if not isinstance(d, dict): return d
attrs_attrs = getattr(type, '__attrs_attrs__', None)
if attrs_attrs is None:
raise ValueError(f'type {type} is not an attrs class')
attrs: Set[str] = {attr.name for attr in attrs_attrs}
return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
return cattr.structure(drop_nonattrs(d, type), type)

这里定义了一个 drop_nonattrs 方法,用于从 JSON 里面删除对象里面不存在的属性,然后调用新的 structure 方法即可,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from typing import Set
from attr import attrs, attrib
import cattr

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

def drop_nonattrs(d, type):
if not isinstance(d, dict): return d
attrs_attrs = getattr(type, '__attrs_attrs__', None)
if attrs_attrs is None:
raise ValueError(f'type {type} is not an attrs class')
attrs: Set[str] = {attr.name for attr in attrs_attrs}
return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
return cattr.structure(drop_nonattrs(d, type), type)

json = {'x': 1, 'y': 2, 'z': 3}
print(structure(json, Point))

这样我们就可以避免 JSON 字段冗余导致的转换问题了。 另外还有一个常见的问题,那就是数据对象转换,比如对于时间来说,在对象里面声明我们一般会声明为 datetime 类型,但在序列化的时候却需要序列化为字符串。 所以,对于一些特殊类型的属性,我们往往需要进行特殊处理,这时候就需要我们针对某种特定的类型定义特定的 hook 处理方法,这里就需要用到 register_unstructure_hook 和 register_structure_hook 方法了。 下面这个例子是时间 datetime 转换的时候进行的处理:

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

TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'

@attrs
class Event(object):
happened_at = attrib(type=datetime.datetime)

cattr.register_unstructure_hook(datetime.datetime, lambda dt: dt.strftime(TIME_FORMAT))
cattr.register_structure_hook(datetime.datetime,
lambda string, _: datetime.datetime.strptime(string, TIME_FORMAT))

event = Event(happened_at=datetime.datetime(2019, 6, 1))
print('event:', event)
json = cattr.unstructure(event)
print('json:', json)
event = cattr.structure(json, Event)
print('Event:', event)

在这里我们对 datetime 这个类型注册了两个 hook,当序列化的时候,就调用 strftime 方法转回字符串,当反序列化的时候,就调用 strptime 将其转回 datetime 类型。 看下运行结果:

1
2
3
event: Event(happened_at=datetime.datetime(2019, 6, 1, 0, 0))
json: {'happened_at': '2019-06-01T00:00:00.000000Z'}
Event: Event(happened_at=datetime.datetime(2019, 6, 1, 0, 0))

这样对于一些特殊类型的属性处理也得心应手了。

嵌套处理

最后我们再来看看嵌套类型的处理,比如类里面有个属性是另一个类的类型,如果遇到这种嵌套类的话,怎样类转转换呢?我们用一个实例感受下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from attr import attrs, attrib
from typing import List
from cattr import structure, unstructure

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

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

@attrs
class Line(object):
color = attrib(type=Color)
points = attrib(type=List[Point])

if __name__ == '__main__':
line = Line(color=Color(), points=[Point(i, i) for i in range(5)])
print('Object:', line)
json = unstructure(line)
print('JSON:', json)
line = structure(json, Line)
print('Object:', line)

这里我们定义了两个 Class,一个是 Point,一个是 Color,然后定义了 Line 对象,其属性类型一个是 Color 类型,一个是 Point 类型组成的列表,下面我们进行序列化和反序列化操作,转成 JSON 然后再由 JSON 转回来,运行结果如下:

1
2
3
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])
JSON: {'color': {'r': 0, 'g': 0, 'b': 0}, 'points': [{'x': 0, 'y': 0}, {'x': 1, 'y': 1}, {'x': 2, 'y': 2}, {'x': 3, 'y': 3}, {'x': 4, 'y': 4}]}
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到,我们非常方便地将对象转化为了 JSON 对象,然后也非常方便地转回了对象。 这样我们就成功实现了嵌套对象的序列化和反序列化,所有问题成功解决!

结语

本节介绍了利用 attrs 和 cattrs 两个库实现 Python 面向对象编程的实践,有了它们两个的加持,Python 面向对象编程不再是难事。

Python

很多新手在开始学一门新的语言的时候,往往会忽视一些不应该忽视的细节,比如变量命名和函数命名以及注释等一些内容的规范性,久而久之养成了一种习惯。对此呢,我特意收集了一些适合所有学习 Python 的人,代码整洁之道。

写出 Pythonic 代码

谈到规范首先想到就是 Python 有名的 PEP8 代码规范文档,它定义了编写 Pythonic 代码的最佳实践。可以在 https://www.python.org/dev/peps/pep-0008/ 上查看。但是真正去仔细研究学习这些规范的朋友并不是很多,对此呢这篇文章摘选一些比较常用的代码整洁和规范的技巧和方法,下面让我们一起来学习吧!

命名

所有的编程语言都有变量、函数、类等的命名约定,以美之称的 Python 当然更建议使用命名约定。 接下来就针对类、函数、方法等等内容进行学习。

变量和函数

使用小写字母命名函数和变量,并用下划线分隔单词,提高代码可读性。

变量的声明

1
2
3
names = "Python" #变量名 
namejob_title = "Software Engineer" #带有下划线的变量名
populated_countries_list = [] #带有下划线的变量名

还应该考虑在代码中使用非 Python 内置方法名,如果使用 Python 中内置方法名请使用一个或两个下划线()。

1
2
_books = {}# 变量名私有化
__dict = []# 防止python内置库中的名称混淆

那如何选择是用还是__呢? 如果不希望外部类访问该变量,应该使用一个下划线()作为类的内部变量的前缀。如果要定义的私有变量名称是 Python 中的关键字如 dict 就要使用(__)。

函数的声明

1
2
3
4
def get_data(): 
pass
def calculate_tax_data():
pass

函数的声明和变量一样也是通过小写字母和单下划线进行连接。 当然对于函数私有化也是和声明变量类似。

1
2
def _get_data():
pass

函数的开头使用单下划线,将其进行私有化。对于使用 Pyton 中的关键字来进行命名的函数 要使用双下划线。

1
2
def __path():
pass

除了遵循这些命名规则之外,使用清晰易懂的变量名和很重要。

函数名规范

1
2
3
4
5
6
7
8
9
10
11
# Wrong Way
def get_user_info(id):
db = get_db_connection()
user = execute_query_for_user(id)
return user

# Right way
def get_user_by(user_id):
db = get_db_connection()
user = execute_user_query(user_id)
return user

这里,第二个函数 get_user_by 确保使用相同的参数来传递变量,从而为函数提供正确的上下文。 第一个函数 get_user_info 就不怎么不明确了,因为参数 id 意味着什么这里我们不能确定,它是用户 ID,还是用户付款ID或任何其他 ID? 这种代码可能会对使用你的API的其他开发人员造成混淆。为了解决这个问题,我在第二个函数中更改了两个东西; 我更改了函数名称以及传递的参数名称,这使代码可读性更高。 作为开发人员,你有责任在命名变量和函数时仔细考虑,要写让人能够清晰易懂的代码。 当然也方便自己以后去维护。

类的命名规范

类的名称应该像大多数其他语言一样使用驼峰大小写。

1
2
3
4
5
class UserInformation:
def get_user(id):
db = get_db_connection()
user = execute_query_for_user(id)
return user

常量的命名规范

通常应该用大写字母定义常量名称。

1
2
3
TOTAL = 56
TIMOUT = 6
MAX_OVERFLOW = 7

函数和方法的参数

函数和方法的参数命名应遵循与变量和方法名称相同的规则。因为类方法将self作为第一个关键字参数。所以在函数中就不要使用 self 作为关键字作为参数,以免造成混淆 .

1
2
3
4
5
6
def calculate_tax(amount, yearly_tax):
passs

class Player:
def get_total_score(self, player_name):
pass

关于命名大概就强调这些,下面让我们看看表达式和语句中需要的问题。

代码中的表达式和语句

1
2
3
4
5
6
users = [
{"first_name":"Helen", "age":39},
{"first_name":"Buck", "age":10},
{"first_name":"anni", "age":9}
]
users = sorted(users, key=lambda user: user["first_name"].lower())

这段代码有什么问题? 乍一看并不容易理解这段代码,尤其是对于新开发人员来说,因为 lambdas 的语法很古怪,所以不容易理解。虽然这里使用 lambda 可以节省行,然而,这并不能保证代码的正确性和可读性。同时这段代码无法解决字典缺少键出现异常的问题。 让我们使用函数重写此代码,使代码更具可读性和正确性; 该函数将判断异常情况,编写起来要简单得多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
users = [
{"first_name":"Helen", "age":39},
{"first_name":"Buck", "age":10},
{"name":"anni", "age":9}
]
def get_user_name(users):
"""Get name of the user in lower case"""
return users["first_name"].lower()
def get_sorted_dictionary(users):
"""Sort the nested dictionary"""
if not isinstance(users, dict):
raise ValueError("Not a correct dictionary")
if not len(users):
raise ValueError("Empty dictionary")
users_by_name = sorted(users, key=get_user_name)
return users_by_name

如您所见,此代码检查了所有可能的意外值,并且比起以前的单行代码更具可读性。 单行代码虽然看起来很酷节省了行,但是会给代码添加很多复杂性。 但是这并不意味着单行代码就不好 这里提出的一点是,如果你的单行代码使代码变得更难阅读,那么就请避免使用它,记住写代码不是为了炫酷的,尤其在项目组中。 让我们再考虑一个例子,你试图读取 CSV 文件并计算 CSV 文件处理的行数。下面的代码展示使代码可读的重要性,以及命名如何在使代码可读中发挥重要作用。

1
2
3
4
5
6
7
8
9
10
11
12
import csv
with open("employee.csv", mode="r") as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
for row in csv_reader:
if line_count == 0:
print(f'Column names are {", ".join(row)}')
line_count += 1
print(f'\\t{row["name"]} salary: {row["salary"]}'
f'and was born in {row["birthday month"]}.')
line_count += 1
print(f'Processed {line_count} lines.')

将代码分解为函数有助于使复杂的代码变的易于阅读和调试。 这里的代码在 with 语句中执行多项操作。为了提高可读性,您可以将带有 process salary 的代码从 CSV 文件中提取到另一个函数中,以降低出错的可能性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import csv
with open("employee.csv", mode="r") as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
process_salary(csv_reader)


def process_salary(csv_reader):
"""Process salary of user from csv file."""
for row in csv_reader:
if line_count == 0:
print(f'Column names are {", ".join(row)}')
line_count += 1
print(f'\\t{row["name"]} salary: {row["salary"]}'
f'and was born in {row["birthday month"]}.')
line_count += 1
print(f'Processed {line_count} lines.')

代码是不是变得容易理解了不少呢。 在这里,创建了一个帮助函数,而不是在with语句中编写所有内容。这使读者清楚地了解了函数的实际作用。如果想处理一个特定的异常或者想从CSV文件中读取更多的数据,可以进一步分解这个函数,以遵循单一职责原则,一个函数一做一件事。这个很重要

return语句的类型尽量一致

如果希望函数返回一个值,请确保该函数的所有执行路径都返回该值。但是,如果期望函数只是在不返回值的情况下执行操作,则 Python 会隐式返回 None 作为函数的默认值。 先看一个错误示范

1
2
3
4
5
6
7
def calculate_interest(principle, time rate):    
if principle > 0:
return (principle * time * rate) / 100
def calculate_interest(principle, time rate):
if principle < 0:
return
return (principle * time * rate) / 100ChaPTER 1 PyThonIC ThInkIng

正确的示范应该是下面这样

1
2
3
4
5
6
7
8
9
def calculate_interest(principle, time rate):    
if principle > 0:
return (principle * time * rate) / 100
else:
return None
def calculate_interest(principle, time rate):
if principle < 0:
return None
return (principle * time * rate) / 100ChaPTER 1 PyThonIC ThInkIng

还是那句话写易读的代码,代码多写点没关系,可读性很重要。

使用 isinstance() 方法而不是 type() 进行比较

当比较两个对象类型时,请考虑使用 isinstance() 而不是 type,因为 isinstance() 判断一个对象是否为另一个对象的子类是 true。考虑这样一个场景:如果传递的数据结构是dict 的子类,比如 orderdict。type() 对于特定类型的数据结构将失败;然而,isinstance() 可以将其识别出它是 dict 的子类。 错误示范

1
2
user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
type(user_ages) == dict:

正确选择

1
2
user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
if isinstance(user_ages, dict):

比较布尔值

在Python中有多种方法可以比较布尔值。 错误示范

1
2
3
if is_empty = False
if is_empty == False:
if is_empty is False:

正确示范

1
2
is_empty = False
if is_empty

使用文档字符串

Docstrings可以在 Python 中声明代码的功能的。通常在方法,类和模块的开头使用。 docstring是该对象的doc特殊属性。 Python 官方语言建议使用“”三重双引号“”来编写文档字符串。 你可以在 PEP8 官方文档中找到这些实践。 下面让我们简要介绍一下在 Python 代码中编写 docstrings 的一些最佳实践 。

方法中使用docstring

1
2
def get_prime_number():
"""Get list of prime numbers between 1 to 100.""""

关于docstring的格式的写法,目前存在多种风格,但是这几种风格都有一些统一的标准。

  • 即使字符串符合一行,也会使用三重引号。当你想要扩展时,这种注释非常有用。‘
  • 三重引号中的字符串前后不应有任何空行
  • 使用句点(.)结束docstring中的语句 类似地,可以应用 Python 多行 docstring 规则来编写多行 docstring。在多行上编写文档字符串是用更具描述性的方式记录代码的一种方法。你可以利用 Python 多行文档字符串在 Python 代码中编写描述性文档字符串,而不是在每一行上编写注释。 多行的docstring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def call_weather_api(url, location):
"""Get the weather of specific location.

Calling weather api to check for weather by using weather api and
location. Make sure you provide city name only, country and county
names won't be accepted and will throw exception if not found the
city name.

:param url:URL of the api to get weather.
:type url: str
:param location:Location of the city to get the weather.
:type location: str
:return: Give the weather information of given location.
:rtype: str"""

说一下上面代码的注意点

  • 第一行是函数或类的简要描述
  • 每一行语句的末尾有一个句号
  • 文档字符串中的简要描述和摘要之间有一行空白

如果使用 Python3.6 可以使用类型注解对上面的docstring以及参数的声明进行修改。

1
2
3
4
5
6
7
8
def call_weather_api(url: str, location: str) -> str:
"""Get the weather of specific location.

Calling weather api to check for weather by using weather api and
location. Make sure you provide city name only, country and county
names won't be accepted and will throw exception if not found the
city name.
"""

怎么样是不是简洁了不少,如果使用 Python 代码中的类型注解,则不需要再编写参数信息。 关于类型注解(type hint)的具体用法可以参考我之前写的http://mp.weixin.qq.com/s?__biz=MzU0NDQ2OTkzNw==&mid=2247484258&idx=1&sn=b13db84dad8eccaec9cd4af618e812e4&chksm=fb7ae5bccc0d6caa8e145e9fd22d0395e68d618672ebbfe99794926c0b9e782974fb16321e32&scene=21#wechat_redirect

模块级别的docstring

一般在文件的顶部放置一个模块级的 docstring 来简要描述模块的使用。 这些注释应该放在在导包之前,模块文档字符串应该表明模块的使用方法和功能。 如果觉得在使用模块之前客户端需要明确地知道方法或类,你还可以简要地指定特定方法或类。

1
2
3
4
5
6
7
8
9
10
11
"""This module contains all of the network related requests. 
This module will check for all the exceptions while making the network
calls and raise exceptions for any unknown exception.
Make sure that when you use this module,
you handle these exceptions in client code as:
NetworkError exception for network calls.
NetworkNotFound exception if network not found.
"""

import urllib3
import json

在为模块编写文档字符串时,应考虑执行以下操作:

  • 对当前模块写一个简要的说明
  • 如果想指定某些对读者有用的模块,如上面的代码,还可以添加异常信息,但是注意不要太详细。
1
2
NetworkError exception for network calls.
NetworkNotFound exception if network not found.
  • 将模块的docstring看作是提供关于模块的描述性信息的一种方法,而不需要详细讨论每个函数或类具体操作方法。 类级别的docstring 类docstring主要用于简要描述类的使用及其总体目标。 让我们看一些示例,看看如何编写类文档字符串 单行类docstring
1
2
3
4
class Student:
"""This class handle actions performed by a student."""
def __init__(self):
pass

这个类有一个一行的 docstring,它简要地讨论了学生类。如前所述,遵守了所以一行docstring 的编码规范。

多行类docstring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Student:
"""Student class information.

This class handle actions performed by a student.
This class provides information about student full name, age,
roll-number and other information.
Usage:
import student
student = student.Student()
student.get_name()
>>> 678998
"""
def __init__(self):
pass

这个类 docstring 是多行的; 我们写了很多关于 Student 类的用法以及如何使用它。

函数的docstring

函数文档字符串可以写在函数之后,也可以写在函数的顶部。

1
2
3
4
5
6
7
8
9
10
11
12
def is_prime_number(number):
"""Check for prime number.

Check the given number is prime number
or not by checking against all the numbers
less the square root of given number.

:param number:Given number to check for prime
:type number: int
:return: True if number is prime otherwise False.
:rtype: boolean
"""

如果我们使用类型注解对其进一步优化。

1
2
3
4
5
6
7
def is_prime_number(number: int)->bool:
"""Check for prime number.

Check the given number is prime number
or not by checking against all the numbers
less the square root of given number.
"""

结语

当然关于 Python 中的规范还有很多很多,建议大家参考 Python 之禅和 Pep8 对代码进行优化,养成编写 Pythonic 代码的良好习惯。 [caption id=”attachment_6600” align=”alignnone” width=”258”] 扫码关注[/caption] 更多精彩内容关注微信公众号:python学习开发

Python

在本教程中,你将了解如何使用 pathlib 模块操作目录和文件的名称。 学习如何读取和写入文件,拼接路径和操作底层文件系统的新方法,以及如何列出文件并迭代它们的一些示例。 大多人处理文件用的最多的还是 os 模快吧,比如下面这样的操作

1
>>> path.rsplit('\\', maxsplit=1)[0]

或者写出下面这样长长的代码

1
>>> os.path.isfile(os.path.join(os.path.expanduser('~'), 'realpython.txt'))

使用 pathlib 模块,可以使代码使用优雅,可读和 Pythonic 代码重写上面的两个示例,如:

1
2
>>> path.parent
>>> (pathlib.Path.home() / 'realpython.txt').is_file()

Python 文件路径处理问题

由于许多不同的原因,使用文件和与文件系统交互很重要。 最简单的情况可能只涉及读取或写入文件,但有时候会有更复杂的任务。 也许你需要列出给定类型的目录中的所有文件,查找给定文件的父目录,或者创建一个尚不存在的唯一文件名。 一般情况,Python 使用常规文本字符串表示文件路径。 一般在使用 os,glob 和 shutil 等库的时候会使用到路径拼接的操作,使用os模块拼接起来显得略显复杂,以下示例仅需要三个 import 语句来将所有文本文件移动到归档目录:

1
2
3
4
5
6
7
import glob
import os
import shutil

for file_name in glob.glob('*.txt'):
new_path = os.path.join('archive', file_name)
shutil.move(file_name, new_path)

使用常规的字符串去拼接路径是可以的,但是由于不同的操作系统使用的分隔符不同,这样就容易出现问题,所以一般我们使用最多的还是使用 os.path.join()。 Python 3.4 中引入了 pathlib 模块(PEP 428)再一次的优化了路径的拼接。使用 pathlib 库的 Path 方法,可以将一个普通的字符串转换为 pathlib.Path 对象类型的路径。 早期,其他软件包仍然使用字符串作为文件路径,但从 Python 3.6 开始,pathlib 模块在整个标准库中得到支持,部分原因是由于增加了文件系统路径协议。 如果你坚持使用传统的 Python,那么 Python 2 也有一个可用的向后移植。 ok,说了那么多下面让我们看看 pathlib 如何在实践中发挥作用。

创建路径

这里我们首先要知道两个用法,先看代码:

1
from pathlib import Path

你真正需要知道的是 pathlib.Path 类。 创建路径有几种不同的方式。 首先,有类方法,如 .cwd(当前工作目录)和 .home(用户的主目录):

1
2
3
4
5
6
7
from pathlib import Path

now_path = Path.cwd()
home_path = Path.home()

print("当前工作目录",now_path,type(now_path))
print("home目录",home_path,type(home_path))

输出内容

1
2
当前工作目录 /Users/chennan/pythonproject/demo <class 'pathlib.PosixPath'>
home目录 /Users/chennan <class 'pathlib.PosixPath'>

可以发现路径格式为 pathlib.PosixPath 这是在 unix 系统下的显示。在不同的系统上显示的格式也是不一样,在 windows 系统会显示为 WindowsPath。但是不管什么显示类型,都不影响后面的操作。 前面我们提到过可以通过把字符串类型的路径,转换为 Pathlib.Path 类型的路径,经过测试发现在 Python3.4 以后很多模块以及支持该格式的路径。不用转为成字符串使用了。比起 os.path.join 拼接路径的方式, pathlib 使用起来更加的方便,使用示例如下:

1
2
3
import pathlib
DIR_PATH = pathlib.Path("/Users/chennan/CDM")
print(DIR_PATH,type(DIR_PATH))

输出内容:

1
/Users/chennan/CDM <class 'pathlib.PosixPath'>

通过 “/“ 我们就可以对路径进行拼接了,怎么样是不是很方便呢。

读文件和写文件

在我们使用 open 来操作文件读写操作的时候,不仅可以使用字符串格式的路径,对于 pathlib 生成的路径完全可以直接使用:

1
2
3
4
path = pathlib.Path.cwd() / 'test.md'
with open(path, mode='r') as fid:
headers = [line.strip() for line in fid if line.startswith('#')]
print('\n'.join(headers))

或者在 pathlib 的基础使用 open,我们推荐使用下面的方式

1
2
3
4
5
import pathlib
DIR_PATH = pathlib.Path("/Users/chennan/CDM") / "2000" / "hehe.txt"
with DIR_PATH.open("r") as fs:
data = fs.read()
print(data)

这样写的好处就是 open 里面我们不需要再去传入路径了,直接指定文件读写模式即可。实际上这里的 open 方法,底层也是调用了 os.open 的方法。使用哪种方式看个人的喜好。 pathlib 还提供几种文件的读写方式: 可以不用再使用 with open 的形式即可以进行读写。

1
2
3
4
.read_text(): 找到对应的路径然后打开文件,读成str格式。等同open操作文件的"r"格式。
.read_bytes(): 读取字节流的方式。等同open操作文件的"rb"格式。
.write_text(): 文件的写的操作,等同open操作文件的"w"格式。
.write_bytes(): 文件的写的操作,等同open操作文件的"wb"格式。

使用 resolve 可以通过传入文件名,来返回文件的完整路径,使用方式如下

1
2
3
import pathlib
py_path =pathlib.Path("superdemo.py")
print(py_path.resolve())

输出

1
/Users/chennan/pythonproject/demo/superdemo.py

需要注意的是 “superdemo.py” 文件要和我当前的程序文件在同一级目录。

选择路径的不同组成部分

pathlib 还提供了很多路径操作的属性,这些属性可以选择路径的不用部位,如 .name: 可以获取文件的名字,包含拓展名。 .parent: 返回上级文件夹的名字 .stem: 获取文件名不包含拓展名 .suffix: 获取文件的拓展名 .anchor: 类似盘符的一个东西,

1
2
3
4
5
6
7
8
import pathlib

now_path = pathlib.Path.cwd() / "demo.txt"
print("name",now_path.name)
print("stem",now_path.stem)
print("suffix",now_path.suffix)
print("parent",now_path.parent)
print("anchor",now_path.anchor)

输出内容如下

1
2
3
4
5
name demo.txt
stem demo
suffix .txt
parent /Users/chennan/pythonproject/demo
anchor /

移动和删除文件

当然 pathlib 还可以支持文件其他操作,像移动,更新,甚至删除文件,但是使用这些方法的时候要小心因为,使用过程不用有任何的错误提示即使文件不存在也不会出现等待的情况。 使用 replace 方法可以移动文件,如果文件存在则会覆盖。为避免文件可能被覆盖,最简单的方法是在替换之前测试目标是否存在。

1
2
3
4
5
6
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
if not destination.exists():
source.replace(destination)

但是上面的方法存在问题就是,在多个进程多 destination 进行的操作的时候就会现问题,可以使用下面的方法避免这个问题。也就是说上面的方法适合单个文件的操作。

1
2
3
4
5
6
7
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
with destination.open(mode='xb') as fid:
#xb表示文件不存在才操作
fid.write(source.read_bytes())

当 destination文件存在的时候上面的代码就会出现 FileExistsError 异常。 从技术上讲,这会复制一个文件。 要执行移动,只需在复制完成后删除源即可。 使用 with_name 和 with.shuffix 可以修改文件名字或者后缀。

1
2
3
import pathlib
source = pathlib.Path.cwd() / "demo.py"
source.replace(source.with_suffix(".txt")) #修改后缀并移动文件,即重命名

可以使用 .rmdir() 和 .unlink() 来删除文件。

1
2
3
4
5
import pathlib

destination = pathlib.Path.cwd() / "target"
source = pathlib.Path.cwd() / "demo.txt"
source.unlink()

几个 pathlib 的使用例子

统计文件个数

我们可以使用.iterdir方法获取当前文件下的所以文件.

1
2
3
4
5
import pathlib
from collections import Counter
now_path = pathlib.Path.cwd()
gen = (i.suffix for i in now_path.iterdir())
print(Counter(gen))

输出内容

1
Counter({'.py': 16, '': 11, '.txt': 1, '.png': 1, '.csv': 1})

通过配合使用 collections 模块的 Counter 方法,我们获取了当文件夹下文件类型情况。 前面我们说过 glob 模块点这里了解【https://www.cnblogs.com/c-x-a/p/9261832.html】,同样的 pathlib 也有 glob 方法和 rglob 方法,不同的是 glob 模块里的 glob 方法结果是列表形式的,iglob 是生成器类型,在这里 pathlib 的 glob 模块返回的是生成器类型,然后 pathlib 还有一个支持递归操作的 rglob 方法。 下面的这个操作我通过使用 glob 方法,设定规则进行文件的匹配。

1
2
3
4
import pathlib
from collections import Counter
gen =(p.suffix for p in pathlib.Path.cwd().glob('*.py'))
print(Counter(gen))

展示目录树

下一个示例定义了一个函数 tree(),该函数的作用是打印一个表示文件层次结构的可视树,该树以一个给定目录为根。因为想列出其子目录,所以我们要使用 .rglob() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pathlib
from collections import Counter
def tree(directory):
print(f'+ {directory}')
for path in sorted(directory.rglob('*')):
depth = len(path.relative_to(directory).parts)
spacer = ' ' * depth
print(f'{spacer}+ {path.name}')

now_path = pathlib.Path.cwd()

if __name__ == '__main__':
tree(now_path)

其中 relative_to 的方法的作用是返回 path 相对于 directory 的路径。 parts 方法可以返回路径的各部分。例如

1
2
3
4
import pathlib
now_path = pathlib.Path.cwd()
if __name__ == '__main__':
print(now_path.parts)

返回

1
('/', 'Users', 'chennan', 'pythonproject', 'demo')

获取文件最后一次修改时间

iterdir(),.glob()和.rglob()方法非常适合于生成器表达式和列表理解。 使用stat()方法可以获取文件的一些基本信息,使用.stat().st_mtime可以获取文件最后一次修改的信息

1
2
3
4
5
import pathlib
now_path = pathlib.Path.cwd()
from datetime import datetime
time, file_path = max((f.stat().st_mtime, f) for f in now_path.iterdir())
print(datetime.fromtimestamp(time), file_path)

甚至可以使用类似的表达式获取上次修改的文件内容

1
2
3
4
5
import pathlib
from datetime import datetime
now_path =pathlib.Path.cwd()
result = max((f.stat().st_mtime, f) for f in now_path.iterdir())[1]
print(result.read_text())

.stat().st_mtime 会返回文件的时间戳,可以使用 datetime 或者 time 模块对时间格式进行进一步转换。

其他内容

关于 pathlib.Path 格式路径转换为字符串类型

因为通过 pathlib 模块操作生成的路径,不能直接应用字符串的一些操作,所以需要转换成字符串,虽然可以使用 str() 函数进行转换,但是安全性不高,建议使用 os.fspath() 方法,因为如果路径格式非法的,可以抛出一个异常。str()就不能做到这一点。

拼接符号”/“背后的秘密

/ 运算符由 truediv 方法定义。 实际上,如果你看一下 pathlib 的源代码,你会看到类似的东西。

1
2
3
4
class PurePath(object):

def __truediv__(self, key):
return self._make_child((key,))

后记

从 Python 3.4 开始,pathlib 已在标准库中提供。 使用 pathlib,文件路径可以由适当的 Path 对象表示,而不是像以前一样用纯字符串表示。 这些对象使代码处理文件路径:

  • 更容易阅读,特别是可以使用“/”将路径连接在一起
  • 更强大,直接在对象上提供最必要的方法和属性
  • 在操作系统中更加一致,因为Path对象隐藏了不同系统的特性

在本教程中,你已经了解了如何创建 Path 对象、读取和写入文件、操作路径和底层文件系统,以及如何遍历多个文件路径等一系列实例。 最后,建议下去自己多加练习,我对文章中的代码都进行了验证,不会出现运行错误的情况。 ————————————————————————————————————————————— 原文: https://realpython.com/python-pathlib/ 译者: 陈祥安 [gallery ids=”6600”] 更多精彩内容,请关注微信公众号: python学习开发。

JavaScript

想写这篇文章很久了,也想做这件事很久了,我个人感觉自己是有强迫症的,所以一直有什么事让我看着不太舒服就想把它纠正过来。 文字,也不例外。 现在大家看各种新闻啊、文章啊,几乎每篇文章都会有点数字和英文的吧,比如就拿 Python 来说,看下面两句话:

  • 卧槽Python真牛逼啊排名第1了。
  • 卧槽 Python 真牛逼啊排名第 1 了。

Python 是不是第一先不说,就看看上面两句话的排版,哪个看起来更舒服?说实话我是真觉得第一句话太别扭了。因为我们大部分的文本编辑器和浏览器是没有对中文和外文的混排做排版优化的,所以如果写的时候如果二者之间不加个空格,二者就会紧紧贴在一起,然后就变成了上面第一句的样子。 当然如果你觉得第一句的排版更好看,好吧,那么本文后面的内容其实可以不必看了。OK,如果你觉得第二个好看,那不妨接着看下去哈。

出发点

首先有一点需要明确的是,中英文排版的美学是在于 Readability,易读性。而为了易读性,中英文之间是需要留有”间距”的,注意这里是间距,不是说的”空格”。”空格”会造成间距,但是间距不一定非得需要”空格”。 好,所以,其实我们只需要留有适当的间距,就会显得美观易读,这个间距大约是一个半角空格的距离。 好明确了这一点,我们只要能留有间距,不一定非得加空格。 现在很多专业的排版软件,比如 Adobe InDesign、Microsoft Word 对中英文混排支持非常好,他们会有这么一个功能:可以设置中文西文之间留适当的间距。 所以,如果如果我们使用了这些软件,本身就可以做到 Readability,这就够了。 但是,为什么还会说空格的问题呢?这是因为现在绝大多数软件,不管是文本编辑器还是网页,都没有这个机制。 几乎所有的文本编辑器和浏览器中,只要我们中文和英文连续输入,它们之间是不会出现间距的,就像文章开头所示的样例中的第一句话,显得很别扭。但比如 Adobe InDesign、Microsoft Word、IE 浏览器会有这方面的支持。 所以,怎么解决?手动加空格。 因此,总结下:

  • 间距要有,但不一定是空格。
  • 部分软件能自动呈现间距,那就不必加空格。
  • 绝大多数软件不能自动呈现间距,那就需要手动加空格。

所以,作为强迫症的我,一定是会为了这个间距而去敲下一个空格的。 「有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。与大家共勉之。」 盘古之白 所以,求求你加个空格吧(逃。

规范

好,下面就说到规范的问题了,到底什么时候应该加空格什么时候不加,这也是有讲究的。下面的内容摘自 GitHub 上的一个中英文混排规范,网址为:https://github.com/mzlogin/chinese-copywriting-guidelines,下面转述一下。

1. 中英文之间需要增加空格

中英文之间是需要添加空格的,不论是普通英文还是引用的英文,下面给个示例: 正确:

  • 在 LeanCloud 上,数据存储是围绕 AVObject 进行的。

错误:

  • 在LeanCloud上,数据存储是围绕AVObject进行的。
  • 在 LeanCloud上,数据存储是围绕AVObject 进行的。

完整的正确用法:

  • 在 LeanCloud 上,数据存储是围绕 AVObject 进行的。每个 AVObject 都包含了与 JSON 兼容的 key-value 对应的数据。数据是 schema-free 的,你不需要在每个 AVObject 上提前指定存在哪些键,只要直接设定对应的 key-value 即可。

但有例外,比如「豆瓣FM」等产品名词,按照官方所定义的格式书写。 再比如,我的公众号为「进击的Coder」,那么这里面就不要加空格,按照其本身的形式书写即可。

中文与数字之间需要增加空格

中文和数字之间也是需要的,下面给个示例: 正确:

  • 今天出去买菜花了 5000 元。

错误:

  • 今天出去买菜花了 5000元。
  • 今天出去买菜花了5000元。

数字与单位之间无需增加空格

但是数字和单位之间不需要再加额外的空格了,下面给个 正确:

  • 我家的光纤入户宽带有 10Gbps,SSD 一共有 10TB。

错误:

  • 我家的光纤入户宽带有 10 Gbps,SSD 一共有 20 TB。

另外,度/百分比与数字之间不需要增加空格: 正确:

  • 今天是 233° 的高温。
  • 新 MacBook Pro 有 15% 的 CPU 性能提升。

错误:

  • 今天是 233 ° 的高温。
  • 新 MacBook Pro 有 15 % 的 CPU 性能提升。

全角标点与其他字符之间不加空格

标点是分全角和半角的,全角标点一般是在中文状态下输出来的,比如 ,半角标点一般是在英文状态下输出来的,比如 ,.!,两个看起来不一样吧?所以,如果是中文标点,即全角标点,那不需要加空格。 正确:

  • 刚刚买了一部 iPhone,好开心!

错误:

  • 刚刚买了一部 iPhone ,好开心!

嗯,基本就是以上的几个规范,只要明白了这些规范,中英文混排就 OK 了!

网页

有人说,我就是不想打空格,在网页中,我能像 Microsoft Word 一样不打空格而直接显示间距吗? 也就是说,我能不能设置一个 CSS 样式,就能使得中英文之间自动留有间距呢? 其实,只有 IE 有这样的支持。这个 CSS 样式叫做 \-ms-text-autospace ,可以在这里了解下:https://msdn.microsoft.com/library/ms531164(v=vs.85).aspx.aspx)。 但是很遗憾的是,几乎所有其他的浏览器都不支持这个,Chrome、Firefox 统统都不支持这个特性。放弃吧。 image-20190507220822252 这里提供一些手动的解决方案,比如使用 JavaScript 添加标记,然后 CSS 控制标记的间距,解决方案可以参考:http://mastermay.github.io/text-autospace.js/

编辑器

那么有编辑器支持这个吗?有,Microsoft Word,用它我们不用加空格,会自动给我们加好间距。 有人说,我平时不想用 Word,我就想用 Markdown,有编辑器吗?有,叫做 MarkEditor,它的 2.0 Pro 版本可以在打字的时候自动给我们添加空格。注意,这里是自动添加空格,不是自动留间距,是用空格的方式实现了间距。但是这个只能在你一个个打字的时候自动添加空格,如果把一个不带空格的话粘贴进去是不行的。另外 MarkEditor 解锁这个功能需要付费,所以我个人感觉其实不太划算的。 所以,平时还是自己手动加空格吧,经济实惠方便。 其他的编辑器如有好用的欢迎大家推荐哈。

类库

好吧,看到现在,你是不是现在都想把自己的中英文笔记加上空格了?难道要手调吗?不需要。 有现成的工具了,名字叫做 pangu,它支持各种语言,另外还有浏览器插件可以用,列表如下:

浏览器插件

  • Google Chrome
  • Mozilla Firefox

开发工具包

  • Official supports:
    • pangu.go (Go)
    • pangu.java (Java)
    • pangu.js (JavaScript)
    • pangu.py (Python)
    • pangu.space (Web API)
  • Community supports:
    • pangu.clj (Clojure)
    • pangu.dart (Dart)
    • pangu.ex (Elixir)
    • pangu.objective-c (Objective-C)
    • pangu.php (PHP)
    • pangu.rb (Ruby)
    • pangu.rs (Rust)
    • pangu.swift (Swift)

比如 Python 的话,就可以使用 pangu.py 这个包,GitHub 地址为:https://github.com/vinta/pangu.py,安装方式如下

1
pip3 install -U pangu

这么用就好了:

1
2
import pangu
print(pangu.spacing_text('當你凝視著bug,bug也凝視著你'))

运行结果如下:

1
當你凝視著 bug,bug 也凝視著你

嗯,它自动给我们添加好了空格,非常不错。 不过这有点费劲,有简单一点的工具吗? 有,我为此专门做了一个网页,功能很简单。 在左侧输入源文本,右侧就会显示添加空格之后的文本,页面如下: image-20190507222427295 这个是我用 Vue.js 开发的,实际上就是用了 pangu.js 这个库实现的,原理非常简单,主要目的就是为了方便空格排版。 另外这个网站我也部署了一下,叫做:http://space.cuiqingcai.com/,大家以后也可以直接访问使用,以后我有想调整的文本,直接就用它了。 如果大家想获取源码,可以在公众号「进击的Coder」回复”空格”。 希望对大家有所帮助。 最后,为了世界的美好与和平,加个空格吧!

Python

最近碰到了一个问题,项目中很多文件都是接手过来的中文命名的一些素材,结果在部署的时候文件名全都乱码了,导致项目无法正常运行。 后来请教了一位大佬怎么解决文件名乱码的问题,他说这个需要正面解决吗?不需要,把文件名全部改掉,文件名永远不要用中文,永远不要。 我想他这么说的话,一定也是凭经验得出来的。 这里也友情提示大家,项目里面文件永远不要用中文,永远不要! 好,那不用中文用啥?平时来看,一般我们都会用英文来命名,一般也不会出现中文,比如 resource, controller, result, view, spider 等等,所以绝大多数情况下,是不会出现什么问题的。但是也有个别的情况,比如一些素材、资源文件可能的中文命名的,那么这时候该咋办呢? 首先像,因为是中文资源文件,我们要改成非中文命名的,无非两种,一种是英文,一种是拼音。 如果改英文,当然可以翻译、我们想翻译的话,逐个人工翻译成本太高,机器翻译的话,翻译完可能有些文不对题了,而且我们自己也不知道一些奇怪的资源英语应该叫什么,所以到时候真的找起来都找不到了。 所以第二种解决方案,那就是拼音了。中文转拼音,很自然,而且一个字就对应一串拼音,而且也非常容易从拼音看懂是什么意思,所以这确实是一个不错的方案。 那么问题就来了,怎样把一批中文文件转拼音命名呢?下面就让我们来了解 Python 的一个库 PyPinyin 吧!

概述

Python 中提供了汉字转拼音的库,名字叫做 PyPinyin,可以用于汉字注音、排序、检索等等场合,是基于 hotto/pinyin 这个库开发的,一些站点链接如下:

  • GitHub: https://github.com/mozillazg/python-pinyin
  • 文档:https://pypinyin.readthedocs.io/zh_CN/master/
  • PyPi:https://pypi.org/project/pypinyin/

它有这么几个特性:

  • 根据词组智能匹配最正确的拼音。
  • 支持多音字。
  • 简单的繁体支持, 注音支持。
  • 支持多种不同拼音/注音风格。

是不是等不及了呢?那就让我们来了解一下它的用法吧!

安装

首先就是这个库的安装了,通过 pip 安装即可:

1
pip3 install pypinyin

安装完成之后导入一下这个库,如果不报错,那就说明安装成功了。

1
\>>> import pypinyin

好,接下来我们看下它的具体功能。

基本拼音

首先我们进行一下基本的拼音转换,方法非常简单,直接调用 pinyin 方法即可:

1
2
from pypinyin import pinyin
print(pinyin('中心'))

运行结果:

1
[['zhōng'], ['xīn']]

可以看到结果会是一个二维的列表,每个元素都另外成了一个列表,其中包含了每个字的读音。 那么如果这个词是多音字咋办呢?比如 “朝阳”,它有两个读音,我们拿来试下:

1
2
from pypinyin import pinyin
print(pinyin('朝阳'))

运行结果:

1
[['zhāo'], ['yáng']]

好吧,它只给出来了一个读音,但是如果我们想要另外一种读音咋办呢? 其实很简单,只需添加 heteronym 参数并设置为 True 就好了,我们试下:

1
2
from pypinyin import pinyin
print(pinyin('朝阳', heteronym=True))

运行结果:

1
[['zhāo', 'cháo'], ['yáng']]

OK 了,这下子就显示出来了两个读音了,而且我们也明白了结果为什么是一个二维列表,因为里面的一维的结果可能是多个,比如多音字的情况就是这样。 但这个多少解析起来有点麻烦,很多情况下我们是不需要管多音字的,我们只是用它来转换一下名字而已,而处理上面的二维数组又比较麻烦。 所以有没有一个方法直接给我们一个一维列表呢?有! 我们可以使用 lazy_pinyin 这个方法来生成,尝试一下:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('聪明的小兔子'))

运行结果:

1
['cong', 'ming', 'de', 'xiao', 'tu', 'zi']

这时候观察到得到的是一个列表,并且不再包含音调了。 这里我们就有一个疑问了,为啥 pinyin 方法返回的结果默认是带音调的,而 lazy_pinyin 是不带的,这里面就涉及到一个风格转换的问题了。

风格转换

我们可以对结果进行一些风格转换,比如不带声调风格、标准声调风格、声调在拼音之后、声调在韵母之后、注音风格等等,比如我们想要声调放在拼音后面,可以这么来实现:

1
2
3
4
from pypinyin import lazy_pinyin, Style

style = Style.TONE3
print(lazy_pinyin('聪明的小兔子', style=style))

运行结果:

1
['cong1', 'ming2', 'de', 'xiao3', 'tu4', 'zi']

可以看到运行结果每个拼音后面就多了一个声调,这就是其中的一个风格,叫做 TONE3,其实还有很多风格,下面是我从源码里面找出来的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#: 普通风格,不带声调。如: 中国 -> ``zhong guo``
NORMAL = 0
#: 标准声调风格,拼音声调在韵母第一个字母上(默认风格)。如: 中国 -> ``zhōng guó``
TONE = 1
#: 声调风格2,即拼音声调在各个韵母之后,用数字 [1-4] 进行表示。如: 中国 -> ``zho1ng guo2``
TONE2 = 2
#: 声调风格3,即拼音声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``zhong1 guo2``
TONE3 = 8
#: 声母风格,只返回各个拼音的声母部分(注:有的拼音没有声母,详见 `#27`_)。如: 中国 -> ``zh g``
INITIALS = 3
#: 首字母风格,只返回拼音的首字母部分。如: 中国 -> ``z g``
FIRST_LETTER = 4
#: 韵母风格,只返回各个拼音的韵母部分,不带声调。如: 中国 -> ``ong uo``
FINALS = 5
#: 标准韵母风格,带声调,声调在韵母第一个字母上。如:中国 -> ``ōng uó``
FINALS_TONE = 6
#: 韵母风格2,带声调,声调在各个韵母之后,用数字 [1-4] 进行表示。如: 中国 -> ``o1ng uo2``
FINALS_TONE2 = 7
#: 韵母风格3,带声调,声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``ong1 uo2``
FINALS_TONE3 = 9
#: 注音风格,带声调,阴平(第一声)不标。如: 中国 -> ``ㄓㄨㄥ ㄍㄨㄛˊ``
BOPOMOFO = 10
#: 注音风格,仅首字母。如: 中国 -> ``ㄓ ㄍ``
BOPOMOFO_FIRST = 11
#: 汉语拼音与俄语字母对照风格,声调在各个拼音之后,用数字 [1-4] 进行表示。如: 中国 -> ``чжун1 го2``
CYRILLIC = 12
#: 汉语拼音与俄语字母对照风格,仅首字母。如: 中国 -> ``ч г``
CYRILLIC_FIRST = 13

有了这些,我们就可以轻松地实现风格转换了。 好,再回到原来的问题,为什么 pinyin 的方法默认带声调,而 lazy_pinyin 方法不带声调,答案就是:它们二者使用的默认风格不同,我们看下它的函数定义就知道了: pinyin 方法的定义如下:

1
def pinyin(hans, style=Style.TONE, heteronym=False, errors='default', strict=True)

lazy_pinyin 方法的定义如下:

1
def lazy_pinyin(hans, style=Style.NORMAL, errors='default', strict=True)

这下懂了吧,因为 pinyin 方法默认使用了 TONE 的风格,而 lazy_pinyin 方法默认使用了 NORMAL 的风格,所以就导致二者返回风格不同了。 好了,有了这两个函数的定义,我们再来研究下其他的参数,比如定义里面的 errors 和 strict 参数又怎么用呢?

错误处理

在这里我们先做一个测试,比如我们传入无法转拼音的字,比如:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('你好☆☆,我是xxx'))

其中包含了星号两个,还有标点一个,另外还包含了一个 xxx 英文字符,结果会是什么呢?

1
['ni', 'hao', '☆☆,', 'wo', 'shi', 'xxx']

可以看到结果中星号和英文字符都作为一个整体并原模原样返回了。 那么这种特殊字符可以单独进行处理吗?当然可以,这里就用到刚才提到的 errors 参数了。 errors 参数是有几种模式的:

  • default:默认行为,不处理,原木原样返回
  • ignore:忽略字符,直接抛掉
  • replace:直接替换为去掉 u 的 unicode 编码
  • callable 对象:当传入一个可调用的对象的时候,则可以自定义处理方式。

下面是 errors 这个参数的源码实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _handle_nopinyin_char(chars, errors='default'):
"""处理没有拼音的字符"""
if callable_check(errors):
return errors(chars)

if errors == 'default':
return chars
elif errors == 'ignore':
return None
elif errors == 'replace':
if len(chars) > 1:
return ''.join(text_type('%x' % ord(x)) for x in chars)
else:
return text_type('%x' % ord(chars))

当处理没有拼音的字符的时候,errors 的不同参数会有不同的处理结果,更详细的逻辑可以翻看源码。 好了,下面我们来尝试一下,比如我们想将不能转拼音的字符去掉,则可以这么设置:

1
2
from pypinyin import lazy_pinyin
print(lazy_pinyin('你好☆☆,我是xxx', errors='ignore'))

运行结果:

1
['ni', 'hao', 'wo', 'shi']

如果我们想要自定义处理,比如把 转化为 ,则可以这么设置:

1
print(lazy_pinyin('你好☆☆,我是xxx', errors=lambda item: ''.join(['※' if c == '☆' else c for c in item])))

运行结果:

1
['ni', 'hao', '※※,', 'wo', 'shi', 'xxx']

如上便是一些相关异常处理的操作,我们可以随心所欲地处理自己想处理的字符了。

严格模式

最后再看下 strict 模式,这个参数用于控制处理声母和韵母时是否严格遵循 《汉语拼音方案》 标准。 下面的一些说明来源于官方文档: 当 strict 参数为 True 时根据 《汉语拼音方案》 的如下规则处理声母、在韵母相关风格下还原正确的韵母:

  • 21 个声母: b p m f d t n l g k h j q x zh ch sh r z c sy, w 不是声母
  • i行的韵母,前面没有声母的时候,写成yi(衣),ya(呀),ye(耶),yao(腰),you(忧),yan(烟), yin(因),yang(央),ying(英),yong(雍)。(y 不是声母
  • u行的韵母,前面没有声母的时候,写成wu(乌),wa(蛙),wo(窝),wai(歪),wei(威),wan(弯), wen(温),wang(汪),weng(翁)。(w 不是声母
  • ü行的韵母,前面没有声母的时候,写成yu(迂),yue(约),yuan(冤),yun(晕);ü上两点省略。 (韵母相关风格下还原正确的韵母 ü
  • ü行的韵跟声母j,q,x拼的时候,写成ju(居),qu(区),xu(虚),ü上两点也省略; 但是跟声母n,l拼的时候,仍然写成nü(女),lü(吕)。(韵母相关风格下还原正确的韵母 ü
  • iou,uei,uen前面加声母的时候,写成iu,ui,un。例如niu(牛),gui(归),lun(论)。 (韵母相关风格下还原正确的韵母 iou,uei,uen

当 strict 为 False 时就是不遵守上面的规则来处理声母和韵母, 比如:y, w 会被当做声母,yu(迂) 的韵母就是一般认为的 u 等。 具体差异可以查看源码中 tests/test_standard.py 中的对比结果测试用例。

自定义拼音

如果对库返回的结果不满意,我们还可以自定义自己的拼音库,这里用到的方法就有 load_single_dict 和 load_phrases_dict 方法了。 比如刚才我们看到 “朝阳” 两个字的发音默认返回的是 zhao yang,我们想默认返回 chao yang,那可以这么做:

1
2
3
4
5
6
7
8
from pypinyin import lazy_pinyin, load_phrases_dict

print(lazy_pinyin('朝阳'))
personalized_dict = {
'朝阳': [['cháo'], ['yáng']]
}
load_phrases_dict(personalized_dict)
print(lazy_pinyin('朝阳'))

这里我们自定义了一个词典,然后使用 load_phrases_dict 方法设置了一下就可以了。 运行结果:

1
2
['zhao', 'yang']
['chao', 'yang']

这样就可以完成自定义的设置了。 在一些项目里面我们可以自定义很多拼音库,然后加载就可以了。 另外我们还可以注册样式实现自定义,比如将某个拼音前面加上 Emoji 表情,样例:

1
2
3
4
5
6
7
8
9
10
from pypinyin.style import register
from pypinyin import lazy_pinyin

@register('kiss')
def kiss(pinyin, **kwargs):
if pinyin == 'me':
return f'?{pinyin}'
return pinyin

print(lazy_pinyin('么么哒', style='kiss'))

运行结果:

1
['?me', '?me', 'dá']

这里我们调用 register 方法注册了一个样式 style,然后转换的时候指定即可,通过观察运行结果我们可以发现,这样我们就可以将 me 字的拼音前面加上 ? 这个 Emoji 表情了。 以上就是 PyPinyin 这个库的基本用法,更多的用法建议大家看看源码或者看 API 文档:https://pypinyin.readthedocs.io/zh_CN/master/api.html

个人随笔

正式入职微软,提交了第一个 PR 之后,我坐在椅子上思考人生。终于我也变成了一名正式的企业员工,变成了一名正式的踏入社会的职业人士,从此我的学生生涯也算是画上了一个句号,不,更确切的说应该是画上了一个引号。 和同事租了房子,生活条件算是还不错,有了属于自己的房间,有了专属自己的衣柜、书橱、办公桌,想要的硬件、软件、日常用品想配就配,算是应有尽有了。首先日常生活上最大的感受就是自己的生活条件变得更好、更自由,不再像学校一样有各种限制了,虽然日常花销变多了,但总体上来说我更喜欢现在的生活环境。

回顾

先回想一下自己的学生生活吧,初高中就不说了,就天天上课为了高考,后来在大学基本上就是三点一线,宿舍——食堂——教室/实验室,然后读研,研究生模式也差不多,宿舍——食堂——实验室/公司。

编程入门

我大学的时候选的就是计算机科学与技术这个专业,当时学校开的第一门入门课就是 Java,当时可以说是对编程一窍不通,什么 print?打印机吗?控制台是什么?控制谁?什么面向对象?我对象在哪里?都是些什么玩意。就这样,随着老师的课堂洗脑和一些并不怎么感兴趣的编程作业,我慢慢理解了原来 print 是这么个玩意,对象原来不是那个对象,就慢慢对编程建立了一个概念。插句话,其实我感觉编程的一些概念和思维还是很重要的,有人说编程学不会,可能就是脑中没有形成一个比较清晰的概念,想清楚它能做到什么,怎样从用双手解决一件事的思维转换成用编程解决一件事的思维。 扯远了,但那时候仅仅 Java 是一门课而已,虽然最后考试考得还不错,但是还是不是很懂它能为我带来些什么东西。后来大学就开了一个叫做课程设计的课,意思就是说让自己动手编程实现一个可以操作的项目。我们当时学校要求的就是实现一个黑白棋在线对战系统,当时可把我为难坏了。后来了解到了这里面还挺复杂的,又得编写界面又得搞一些算法,还得搞一个在线 WebSocket 通信,当时可以说是毫无思路,然后就去网上搜一些 Java 的教程,当时是搜到了马士兵的 Java 课就顺着看了起来,可以说马士兵是我的 Java 最重要的启蒙老师了,慢慢地把一些原理和基础学会了之后就有了基本的编程思路了,开始上手编,做界面,做服务器等等。另外还有一些小插曲,有的界面还得抠图,当时为了打造一个完美的棋盘效果还学了 PS 来花了一个木质棋盘和黑白棋子,估计得花了好几个月的时间终于做出来了一个像样点的系统,虽然现在源码已经找不到了,但真的说这个课程设计真的让我理解了编程的一些思维和理念以及它能为我带来些什么,我能够用它做到什么,脑中的一些概念变得更加清晰了,收获非常大。 后来学校开了数据结构和算法的一些课,慢慢地我又对一些基础的算法和 C++ 的一些编程语言有了一定的了解。再后来就是一些基础专业课了,比如操作系统,计算机组成原理,计算机网络等等,总体来说其实我没太感觉出具体有多大作用,但你要假设我没学过,我可能有很多东西都不知其理。人就是这样,有些东西在拥有的时候觉不出有什么好的,但一旦没有才会有明显的感觉。

加实验室

好吧又扯远了,然后就可以说迈入了我人生中一个比较重要的点了,那就是加入学校的一个实验室。之前许多东西我和室友自己瞎倒腾,比如当时进行版本控制的话,就是自己手动压缩一下,命名项目的时候添加一个版本号并用下划线分割,后来进了实验室才知道还有 Git 这么牛逼的东西,于是乎就跟着学习了 Git,了解了 GitHub,觉得整个代码世界都光明了。当时我加入的是后台组,一开始是从 PHP 开始学起的,从原生的 PHP,到普通的 CodeIgniter 框架,到高级的 Laravel 框架,当时写的时候主要用是 MVC 模式,所以前端的东西也难免需要用到的,所以那会儿又学习了前端的一些知识,慢慢地就变成了 Web 前后端通吃,自己也可以逐渐完成一些大型项目的开发工作。当时还自己开通了博客来记录自己学习的一些经验,然后跟着实验室一起接外包做外包,做了不下十个门户及商业网站的开发。

学习爬虫

再往后可能就是临近大学毕业的时候了,那会儿实验室的一位学长写了一些爬虫的入门文章,当时也跟着学了起来,边学边记录,学的整理的一些知识点都放在了自己的博客上。后来又探索了一些新的爬取方案,也一并整理到博客上了,形成了一个入门到进阶的一套简易版教程,后来随着写的越来越多,来看的人也越来越多了,后来访问量也逐渐上来了,现在的话日均访问量可以达到 15000+。 再往后可能就差不多大学毕业了,当时由于是保研到北航的,所以就提前来到了北京开始了研究生的预备工作,也节省了不少时间。那会儿就有充足的时间来做自己的事情,比如学习一些网络课程,继续做一些关于爬虫的研究工作。当时随着我的博客访问量越来越大,图灵便联系我看能不能写一本关于爬虫的书,当时想的一个是可以把自己学习的知识好好整理一下,还可以作为自己的一部个人作品出版出来,的确是一件非常不错的事情,所以当时就答应下来了。不得不说写书的过程是非常艰辛的,舍去了好多平时的休息时间,同时还发现了自己的很多不足的地方去查漏补缺,最终也不得不延期了好几个月才交上稿。后来又审校了非常久的时间,到去年五月份才出版出来,定名字叫《Python3网络爬虫开发实战》。不过后来的销量还算不错,现在已经重印了 10 次,50000 本了。现在还在继续撰写第二版中,把一些过期的案例和知识点更新,再把一些新的技术加进去。

研究生生活

OK,当然研究生阶段也不是都写书了。研究生阶段其实一开始是比较迷茫的,其实当时并不知道自己毕业之后要做什么方面的工作以及想去哪里。最初读的时候是选了网络安全的方向,做一些 Web 渗透方面的研究,后来觉得研究得差不多了,就又转了自然语言处理的方向,从吴恩达的机器学习开始学起,然后了解了深度学习的一些模型,又了解了自然语言处理的一些知识。与此同时,我也在微软这边当实习生,从最初的爬虫、 Platform 再到 Science NLP 研究,慢慢地也认识了一些大佬,和他们一起交流的确让我学习到了不少。 由于我在微软这边实习时间不短,所以当时也参加了实习生转正的面试。微软整个的面试流程还是很规范和严格的,包括多面技术面,另外每一面的要求也都不低的。首先最基础的要求就是算法,给你几道题目,来白纸上把这道题的代码写出来,面试官会非常注重边界处理和细节把握,如果要写不出来,基本上也离凉凉不远了。接下来还有一些基本的公式推导,比如如果要面试机器学习算法工程师的话,可能会让手写推导 SVM、LR 等算法。另外还有一些系统设计题,来看看你的思维和架构是不是能达到要求。最后我记得还有一些智力题,看看反应得快不快。再往后的面试也是谈谈自己对行业的一些理解和看法,谈得还是比较深入的。总之考察得非常综合,当时准备面试的期间真的是无比焦虑,感觉人心惶惶的,当时在疯狂地刷题,复习各种算法推导,准备了也差不多有一两个月。最后得知 Offer 的那一刻,一块石头终于落地了。最后我也如愿入职了微软小冰,今年三月刚刚入职,也希望能为小冰带来更多的贡献,也希望大家可以多多关注微软小冰。 其实我已经在微软这边实习了一年多的时间了,平时很多时间也都呆在公司里,自己也算提前一步迈入职场了,经过我个人实习的体验和感受,同时也结合自己平时的了解,总结出来了一些经验。当然这仅仅是我个人的一些看法,仁者见仁智者见智,在这里仅仅做一些经验总结和分享,总结一些从学生迈入职场之后,我会注重的一些地方。如果对你有些启发,那是再好不过了。

工作相关

首先就是工作相关的一些东西了,由学生到工作,我个人觉得还是有一些需要调整的地方的,下面稍微这里说一下。

转换思维

学生到职场的转换,第一个重要的就是转变自己看待事情的思维。迈入职场,就别再有一种”我是学生“、”我刚刚毕业“ 这样的想法,在职场里别人才不管我们是不是刚毕业的学生,他们看的基本上都是我们能不能完成工作或者配合他们完成工作。 另外也别总有一种”努力必有回报“这样的思维。学生时代,可能一道题解不出来,一个项目做不出来,努努力很大程度上还是可以能解的。但是到了职场,这个很不一样,很多事情并不是一定存在因果这样的线性关系的。比如说某个项目你辛辛苦苦做了很久,可能就因为领导不想要这个功能而直接砍掉了。比如说你辛辛苦苦写了稿子,可能因为和某个评阅人的想法不一致而被直接拒掉了。想开点,有时候就是这么操蛋。 还有一个就是别把一件事想的太简单,在工作中,其实很多事上牵扯的东西是很多的。学生的一件事可能就是一件事,一道题可能就是一套题。工作中一件事可能不仅仅是一件事,它所关系到的东西要复杂的多。我们可能会考虑到对公司、对领导、对同事、对绩效、对家人的很多事情,多考虑考虑。 总之,思维的转换是第一步,别再像之前当学生的时候一样了。

靠真本事

首先得考虑清楚啊,公司把我们招来是为了让我们来发挥价值的。 而我们的价值在哪里发挥?当然是体现在工作成果中。那成果哪里来的?那当然是把自己的能力转化过来的。 不同职位有不同的要求,首先确保的是要利用自己的能力把本职工作做好。当然我是做技术的,我所专注的就是技术这个领域了,技术能力是必不可少的,当然做这份工作需要的其实也不仅仅是技术能力,还有一些非表面意义上的能力,如学习探索能力、沟通合作能力等等。 而这些吧,归根结底还是要靠自己的真本事的。别想着投机取巧,别想着耍点小聪明,虽然一时方便了,但是长远来看,吃亏的还是自己。 当然这例子很多,比如明明不是自己做的,却要为了某些目的非要伪装自己,被看穿之后,不少人其实是看破不说破的,慢慢地自己就会知道后果了。比如为了某些目标,背地里各种跟领导各种小恩小惠等,其实领导基本不会 Care 你这种小聪明,甚至还觉得这个人有点靠不住,自己的受信任度也会大打折扣,另外自己不会感到心虚吗? 最好的办法是什么,其实就是把该做完的工作保质保量完成,靠自己的真本事,实打实做好要求的每一件事就好了,领导看重的就是这个。

利益为先

职场上面啊,你说人情,当然也是会有的,但是更多的人关注的其实是工作本身以及自己的利益。这和学校的差距还是很大的。比如你要是个学生的话,有些人可能觉得你的身份而稍微谅解一下,而踏入社会之后,基本就不会了。 比如你要跟某个人交流和合作,就别总是寒暄以此来套近乎,不是说这个一点用都没有,多少可能还是有点用的,如果你有价值,靠这个可能能快速拉近他与你的距离。但很多情况下,这个的效果可能真的作用不大。 到了职场,大家都很忙的,比如找人谈事情,稍微介绍几句,开口直接谈正事、谈利益就好了。如果不能为对方提供有价值的信息,或者提供的价值并不是对方想要的,对方会觉得比较浪费时间。这种时候就别掺杂什么别的什么交情之类的东西了,虽说我认为成年人只看利益不够准确,但基本上是没错的。 比如跟人谈个合作的时候,讲清楚两点,第一是我们能提供什么价值和服务,第二是他能得到什么利益。当然第二点有的情况下是不需要说的,因为我们本身提供的价值就是给对方的利益,对方觉得值,自然会跟我们合作的。其中也不乏一些沟通的小技巧,还需要在这个过程中慢慢摸索。

保持交流

领导给了一个要求,如果有疑问,要多问多交流,别偏离了方向,别是自作主张。 之前的时候,我接到了一个任务,了解了基本的需求时候就开始开工干。在做的过程中其实有不少不确定的地方,但是当时我也不知道是不好意思还是什么原因,就没有跟领导交流,按照自己的想法做了出来。最后演示的时候,领导说你怎么做成这样了?我本意不是这样的,你有疑问怎么不跟我讨论呢? 我就意识到了,这其实也可以多少印证”选择比努力更重要“,方向性或方案性的东西是很重要的。 所以,遇到什么不确定的点,要多多沟通交流。当然这并不是说突然想到了某个点就去找对方讨论,把自己的疑问以及可能解决方案和结果梳理清楚再好好交流一下,效果一定好上太多。

工作日志

做好工作日志,时间长了,会发现这个非常有用,它的有用不仅仅是体现在它仅仅是做了总结,它也会潜移默化地影响我们自己。 比如每天可以找个时间简单记录一下自己今天做了什么事情,收获了什么,还需要做什么。这个记录的过程就是一个思考的过程,它会让我们反思自己的一些不足和需要做的更好的地方,会变成一种激励的。 另外工作日志,等某天你打开的时候,会发现成就感满满,同时写什么总计和汇报也就不用愁了。

个人相关

刚才所说的挺多都是和工作相关的,另外还有一些和个人相关的我觉得也应该好好注意下。

迈出第一步

我曾经尝试过很多坚持每天做点什么的事情,发现有的挺难坚持下来的,一而再再而三地累积,就变得越来越多,后来整个坚持的事情就失败了,这就是拖延症的一种现象。 拖延症应该很多人都会有,当然我也是。我平时分析拖延症的一个很大的原因就是迈不出第一步,进而一件事就搁置了。就比如说要去健身房,我觉得最难的就是出家门;比如说要每天刷一道算法题,最难的可能就是打开LeetCode界面。不知道你们什么意见,我至少是这么认为的。 因此,迈出第一步非常重要,迈出了第一步,可以说就成功了一半。

言多必失

的确是,言多必失,这话没毛病。 可能我们仅仅是刚入职的新人,很多情况下不该说的就别说。你永远也不知道你说了某些话之后,你在别人耳中会变成什么样的版本。另外如果我们突然说错了什么话,被人揪住了把柄,那可是很难的。所以,一些场景中,我们一些没必要说的就不要去说,不知道该说不该说的也不要说。 另外我个人比较反感的是明一套暗一套。我们不可能让所有人都认同我们自己,不认同我们的,他们表现出来,我们道不同不相为谋。认同我们的支持我们的,我们可以与他们成为朋友。但表面上显得非常友好,然而在背地里面却说坏话,这是非常令人反感的。

终身学习

虽然表面上看学生生涯结束了,但实际上迈入职场恰恰是一个新的开始,其实人与人之间的差距就是因为工作后的这些年逐渐拉开的,所以不论什么时候都不能放弃学习。 当然迈入职场以后,学习的一些侧重点可能就不太一样了。工作后学习的第一肯定是能用在岗位之上的专业知识,我们所掌握的知识一定至少要能够让我们顺利地专业地完成自己的任务。其次可以扩宽一下知识面,比如可以关注下经济、理财、交际等知识。 总之还是一句话,学习到的本事是别人所偷不走的,做一个终身学习者。

做好记录

很多事情是确实很有必要记录下来的。首先不瞒说,随着事情的增多,工作的忙碌,我发现比原来更加”忘事“了。后来我看了一篇报道,说其实并不是脑子记忆力下降了多少,而是集中的注意力变得更少了。 一件事如果注意力分配得少了,自然很多事情就不容易被我们记住。想想确实是这样的,现在我每天都把自己的计划安排得满满当当,现在连拿出时间好好读一本书的时间都不多了,因此就别提那些日常小事了。那咋办?随手记录下来,比如记到手机的备忘录或者自己的 TodoList 软件里面,然后再进一步安排如何执行就好。 当然记录并不仅仅局限于这个记录下来平时的闪念,平时的一些工作总结、学习笔记也可以时刻记录下来。我有记录学习笔记的习惯,当然肯定也有的时候有一些内容没有及时记录下来。过了一段时间,我发现唯一记得的就是自己曾经整理过笔记甚至发表博客的那些内容,没写过的或者没发过的基本都忘干净了。 记录,成为更好的自己。

作息调整

良好的作息还是非常有必要的。现在肯定非常多的朋友会倾向于熬夜,十二点之后才睡。我之前实习的时候,比这个更狠,经常一两点钟睡觉,睡到将近中午,就直接吃中午饭得了,然后一上午就没有了,当时就有一种半天已经被我浪费的感觉,会有一种负罪感。 后来我开始逐步调整我的作息,尽量早睡早起,从之前的九点多慢慢地调整到八点,甚至是七点多,醒来之后整理一些东西,开始全新的一天,整体的体验确实是比之前睡到中午好太多了。另外我个人也参加了一个早起打卡活动,现在已经坚持每天打卡将近一个月了,基本上也养成了早起的习惯。 另外工作之后,下班的时间也可以好好利用起来,比如晚上稍微拉伸一下,睡前记得喝水等等,由于我平时也每天对着电脑,所以也需要定期地起来活动一下。我把它们设置到滴答清单里面,每天都有软件的定时提醒,到了时间就做,一天天坚持下来。总体来说,感觉也比之前好多了。 所以从现在起,拿起笔好好规划下自己的目标和平时的作息吧。 另外可能大家听说过,某个老板每天只睡五个多小时,晚上工作到很晚才走,早上一大早就在了。我当时也比较好奇这到底是怎么做到的?难道有什么特殊的睡眠技巧?经过我的一阵搜罗,结果是,没有。BOSS 们并没有我们这么忙,可能早上开完会,我们去干活了,而人家去补觉了。所以,保证充足的睡眠还是很有必要的。

合理分配时间

从学生到工作,其实就相当于我们的事情又多了一部分,工作日大部分为工作时间,非工作日则大部分是自己的时间。另外工作之后,相比学生时代来说,事情可能也会变得更多更繁琐。那么如何合理分配自己的时间呢? 我这边采用了目前比较流行的四象限时间管理法,他就是把处理的事按“重要”和“紧急”两个维度划分,并对应到四种待处理状态中,分别叫做”重要不紧急“,”重要紧急“,”不重要紧急“,”不重要不紧急“四个类别。 通常来说,我们需要马上执行“急事”,确保它们不会延期。但长远来看,我们最好将重心放在“要事”上。可能有一些紧急的小事,它确实是不重要的,但我们能比较快的做完,然后把它勾掉,所以现在很多人可能更加优先处理的是紧急的事情而不是重要事情,最后紧急的事情做完了,剩下重要的事情没做完,所以要么加班熬夜,要么就延期。回想起来,其实这是很得不偿失的,忙活了一天,重要的事情还是没完成。 所以,我们需要尽量减少”不重要紧急“事件的忙碌,尽早处理”重要不紧急“的事情,合理分配时间,去做对我们而言重要的事情,才是四象限理论的核心。 我自己使用了滴答清单这款软件,之前使用的是 Todoist,这两款软件都非常不错,但是由于滴答清单多了番茄时钟的功能,我就改用了滴答清单,我利用它建立了四个智能清单,分别叫做”重要不紧急“,”重要紧急“,”不重要紧急“,”不重要不紧急“,然后建立筛选条件,软件就会根据任务的优先级和到期日期自动更新。然后就可以每天查看、处理并调整任务——做完的事情及时打勾,有变更的事情及时移动,可以遵循这样的一些准则:

  • 优先执行重要且紧急的事件
  • 尽量提前规划重要不紧急事件,在它们变得紧急前就完成
  • 如果可以的话,试着将不重要但紧急的事情交由他人处理,或者学会对别人的请求说 Sorry
  • 需要控制去做不重要不紧急事件的时间,不要过度放松

有了这几条原则,我们就可以很好的分配我们的时间了,如果大家觉得有用的话也可以试一下。 以上就是我的成长历程以及我所想到的一些需要调整和坚持的一些做法,如果能为大家带来一些帮助,那就再好不过了。

技术杂谈

入职微软之后,这边大多数是使用 Windows 进行开发的,比如我的台式机是 Windows 的,还有一部分服务器是 Windows 的,当然 Linux 是也非常多。 很多情况下我是使用自己的 Mac 笔记本来远程连接我的 Windows 机器来开发的。比如如果我在工位上,我会用我的 Mac 连接两块显示屏,然后一种一块用来远程桌面连接我的 Windows 开发机,这样另外一块屏幕和 Mac 自带的屏幕就用来看文档或者使用 Teams 通讯等等。如果我回家了,我家里也是有两块屏,开上 VPN,照样用一块屏使用远程桌面,另外一块屏幕和 Mac 自带屏幕就可以做其他事情了。 这样就解决了一个问题:我的 Windows 基本上都是仅用作开发的,一块屏幕就开着一个 Visual Studio,其他的操作都会在 Mac 进行,比如查文档,发消息等等。这样我下班之后照样使用远程连接的方式来操作,和在公司就是一样的。这样就避免了一些软件的来回登录,比如如果我上班只用公司机器,下班了之后换了 Mac 还得切 Teams、切微信、切浏览器等等,还是很麻烦的,而且上班期间 Mac 就闲置了也不好。所以我就采取了这样的开发方案。

需求分析

有了这个情景,就引入了一个问题。开了一个远程桌面之后,我几乎一个屏幕都是被 Visual Studio 占据的,而远程桌面貌似只能开一个屏幕?如果我要再开一个终端窗口的话,那可能屏幕就不太够用了,或者它就得覆盖我的全屏 Visual Stuido。 另外我平时 Mac 终端软件都是使用 SSH 的,基本都是用来连 Linux 的,Windows 一般都是开远程桌面。但命令行这个情形的确让我头疼,让我感到不够爽,因为毕竟远程桌面之后,Windows 里面的操作都得挤在一个桌面里面操作了。当然可能能设置多个桌面,如果可以的话,麻烦大家告知一下谢谢。 所以解决的痛点在于:我要把一些操作尽量从 Windows 里面分离出来,例如终端软件,我能否在远程桌面外面操作,能否使用 SSH 来控制我的 Windows 机器。 好,有需求才有动力,说干就干。

配置

查了一下,Windows 上其实也是有 SSH 服务器的,只不过默认是没有装的,这里只需要安装一个 OpenSSH 服务器就好了。 Win10 的话,就在设置里面可以安装,从开始菜单打开“设置”,然后选择应用和功能,这里就有一个“管理可选功能”的选项。 image-20190319093941643 点击之后便可以看到一个可选功能,选择 OpenSSH 服务器即可,一般情况下是没有安装的。如果没有安装的话它会提示一个安装按钮,这里我已经安装好了,就提示了一个卸载按钮。 image-20190319094113033 OK,有了它,直接点击安装即可完成 OpenSSH 服务器的安装。 当然如果你是想批量部署 Windows 服务器的话,当然是推荐使用 PowerShell 来自动化部署了。 首先需要用管理员身份启动 PowerShell,使用如下命令看一下,要确保 OpenSSH 可用于安装:

1
Get-WindowsCapability -Online | ? Name -like 'OpenSSH*'

输出应该是类似的结果:

1
2
3
4
Name  : OpenSSH.Client~~~~0.0.1.0
State : NotPresent
Name : OpenSSH.Server~~~~0.0.1.0
State : NotPresent

然后使用 PowerShell 安装服务器即可:

1
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

输出结果类似:

1
2
3
Path          :
Online : True
RestartNeeded : False

这样也可以完成 OpenSSH 的安装。 安装完成之后,就需要进行一些初始化配置了,还是以管理员身份,使用 PowerShell 执行即可。 首先需要开启 SSHD 服务:

1
Start-Service sshd

然后设置服务的自动启动:

1
Set-Service -Name sshd -StartupType 'Automatic'

最后确认一下防火墙是否是放开的:

1
Get-NetFirewallRule -Name *ssh*

如果是放开的,那么结果会提示 OpenSSH-Server-In-TCP这个状态是 enabled。 好了,完成如上操作之后我们就可以使用 SSH 来连接我们的 Windows 服务器了。

连接

连接非常简单了,用户名密码就是 Windows 的用户名和密码,使用 IP 地址链接即可。 比如我的 Windows 开发机的局域网 IP 为:10.172.134.88,那么就可以使用如下命令完成链接:

1
ssh user@10.172.134.88

然后输入密码,就连接成功了,和 Linux 的是一样的。 另外我自己现在 Mac 常用的 SSH 客户端工具有 Termius,可以多终端同步使用,非常方便,这里我只需要添加我的 Windows 机器就好了,如图所示: image-20190319101812208 OK,以后就可以非常轻松地用 SSH 连接我的 Windows 服务器了,爽歪歪,上面的需求也成功解决。 以上便是使用 SSH 来连接 Windows 服务器的方法,如果大家有需求可以试试。

Python

都说程序猿是一类不解风情的生物,“赚的多,花的少,死的早”已经成为了程序猿的标志,“眼镜、格子衫、垢面蓬头、拖鞋裤衩”已然也成了程序猿的代表形象,“代码、游戏、老湿”也已经快要成了程序猿的生命。 但!有的时候,比如情人节,我们就可以发挥我们的特长了,我们程序猿也可以有自己的浪漫! 不过这个第一步是,你得有一个女朋友(哦哦,是不是可以不用往下看了? 那么有了第一步之后,下面我们应该怎么办呢? 下面介绍一个比较实用的可以送给女朋友的礼物(这其实也是我今天送给女朋友的礼物嘿嘿。 首先想想,作为程序猿,我们的专长是什么?废话,当然是代码。 有了代码,还需要送口红吗?还需要送包包吗?还需要送鲜花吗?废话,都有了代码了,这些当然就….还是要送的。万一写的代码你女朋友看不懂那岂不是死翘翘了。 好那送完了口红或包包或鲜花之后,确保已经平安无事了,我们就可以再发挥我们的光和热了(听起来咋这么奇怪呢? 进入正题,那我们可以利用代码做点什么呢?想想可以做文章的地方有什么,你们的纪念日,你们曾经做过的事情,你们在一起的时间,这些都是属于你们的独一无二的,我们可以想方设法把它们和代码联系起来。 那怎么发给女朋友看呢?做个 App、小程序、网页什么的都是可以的吧,其中网页可能是做起来最快最方便的了,然后配上一个专属域名,简直美滋滋。 好,那一想,基本方向就确定了,直接开干,接下来就描述一下我准备这个礼物的历程吧。 对于我来说,我就计划做一个网页,同时用代码的形式把和女朋友在一起的时间呈现出来,通过网页的动效来呈现我们在一起的时间,另外还计划把我们之间的故事用代码关联表示出来。 本来我打算是从零开始手撸一个的,但是一些组件比如动画特效,还有一些倒计时的组件是相对比较难做的,于是我就在 GitHub 上逛了一下,看了几个示例,找到了一个和我理想作品差不多的项目,然后在它的基础上做了一些改动,就成了最终的效果。 主要功能如下:

  • 第一是通过代码来表述出来和女朋友之间的故事。由于我和女朋友是因为 Python 认识的,而且我们两个平时都会写一些 Python,所以我决定用 Python 来写出我们之间的故事,加上 Python 的注释来辅助描述每一行代码的意义。
  • 第二是通过代码来呈现我和女朋友在一起的时间。这里就用上了一些动画特效和秒数计时方案,实时地呈现出来我和女朋友在一起已经有多久了。

最终完成之后的效果是这样子的: 预览图 然后由于我自己有一个域名,叫做 cuiqingcai.com,然后我就把它设置了二级域名解析,二级域名名称就叫做 love,域名最终就是 love.cuiqingcai.com。 最终的效果大家可以扫码或者复制链接查看一下最终的效果:http://love.cuiqingcai.com/,二维码如下: 二维码 感觉还可以吧?如果你也想送这样的礼物的话,可以根据我现有的代码来进行修改,我已经将源码放到 GitHub 了,地址为:https://github.com/Germey/ValentinesDay,大家可以修改源码,把它变成属于你和你女朋友的专属页面,然后送给女朋友。 下面说一些关键的技术和需要修改的点。

代码动画

打开页面之后,我们可以看到页面的代码是一个字一个字敲出来的,这实际上是利用了一个定时器来实现的。 首先我们可以预定义好所有的文本,然后动画播放的时候,首先把所有的文本隐藏,然后每隔几十毫秒读取一个字符,然后将其呈现出来。由于文本本身就是换行的,所以在呈现的时候就会一行一行地像打字机一样呈现出来。 另外为了模拟打字的效果,在呈现的时候可以在最后的字符后面添加一个下划线符号,模拟打字的效果。 其关键的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function (a) {
a.fn.typewriter = function () {
this.each(function () {
var d = a(this), c = d.html(), b = 0;
d.html("");
var e = setInterval(function () {
var f = c.substr(b, 1);
if (f == "<") {
b = c.indexOf(">", b) + 1
} else {
b++
}
d.html(c.substring(0, b) + (b & 1 ? "_" : ""));
if (b >= c.length) {
clearInterval(e)
}
}, 75)
});
return this
}
})(jQuery);

这里可以看到,首先获取了页面代码区域的内容,然后通过 DOM 操作将代码先清空,然后利用 setInterval 方法设置一个定时器,定时间隔 75 毫秒,也就是说 75 毫秒循环调用一次。每调用一次,就会从原来的字符上多取一个字符,然后尾部拼接一个下划线就好了。

代码内容

接下来就是代码内容了,这里面要想好怎样把一些关键时间来表示出来。比如和女朋友怎样认识的,后来什么时间在一起的,一起做过什么事情,将来有什么计划和打算,都可以来描述出来,另外编程语言可以选择你喜欢的语言,然后配以一定的注释来描述代码所代表的含义。 我和女朋友是在 PyCon 认识的,也算是因为 Python 结缘,然后平时我们都会写一些 Python,所以我就选用 Python 作为编程语言了。 然后我又加上了我们认识的时间、在一起的时间、一起做过的事情,然后再配以一段代码来表达自己的想法,其中的一些灵感也是我看了一些案例想出来的,在表述过程中我使用了面向对象的思维声明了两个对象,一个代表我,一个代表我女朋友,然后一起做过的事情就可以通过对象调用方法的形式来表述出来了,另外一些动作和标志可以通过自定义方法或者代码的参数来表示出来,其中每一行代码的动作我都配以一条 Python 的注释来完成,注释当然是用英文,一些话我还用了翻译软件一句句查的。 然后最后我用了一段 Python 代码来表达了自己的感情,内容如下:

1
2
3
4
5
6
# You are the greatest love of my life.
while True:
if u.with(i):
you = everything
else:
everything = u

这个代码的含义叫做“无论天涯海角,你都是我的一切。“,一个 while True 循环代表了永久。 这些代码其实都是在 HTML 代码中预定义好的,其中注释需要用 span 标签配以 comments 的 class 来修饰,缩进需要用 span 标签配以 placeholder 的 class 来修饰,例如:

1
2
3
4
5
6
<span class="comments"># You are the greatest love of my life.</span><br/>
while <span class="keyword">True</span>:<br/>
<span class="placeholder"></span><span class="keyword">if</span> u.with(i):<br/>
<span class="placeholder"></span><span class="placeholder"></span>you = everything<br/>
<span class="placeholder"></span><span class="keyword">else</span>:<br/>
<span class="placeholder"></span><span class="placeholder"></span>everything = u<br/>

这里不同的格式用 span 的不同 class 来标识,空格缩进一个 placeholder 是两个空格,comments 代表注释格式,关键词使用 keyword 来标识。如果你需要自定义自己的内容,通过控制这些内容穿插写入就好了。

纪念日计时

关于纪念日,这个实现起来其实挺简单的,就是首先定义好你们的纪念日,然后获取当前系统时间,然后计算秒数差值,然后将其转化为天数、小时数即可,关键核心代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function timeElapse(c) {
var e = Date();
var f = (Date.parse(e) - Date.parse(c)) / 1000;
var g = Math.floor(f / (3600 * 24));
f = f % (3600 * 24);
var b = Math.floor(f / 3600);
if (b < 10) {
b = "0" + b
}
f = f % 3600;
var d = Math.floor(f / 60);
if (d < 10) {
d = "0" + d
}
f = f % 60;
if (f < 10) {
f = "0" + f
}
}

另外它也是通过一个定时器来实现的时间刷新,每隔 500 毫秒调用一次:

1
2
3
setInterval(function () {
timeElapse(together);
}, 500);

动画心形

动画心形,其实这个实现起来是很巧妙的。这里在画的时候实际上是利用了贝塞尔曲线来绘制一个心形,同时在在画的过程中还加了花开的效果,花开的效果使用了随机数和随机颜色生成。 其中动画画心形的核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Petal.prototype = {
draw: function () {
var a = this.bloom.garden.ctx;
var e, d, c, b;
e = new Vector(0, this.r).rotate(Garden.degrad(this.startAngle));
d = e.clone().rotate(Garden.degrad(this.angle));
c = e.clone().mult(this.stretchA);
b = d.clone().mult(this.stretchB);
a.strokeStyle = this.bloom.c;
a.beginPath();
a.moveTo(e.x, e.y);
a.bezierCurveTo(c.x, c.y, b.x, b.y, d.x, d.y);
a.stroke()
}, render: function () {
if (this.r <= this.bloom.r) {
this.r += this.growFactor;
this.draw()
} else {
this.isfinished = true
}
}
};

这里最关键的一个部分就是 bezierCurveTo,这里传入的是绘制贝塞尔曲线的参数坐标,那这些坐标怎么生成的呢,这里是利用了数学上的一个桃心线方程,如图所示: 贝塞尔曲线 其中心形线的解析方程为: 这个公式代表了绘制坐标点的 x、y 的解析方程,用代码表示出来就是:

1
2
3
4
5
6
function getHeartPoint(c) {
var b = c / Math.PI;
var a = 19.5 * (16 * Math.pow(Math.sin(b), 3));
var d = -20 * (13 * Math.cos(b) - 5 * Math.cos(2 * b) - 2 * Math.cos(3 * b) - Math.cos(4 * b));
return new Array(offsetX + a, offsetY + d)
}

这里是生产了心形线方程的 x、y 坐标,然后再以此绘制出带有动画效果的心形。 最终呈现的效果就是现在你看到的样子。 不过这些在改代码的时候实际上不用关心,只需要修改你们在一起的时间就好了,就是代码中的这一行:

1
2
together.setFullYear(2018, 10, 5);
together.setHours(15);

这里修改你们在一起的时间和小时就可以了,然后页面就会自动更新你们在一起多久了,并动态呈现出来了。

域名解析

对于域名解析,这个建议大家可以申请一个域名,比如我的域名就是 cuiqingcai.com,我可以设置一个二级域名解析,叫做 love.cuiqingcai.com。 如果没有域名的话可以现买一个,比如阿里云、腾讯云购买,然后设置解析即可。 如果没有域名,也可以使用一些虚拟云服务器,他们会帮你设置二级域名,当然也可以使用 GitHub Pages,甚至你使用 IP 地址来访问也是没问题的。

项目代码

项目的代码我都放在了:https://github.com/Germey/ValentinesDay,大家可以自行修改成想要的样子送给女朋友,只能帮你到这里啦。 嘿嘿,这就是我今天送给女朋友的礼物,女朋友收到了开心得不得了,开心。

我的礼物

其实我今天也收到了女朋友送的特殊的礼物,可以说她确实花了不少心思啊,她送了我什么呢?令我没想到的是,她居然刚申请了一个微店,然后她微店上架了好多商品,我看到时候惊呆了,店铺如图所示: image-20190214182451019 里面上架了什么商品?洗水果服务?做饭刷碗服务?捏肩膀服务?还有自动哄老婆机?我惊了。 她把商品发给我,我好奇问她这是干嘛的。 她说:要获得我的洗水果服务,捏肩膀服务,只需要在我的小店里购买使用就好了(作掐腰状)!还有自动哄老婆机,你要惹我生气了,只需要购买一个自动哄老婆机,我就会不生气了!嘿嘿合不合算? 我说:多少钱?999!这么贵的吗! 她说:当然不是啦,亲亲我们店里有活动的,使用优惠券满 999 减 998 呢,您是我的 VIP 唯一专属客户,我会给您发优惠券的呀,使用优惠券只需要一块钱就可以购买了。购买之后,您每次使用一张,我就可以给您洗水果、捏肩膀了!这个情人节的话呢,我要送亲亲 10 张!可省着点话,不能累到店长我啊! 哦哦,卧槽真牛逼啊!于是乎我就快快乐乐领取到了十张优惠券购买了女朋友的这些服务,等着时不时用一张,享受一下帝王级的待遇,美滋滋!哈哈~ 最后,祝大家情人节快乐!幸福!

技术杂谈

作为一名程序员,能够利用好工具提高开发和工作效率是非常重要的。我个人使用的都是苹果系列产品,电脑为 MacBook Pro 15 寸,手机 iPhone 7P,另外还有一个 iPad Pro 和一副 Apple Watch。我一直觉得 Mac 是非常适合做程序开发的,它既有比较不错的页面,也有类 Unix 的操作系统,使得日常使用和程序开发都极其便利,另外由于苹果本身自有的 iCloud 机制,使用 Mac、iPhone、iPad 跨平台开发和工作也变得十分便利。

近期我又对自己的一些工具进行了整理,弃用了一些工具,新启用了一些工具。目的也只有一个,就是提高自己的工作和开发效率,让生活变得更美好。如果你也在用 Mac 开发,或者你也有使用 iPad、iPhone,下面我所总结的个人的一些工具或许能给你带来帮助。

快速导航

这是 Mac 上的一个工具,要说到提高效率,首推 Alfred,可以说是 Mac 必备软件,利用它我们可以快速地进行各种操作,大幅提高工作效率,如快速打开某个软件、快速打开某个链接、快速搜索某个文档,快速定位某个文件,快速查看本机 IP,快速定义某个色值,几乎我们能想到的都能对接实现。

其实 Mac 本身已经自带了软件搜索还有 Spotlight,但是其功能还是远远比不上 Alfred,有了它,所有的快捷操作几乎都能实现。

这些快速功能是怎么实现的呢?实际上是 Alfred 对接了很多 Workflow,我们可以使用 Workflow 方便地进行功能扩展,一些比较优秀的 Workflow 已经有人专门做过整理了,可以参见:https://github.com/zenorocha/alfred-workflows,大家可以安装自己所需要的 Workflow,大大提高效率。

复制粘贴

Mac 上默认只有一个粘贴板,当我们新复制了一段文字之后,如果我们想再找寻之前复制的历史记录就找不到了,这其实是很反人类的。

好在 Paste 这款软件帮我们解决了这个问题,它可以保存我们粘贴板的历史记录,等需要粘贴某个内容的时候只需要呼出 Paste 历史粘贴板,然后选择某个特定的内容粘贴就好了,另外它还支持文本格式调整粘贴板分类和搜索,还可以支持快速便捷粘贴。有了它,再也不用担心粘贴板丢失了!

另外使用 Mac 和 iPhone、iPad 之间也可以相互之间复制粘贴,可以在一台 Apple 设备上拷贝文本、图像、照片和视频,然后在另一台 Apple 设备上粘贴该内容。例如,可以拷贝在 Mac 上浏览网页时发现的食谱,然后将其粘贴到附近 iPhone 上“备忘录”中的购物清单。这是在 macOS Sierra 版本之后出来的功能,若要使用需要确保 Mac 的版本是 Sierra 及以后。若要使用,几个设备必须满足“连续互通”系统要求。它们还必须在“系统偏好设置”(在 Mac 上)和“设置”(在 iOS 设备上)中打开 Wi-Fi、蓝牙和 Handoff,另外必须在所有设备上使用同一 Apple ID登录 iCloud。

具体的操作流程可以参见苹果的官网说明:https://support.apple.com/kb/PH25168,有了这个功能,日常的一些操作便可以直接同步了,甚至不再需要 AirDrop,更不需要微信和邮件。

时间管理

现在这个时候,时间比什么都重要,每个人的时间都是公平的,如果我们能够合理规划好自己的时间和工作,这就跨出了成功的一步。

我曾经尝试用手写的方式来记录自己的一些任务,但总感觉它有一些并不方便的地方。比如某时某刻突然想起来,想要添加一件事情或者完成了一件事情,或者想要修改截止时间,或者想要划分优先级,其实都不怎么方便。最好的方式还是通过一些专用的时间管理软件来分配分配和管理自己的时间。

我曾经使用过非常多款时间管理工具,最终我选择的是 Todoist,这个是我感觉体验非常不错的一款。这个软件里面基本的任务添加与勾划功能当然必不可少,它也支持优先级管理,分类管理,时间设置,另外还有几个我觉得非常加分的几个点,比如:

  • 添加时间时可以直接通过一句话来添加,比如”每隔两天晚上九点运动”,它会自动识别并设置为循环任务,并能在相应的时刻提醒你。

  • 支持全部平台,不论是网页还是 Windows、Mac、Android、iPhone、iPad、Apple Watch、Chrome、Firefox,你能想到的平台,应有尽有。

  • 它还支持事件同步,可以在 Mac 或者 iPhone 的日历中添加 Todoist 的同步,这样你所有的事情都会被定时同步到日历软件中,这样日历中就既包含了节日、生日等信息,又可以把每天我们需要做的事同步进来,日程信息一目了然。这样你就可以把日历变成一个提醒器,设置什么时候提醒就好了,现在我就在用 Mac 或者 iPhone 上的日历来提醒我什么时间该做什么事情了。

  • 它还支持多人写作,就类似于 Worktile、Teambition、Trello,我曾经使用这款软件完成了多个项目的任务分配和多人协作开发,还是非常方便的。

  • 另外它还支持过期智能重新安排任务,比如有一些任务没有完成,它还可以根据优先级来重新进行时间规划和安排,同时也有任务评分和目标评价机制,来反映我们任务完成情况。

另外关于时间管理还有一个非常重要的四象限法则,大家也可以了解一下。有了这个法则,大家可以合理安排优先级,合理分配每个任务的时间。有关于我的时间管理经验我后面还会详细写一篇相关的文章,介绍一下我平时会怎样进行时间规划和学习的。

另外我还尝试过番茄土豆这个软件,这个软件的缺点在于整体的功能还比较简陋,而且不能和我已有的 Todo List 进行同步。好处就是可以自己设置番茄,保持专注工作。但目前我尚未发现满意的产品,如有还希望大家可以留言推荐一下,谢谢。

笔记记录

在学习的时候来进行记录是非常非常重要的,强烈建议一边学习一边把所做所想记录下来,最后做一下整理成文。一方面方便查阅,另一方面加深印象和理解。

Markdown 想必大家都已经很耳熟了,现在我写文章或者笔记几乎全都用 Markdown 来写,现在很多云笔记也慢慢逐步支持 Markdown 的语法了,我的博客后台也自己配置了 Markdown 的支持。不过也有某些平台尚未支持 Markdown,比如知乎,忍不住吐槽一句,知乎的编辑器实在用得是心累,当然可以使用插件来解决,也当然也有所好,我就不再说什么了。不过我还是强烈推荐 Markdown 来进行写作和记录的,用过之后你可能就不再想用 Word 了。

言归正传,既然谈到笔记和写作。我的笔记本是 Mac,之前几乎所有的笔记,包括写书,都几乎是在 Mac 上完成的,但是确实有的时候是不方便的。比如 Mac 不在身边或者想用 iPhone 或者 iPad 来写点东西的时候,一个需要解决的问题就是云同步问题。有了云同步,我们如果在电脑上写了一部分内容,接着切换了另一台台式机,或者切换了手机的时候,照样能够接着在原来的基础上写,非常方便。

这时候可能就有小伙伴推荐有道云笔记、印象笔记等软件,它们支持 Markdown,但这并不是它们的主打支持方向,对 Makrdown 的支持当然没有一些专业的 Makrdown 编辑工具专业。对我个人而言,我不想因为它们自带了云同步而抛弃了纯粹的 Makrdown 写作环境,我只想要一个纯粹的 Makrdown 写作环境,而不想引入比如有道云里面的普通笔记、签到等冗余的功能,也不想看到里面的广告推荐等内容。所以对于云同步,我使用了另外的解决方案。对于写作软件,我也摸索出了自己的一套方案。

对于电脑端的 Markdown 写作软件,推荐两款,一款是 Typora,另一款是 MarkEditor。

对于 Makrdown 编辑器来说,我觉得有几个比较重要的点:

  • 不能纯写 Makrdown,要实时地能够看到自己写完 Makrdown 之后最终呈现的效果是怎样的。

  • 插入图片要方便,很多编辑器需要先将图片挪到某个文件夹下或者上传图片才可以插入图片,这是很不友好的,如果能够直接通过复制粘贴的形式插入图片,甚至能够自动将图片上传到云端,那就再好不过了。

  • 能够打开一整个 Makrdown 文件夹,左侧显示文件列表,右侧进行写作编辑,不能仅仅支持一个 Markdown 文件的编辑。

  • 如果需要用到公司,那么编辑器需要对 Markdown 公式支持比较好。

以上介绍的这两款软件都可以做到。

  • Typora 是免费的,更加轻量级,而且支持即写即得,界面支持和公式支持都比较好,图片的话可以结合 iPic 软件直接上传到图床,同时也可以直接将复制的图片直接粘贴到编辑器中,非常友好,目前我正在使用。

  • MarkEditor 是收费的,功能更为丰富,支持左右分栏模式、阅读模式等,它也支持直接复制和粘贴图片,另外还有强大的导出功能,还可以直接将文件发布为一个网站等等,也十分推荐。

不过目前由于 Typora 更轻量级,并且能和 iPic 而且功能配合使用,粘贴后的图片可以点击直接上传到云端,非常方便,我目前已经由 MarkEditor 切换到 Typora 了。

写作界面如下:

如图这是打开了一个文件夹,这个文件夹里面有好多 Makrdown 文件,都是我在研究和学习过程中所写的笔记。

然后需要解决的就是云同步问题,云同步其实使用网盘就足够了。由于我使用 Mac,所以我选用了 iCloud,开了 200G 的空间,足够了。这样我所记录的内容能够秒级同步到 iCloud Drive 中,这样我再使用 iPhone、iPad 就可以直接看到最新的内容了。当然还有一些推荐的,比如 OneDrive、谷歌云等多种云盘同步工具,哪个方便用哪个。Mac 和 iPhone 的好处就是已经内置了 iCloud Drive,所以不用再去在各个终端上配置了。

接下来就是在其他的电脑以及 iPhone、iPad 上进行写作的解决方案了。由于我的文件都已经存放在了 iCloud Drive,所以就需要一款 Makrdown 编辑软件可以直接读写 iCloud Drive 里面的内容,同时界面还要友好,功能完善一点。在这里我最终选择了 Markdown Pro,它的功能简洁但是又比较完善,打开之后直接选取 iCloud Drive 里面的 Makrdown 文件即可开始编辑,并且它是左右分栏的,即左侧编辑,右侧预览,非常方便简洁,另外它对公式的支持也很好,下图是我在 iPad 上对本文进行编辑的效果预览图。

对于图片的插入,在 iPhone 和 iPad 上我借助了另外一个工具,叫做 SM.MS,这个软件可以直接选取图片,然后上传到云端,点击复制即可得到链接和 Makrdown 图片链接,所以插图也不是什么问题了。

如图所示,上传照片之后,便会出现各种各样的图片链接形式,有纯链接、HTML、Markdown 等等,直接点击复制按钮即可复制,然后粘贴到文档中。

另外如果你用了 Windows 的话,只要下载一个 iCloud 云盘软件即可同步。如果使用的是其他的云盘软件,也只需要配置一下就好了。

有了这套,我们就可以实现随时随地写笔记,Mac、iPhone、iPad 无缝切换。

思维导图

很多时候我们在构思方案或者流程的时候需要对思维做梳理,或者在列方案呈现的时候也需要分门别类地进行呈现。这时候大多数情况下就需要用到一个工具,思维导图。

思维导图工具我个人使用的是 MindNode,在 Mac 上用它可以通过各种快捷键快速的增删思维导图节点,另外界面也非常绚丽多彩。

对于思维导图软件来说,我也希望能全平台同步,其实 MindNode 也有对应的移动端软件,同样是 MindNode,二者可以通过 iCloud Drive 进行同步,同样可以做到无缝衔接。

另外还有很多朋友也在用 XMind,功能同样很强大,大家也可以试试。

远程控制

我们经常会和各种服务器打交道,例如我们经常使用 SSH 来远程连接某台 Linux 服务器,原生 Terminal 是支持 SSH 的,但你会发现原生带的这个太难用了。可能很多小伙伴使用 iTerm,不得不说这确实是个神器,大大方便了远程管理流程。但我在这里还要推荐一个我经常使用的 SSH Shell,没错,它的名字就是 SSH Shell,它的页面操作简洁,同时管理和记录远程主机十分方便,另外还支持秘钥管理、自动重连、自定义主题等等功能,个人用起来十分顺手,强烈推荐!

当然除了电脑,当我们出去在外的时候,紧急情况也可能需要使用 SSH 来连接和管理我们的服务器,所以我也在 iPhone 和 iPad 上装了远程管理软件,叫做 Termius,同样功能十分强大,快捷操作十分便捷,有免费的试用期限,我觉得非常好用就订购了,推荐给大家。

代码记录

作为一名程序员,我们会经常写或者使用一些关键代码。

比如有一天我写了一些方法,这些方法可以完成非常重要的功能,后面的项目也会经常遇到,那么怎么办呢?很多情况下我们想把它保存起来,放到某个收藏夹里面备用,等到用的时候重新把它复制出来。或者有一些繁琐的命令,我实在是记不住,或许我们也想把它记录下来。

很多情况下,我们可能简单地使用文本文件,但并不方便同步和查找。或者云笔记保存下来,但这些并不是专门用来保存代码的。更高级一点,我们会联想到使用 GitHub Gists,但每次记录的这个流程也比较麻烦。

这里推荐一个专门用来记录代码片段的软件,叫做 SnippetsLab,适用于 Mac 系统,可以专门用来管理代码片段,还支持多种代码格式。比如我就将代码按照编程语言划分,划分为 Python、JavaScript 等等,分文件夹存储,有不错的代码就随手贴过来,另外它也支持搜索,管理代码非常方便。如果某一天想查某个代码了,直接打开它一搜就有了,可以大大提高开发效率。

以上就暂且总结这么多,其实还有不少好用的用具,后面再一一为大家总结分享。

另外再问下大家,你们买 iPad 了吗?是不是觉得比较鸡肋,或者平时都用不上,那这样就没有发挥 iPad 的最大效用,如果利用好了,它可以进一步方便我们的生活,后面我也会专门写一下 iPad 方面的一些用途。

由于水平和见识有限,如果大家有更好的软件或者方案推荐,欢迎大家留言!也希望我的一些方案对大家有所帮助,谢谢!

Python

爬虫是做什么的?是帮助我们来快速获取有效信息的。然而做过爬虫的人都知道,解析是个麻烦事。 比如一篇新闻吧,链接是这个:https://news.ifeng.com/c/7kQcQG2peWU,页面预览图如下: 预览图 我们需要从页面中提取出标题、发布人、发布时间、发布内容、图片等内容。一般情况下我们需要怎么办?写规则。 那么规则都有什么呢?怼正则,怼 CSS 选择器,怼 XPath。我们需要对标题、发布时间、来源等内容做规则匹配,更有甚者再需要正则表达式来辅助一下。我们可能就需要用 re、BeautifulSoup、pyquery 等库来实现内容的提取和解析。 但如果我们有成千上万个不同样式的页面怎么办呢?它们来自成千上万个站点,难道我们还需要对他们一一写规则来匹配吗?这得要多大的工作量啊。另外这些万一弄不好还会解析有问题。比如正则表达式在某些情况下匹配不了了,CSS、XPath 选择器选错位了也会出现问题。 想必大家可能见过现在的浏览器有阅读模式,比如我们把这个页面用 Safari 浏览器打开,然后开启阅读模式,看看什么效果: Safari预览 页面一下子变得非常清爽,只保留了标题和需要读的内容。原先页面多余的导航栏、侧栏、评论等等的统统都被去除了。它怎么做到的?难道是有人在里面写好规则了?那当然不可能的事。其实,这里面就用到了智能化解析了。 那么本篇文章,我们就来了解一下页面的智能化解析的相关知识。

智能化解析

所谓爬虫的智能化解析,顾名思义就是不再需要我们针对某一些页面来专门写提取规则了,我们可以利用一些算法来计算出来页面特定元素的位置和提取路径。比如一个页面中的一篇文章,我们可以通过算法计算出来,它的标题应该是什么,正文应该是哪部分区域,发布时间是什么等等。 其实智能化解析是非常难的一项任务,比如说你给人看一个网页的一篇文章,人可以迅速找到这篇文章的标题是什么,发布时间是什么,正文是哪一块,或者哪一块是广告位,哪一块是导航栏。但给机器来识别的话,它面临的是什么?仅仅是一系列的 HTML 代码而已。那究竟机器是怎么做到智能化提取的呢?其实这里面融合了多方面的信息。

  • 比如标题。一般它的字号是比较大的,而且长度不长,位置一般都在页面上方,而且大部分情况下它应该和 title 标签里的内容是一致的。
  • 比如正文。它的内容一般是最多的,而且会包含多个段落 p 或者图片 img 标签,另外它的宽度一般可能会占用到页面的三分之二区域,并且密度(字数除以标签数量)会比较大。
  • 比如时间。不同语言的页面可能不同,但时间的格式是有限的,如 2019-02-20 或者 2019/02/20 等等,也有的可能是美式的记法,顺序不同,这些也有特定的模式可以识别。
  • 比如广告。它的标签一般可能会带有 ads 这样的字样,另外大多数可能会处于文章底部、页面侧栏,并可能包含一些特定的外链内容。

另外还有一些特点就不再一一赘述了,这其中包含了区块位置、区块大小、区块标签、区块内容、区块疏密度等等多种特征,另外很多情况下还需要借助于视觉的特征,所以说这里面其实结合了算法计算、视觉处理、自然语言处理等各个方面的内容。如果能把这些特征综合运用起来,再经过大量的数据训练,是可以得到一个非常不错的效果的。

业界进展

未来的话,页面也会越来越多,页面的渲染方式也会发生很大的变化,爬虫也会越来越难做,智能化爬虫也将会变得越来越重要。 目前工业界,其实已经有落地的算法应用了。经过我的一番调研,目前发现有这么几种算法或者服务对页面的智能化解析做的比较好:

  • Diffbot,国外的一家专门来做智能化解析服务的公司,https://www.diffbot.com
  • Boilerpipe,Java 语言编写的一个页面解析算法,https://github.com/kohlschutter/boilerpipe
  • Embedly,提供页面解析服务的公司,https://embed.ly/extract
  • Readability,是一个页面解析算法,但现在官方的服务已经关闭了,https://www.readability.com/
  • Mercury,Readability 的替代品,https://mercury.postlight.com/
  • Goose,Java 语音编写的页面解析算法,https://github.com/GravityLabs/goose

那么这几种算法或者服务到底哪些好呢,Driffbot 官方曾做过一个对比评测,使用 Google 新闻的一些文章,使用不同的算法依次摘出其中的标题和文本,然后与真实标注的内容进行比较,比较的指标就是文字的准确率和召回率,以及根据二者计算出的 F1 分数。 其结果对比如下:

Service/Software

Precision

Recall

F1-Score

Diffbot

0.968

0.978

0.971

Boilerpipe

0.893

0.924

0.893

Readability

0.819

0.911

0.854

AlchemyAPI

0.876

0.892

0.850

Embedly

0.786

0.880

0.822

Goose

0.498

0.815

0.608

经过对比我们可以发现,Diffbot 的准确率和召回率都独占鳌头,其中的 F1 值达到了 0.97,可以说准确率非常高了。另外接下来比较厉害的就是 Boilerpipe 和 Readability,Goose 的表现则非常差,F1 跟其他的算法差了一大截。下面是几个算法的 F1 分数对比情况: F1分数对比 有人可能好奇为什么 Diffbot 这么厉害?我也查询了一番。Diffbot 自 2010 年以来就致力于提取 Web 页面数据,并提供许多 API 来自动解析各种页面。其中他们的算法依赖于自然语言技术、机器学习、计算机视觉、标记检查等多种算法,并且所有的页面都会考虑到当前页面的样式以及可视化布局,另外还会分析其中包含的图像内容、CSS 甚至 Ajax 请求。另外在计算一个区块的置信度时还考虑到了和其他区块的关联关系,基于周围的标记来计算每个区块的置信度。 总之,Diffbot 也是一直致力于这一方面的服务,整个 Diffbot 就是页面解析起家的,现在也一直专注于页面解析服务,准确率高也就不足为怪了。 但它们的算法开源了吗?很遗憾,并没有,而且我也没有找到相关的论文介绍它们自己的具体算法。 所以,如果想实现这么好的效果,那就使用它们家的服务就好了。 接下来的内容,我们就来说说如何使用 Diffbot 来进行页面的智能解析。另外还有 Readability 算法也非常值得研究,我会写专门的文章来介绍 Readability 及其与 Python 的对接使用。

Diffbot 页面解析

首先我们需要注册一个账号,它有 15 天的免费试用,注册之后会获得一个 Developer Token,这就是使用 Diffbot 接口服务的凭证。 接下来切换到它的测试页面中,链接为:https://www.diffbot.com/dev/home/,我们来测试一下它的解析效果到底是怎样的。 这里我们选择的测试页面就是上文所述的页面,链接为:https://news.ifeng.com/c/7kQcQG2peWU,API 类型选择 Article API,然后点击 Test Drive 按钮,接下来它就会出现当前页面的解析结果: 结果 这时候我们可以看到,它帮我们提取出来了标题、发布时间、发布机构、发布机构链接、正文内容等等各种结果。而且目前来看都十分正确,时间也自动识别之后做了转码,是一个标准的时间格式。 接下来我们继续下滑,查看还有什么其他的字段,这里我们还可以看到有 html 字段,它和 text 不同的是,它包含了文章内容的真实 HTML 代码,因此图片也会包含在里面,如图所示: 结果 另外最后面还有 images 字段,他以列表形式返回了文章套图及每一张图的链接,另外还有文章的站点名称、页面所用语言等等结果,如图所示: 结果 当然我们也可以选择 JSON 格式的返回结果,其内容会更加丰富,例如图片还返回了其宽度、高度、图片描述等等内容,另外还有各种其他的结果如面包屑导航等等结果,如图所示: 结果 经过手工核对,发现其返回的结果都是完全正确的,准确率相当之高! 所以说,如果你对准确率要求没有那么非常非常严苛的情况下,使用 Diffbot 的服务可以帮助我们快速地提取页面中所需的结果,省去了我们绝大多数的手工劳动,可以说是非常赞了。 但是,我们也不能总在网页上这么试吧。其实 Diffbot 也提供了官方的 API 文档,让我们来一探究竟。

Diffbot API

Driffbot 提供了多种 API,如 Analyze API、Article API、Disscussion API 等。 下面我们以 Article API 为例来说明一下它的用法,其官方文档地址为:https://www.diffbot.com/dev/docs/article/,API 调用地址为:

1
https://api.diffbot.com/v3/article

我们可以用 GET 方式来进行请求,其中的 Token 和 URL 都可以以参数形式传递给这个 API,其必备的参数有:

  • token:即 Developer Token
  • url:即要解析的 URL 链接

另外它还有几个可选参数:

  • fields:用来指定返回哪些字段,默认已经有了一些固定字段,这个参数可以指定还可以额外返回哪些可选字段
  • paging:如果是多页文章的话,如果将这个参数设置为 false 则可以禁止多页内容拼接
  • maxTags:可以设置返回的 Tag 最大数量,默认是 10 个
  • tagConfidence:设置置信度的阈值,超过这个值的 Tag 才会被返回,默认是 0.5
  • discussion:如果将这个参数设置为 false,那么就不会解析评论内容
  • timeout:在解析的时候等待的最长时间,默认是 30 秒
  • callback:为 JSONP 类型的请求而设计的回调

这里大家可能关注的就是 fields 字段了,在这里我专门做了一下梳理,首先是一些固定字段:

  • type:文本的类型,这里就是 article 了
  • title:文章的标题
  • text:文章的纯文本内容,如果是分段内容,那么其中会以换行符来分隔
  • html:提取结果的 HTML 内容
  • date:文章的发布时间,其格式为 RFC 1123
  • estimatedDate:如果日期时间不太明确,会返回一个预估的时间,如果文章超过两天或者没有发布日期,那么这个字段就不会返回
  • author:作者
  • authorUrl:作者的链接
  • discussion:评论内容,和 Disscussion API 返回结果一样
  • humanLanguage:语言类型,如英文还是中文等
  • numPages:如果文章是多页的,这个参数会控制最大的翻页拼接数目
  • nextPages:如果文章是多页的,这个参数可以指定文章后续链接
  • siteName:站点名称
  • publisherRegion:文章发布地区
  • publisherCountry:文章发布国家
  • pageUrl:文章链接
  • resolvedPageUrl:如果文章是从 pageUrl 重定向过来的,则返回此内容
  • tags:文章的标签或者文章包含的实体,根据自然语言处理技术和 DBpedia 计算生成,是一个列表,里面又包含了子字段:
    • label:标签名
    • count:标签出现的次数
    • score:标签置信度
    • rdfTypes:如果实体可以由多个资源表示,那么则返回相关的 URL
    • type:类型
    • uri:Diffbot Knowledge Graph 中的实体链接
  • images:文章中包含的图片
  • videos:文章中包含的视频
  • breadcrumb:面包屑导航信息
  • diffbotUri:Diffbot 内部的 URL 链接

以上的预定字段就是如果可以返回那就会返回的字段,是不能定制化配置的,另外我们还可以通过 fields 参数来指定扩展如下可选字段:

  • quotes:引用信息
  • sentiment:文章的情感值,-1 到 1 之间
  • links:所有超链接的顶级链接
  • querystring:请求的参数列表

好,以上便是这个 API 的用法,大家可以申请之后使用这个 API 来做智能化解析了。 下面我们用一个实例来看一下这个 API 的用法,代码如下:

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

url = 'https://api.diffbot.com/v3/article'
params = {
'token': '77b41f6fbb24496d5113d528306528fa',
'url': 'https://news.ifeng.com/c/7kQcQG2peWU',
'fields': 'meta'
}
response = requests.get(url, params=params)
print(json.dumps(response.json(), indent=2, ensure_ascii=False))

这里首先定义了 API 的链接,然后指定了 params 参数,即 GET 请求参数。 参数中包含了必选的 token、url 字段,也设置了可选的 fields 字段,其中 fields 为可选的扩展字段 meta 标签。 我们来看下运行结果,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
{
"request": {
"pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU",
"api": "article",
"fields": "sentiment, meta",
"version": 3
},
"objects": [
{
"date": "Wed, 20 Feb 2019 02:26:00 GMT",
"images": [
{
"naturalHeight": 460,
"width": 640,
"diffbotUri": "image|3|-1139316034",
"url": "http://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png",
"naturalWidth": 690,
"primary": true,
"height": 426
},
// ...
],
"author": "中国新闻网",
"estimatedDate": "Wed, 20 Feb 2019 06:47:52 GMT",
"diffbotUri": "article|3|1591137208",
"siteName": "ifeng.com",
"type": "article",
"title": "故宫,你低调点!故宫:不,实力已不允许我继续低调",
"breadcrumb": [
{
"link": "https://news.ifeng.com/",
"name": "资讯"
},
{
"link": "https://news.ifeng.com/shanklist/3-35197-/",
"name": "大陆"
}
],
"humanLanguage": "zh",
"meta": {
"og": {
"og:time ": "2019-02-20 02:26:00",
"og:image": "https://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png",
"og:category ": "凤凰资讯",
"og: webtype": "news",
"og:title": "故宫,你低调点!故宫:不,实力已不允许我继续低调",
"og:url": "https://news.ifeng.com/c/7kQcQG2peWU",
"og:description": "  “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”   “重"
},
"referrer": "always",
"description": "  “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”   “重",
"keywords": "故宫 紫禁城 故宫博物院 灯光 元宵节 博物馆 一票难求 元之 中新社 午门 杜洋 藏品 文化 皇帝 清明上河图 元宵 千里江山图卷 中英北京条约 中法北京条约 天津条约",
"title": "故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰资讯"
},
"authorUrl": "https://feng.ifeng.com/author/308904",
"pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU",
"html": "<p>“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。...</blockquote> </blockquote>",
"text": "“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”\\n“...",
"authors": [
{
"name": "中国新闻网",
"link": "https://feng.ifeng.com/author/308904"
}
]
}
]
}

可见其返回了如上的内容,是一个完整的 JSON 格式,其中包含了标题、正文、发布时间等等各种内容。 可见,不需要我们配置任何提取规则,我们就可以完成页面的分析和抓取,得来全不费功夫。

Diffbot SDK

另外 Diffbot 还提供了几乎所有语言的 SDK 支持,我们也可以使用 SDK 来实现如上功能,链接为:https://www.diffbot.com/dev/docs/libraries/,如果大家使用 Python 的话,可以直接使用 Python 的 SDK 即可,Python 的 SDK 链接为:https://github.com/diffbot/diffbot-python-client。 这个库并没有发布到 PyPi,需要自己下载并导入使用,另外这个库是使用 Python 2 写的,其实本质上就是调用了 requests 库,如果大家感兴趣的话可以看一下。 下面是一个调用示例:

1
2
3
4
5
6
7
from client import DiffbotClient,DiffbotCrawl

diffbot = DiffbotClient()
token = 'your_token'
url = 'http://shichuan.github.io/javascript-patterns/'
api = 'article'
response = diffbot.request(url, token, api)

通过这行代码我们就可以通过调用 Article API 来分析我们想要的 URL 链接了,返回结果是类似的。 具体的用法大家直接看下它的源码注释就一目了然了,还是很清楚的。 好,以上便是对智能化提取页面原理的基本介绍以及对 Diffbot 的用法的讲解,后面我会继续介绍其他的智能化解析方法以及一些相关实战,希望大家可以多多关注。

Python

大家好,我是四毛,欢迎关注我的公众号。

有什么想要交流的可以在后台第一时间私我。

今天的文章内容主要是关于字体反爬。

目前已知的几个字体反爬的网站是猫眼,汽车之家,天眼查,起点中文网等等。 以前也看过这方面的文章,今天跟个老哥在交流的时候,终于实操了一把,弄懂了字体反爬是个啥玩意。下面听我慢慢道来。

本文用到的第三方库

fontTools

1、目标网站

url = “https://su.58.com/qztech/

2、反爬虫机制

网页上看见的 后台源代码里面的 从上面可以看出,生这个字变成了乱码,请大家特别注意箭头所指的数字。

3、解决

1、确定反爬方法

在看了别人的解析文章之后,确定采取的是字体反爬机制,即网站定义了字体文件,然后进行相应的查找替换,在前端看起来,是没有任何差异的。其实从审查元素的也是可以看到的: 和大众点评的反爬差不多,都是通过css搞得。

2、寻找字体文件

以上面方框里的”customfont“为关键词搜了一下,发现就在源代码里面: 而且还有base64,直接进行解密,但是解密出来的其实是乱码,这个时候其实要做的很简单,把解密后的内容保存为.ttf格式即可。

ttf文件.ttf是字体文件格式。TTF(TrueTypeFont)是Apple公司和Microsoft公司共同推出的字体文件格式,随着windows的流行,已经变成最常用的一种字体文件表示方式。 *@font-face 是CSS3中的一个模块,主要是实现将自定义的Web字体嵌入到指定网页中去。

因为我们要对字体进行研究,所以必须将它打开,这里我是用的是FontCreator,打开以后是这个样子(其实很多字,在这里为了看的清楚,所以只截了下面的图): 很明显,每个字可以看到字形和字形编码。 观察现在箭头指的地方和前面箭头指的地方的数字是不是一样啊,没错,就是通过这种方法进行映射的。 所以我们现在的思路似乎就是在源代码里找到箭头指的数字,然后再来字体里找到后替换就行了。 恭喜你,如果你也是这么想的,那你就掉坑里了。 因为每次访问,字体字形是不变的,但字符的编码确是变化的。因此,我们需要根据每次访问,动态解析字体文件。 字体1: 字体2: 所以想通过写死的方式也是行不通的。 这个时候我们就要对字体文件进行更深一步的研究了。 3、研究字体文件 刚刚的.ttf文件我们是看不到内部的东西的,所以这个时候我们要对字体文件进行转换格式,将其转换为xml格式,然后来查看: 具体操作如下:

1
2
3
from fontTools.ttLib import TTFont
font_1 = TTFont('58_font_1.ttf')
font_base.saveXML('font_1.xml')

xml的格式如下: 文件很长,我只截取了一部分。 仔细的观察一下,你会发现~这俩下面的x,y,on值都是一毛一样的。所以我们的思路就是以一个已知的字体文件为基本,然后将获取到的新的字体文件的每个文字对应的x,y,on值进行比较,如果相同,那么说明新的文字对就 可以在基础字体那里找到对应的文字,有点绕,下面举个小例子。 假设: “我” 在基本字体中的名为uni1,对应的x=1,y=1,n=1新的字体文件中,一个名为uni2对应的x,y, n分别于上面的相等,那么这个时候就可以确定uni2 对应的文字为”我”。 查资料的时候,发现在特殊情况下,有时候两个字体中的文字对应的x,y不相等,但是差距都是在某一个阈值之内,处理方法差不多,只不过上面是相等,这种情况下就是要比较一下。 其实,如果你用画图工具按照上面的x与y值把点给连起来,你会发现,就是汉字的字形~ 所以,到此总结一下:

一、将某次请求获取到的字体文件保存到本地[基本字体]; 二、用软件打开后,人工的找出每一个数字对应的编码[ 一定要保证顺序的正确,要不然会出事]; 三、我们以后访问网页时,需要保存新字体文件; 四、用Fonttools库对基本字体与新字体进行处理,找 到新的字体与基本字体之间的映射; 五、替换;

4、上代码

微信里上代码真的太丑了, 还是算了吧,微信后台关键词“字体加密” 即可获取github地址。 看一下成果

总结

其实这个流程最大的问题就是我们人工录入的基本字体的字典数据有可能是会发生变化的,这就导致我们后面还要手动去改。 现在,如果你已经看懂了本文,那么还不快去其他几个网站试试? 如果有任何问题,欢迎交流。

Python

大家好,我是四毛,欢迎大家关注我的公众号。

今天在工作中,碰到了第一次碰见的反爬虫机制,感觉很有意思,在这里记录一下,希望对大家有帮助。

今天用到的库

requests [请求] lzstring [解压数据] pyexecjs [执行JS]

简单粗暴,直接上网站部分源代码,因为这个网站应该不太希望别人来爬,所以就不上网站了。为什么这么说,因为刚开始请求的时候,老是给我返回GO TO HELL ,哈哈。 这个网站点击鼠标右键审查元素,查看网页源代码是无法用的,但是这个好像只能防住小白啊,简单的按F12审查元素,CTRL+u 直接查看源代码(谷歌浏览器)。 这次的目的主要是为了获取下面的链接(重度打码)

xxxxxxxxxxx/xxxxx-003-a5f7xxxxxx?cid=xxxxx&xxx=siOE_q4XxBtwdoXqD0xxxx

其中,红色加粗的就是我们要找的变量了。

一、观察与抓包

首先,我观察到了网页源代码中的一部分js代码:

type=”text/javascript”>window“\x65\x76\x61\x6c”{e=function(c){return(c35?String.fromCharCode(c+29):c.toString(36))};if(!’’.replace(/^/,String)){while(c—)d[e(c)]=k[c]||e(c);k=[function(e){return d[e]}];e=function(){return’\\w+’};c=1;};while(c—)if(k[c])p=p.replace(new RegExp(‘\\b’+e(c)+’\\b’,’g’),k[c]);return p;}(‘29.2a({“2c”:1k,”26”:”21”,”22”:”1k.24”,”2o”:2n,”2s”:”2r”,”2l”:[“0-2f-2e.3.2”,”0-2j-2i.3.2”,”0-1r-1L.3.2”,”0-1r-1K.3.2”,”0-1e-1X.3.2”,”0-1e-1Q.3.2”,”0-1x-1W.3.2”,”0-1x-1M.3.2”,”0-1B-33.3.2”,”0-1B-36.3.2”,”0-1F-30.3.2”,”0-1F-2U.3.2”,”0-1g-2Y.3.2”,”0-1g-3e.3.2”,”0-1q-3i.3.2”,”0-1q-38.3.2”,”0-1o-37.3.2”,”0-1o-3a.3.2”,”0-1s-2B.3.2”,”0-1s-2E.3.2”,”0-1i-2v.3.2”,”0-1i-2y.3.2”,”0-1G-2P.3.2”,”0-1G-2S.3.2”,”0-1E-2R.3.2”,”0-1E-2H.3.2”,”0-1I-2L.3.2”,”0-1I-2J.3.2”,”0-t-2K.3.2”,”0-t-2G.3.2”,”0-p-2I.3.2”,”0-p-2M.3.2”,”0-o-2Q.3.2”,”0-o-2N.3.2”,”0-u-2O.3.2”,”0-u-2w.3.2”,”0-D-2x.3.2”,”0-D-2t.3.2”,”0-z-2u.3.2”,”0-z-2z.3.2”,”0-y-2D.3.2”,”0-y-2F.3.2”,”0-7-2A.3.2”,”0-7-2C.3.2”,”0-5-2T.3.2”,”0-5-3b.3.2”,”0-4-3c.3.2”,”0-4-39.3.2”,”0-g-3d.3.2”,”0-g-3h.3.2”,”0-i-3j.3.2”,”0-i-3f.3.2”,”0-b-3g.3.2”,”0-b-2X.3.2”,”0-d-2Z.3.2”,”0-d-2V.3.2”,”0-11-2W.3.2”,”0-11-34.3.2”,”0-13-35.3.2”,”0-13-31.3.2”,”0-10-32.3.2”,”0-10-1J.3.2”,”0-15-1O.3.2”,”0-15-1P.3.2”,”0-1d-1R.3.2”,……………………………………,’M4UxFsAsB9odxAIwA7WQOwObQAwCYBWXPAFlwHYBmY6ncousuvYgDlwDZ38BOT8zn3wcKARmICKOXK1qsmrFjlHdRAnKxEb1rBqK2ih5dSSbkteJXnEbpGm+SF47HLZSEElBOwRsEmlOqU3AS0JHYkWiRKJAwkYTYkQiQmIQwcShy0HDaUSpQuDJQ2HAG0BEFFWgTVQhw+3JRMOEw8Shq4PK……………………………………..GHXTzLjxjzN5CziB+3ODSAhicwuCyeBJ3YJD+eKhEi83fHm76PmK3Y/htPJFzw8LzOCqKTMcXGHr7Jm/nlPtuSUfPBAA===’‘\x73\x70\x6c\x69\x63’,0,{})) </script>

为了节省篇幅,我把一些替换成…….了。如果你对这些数据的解压有兴趣,请后台联系我。 我第一眼看见时,做了2件事: 1、把[\x65\x76\x61\x6c],[ \x73\x70\x6c\x69\x63],[\x7c]分别解码,解码出的结果为eval和splic,| ; 注:其实这里看到eval时就应该想到可以试试直接用pyexecjs执行后面的那段js匿名函数,但是当时确实啥都没有想起来,很惭愧,后来复盘时才发现这一点。虽然执行了也会报错。 2、把那一长串的字母试着用base64解码了,但是解不出来; 然后,就只能再去其他js文件里找了,也找到了js代码,打了断点,但是看着还是很烦,于是这个时候我就没有在js上面死抠。 接着,我调转了方向,在GITHUB,Google运用了我祖传的高级搜索技巧,Finally,终于可以盘它了。

二、解密

下面开始解密: 1、数据解压 包含A===的字符串到底时啥呢,其实这个是js的一种数据的压缩方式,知道了这种方式你就可以立即破解这种反爬虫机制了,反正我以前是不知道的,第一次见到,学习了。 在这里,lzstring闪亮登场,运用这个库,执行下面的代码:

lzstring.LZString().decompressFromBase64(string)

这样就可以把上面的那串字符给解码了,解出来的数据像下面这样:

webp|png|025|024|073

没错,数据解压就是这么的简单; 2、看懂JS 我们在上面解码出来了一个splic和一个[ | ],再看看我们上面解压出来的数据,是不是很有感觉;但是,查了好一会资料,也没有发现js里面有这个方法,可能是在别的地方定义了;在我找到的资料里面,那个作者是直接用了split,最后的结果是对的,走逻辑上也说得过去,不知道为什么这么设置成splic,还希望有大神可以指教; 3、执行JS 所以,到这里就很明显了,把解码以后以及解压以后的数据在替换会原js数据中,我们前面解码出来的eval就有用了,我找到的资料里面作者使用node做的,我没有安装node环境,所以我就直接用了pyexecjs.eval直接执行了,结果也正确; 4、梳理流程: 匹配长字符串==>lzstring解压+解码后的字段==>拼凑成新的js代码==>pyexecjs执行==>出结果 5、部分代码 执行的主要步骤和结果

execjs.eval(res)

没错,就是这么简单,res就是替换后的js,然后就可以直接出来我们上面网址中需要的两个字段了。 截个图吧

三、结语

到这里,今天的文章就结束了,总结一下就是知道了一种JS数据的压缩方式,并且学习了解压的方式,JS的执行,同时写代码的时候**多观察,多想,多试**。 如果觉得还不错,欢迎动动手指关注我。

Python

大家好,我是四毛,好久没有写东西了,欢迎大家关注我的公众号。

今天的文章是关于如何使用requests来爬取大众点评的数据。 看完本文,你可以:

1、了解大众点评的CSS反爬虫机制 2、破解反爬虫机制 3、使用requests即可正确获取到评论数,平均价格,服务,味道,环境数据,评论文本数据;

如果你想跟我继续交流的话,欢迎加我的个人微信,二维码在最后。 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。 同时,代码我并没有做太多的优化,因为没有大量的代理,爬不了太多的内容。 这里只是跟大家分享一下处理的流程。欢迎来公众号留言探讨。

正文开始。

1.前言

在工作生活中,发现越来越多的人对大众点评的数据感兴趣,而大众点评的反爬又是比较严格的。采取的策略差不多是宁可错杀一万,也不放过一个。有的时候正常浏览都会跳出验证码。 另外,在PC端的展示数据是通过CSS来控制的,从网页上看不出来太大的区别,但是用普通的脚本取获取时,会发现数据是获取不到的,具体的源代码是下面这样的: 然,在搜资料的时候,你会发现,很多教程都是用的selenium之类的方法,效率太低,没有啥技术含量。 所以,这篇文章的面向的对象就是PC端的大众点评;目标是解决这种反爬虫措施,使用requests获取到干净正确的数据; 跟着我,绝不会让你失望。

2.正文开始

相信搞过大众点评网站的同学都应该知道上面的这种是一个css反爬的方法,具体的解析操作,即将开始。

找到藏着秘密的css

当我们的鼠标在上面框框内的span上面点击时,会发现右边部分会相应的发生变化: 这张图片很重要,很重要,很重要,我们要的值,几乎都从这里匹配出来。 这里我们看到了“vxt20”这个变量对应的两个像素值,前面的是控制用哪个数字,后面的是控制用哪一段的数字集合,先记下,后面要用,同时这里的值应该是6; 这里其实就是整个破解流程最关键的一步了。在这里我们看到了一个链接。 瞎猫当死耗子吧,点进去看看。 https://s3plus.meituan.net/v1/mss_0a06a471f9514fc79c981b5466f56b91/svgtextcss/f556c0559161832a4c6192e097db3dc2.svg 你会发现,返回的是一些数字,我一开始是不知道这是啥玩意的,看过一些大神的解析才知道,其实这里就是我们看到的数字的来源,也就是整个破解的源头,知道了这些数字的含义,也就可以直接破解了整个反爬的流程。 现在直接看源代码: 可以看到这里面的几个关键数字:font-size:字体大小;还有几个y的值,我到后面才知道原来这个y是个阈值,起的是个控制的作用。 所以,这一反爬的原理就是:

获取属性值与偏移量和阈值映射,然后从svg文件中找到真数据。

现在我们就要用到上面的像素值了。

1.把所有的值取绝对值; 2.用后面的值来选择用哪个段的数字,这里的值是103,所以使用第三个段的数字集合; 3.因为每个字体是12个像素,所以用163/12=13.58,约等于14,那么我们数一下第14个数字是啥,没错,是6,和预期一样。你可以多试验几次。

以上,就是整个破解的逻辑过程。 画个流程图,装个逼:

3.Show Code

下面开始晒代码,俗话说得好,天下代码一大抄。 这里对主要的步骤代码进行解释, 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。1.获取css_url及span对应的TAG值;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def get_tag(_list, offset=1):
# 从第一个开始查
_new_list = [data[0:offset] for data in _list]

if len(set(_new_list)) == 1:
# 如果set后只有一个值,说明全部重复,这个时候就把offset加1
offset += 1
return get_tag(_list, offset)
else:
_return_data = [data[0:offset - 1] for data in _list][0]

return _return_data

def get_css_and_tag(content):
"""
:param url: 待爬链接
:return: css链接,该span对应的tag
"""
find_css_url = re.search(r'href="([^"]+svgtextcss[^"]+)"', content, re.M)
if not find_css_url:
raise Exception("cannot find css_url ,check")
css_url = find_css_url.group(1)

css_url = "https:" + css_url
# 这个网页上不同的字段是由不同的css段来进行控制的,所以要找到这个评论数据对应的tag,在这里返回的值为vx;而在获取评论数据时,tag就是fu-;
# 具体可以观察上面截图的3个span对应的属性值,相等的最长部分为vx
class_tag = re.findall("<b class=\"(.*?)\"></b>", content)
_tag = get_tag(class_tag)

return css_url, _tag

2.获取属性与像素值的对应关系

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_css_and_px_dict(css_url):
con = requests.get(css_url, headers=headers).content.decode("utf-8")
find_datas = re.findall(r'(\.[a-zA-Z0-9-]+)\{background:(\-\d+\.\d+)px (\-\d+\.\d+)px', con)
css_name_and_px = {}
for data in find_datas:
# 属性对应的值
span_class_attr_name= data[0][1:]
# 偏移量
offset = data[1]
# 阈值
position = data[2]
css_name_and_px[span_class_attr_name] = [offset, position]
return css_name_and_px

3.获取svg文件的url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_svg_threshold_and_int_dict(css_url, _tag):
con = requests.get(css_url, headers=headers).content.decode("utf-8")
index_and_word_dict = {}
# 根据tag值匹配到相应的svg的网址

find_svg_url = re.search(r'span[class\^="%s"].*?background\-image: url\((.*?)\);' % _tag, con)
if not find_svg_url:
raise Exception("cannot find svg file, check")
svg_url = find_svg_url.group(1)
svg_url = "https:" + svg_url
svg_content = requests.get(svg_url, headers=headers).content
root = H.document_fromstring(svg_content)
datas = root.xpath("//text")
# 把阈值和对应的数字集合放入一个字典中
last = 0
for index, data in enumerate(datas):
y = int(data.xpath('@y')[0])
int_data = data.xpath('text()')[0]
index_and_word_dict[int_data] = range(last, y+1)
last = y
return index_and_word_dict

4. 获取最终的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def get_data(url ):
"""
:param page_url: 待获取url
:return:
"""
con = requests.get(url, headers=headers).content.decode("utf-8")
# 获取css url,及tag
css_url, _tag = get_css(con)
# 获取css对应名与像素的映射
css_and_px_dict = get_css_and_px_dict(css_url)
# 获取svg的阈值与数字集合的映射
svg_threshold_and_int_dict = get_svg_threshold_and_int_dict(css_url, _tag)

doc = etree.HTML(con)
shops = doc.xpath('//div[@id="shop-all-list"]/ul/li')
for shop in shops:
# 店名
name = shop.xpath('.//div[@class="tit"]/a')[0].attrib["title"]
print name
comment_num = 0

comment_and_price_datas = shop.xpath('.//div[@class="comment"]')
for comment_and_price_data in comment_and_price_datas:
_comment_data = comment_and_price_data.xpath('a[@class="review-num"]/b/node()')
# 遍历每一个node,这里node的类型不同,分别有etree._ElementStringResult(字符),etree._Element(元素),etree._ElementUnicodeResult(字符)
for _node in _comment_data:
# 如果是字符,则直接取出
if isinstance(_node, etree._ElementStringResult):
comment_num = comment_num * 10 + int(_node)
else:
# 如果是span类型,则要去找数据
# span class的attr
span_class_attr_name = _node.attrib["class"]
# 偏移量,以及所处的段
offset, position = css_and_px_dict[span_class_attr_name]
index = abs(int(float(offset) ))
position = abs(int(float(position)))
# 判断
for key, value in svg_threshold_and_int_dict.iteritems():
if position in value:
threshold = int(math.ceil(index/12))
number = int(key[threshold])
comment_num = comment_num * 10 + number
print comment_num

4.结果展示

评论条数数据

其实,其他的我都写好了,就不贴了

评论具体数据

5.结语

以上就是大众点评Css反爬破解的全部步骤和部分代码。 如果你想获取更多的代码,请关注我的公众号,并发送 “大众点评”即可。

个人日记

最近一段时间没有更新原创文章了,主要是因为最近整个在忙硕士毕业的各种事情,毕业答辩完了以后休假了一小段时间,整个十二月就这么过去了。 转眼已经 2019 年了,其实去年我并没有写年终总结,现在到头来还是蛮后悔的,说实话总结其实还是蛮有必要的,现在就趁着这个时间来对自己的 2018 做一下总结,并立一下 2019 的一些 Flag,再等到 2020 年翻出来打脸吧。 世界上最了解自己的人当然是自己,一句话总结我的 2018 可以说是: 成就不算少,进步不算多 的一年。为什么成就不算少呢?因为今年可能发生的事相比我之前几年,自己取得了一些从未有的成就,或者完成了一些比较重要的事情。下面就先总结一下自己取得的一些成就吧: 1. 2018 年中出版了自己人生中第一本书籍作品《Python3网络爬虫开发实战》,半年多时间现在已经累计印刷九次,共计 45000 册,上市初长期处在京东科技新书榜第一位的位置,豆瓣评分 9.2。 2. 2018 年 1 月 20 日正式开始运营自己的个人公众号“进击的Coder”,发布了两百多篇文章,其中大约一半为原创,粉丝现在是 25000 多人。 3. 发布了自己开发的一款分布式网络爬虫管理框架 Gerapy,可以完成爬虫项目的配置、部署、管理、监控等功能,目前 GitHub 上 Star 数目将近 1000。 4. 在 GitHub 上创建和贡献了近 100 个项目,目前个人 Followers 达到 3000 人,收获 Star 数目在 2000 左右。 5. 开始从事机器学习、深度学习方向的研究和开发,并发布了自己开发的一套深度学习脚手架 ModelZoo,并不断对接各种模型,持续完善中。 6. 比较顺利地完成秋招,并最终拿到了微软的 Offer,达成理想的结果。 7. 完成了多次大型公开演讲并结识了许多优秀的业界大佬。 8. 顺利地完成了毕业论文和毕业答辩,成功取得北航硕士学位。 9. 凭借自己赚得人生第二桶金,实现收入翻番。 10. Last but not least,她。 当然还有一些其他的就不再一一列举了。这些可能有的跟一些大佬比起来可能真的是小巫见大巫,不过对我来说,这些成就我总体还是比较满意的。 在这些成就里,有些是真的改变我人生轨迹的事情,有些可能只是我人生路顺理成章踏过去的事情,有的可能只是昙花一现几年后毫无价值的东西。但拿掉其中一个,不管是看似有用或者没用的,也可能会产生意想不到的影响,他们确实是我真实经历过,并出现在我生命旅途中的一部分。 本来把成就一个个地展开来说,但后来想想没这个必要。昨日取得的成就就让它过去吧,不要念过去,把更多的精力着眼在未来吧。重要的在于,我在这一年里收获了多少,这些收获的东西能够为我未来的成长带来多少价值。以及另外重要的是,对过去的反思,我还有哪些做的不好的地方,我需要在未来做怎样的调整。 所以为什么说进步不算多呢?虽然自己达成了某些目标,但我真的没怎么感觉到自己相比去年年初的自己有特别大的成长,或许一切都是潜移默化的,或许真的把我现在放回到 2018年初,和现在的自己相比,我才会感觉到差别,但现在自己仿佛真的感觉自己本身和之前没有太多不同。但总的来说,我还有非常多需要反思和改进的地方。 下面再好好反思下自己吧,我总觉得自己的这一年,自己的有些方面做得不够好,总体体现在这么几个方面吧: 1. 拖延症。还是觉得自己的拖延症太严重,执行力还不够好。有些事总是会拖个好几天才完成,没太有恒心。或者有的时候这件事到了要做的时间,却又不想做。之前也会有,但是感觉今年犯的很严重。其实我挺佩服那些持之以恒的人的,自制力和执行力真的很重要。 2. 记录少。挺多自己觉得会的东西,由于懒或者忙,没有记下来,其实还是因为懒。到头来发现真正学会的其实仅仅就是自己记录的部分,因为当时没做记录的,又忘得一干二净了。所以我总体感觉,今年输出的文章并不多。 3. 白忙活。这一年来,其实回想起来,感觉做的无用功是很多了。许多事情感觉做完了之后,真正的价值并不大,所以这个真的需要我来好好权衡。 4. 虎头蛇尾。由于自己的很多事情安排的满满当当,所以有的项目可能就容易被搁置了,导致有一些项目做得虎头蛇尾,草草收场,想想还是很可惜的,但有时候就是缺乏那重新捡起来的一步动力,这个必须得改。 5. 时间规划。我使用 Todoist 已经好多年了,感谢它帮我完成了大部分的时间规划,但有些时候发现还是有些不够好。我多少还会有一些小事优先,大事拖延的倾向,但总体来说已经把握得还算可以了,还得继续加油。 6. 缺乏锻炼。平时事情安排的挺多,有时候也懒得去健身房锻炼,唯一锻炼的时间就是睡前的几分钟了,但是效果不大。可能偶尔一时兴起会去几次健身房,但是还是难以坚持下来。虽然还没感觉到身体有啥不适,但也得加强锻炼了。 所以总体感觉下来,感觉到自己做的不够好的地方还有很多,或许真的要改正对我来说真的挺难,但我也要尽力去做的更好。 这一年中,领悟或贯彻的一些理念也不少,其实太多太多,在这里也不能一一列举了,这里就把现在想到的一些说一下吧: 1. 做一件事前,想想这件事的意义和成本有多大,别把时间浪费在无意义的事情上面。 2. 记录和总结真的很重要。别以为自己当时记住了,等回过头来,你有时候会发现自己的收获就是你所记录的那一部分,没有记录的你想也想不起来了。 3. 如果自己没本事,认识很多牛人,没什么太大意义,重要的还是自己要牛逼,多投资自己。自己牛逼了,别人就都会看到你了。 4. 成年人、社会人的世界,有太多大家都已经默认的规矩,自己要去懂,没几个人会教你。 5. 钱,不能保证能买到所有东西,但是它的确可以提供便利或者免去不必要的麻烦。它是可以增加幸福感的。 6. 如果想要赚钱,最好不要选和时间或精力成正比的事情,多考虑复利。 7. 如果一件东西,花了钱,买到的又不是达到自己预期的,这是双亏。如果买到的是自己所满意或喜欢的的,这就是值的。 8. 选择比努力很重要,眼界比本事更重要。 9. 所有的一切,如果有能力,就不要将就,可能一时由于自己的能力还无法改变,但一定不要丢了改变下去的动力。 10. 如果一件事是自己不喜欢的但是非要做,那就用最短的时间去完成。 11. 健康第一。 还有很多,就暂且写这些吧。这是我现阶段所领悟到的一些东西,也是我成长路上的一种认知转变过程吧。也可能再过几年我就不会这么想了。不过现阶段我会秉承这些理念继续前行。 最后就立几个 2019 年的 Flag 吧(等明年翻出来打脸: 1. 踏入社会和职场的第一年,好好工作,把握好节奏。 2. 完成《Python3网络爬虫开发实战(第二版)》以及现在初步规划的另外的两部作品(名称尚未完全确定暂不透露。 3. 微信公众号读者粉丝在 2019 年底达到 10 万。 4. 自己的 Gerapy、ModelZoo 两个项目持续运营维护,力求完善。 5. 坚持记录,持续输出(内容规划了挺多),达到自己内心中设定的基本目标。 6. 2019 年年收入比 2018 年继续翻番。 7. 结识更多的人,扩展更广的人脉。 8. Last but not least,和她好好地在一起,过我们的向往的生活,做喜欢的事情。 好啦,感谢大家一直以来的支持!新的一年,继续努力!愿我们都成为更好的自己!

Linux

Hello 各位小伙伴 雷门吼!

在教程之前首先申明!此教程适合土豪不缺钱的玩家

潜水了许久了,今天来更新点东西~ 今天说点啥呢? 那就是代理!! 代理在爬虫界的重要作用相信各位应该清楚吧!毕竟绝大部分反爬可以靠代理解决;不能被代理解决的也得要代理配合解决。 市面上各种代理也是琳琅满目的说··· 相信大家最喜欢用的之一应该就是 某布云。 根据官网的显示他他家的代理是这个样子的:

  • 无须切换 IP,每一个请求一个随机 IP。

哇!感觉很爽的样子今天我们就来实现一个类似的代理! 其实 So Easy! 我们需要借助 公有云 来实现。 下面我以 AWS 举例(其它公有云操作类似,唯一的区别的就是:各个服务的名字不同而已)

  1. 首先我们需要需要使用EC2来建立一个代理(Google Could 叫 GCE)
    1. 安装Squid(当然你可以使用其它的代理)
      1. 无认证安装参考这儿(点我)设置代理服务器那一段
      2. MySQL认证安装(点我)
      3. Note: 请注意检查!!!务必设置Ipv4转发
  2. 安装完成之后我们制作启动模板(毕竟一个EC2 一个IP 你总不能安装很多很多台吧!会死人的)
    1. 注意设置你的安全组!正常情况下 入站规则只应该有你需要的端口(squid使用的端口一定要放心!嫌麻烦的小伙伴儿 可以进出都放行全部流量!)出站则是全部流量!
    2. 启动EC2的时候选择安全组一定要看清是否是设置过放行的规则! 不要选错了!
    3. 好了现在就可以批量启动了!
    4. 实例数量就是需要同时有多少个IP就启动多少个了。
  3. 设置前端负载均衡(提供一个固定地址,这个地址负责随机将请求转发到后端代理服务器上)
    1. 必须使用TCP四层负载!原因为啥大家自己百度一哈
    2. 等待负载均衡器启动完成!
    3. 启动完成后获取负载地址
  4. 下面来测试一下效果!

以上完毕!你可以不停的重启Ec2实例!你就有百万IP池啦!!(前提是你有钱啊) 下面是重启Ec2的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import boto3
from boto3 import setup_default_session


setup_default_session(aws_access_key_id='XXXXXX',
aws_secret_access_key='XXXXXX',
region_name='区域')

ec2 = boto3.client('ec2')


def get_public_ip_address():
"""
获取IP和实例ID
:return: {实例ID: ip}
"""
response = ec2.describe_instances()
reservations = response.get('Reservations')
instances = [i.get('Instances')[0] for i in reservations]
instance_id_public_ip_address = {i.get('InstanceId'): i.get('PublicIpAddress') for i in instances}
return instance_id_public_ip_address


def reboot_ec2(ip):
"""重启实例
:param ip:
:return:
"""
instance_id_public_ip_address = get_public_ip_address()
instance_id = instance_id_public_ip_address.get(ip)
try:
ec2.reboot_instances(InstanceIds=[instance_id], DryRun=True)
except ClientError as e:
if 'DryRunOperation' not in str(e):
print("You don't have permission to reboot instances.")
raise

try:
response = ec2.reboot_instances(InstanceIds=[instance_id], DryRun=False)
print('Success', response)
except ClientError as e:
print('Error', e)

Python

前言

随着大数据时代的到来,爬虫已经成了获取数据的必不可少的方式,做过爬虫的想必都深有体会,爬取的时候莫名其妙 IP 就被网站封掉了,毕竟各大网站也不想自己的数据被轻易地爬走。 对于爬虫来说,为了解决封禁 IP 的问题,一个有效的方式就是使用代理,使用代理之后可以让爬虫伪装自己的真实 IP,如果使用大量的随机的代理进行爬取,那么网站就不知道是我们的爬虫一直在爬取了,这样就有效地解决了反爬的问题。 那么问题来了,使用什么代理好呢?这里指的代理一般是 HTTP 代理,主要用于数据爬取。现在打开搜索引擎一搜 HTTP 代理,免费的、付费的太多太多品牌,我们该如何选择呢?看完这一篇文章,想必你心中就有了答案。 对于免费代理,其实想都不用想了,可用率能超过 10% 就已经是谢天谢地了。真正靠谱的代理还是需要花钱买的,那这么多家到底哪家可用率高?哪家响应速度快?哪家比较稳定?哪家性价比比较高?为此,我对市面上比较流行的多家付费代理针对可用率、爬取速度、爬取稳定性、价格、安全性、请求限制等做了详细的评测,让我们来一起看一下到底哪家更强!

测评范围

免费代理

在这里我主要测试的是付费代理,免费代理可用率太低,几乎不会超过 10%,但为了作为对比,我选取了西刺免费代理进行了测试。

付费代理

付费代理我选取了站大爷、芝麻 HTTP 代理、太阳 HTTP 代理、讯代理、快代理、蘑菇代理、阿布云代理、全网代理、云代理、大象代理、多贝云进行了对比评测,购买了他们的各个不同级别的套餐使用同样的网络环境进行了测评,详情如下:

代理商家 套餐类型 官方网站
芝麻 HTTP 代理 默认版 http://h.zhimaruanjian.com
阿布云代理 专业版 https://www.abuyun.com
动态版
经典版
大象代理 个人版 http://www.daxiangdaili.com
专业版
企业版
全网代理 普通版 http://www.goubanjia.com
动态版
快代理 开放代理 https://www.kuaidaili.com
私密代理
独享代理
蘑菇代理 默认版 http://www.mogumiao.com
太阳 HTTP 代理 默认版 http://http.taiyangruanjian.com
讯代理 优质代理 http://www.xdaili.cn
混播代理
独享代理
云代理 VIP 套餐 http://www.ip3366.net
站大爷代理 普通代理 http://ip.zdaye.com
短效优质代理

注:其中蘑菇代理、太阳 HTTP 代理、芝麻 HTTP 代理的默认版表示此网站只有这一种代理,不同套餐仅是时长区别,代理质量没有差别。 嗯,我把上面的套餐全部买了一遍,以供下面的评测使用。

测评目标

本次测评主要分析代理的可用率、响应速度、稳定性、价格、安全性、使用频率等因素,下面我们来一一进行说明。

可用率

可用率就是提取的这些代理中可以正常使用的比率。假如我们无法使用这个代理请求某个网站或者访问超时,那么就代表这个代理不可用,在这里我的测试样本大小为 500,即提取 500 个代理,看看里面可用的比率多少。

响应速度

响应速度可以用耗费时间来衡量,即计算使用这个代理请求网站一直到得到响应所耗费的时间。时间越短,证明代理的响应速度越快,这里同样是 500 个样本,计算时只对正常可用的代理做统计,计算耗费时间的平均值。

稳定性

由于爬虫时我们需要使用大量代理,如果一个代理响应速度特别快,很快就能得到响应,而下一次请求使用的代理响应速度特别慢,等了三十秒才得到响应,那势必会影响爬取效率,所以我们需要看下商家提供的这些代理稳定性怎样,总不能这一个特别快,下一个又慢的不行。所以这里我们需要统计一下耗费时间的方差,方差越大,证明稳定性越差。

价格

价格,这个当然是需要考虑的内容,如果一个代理不论是响应速度还是稳定性都特别不错,但是价格非常非常高,这也是不可接受的。

安全性

这的确也是需要考虑的因素,比如一旦不小心把代理提取的 API 泄露出去了,别人就肆意使用我们的 API 提取代理使用,而一直耗费的是我们的套餐。另外一旦别人通过某些手段获取了我们的代理列表,而这些代理是没有安全验证的,这也会导致别人偷偷使用我们的代理。在生产环境上,这方面尤其需要注意。

使用频率

有些代理套餐在 API 调用提取代理时有频率限制,有的代理套餐则会限制请求频率,这些因素都会或多或少影响爬虫的效率,这部分因素我们也需要考虑进来。

测评标准

要做标准的测评,那就必须在标准的测评环境下进行,且尽可能排除一些杂项的干扰,如网络波动、传输延迟等一系列的影响。

主机选取

由于我的个人笔记本是使用 WiFi 上网的,所以可能会有网络波动,而且实际带宽其实并不太好把控,因此它并不适合来做标准评测使用。评测需要在一个网络稳定的条件下进行,而且多个代理的评测环境必须相同,在此我选择了一台腾讯云主机作为测试,主机配置如下:

参数名 参数值
操作系统 Ubuntu 16.04.1 LTS (GNU/Linux 4.4.0-53-generic x86_64)
带宽 5 Mbps
核心数 2
内存 4GB
Python 版本 3.5.2

这样我们就可以保证一个标准统一的测试环境了。

现取现测

另外在评测时还需要遵循一个原则,那就是现取现测,即取一个测一个。现在很多付费代理网站都提供了 API 接口,我们可以一次性提取多个代理,但是这样会导致一个问题,每个代理在提取出来的时候,商家是会尽量保证它的可用性的,但过一段时间,这个代理可能就不好用了,所以假如我们一次性提取出来了 100 个代理,但是这 100 个代理并没有同时参与测试,后面的代理就会经历一个的等待期,过一段时间再测这些代理的话,肯定会影响后半部分代理的有效性,所以这里我们将提取的数量统一设置成 1,即请求一次接口获取一个代理,然后立即进行测试,这样可以保证测试的公平性,排除了不同代理有效期的干扰。

时间计算

由于我们有一项是测试代理的响应速度,所以我们需要计算程序请求之前和得到响应之后的时间差,这里我们使用的测试 Python 库是 requests,所以我们就计算发起请求和得到响应之间的时间差即可,时间计算方法如下所示:

1
2
3
4
start_time = time.time()
requests.get(test_url, timeout=timeout, proxies=proxies)
end_time = time.time()
used_time = end_time - start_time

这里 used_time 就是使用代理请求的耗时,这样测试的就仅仅是发起请求到得到响应的时间。

测试链接

测试时我们也需要使用一个稳定的且没有反爬虫的链接,这样可以排除服务器的干扰,这里我们使用百度来作为测试目标。

超时限制

在测试时免不了的会遇到代理请求超时的问题,所以这里我们也需要统一一个超时时间,这里设置为 60 秒,如果使用代理请求百度,60 秒还没有得到响应,那就视为该代理无效。

测试数量

要做测评,那么样本不能太小,如只有十几次测试是不能轻易下结论的,这里我选取了一个适中的测评数量 500,即每个套餐获取 500 个代理进行测试。

测评过程

嗯,测评过程这边主要说一下测评的代码逻辑,首先测的时候是取一个测一个的,所以这里定义了一个 test_proxy() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
test_url = 'https://www.baidu.com/'
timeout = 60

def test_proxy(proxy):
try:
proxies = {
'https': 'http://' + proxy
}
start_time = time.time()
requests.get(test_url, timeout=timeout, proxies=proxies)
end_time = time.time()
used_time = end_time - start_time
print('Proxy Valid', 'Used Time:', used_time)
return True, used_time
except (ProxyError, ConnectTimeout, SSLError, ReadTimeout, ConnectionError):
print('Proxy Invalid:', proxy)
return False, None

这里需要传入一个参数 proxy,代表一个代理,即 IP 加端口组成的代理,然后这里使用了 requests 的 proxies 参数传递给 get() 方法。对于代理无效的检测,这里判断了 | ProxyError, ConnectTimeout, SSLError, ReadTimeout, ConnectionError 这几种异常,如果发生了这些异常统统视为代理无效,返回错误。如果在 timeout 60 秒内得到了响应,那么就计算其耗费时间并返回。 在主程序里,就是获取 API 然后统计结果了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
max = 500

def main():
print('Testing')
used_time_list = []
valid_count = 0
total_count = 0
while True:
flag, result = get_page(api_url)
if flag:
proxy = result.strip()
if is_proxy(proxy):
total_count += 1
print('Testing proxy', proxy)
test_flag, test_result = test_proxy(proxy=proxy)
if test_flag:
valid_count += 1
used_time_list.append(test_result)
stats_result(used_time_list, valid_count, total_count)
time.sleep(wait)
if total_count == max:
break

这里加了一些判断,如 is_proxy() 方法判断了获取的是不是符合有效的代理规则,即判断它是不是 IP 加端口的形式,这样可以排除 API 返回一些错误信息的干扰。另外这里设置了 total_count 和 valid_count 变量,只有符合代理规则的代理参与了测试,这样才算一次有效测试,total_count 加一,如果测试可用,那么 valid_count 加一并记录耗费时间。最后调用了 stats_results 方法进行了统计:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np

def stats_result(used_time_list, valid_count, total_count):
if not used_time_list or not total_count:
return
used_time_array = np.asarray(used_time_list, np.float32)
print('Total Count:', total_count,
'Valid Count:', valid_count,
'Valid Percent: %.2f%%' % (valid_count * 100.0 / total_count),
'Used Time Mean:', used_time_array.mean(),
'Used Time Var', used_time_array.var())

这里使用了 Numpy 来统计了耗费时间的均值和方差,分别反映代理的响应速度和稳定性。 嗯,就这样,利用这个方法我对各个不同的代理套餐逐一进行了测试。

测评结果

经过测评,初步得到如下统计结果:

代理商家 套餐类型 测试次数 有效次数 可用率 响应时间均值 响应时间方差
芝麻 HTTP 代理 默认版 500 495 99.00% 0.916853 1.331989
阿布云代理 专业版 500 452 90.40% 0.68770707 1.1477163
动态版 500 494 98.80% 1.83994 6.0491614
经典版 500 499 99.80% 0.49301904 0.25724468
大象代理 个人版 500 238 47.60% 5.340489 78.56444
专业版 500 284 56.80% 6.87078 105.7984
企业版 500 259 51.80% 6.3081837 121.08402
全网代理 普通版 500 220 44.00% 5.584057 47.442596
动态版 500 485 97.00% 2.776973 17.568045
快代理 开放代理 500 178 35.60% 16.636587 221.69661
私密代理 500 495 99.00% 1.2044522 3.72582
独享代理 500 497 99.40% 0.5435687 2.27832
蘑菇代理 默认版 500 497 99.40% 1.0985725 9.532586
太阳 HTTP 代理 默认版 500 400 80.00% 1.2522483 12.662229
讯代理 优质代理 500 495 99.00% 1.0512681 6.4247565
混播代理 500 494 98.80% 1.0664985 6.451699
独享代理 500 500 100% 0.7056521 0.35416448
云代理 VIP 套餐 500 489 97.80% 3.4216988 38.120296
站大爷代理 普通代理 500 92 18.40% 5.067193 66.12128
短效优质代理 500 488 97.60% 1.5625348 8.121197
西刺代理 免费 500 31 6.2% 9.712833 95.09569

注:

  • 表中的响应时间方差越大,代表稳定性越低。
  • 阿布云代理经典版方差较小是因为它是长时间锁定了同一个 IP,因此极其稳定,但每秒最大请求默认 5 次。
  • 多贝云代理套餐一二方差较小是因为它是长时间锁定了同一个 IP,因此极其稳定,但每秒最大请求默认 20 次。

测评分析

下面我们将从各个方面分析一下各个套餐的优劣。

可用率

通过可用率统计,我们可以发现可用率较高的代理套餐有:

级别 套餐 描述
第一梯队 讯代理独享代理、阿布云代理经典版、快代理私密代理、蘑菇代理、芝麻 HTTP 代理、快代理独享代理、讯代理优质代理 可用率 99% 以上
第二梯队 阿布云代理动态版、讯代理混播代理、云代理、站大爷短效优质代理、全网代理动态版、阿布云代理专业版 可用率 99% 以下,90% 以上
第三梯队 太阳 HTTP 代理、大象代理专业版、大象代理企业版 可用率 90% 以下,50% 以上
第四梯队 大象代理个人版、全网代理普通版、快代理开放代理 可用率 50% 以下,20% 以上
第五梯队 站大爷普通代理、西刺代理 可用率 20% 以下

响应速度

通过平均响应速度判别,我们可以发现响应速度较快的代理套餐有:

级别 套餐 描述
第一梯队 阿布云代理经典版、阿布云代理专业版、快代理私密代理、讯代理独享代理、芝麻 HTTP 代理 响应时间 1s 以内
第二梯队 讯代理优质代理、快代理独享代理、讯代理混播代理、蘑菇代理、太阳代理、站大爷短效优质代理、阿布云代理动态版 响应时间 1s 以上,2s 以内
第三梯队 全网代理动态版、云代理 响应时间 2s 以上,5s 以内
第四梯队 站大爷普通代理、大象代理个人版、全网代理普通版、大象代理企业版、大象代理专业版、西刺代理 响应时间 5s 以上,10s 以内
第五梯队 快代理开放代理 响应时间 10s 以上

稳定性

通过平均响应速度方差分析,我们可以发现稳定性较高的代理套餐有:

级别 套餐 描述
第一梯队 阿布云代理经典版、讯代理独享代理、快代理私密代理、阿布云代理专业版、芝麻 HTTP 代理 方差 3 以内
第二梯队 快代理独享代理、阿布云代理动态版、讯代理优质代理、讯代理混播代理、站大爷短效优质代理、蘑菇代理 方差 10 以内,3 以上
第三梯队 太阳HTTP代理、全网代理动态版、云代理、全网代理普通版、站大爷普通代理、大象代理个人版、西刺代理 方差 100 以内,10 以上
第四梯队 大象代理专业版、大象代理企业版、快代理开放代理 方差 100 以上

价格

我们可以看一下各个套餐的价格:

代理商家 套餐类型 价格描述 价格 URL 备注
芝麻 HTTP 代理 默认版 ¥98/周 ¥360/月 http://h.zhimaruanjian.com/newrecharge/ 另有包量套餐、长效 IP 套餐可选购,定期有优惠活动,可领免费 IP,可免费试用
阿布云代理 专业版 ¥1/时 ¥16/天 ¥108/周 ¥429/月 https://www.abuyun.com/ 每秒请求只有5个,多加每秒请求1个需要 1¥0.5/月,¥90 /年
动态版 ¥1/时 ¥16/天 ¥108/周 ¥429/月
经典版 ¥1/时 ¥16/天 ¥108/周 ¥429/月
大象代理 个人版 ¥9/天 ¥98/月 http://www.daxiangdaili.com/ 好评可送时长
专业版 ¥19/天 ¥198/月
企业版 ¥49/天 ¥498/月
全网代理 普通版 ¥9/天 ¥35/周 ¥93/月 ¥500/年 http://www.goubanjia.com/buy/high.shtml
动态版 ¥10/天 ¥160/月 ¥1250/年 http://www.goubanjia.com/buy/dynamic.shtml
快代理 开放代理 ¥20/天 ¥60/周 ¥200/月 ¥2000/年 https://www.kuaidaili.com/pricing 有普通、VIP、SVIP、专业版可选
独享代理 ¥8/天 ¥32/周 ¥80/月
私密代理 ¥48/天 ¥240/周 ¥720/月
蘑菇代理 默认版 ¥6/天 ¥169/月 ¥1699/年 http://www.mogumiao.com/buy 另有包量套餐可选购,可免费试用
太阳 HTTP 代理 默认版 ¥60/周 ¥198/月 ¥498/季 ¥1590/年 http://http.taiyangruanjian.com/newrecharge/ 另有保量套餐可选购,可领免费 IP,可免费试用
讯代理 优质代理 ¥9/天 ¥210/月 ¥2100/年 http://www.xdaili.cn/buyproxy 可免费试用
混播代理 ¥29/天 ¥729/月 ¥6999/年
独享代理 ¥9/天 ¥210/月 ¥2100/年
云代理 VIP 套餐 ¥10/天 ¥120/月 ¥599/年 http://www.ip3366.net/pricing/ 另有普通套餐可选
站大爷代理 普通代理 ¥8/天 ¥80/月 ¥720/年 http://ip.zdaye.com/buy.html 另有私密代理可选
短效优质代理 ¥17/天 ¥475/月 ¥4569/年 http://ip.zdaye.com/ShortProxy.html

安全性

对于安全性,此处主要考虑提取 API 是否有访问验证,使用代理时是否有访问验证,即可以通过设置白名单来控制哪些可以使用。 其中只有芝麻 HTTP 代理、太阳 HTTP 代理默认使用了白名单限制,即只有将使用 IP 添加到白名单才可以使用,可以有效控制使用权限。 另外阿布云代理提供了隧道代理验证,只有成功配置了用户名和密码才可以正常使用。 所以在此归纳如下:

级别 套餐 描述
第一梯队 快代理、芝麻 HTTP 代理、太阳 HTTP 代理、阿布云代理、多贝云代理 默认使用了白名单控制或隧道代理验证
第二梯队 其他 可直接使用

调取频率

不同的接口具有不同的 API 调用频率限制,归纳如下:

代理商家 套餐类型 调取频率限制
芝麻 HTTP 代理 默认版 1秒
阿布云代理 专业版 无需获取
动态版 无需获取
经典版 无需获取
大象代理 个人版 1秒
专业版 1秒
企业版 无限制
全网代理 普通版 无限制
动态版 100毫秒
快代理 开放代理 200毫秒
独享代理 100毫秒
私密代理 100毫秒
蘑菇代理 默认版 5秒
太阳 HTTP 代理 默认版 1秒
讯代理 优质代理 5秒
混播代理 10秒
独享代理 15秒
云代理 VIP 套餐 无限制
站大爷代理 普通代理 3秒
短效优质代理 10秒
西刺代理 免费 无限制

在此可以简单总结如下:

级别

级别 套餐 描述
第一梯队 云代理、全网代理普通版、大象代理企业版、西刺代理、阿布云(调取无限制,请求默认最大 1 秒 5 请求) 无限制
第二梯队 全网代理动态版、快代理(所有套餐) 小于 1s
第三梯队 大象代理个人版、大象代理专业版、芝麻 HTTP 代理、太阳 HTTP 代理、站大爷普通代理、蘑菇代理、讯代理优质代理 1s – 5s
第四梯队 讯代理混播代理、讯代理独享代理、站大爷短效优质代理 大于 5s

特色功能

除了常规的测试之外,我这边还选取了某些套餐的与众不同之处进行说明,这些特点有的算是缺点,有的算是优点,现列举如下:

代理 描述
阿布云代理 多贝云代理 快代理 使用隧道技术实现,代理不能直接拿到,必须配置访问认证,默认 1 秒只能支持 5/20 个请求,如需更多需要付费。
讯代理 独享代理拨号时间略长,可用主机少,容易出现拨号失败现象,单个代理有效时长可控。
芝麻 HTTP 代理、快代理 必须要设置白名单才可以使用,后台可控,使用 API 提取代理不扣费,使用时才扣费。

测评综合

分项了解了各个代理套餐的可用率、响应速度、稳定性、性价比、安全性等内容之后,最后做一下总结:

代理商家 套餐类型 可用率 可用率评价 响应时间均值 响应速度评价 响应时间方差 稳定性 包月价格 价格评价 安全性 访问频率限制 调取频率限制 推荐指数
芝麻 HTTP 代理 默认版 99% 极高 0.916853 极快 1.331989 极好 360 较高 1 秒 ★★★★★
阿布云代理 专业版 90.4% 0.68770707 极快 1.1477163 极好 429 无需获取 ★★★☆
动态版 98.8% 1.83994 6.0491614 429 无需获取 ★★★★
经典版 99.8% 极高 0.49301904 极快 0.25724468 极好 429 无需获取 ★★★★
大象代理 个人版 47.6% 5.340489 78.56444 一般 98 1 秒 ★★
专业版 56.8% 一般 6.87078 105.7984 198 较低 1 秒 ★☆
企业版 51.8% 一般 6.3081837 121.08402 498 无限制
全网代理 普通版 44% 5.584057 47.442596 一般 93 无限制 ★★
动态版 97% 2.776973 一般 17.568045 一般 160 较低 100毫秒 ★★★
快代理 开放代理 35.6% 一般 16.636587 极慢 221.69661 200 200毫秒
独享代理 99.00% 极高 1.2044522 3.72582 64 100毫秒 ★★★★★
私密代理 99.40% 极高 0.5435687 极快 2.27832 720 100毫秒 ★★★★☆
蘑菇代理 默认版 99.4% 极高 1.0985725 9.532586 169 较低 5秒 ★★★★☆
太阳 HTTP 代理 默认版 80% 一般 1.2522483 12.662229 一般 198 较低 1秒 ★★★★
讯代理 优质代理 99% 极高 1.0512681 6.4247565 210 5秒 ★★★★☆
混播代理 98.8% 1.0664985 6.451699 729 10秒 ★★★☆
独享代理 100% 极高 0.7056521 极快 0.35416448 极好 210 15秒 ★★★★☆
云代理 VIP 套餐 97.8% 3.4216988 一般 38.120296 一般 120 较低 无限制 ★★★☆
站大爷代理 普通代理 18.4% 极低 5.067193 66.12128 一般 80 3秒 ★☆
短效优质代理 97.6% 1.5625348 8.121197 475 10秒 ★★★☆
西刺代理 免费 6.2% 极低 9.712833 95.09569 一般 0 免费 无限制

所以在综合来看比较推荐的有:芝麻代理、快代理、讯代理、阿布云、多贝云代理,详细的对比结果可以参照表格。 以上便是各家代理的详细对比测评情况,希望此文能够在大家选购代理的时候有所帮助。

Python

大家好,我是四毛,下面的是我的公众号,欢迎关注。

今天的内容主要讲的是破解一个网站的rsa加密,当然肯定不是破解这个算法,而是找到加密的参数,正确模拟这个算法即可。

1. 什么是rsa算法

下面的资料摘抄自阮一峰老师的文章, 点这里了解更多 1976年,两位美国计算机学家Whitfield Diffie 和 Martin Hellman,提出了一种崭新构思,可以在不直接传递密钥的情况下,完成解密。这被称为“Diffie-Hellman密钥交换算法”。这个算法启发了其他科学家。人们认识到,加密和解密可以使用不同的规则,只要这两种规则之间存在某种对应关系即可,这样就避免了直接传递密钥。 这种新的加密模式被称为”非对称加密算法”。

(1)乙方生成两把密钥(公钥和私钥)。公钥是公开的,任何人都可以获得,私钥则是保密的。 (2)甲方获取乙方的公钥,然后用它对信息加密。 (3)乙方得到加密后的信息,用私钥解密。

如果公钥加密的信息只有私钥解得开,那么只要私钥不泄漏,通信就是安全的。

2. 研究目标

从我要研究的网站来说,就是根据参数得到正确的公钥,加密以后返回给服务器,让服务器使用私钥可以解密出正确的数据即可。 同时,本文不会将具体的网站说出来,只是给大家提供一个解决问题的思路。

3. 开始

3.1 抓包找参数

首先,打开某个网站的登录页面,输入用户名,密码,验证码之类的参数, 抓包看到了下面这个页面: 我实际输入的值全是1, 然后都被加密了, 没办法,只能去找加密的方法了。 经过一番搜索过后,才发现,原来加密的算法就在源代码里面,这里截个图: 从这里就可以看到具体的算法名以及相关的参数了,你会说,这是什么算法我都不知道啊?搜啊,用关键词搜一下就能知道了。 同时,是不是觉得这个网站好傻逼,这不太简单了吗? 肯定不是!!! 这么简单,说明此处也是必有玄机!!! 至于什么玄机,到后面说,都是泪。

3.2 分析加密流程

首先, 我们知道了公钥以后,解析这个公钥,就可以得到相关的参数,给大家找了示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# /usr/bin/python
# encoding: utf-8

import base64

def str2key(s):
# 对字符串解码
b_str = base64.b64decode(s)

if len(b_str) < 162:
return False

hex_str = ''

# 按位转换成16进制
for x in b_str:
h = hex(ord(x))[2:]
h = h.rjust(2, '0')
hex_str += h

# 找到模数和指数的开头结束位置
m_start = 29 * 2
e_start = 159 * 2
m_len = 128 * 2
e_len = 3 * 2

modulus = hex_str[m_start:m_start + m_len]
exponent = hex_str[e_start:e_start + e_len]

return modulus,exponent

if __name__ == "__main__":

pubkey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDC7kw8r6tq43pwApYvkJ5laljaN9BZb21TAIfT/vexbobzH7Q8SUdP5uDPXEBKzOjx2L28y7Xs1d9v3tdPfKI2LR7PAzWBmDMn8riHrDDNpUpJnlAGUqJG9ooPn8j7YNpcxCa1iybOlc2kEhmJn5uwoanQq+CA6agNkqly2H4j6wIDAQAB"
key = str2key(pubkey)
print key

相应的输出

1
2
('c2ee4c3cafab6ae37a7002962f909e656a58da37d0596f6d530087d3fef7b16e86f31fb43c49474fe6e0cf5c404acce8f1d8bdbccbb5ecd5df6fded74f7ca2362d1ecf033581983327f2b887ac30cda54a499e500652a246f68a0f9fc8fb60da5cc426b58b26ce95cda41219899f9bb0a1a9d0abe080e9a80d92a972d87e23eb', 
'010001')

从代码中可以看出,解析了公钥之后得到了两个值,一个就是010001,和我们在网站源代码里面找到的值是一样的。所以,源代码里面的参数我们应该就是可以直接使用的,是不是有种找到组织的赶脚。 接下来,利用下面的代码,来对数据进行加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import rsa
import binascii
def en_test():
param_1 = "010001"
# 某次我找到的
param_2 = "955120AB9334B7CD52FCDB422DBF564AFD46DEBDC706F33502BBFAD9DD216A22E4D5012CB70F28473B46FB7190D08C31B4B8E76B5112ACE1C5552408961530B1C932DEEA8FC38A9A624AD22073F56F02BF453DD2C1FEA0164106D6B099CC9E5EC88C356FC164FCA47C766DD565D3D11048D27F2DD4221A0B26AB59BD7D09841F"
message = 'nihao'
modulus = int(param_2, 16)
exponent = int(param_1, 16)
rsa_pubkey = rsa.PublicKey(modulus, exponent)
crypto = rsa.encrypt(message, rsa_pubkey)
data = binascii.b2a_hex(crypto)

print data

if __name__ == '__main__':
en_test()

但是当我这样做完,进行模拟登录,还以为自己很牛逼的时候,服务器却给我返回了这样的结果, 目瞪狗呆啊:

1
{"Status":false,"ResultValue":"","StatusCode":"REFRESH","StatusMessage":"请尝试重新登录","RecordCount":0,"Data":null}

可以看到信息提示要刷新,但是当时是百思不得其解,为毛线要刷新? 困惑了一会之后,我再次从头走了一遍流程,这下我才发现,原来源代码里面的那个长长的数据是会改变的,直到这个时候,我才意识到为什么要我刷新。。。。。。 服务器啊,你就不能直接说参数错误吗?刷新你大爷啊。 果然,我还是太年轻啊。

果然,天上掉下的绝不是馅饼,绝逼是个陷阱。

知道这个坑以后就好办了,用个正则匹配一下就行了,而结果也是对的:

1
{"Status":true,"ResultValue":"","StatusCode":"OK","StatusMessage":"成功","RecordCount":0,"Data":{"LoginUrl":"/System/Welcome"}}

4 总结

到这里这篇文章就结束了,这个案例相对于来说很简单,而且为了保护网站的隐私,所以没办法展开说。 有些网站的加密方式是很变态的,比如网易云音乐,知晓常见的加密方法,就可以处理大部分的情况了。 其实,网易云音乐并不是一定要加密, 有想知道非加密的方法的,可以关注我,私聊我。有点敏感,就不写文章了。 反正,我爬了1000W+的网易云音乐都是不加密的~~ 如果你有类似的问题待解决或者想了解的更清楚的细节的,欢迎关注我的公众号以后,后台私我一下。

Linux

最近和几个朋友开发项目,期间使用了一台服务器跑模型,这台服务器是多人公用的,很多人都在上面有自己的账号,互不干涉内政,一切看起来十分井然有序。近期,这个服务器上刚挂载了一块新硬盘,是一位朋友使用 root 账号挂载的,然后将磁盘映射到某个文件夹下。然而挂载好了之后发现使用普通账号没有权限在文件夹下操作,无法创建文件,于是他干脆就直接把文件夹权限改成 777 了。我心想,这还了得,改成 777 了,其他人在里面乱改咋办?会出人命的!所以,我就这件事详细梳理了一下 Linux 下的用户、用户组、文件权限等基本知识,看完这些,以后不要动不动就把文件夹改成 777 权限了。

基本操作

首选我们梳理一下 Linux 下的用户、用户组、文件权限等基本知识,然后后面通过一个案例来实际演示一下权限设置的一些操作。 首先 Linux 系统中,是有用户和用户组的概念的,用户就是身份的象征,我们必须以某一个用户身份来操作一个系统,实际上这就对应着我们登录系统时的账号。而用户组就是一些用户的集合,我们可以通过用户组来划分和统一管理某些用户。 比如我要在微信发一条朋友圈,我只想给我的亲人们看,难道我发的时候还要一个个去勾选所有的人?这未免太麻烦了。为了解决这问题,微信里面就有了标签的概念,我们可以提前给好友以标签的方式分类,发的时候直接勾选某个标签就好了,简单高效。实际上这就是用户组的概念,我们可以将某些人进行分组和归类,到时候只需要指定类别或组别就可以了,而不用一个个人去对号入座,从而节省了大量时间。 在 Linux 中,一个用户是可以属于多个组的,一个组也是可以包含多个用户的,下面我以一台 Ubuntu Linux 为例来演示一下相关的命令和操作。

用户和用户组

首先查看所有用户,命令如下:

1
cut -d':' -f 1 /etc/passwd

结果:

1
2
3
4
5
6
7
root
daemon
bin
sys
...
ubuntu
mysql

这里一行就是一个用户名,由于太多,部分就省略了,实际上这个命令就是从密码文件中把用户名单独列出来了。 然后查看所有用户组,命令也是类似的:

1
cut -d':' -f 1 /etc/group

结果:

1
2
3
4
5
6
7
root
daemon
bin
sys
...
ubuntu
mysql

结果基本是类似的,因为每个用户在被创建的时候都会自动创建一个同名的组作为其默认的用户组。 这里我是使用 ubuntu 这个账号来登录的,下面我来看下 ubuntu 这个账号是属于哪些组。 查看一个用户所属组的命令格式如下:

1
gorups <username>

这里就是 groups 命令加上用户名就能查看该用户名所属的组了,如果不加用户名的话就默认是当前用户。 例如查看 ubuntu 这个用户所属于的组,命令如下:

1
groups ubuntu

结果:

1
ubuntu : ubuntu adm cdrom sudo dip plugdev lxd lpadmin sambashare

还不少,这个用户被分配到了很多组下,比如同名的组 ubuntu,还有 sudo 组,另外还有一些其他的组。 其中 sudo 组比较特殊,如果被分到了这个组里面就代表该账号拥有 root 权限,可以使用 sudo 命令。 了解了怎样查看用户所属的组,我们也应该反过来了解如何查看一个用户组里面包含哪些用户啊。 查看某个用户组下所有用户命令如下:

1
members <group>

不过这个命令不是自带的,需要额外安装 members 包,命令如下:

1
sudo apt-get install members

例如查看 sudo 用户组下的所有用户,即拥有 root 权限的用户:

1
members sudo

结果:

1
ubuntu hadoop

可以看到拥有 root 权限的用户有两个,ubuntu 和 hadoop,当然不同的机器结果肯定是不一样的。 接下来介绍一个比较有用的命令,就是 id 命令,它可以用来查看用户的所属组别,格式如下:

1
id <username>

例如查看 ubuntu 用户的信息,就是这样:

1
id ubuntu

结果:

1
uid=500(ubuntu) gid=500(ubuntu) groups=500(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),110(lxd),115(lpadmin),116(sambashare)

这里有一个 gid,作为主工作组,后面还有个 groups,它列出了用户所在的所有组。主工作组只有一个,而后者的数量则不限。可以看到用户组的结果和使用 groups 命令看到的结果是一致的。 接下来我们再来了解一下如何创建一个用户和怎样为用户分配组别。 添加一个用户命令格式如下:

1
sudo adduser <username>

比如我要添加一个用户 cqc,命令就可以这么写:

1
sudo adduser cqc

这里使用的命令前面都带有 sudo,因为毕竟是系统级别的操作。 添加一个组的命令格式如下:

1
sudo groupadd <group>

格式是类似的,后面跟一个组的名称就可以了,例如我要为我的实验室创建一个用户组,那么就可以使用如下命令:

1
sudo groupadd lab

创建完了用户和组,那得把它们关联起来吧,关联的意思就是把某个用户加入到某个组里面,命令格式如下:

1
sudo adduser <username> <group>

或者使用 usermod 命令:

1
sudo usermod -G <group> <username>

如果要添加多个组的话,可以通过 -a 选项指定多个名称:

1
sudo usermod -aG <group1,group2,group3..> <username>

例如我要将 cqc 用户添加到 sudo 用户组中,命令就是:

1
sudo adduser cqc sudo

或:

1
sudo usermod -G sudo cqc

这样就为用户和用户组做好关联了。

文件权限管理

了解了这些之后,我们再来了解一下文件权限的相关知识,下面我们先随便找一个目录,查看一下文件的列表。 列出某个目录下文件详细信息的命令如下:

1
ll

或者使用:

1
ls -l

比如我这里列出了 /etc/nginx 目录下的文件列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
total 80
drwxr-xr-x   7 root root  4096 Jun 21 22:16 ./
drwxr-xr-x 103 root root  4096 Sep  4 18:04 ../
drwxr-xr-x   2 root root  4096 Jul 12  2017 conf.d/
-rw-r--r--   1 root root  1077 Feb 12  2017 fastcgi.conf
-rw-r--r--   1 root root  1007 Feb 12  2017 fastcgi_params
-rw-r--r--   1 root root  2837 Feb 12  2017 koi-utf
-rw-r--r--   1 root root  2223 Feb 12  2017 koi-win
-rw-r--r--   1 root root  3957 Feb 12  2017 mime.types
-rw-r--r--   1 root root  1505 Jun 21 20:24 nginx.conf
-rw-r--r--   1 root root 12288 Jun 21 20:44 .nginx.conf.swp
-rw-r--r--   1 root root   180 Feb 12  2017 proxy_params
-rw-r--r--   1 root root   636 Feb 12  2017 scgi_params
drwxr-xr-x   2 root root  4096 Jun 21 22:42 sites-available/
drwxr-xr-x   2 root root  4096 Jun 21 19:08 sites-enabled/
drwxr-xr-x   2 root root  4096 Jun 21 19:08 snippets/
-rw-r--r--   1 root root   664 Feb 12  2017 uwsgi_params
drwxr-xr-x   2 root root  4096 Jun 22 02:44 vhosts/
-rw-r--r--   1 root root  3071 Feb 12  2017 win-utf

我们注意到了每一行都是一个文件或文件夹的信息,一共包括七列:

  • 第一列是文件的权限信息
  • 第二列表示该文件夹连接的文件数
  • 第三列表示文件所属用户
  • 第四列表示文件所属用户组
  • 第五列表示文件大小(字节)
  • 第六列表示最后修改日期
  • 第七列表示文件名

其中第一列的文件权限信息是非常重要的,它由十个字符组成:

  • 第一个字符代表文件的类型,有三种,- 代表这是一个文件,d 代表这是一个文件夹,l 代表这是一个链接。
  • 第 2-4 个字符代表文件所有者对该文件的权限,r 就是读,w 就是写,x 就是执行,如果是文件夹的话,执行就意味着查看文件夹下的内容,例如 rw- 就代表文件所有者可以对该文件进行读取和写入。
  • 第 5-7 个字符代表文件所属组对该文件的权限,含义是一样的,如 r-x 就代表该文件所属组内的所有用户对该文件有读取和执行的权限。
  • 第 8-10 个字符代表是其他用户对该文件的权限,含义也是一样的,如 r— 就代表非所有者,非用户组的用户只拥有对该文件的读取权限。

我们可以使用 chmod 命令来改变文件或目录的权限,有这么几种用法。 一种是数字权限命名,rwx 对应一个二进制数字,如 101 就代表拥有读取和执行的权限,而转为十进制的话,r 就代表 4,w 就代表 2,x 就代表 1,然后三个数字加起来就和二进制数字对应起来了。如 7=4+2+1,这就对应着 rwx;5=4+1,这就对应着 r-x。所以,相应地 777 就代表了 rwxrwxrwx,即所有者、所属用户组、其他用户对该文件都拥有读取、写入、执行的权限,这是相当危险的! 赋予权限的命令如下:

1
sudo chmod <permission> <file>

例如我要为一个 file.txt 赋予 777 权限,就写成:

1
sudo chmod 777 file.txt

另外我们也可以使用代号来赋予权限,代号有 u、g、o、a 四中,分别代表所有者权限,用户组权限,其他用户权限和所有用户权限,这些代号后面通过 + 和 - 符号来控制权限的添加和移除,再后面跟上权限类型就好,例如:

1
sudo chmod u-x file.txt

就是给所有者移除 x 权限,也就是执行权限。

1
sudo chmod g+w file.txt

就是为用户组添加 w 权限,即写入权限。 另外如果是文件夹的话还可以对文件夹进行递归赋权限操作,如:

1
sudo chmod -R 777 share

就是将 share 文件夹和其内所有内容都赋予 777 权限。 好,有了权限的标识,那我们还得把用户和用户组与文件关联起来啊,这里使用的命令就是 chown 和 chgrp 命令。 命令格式如下:

1
2
sudo chown <username> <file>
sudo chgrp <group> <file>

例如我要将 file.txt 的所有者换成 cqc,那就可以使用如下命令:

1
sudo chown cqc file.txt

如果我要将 file.txt 所属用户组换成 lab,那就可以使用如下命令:

1
sudo chgrp lab file.txt

另外同样可以使用 -R 来进行递归操作,如将 share 文件夹及其内所有内容的所有者都换成 cqc,命令如下:

1
sudo chown -R cqc share/

好,了解了 chown、chgrp、chmod 之后,我们就可以灵活地对文件权限进行控制了。

实战演示

可能上面说起来有点抽象,下面我们以一个实例来演示一下权限控制的流程,通过这个流程,相信理解以上的命令都不在话下了。 首先情况是这样的,我要在某台主机上共享一些文件给我实验室的人,但这台主机上还有其他非实验室的人在使用,我只想让实验室的人查看和修改这些文件,其他人不行。 另外我自己的账号要有最高权限来管理这些文件的共享权限,即要有 root 权限。 现在我已经登录了一个 ubuntu 的账号,是系统初始化的,拥有 root 权限。 下面我就模拟创建三个账号和一个用户组,来得到如下效果:

  • 账号 cqc 是我自己使用的账号,拥有最高权限,可以自由调整文件权限信息,可以自由为某个用户分配用户组。
  • 账号 lbd 是我实验室的人员,没有 root 权限,但能查看和修改我共享的文件。
  • 账号 slb 不是我实验室的人员,没有 root 权限,也不能修改我共享的文件。

创建自己的账户

首先我先为自己创建一个账户,添加一个 cqc 的用户:

1
sudo adduser cqc

运行之后会提示输入密码和其他信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Adding user `cqc' ...
Adding new group `cqc' (1002) ...
Adding new user `cqc' (1002) with group `cqc' ...
Creating home directory `/home/cqc' ...
Copying files from `/etc/skel' ...
Enter new UNIX password: 
Retype new UNIX password: 
passwd: password updated successfully
Changing the user information for cqc
Enter the new value, or press ENTER for the default
        Full Name []: 
        Room Number []: 
        Work Phone []: 
        Home Phone []: 
        Other []: 
Is the information correct? [Y/n]

这时候发现一个同名的组就被创建了,查看下 cqc 所在的组:

1
groups cqc

结果如下:

1
cqc : cqc

再用 id 命令查看下信息:

1
id cqc

结果如下:

1
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc)

可以看到当前 cqc 只属于 cqc 用户组。 接下来我们创建一个用户组,叫做 lab,来标明我的实验室,命令如下:

1
sudo groupadd lab

然后查看下用户组里面的成员:

1
members lab

没有任何结果,说明我们创建了一个空的组,没有任何成员。 然后我们将刚才创建的 cqc 加入到该组中,因为我自己也属于该实验室,肯定也要加进来,命令如下:

1
sudo adduser cqc lab

结果:

1
2
3
Adding user `cqc' to group `lab' ...
Adding user cqc to group lab
Done.

然后查看下组内成员:

1
members lab

结果:

1
cqc

这样 lab 组内就有了 cqc 这个用户了。 别忘了 cqc 还需要拥有 root 权限,所以我们还需要将 cqc 添加到 sudo 组内,命令如下:

1
sudo adduser cqc sudo

结果:

1
2
3
Adding user `cqc' to group `sudo' ...
Adding user cqc to group sudo
Done.

这样就成功加入到 sudo 组了,cqc 也就是我的账户就可以使用 sudo 命令了。 查看下用户状态:

1
id cqc

结果如下:

1
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc),27(sudo),1003(lab)

这样 cqc 就属于三个用户组了,既是实验室成员,又拥有 root 权限。 上面的分配用户组的命令我们也可以使用 usermod 来实现:

1
sudo usermod -aG sudo,lab cqc

这样就添加到多个组了。

添加实验室用户

接下来,再添加实验室的另外一个人员 lbd,然后将其添加到 lab 组中,流程是类似的,命令如下:

1
2
sudo adduser lbd
sudo adduser lbd lab

运行完毕之后,id 命令查看其信息:

1
id lbd

结果如下:

1
uid=1004(lbd) gid=1005(lbd) groups=1005(lbd),1003(lab)

这样就成功创建 lbd,并将其添加到实验室 lab 组了。

添加非实验室用户

最后另外添加一个用户 slb,非实验室成员,只创建账户就好了,命令如下:

1
sudo adduser slb

但是我们不把他加入 lab 组中。 查看他的状态:

1
id slb

结果如下:

1
uid=1003(slb) gid=1004(slb) groups=1004(slb)

所以三人的状态是这样的:

1
2
3
4
5
6
id cqc
uid=1002(cqc) gid=1002(cqc) groups=1002(cqc),27(sudo),1003(lab)
id lbd
uid=1004(lbd) gid=1005(lbd) groups=1005(lbd),1003(lab)
id slb
uid=1003(slb) gid=1004(slb) groups=1004(slb)

文件权限分配

接下来我们创建一个文件夹来共享实验室数据,放在 /srv 目录下。然后调用 mkdir 命令创建名称为 share 的文件夹,命令如下:

1
2
cd /srv
sudo mkdir share

注意这里我还是使用 ubuntu 账户来创建的。 先看下当前目录权限:

1
ls -l

结果如下:

1
2
3
4
total 12
drwxr-xr-x  3 root root 4096 Sep  4 18:17 ./
drwxr-xr-x 24 root root 4096 Sep  4 18:17 ../
drwxr-xr-x  2 root root 4096 Sep  4 18:17 share/

可以看到 share 文件的所有者是 root,用户组也是 root,权限是 755,即只有 root 拥有修改权限,其他的只有读取和执行权限。 然后进入 share 文件夹创建一个 names.txt:

1
2
cd share
sudo vi names.txt

编辑内容如下:

1
2
cqc
lbd

保存完毕之后,这时查看一下文件权限,如下:

1
-rw-r----- 1 root root    8 Sep  4 20:00 names.txt

权限是 640,这表明只有所有者 root 拥有写入的权限,所在组只有读的权限。 这时开启另外一个终端,登录 cqc 账号,实际上是不能查看和修改任何该文件的内容的,下面的修改和读取命令都会提示权限不够:

1
2
vi names.txt
cat names.txt

为什么呢?因为该文件是刚才由 ubuntu 账号使用 sudo 命令创建的,因此文件的所有者是 root,并不是 cqc,因此即使文件的权限是 640,那也就不能使用文件所有者的权限,而且 cqc 也不属于 root 组,所以也不能使用文件组的权限了,因此什么都看不了,什么都改不了。 但 cqc 属于 sudo 组啊,可以利用 sudo 命令临时获取 root 权限,临时以 root 的身份来操作该文件,这样就可以来查看和修改文件了,因此下面的命令是有效的:

1
2
sudo vi names.txt
sudo cat names.txt

但这样还是需要使用 sudo 才能修改,很不方便。 这时如果我们把文件的所有者改成 cqc,情况那就不一样了。 使用 ubuntu 账号,对 names.txt 更改其所有者为 cqc,改的命令如下:

1
sudo chown cqc names.txt

这时查看下文件信息:

1
-rw-r----- 1 cqc  root    8 Sep  4 20:29 names.txt

可以看到所有者信息已经变成了 cqc,这样 cqc 账号再直接查看和修改,那就可以了,不再需要 sudo 命令:

1
2
vi names.txt
cat names.txt

这样就不会有权限提示,当然加上 sudo 更是没问题。 好,接下来 lbd 呢?我们登录试试修改。 首先当前的文件状态是这样的:

1
-rw-r----- 1 cqc  root    8 Sep  4 20:31 names.txt

lbd 不是所有者了,因此前面的 rw- 权限是没什么用的,但他属于 lab 组,而该文件对于用户组的权限是 r—,也就是读取权限。 我们使用 lbd 账号来尝试看下文件的内容:

1
2
cat names.txt 
cat: names.txt: Permission denied

很遗憾,又没有权限。因为什么?因为这个文件的用户组并不是 lab 啊,而 lbd 这个用户又不属于 root 组,所以没有任何权限。 那咋办?将文件的用户组改成 lab 就好了,使用 ubuntu 账号或 cqc 账号来操作:

1
sudo chgrp lab names.txt

这样就成功将文件所属用户组改成 lab 了,接下来再使用 lbd 账号查看下文件内容:

1
cat names.txt

就成功读取了。 然而 lbd 现在是没有写入权限的,因为对于用户组来说,该文件的权限是 r—,如果要获取写入权限,我们可以使用如下命令:

1
sudo chmod g+w names.txt

或:

1
sudo chmod 660 names.txt

这样就相当于赋予了 rw- 权限,下面我们再使用 lbd 账号尝试修改这个文件:

1
vi names.txt

就没问题了。 那么对于非实验室同学 slb 呢?它没有任何权限,我们登录 slb 账号尝试修改和读取该文件:

1
2
cat names.txt
vi names.txt

均无权限。 所以说,这样我们就成功为实验室的人员赋予了权限,而非实验室的人则没有任何权限。 如果我要为 slb 赋予读取权限咋办呢?很简单,添加一下就好了:

1
sudo chmod o+r names.txt

这就是为其他用户添加了读取权限。这时 slb 就可以读取文件,但不能修改文件,也是比较安全的。 好,如果我的文件非常多呢?比如十几二十个,都放在 share 文件夹内,那不能一个个进行权限设置吧? 这时候我们只需要针对文件夹进行操作即可,下面的命令就可以为 share 文件夹赋予 775 权限,即所有者 cqc 和所在组 lab 可对其进行查看和修改,其他的人只能看不能改:

1
2
3
sudo chmod -R 775 share/
sudo chown -R cqc share/
sudo chgrp -R lab share/

注意文件夹一般都会赋予 x 权限,不然连进入文件夹的权限都没有。这也就是文件夹一般会赋予 775、755,而文件会赋予 664、600、644、640 的原因了。 赋予 775 权限之后,share 的权限就变成了:

1
drwxrwxr-x  2 cqc  lab  4096 Sep  4 20:31 share/

这样其他用户就只能看,不能改,这样普通文件就没什么问题了。 如文件夹内包含了可执行文件,还可以单独为其他用户针对可执行文件去除 x 权限,如去除 Python 文件的可执行权限:

1
sudo chmod o-x *.py

好了,到现在为止,我们就得心应手地完成了权限控制了! 相信如果你耐心看完的话,什么用户管理、权限管理,都不在话下!

Linux

本文介绍一下如何给 Azure 的云服务器增加一块磁盘。

页面操作

首先切换到磁盘页面,然后点击添加数据磁盘按钮: 然后选定存储容器,这里使用的是存储账户 Blob,然后点击确定按钮: 主机缓存切换为“读/写”,然后点击保存: 这样就添加好了。

挂载磁盘

接下来回到 Linux 服务器下,我们需要将磁盘进行挂载。 首先 SSH 连接到服务器,然后使用 dmesg 命令来查找磁盘:

1
dmesg | grep SCSI

输出类似如下:

1
2
3
4
5
6
[    0.728389] SCSI subsystem initialized
[ 2.139341] Block layer SCSI generic (bsg) driver version 0.4 loaded (major 244)
[ 2.978928] sd 1:0:1:0: [sdb] Attached SCSI disk
[ 3.341183] sd 0:0:0:0: [sda] Attached SCSI disk
[ 18.397942] Loading iSCSI transport class v2.0-870.
[ 6641.364794] sd 3:0:0:0: [sdc] Attached SCSI disk

这里 sdc 就是我们新添加的一块硬盘。 然后我们使用 fdisk 对其进行分区,将其设置为分区 1 中的主磁盘,并接受其他的默认值,命令如下:

1
sudo fdisk /dev/sdc

使用 n 命令添加新分区,然后 p 选择主分区,其他的默认:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Welcome to fdisk (util-linux 2.27.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.


Device does not contain a recognized partition table.
Created a new DOS disklabel with disk identifier 0xc305fe54.

Command (m for help): n
Partition type
p primary (0 primary, 0 extended, 4 free)
e extended (container for logical partitions)
Select (default p): p
Partition number (1-4, default 1):
First sector (2048-2145386495, default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-2145386495, default 2145386495):

Created a new partition 1 of type 'Linux' and of size 1023 GiB.

然后使用 p 打印分区表并使用 w 将表写入磁盘,然后退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Command (m for help): p
Disk /dev/sdc: 1023 GiB, 1098437885952 bytes, 2145386496 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xc305fe54

Device Boot Start End Sectors Size Id Type
/dev/sdc1 2048 2145386495 2145384448 1023G 83 Linux

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.

接下来使用 mkfs 命令将文件系统写入分区,指定文件系统的类型和设备名称:

1
sudo mkfs -t ext4 /dev/sdc1

输出类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
mke2fs 1.42.13 (17-May-2015)
Creating filesystem with 268173056 4k blocks and 67043328 inodes
Filesystem UUID: d744c5d7-f4d1-4f81-9f56-59dfab956782
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
4096000, 7962624, 11239424, 20480000, 23887872, 71663616, 78675968,
102400000, 214990848

Allocating group tables: done
Writing inode tables: done
Creating journal (32768 blocks): done
Writing superblocks and filesystem accounting information: done

然后使用 mkdir 创建一个目录来装载该文件系统,然后挂载:

1
2
sudo mkdir /datadrive
sudo mount /dev/sdc1 /datadrive

这样就挂载成功了。

添加引导信息

若要确保在重新引导后自动重新装载驱动器,必须将其添加到 /etc/fstab 文件。 此外,强烈建议在 /etc/fstab 中使用 UUID(全局唯一标识符)来引用驱动器而不是只使用设备名称(例如 /dev/sdc1)。 如果 OS 在启动过程中检测到磁盘错误,使用 UUID 可以避免将错误的磁盘装载到给定位置。 然后,为剩余的数据磁盘分配这些设备 ID。 若要查找新驱动器的 UUID,请使用 blkid 实用工具:

1
sudo -i blkid

输入类似如下:

1
2
3
/dev/sdb1: UUID="d5b61f40-4129-4b39-b861-c2d3b09cee69" TYPE="ext4" PARTUUID="4927b944-01"
/dev/sda1: LABEL="cloudimg-rootfs" UUID="b2e62f4f-d338-470e-9ae7-4fc0e014858c" TYPE="ext4" PARTUUID="577c3e7c-01"
/dev/sdc1: UUID="d744c5d7-f4d1-4f81-9f56-59dfab956782" TYPE="ext4" PARTUUID="c305fe54-01"

然后编辑 /etc/fstab,添加下面一行:

1
UUID=d744c5d7-f4d1-4f81-9f56-59dfab956782       /datadrive      ext4    defaults,nofail 1      2

然后保存退出即可。 这样就成功添加了一块外部磁盘。

Other

为何要搭建 Elasticsearch 集群

凡事都要讲究个为什么。在搭建集群之前,我们首先先问一句,为什么我们需要搭建集群?它有什么优势呢?

高可用性

Elasticsearch 作为一个搜索引擎,我们对它的基本要求就是存储海量数据并且可以在非常短的时间内查询到我们想要的信息。所以第一步我们需要保证的就是 Elasticsearch 的高可用性,什么是高可用性呢?它通常是指,通过设计减少系统不能提供服务的时间。假设系统一直能够提供服务,我们说系统的可用性是 100%。如果系统在某个时刻宕掉了,比如某个网站在某个时间挂掉了,那么就可以它临时是不可用的。所以,为了保证 Elasticsearch 的高可用性,我们就应该尽量减少 Elasticsearch 的不可用时间。 那么怎样提高 Elasticsearch 的高可用性呢?这时集群的作用就体现出来了。假如 Elasticsearch 只放在一台服务器上,即单机运行,假如这台主机突然断网了或者被攻击了,那么整个 Elasticsearch 的服务就不可用了。但如果改成 Elasticsearch 集群的话,有一台主机宕机了,还有其他的主机可以支撑,这样就仍然可以保证服务是可用的。 那可能有的小伙伴就会说了,那假如一台主机宕机了,那么不就无法访问这台主机的数据了吗?那假如我要访问的数据正好存在这台主机上,那不就获取不到了吗?难道其他的主机里面也存了一份一模一样的数据?那这岂不是很浪费吗? 为了解答这个问题,这里就引出了 Elasticsearch 的信息存储机制了。首先解答上面的问题,一台主机宕机了,这台主机里面存的数据依然是可以被访问到的,因为在其他的主机上也有备份,但备份的时候也不是整台主机备份,是分片备份的,那这里就又引出了一个概念——分片。 分片,英文叫做 Shard,顾名思义,分片就是对数据切分成了多个部分。我们知道 Elasticsearch 中一个索引(Index)相当于是一个数据库,如存某网站的用户信息,我们就建一个名为 user 的索引。但索引存储的时候并不是整个存一起的,它是被分片存储的,Elasticsearch 默认会把一个索引分成五个分片,当然这个数字是可以自定义的。分片是数据的容器,数据保存在分片内,分片又被分配到集群内的各个节点里。当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里,所以相当于一份数据被分成了多份并保存在不同的主机上。 那这还是没解决问题啊,如果一台主机挂掉了,那么这个分片里面的数据不就无法访问了?别的主机都是存储的其他的分片。其实是可以访问的,因为其他主机存储了这个分片的备份,叫做副本,这里就引出了另外一个概念——副本。 副本,英文叫做 Replica,同样顾名思义,副本就是对原分片的复制,和原分片的内容是一样的,Elasticsearch 默认会生成一份副本,所以相当于是五个原分片和五个分片副本,相当于一份数据存了两份,并分了十个分片,当然副本的数量也是可以自定义的。这时我们只需要将某个分片的副本存在另外一台主机上,这样当某台主机宕机了,我们依然还可以从另外一台主机的副本中找到对应的数据。所以从外部来看,数据结果是没有任何区别的。 一般来说,Elasticsearch 会尽量把一个索引的不同分片存储在不同的主机上,分片的副本也尽可能存在不同的主机上,这样可以提高容错率,从而提高高可用性。 但这时假如你只有一台主机,那不就没办法了吗?分片和副本其实是没意义的,一台主机挂掉了,就全挂掉了。

健康状态

针对一个索引,Elasticsearch 中其实有专门的衡量索引健康状况的标志,分为三个等级:

  • green,绿色。这代表所有的主分片和副本分片都已分配。你的集群是 100% 可用的。
  • yellow,黄色。所有的主分片已经分片了,但至少还有一个副本是缺失的。不会有数据丢失,所以搜索结果依然是完整的。不过,你的高可用性在某种程度上被弱化。如果更多的分片消失,你就会丢数据了。所以可把 yellow 想象成一个需要及时调查的警告。
  • red,红色。至少一个主分片以及它的全部副本都在缺失中。这意味着你在缺少数据:搜索只能返回部分数据,而分配到这个分片上的写入请求会返回一个异常。

如果你只有一台主机的话,其实索引的健康状况也是 yellow,因为一台主机,集群没有其他的主机可以防止副本,所以说,这就是一个不健康的状态,因此集群也是十分有必要的。

存储空间

另外,既然是群集,那么存储空间肯定也是联合起来的,假如一台主机的存储空间是固定的,那么集群它相对于单个主机也有更多的存储空间,可存储的数据量也更大。 所以综上所述,我们需要一个集群!

详细了解 Elasticsearch 集群

接下来我们再来了解下集群的结构是怎样的。 首先我们应该清楚多台主机构成了一个集群,每台主机称作一个节点(Node)。 如图就是一个三节点的集群:

在图中,每个 Node 都有三个分片,其中 P 开头的代表 Primary 分片,即主分片,R 开头的代表 Replica 分片,即副本分片。所以图中主分片 1、2,副本分片 0 储存在 1 号节点,副本分片 0、1、2 储存在 2 号节点,主分片 0 和副本分片 1、2 储存在 3 号节点,一共是 3 个主分片和 6 个副本分片。同时我们还注意到 1 号节点还有个 MASTER 的标识,这代表它是一个主节点,它相比其他的节点更加特殊,它有权限控制整个集群,比如资源的分配、节点的修改等等。 这里就引出了一个概念就是节点的类型,我们可以将节点分为这么四个类型:

  • 主节点:即 Master 节点。主节点的主要职责是和集群操作相关的内容,如创建或删除索引,跟踪哪些节点是群集的一部分,并决定哪些分片分配给相关的节点。稳定的主节点对集群的健康是非常重要的。默认情况下任何一个集群中的节点都有可能被选为主节点。索引数据和搜索查询等操作会占用大量的cpu,内存,io资源,为了确保一个集群的稳定,分离主节点和数据节点是一个比较好的选择。虽然主节点也可以协调节点,路由搜索和从客户端新增数据到数据节点,但最好不要使用这些专用的主节点。一个重要的原则是,尽可能做尽量少的工作。
  • 数据节点:即 Data 节点。数据节点主要是存储索引数据的节点,主要对文档进行增删改查操作,聚合操作等。数据节点对 CPU、内存、IO 要求较高,在优化的时候需要监控数据节点的状态,当资源不够的时候,需要在集群中添加新的节点。
  • 负载均衡节点:也称作 Client 节点,也称作客户端节点。当一个节点既不配置为主节点,也不配置为数据节点时,该节点只能处理路由请求,处理搜索,分发索引操作等,从本质上来说该客户节点表现为智能负载平衡器。独立的客户端节点在一个比较大的集群中是非常有用的,他协调主节点和数据节点,客户端节点加入集群可以得到集群的状态,根据集群的状态可以直接路由请求。
  • 预处理节点:也称作 Ingest 节点,在索引数据之前可以先对数据做预处理操作,所有节点其实默认都是支持 Ingest 操作的,也可以专门将某个节点配置为 Ingest 节点。

以上就是节点几种类型,一个节点其实可以对应不同的类型,如一个节点可以同时成为主节点和数据节点和预处理节点,但如果一个节点既不是主节点也不是数据节点,那么它就是负载均衡节点。具体的类型可以通过具体的配置文件来设置。

怎样搭建 Elasticsearch 集群

好,接下来我们就来动手搭建一个集群吧。 这里我一共拥有七台 Linux 主机,系统是 Ubuntu 16.04,都连接在一个内网中,IP 地址为:

1
2
3
4
5
6
7
10.0.0.4
10.0.0.5
10.0.0.6
10.0.0.7
10.0.0.8
10.0.0.9
10.0.0.10

每台主机的存储空间是 1TB,内存是 13GB。 下面我们来一步步介绍如何用这几台主机搭建一个 Elasticsearch 集群,这里使用的 Elasticsearch 版本是 6.3.2,另外我们还需要安装 Kibana 用来可视化监控和管理 Elasticsearch 的相关配置和数据,使得集群的管理更加方便。 环境配置如下所示:

名称

内容

主机台数

7

主机内存

13GB

主机系统

Ubuntu 16.04

存储空间

1TB

Elasticsearch 版本

6.3.2

Java 版本

1.8

Kibana 版本

6.3.2

安装 Java

Elasticsearch 是基于 Lucene 的,而 Lucene 又是基于 Java 的。所以第一步我们就需要在每台主机上安装 Java。 首先更新 Apt 源:

1
sudo apt-get update

然后安装 Java:

1
sudo apt-get install default-jre

安装好了之后可以检查下 Java 的版本:

1
java -version

这里的版本是 1.8,类似输出如下:

1
2
3
openjdk version "1.8.0_171"
OpenJDK Runtime Environment (build 1.8.0_171-8u171-b11-0ubuntu0.16.04.1-b11)
OpenJDK 64-Bit Server VM (build 25.171-b11, mixed mode)

如果看到上面的内容就说明安装成功了。 注意一定要每台主机都要安装。

安装 Elasticsearch

接下来我们来安装 Elasticsearch,同样是每台主机都需要安装。 首先需要添加 Apt-key:

1
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -

然后添加 Elasticsearch 的 Repository 定义:

1
echo "deb https://artifacts.elastic.co/packages/6.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-6.x.list

接下来安装 Elasticsearch 即可:

1
2
sudo apt-get update 
sudo apt-get install elasticsearch

运行完毕之后我们就完成了 Elasticsearch 的安装,注意还是要每台主机都要安装。

配置 Elasticsearch

这时我们只是每台主机都安装好了 Elasticsearch,接下来我们还需要将它们联系在一起构成一个集群。 安装完之后,Elasticsearch 的配置文件是 /etc/elasticsearch/elasticsearch.yml,接下来让我们编辑一下配置文件:

  • 集群的名称

通过 cluster.name 可以配置集群的名称,集群是一个整体,因此名称都要一致,所有主机都配置成相同的名称,配置示例:

1
cluster.name: germey-es-clusters
  • 节点的名称

通过 node.name 可以配置每个节点的名称,每个节点都是集群的一部分,每个节点名称都不要相同,可以按照顺序编号,配置示例:

1
node.name: es-node-1

其他的主机可以配置为 es-node-2、es-node-3 等。

  • 是否有资格成为主节点

通过 node.master 可以配置该节点是否有资格成为主节点,如果配置为 true,则主机有资格成为主节点,配置为 false 则主机就不会成为主节点,可以去当数据节点或负载均衡节点。注意这里是有资格成为主节点,不是一定会成为主节点,主节点需要集群经过选举产生。这里我配置所有主机都可以成为主节点,因此都配置为 true,配置示例: node.master: true

  • 是否是数据节点

通过 node.data 可以配置该节点是否为数据节点,如果配置为 true,则主机就会作为数据节点,注意主节点也可以作为数据节点,当 node.master 和 node.data 均为 false,则该主机会作为负载均衡节点。这里我配置所有主机都是数据节点,因此都配置为 true,配置示例: node.data: true

  • 数据和日志路径

通过 path.data 和 path.logs 可以配置 Elasticsearch 的数据存储路径和日志存储路径,可以指定任意位置,这里我指定存储到 1T 硬盘对应的路径下,另外注意一下写入权限问题,配置示例: path.data: /datadrive/elasticsearch/data path.logs: /datadrive/elasticsearch/logs

  • ​设置访问的地址和端口

我们需要设定 Elasticsearch 运行绑定的 Host,默认是无法公开访问的,如果设置为主机的公网 IP 或 0.0.0.0 就是可以公开访问的,这里我们可以都设置为公开访问或者部分主机公开访问,如果是公开访问就配置为:

1
network.host: 0.0.0.0

如果不想被公开访问就不用配置。 另外还可以配置访问的端口,默认是 9200: http.port: 9200

  • 集群地址设置

通过 discovery.zen.ping.unicast.hosts 可以配置集群的主机地址,配置之后集群的主机之间可以自动发现,这里我配置的是内网地址,配置示例:

1
discovery.zen.ping.unicast.hosts["10.0.0.4""10.0.0.5""10.0.0.6""10.0.0.7""10.0.0.8""10.0.0.9""10.0.0.10"]

这里请改成你的主机对应的 IP 地址。

  • 节点数目相关配置

为了防止集群发生“脑裂”,即一个集群分裂成多个,通常需要配置集群最少主节点数目,通常为 (可成为主节点的主机数目 / 2) + 1,例如我这边可以成为主节点的主机数目为 7,那么结果就是 4,配置示例:

1
discovery.zen.minimum_master_nodes: 4

另外还可以配置当最少几个节点回复之后,集群就正常工作,这里我设置为 4,可以酌情修改,配置示例:

1
gateway.recover_after_nodes: 4

其他的暂时先不需要配置,保存即可。注意每台主机都需要配置。

启动 Elasticsearch

配置完成之后就可以在每台主机上分别启动 Elasticsearch 服务了,命令如下:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable elasticsearch.service
sudo systemctl start elasticsearch.service

所有主机都启动之后,我们在任意主机上就可以查看到集群状态了,命令行如下:

1
curl -XGET 'http://localhost:9200/_cluster/state?pretty'

类似的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
    "cluster_name""germey-es-clusters",
    "compressed_size_in_bytes"20799,
    "version"658,
    "state_uuid""a64wCwPnSueKRtVuKx8xRw",
    "master_node""73BQvOC2TpSXcr-IXBcDdg",
    "blocks": {},
    "nodes": {
        "I2M80AP-T7yVP_AZPA0bpA": {
            "name""es-node-1",
            "ephemeral_id""KpCG4jNvTUGKNHNwKKoMrA",
            "transport_address""10.0.0.4:9300",
            "attributes": {
                "ml.machine_memory""7308464128",
                "ml.max_open_jobs""20",
                "xpack.installed""true",
                "ml.enabled""true"
            }
        },
        "73BQvOC2TpSXcr-IXBcDdg": {
            "name""es-node-7",
            "ephemeral_id""Fs9v2XTASnGbqrM8g7IhAQ",
            "transport_address""10.0.0.10:9300",
            "attributes": {
                "ml.machine_memory""14695202816",
                "ml.max_open_jobs""20",
                "xpack.installed""true",
                "ml.enabled""true"
            }
        },
....

可以看到这里输出了集群的相关信息,同时 nodes 字段里面包含了每个节点的详细信息,这样一个基本的集群就构建完成了。

安装 Kibana

接下来我们需要安装一个 Kibana 来帮助可视化管理 Elasticsearch,依然还是通过 Apt 安装,只需要任意一台主机安装即可,因为集群是一体的,所以 Kibana 在任意一台主机只要能连接到 Elasticsearch 即可,安装命令如下:

1
sudo apt-get install kibana

安装之后修改 /etc/kibana/kibana.yml,设置公开访问和绑定的端口:

1
2
server.port: 5601
server.host: "0.0.0.0"

然后启动服务:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable kibana.service
sudo systemctl start kibana.service

这样我们可以在浏览器输入该台主机的 IP 加端口,查看 Kibana 管理页面了,类似如下:

这样 Kibana 可视化管理就配置成功了。

配置认证

现在集群已经初步搭建完成了,但是现在集群很危险,如果我们配置了可公网访问,那么它是可以被任何人操作的,比如储存数据,增删节点等,这是非常危险的,所以我们必须要设置访问权限。 在 Elasticsearch 中,配置认证是通过 X-Pack 插件实现的,幸运的是,我们不需要额外安装了,在 Elasticsearch 6.3.2 版本中,该插件是默认集成到 Elasticsearch 中的,所以我们只需要更改一部分设置就可以了。 首先我们需要升级 License,只有修改了高级版 License 才能使用X-Pack 的权限认证功能。 在 Kibana 中访问 Management -> Elasticsearch -> License Management,点击右侧的升级 License 按钮,可以免费试用 30 天的高级 License,升级完成之后页面会显示如下:

另外还可以使用 API 来更新 License,详情可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/update-license.html。 然后每台主机需要修改 /etc/elasticsearch/elasticsearch.yml 文件,开启 Auth 认证功能:

1
xpack.security.enabledtrue

随后设置 elastic、kibana、logstash_system 三个用户的密码,任意一台主机修改之后,一台修改,多台生效,命令如下:

1
/usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive

运行之后会依次提示设置这三个用户的密码并确认,一共需要输入六次密码,完成之后就成功设置好了密码了。 修改完成之后重启 Elasticsearch 和 Kibana 服务:

1
2
sudo systemctl restart elasticsearch.service
sudo systemctl restart kibana.service

这时再访问 Kibana 就会跳转到登录页面了:

可以使用 elastic 用户登录,它的角色是超级管理员,登录之后就可以重新进入 Kibana 的管理页面。 我们还可以自行修改和添加账户,在 Management -> Security -> User/Roles 里面:

例如这里添加一个超级管理员的账户:

这样以后我们就可以使用新添加的用户来登录和访问了。 另外修改权限认证之后,Elasticsearch 也不能直接访问了,我们也必须输入用户密码才可以访问和调用其 API,保证了安全性。

开启内存锁定

系统默认会进行内存交换,这样会导致 Elasticsearch 的性能变差,我们查看下内存锁定状态,在任意一台主机上的访问 http://ip:port/_nodes?filter_path=**.mlockall: 可以看到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
    "nodes": {
        "73BQvOC2TpSXcr-IXBcDdg": {
            "process": {
                "mlockall"false
            }
        },
        "9tRr4nFDT_2rErLLQB2dIQ": {
            "process": {
                "mlockall"false
            }
        },
        "hskSDv_JQlCUnjp_INI8Kg": {
            "process": {
                "mlockall"false
            }
        },
        "LgaRuqXBTZaBdDGAktFWJA": {
            "process": {
                "mlockall"false
            }
        },
        "ZcsZgowERzuvpqVbYOgOEA": {
            "process": {
                "mlockall"false
            }
        },
        "I2M80AP-T7yVP_AZPA0bpA": {
            "process": {
                "mlockall"false
            }
        },
        "_mSmfhUtQiqhzTKZ7u75Dw": {
            "process": {
                "mlockall"true
            }
        }
    }
}

这代表内存交换没有开启,会影响 Elasticsearch 的性能,所以我们需要开启内存物理地址锁定,每台主机需要修改 /etc/elasticsearch/elasticsearch.yml 文件,修改如下配置:

1
bootstrap.memory_lock: true

但这样修改之后重新启动是会报错的,Elasticsearch 无法正常启动,查看日志,报错如下:

1
2
[1] bootstrap checks failed
[1]: memory locking requested for elasticsearch process but memory is not locked

这里需要修改两个地方,第一个是 /etc/security/limits.conf,添加如下内容:

1
2
3
4
5
6
soft nofile 65536
hard nofile 65536
soft nproc 32000
hard nproc 32000
hard memlock unlimited
soft memlock unlimited

另外还需要修改 /etc/systemd/system.conf,修改如下内容:

1
2
3
DefaultLimitNOFILE=65536
DefaultLimitNPROC=32000
DefaultLimitMEMLOCK=infinity

详细的解释可以参考:https://segmentfault.com/a/1190000014891856。 修改之后重启 Elasticsearch 服务:

1
sudo systemctl restart elasticsearch.service

重新访问刚才的地址,即可发现每台主机的物理地址锁定都被打开了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
    "nodes": {
        "73BQvOC2TpSXcr-IXBcDdg": {
            "process": {
                "mlockall"true
            }
        },
        "9tRr4nFDT_2rErLLQB2dIQ": {
            "process": {
                "mlockall"true
            }
        },
        "hskSDv_JQlCUnjp_INI8Kg": {
            "process": {
                "mlockall"true
            }
        },
        "LgaRuqXBTZaBdDGAktFWJA": {
            "process": {
                "mlockall"true
            }
        },
        "ZcsZgowERzuvpqVbYOgOEA": {
            "process": {
                "mlockall"true
            }
        },
        "I2M80AP-T7yVP_AZPA0bpA": {
            "process": {
                "mlockall"true
            }
        },
        "_mSmfhUtQiqhzTKZ7u75Dw": {
            "process": {
                "mlockall"true
            }
        }
    }
}

这样我们就又解决了性能的问题。

安装分词插件

另外还推荐安装中文分词插件,这样可以对中文进行全文索引,安装命令如下:

1
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v6.3.2/elasticsearch-analysis-ik-6.3.2.zip

安装完之后需要重启 Elasticsearch 服务:

1
sudo systemctl restart elasticsearch.service

主机监控

到此为止,我们的 Elasticsearch 集群就搭建完成了。 最后我们看下 Kibana 的部分功能,看下整个 Elasticsearch 有没有在正常工作。 访问 Kibana,打开 Management -> Elasticsearch ->Index Management,即可看到当前有的一些索引和状态:

打开 Monitoring,可以查看 Elasticsearch 和 Kibana 的状态:

进一步点击 Nodes,可以查看各个节点的状态:

打开任意节点,可以查看当前资源状况变化:

另外还有一些其他的功能如可视化、图表、搜索等等,这里就不再一一列举了,更多功能可以详细了解 Kibana。 以上都是自己在安装过程中趟过的坑,如有疏漏,还望指正。 还有更多的 Elasticsearch 相关的内容可以参考官方文档:https://www.elastic.co/guide/index.html

参考资料

  • https://www.elastic.co/guide/en/x-pack/current/security-getting-started.html
  • https://segmentfault.com/a/1190000014891856
  • https://blog.csdn.net/a19860903/article/details/72467996
  • https://logz.io/blog/elasticsearch-cluster-tutorial/
  • https://es.xiaoleilu.com/020_Distributed_Cluster/30_Scale_more.html
  • https://blog.csdn.net/archer119/article/details/76589189

Python

大家好,我是四毛,下面是我的个人公众号,欢迎关注。有问题的可以私信我,看到就会回复。

更新 2018年08月03日14:39:32

其实可以利用scrapy的扩展展示更多的数据,立个flag,后面更新上来

好,开始今天的文章。 今天主要是来说一下怎么可视化来监控你的爬虫的状态。 相信大家在跑爬虫的过程中,也会好奇自己养的爬虫一分钟可以爬多少页面多大的数据量,当然查询的方式多种多样。今天我来讲一种可视化的方法。

关于爬虫数据在mongodb里的版本我写了一个可以热更新配置的版本,即添加了新的爬虫配置以后,不用重启程序,即可获取刚刚添加的爬虫的状态数据,大家可以通过关注我的公众号以后, 回复“可视化”即可获取脚本地址

1.成品图

这个是监控服务器网速的最后成果,显示的是下载与上传的网速,单位为M。爬虫的原理都是一样的,只不过将数据存到InfluxDB的方式不一样而已, 如下图。

可以实现对爬虫数量,增量,大小,大小增量的实时监控。

2. 环境

  • InfluxDb,是目前比较流行的时间序列数据库;
  • Grafana,一个可视化面板(Dashboard),有着非常漂亮的图表和布局展示,功能齐全的度量仪表盘和图形编辑器,支持Graphite、zabbix、InfluxDB、Prometheus和OpenTSDB作为数据源
  • Ubuntu
  • influxdb(pip install influxdb)

  • Python 2.7

3. 原理

获取要展示的数据,包含当前的时间数据,存到InfluxDb里面,然后再到Grafana里面进行相应的配置即可展示;

4. 安装

4.1 Grafana安装

官方安装指导 安装好以后,打开本地的3000端口,即可进入管理界面,用户名与密码都是admin

4.2 InfulxDb安装

这个安装就网上自己找吧,有很多的配置我都没有配置,就不在这里误人子弟了。

5. InfluxDb简单操作

碰到了数据库,肯定要把增删改查学会了啊, 和sql几乎一样,只有一丝丝的区别,具体操作,大家可以参考官方的文档。

  • influx 进入命令行
  • CREATE DATABASE test 创建数据库
  • show databases 查看数据库
  • use test 使用数据库
  • show series 看表
  • select * from table_test 选择数据
  • DROP MEASUREMENT table_test 删表

6. 存数据

InfluxDb数据库的数据有一定的格式,因为我都是利用python库进行相关操作,所以下面将在python中的格式展示一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
json_body = [
{
"measurement": "crawler",
"time": current_time,
"tags": {
"spider_name": collection_name
},
"fields": {
"count": current_count,
"increase_count": increase_amount,
"size": co_size,
"increase_size": increase_co_size

}
}
]

其中:

  • measurement, 表名
  • time,时间
  • tags,标签
  • fields,字段

可以看到,就是个列表里面,嵌套了一个字典。其中,对于时间字段,有特殊要求,可以参考这里, 下面是python实现方法:

1
2
from datetime import datetime
current_time = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')

所以,到这里,如何将爬虫的相关属性存进去呢?以MongoDB为例

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
mongodb_client = pymongo.MongoClient(uri)
for db_name, collection_name in dbs_and_cos.iteritems():
# 数据库操作
db = mongodb_client[db_name]
co = db[collection_name]
# 集合大小
co_size = round(float(db.command("collstats", collection_name).get('size')) / 1024 / 1024, 2)
# 集合内数据条数
current_count = co.count()

# 初始化,当程序刚执行时,初始量就设置为第一次执行时获取的数据
init_count = _count_dict.get(collection_name, current_count)
# 初始化,当程序刚执行时,初始量就设置为第一次执行时获取的数据大小
init_size = _size_dict.get(collection_name, co_size)

# 条数增长量
increase_amount = current_count - init_count
# 集合大小增长量
increase_co_size = co_size - init_size

current_time = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')

# 赋值
_size_dict[collection_name] = co_size
_count_dict[collection_name] = current_count

json_body = [
{
"measurement": "crawler",
"time": current_time,
"tags": {
"spider_name": collection_name
},
"fields": {
"count": current_count,
"increase_count": increase_amount,
"size": co_size,
"increase_size": increase_co_size

}
}
]
print json_body
client.write_points(json_body)

完整代码,关注上面的公众号,发送“”可视化“”即可获取。 那么现在我们已经往数据里存了数据了,那么接下来要做的就是把存的数据展示出来。

7.展示数据

7.1 配置数据源

以admin登录到Grafana的后台后,我们首先需要配置一下数据源。点击左边栏的最下面的按钮,然后点击DATA SOURCES,这样就可以进入下面的页面: 点击ADD DATA SOURCE,进行配置即可,如下图: 其中,name自行设定;Type 选择InfluxDB;url为默认的http://localhost:8086, 其他的因为我前面没有进行配置,所以默认的即可。然后在InfluxDB Details里的填入Database名,最后点击测试,如果没有报错的话,则可以进入下一步的展示数据了;

7.2 展示数据

点击左边栏的+号,然后点击GRAPH 接着点击下图中的edit进入编辑页面: 从上图中可以发现:

  • 中间板块是最后的数据展示
  • 下面是数据的设置项
  • 右上角是展示时间的设置板块,在这里可以选择要展示多久的数据

7.2.1 配置数据

  1. 在Data Source中选择刚刚在配置数据源的时候配置的NAME字段,而不是database名。
  2. 接着在下面选择要展示的数据。看着就很熟悉是不是,完全是sql语句的可视化。同时,当我们的数据放到相关的字段上的时候,双击,就会把可以选择的项展示出来了,我们要做的就是直接选择即可;
  3. 设置右上角的时间,则可以让数据实时进行更新与展示

因为下面的配置实质就是sql查询语句,所以大家按照自己的需求,进行选择配置即可,当配置完以后,就可以在中间的面板里面看到数据了。

8. 总结

到这里,本篇文章就结束了。其中,对于Grafana的操作我没有介绍的很详细,因为本篇主要讲的是怎么利用这几个工具完成我们的任务。 同时,里面的功能确实很多,还有可以安装的插件。我自己目前还是仅仅对于用到的部分比较了解,所以大家可以查询官方的或者别的教程资料来对Grafana进行更深入的了解,制作出更加好看的可视化作品来。 最后,关注公众号,回复“可视化” 即可获取本文代码哦

Python

什么是 Elasticsearch

想查数据就免不了搜索,搜索就离不开搜索引擎,百度、谷歌都是一个非常庞大复杂的搜索引擎,他们几乎索引了互联网上开放的所有网页和数据。然而对于我们自己的业务数据来说,肯定就没必要用这么复杂的技术了,如果我们想实现自己的搜索引擎,方便存储和检索,Elasticsearch 就是不二选择,它是一个全文搜索引擎,可以快速地储存、搜索和分析海量数据。

为什么要用 Elasticsearch

Elasticsearch 是一个开源的搜索引擎,建立在一个全文搜索引擎库 Apache Lucene™ 基础之上。 那 Lucene 又是什么?Lucene 可能是目前存在的,不论开源还是私有的,拥有最先进,高性能和全功能搜索引擎功能的库,但也仅仅只是一个库。要用上 Lucene,我们需要编写 Java 并引用 Lucene 包才可以,而且我们需要对信息检索有一定程度的理解才能明白 Lucene 是怎么工作的,反正用起来没那么简单。 那么为了解决这个问题,Elasticsearch 就诞生了。Elasticsearch 也是使用 Java 编写的,它的内部使用 Lucene 做索引与搜索,但是它的目标是使全文检索变得简单,相当于 Lucene 的一层封装,它提供了一套简单一致的 RESTful API 来帮助我们实现存储和检索。 所以 Elasticsearch 仅仅就是一个简易版的 Lucene 封装吗?那就大错特错了,Elasticsearch 不仅仅是 Lucene,并且也不仅仅只是一个全文搜索引擎。 它可以被下面这样准确的形容:

  • 一个分布式的实时文档存储,每个字段可以被索引与搜索
  • 一个分布式实时分析搜索引擎
  • 能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据

总之,是一个相当牛逼的搜索引擎,维基百科、Stack Overflow、GitHub 都纷纷采用它来做搜索。

Elasticsearch 的安装

我们可以到 Elasticsearch 的官方网站下载 Elasticsearch:https://www.elastic.co/downloads/elasticsearch,同时官网也附有安装说明。 首先把安装包下载下来并解压,然后运行 bin/elasticsearch(Mac 或 Linux)或者 bin\elasticsearch.bat (Windows) 即可启动 Elasticsearch 了。 我使用的是 Mac,Mac 下个人推荐使用 Homebrew 安装:

1
brew install elasticsearch

Elasticsearch 默认会在 9200 端口上运行,我们打开浏览器访问 http://localhost:9200/ 就可以看到类似内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "name" : "atntrTf",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "e64hkjGtTp6_G2h1Xxdv5g",
  "version" : {
    "number""6.2.4",
    "build_hash""ccec39f",
    "build_date""2018-04-12T20:37:28.497551Z",
    "build_snapshot"false,
    "lucene_version""7.2.1",
    "minimum_wire_compatibility_version""5.6.0",
    "minimum_index_compatibility_version""5.0.0"
  },
  "tagline" : "You Know, for Search"
}

如果看到这个内容,就说明 Elasticsearch 安装并启动成功了,这里显示我的 Elasticsearch 版本是 6.2.4 版本,版本很重要,以后安装一些插件都要做到版本对应才可以。 接下来我们来了解一下 Elasticsearch 的基本概念以及和 Python 的对接。

Elasticsearch 相关概念

在 Elasticsearch 中有几个基本的概念,如节点、索引、文档等等,下面来分别说明一下,理解了这些概念对熟悉 Elasticsearch 是非常有帮助的。

Node 和 Cluster

Elasticsearch 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elasticsearch 实例。 单个 Elasticsearch 实例称为一个节点(Node)。一组节点构成一个集群(Cluster)。

Index

Elasticsearch 会索引所有字段,经过处理后写入一个反向索引(Inverted Index)。查找数据的时候,直接查找该索引。 所以,Elasticsearch 数据管理的顶层单位就叫做 Index(索引),其实就相当于 MySQL、MongoDB 等里面的数据库的概念。另外值得注意的是,每个 Index (即数据库)的名字必须是小写。

Document

Index 里面单条的记录称为 Document(文档)。许多条 Document 构成了一个 Index。 Document 使用 JSON 格式表示,下面是一个例子。 同一个 Index 里面的 Document,不要求有相同的结构(scheme),但是最好保持相同,这样有利于提高搜索效率。

Type

Document 可以分组,比如 weather 这个 Index 里面,可以按城市分组(北京和上海),也可以按气候分组(晴天和雨天)。这种分组就叫做 Type,它是虚拟的逻辑分组,用来过滤 Document,类似 MySQL 中的数据表,MongoDB 中的 Collection。 不同的 Type 应该有相似的结构(Schema),举例来说,id 字段不能在这个组是字符串,在另一个组是数值。这是与关系型数据库的表的一个区别。性质完全不同的数据(比如 products 和 logs)应该存成两个 Index,而不是一个 Index 里面的两个 Type(虽然可以做到)。 根据规划,Elastic 6.x 版只允许每个 Index 包含一个 Type,7.x 版将会彻底移除 Type。

Fields

即字段,每个 Document 都类似一个 JSON 结构,它包含了许多字段,每个字段都有其对应的值,多个字段组成了一个 Document,其实就可以类比 MySQL 数据表中的字段。 在 Elasticsearch 中,文档归属于一种类型(Type),而这些类型存在于索引(Index)中,我们可以画一些简单的对比图来类比传统关系型数据库:

1
2
Relational DB -> Databases -> Tables -> Rows -> Columns
Elasticsearch -> Indices   -> Types  -> Documents -> Fields

以上就是 Elasticsearch 里面的一些基本概念,通过和关系性数据库的对比更加有助于理解。

Python 对接 Elasticsearch

Elasticsearch 实际上提供了一系列 Restful API 来进行存取和查询操作,我们可以使用 curl 等命令来进行操作,但毕竟命令行模式没那么方便,所以这里我们就直接介绍利用 Python 来对接 Elasticsearch 的相关方法。 Python 中对接 Elasticsearch 使用的就是一个同名的库,安装方式非常简单:

1
pip3 install elasticsearch

官方文档是:https://elasticsearch-py.readthedocs.io/,所有的用法都可以在里面查到,文章后面的内容也是基于官方文档来的。

创建 Index

我们先来看下怎样创建一个索引(Index),这里我们创建一个名为 news 的索引:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.indices.create(index='news'ignore=400)
print(result)

如果创建成功,会返回如下结果:

1
{'acknowledged': True'shards_acknowledged': True'index': 'news'}

返回结果是 JSON 格式,其中的 acknowledged 字段表示创建操作执行成功。 但这时如果我们再把代码执行一次的话,就会返回如下结果:

1
{'error': {'root_cause': [{'type''resource_already_exists_exception''reason''index [news/QM6yz2W8QE-bflKhc5oThw] already exists', 'index_uuid''QM6yz2W8QE-bflKhc5oThw', 'index''news'}], 'type''resource_already_exists_exception''reason''index [news/QM6yz2W8QE-bflKhc5oThw] already exists', 'index_uuid''QM6yz2W8QE-bflKhc5oThw', 'index''news'}, 'status'400}

它提示创建失败,status 状态码是 400,错误原因是 Index 已经存在了。 注意这里我们的代码里面使用了 ignore 参数为 400,这说明如果返回结果是 400 的话,就忽略这个错误不会报错,程序不会执行抛出异常。 假如我们不加 ignore 这个参数的话:

1
2
3
es = Elasticsearch()
result = es.indices.create(index='news')
print(result)

再次执行就会报错了:

1
2
raise HTTP_EXCEPTIONS.get(status_code, TransportError)(status_code, error_message, additional_info)
elasticsearch.exceptions.RequestError: TransportError(400, 'resource_already_exists_exception', 'index [news/QM6yz2W8QE-bflKhc5oThwalready exists')

这样程序的执行就会出现问题,所以说,我们需要善用 ignore 参数,把一些意外情况排除,这样可以保证程序的正常执行而不会中断。

删除 Index

删除 Index 也是类似的,代码如下:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.indices.delete(index='news', ignore=[400, 404])
print(result)

这里也是使用了 ignore 参数,来忽略 Index 不存在而删除失败导致程序中断的问题。 如果删除成功,会输出如下结果:

1
{'acknowledged': True}

如果 Index 已经被删除,再执行删除则会输出如下结果:

1
{'error': {'root_cause': [{'type''index_not_found_exception''reason''no such index', 'resource.type': 'index_or_alias''resource.id': 'news''index_uuid''_na_''index''news'}], 'type''index_not_found_exception''reason''no such index', 'resource.type': 'index_or_alias''resource.id': 'news''index_uuid''_na_''index''news'}, 'status'404}

这个结果表明当前 Index 不存在,删除失败,返回的结果同样是 JSON,状态码是 400,但是由于我们添加了 ignore 参数,忽略了 400 状态码,因此程序正常执行输出 JSON 结果,而不是抛出异常。

插入数据

Elasticsearch 就像 MongoDB 一样,在插入数据的时候可以直接插入结构化字典数据,插入数据可以调用 create() 方法,例如这里我们插入一条新闻数据:

1
2
3
4
5
6
7
8
from elasticsearch import Elasticsearch

es = Elasticsearch()
es.indices.create(index='news'ignore=400)

data = {'title''美国留给伊拉克的是个烂摊子吗''url''http://view.news.qq.com/zt2011/usa_iraq/index.htm'}
result = es.create(index='news'doc_type='politics'id=1, body=data)
print(result)

这里我们首先声明了一条新闻数据,包括标题和链接,然后通过调用 create() 方法插入了这条数据,在调用 create() 方法时,我们传入了四个参数,index 参数代表了索引名称,doc_type 代表了文档类型,body 则代表了文档具体内容,id 则是数据的唯一标识 ID。 运行结果如下:

1
{'_index': 'news', '_type': 'politics', '_id': '1', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 1}

结果中 result 字段为 created,代表该数据插入成功。 另外其实我们也可以使用 index() 方法来插入数据,但与 create() 不同的是,create() 方法需要我们指定 id 字段来唯一标识该条数据,而 index() 方法则不需要,如果不指定 id,会自动生成一个 id,调用 index() 方法的写法如下:

1
es.index(index='news'doc_type='politics'body=data)

create() 方法内部其实也是调用了 index() 方法,是对 index() 方法的封装。

更新数据

更新数据也非常简单,我们同样需要指定数据的 id 和内容,调用 update() 方法即可,代码如下:

1
2
3
4
5
6
7
8
9
10
from elasticsearch import Elasticsearch

es = Elasticsearch()
data = {
    'title''美国留给伊拉克的是个烂摊子吗',
    'url''http://view.news.qq.com/zt2011/usa_iraq/index.htm',
    'date''2011-12-16'
}
result = es.update(index='news'doc_type='politics'body=data, id=1)
print(result)

这里我们为数据增加了一个日期字段,然后调用了 update() 方法,结果如下:

1
{'_index': 'news', '_type': 'politics', '_id': '1', '_version': 2, 'result': 'updated', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 1, '_primary_term': 1}

可以看到返回结果中,result 字段为 updated,即表示更新成功,另外我们还注意到有一个字段 _version,这代表更新后的版本号数,2 代表这是第二个版本,因为之前已经插入过一次数据,所以第一次插入的数据是版本 1,可以参见上例的运行结果,这次更新之后版本号就变成了 2,以后每更新一次,版本号都会加 1。 另外更新操作其实利用 index() 方法同样可以做到,写法如下:

1
es.index(index='news'doc_type='politics'body=data, id=1)

可以看到,index() 方法可以代替我们完成两个操作,如果数据不存在,那就执行插入操作,如果已经存在,那就执行更新操作,非常方便。

删除数据

如果想删除一条数据可以调用 delete() 方法,指定需要删除的数据 id 即可,写法如下:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.delete(index='news'doc_type='politics'id=1)
print(result)

运行结果如下:

1
{'_index': 'news', '_type': 'politics', '_id': '1', '_version': 3, 'result': 'deleted', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 2, '_primary_term': 1}

可以看到运行结果中 result 字段为 deleted,代表删除成功,_version 变成了 3,又增加了 1。

查询数据

上面的几个操作都是非常简单的操作,普通的数据库如 MongoDB 都是可以完成的,看起来并没有什么了不起的,Elasticsearch 更特殊的地方在于其异常强大的检索功能。 对于中文来说,我们需要安装一个分词插件,这里使用的是 elasticsearch-analysis-ik,GitHub 链接为:https://github.com/medcl/elasticsearch-analysis-ik,这里我们使用 Elasticsearch 的另一个命令行工具 elasticsearch-plugin 来安装,这里安装的版本是 6.2.4,请确保和 Elasticsearch 的版本对应起来,命令如下:

1
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v6.2.4/elasticsearch-analysis-ik-6.2.4.zip

这里的版本号请替换成你的 Elasticsearch 的版本号。 安装之后重新启动 Elasticsearch 就可以了,它会自动加载安装好的插件。 首先我们新建一个索引并指定需要分词的字段,代码如下:

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

es = Elasticsearch()
mapping = {
    'properties': {
        'title': {
            'type''text',
            'analyzer''ik_max_word',
            'search_analyzer''ik_max_word'
        }
    }
}
es.indices.delete(index='news', ignore=[400, 404])
es.indices.create(index='news'ignore=400)
result = es.indices.put_mapping(index='news'doc_type='politics'body=mapping)
print(result)

这里我们先将之前的索引删除了,然后新建了一个索引,然后更新了它的 mapping 信息,mapping 信息中指定了分词的字段,指定了字段的类型 type 为 text,分词器 analyzer 和 搜索分词器 search_analyzer 为 ik_max_word,即使用我们刚才安装的中文分词插件。如果不指定的话则使用默认的英文分词器。 接下来我们插入几条新的数据:

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
datas = [
    {
        'title': '美国留给伊拉克的是个烂摊子吗',
        'url': 'http://view.news.qq.com/zt2011/usa_iraq/index.htm',
        'date': '2011-12-16'
    },
    {
        'title': '公安部:各地校车将享最高路权',
        'url': 'http://www.chinanews.com/gn/2011/12-16/3536077.shtml',
        'date': '2011-12-16'
    },
    {
        'title': '中韩渔警冲突调查:韩警平均每天扣1艘中国渔船',
        'url': 'https://news.qq.com/a/20111216/001044.htm',
        'date': '2011-12-17'
    },
    {
        'title': '中国驻洛杉矶领事馆遭亚裔男子枪击 嫌犯已自首',
        'url': 'http://news.ifeng.com/world/detail_2011_12/16/11372558_0.shtml',
        'date': '2011-12-18'
    }
]

for data in datas:
    es.index(index='news', doc_type='politics', body=data)

这里我们指定了四条数据,都带有 title、url、date 字段,然后通过 index() 方法将其插入 Elasticsearch 中,索引名称为 news,类型为 politics。 接下来我们根据关键词查询一下相关内容:

1
2
result = es.search(index='news'doc_type='politics')
print(result)

可以看到查询出了所有插入的四条数据:

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
{
  "took"0,
  "timed_out"false,
  "_shards": {
    "total"5,
    "successful"5,
    "skipped"0,
    "failed"0
  },
  "hits": {
    "total"4,
    "max_score"1.0,
    "hits": [
      {
        "_index""news",
        "_type""politics",
        "_id""c05G9mQBD9BuE5fdHOUT",
        "_score"1.0,
        "_source": {
          "title""美国留给伊拉克的是个烂摊子吗",
          "url""http://view.news.qq.com/zt2011/usa_iraq/index.htm",
          "date""2011-12-16"
        }
      },
      {
        "_index""news",
        "_type""politics",
        "_id""dk5G9mQBD9BuE5fdHOUm",
        "_score"1.0,
        "_source": {
          "title""中国驻洛杉矶领事馆遭亚裔男子枪击,嫌犯已自首",
          "url""http://news.ifeng.com/world/detail_2011_12/16/11372558_0.shtml",
          "date""2011-12-18"
        }
      },
      {
        "_index""news",
        "_type""politics",
        "_id""dU5G9mQBD9BuE5fdHOUj",
        "_score"1.0,
        "_source": {
          "title""中韩渔警冲突调查:韩警平均每天扣1艘中国渔船",
          "url""https://news.qq.com/a/20111216/001044.htm",
          "date""2011-12-17"
        }
      },
      {
        "_index""news",
        "_type""politics",
        "_id""dE5G9mQBD9BuE5fdHOUf",
        "_score"1.0,
        "_source": {
          "title""公安部:各地校车将享最高路权",
          "url""http://www.chinanews.com/gn/2011/12-16/3536077.shtml",
          "date""2011-12-16"
        }
      }
    ]
  }
}

可以看到返回结果会出现在 hits 字段里面,然后其中有 total 字段标明了查询的结果条目数,还有 max_score 代表了最大匹配分数。 另外我们还可以进行全文检索,这才是体现 Elasticsearch 搜索引擎特性的地方:

1
2
3
4
5
6
7
8
9
10
11
dsl = {
    'query': {
        'match': {
            'title''中国 领事馆'
        }
    }
}

es = Elasticsearch()
result = es.search(index='news'doc_type='politics'body=dsl)
print(json.dumps(result, indent=2, ensure_ascii=False))

这里我们使用 Elasticsearch 支持的 DSL 语句来进行查询,使用 match 指定全文检索,检索的字段是 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
36
37
38
{
  "took"1,
  "timed_out"false,
  "_shards": {
    "total"5,
    "successful"5,
    "skipped"0,
    "failed"0
  },
  "hits": {
    "total"2,
    "max_score"2.546152,
    "hits": [
      {
        "_index""news",
        "_type""politics",
        "_id""dk5G9mQBD9BuE5fdHOUm",
        "_score"2.546152,
        "_source": {
          "title""中国驻洛杉矶领事馆遭亚裔男子枪击,嫌犯已自首",
          "url""http://news.ifeng.com/world/detail_2011_12/16/11372558_0.shtml",
          "date""2011-12-18"
        }
      },
      {
        "_index""news",
        "_type""politics",
        "_id""dU5G9mQBD9BuE5fdHOUj",
        "_score"0.2876821,
        "_source": {
          "title""中韩渔警冲突调查:韩警平均每天扣1艘中国渔船",
          "url""https://news.qq.com/a/20111216/001044.htm",
          "date""2011-12-17"
        }
      }
    ]
  }
}

这里我们看到匹配的结果有两条,第一条的分数为 2.54,第二条的分数为 0.28,这是因为第一条匹配的数据中含有“中国”和“领事馆”两个词,第二条匹配的数据中不包含“领事馆”,但是包含了“中国”这个词,所以也被检索出来了,但是分数比较低。 因此可以看出,检索时会对对应的字段全文检索,结果还会按照检索关键词的相关性进行排序,这就是一个基本的搜索引擎雏形。 另外 Elasticsearch 还支持非常多的查询方式,详情可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/6.3/query-dsl.html 以上便是对 Elasticsearch 的基本介绍以及 Python 操作 Elasticsearch 的基本用法,但这仅仅是 Elasticsearch 的基本功能,它还有更多强大的功能等待着我们的探索,后面会继续更新,敬请期待。 本节代码:https://github.com/Germey/ElasticSearch

资料推荐

另外推荐几个不错的学习站点:

  • Elasticsearch 权威指南:https://es.xiaoleilu.com/index.html
  • 全文搜索引擎 Elasticsearch 入门教程:http://www.ruanyifeng.com/blog/2017/08/elasticsearch.html
  • Elastic 中文社区:https://www.elasticsearch.cn/

参考资料

  • https://es.xiaoleilu.com/index.html
  • https://blog.csdn.net/y472360651/article/details/76468327
  • https://elasticsearch-py.readthedocs.io/en/master/
  • https://es.xiaoleilu.com/010_Intro/10_Installing_ES.html
  • https://github.com/medcl/elasticsearch-analysis-ik

Python

大家好, 我不是崔老师,我是四毛,下面是我的个人公众号,欢迎大家关注。

好久没有写东西了,一直都记录在了自己的笔记上,这一篇是关于glom的一个介绍与初步使用,后期会将里面的各种API再给大家介绍下,同时,最近在搞爬虫的实时数据监控,也挺有意思,后面会和大家分享,敬请期待。

猛然发现,英语水平巅峰就在高考那一天。 因为是边看,边练习,然后翻译,所以个人理解可能有偏差,有错误的地方,请大家指正。 首先,这个库是用来处理一些嵌套的数据的,作者也在PyCon 2018上做了个分享,老美的PyCon还是有点质量的,不像国内的,搞的什么玩意。 视频地址:https://www.youtube.com/watch?v=bTAFl8P2DkE&t=18m07s

更新: 2018年7月28日10:32:08

经过咨询库的作者,在最后留的那个问题的准确解法如下:

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

target = {
'data': {
'name': 'just_test',
'likes': [{'ball': 'basketball'},
{'ball': 'football'},
{'water': 'swim'}]
}
}

spec = {
'name' : ('data.name'),
'likes' : ('data', 'likes', [glom.Coalesce('ball', 'water')])
}

print glom.glom(target, spec)
####
{'name': 'just_test', 'likes': ['basketball', 'football', 'swim']}

非常棒,准确来说就是得灵活运用Coalesce方法啊,不能太死板。非常Pythonic。 另附网址,作者有个很搞笑little four hair ,哈哈哈哈 Issue地址

1. 官方文档地址

文档地址

2. 安装方法

1
pip install glom

3. 正式开始

glom,官方的说法是用PYTHONIC的方式来处理内嵌的数据。对于现实世界中的数据处理更加给力,现实世界中的数据,我的理解就是AJAX越来越流行了,处理这类数据会越来越频繁。有如下特点:

  • 对于嵌套数据结构的基于路径式的访问
  • 可读,有意义的错误消息
  • 声明性数据转换,使用轻量级,Pythonic规范
  • 内置数据探索和调试功能

3.1 原始处理嵌套数据

下面的脚本包导入

1
from glom import glom

下面的data就是个简单的嵌套数据,一般都可以用下面几种方法进行处理

1
2
3
4
data = {'a': {'b': {'c': 'd'}}}
data['a']['b']['c']
data.get('a').get('b').get('c')
data.get('a', {}).get('b',{}).get('c')

但是当我们的数据改变成下面的这样时:

1
2
3
4
5
data2 = {'a': {'b': None}}
data2['a']['b']['c']
Traceback (most recent call last):
...
TypeError: 'NoneType' object has no attribute '__getitem__'

会报错,而且由于是嵌套数据,从错误信息里我们只知道有个None值,但是到底谁是呢,是a,是b呢,反正肯定不是我们的朋友小哪吒。

3.2 glom出场

那么glom怎么处理上面的数据呢? 如其所言,路径式:

1
2
data = {'a': {'b': {'c': 'd'}}}
print glom(data, 'a.b.c') # d

看起来还是很优雅, 很Pythonic。

1
2
data2 = {'a': {'b': None}}
glom(data2, 'a.b.c')

错误信息如下:

1
glom.core.PathAccessError: could not access 'c', part 2 of Path('a', 'b', 'c'), got error: AttributeError("'NoneType' object has no attribute 'c'",)

很明显,这个错误就很直观。 难道仅仅只有这个?当然不是

3.2.1 Going Beyond Access

上面的是原标题,我的理解是不仅仅获取数据,还有别的呢。 首先,介绍两个基本的术语

1
2
target 目标数据,可以是字典,列表,或其他任意的对象
spec 我们想要的输出格式 【specifications】, 定义你自己所需要的格式

现在让我们跟随宇航员的脚步,探索太阳系吧。

  • 获取某个行星的名字:
1
2
3
4
5
target = {'galaxy': {'system': {'planet': 'jupiter'}}}
# 这个格式就是需要个字段值,所以输出的就是个字段值
spec = 'galaxy.system.planet'
glom(target, spec)
# 'jupyter'
  • 现在,宇航员们想把行星的名字放进一个列表中,数据是这样:
1
target = {'system': {'planets': [{'name': 'earth'}, {'name': 'jupiter'}]}}
  • 通常,处理这样的话,都要写个循环,或者搞个列表解析式,那么glom怎么处理呢?
1
2
3
glom(target, ('system.planets', ['name']))
print glom(target, spec)
# ['earth', 'jupiter']

是不是很简单。那么现在新需求又来了,宇航员想得到下面这个数据里面的行星的卫星的数:

1
2
target = {'system': {'planets': [{'name': 'earth', 'moons': 1},
{'name': 'jupiter', 'moons': 69}]}}
  • glom解决方法:
1
2
3
4
5
# 自定义的格式
spec = {'names': ('system.planets', ['name']),
'moons': ('system.planets', ['moons'])}
print glom(target, spec)
# {'moons': [1, 69], 'names': ['earth', 'jupiter']}

3.2.2 Changing Requirements

Coalesce 是glom定义的一种结构,允许我们对于spec中的子spec进行进一步的处理,你只要在子spec中将可能存在的值定义好就行了,听起来有点绕,现在来梳理一下。

  • 首先,子spec是什么?
1
2
3
spec = {'names': ('system.planets', ['name']),
'moons': ('system.planets', ['moons'])}
# 以这个为例,这里面的system.planets就是个子spec
  • 然后,使用其解析数据:
1
2
3
4
5
6
7
8
9
target = {'system': {
'planets': [{'name': 'earth', 'moons': 1}, {'name': 'jupiter', 'moons': 69}],
}
}
spec = {'names': (Coalesce('system.planets', 'system.dwarf_planets'), ['name']),
'moons': (Coalesce('system.planets', 'system.dwarf_planets'), ['moons'])}

print glom(target, spec)
# {'moons': [1, 69], 'names': ['earth', 'jupiter']}
  • 接着当我们的数据变成了这个以后
1
2
3
4
5
6
target = {'system': {'dwarf_planets': [{'name': 'pluto', 'moons': 5},
{'name': 'ceres', 'moons': 0}]}}
spec = {'names': (Coalesce('system.planets', 'system.dwarf_planets'), ['name']),
'moons': (Coalesce('system.planets', 'system.dwarf_planets'), ['moons'])}
print glom(target, spec)
# {'moons': [5, 0], 'names': ['pluto', 'ceres']}

可以看到,依然可以使用相同的spec来解析不同的目标数据。 有意思的是,你可以在target里面同时写入plantes和dwarf_plants数据试试看,会返回什么数据。 【这里应该是个惰性的匹配,只要匹配到一个,后面的就不再去匹配了】

3.2.3 True Python Native

真正的原生python 在glom里面,你可以传值给python里面的任意的函数 举例:

  • 求和
1
2
3
4
5
6
target = {'system': {'planets': [{'name': 'earth', 'moons': 1},
{'name': 'jupiter', 'moons': 69}]}}

print glom(target, {'moon_count': ('system.planets', ['moons'], sum)})

# {'moon_count': 70}

原教程这里还有个案例,但是我还没有理解好,就不写出来了,大家可以点击链接自己看一下。

4. 结论

下一节,为大家带来其中一些重要的函数。 最后,在用的过程中,一直有个疑问,数据如下:

1
2
3
4
5
6
7
8
target = {
'data': {
'name': 'just_test',
'likes': [{'ball': 'basketball'},
{'ball': 'football'},
{'water': 'swim'}]
}
}

现在,我想返回的数据格式为:

1
{'name': 'just_for_test', 'likes': ['basketball', 'football', 'water']}

一开始我以为可以这么用:

1
2
3
4
spec = {
'name': ('data.name'),
'likes': ('data.likes', ['ball', 'water'] ),
}

但是不行,这样会报错。后来用了另外的方法:

1
2
3
4
5
6
7
spec = {
'name': ('data.name'),
'likes': ('data.likes', [lambda x: x.values()[0] if 'ball' or 'water' in x.keys() else ''] ),
}

print glom(target, spec)
# {'name': 'just_test', 'likes': ['basketball', 'football', 'swim']}

这样感觉很不爽啊,还望会的同学不吝赐教啊。

Python

开门见山

话不多说了!第三波送书活动来了!这次送 20 本签名版《Python3网络爬虫开发实战》。 本书目前上市三个月已经重印 6 次,上市三个月以来长期位居京东计算机类新书榜第一位(现已不算新书),目前在豆瓣的评分是 9.2 分。

书籍介绍

本书《Python3网络爬虫开发实战》全面介绍了利用 Python3 开发网络爬虫的知识,书中首先详细介绍了各种类型的环境配置过程和爬虫基础知识,还讨论了 urllib、requests 等请求库和 Beautiful Soup、XPath、pyquery 等解析库以及文本和各类数据库的存储方法,另外本书通过多个真实新鲜案例介绍了分析 Ajax 进行数据爬取,Selenium 和 Splash 进行动态网站爬取的过程,接着又分享了一些切实可行的爬虫技巧,比如使用代理爬取和维护动态代理池的方法、ADSL 拨号代理的使用、各类验证码(图形、极验、点触、宫格等)的破解方法、模拟登录网站爬取的方法及 Cookies 池的维护等等。 此外,本书的内容还远远不止这些,作者还结合移动互联网的特点探讨了使用 Charles、mitmdump、Appium 等多种工具实现 App 抓包分析、加密参数接口爬取、微信朋友圈爬取的方法。此外本书还详细介绍了 pyspider 框架、Scrapy 框架的使用和分布式爬虫的知识,另外对于优化及部署工作,本书还包括 Bloom Filter 效率优化、Docker 和 Scrapyd 爬虫部署、分布式爬虫管理框架Gerapy 的分享。 全书共 604 页,足足两斤重呢~ 定价为 99 元!

作者介绍

看书就先看看谁写的嘛,我们来了解一下~ 崔庆才,静觅博客博主(https://cuiqingcai.com),博客 Python 爬虫博文阅读量已过千万,北京航空航天大学硕士,天善智能、网易云课堂讲师,微软小冰大数据工程师,有多个大型分布式爬虫项目经验,乐于技术分享,文章通俗易懂 ^^ 附皂片一张 ~(@^^@)~

图文介绍

呕心沥血设计的宣传图也得放一下~

专家评论

书是好是坏,得让专家看评一评呀,那么下面就是几位专家的精彩评论,快来看看吧~ 在互联网软件开发工程师的分类中,爬虫工程师是非常重要的。爬虫工作往往是一个公司核心业务开展的基础,数据抓取下来,才有后续的加工处理和最终展现。此时数据的抓取规模、稳定性、实时性、准确性就显得非常重要。早期的互联网充分开放互联,数据获取的难度很小。随着各大公司对数据资产日益看重,反爬水平也在不断提高,各种新技术不断给爬虫软件提出新的课题。本书作者对爬虫的各个领域都有深刻研究,书中探讨了Ajax数据的抓取、动态渲染页面的抓取、验证码识别、模拟登录等高级话题,同时也结合移动互联网的特点探讨了App的抓取等。更重要的是,本书提供了大量源码,可以帮助读者更好地理解相关内容。强烈推荐给各位技术爱好者阅读!

——梁斌,八友科技总经理

数据既是当今大数据分析的前提,也是各种人工智能应用场景的基础。得数据者得天下,会爬虫者走遍天下也不怕!一册在手,让小白到老司机都能有所收获!

——李舟军,北京航空航天大学教授,博士生导师

本书从爬虫入门到分布式抓取,详细介绍了爬虫技术的各个要点,并针对不同的场景提出了对应的解决方案。另外,书中通过大量的实例来帮助读者更好地学习爬虫技术,通俗易懂,干货满满。强烈推荐给大家!

——宋睿华,微软小冰首席科学家

有人说中国互联网的带宽全给各种爬虫占据了,这说明网络爬虫的重要性以及中国互联网数据封闭垄断的现状。爬是一种能力,爬是为了不爬。

——施水才,北京拓尔思信息技术股份有限公司总裁

全书目录

书的目录也有~ 看这里!

  • 1-开发环境配置
  • 1.1-Python3的安装
  • 1.2-请求库的安装
  • 1.3-解析库的安装
  • 1.4-数据库的安装
  • 1.5-存储库的安装
  • 1.6-Web库的安装
  • 1.7-App爬取相关库的安装
  • 1.8-爬虫框架的安装
  • 1.9-部署相关库的安装
  • 2-爬虫基础
  • 2.1-HTTP基本原理
  • 2.2-网页基础
  • 2.3-爬虫的基本原理
  • 2.4-会话和Cookies
  • 2.5-代理的基本原理
  • 3-基本库的使用
  • 3.1-使用urllib
  • 3.1.1-发送请求
  • 3.1.2-处理异常
  • 3.1.3-解析链接
  • 3.1.4-分析Robots协议
  • 3.2-使用requests
  • 3.2.1-基本用法
  • 3.2.2-高级用法
  • 3.3-正则表达式
  • 3.4-抓取猫眼电影排行
  • 4-解析库的使用
  • 4.1-使用XPath
  • 4.2-使用Beautiful Soup
  • 4.3-使用pyquery
  • 5-数据存储
  • 5.1-文件存储
  • 5.1.1-TXT文本存储
  • 5.1.2-JSON文件存储
  • 5.1.3-CSV文件存储
  • 5.2-关系型数据库存储
  • 5.2.1-MySQL存储
  • 5.3-非关系型数据库存储
  • 5.3.1-MongoDB存储
  • 5.3.2-Redis存储
  • 6-Ajax数据爬取
  • 6.1-什么是Ajax
  • 6.2-Ajax分析方法
  • 6.3-Ajax结果提取
  • 6.4-分析Ajax爬取今日头条街拍美图
  • 7-动态渲染页面爬取
  • 7.1-Selenium的使用
  • 7.2-Splash的使用
  • 7.3-Splash负载均衡配置
  • 7.4-使用Selenium爬取淘宝商品
  • 8-验证码的识别
  • 8.1-图形验证码的识别
  • 8.2-极验滑动验证码的识别
  • 8.3-点触验证码的识别
  • 8.4-微博宫格验证码的识别
  • 9-代理的使用
  • 9.1-代理的设置
  • 9.2-代理池的维护
  • 9.3-付费代理的使用
  • 9.4-ADSL拨号代理
  • 9.5-使用代理爬取微信公众号文章
  • 10-模拟登录
  • 10.1-模拟登录并爬取GitHub
  • 10.2-Cookies池的搭建
  • 11-App的爬取
  • 11.1-Charles的使用
  • 11.2-mitmproxy的使用
  • 11.3-mitmdump爬取“得到”App电子书信息
  • 11.4-Appium的基本使用
  • 11.5-Appium爬取微信朋友圈
  • 11.6-Appium+mitmdump爬取京东商品
  • 12-pyspider框架的使用
  • 12.1-pyspider框架介绍
  • 12.2-pyspider的基本使用
  • 12.3-pyspider用法详解
  • 13-Scrapy框架的使用
  • 13.1-Scrapy框架介绍
  • 13.2-Scrapy入门
  • 13.3-Selector的用法
  • 13.4-Spider的用法
  • 13.5-Downloader Middleware的用法
  • 13.6-Spider Middleware的用法
  • 13.7-Item Pipeline的用法
  • 13.8-Scrapy对接Selenium
  • 13.9-Scrapy对接Splash
  • 13.10-Scrapy通用爬虫
  • 13.11-Scrapyrt的使用
  • 13.12-Scrapy对接Docker
  • 13.13-Scrapy爬取新浪微博
  • 14-分布式爬虫
  • 14.1-分布式爬虫原理
  • 14.2-Scrapy-Redis源码解析
  • 14.3-Scrapy分布式实现
  • 14.4-Bloom Filter的对接
  • 15-分布式爬虫的部署
  • 15.1-Scrapyd分布式部署
  • 15.2-Scrapyd-Client的使用
  • 15.3-Scrapyd对接Docker
  • 15.4-Scrapyd批量部署
  • 15.5-Gerapy分布式管理

购买链接

想必很多小伙伴已经等了很久了,之前预售那么久也一直迟迟没有货,发售就有不少网店又售空了,不过现在起不用担心了!

书籍现已在京东、天猫、当当等网店上架并全面供应啦,复制链接到浏览器打开或扫描二维码打开即可购买了!

京东商城

https://item.jd.com/12333540.html

天猫商城

https://detail.tmall.com/item.htm?id=566699703917

当当网

http://product.dangdang.com/25249602.html

欢迎大家购买,谢谢支持!O(∩_∩)O

免费预览

不放心?想先看看有些啥,没问题!看这里: 免费章节试读: https://cuiqingcai.com/5052.html 将一直免费开放前7章节,欢迎大家试读! 好了,接下来就是我们的福利环节啦~

福利一:签名书!!!

恭喜你看到这里了!那么接下来的福利时间就到了!后面还有两个福利不容错过~ 赠书活动第三波来袭,送 20 本作者亲笔签名书籍!!! 活动流程(重要,请一定认真阅读): 公众号进击的Coder回复 “赠书” 获取序列码参与活动,2018.7.24 22:00 截止,逾期参与无效,请记住您的序列码,这是您的唯一标识。 您可以转发活动页面邀请好友帮忙积攒人气值,最终取人气值前 20 位赠书,截止日期 2018.7.24 22:00,该时刻人气值前 20 位的朋友每人会获得签名书一本。 最终赠书名单会在微信公众号进击的Coder公布,届时请关注公众号消息!

福利二:独家优惠!!!

等等,你以为这就是全部福利吗?当然不是!除了抽奖送书,我们还拿到了拨号VPS知名品牌云立方的独家优惠,在公众号(进击的Coder )中回复:“优惠券”,即可免费领取云立方50元主机优惠券,数量有限,先到先得!优惠券可在云立方官网(www.yunlifang.cn)购买动态IP拨号VPS时抵扣现金,有了它,爬虫代理易如反掌! 你问我动态拨号VPS能做什么?应该怎么用在爬虫里?来这里了解一下: 轻松获得海量稳定代理!ADSL拨号代理的搭建

福利三:视频课程!!!

当然除了书籍,也有配套的视频课程,目前半价促销中,作者同样是崔庆才,二者结合学习效果更佳!限时优惠折扣中!扫描下图中二维码即可了解详情! 最后也是最重要的就是参与活动的地址了!!!快来扫码回复领取属于你的福利吧!!!

特别致谢

最后特别感谢云立方、天善智能对本活动的大力支持!

Python

1. 前言

在执行一些 IO 密集型任务的时候,程序常常会因为等待 IO 而阻塞。比如在网络爬虫中,如果我们使用 requests 库来进行请求的话,如果网站响应速度过慢,程序一直在等待网站响应,最后导致其爬取效率是非常非常低的。 为了解决这类问题,本文就来探讨一下 Python 中异步协程来加速的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。 注:本文协程使用 async/await 来实现,需要 Python 3.5 及以上版本。

2. 基本了解

在了解异步协程之前,我们首先得了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。

2.1 阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。 常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。

2.2 非阻塞

程序在等待某操作过程中,自身不被阻塞,可以继续运行干别的事情,则称该程序在该操作上是非阻塞的。 非阻塞并不是在任何程序级别、任何情况下都可以存在的。 仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。 非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

2.3 同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,称这些程序单元是同步执行的。 例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。 简言之,同步意味着有序。

2.4 异步

为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。 例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。 简言之,异步意味着无序。

2.5 多进程

多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。

2.6 协程

协程,英文叫做 Coroutine,又称微线程,纤程,协程是一种用户态的轻量级线程。 协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。 协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。 我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是异步协程的优势。

3. 异步协程用法

接下来让我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。 Python 中使用协程最常用的库莫过于 asyncio,所以本文会以 asyncio 为基础来介绍协程的使用。 首先我们需要了解下面几个概念:

  • event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
  • coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
  • task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
  • future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。

另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。

3.1 定义协程

首先我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:

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

async def execute(x):
    print('Number:', x)

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine)
print('After calling loop')

运行结果:

1
2
3
4
Coroutine: <coroutine object execute at 0x1034cf830>
After calling execute
Number: 1
After calling loop

首先我们引入了 asyncio 这个包,这样我们才可以使用 async 和 await,然后我们使用 async 定义了一个 execute() 方法,方法接收一个数字参数,方法执行之后会打印这个数字。 随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine 协程对象。随后我们使用 get_event_loop() 方法创建了一个事件循环 loop,并调用了 loop 对象的 run_until_complete() 方法将协程注册到事件循环 loop 中,然后启动。最后我们才看到了 execute() 方法打印了输出结果。 可见,async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。 上文我们还提到了 task,它是对 coroutine 对象的进一步封装,它里面相比 coroutine 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。 在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete() 方法的时候,实际上它进行了一个操作就是将 coroutine 封装成了 task 对象,我们也可以显式地进行声明,如下所示:

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

async def execute(x):
    print('Number:', x)
    return x

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task:'task)
loop.run_until_complete(task)
print('Task:'task)
print('After calling loop')

运行结果:

1
2
3
4
5
6
Coroutine: <coroutine object execute at 0x10e0f7830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

这里我们定义了 loop 对象之后,接着调用了它的 create_task() 方法将 coroutine 对象转化为了 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着我们将 task 对象添加到事件循环中得到执行,随后我们再打印输出一下 task 对象,发现它的状态就变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 execute() 方法的返回结果。 另外定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future() 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:

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

async def execute(x):
    print('Number:', x)
    return x

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

task = asyncio.ensure_future(coroutine)
print('Task:'task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:'task)
print('After calling loop')

运行结果:

1
2
3
4
5
6
Coroutine: <coroutine object execute at 0x10aa33830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

发现其效果都是一样的。

3.2 绑定回调

另外我们也可以为某个 task 绑定一个回调方法,来看下面的例子:

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

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

def callback(task):
    print('Status:'task.result())

coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task:'task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:'task)

在这里我们定义了一个 request() 方法,请求了百度,返回状态码,但是这个方法里面我们没有任何 print() 语句。随后我们定义了一个 callback() 方法,这个方法接收一个参数,是 task 对象,然后调用 print() 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback() 方法。 那么它们二者怎样关联起来呢?很简单,只需要调用 add_done_callback() 方法即可,我们将 callback() 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback() 方法了,同时 task 对象还会作为参数传递给 callback() 方法,调用 task 对象的 result() 方法就可以获取返回结果了。 运行结果:

1
2
3
Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>
Status: <Response [200]>
Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>

实际上不用回调方法,直接在 task 运行完毕之后也可以直接调用 result() 方法获取结果,如下所示:

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

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

coroutine = request()
task = asyncio.ensure_future(coroutine)
print('Task:'task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:'task)
print('Task Result:'task.result())

运行结果是一样的:

1
2
3
Task: <Task pending coro=<request() running at demo.py:4>>
Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>
Task Result: <Response [200]>

3.3 多任务协程

上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait() 方法即可执行,看下面的例子:

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

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks:', tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

for task in tasks:
    print('Task Result:', task.result())

这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:

1
2
3
4
5
6
Tasks: [<Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>]
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>

可以看到五个任务被顺次执行了,并得到了运行结果。

3.4 协程实现

前面说了这么一通,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并没有看出协程的优势啊?反而写法上更加奇怪和麻烦了,别急,上面的案例只是为后面的使用作铺垫,接下来我们正式来看下协程在解决 IO 密集型任务上有怎样的优势吧! 上面的代码中,我们用一个网络请求作为示例,这就是一个耗时等待的操作,因为我们请求网页之后需要等待页面响应并返回结果。耗时等待的操作一般都是 IO 操作,比如文件读取、网络请求等等。协程对于处理这种操作是有很大优势的,当遇到需要等待的情况的时候,程序可以暂时挂起,转而去执行其他的操作,从而避免一直等待一个程序而耗费过多的时间,充分利用资源。 为了表现出协程的优势,我们需要先创建一个合适的实验环境,最好的方法就是模拟一个需要等待一定时间才可以获取返回结果的网页,上面的代码中使用了百度,但百度的响应太快了,而且响应速度也会受本机网速影响,所以最好的方式是自己在本地模拟一个慢速服务器,这里我们选用 Flask。 如果没有安装 Flask 的话可以执行如下命令安装:

1
pip3 install flask

然后编写服务器代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
from flask import Flask
import time

app = Flask(__name__)

@app.route('/')
def index():
    time.sleep(3)
    return 'Hello!'

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

这里我们定义了一个 Flask 服务,主入口是 index() 方法,方法里面先调用了 sleep() 方法休眠 3 秒,然后接着再返回结果,也就是说,每次请求这个接口至少要耗时 3 秒,这样我们就模拟了一个慢速的服务接口。 注意这里服务启动的时候,run() 方法加了一个参数 threaded,这表明 Flask 启动了多线程模式,不然默认是只有一个线程的。如果不开启多线程模式,同一时刻遇到多个请求的时候,只能顺次处理,这样即使我们使用协程异步请求了这个服务,也只能一个一个排队等待,瓶颈就会出现在服务端。所以,多线程模式是有必要打开的。 启动之后,Flask 应该默认会在 127.0.0.1:5000 上运行,运行之后控制台输出结果如下:

1
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

接下来我们再重新使用上面的方法请求一遍:

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

start = time.time()

async def request():
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    response = requests.get(url)
    print('Get response from', url, 'Result:', response.text)

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:'end - start)

在这里我们还是创建了五个 task,然后将 task 列表传给 wait() 方法并注册到时间循环中执行。 运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Cost time: 15.049368143081665

可以发现和正常的请求并没有什么两样,依然还是顺次执行的,耗时 15 秒,平均一个请求耗时 3 秒,说好的异步处理呢? 其实,要实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源,上面方法都是一本正经的串行走下来,连个挂起都没有,怎么可能实现异步?想太多了。 要实现异步,接下来我们再了解一下 await 的用法,使用 await 可以将耗时等待的操作挂起,让出控制权。当协程执行的时候遇到 await,时间循环就会将本协程挂起,转而去执行别的协程,直到其他的协程挂起或执行完毕。 所以,我们可能会将代码中的 request() 方法改成如下的样子:

1
2
3
4
5
async def request():
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    response = await requests.get(url)
    print('Get response from', url, 'Result:', response.text)

仅仅是在 requests 前面加了一个 await,然而执行以下代码,会得到如下报错:

1
2
3
4
5
6
7
8
9
10
11
12
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Cost time: 15.048935890197754
Task exception was never retrieved
future: <Task finished coro=<request() done, defined at demo.py:7> exception=TypeError("object Response can't be used in 'await' expression",)>
Traceback (most recent call last):
  File "demo.py", line 10in request
    status = await requests.get(url)
TypeError: object Response can't be used in 'await' expression

这次它遇到 await 方法确实挂起了,也等待了,但是最后却报了这么个错,这个错误的意思是 requests 返回的 Response 对象不能和 await 一起使用,为什么呢?因为根据官方文档说明,await 后面的对象必须是如下格式之一:

  • A native coroutine object returned from a native coroutine function,一个原生 coroutine 对象。
  • A generator-based coroutine object returned from a function decorated with types.coroutine(),一个由 types.coroutine() 修饰的生成器,这个生成器可以返回 coroutine 对象。
  • An object with an await method returning an iterator,一个包含 await 方法的对象返回的一个迭代器。

可以参见:https://www.python.org/dev/peps/pep-0492/#await-expression。 reqeusts 返回的 Response 不符合上面任一条件,因此就会报上面的错误了。 那么有的小伙伴就发现了,既然 await 后面可以跟一个 coroutine 对象,那么我用 async 把请求的方法改成 coroutine 对象不就可以了吗?所以就改写成如下的样子:

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

start = time.time()

async def get(url):
    return requests.get(url)

async def request():
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    response = await get(url)
    print('Get response from', url, 'Result:', response.text)

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:'end - start)

这里我们将请求页面的方法独立出来,并用 async 修饰,这样就得到了一个 coroutine 对象,我们运行一下看看:

1
2
3
4
5
6
7
8
9
10
11
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Cost time: 15.134317874908447

还是不行,它还不是异步执行,也就是说我们仅仅将涉及 IO 操作的代码封装到 async 修饰的方法里面是不可行的!我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 aiohttp 派上用场了。

3.5 使用 aiohttp

aiohttp 是一个支持异步请求的库,利用它和 asyncio 配合我们可以非常方便地实现异步请求操作。 安装方式如下:

1
pip3 install aiohttp

官方文档链接为:https://aiohttp.readthedocs.io/,它分为两部分,一部分是 Client,一部分是 Server,详细的内容可以参考官方文档。 下面我们将 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
import asyncio
import aiohttp
import time

start = time.time()

async def get(url):
    session = aiohttp.ClientSession()
    response = await session.get(url)
    result = await response.text()
    session.close()
    return result

async def request():
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    result = await get(url)
    print('Get response from', url, 'Result:', result)

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:'end - start)

在这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get() 方法进行请求,结果如下:

1
2
3
4
5
6
7
8
9
10
11
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Waiting for http://127.0.0.1:5000
Get response from http://127.0.0.1:5000 Result: Hello!
Get response from http://127.0.0.1:5000 Result: Hello!
Get response from http://127.0.0.1:5000 Result: Hello!
Get response from http://127.0.0.1:5000 Result: Hello!
Get response from http://127.0.0.1:5000 Result: Hello!
Cost time: 3.0199508666992188

成功了!我们发现这次请求的耗时由 15 秒变成了 3 秒,耗时直接变成了原来的 1/5。 代码里面我们使用了 await,后面跟了 get() 方法,在执行这五个协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行。 开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get() 方法时,它被挂起,但这个 get() 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get() 请求方法,然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒,好第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第五个 task 的 session.get() 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,那咋办?只好等待了。3 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后耗时,3 秒! 怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等着,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。 有人就会说了,既然这样的话,在上面的例子中,在发出网络请求后,既然接下来的 3 秒都是在等待的,在 3 秒之内,CPU 可以处理的 task 数量远不止这些,那么岂不是我们放 10 个、20 个、50 个、100 个、1000 个 task 一起执行,最后得到所有结果的耗时不都是 3 秒左右吗?因为这几个任务被挂起后都是一起等待的。 理论来说确实是这样的,不过有个前提,那就是服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。 我们这里将 task 数量设置成 100,再试一下:

1
tasks = [asyncio.ensure_future(request()) for _ in range(100)]

耗时结果如下:

1
Cost time3.106252670288086

最后运行时间也是在 3 秒左右,当然多出来的时间就是 IO 时延了。 可见,使用了异步协程之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升可谓是非常可观了。

3.6 与单进程、多进程对比

可能有的小伙伴非常想知道上面的例子中,如果 100 次请求,不是用异步协程的话,使用单进程和多进程会耗费多少时间,我们来测试一下: 首先来测试一下单进程的时间:

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

start = time.time()

def request():
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    result = requests.get(url).text
    print('Get response from', url, 'Result:', result)

for _ in range(100):
    request()

end = time.time()
print('Cost time:'end - start)

最后耗时:

1
Cost time305.16639709472656

接下来我们使用多进程来测试下,使用 multiprocessing 库:

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

start = time.time()

def request(_):
    url = 'http://127.0.0.1:5000'
    print('Waiting for', url)
    result = requests.get(url).text
    print('Get response from', url, 'Result:', result)

cpu_count = multiprocessing.cpu_count()
print('Cpu count:', cpu_count)
pool = multiprocessing.Pool(cpu_count)
pool.map(request, range(100))

end = time.time()
print('Cost time:', end - start)

这里我使用了multiprocessing 里面的 Pool 类,即进程池。我的电脑的 CPU 个数是 8 个,这里的进程池的大小就是 8。 运行时间:

1
Cost time48.17306900024414

可见 multiprocessing 相比单线程来说,还是可以大大提高效率的。

3.7 与多进程的结合

既然异步协程和多进程对网络请求都有提升,那么为什么不把二者结合起来呢?在最新的 PyCon 2018 上,来自 Facebook 的 John Reese 介绍了 asyncio 和 multiprocessing 各自的特点,并开发了一个新的库,叫做 aiomultiprocess,感兴趣的可以了解下:https://www.youtube.com/watch?v=0kXaLh8Fz3k。 这个库的安装方式是:

1
pip3 install aiomultiprocess

需要 Python 3.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
import asyncio
import aiohttp
import time
from aiomultiprocess import Pool

start = time.time()

async def get(url):
    session = aiohttp.ClientSession()
    response = await session.get(url)
    result = await response.text()
    session.close()
    return result

async def request():
    url = 'http://127.0.0.1:5000'
    urls = [url for _ in range(100)]
    async with Pool() as pool:
        result = await pool.map(get, urls)
        return result

coroutine = request()
task = asyncio.ensure_future(coroutine)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)

end = time.time()
print('Cost time:'end - start)

这样就会同时使用多进程和异步协程进行请求,当然最后的结果其实和异步是差不多的:

1
Cost time3.1156570434570312

因为我的测试接口的原因,最快的响应也是 3 秒,所以这部分多余的时间基本都是 IO 传输时延。但在真实情况下,我们在做爬取的时候遇到的情况千变万化,一方面我们使用异步协程来防止阻塞,另一方面我们使用 multiprocessing 来利用多核成倍加速,节省时间其实还是非常可观的。 以上便是 Python 中协程的基本用法,希望对大家有帮助。

4. 参考来源

  • http://python.jobbole.com/87310/
  • https://www.cnblogs.com/xybaby/p/6406191.html
  • http://python.jobbole.com/88291/
  • http://lotabout.me/2017/understand-python-asyncio/
  • https://segmentfault.com/a/1190000008814676
  • https://www.cnblogs.com/animalize/p/4738941.html

技术杂谈

最近有一个朋友刚入手了 Mac,准备专门搞开发用,让我给他推荐几款软件,然后我就把我的 Launchpad 截图发给了他,他看到这密密麻麻的软件完全不知所措。 于是乎,我就大略整理了一些我比较推荐的几款软件,同时分享给大家,希望对大家有所帮助! 下面的一些软件都是我个人比较喜欢的,其实还有很多其他的恕不能一一列举了,如果大家有其他推荐的欢迎留言给我,谢谢!

日常工具

一些日常工具在这里我就不一一列举了,大部分使用 Mac 的小伙伴都会安装,比如 QQ、微信、Chrome 浏览器、网易云音乐、迅雷等等,这些在 Windows 上也几乎都是必备软件,这里就不再展开说明了。

效率工具

效率工具顾名思义,可以方便和简化 Mac 的操作,提高生产工作效率的工具,下面推荐几款我比较常用的。

Alfred

首推 Alfred,可以说是 Mac 必备软件,利用它我们可以快速地进行各种操作,大幅提高工作效率,如快速打开某个软件、快速打开某个链接、快速搜索某个文档,快速定位某个文件,快速查看本机 IP,快速定义某个色值,几乎你能想到的都能对接实现。 这些快速功能是怎么实现的呢?实际上是 Alfred 对接了很多 Workflow,我们可以使用 Workflow 方便地进行功能扩展,一些比较优秀的 Workflow 已经有人专门做过整理了,可以参见:https://github.com/zenorocha/alfred-workflows。 推荐指数:★★★★★

Todoist

大家肯定也在使用各种 Todo List 的软件,这种软件其实也是五花八门,经过我本人试用,我觉得 Todoist 这款软件是最方便的。 它支持各种类型的任务定制,还可以设置分组、优先级、Deadline、执行人员、提醒、协作、效率统计等功能。另外它的各个平台支持真是异常地全啊,网页、PC、移动端就不用说了,都必须有的,另外它还有浏览器插件版、电邮版、可穿戴设备(如 Apple Watch、Google Wear)版,另外他还可以和 Mac 的日历事件进行同步,日历添加的事件也会自动添加到 Todoist 里面,非常方便,是目前我体验过的最好用的一款。 这款软件个人推荐购买专业版解锁全部功能,一个月 3 刀,但个人觉得确实非常值。 推荐指数:★★★★☆

Paste

Mac 上默认只有一个粘贴板,当我们新复制了一段文字之后,如果我们想再找寻之前复制的历史记录就找不到了,这其实是很反人类的。 好在 Paste 这款软件帮我们解决了这个问题,它可以保存我们粘贴板的历史记录,等需要粘贴某个内容的时候只需要呼出 Paste 历史粘贴板,然后选择某个特定的内容粘贴就好了,另外它还支持文本格式调整粘贴板分类和搜索,还可以支持快速便捷粘贴。有了它,妈妈再也不用担心我的粘贴板丢失了! 推荐指数:★★★★★

Synergy

工作时我会使用公司的台式机,是 Windows 系统,另外自己的个人笔记本 Mac 也会放在旁边,两台 PC 有时候会交替使用,但是我总不能配两套键盘和鼠标吧,这样就显得累赘了,而且也没那么多地方放啊。 有了 Synergy,我们可以将两台 PC 关联,实现键盘鼠标共享。我们可以使用一套键盘和鼠标来操作两台 PC,注意这是两个完全独立的 PC,各自有各自的屏幕和系统,使用 Synergy 我们可以做到一套键鼠同时控制两台电脑,鼠标可以直接从一台电脑的屏幕滑动到另一台电脑屏幕上,同时键盘、粘贴板也都是共享的。 设想这么个情景,我在我的台式机 Windows 上打开了一个页面,需要让我输入一个很长的序列号,而这个序列号又恰巧存在 Mac 上,这时如果有了 Synergy 将二者关联,我们只需要把鼠标从 Windows 的屏幕上直接滑动到 Mac 的屏幕上,选中序列号,然后键盘按下复制的快捷键,然后再把鼠标移回 Windows,粘贴即可,一气呵成。而不必再想办法发消息传输了,大大提高效率。 推荐指数:★★★★

Feedly、Reeder

博客现在已经越来越多了,越来越多的人开始在博客上发表文章,而当我们遇到优质的博客时,我们还想随时知道博客的发表动态,一旦有新文章发表我们想立马得到相关动态,这样可以实现吗? 肯定是可行的,现在绝大多数博客都有 RSS 订阅功能,有了它我们可以订阅自己喜欢的博客,这里我使用的 RSS 订阅工具就是 Feedly,利用它我可以很轻松地添加自己喜欢的博客或论坛到自己的 Feed 流里面,一旦有文章更新,我就会收到相应提示。 但是 Feedly 有个小问题,就是在国内速度太慢了,所以我又使用 Reeder 将 Feedly 里面的 Feed 流做了转接,它可以添加 Feedly 源,并带有灵活的分类、标记等管理功能,还支持各种预览方式,还支持存储到 Pocket,还有各种分享方式,功能十分齐全。 总之,推荐 Feedly 来添加自己喜欢的博客,用 Reeder 来阅读订阅的内容,双剑合璧,另外 Reeder 对移动版的支持也很不错,可以体验一下。 推荐指数:★★★★

Mindnode

有时候在思考问题的时候我们想要把一些思路记录下来,另外在做一些概要设计的时候需要把概要图大体描述出来,这时候画一个思维导图再合适不过了,比如你现在读的这篇文章就很适合用一个思维导图画一下。 画思维导图我个人比较喜欢的一款软件是 Mindnode,觉得比较简洁好用,当然也有不少人使用 XMind,也很不错。可能是先入为主,也可能是界面设计风格,我个人更加偏向于使用 Mindnode。 推荐指数:★★★★

1Password

随着年龄的增长,我们可能变得越来越忘事了。另外还有些反人类的网站密码必须要至少大写、小写、数字、特殊符号,有的还要求不少于多少位,有的还要求我么能定时更换密码,还不能与之前用过的相同!这会使得我们之前预想设计的很多密码都没法用了。另外网站又这么多,谁又能把网站的密码都记下来啊? 这时候我们就需要一款专门管理密码的软件,我个人推荐一款叫做 1Password,有了它我们可以将各个平台的密码保存起来,同时它还可以根据我们的要求帮我们随机生成一些密码并保存,这对注册一些新网站非常有用,同时使用随机的密码还降低了撞库的风险,不然一个平台的密码被盗了,其他平台用的同样的密码的话,就很不安全了。 1Password 还支持各种平台,如网页、PC、移动版都通通完美支持,实现密码云同步,妈妈再也不用担心我忘记密码了! 推荐指数:★★★★

系统工具

下面介绍的两款系统工具软件几乎是装机必备的。

Tuxera NTFS For Mac

用了 Mac,我们在使用移动硬盘的时候可能会遇到一个无法传输数据(如拷贝文件)的问题,这是因为部分移动硬盘是 NTFS 格式的,而 Mac 的磁盘不是这个格式,因此就会导致二者之间无法拷贝文件。有一个解决方法就是使用 Tuxera NTFS For Mac,有了它,我们就可以比较顺利地拷贝文件了。 另外还有其他品牌的 NTFS For Mac 软件,也可以尝试使用一下。 推荐指数:★★★★☆

VMware、Parallels Desktop

用了 Mac 之后,难免会有些情况下也还会不得不使用 Windows,毕竟很多软件可能只有 Windows 版本,但用 Mac 我就不推荐装双系统了,直接装虚拟机就好了,Mac 上虚拟机软件有两款比较好用,一个就是著名的 VMware,另一个就是 Parallels Desktop,这两款我都使用过,觉得都非常不错,现在用的是 VMware。 推荐指数:★★★★☆

CleanMyMac

很多时候用着用着磁盘就不够用了,如果你的 Mac 硬盘是 512GB 的倒还好,256GB 的你就得多注意一下了,另外 1T 定制版土豪请绕道,这款软件不适合你。 CleanMyMac 可以非常方便地帮助我们扫描缓存、大文件、废纸篓、残留项等内容,清理这些内容之后我们可以节省很多硬盘空间,另外它还支持软件卸载和残留清扫功能,可以帮我们非常干净地移除 Mac 中的软件,目前应该是出到第三版了,非常推荐。 推荐指数:★★★★☆

编辑器

既然做程序开发嘛,不配置好自己的开发环境怎么行,下面推荐一下我平常使用的开发软件。

JetBrains

我目前使用的 IDE 是 JetBrains 全家桶,目前我编写 Python 比较多,所以主要使用 PyCharm,另外写前端的时候也会使用 WebStorm,写 Java 就用 IntelliJ IDEA,C、C++ 用 CLion,PHP 的话就用 PhpStorm,Ruby 的话就用 RubyMine,其他的语言用的就少了,就没有装了。 当然有的小伙伴会说 JetBrains 系列的 IDE 需要购买啊?我只想说,国人的力量是无穷的,在网上其实可以搜到各种破解方法,如 License Server 验证,你能搜到各种五花八门的 License Server。另外 JetBrains 还有专门的 Educational Programs,可以来这里申请:https://www.jetbrains.com/education/programs/?fromMenu,学生、老师或教育工作者可以使用学校的 edu 邮箱申请免费的 License,如果你还是学生的话,那么申请是十分方便的,因为我还是个学生,我目前就在使用学生套餐,当然如果你已经工作的话也可以向正在上学的弟弟妹妹们借一下嘛。 总之我个人比较喜欢 JetBrains 全家桶,不论是页面风格还是开发习惯我都比较喜欢,推荐使用。 推荐指数:★★★★☆

Sublime

有时候我们可能下载了或接收了一些单个的文本文件,我们只想看看文本文件内容是什么,或者对其再做一些简单的修改操作,这时候就没必要单独用 JetBrains 的 IDE 打开了,显得有点重了。或者有时候需要修改某个配置文件,这时候也需要一个比较好用的编辑器。我使用的就是 Sublime,对于一些日常的文本编辑是足够了,另外 Sublime 还可以扩展好多插件,配置好了功能上基本不输 JetBrains IDE,非常推荐。 推荐指数:★★★★

MarkEditor

现在越来越多的写作平台开始支持 MarkDown,不得不说这确实是一门提高文字生产效率的语言,写 MarkDown 我强烈推荐 MarkEditor,我之前尝试过各种 MarkDown 写作软件,觉得都不如这款好用,如 Typora、MWeb、GitBook 等等。 MarkEditor 支持写作及预览模式,更重要的是支持文件管理,很多软件如 Typora 只能打开单个的 Makrdown 文件,不能打开整个文件夹,这就很鸡肋了。另外 MarkEditor 支持直接插入图片,如我们截了一张图或者刚从网上复制了一张图,在 MarkEditor 里面直接粘贴就可以了,它会自动把这张图保存到当前目录下,同时生成 Makrdown 格式的的图片链接,不能更方便了!另外还支持主题自定义、样式自定义,还可以快速插入某些 Makrdown 元素,还支持 Latex 公式,还可以快速导出电子书,快速生成文稿网页,快速局域网共享,功能应有尽有,强烈推荐! 这个软件我购买了 Pro 版,解锁了全部功能,订购地址:https://www.markeditor.com/,个人觉得物超所值! 推荐指数:★★★★★

SnippetLab

在写代码的时候,我们经常会有一些常用代码或者精华代码,或者一些常用的配置,想要单独保存下来复用,这时我们可能会把它保存到某个文本文件里面,更高级点可以使用云笔记,如有道云笔记或者印象笔记,用过 GitHub Gists 的小伙伴可能会选择 GitHub Gists,但我觉得这些都不是最佳的。 首先文本文件、云笔记里面其实并不是专门为了保存代码使用的,另外 GitHub Gists 保存操作并没有那么便捷,而且打开速度也很慢,影响体验。在这里推荐一款专门用来保存代码的软件叫做 SnippetLab,涉设计初衷就是为了保存短代码片的,它支持几乎所有编程语言,另外支持分类、分级、加标签、加描述等,另外它还可以和 Alfred 对接实现快速搜索查找,另外还支持备份、导出、云同步等各种功能,非常适合做代码片的管理。 推荐指数:★★★★

Beyond Compare

有时候我们需要比较两个文件的不同之处,以便于快速得知两个版本的修改内容,我使用的软件是 Beyond Compare,个人觉得比较简洁好用,同时删除和添加的内容有对应的红绿颜色标识,推荐给大家使用。 推荐指数:★★★☆

管理工具

有时候我们需要管理很多文件,或者还需要远程管理很多终端设备,在这里推荐几款比较好用的工具。

Filezlla

有时候我们需要管理一些远程的服务器,比如 Linux 服务器。那么如何和这些服务器之间传递数据和文件呢?这里推荐一个轻便简洁的软件 Filezlla,它支持 FTP、SFTP 等协议类型,使用它我们可以方便地进行文件传输和远程文件管理。 推荐指数:★★★

ForkLift

Mac 上的 Finder 你是不是已经受够了?在一些方面做得相当不友好,例如在当前打开的目录下新建一个空白文件,在当前的目录下打开命令行工具等等,有了 ForkLift 这些都是小意思了。另外 ForkLift 还集成了 Filezlla 的功能,利用它我们还可以像普通文件管理器一样管理远程的主机内容,它还支持 FTP、SFTP、SMB、WebDAV、NFS 等等各种协议。同时界面也非常美观,有了它,几乎可以抛弃 Finder 和 Filezlla 了,强烈推荐! 推荐指数:★★★★☆

SSH Shell

我们经常会和各种服务器打交道,例如我们经常使用 SSH 来远程连接某台 Linux 服务器,原生 Terminal 是支持 SSH 的,但你会发现原生带的这个太难用了。可能很多小伙伴使用 iTerm,不得不说这确实是个神器,大大方便了远程管理流程。但我在这里还要推荐一个我经常使用的 SSH Shell,没错,它的名字就是 SSH Shell,它的页面操作简洁,同时管理和记录远程主机十分方便,另外还支持秘钥管理、自动重连、自定义主题等等功能,个人用起来十分顺手,强烈推荐! 推荐指数:★★★★☆

HomeBrew、CakeBrew

对于开发者来说,这个软件几乎是 Mac 上必备的一个软件,它的官方简介就是 “The missing package manager for macOS”,算是 Mac 上的一个软件包平台,它里面包含着非常多的 Mac 开发软件包,比如 Python、PHP、Redis、MySQL、RabbitMQ、HBase 等等,几乎你能想到的开发软件都集成在里面了,堪称神器! 它的安装也非常简单,参见这里:https://brew.sh/,另外 HomeBrew 也有对应的图形界面,叫做 CakeBrew,如果不喜欢命令行操作的话可以使用 CakeBrew 来代替。 推荐指数:★★★★★

影音图像

IINA

这个必须要赞一下,非常强大简洁好用的视频播放器,是 GitHub 上的一个开源软件,链接是:https://lhc70000.github.io/iina/,播放控制、视频设置、音频设置、字幕设置、文件操作,几乎你能想到的应有尽有,而且无广告,简洁清爽,支持的视频格式也十分广泛,推荐使用! 推荐指数:★★★★

ScreenFlow

之前我曾录制过一些 Python 的视频课程,本来尝试过 QuickTime 录制,可是实在是太难用了,另外视频剪辑、音频剪辑等又是个麻烦事。后来我就使用了 ScreenFlow,它集录制、剪辑、配音、字幕、特效等功能于一体,另外录制质量,渲染质量也是一流,大大提高了我的效率,堪称神器! 推荐指数:★★★★☆

iPic

有时候我们在写 MarkDown 的时候,可能突然需要一张插入一张图片,比如我们想插入一张屏幕截图,我们就需要把这张图片先存下来,然后加上图片的路径,如果转发给别人还需要连着图片一并发给对方,这其实是不怎么方便的,倘若这张图片是一张来自网络的图片,我们直接用 HTTP 访问的话,那岂不是方便太多了? 要将图片传到网上分几步?三步。第一步,把上传页面打开,第二步,把图片传到网上并把传后链接拷贝下来,第三步,把上传页面关闭。简直是太麻烦了对不对?另外找个合适的图床也是个麻烦事啊,七牛?又拍?你不得又得申请和注册。那么有了 iPic,一切就不是难事了,它可以监听 Mac 的粘贴板,一旦我们复制了一张图或者新截了一张图,它就能显示到待上传队列里面,我们点一下它就会把图片上传到网络上,然后生成上传后的链接,默认使用的是新浪的图床,网速也非常快。有了它,传图什么的都不是事了!另外付费版还支持各种自定义图床,如七牛云、又拍云、阿里云、腾讯云等等。 推荐指数:★★★★☆

PixelMator

在 Windows 上我们常用 PS 来修改和处理图片,Mac 上我是没有使用 PS,使用了 PixelMator,个人觉得使用这款软件能完全胜任 PS 的工作,一般的图片设计、排版、抠图、特效、蒙版等操作都支持,我个人比较喜欢使用这款软件做设计。 推荐指数:★★★★

Polarr Photo Editor

这个软件又名“泼辣修图”,类似 Mac 上的美图秀秀,它自带了各种后期滤镜,还带有 Lightroom 的很多调光调色的工具,能够帮我们快速对照片进行后期处理,效果也还不错,当然比不上 Photoshop 和 Lightroom 那么专业,但对于快速进行后处理的小伙伴来说不失为一个好的选择。 推荐指数:★★★★

Boom2

我有边工作边听歌的习惯,所以音乐几乎离不开我的生活,入了个好耳机,那当然就得配上好音乐。大家肯定也听说过音效均衡器,我们可以调整不同的音效参数来达到不同的声音效果,如电子音、人声、环绕、重低音等等,在 Mac 上我觉得最好用的就是 Boom2 了,它内置了各种音效均衡器,还有一些高保真效果的渲染,效果非常给力。我一般听歌的时候就会把 Boom2 开起来,享受不一样的音效感觉,美哉。 推荐指数:★★★★

趣味扩展

另外还有几个比较有意思的工具推荐下。

Tickeys

使用过机械键盘吗?按键感觉和声音很爽吧,但是用了 Mac,你如果不使用外接键盘的话,想必手感就差上不少,但这款软件或许可以拯救一下,它可以模拟机械键盘的按键声,每次按键都有有机械键盘清脆的声音,我平时戴耳机撸代码的时候就会开着这个软件,感觉体验还是不错的,建议尝试一下。 推荐指数:★★★☆

Duet

Duet 这款软件可以将 iPad 或 iPhone 变成电脑的扩展屏幕,如果你有一个大屏的比如 12.9 寸的 iPad 的话,非常建议你尝试一下这款软件,这样如果正你在用 Mac 不用 iPad 的话,完全可以用 Duet 把 iPad 和电脑屏幕连接起来来扩展显示,充分利用资源。 推荐指数:★★★☆ 好了,暂时推荐这么多,其实还有很多很多,尤其是专门针对于开发者的一些工具,这些就太偏极客化了,后面再为大家整理一些好用的开发者工具,敬请期待。 还不尽兴的小伙伴可以关注 GitHub 上的一个仓库叫 awesome-mac,里面列出来了 Mac 上推荐的非常多的软件,总结得非常非常详细,链接是:https://github.com/jaywcjlove/awesome-mac,大家可以去看下。

Tips

可能有的小伙伴好奇我的 Launchpad 为啥能放那么多图标,是怎么做到的?其实很简单,几行代码就搞定了。 调整每列显示图标数量,这里以 7 为例:

1
defaults write com.apple.dock springboard-rows -int 7

调整每行显示图标的数量,这里以 8 为例:

1
defaults write com.apple.dock springboard-columns -int 8

上面两行代码最后的数字可以自行修改。 修改完了之后还需要重置一下 Launchpad,代码如下:

1
defaults write com.apple.dock ResetLaunchPad -bool TRUE;killall Dock

好了,这样我们就可以自由定制我们的 Launchpad 图标数量啦! 另外,还有的小伙伴会说,很多软件都需要花钱购买啊,咋办?告诉你个网址:http://xclient.info/,几乎你想找的破解版都有,别说别的了,雷锋也别叫了,省下的钱打赏给我一点就行哈哈。 以上就是我的一些 Mac 常用软件分享及 Tips,希望对大家有帮助! 另外大家如有还有推荐的软件,欢迎留言给我,非常感谢!

Python

嗨~ 给大家重磅推荐一本书!上市两月就已经重印 4 次的 Python 爬虫书!它就是由静觅博客博主崔庆才所作的《Python3网络爬虫开发实战》!!!同时文末还有抽奖赠书活动,不容错过!!!

书籍介绍

本书《Python3网络爬虫开发实战》全面介绍了利用 Python3 开发网络爬虫的知识,书中首先详细介绍了各种类型的环境配置过程和爬虫基础知识,还讨论了 urllib、requests 等请求库和 Beautiful Soup、XPath、pyquery 等解析库以及文本和各类数据库的存储方法,另外本书通过多个真实新鲜案例介绍了分析 Ajax 进行数据爬取,Selenium 和 Splash 进行动态网站爬取的过程,接着又分享了一些切实可行的爬虫技巧,比如使用代理爬取和维护动态代理池的方法、ADSL 拨号代理的使用、各类验证码(图形、极验、点触、宫格等)的破解方法、模拟登录网站爬取的方法及 Cookies 池的维护等等。 此外,本书的内容还远远不止这些,作者还结合移动互联网的特点探讨了使用 Charles、mitmdump、Appium 等多种工具实现 App 抓包分析、加密参数接口爬取、微信朋友圈爬取的方法。此外本书还详细介绍了 pyspider 框架、Scrapy 框架的使用和分布式爬虫的知识,另外对于优化及部署工作,本书还包括 Bloom Filter 效率优化、Docker 和 Scrapyd 爬虫部署、分布式爬虫管理框架Gerapy 的分享。 全书共 604 页,足足两斤重呢~ 定价为 99 元!

作者介绍

看书就先看看谁写的嘛,我们来了解一下~ 崔庆才,静觅博客博主(https://cuiqingcai.com),博客 Python 爬虫博文阅读量已过千万,北京航空航天大学硕士,天善智能、网易云课堂讲师,微软小冰大数据工程师,有多个大型分布式爬虫项目经验,乐于技术分享,文章通俗易懂 ^^ 附皂片一张 ~(@^^@)~

图文介绍

呕心沥血设计的宣传图也得放一下~

专家评论

书是好是坏,得让专家看评一评呀,那么下面就是几位专家的精彩评论,快来看看吧~ 在互联网软件开发工程师的分类中,爬虫工程师是非常重要的。爬虫工作往往是一个公司核心业务开展的基础,数据抓取下来,才有后续的加工处理和最终展现。此时数据的抓取规模、稳定性、实时性、准确性就显得非常重要。早期的互联网充分开放互联,数据获取的难度很小。随着各大公司对数据资产日益看重,反爬水平也在不断提高,各种新技术不断给爬虫软件提出新的课题。本书作者对爬虫的各个领域都有深刻研究,书中探讨了Ajax数据的抓取、动态渲染页面的抓取、验证码识别、模拟登录等高级话题,同时也结合移动互联网的特点探讨了App的抓取等。更重要的是,本书提供了大量源码,可以帮助读者更好地理解相关内容。强烈推荐给各位技术爱好者阅读!

——梁斌,八友科技总经理

数据既是当今大数据分析的前提,也是各种人工智能应用场景的基础。得数据者得天下,会爬虫者走遍天下也不怕!一册在手,让小白到老司机都能有所收获!

——李舟军,北京航空航天大学教授,博士生导师

本书从爬虫入门到分布式抓取,详细介绍了爬虫技术的各个要点,并针对不同的场景提出了对应的解决方案。另外,书中通过大量的实例来帮助读者更好地学习爬虫技术,通俗易懂,干货满满。强烈推荐给大家!

——宋睿华,微软小冰首席科学家

有人说中国互联网的带宽全给各种爬虫占据了,这说明网络爬虫的重要性以及中国互联网数据封闭垄断的现状。爬是一种能力,爬是为了不爬。

——施水才,北京拓尔思信息技术股份有限公司总裁

全书目录

书的目录也有~ 看这里!

  • 1-开发环境配置
  • 1.1-Python3的安装
  • 1.2-请求库的安装
  • 1.3-解析库的安装
  • 1.4-数据库的安装
  • 1.5-存储库的安装
  • 1.6-Web库的安装
  • 1.7-App爬取相关库的安装
  • 1.8-爬虫框架的安装
  • 1.9-部署相关库的安装
  • 2-爬虫基础
  • 2.1-HTTP基本原理
  • 2.2-网页基础
  • 2.3-爬虫的基本原理
  • 2.4-会话和Cookies
  • 2.5-代理的基本原理
  • 3-基本库的使用
  • 3.1-使用urllib
  • 3.1.1-发送请求
  • 3.1.2-处理异常
  • 3.1.3-解析链接
  • 3.1.4-分析Robots协议
  • 3.2-使用requests
  • 3.2.1-基本用法
  • 3.2.2-高级用法
  • 3.3-正则表达式
  • 3.4-抓取猫眼电影排行
  • 4-解析库的使用
  • 4.1-使用XPath
  • 4.2-使用Beautiful Soup
  • 4.3-使用pyquery
  • 5-数据存储
  • 5.1-文件存储
  • 5.1.1-TXT文本存储
  • 5.1.2-JSON文件存储
  • 5.1.3-CSV文件存储
  • 5.2-关系型数据库存储
  • 5.2.1-MySQL存储
  • 5.3-非关系型数据库存储
  • 5.3.1-MongoDB存储
  • 5.3.2-Redis存储
  • 6-Ajax数据爬取
  • 6.1-什么是Ajax
  • 6.2-Ajax分析方法
  • 6.3-Ajax结果提取
  • 6.4-分析Ajax爬取今日头条街拍美图
  • 7-动态渲染页面爬取
  • 7.1-Selenium的使用
  • 7.2-Splash的使用
  • 7.3-Splash负载均衡配置
  • 7.4-使用Selenium爬取淘宝商品
  • 8-验证码的识别
  • 8.1-图形验证码的识别
  • 8.2-极验滑动验证码的识别
  • 8.3-点触验证码的识别
  • 8.4-微博宫格验证码的识别
  • 9-代理的使用
  • 9.1-代理的设置
  • 9.2-代理池的维护
  • 9.3-付费代理的使用
  • 9.4-ADSL拨号代理
  • 9.5-使用代理爬取微信公众号文章
  • 10-模拟登录
  • 10.1-模拟登录并爬取GitHub
  • 10.2-Cookies池的搭建
  • 11-App的爬取
  • 11.1-Charles的使用
  • 11.2-mitmproxy的使用
  • 11.3-mitmdump爬取“得到”App电子书信息
  • 11.4-Appium的基本使用
  • 11.5-Appium爬取微信朋友圈
  • 11.6-Appium+mitmdump爬取京东商品
  • 12-pyspider框架的使用
  • 12.1-pyspider框架介绍
  • 12.2-pyspider的基本使用
  • 12.3-pyspider用法详解
  • 13-Scrapy框架的使用
  • 13.1-Scrapy框架介绍
  • 13.2-Scrapy入门
  • 13.3-Selector的用法
  • 13.4-Spider的用法
  • 13.5-Downloader Middleware的用法
  • 13.6-Spider Middleware的用法
  • 13.7-Item Pipeline的用法
  • 13.8-Scrapy对接Selenium
  • 13.9-Scrapy对接Splash
  • 13.10-Scrapy通用爬虫
  • 13.11-Scrapyrt的使用
  • 13.12-Scrapy对接Docker
  • 13.13-Scrapy爬取新浪微博
  • 14-分布式爬虫
  • 14.1-分布式爬虫原理
  • 14.2-Scrapy-Redis源码解析
  • 14.3-Scrapy分布式实现
  • 14.4-Bloom Filter的对接
  • 15-分布式爬虫的部署
  • 15.1-Scrapyd分布式部署
  • 15.2-Scrapyd-Client的使用
  • 15.3-Scrapyd对接Docker
  • 15.4-Scrapyd批量部署
  • 15.5-Gerapy分布式管理

购买链接

想必很多小伙伴已经等了很久了,之前预售那么久也一直迟迟没有货,发售就有不少网店又售空了,不过现在起不用担心了!

书籍现已在京东、天猫、当当等网店上架并全面供应啦,复制链接到浏览器打开或扫描二维码打开即可购买了!

京东商城

https://item.jd.com/12333540.html

天猫商城

https://detail.tmall.com/item.htm?id=566699703917

当当网

http://product.dangdang.com/25249602.html

欢迎大家购买,谢谢支持!O(∩_∩)O

免费预览

不放心?想先看看有些啥,没问题!看这里: 免费章节试读(复制粘贴至浏览器打开): https://cuiqingcai.com/5052.html 将一直免费开放前7章节,欢迎大家试读! 好了,接下来就是我们的福利环节啦~

福利一:抽奖送书!!!

恭喜你看到这里了!那么接下来的福利时间就到了!后面还有两个福利不容错过哦~ 抽奖送书活动第二波来袭(后面还有很多波哦),公众号抽奖送 30 本作者亲笔签名书籍!!! 活动流程(重要,请一定认真阅读): 公众号进击的Coder回复 “抽奖” 获取抽奖码,2018.6.24 22:00 截止,逾期参与无效,请记住您的抽奖码,活动结束后会从参与活动的小伙伴中根据幸运值按照权重比例抽取 30 位并在微信公众号公布,届时请关注公众号抽奖结果的公布!获奖的小伙伴会获得作者亲笔签名的《Python3网络爬虫开发实战》一本。

福利二:独家优惠!!!

等等,你以为这就是全部福利吗?当然不是!除了抽奖送书,我们还拿到了拨号VPS知名品牌云立方的独家优惠,在公众号(进击的Coder )中回复:“优惠券”,即可免费领取云立方50元主机优惠券,数量有限,先到先得!优惠券可在云立方官网(www.yunlifang.cn)购买动态IP拨号VPS时抵扣现金,有了它,爬虫代理易如反掌! 你问我动态拨号VPS能做什么?应该怎么用在爬虫里?来这里了解一下: 轻松获得海量稳定代理!ADSL拨号代理的搭建

福利三:视频课程!!!

当然除了书籍,也有配套的视频课程,作者同样是崔庆才,二者结合学习效果更佳!限时优惠折扣中!扫描下图中二维码即可了解详情! 最后也是最重要的就是参与活动的地址了!!!快来扫码回复领取属于你的福利吧!!!

特别致谢

最后特别感谢云立方、天善智能对本活动的大力支持!

Python

在做自然语言处理的过程中,我们经常会遇到需要找出相似语句的场景,或者找出句子的近似表达,这时候我们就需要把类似的句子归到一起,这里面就涉及到句子相似度计算的问题,那么本节就来了解一下怎么样来用 Python 实现句子相似度的计算。

基本方法

句子相似度计算我们一共归类了以下几种方法:

  • 编辑距离计算
  • 杰卡德系数计算
  • TF 计算
  • TFIDF 计算
  • Word2Vec 计算

下面我们来一一了解一下这几种算法的原理和 Python 实现。

编辑距离计算

编辑距离,英文叫做 Edit Distance,又称 Levenshtein 距离,是指两个字串之间,由一个转成另一个所需的最少编辑操作次数,如果它们的距离越大,说明它们越是不同。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。 例如我们有两个字符串:string 和 setting,如果我们想要把 string 转化为 setting,需要这么两步:

  • 第一步,在 s 和 t 之间加入字符 e。
  • 第二步,把 r 替换成 t。

所以它们的编辑距离差就是 2,这就对应着二者要进行转化所要改变(添加、替换、删除)的最小步数。 那么用 Python 怎样来实现呢,我们可以直接使用 distance 库:

1
2
3
4
5
6
7
8
import distance

def edit_distance(s1, s2):
return distance.levenshtein(s1, s2)

s1 = 'string'
s2 = 'setting'
print(edit_distance(s1, s2))

这里我们直接使用 distance 库的 levenshtein() 方法,传入两个字符串,即可获取两个字符串的编辑距离了。 运行结果如下:

1
2

这里的 distance 库我们可以直接使用 pip3 来安装:

1
pip3 install distance

这样如果我们想要获取相似的文本的话可以直接设定一个编辑距离的阈值来实现,如设置编辑距离为 2,下面是一个样例:

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

def edit_distance(s1, s2):
return distance.levenshtein(s1, s2)

strings = [
'你在干什么',
'你在干啥子',
'你在做什么',
'你好啊',
'我喜欢吃香蕉'
]

target = '你在干啥'
results = list(filter(lambda x: edit_distance(x, target) <= 2, strings))
print(results)

这里我们定义了一些字符串,然后定义了一个目标字符串,然后用编辑距离 2 的阈值进行设定,最后得到的结果就是编辑距离在 2 及以内的结果,运行结果如下:

1
['你在干什么', '你在干啥子']

通过这种方式我们可以大致筛选出类似的句子,但是发现一些句子例如“你在做什么” 就没有被识别出来,但他们的意义确实是相差不大的,因此,编辑距离并不是一个好的方式,但是简单易用。

杰卡德系数计算

杰卡德系数,英文叫做 Jaccard index, 又称为 Jaccard 相似系数,用于比较有限样本集之间的相似性与差异性。Jaccard 系数值越大,样本相似度越高。 实际上它的计算方式非常简单,就是两个样本的交集除以并集得到的数值,当两个样本完全一致时,结果为 1,当两个样本完全不同时,结果为 0。 算法非常简单,就是交集除以并集,下面我们用 Python 代码来实现一下:

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
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np


def jaccard_similarity(s1, s2):
def add_space(s):
return ' '.join(list(s))

# 将字中间加入空格
s1, s2 = add_space(s1), add_space(s2)
# 转化为TF矩阵
cv = CountVectorizer(tokenizer=lambda s: s.split())
corpus = [s1, s2]
vectors = cv.fit_transform(corpus).toarray()
# 求交集
numerator = np.sum(np.min(vectors, axis=0))
# 求并集
denominator = np.sum(np.max(vectors, axis=0))
# 计算杰卡德系数
return 1.0 * numerator / denominator


s1 = '你在干嘛呢'
s2 = '你在干什么呢'
print(jaccard_similarity(s1, s2))

这里我们使用了 Sklearn 库中的 CountVectorizer 来计算句子的 TF 矩阵,然后利用 Numpy 来计算二者的交集和并集,随后计算杰卡德系数。 这里值得学习的有 CountVectorizer 的用法,通过它的 fit_transform() 方法我们可以将字符串转化为词频矩阵,例如这里有两句话“你在干嘛呢”和“你在干什么呢”,首先 CountVectorizer 会计算出不重复的有哪些字,会得到一个字的列表,结果为:

1
['么', '什', '你', '呢', '嘛', '在', '干']

这个其实可以通过如下代码来获取,就是获取词表内容:

1
cv.get_feature_names()

接下来通过转化之后,vectors 变量就变成了:

1
2
[[0 0 1 1 1 1 1]
[1 1 1 1 0 1 1]]

它对应的是两个句子对应词表的词频统计,这里是两个句子,所以结果是一个长度为 2 的二维数组,比如第一句话“你在干嘛呢”中不包含“么”字,那么第一个“么”字对应的结果就是0,即数量为 0,依次类推。 后面我们使用了 np.min() 方法并传入了 axis 为 0,实际上就是获取了每一列的最小值,这样实际上就是取了交集,np.max() 方法是获取了每一列的最大值,实际上就是取了并集。 二者分别取和即是交集大小和并集大小,然后作商即可,结果如下:

1
0.5714285714285714

这个数值越大,代表两个字符串越接近,否则反之,因此我们也可以使用这个方法,并通过设置一个相似度阈值来进行筛选。

TF 计算

第三种方案就是直接计算 TF 矩阵中两个向量的相似度了,实际上就是求解两个向量夹角的余弦值,就是点乘积除以二者的模长,公式如下:

1
cosθ=a·b/|a|*|b|

上面我们已经获得了 TF 矩阵,下面我们只需要求解两个向量夹角的余弦值就好了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
from scipy.linalg import norm

def tf_similarity(s1, s2):
def add_space(s):
return ' '.join(list(s))

# 将字中间加入空格
s1, s2 = add_space(s1), add_space(s2)
# 转化为TF矩阵
cv = CountVectorizer(tokenizer=lambda s: s.split())
corpus = [s1, s2]
vectors = cv.fit_transform(corpus).toarray()
# 计算TF系数
return np.dot(vectors[0], vectors[1]) / (norm(vectors[0]) * norm(vectors[1]))


s1 = '你在干嘛呢'
s2 = '你在干什么呢'
print(tf_similarity(s1, s2))

在在这里我们使用了 np.dot() 方法获取了向量的点乘积,然后通过 norm() 方法获取了向量的模长,经过计算得到二者的 TF 系数,结果如下:

1
0.7302967433402214

TFIDF 计算

另外除了计算 TF 系数我们还可以计算 TFIDF 系数,TFIDF 实际上就是在词频 TF 的基础上再加入 IDF 的信息,IDF 称为逆文档频率,不了解的可以看下阮一峰老师的讲解:http://www.ruanyifeng.com/blog/2013/03/tf-idf.html,里面对 TFIDF 的讲解也是十分透彻的。 下面我们还是借助于 Sklearn 中的模块 TfidfVectorizer 来实现,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
from scipy.linalg import norm


def tfidf_similarity(s1, s2):
def add_space(s):
return ' '.join(list(s))

# 将字中间加入空格
s1, s2 = add_space(s1), add_space(s2)
# 转化为TF矩阵
cv = TfidfVectorizer(tokenizer=lambda s: s.split())
corpus = [s1, s2]
vectors = cv.fit_transform(corpus).toarray()
# 计算TF系数
return np.dot(vectors[0], vectors[1]) / (norm(vectors[0]) * norm(vectors[1]))


s1 = '你在干嘛呢'
s2 = '你在干什么呢'
print(tfidf_similarity(s1, s2))

这里的 vectors 变量实际上就对应着 TFIDF 值,内容如下:

1
2
[[0.         0.         0.4090901  0.4090901  0.57496187 0.4090901 0.4090901 ]
[0.49844628 0.49844628 0.35464863 0.35464863 0. 0.35464863 0.35464863]]

运行结果如下:

1
0.5803329846765686

所以通过 TFIDF 系数我们也可以进行相似度的计算。

Word2Vec 计算

Word2Vec,顾名思义,其实就是将每一个词转换为向量的过程。如果不了解的话可以参考:https://blog.csdn.net/itplus/article/details/37969519。 这里我们可以直接下载训练好的 Word2Vec 模型,模型的链接地址为:https://pan.baidu.com/s/1TZ8GII0CEX32ydjsfMc0zw,是使用新闻、百度百科、小说数据来训练的 64 维的 Word2Vec 模型,数据量很大,整体效果还不错,我们可以直接下载下来使用,这里我们使用的是 news_12g_baidubaike_20g_novel_90g_embedding_64.bin 数据,然后实现 Sentence2Vec,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import gensim
import jieba
import numpy as np
from scipy.linalg import norm

model_file = './word2vec/news_12g_baidubaike_20g_novel_90g_embedding_64.bin'
model = gensim.models.KeyedVectors.load_word2vec_format(model_file, binary=True)

def vector_similarity(s1, s2):
def sentence_vector(s):
words = jieba.lcut(s)
v = np.zeros(64)
for word in words:
v += model[word]
v /= len(words)
return v

v1, v2 = sentence_vector(s1), sentence_vector(s2)
return np.dot(v1, v2) / (norm(v1) * norm(v2))

在获取 Sentence Vector 的时候,我们首先对句子进行分词,然后对分好的每一个词获取其对应的 Vector,然后将所有 Vector 相加并求平均,这样就可得到 Sentence Vector 了,然后再计算其夹角余弦值即可。 调用示例如下:

1
2
3
s1 = '你在干嘛'
s2 = '你正做什么'
vector_similarity(s1, s2)

结果如下:

1
0.6701133967824016

这时如果我们再回到最初的例子看下效果:

1
2
3
4
5
6
7
8
9
10
11
12
strings = [
'你在干什么',
'你在干啥子',
'你在做什么',
'你好啊',
'我喜欢吃香蕉'
]

target = '你在干啥'

for string in strings:
print(string, vector_similarity(string, target))

依然是前面的例子,我们看下它们的匹配度结果是多少,运行结果如下:

1
2
3
4
5
你在干什么 0.8785495016487204
你在干啥子 0.9789649689827049
你在做什么 0.8781992402695274
你好啊 0.5174225914249863
我喜欢吃香蕉 0.582990841450621

可以看到相近的语句相似度都能到 0.8 以上,而不同的句子相似度都不足 0.6,这个区分度就非常大了,可以说有了 Word2Vec 我们可以结合一些语义信息来进行一些判断,效果明显也好很多。 所以总体来说,Word2Vec 计算的方式是非常好的。 另外学术界还有一些可能更好的研究成果,这个可以参考知乎上的一些回答:https://www.zhihu.com/question/29978268/answer/54399062。 以上便是进行句子相似度计算的基本方法和 Python 实现,本节代码地址:https://github.com/AIDeepLearning/SentenceDistance

Python

Scrapy-Redis 详解

通常我们在一个站站点进行采集的时候,如果是小站的话 我们使用scrapy本身就可以满足。 但是如果在面对一些比较大型的站点的时候,单个scrapy就显得力不从心了。 要是我们能够多个Scrapy一起采集该多好啊 人多力量大。 很遗憾Scrapy官方并不支持多个同时采集一个站点,虽然官方给出一个方法: 将一个站点的分割成几部分 交给不同的scrapy去采集 似乎是个解决办法,但是很麻烦诶!毕竟分割很麻烦的哇 下面就改轮到我们的额主角Scrapy-Redis登场了!

什么??你这么就登场了?还没说为什么呢?

好吧 为了简单起见 就用官方图来简单说明一下: 这张图大家相信大家都很熟悉了。重点看一下SCHEDULER 1. 先来看看官方对于SCHEDULER的定义: SCHEDULER接受来自Engine的Requests,并将它们放入队列(可以按顺序优先级),以便在之后将其提供给Engine 点我看文档 2. 现在我们来看看SCHEDULER都提供了些什么功能: 根据官方文档说明 在我们没有没有指定 SCHEDULER 参数时,默认使用:’scrapy.core.scheduler.Scheduler’ 作为SCHEDULER(调度器) scrapy.core.scheduler.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class Scheduler(object):

def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None,
logunser=False, stats=None, pqclass=None):
self.df = dupefilter
self.dqdir = self._dqdir(jobdir)
self.pqclass = pqclass
self.dqclass = dqclass
self.mqclass = mqclass
self.logunser = logunser
self.stats = stats
# 注意在scrpy中优先注意这个方法,此方法是一个钩子 用于访问当前爬虫的配置
@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
# 获取去重用的类 默认:scrapy.dupefilters.RFPDupeFilter
dupefilter_cls = load_object(settings['DUPEFILTER_CLASS'])
# 对去重类进行配置from_settings 在 scrapy.dupefilters.RFPDupeFilter 43行
# 这种调用方式对于IDE跳转不是很好 所以需要自己去找
# @classmethod
# def from_settings(cls, settings):
# debug = settings.getbool('DUPEFILTER_DEBUG')
# return cls(job_dir(settings), debug)
# 上面就是from_settings方法 其实就是设置工作目录 和是否开启debug
dupefilter = dupefilter_cls.from_settings(settings)
# 获取优先级队列 类对象 默认:queuelib.pqueue.PriorityQueue
pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE'])
# 获取磁盘队列 类对象(SCHEDULER使用磁盘存储 重启不会丢失)
dqclass = load_object(settings['SCHEDULER_DISK_QUEUE'])
# 获取内存队列 类对象(SCHEDULER使用内存存储 重启会丢失)
mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE'])
# 是否开启debug
logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG'))
# 将这些参数传递给 __init__方法
return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser,
stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass)


def has_pending_requests(self):
"""检查是否有没处理的请求"""
return len(self) > 0

def open(self, spider):
"""Engine创建完毕之后会调用这个方法"""
self.spider = spider
# 创建一个有优先级的内存队列 实例化对象
# self.pqclass 默认是:queuelib.pqueue.PriorityQueue
# self._newmq 会返回一个内存队列的 实例化对象 在110 111 行
self.mqs = self.pqclass(self._newmq)
# 如果self.dqdir 有设置 就创建一个磁盘队列 否则self.dqs 为空
self.dqs = self._dq() if self.dqdir else None
# 获得一个去重实例对象 open 方法是从BaseDupeFilter继承的
# 现在我们可以用self.df来去重啦
return self.df.open()

def close(self, reason):
"""当然Engine关闭时"""
# 如果有磁盘队列 则对其进行dump后保存到active.json文件中
if self.dqs:
prios = self.dqs.close()
with open(join(self.dqdir, 'active.json'), 'w') as f:
json.dump(prios, f)
# 然后关闭去重
return self.df.close(reason)

def enqueue_request(self, request):
"""添加一个Requests进调度队列"""
# self.df.request_seen是检查这个Request是否已经请求过了 如果有会返回True
if not request.dont_filter and self.df.request_seen(request):
# 如果Request的dont_filter属性没有设置(默认为False)和 已经存在则去重
# 不push进队列
self.df.log(request, self.spider)
return False
# 先尝试将Request push进磁盘队列
dqok = self._dqpush(request)
if dqok:
# 如果成功 则在记录一次状态
self.stats.inc_value('scheduler/enqueued/disk', spider=self.spider)
else:
# 不能添加进磁盘队列则会添加进内存队列
self._mqpush(request)
self.stats.inc_value('scheduler/enqueued/memory', spider=self.spider)
self.stats.inc_value('scheduler/enqueued', spider=self.spider)
return True

def next_request(self):
"""从队列中获取一个Request"""
# 优先从内存队列中获取
request = self.mqs.pop()
if request:
self.stats.inc_value('scheduler/dequeued/memory', spider=self.spider)
else:
# 不能获取的时候从磁盘队列队里获取
request = self._dqpop()
if request:
self.stats.inc_value('scheduler/dequeued/disk', spider=self.spider)
if request:
self.stats.inc_value('scheduler/dequeued', spider=self.spider)
# 将获取的到Request返回给Engine
return request

def __len__(self):
return len(self.dqs) + len(self.mqs) if self.dqs else len(self.mqs)

def _dqpush(self, request):
if self.dqs is None:
return
try:
reqd = request_to_dict(request, self.spider)
self.dqs.push(reqd, -request.priority)
except ValueError as e: # non serializable request
if self.logunser:
msg = ("Unable to serialize request: %(request)s - reason:"
" %(reason)s - no more unserializable requests will be"
" logged (stats being collected)")
logger.warning(msg, {'request': request, 'reason': e},
exc_info=True, extra={'spider': self.spider})
self.logunser = False
self.stats.inc_value('scheduler/unserializable',
spider=self.spider)
return
else:
return True

def _mqpush(self, request):
self.mqs.push(request, -request.priority)

def _dqpop(self):
if self.dqs:
d = self.dqs.pop()
if d:
return request_from_dict(d, self.spider)

def _newmq(self, priority):
return self.mqclass()

def _newdq(self, priority):
return self.dqclass(join(self.dqdir, 'p%s' % priority))

def _dq(self):
activef = join(self.dqdir, 'active.json')
if exists(activef):
with open(activef) as f:
prios = json.load(f)
else:
prios = ()
q = self.pqclass(self._newdq, startprios=prios)
if q:
logger.info("Resuming crawl (%(queuesize)d requests scheduled)",
{'queuesize': len(q)}, extra={'spider': self.spider})
return q

def _dqdir(self, jobdir):
if jobdir:
dqdir = join(jobdir, 'requests.queue')
if not exists(dqdir):
os.makedirs(dqdir)
return dqdir

只挑了一些重点的写了一些注释剩下大家自己领会(才不是我懒哦 ) 从上面的代码 我们可以很清楚的知道 SCHEDULER的主要是完成了 push Request pop Request 和 去重的操作。 而且queue 操作是在内存队列中完成的。 大家看queuelib.queue就会发现基于内存的(deque) 那么去重呢?

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
class RFPDupeFilter(BaseDupeFilter):
"""Request Fingerprint duplicates filter"""

def __init__(self, path=None, debug=False):
self.file = None
self.fingerprints = set()
self.logdupes = True
self.debug = debug
self.logger = logging.getLogger(__name__)
if path:
# 此处可以看到去重其实打开了一个名叫 requests.seen的文件
# 如果是使用的磁盘的话
self.file = open(os.path.join(path, 'requests.seen'), 'a+')
self.file.seek(0)
self.fingerprints.update(x.rstrip() for x in self.file)

@classmethod
def from_settings(cls, settings):
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(job_dir(settings), debug)

def request_seen(self, request):
fp = self.request_fingerprint(request)
if fp in self.fingerprints:
# 判断我们的请求是否在这个在集合中
return True
# 没有在集合就添加进去
self.fingerprints.add(fp)
# 如果用的磁盘队列就写进去记录一下
if self.file:
self.file.write(fp + os.linesep)
  按照正常流程就是大家都会进行重复的采集;我们都知道进程之间内存中的数据不可共享的,那么你在开启多个Scrapy的时候,它们相互之间并不知道对方采集了些什么那些没有没采集。那就大家伙儿自己玩自己的了。完全没没有效率的提升啊! ![](https://thsheep-wordpress.oss-cn-beijing.aliyuncs.com/7015cb643d0c05854dab5b8457f076af.jpg) 怎么解决呢? 这就是我们Scrapy-Redis解决的问题了,不能协作不就是因为Request 和 去重这两个 不能共享吗? 那我把这两个独立出来好了。 将Scrapy中的SCHEDULER组件独立放到大家都能访问的地方不就OK啦!加上scrapy-redis后流程图就应该变成这样了? ![](https://thsheep-wordpress.oss-cn-beijing.aliyuncs.com/0a94645a8f10707fe80610b5ebeb945e.jpg) So············· 这样是不是看起来就清楚多了??? 下面我们来看看Scrapy-Redis是怎么处理的? scrapy_redis.scheduler.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class Scheduler(object):
"""Redis-based scheduler

Settings
--------
SCHEDULER_PERSIST : bool (default: False)
Whether to persist or clear redis queue.
SCHEDULER_FLUSH_ON_START : bool (default: False)
Whether to flush redis queue on start.
SCHEDULER_IDLE_BEFORE_CLOSE : int (default: 0)
How many seconds to wait before closing if no message is received.
SCHEDULER_QUEUE_KEY : str
Scheduler redis key.
SCHEDULER_QUEUE_CLASS : str
Scheduler queue class.
SCHEDULER_DUPEFILTER_KEY : str
Scheduler dupefilter redis key.
SCHEDULER_DUPEFILTER_CLASS : str
Scheduler dupefilter class.
SCHEDULER_SERIALIZER : str
Scheduler serializer.

"""

def __init__(self, server,
persist=False,
flush_on_start=False,
queue_key=defaults.SCHEDULER_QUEUE_KEY,
queue_cls=defaults.SCHEDULER_QUEUE_CLASS,
dupefilter_key=defaults.SCHEDULER_DUPEFILTER_KEY,
dupefilter_cls=defaults.SCHEDULER_DUPEFILTER_CLASS,
idle_before_close=0,
serializer=None):
"""Initialize scheduler.

Parameters
----------
server : Redis
这是Redis实例
persist : bool
是否在关闭时清空Requests.默认值是False。
flush_on_start : bool
是否在启动时清空Requests。 默认值是False。
queue_key : str
Request队列的Key名字
queue_cls : str
队列的可导入路径(就是使用什么队列)
dupefilter_key : str
去重队列的Key
dupefilter_cls : str
去重类的可导入路径。
idle_before_close : int
等待多久关闭

"""
if idle_before_close < 0:
raise TypeError("idle_before_close cannot be negative")

self.server = server
self.persist = persist
self.flush_on_start = flush_on_start
self.queue_key = queue_key
self.queue_cls = queue_cls
self.dupefilter_cls = dupefilter_cls
self.dupefilter_key = dupefilter_key
self.idle_before_close = idle_before_close
self.serializer = serializer
self.stats = None

def __len__(self):
return len(self.queue)

@classmethod
def from_settings(cls, settings):
kwargs = {
'persist': settings.getbool('SCHEDULER_PERSIST'),
'flush_on_start': settings.getbool('SCHEDULER_FLUSH_ON_START'),
'idle_before_close': settings.getint('SCHEDULER_IDLE_BEFORE_CLOSE'),
}

# If these values are missing, it means we want to use the defaults.
optional = {
# TODO: Use custom prefixes for this settings to note that are
# specific to scrapy-redis.
'queue_key': 'SCHEDULER_QUEUE_KEY',
'queue_cls': 'SCHEDULER_QUEUE_CLASS',
'dupefilter_key': 'SCHEDULER_DUPEFILTER_KEY',
# We use the default setting name to keep compatibility.
'dupefilter_cls': 'DUPEFILTER_CLASS',
'serializer': 'SCHEDULER_SERIALIZER',
}
# 从setting中获取配置组装成dict(具体获取那些配置是optional字典中key)
for name, setting_name in optional.items():
val = settings.get(setting_name)
if val:
kwargs[name] = val

# Support serializer as a path to a module.
if isinstance(kwargs.get('serializer'), six.string_types):
kwargs['serializer'] = importlib.import_module(kwargs['serializer'])
# 或得一个Redis连接
server = connection.from_settings(settings)
# Ensure the connection is working.
server.ping()

return cls(server=server, **kwargs)

@classmethod
def from_crawler(cls, crawler):
instance = cls.from_settings(crawler.settings)
# FIXME: for now, stats are only supported from this constructor
instance.stats = crawler.stats
return instance

def open(self, spider):
self.spider = spider

try:
# 根据self.queue_cls这个可以导入的类 实例化一个队列
self.queue = load_object(self.queue_cls)(
server=self.server,
spider=spider,
key=self.queue_key % {'spider': spider.name},
serializer=self.serializer,
)
except TypeError as e:
raise ValueError("Failed to instantiate queue class '%s': %s",
self.queue_cls, e)

try:
# 根据self.dupefilter_cls这个可以导入的类 实例一个去重集合
# 默认是集合 可以实现自己的去重方式 比如 bool 去重
self.df = load_object(self.dupefilter_cls)(
server=self.server,
key=self.dupefilter_key % {'spider': spider.name},
debug=spider.settings.getbool('DUPEFILTER_DEBUG'),
)
except TypeError as e:
raise ValueError("Failed to instantiate dupefilter class '%s': %s",
self.dupefilter_cls, e)

if self.flush_on_start:
self.flush()
# notice if there are requests already in the queue to resume the crawl
if len(self.queue):
spider.log("Resuming crawl (%d requests scheduled)" % len(self.queue))

def close(self, reason):
if not self.persist:
self.flush()

def flush(self):
self.df.clear()
self.queue.clear()

def enqueue_request(self, request):
"""这个和Scrapy本身的一样"""
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
if self.stats:
self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider)
# 向队列里面添加一个Request
self.queue.push(request)
return True

def next_request(self):
"""获取一个Request"""
block_pop_timeout = self.idle_before_close
# block_pop_timeout 是一个等待参数 队列没有东西会等待这个时间 超时就会关闭
request = self.queue.pop(block_pop_timeout)
if request and self.stats:
self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
return request

def has_pending_requests(self):
return len(self) > 0

来先来看看 以上就是Scrapy-Redis中的SCHEDULER模块。下面我们来看看queue和本身的什么不同: scrapy_redis.queue.py 以最常用的优先级队列 PriorityQueue 举例:

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
class PriorityQueue(Base):
"""Per-spider priority queue abstraction using redis' sorted set"""
"""其实就是使用Redis的有序集合 来对Request进行排序,这样就可以优先级高的在有序集合的顶层 我们只需要"""
"""从上往下依次获取Request即可"""
def __len__(self):
"""Return the length of the queue"""
return self.server.zcard(self.key)

def push(self, request):
"""Push a request"""
"""添加一个Request进队列"""
# self._encode_request 将Request请求进行序列化
data = self._encode_request(request)
"""
d = {
'url': to_unicode(request.url), # urls should be safe (safe_string_url)
'callback': cb,
'errback': eb,
'method': request.method,
'headers': dict(request.headers),
'body': request.body,
'cookies': request.cookies,
'meta': request.meta,
'_encoding': request._encoding,
'priority': request.priority,
'dont_filter': request.dont_filter,
'flags': request.flags,
'_class': request.__module__ + '.' + request.__class__.__name__
}

data就是上面这个字典的序列化
在Scrapy.utils.reqser.py 中的request_to_dict方法中处理
"""

# 在Redis有序集合中数值越小优先级越高(就是会被放在顶层)所以这个位置是取得 相反数
score = -request.priority
# We don't use zadd method as the order of arguments change depending on
# whether the class is Redis or StrictRedis, and the option of using
# kwargs only accepts strings, not bytes.
# ZADD 是添加进有序集合
self.server.execute_command('ZADD', self.key, score, data)

def pop(self, timeout=0):
"""
Pop a request
timeout not support in this queue class
有序集合不支持超时所以就木有使用timeout了 这个timeout就是挂羊头卖狗肉
"""
"""从有序集合中取出一个Request"""
# use atomic range/remove using multi/exec
"""使用multi的原因是为了将获取Request和删除Request合并成一个操作(原子性的)在获取到一个元素之后 删除它,因为有序集合 不像list 有pop 这种方式啊"""
pipe = self.server.pipeline()
pipe.multi()
# 取出 顶层第一个
# zrange :返回有序集 key 中,指定区间内的成员。0,0 就是第一个了
# zremrangebyrank:移除有序集 key 中,指定排名(rank)区间内的所有成员 0,0也就是第一个了
# 更多请参考Redis官方文档
pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0)
results, count = pipe.execute()
if results:
return self._decode_request(results[0])

以上就是SCHEDULER在处理Request的时候做的操作了。 是时候来看看SCHEDULER是怎么处理去重的了! 只需要注意这个?方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def request_seen(self, request):
"""Returns True if request was already seen.

Parameters
----------
request : scrapy.http.Request

Returns
-------
bool

"""
# 通过self.request_fingerprint 会生一个sha1的指纹
fp = self.request_fingerprint(request)
# This returns the number of values added, zero if already exists.
# 添加进一个Redis集合如果self.key这个集合中存在fp这个指纹会返回1 不存在返回0
added = self.server.sadd(self.key, fp)
return added == 0
这样大家就都可以访问同一个Redis 获取同一个spider的Request 在同一个位置去重,就不用担心重复啦 大概就像这样:
  1. spider1:检查一下这个Request是否在Redis去重,如果在就证明其它的spider采集过啦!如果不在就添加进调度队列,等待别 人获取。自己继续干活抓取网页 产生新的Request了 重复之前步骤。
  2. spider2:以相同的逻辑执行

可能有些小伙儿会产生疑问了~~!spider2拿到了别人的Request了 怎么能正确的执行呢?逻辑不会错吗? 这个不用担心啦 因为整Request当中包含了,所有的逻辑,回去看看上面那个序列化的字典。 总结一下:

  1. 1. Scrapy-Reids 就是将Scrapy原本在内存中处理的 调度(就是一个队列Queue)、去重、这两个操作通过Redis来实现
  2. 多个Scrapy在采集同一个站点时会使用相同的redis key(可以理解为队列)添加Request 获取Request 去重Request,这样所有的spider不会进行重复采集。效率自然就嗖嗖的上去了。
  3. 3. Redis是原子性的,好处不言而喻(一个Request要么被处理 要么没被处理,不存在第三可能)

另外Scrapy-Redis本身不支持Redis-Cluster,大量网站去重的话会给单机很大的压力(就算使用boolfilter 内存也不够整啊!) 改造方式很简单:

  1. 使用 rediscluster 这个包替换掉本身的Redis连接
  2. Redis-Cluster 不支持事务,可以使用lua脚本进行代替(lua脚本是原子性的哦)
  3. 注意使用lua脚本 不能写占用时间很长的操作(毕竟一大群人等着操作Redis 你总不能让人家等着吧)

以上!完毕 对于懒人小伙伴儿 看看这个我改好的: 集群版Scrapy-Redis PS: 支持Python3.6+ 哦 ! 其余的版本没测试过

Python

在 PyCon 2018 上,Mario Corchero 介绍了在开发过程中如何更方便轻松地记录日志的流程。

整个演讲的内容包括:

  • 为什么日志记录非常重要
  • 日志记录的流程是怎样的
  • 怎样来进行日志记录
  • 怎样进行日志记录相关配置
  • 日志记录使用常见误区

下面我们来梳理一下整个演讲的过程,其实其核心就是介绍了 logging 模块的使用方法和一些配置。

日志记录的重要性

在开发过程中,如果程序运行出现了问题,我们是可以使用我们自己的 Debug 工具来检测到到底是哪一步出现了问题,如果出现了问题的话,是很容易排查的。但程序开发完成之后,我们会将它部署到生产环境中去,这时候代码相当于是在一个黑盒环境下运行的,我们只能看到其运行的效果,是不能直接看到代码运行过程中每一步的状态的。在这个环境下,运行过程中难免会在某个地方出现问题,甚至这个问题可能是我们开发过程中未曾遇到的问题,碰到这种情况应该怎么办? 如果我们现在只能得知当前问题的现象,而没有其他任何信息的话,如果我们想要解决掉这个问题的话,那么只能根据问题的现象来试图复现一下,然后再一步步去调试,这恐怕是很难的,很大的概率上我们是无法精准地复现这个问题的,而且 Debug 的过程也会耗费巨多的时间,这样一旦生产环境上出现了问题,修复就会变得非常棘手。但这如果我们当时有做日志记录的话,不论是正常运行还是出现报错,都有相关的时间记录,状态记录,错误记录等,那么这样我们就可以方便地追踪到在当时的运行过程中出现了怎样的状况,从而可以快速排查问题。 因此,日志记录是非常有必要的,任何一款软件如果没有标准的日志记录,都不能算作一个合格的软件。作为开发者,我们需要重视并做好日志记录过程。

日志记录的流程框架

那么在 Python 中,怎样才能算作一个比较标准的日志记录过程呢?或许很多人会使用 print 语句输出一些运行信息,然后再在控制台观察,运行的时候再将输出重定向到文件输出流保存到文件中,这样其实是非常不规范的,在 Python 中有一个标准的 logging 模块,我们可以使用它来进行标注的日志记录,利用它我们可以更方便地进行日志记录,同时还可以做更方便的级别区分以及一些额外日志信息的记录,如时间、运行模块信息等。 接下来我们先了解一下日志记录流程的整体框架。 如图所示,整个日志记录的框架可以分为这么几个部分:

  • Logger:即 Logger Main Class,是我们进行日志记录时创建的对象,我们可以调用它的方法传入日志模板和信息,来生成一条条日志记录,称作 Log Record。
  • Log Record:就代指生成的一条条日志记录。
  • Handler:即用来处理日志记录的类,它可以将 Log Record 输出到我们指定的日志位置和存储形式等,如我们可以指定将日志通过 FTP 协议记录到远程的服务器上,Handler 就会帮我们完成这些事情。
  • Formatter:实际上生成的 Log Record 也是一个个对象,那么我们想要把它们保存成一条条我们想要的日志文本的话,就需要有一个格式化的过程,那么这个过程就由 Formatter 来完成,返回的就是日志字符串,然后传回给 Handler 来处理。
  • Filter:另外保存日志的时候我们可能不需要全部保存,我们可能只需要保存我们想要的部分就可以了,所以保存前还需要进行一下过滤,留下我们想要的日志,如只保存某个级别的日志,或只保存包含某个关键字的日志等,那么这个过滤过程就交给 Filter 来完成。
  • Parent Handler:Handler 之间可以存在分层关系,以使得不同 Handler 之间共享相同功能的代码。

以上就是整个 logging 模块的基本架构和对象功能,了解了之后我们详细来了解一下 logging 模块的用法。

日志记录的相关用法

总的来说 logging 模块相比 print 有这么几个优点:

  • 可以在 logging 模块中设置日志等级,在不同的版本(如开发环境、生产环境)上通过设置不同的输出等级来记录对应的日志,非常灵活。
  • print 的输出信息都会输出到标准输出流中,而 logging 模块就更加灵活,可以设置输出到任意位置,如写入文件、写入远程服务器等。
  • logging 模块具有灵活的配置和格式化功能,如配置输出当前模块信息、运行时间等,相比 print 的字符串格式化更加方便易用。

下面我们初步来了解下 logging 模块的基本用法,先用一个实例来感受一下:

1
2
3
4
5
6
7
8
9
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

logger.info('This is a log info')
logger.debug('Debugging')
logger.warning('Warning exists')
logger.info('Finish')

在这里我们首先引入了 logging 模块,然后进行了一下基本的配置,这里通过 basicConfig 配置了 level 信息和 format 信息,这里 level 配置为 INFO 信息,即只输出 INFO 级别的信息,另外这里指定了 format 格式的字符串,包括 asctime、name、levelname、message 四个内容,分别代表运行时间、模块名称、日志级别、日志内容,这样输出内容便是这四者组合而成的内容了,这就是 logging 的全局配置。 接下来声明了一个 Logger 对象,它就是日志输出的主类,调用对象的 info() 方法就可以输出 INFO 级别的日志信息,调用 debug() 方法就可以输出 DEBUG 级别的日志信息,非常方便。在初始化的时候我们传入了模块的名称,这里直接使用 name 来代替了,就是模块的名称,如果直接运行这个脚本的话就是 main,如果是 import 的模块的话就是被引入模块的名称,这个变量在不同的模块中的名字是不同的,所以一般使用 name 来表示就好了,再接下来输出了四条日志信息,其中有两条 INFO、一条 WARNING、一条 DEBUG 信息,我们看下输出结果:

1
2
3
2018-06-03 13:42:43,526 - __main__ - INFO - This is a log info
2018-06-03 13:42:43,526 - __main__ - WARNING - Warning exists
2018-06-03 13:42:43,526 - __main__ - INFO - Finish

可以看到输出结果一共有三条日志信息,每条日志都是对应了指定的格式化内容,另外我们发现 DEBUG 的信息是没有输出的,这是因为我们在全局配置的时候设置了输出为 INFO 级别,所以 DEBUG 级别的信息就被过滤掉了。 这时如果我们将输出的日志级别设置为 DEBUG,就可以看到 DEBUG 级别的日志输出了:

1
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

输出结果:

1
2
3
4
2018-06-03 13:49:22,770 - __main__ - INFO - This is a log info
2018-06-03 13:49:22,770 - __main__ - DEBUG - Debugging
2018-06-03 13:49:22,770 - __main__ - WARNING - Warning exists
2018-06-03 13:49:22,770 - __main__ - INFO - Finish

由此可见,相比 print 来说,通过刚才的代码,我们既可以输出时间、模块名称,又可以输出不同级别的日志信息作区分并加以过滤,是不是灵活多了? 当然这只是 logging 模块的一小部分功能,接下来我们首先来全面了解一下 basicConfig 的参数都有哪些:

  • filename:即日志输出的文件名,如果指定了这个信息之后,实际上会启用 FileHandler,而不再是 StreamHandler,这样日志信息便会输出到文件中了。
  • filemode:这个是指定日志文件的写入方式,有两种形式,一种是 w,一种是 a,分别代表清除后写入和追加写入。
  • format:指定日志信息的输出格式,即上文示例所示的参数,详细参数可以参考:docs.python.org/3/library/l…,部分参数如下所示:
    • %(levelno)s:打印日志级别的数值。
    • %(levelname)s:打印日志级别的名称。
    • %(pathname)s:打印当前执行程序的路径,其实就是sys.argv[0]。
    • %(filename)s:打印当前执行程序名。
    • %(funcName)s:打印日志的当前函数。
    • %(lineno)d:打印日志的当前行号。
    • %(asctime)s:打印日志的时间。
    • %(thread)d:打印线程ID。
    • %(threadName)s:打印线程名称。
    • %(process)d:打印进程ID。
    • %(processName)s:打印线程名称。
    • %(module)s:打印模块名称。
    • %(message)s:打印日志信息。
  • datefmt:指定时间的输出格式。
  • style:如果 format 参数指定了,这个参数就可以指定格式化时的占位符风格,如 %、{、$ 等。
  • level:指定日志输出的类别,程序会输出大于等于此级别的信息。
  • stream:在没有指定 filename 的时候会默认使用 StreamHandler,这时 stream 可以指定初始化的文件流。
  • handlers:可以指定日志处理时所使用的 Handlers,必须是可迭代的。

下面我们再用一个实例来感受一下:

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

logging.basicConfig(level=logging.DEBUG,
filename='output.log',
datefmt='%Y/%m/%d %H:%M:%S',
format='%(asctime)s - %(name)s - %(levelname)s - %(lineno)d - %(module)s - %(message)s')
logger = logging.getLogger(__name__)

logger.info('This is a log info')
logger.debug('Debugging')
logger.warning('Warning exists')
logger.info('Finish')

这里我们指定了输出文件的名称为 output.log,另外指定了日期的输出格式,其中年月日的格式变成了 %Y/%m/%d,另外输出的 format 格式增加了 lineno、module 这两个信息,运行之后便会生成一个 output.log 的文件,内容如下:

1
2
3
4
2018/06/03 14:43:26 - __main__ - INFO - 9 - demo3 - This is a log info
2018/06/03 14:43:26 - __main__ - DEBUG - 10 - demo3 - Debugging
2018/06/03 14:43:26 - __main__ - WARNING - 11 - demo3 - Warning exists
2018/06/03 14:43:26 - __main__ - INFO - 12 - demo3 - Finish

可以看到日志便会输出到文件中,同时输出了行号、模块名称等信息。 以上我们通过 basicConfig 来进行了一些全局的配置,我们同样可以使用 Formatter、Handler 进行更灵活的处理,下面我们来了解一下。

Level

首先我们来了解一下输出日志的等级信息,logging 模块共提供了如下等级,每个等级其实都对应了一个数值,列表如下:

等级

数值

CRITICAL

50

FATAL

50

ERROR

40

WARNING

30

WARN

30

INFO

20

DEBUG

10

NOTSET

0

这里最高的等级是 CRITICAL 和 FATAL,两个对应的数值都是 50,另外对于 WARNING 还提供了简写形式 WARN,两个对应的数值都是 30。 我们设置了输出 level,系统便只会输出 level 数值大于或等于该 level 的的日志结果,例如我们设置了输出日志 level 为 INFO,那么输出级别大于等于 INFO 的日志,如 WARNING、ERROR 等,DEBUG 和 NOSET 级别的不会输出。

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

logger = logging.getLogger(__name__)
logger.setLevel(level=logging.WARN)

# Log
logger.debug('Debugging')
logger.critical('Critical Something')
logger.error('Error Occurred')
logger.warning('Warning exists')
logger.info('Finished')

这里我们设置了输出级别为 WARN,然后对应输出了五种不同级别的日志信息,运行结果如下:

1
2
3
Critical Something
Error Occurred
Warning exists

可以看到只有 CRITICAL、ERROR、WARNING 信息输出了,DEBUG、INFO 信息没有输出。

Handler

下面我们先来了解一下 Handler 的用法,看下面的实例:

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

logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
handler = logging.FileHandler('output.log')
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

logger.info('This is a log info')
logger.debug('Debugging')
logger.warning('Warning exists')
logger.info('Finish')

这里我们没有再使用 basicConfig 全局配置,而是先声明了一个 Logger 对象,然后指定了其对应的 Handler 为 FileHandler 对象,然后 Handler 对象还单独指定了 Formatter 对象单独配置输出格式,最后给 Logger 对象添加对应的 Handler 即可,最后可以发现日志就会被输出到 output.log 中,内容如下:

1
2
3
2018-06-03 14:53:36,467 - __main__ - INFO - This is a log info
2018-06-03 14:53:36,468 - __main__ - WARNING - Warning exists
2018-06-03 14:53:36,468 - __main__ - INFO - Finish

另外我们还可以使用其他的 Handler 进行日志的输出,logging 模块提供的 Handler 有:

  • StreamHandler:logging.StreamHandler;日志输出到流,可以是 sys.stderr,sys.stdout 或者文件。
  • FileHandler:logging.FileHandler;日志输出到文件。
  • BaseRotatingHandler:logging.handlers.BaseRotatingHandler;基本的日志回滚方式。
  • RotatingHandler:logging.handlers.RotatingHandler;日志回滚方式,支持日志文件最大数量和日志文件回滚。
  • TimeRotatingHandler:logging.handlers.TimeRotatingHandler;日志回滚方式,在一定时间区域内回滚日志文件。
  • SocketHandler:logging.handlers.SocketHandler;远程输出日志到TCP/IP sockets。
  • DatagramHandler:logging.handlers.DatagramHandler;远程输出日志到UDP sockets。
  • SMTPHandler:logging.handlers.SMTPHandler;远程输出日志到邮件地址。
  • SysLogHandler:logging.handlers.SysLogHandler;日志输出到syslog。
  • NTEventLogHandler:logging.handlers.NTEventLogHandler;远程输出日志到Windows NT/2000/XP的事件日志。
  • MemoryHandler:logging.handlers.MemoryHandler;日志输出到内存中的指定buffer。
  • HTTPHandler:logging.handlers.HTTPHandler;通过”GET”或者”POST”远程输出到HTTP服务器。

下面我们使用三个 Handler 来实现日志同时输出到控制台、文件、HTTP 服务器:

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
import logging
from logging.handlers import HTTPHandler
import sys

logger = logging.getLogger(__name__)
logger.setLevel(level=logging.DEBUG)

# StreamHandler
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setLevel(level=logging.DEBUG)
logger.addHandler(stream_handler)

# FileHandler
file_handler = logging.FileHandler('output.log')
file_handler.setLevel(level=logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)

# HTTPHandler
http_handler = HTTPHandler(host='localhost:8001', url='log', method='POST')
logger.addHandler(http_handler)

# Log
logger.info('This is a log info')
logger.debug('Debugging')
logger.warning('Warning exists')
logger.info('Finish')

运行之前我们需要先启动 HTTP Server,并运行在 8001 端口,其中 log 接口是用来接收日志的接口。 运行之后控制台输出会输出如下内容:

1
2
3
4
This is a log info
Debugging
Warning exists
Finish

output.log 文件会写入如下内容:

1
2
3
2018-06-03 15:13:44,895 - __main__ - INFO - This is a log info
2018-06-03 15:13:44,947 - __main__ - WARNING - Warning exists
2018-06-03 15:13:44,949 - __main__ - INFO - Finish

HTTP Server 会收到控制台输出的信息。 这样一来,我们就通过设置多个 Handler 来控制了日志的多目标输出。 另外值得注意的是,在这里 StreamHandler 对象我们没有设置 Formatter,因此控制台只输出了日志的内容,而没有包含时间、模块等信息,而 FileHandler 我们通过 setFormatter() 方法设置了一个 Formatter 对象,因此输出的内容便是格式化后的日志信息。 另外每个 Handler 还可以设置 level 信息,最终输出结果的 level 信息会取 Logger 对象的 level 和 Handler 对象的 level 的交集。

Formatter

在进行日志格式化输出的时候,我们可以不借助于 basicConfig 来全局配置格式化输出内容,可以借助于 Formatter 来完成,下面我们再来单独看下 Formatter 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import logging

logger = logging.getLogger(__name__)
logger.setLevel(level=logging.WARN)
formatter = logging.Formatter(fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y/%m/%d %H:%M:%S')
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)

# Log
logger.debug('Debugging')
logger.critical('Critical Something')
logger.error('Error Occurred')
logger.warning('Warning exists')
logger.info('Finished')

在这里我们指定了一个 Formatter,并传入了 fmt 和 datefmt 参数,这样就指定了日志结果的输出格式和时间格式,然后 handler 通过 setFormatter() 方法设置此 Formatter 对象即可,输出结果如下:

1
2
3
2018/06/03 15:47:15 - __main__ - CRITICAL - Critical Something
2018/06/03 15:47:15 - __main__ - ERROR - Error Occurred
2018/06/03 15:47:15 - __main__ - WARNING - Warning exists

这样我们可以每个 Handler 单独配置输出的格式,非常灵活。

捕获 Traceback

如果遇到错误,我们更希望报错时出现的详细 Traceback 信息,便于调试,利用 logging 模块我们可以非常方便地实现这个记录,我们用一个实例来感受一下:

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

logger = logging.getLogger(__name__)
logger.setLevel(level=logging.DEBUG)

# Formatter
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')

# FileHandler
file_handler = logging.FileHandler('result.log')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)

# StreamHandler
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

# Log
logger.info('Start')
logger.warning('Something maybe fail.')
try:
result = 10 / 0
except Exception:
logger.error('Faild to get result', exc_info=True)
logger.info('Finished')

这里我们在 error() 方法中添加了一个参数,将 exc_info 设置为了 True,这样我们就可以输出执行过程中的信息了,即完整的 Traceback 信息。 运行结果如下:

1
2
3
4
5
6
7
8
9
2018-06-03 16:00:15,382 - __main__ - INFO - Start print log
2018-06-03 16:00:15,382 - __main__ - DEBUG - Do something
2018-06-03 16:00:15,382 - __main__ - WARNING - Something maybe fail.
2018-06-03 16:00:15,382 - __main__ - ERROR - Faild to get result
Traceback (most recent call last):
File "/private/var/books/aicodes/loggingtest/demo8.py", line 23, in <module>
result = 10 / 0
ZeroDivisionError: division by zero
2018-06-03 16:00:15,383 - __main__ - INFO - Finished

可以看到这样我们就非常方便地记录下来了报错的信息,一旦出现了错误,我们也能非常方便地排查。

配置共享

在写项目的时候,我们肯定会将许多配置放置在许多模块下面,这时如果我们每个文件都来配置 logging 配置那就太繁琐了,logging 模块提供了父子模块共享配置的机制,会根据 Logger 的名称来自动加载父模块的配置。 例如我们这里首先定义一个 main.py 文件:

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

logger = logging.getLogger('main')
logger.setLevel(level=logging.DEBUG)

# Handler
handler = logging.FileHandler('result.log')
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

logger.info('Main Info')
logger.debug('Main Debug')
logger.error('Main Error')
core.run()

这里我们配置了日志的输出格式和文件路径,同时定义了 Logger 的名称为 main,然后引入了另外一个模块 core,最后调用了 core 的 run() 方法。 接下来我们定义 core.py,内容如下:

1
2
3
4
5
6
7
8
import logging

logger = logging.getLogger('main.core')

def run():
logger.info('Core Info')
logger.debug('Core Debug')
logger.error('Core Error')

这里我们定义了 Logger 的名称为 main.core,注意这里开头是 main,即刚才我们在 main.py 里面的 Logger 的名称,这样 core.py 里面的 Logger 就会复用 main.py 里面的 Logger 配置,而不用再去配置一次了。 运行之后会生成一个 result.log 文件,内容如下:

1
2
3
4
2018-06-03 16:55:56,259 - main - INFO - Main Info
2018-06-03 16:55:56,259 - main - ERROR - Main Error
2018-06-03 16:55:56,259 - main.core - INFO - Core Info
2018-06-03 16:55:56,259 - main.core - ERROR - Core Error

可以看到父子模块都使用了同样的输出配置。 如此一来,我们只要在入口文件里面定义好 logging 模块的输出配置,子模块只需要在定义 Logger 对象时名称使用父模块的名称开头即可共享配置,非常方便。

文件配置

在开发过程中,将配置在代码里面写死并不是一个好的习惯,更好的做法是将配置写在配置文件里面,我们可以将配置写入到配置文件,然后运行时读取配置文件里面的配置,这样是更方便管理和维护的,下面我们以一个实例来说明一下,首先我们定义一个 yaml 配置文件:

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
version: 1
formatters:
brief:
format: "%(asctime)s - %(message)s"
simple:
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handlers:
console:
class : logging.StreamHandler
formatter: brief
level : INFO
stream : ext://sys.stdout
file:
class : logging.FileHandler
formatter: simple
level: DEBUG
filename: debug.log
error:
class: logging.handlers.RotatingFileHandler
level: ERROR
formatter: simple
filename: error.log
maxBytes: 10485760
backupCount: 20
encoding: utf8
loggers:
main.core:
level: DEBUG
handlers: [console, file, error]
root:
level: DEBUG
handlers: [console]

这里我们定义了 formatters、handlers、loggers、root 等模块,实际上对应的就是各个 Formatter、Handler、Logger 的配置,参数和它们的构造方法都是相同的。 接下来我们定义一个主入口文件,main.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
28
import logging
import core
import yaml
import logging.config
import os


def setup_logging(default_path='config.yaml', default_level=logging.INFO):
path = default_path
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
config = yaml.load(f)
logging.config.dictConfig(config)
else:
logging.basicConfig(level=default_level)


def log():
logging.debug('Start')
logging.info('Exec')
logging.info('Finished')


if __name__ == '__main__':
yaml_path = 'config.yaml'
setup_logging(yaml_path)
log()
core.run()

这里我们定义了一个 setup_logging() 方法,里面读取了 yaml 文件的配置,然后通过 dictConfig() 方法将配置项传给了 logging 模块进行全局初始化。 另外这个模块还引入了另外一个模块 core,所以我们定义 core.py 如下:

1
2
3
4
5
6
7
8
import logging

logger = logging.getLogger('main.core')

def run():
logger.info('Core Info')
logger.debug('Core Debug')
logger.error('Core Error')

这个文件的内容和上文是没有什么变化的。 观察配置文件,主入口文件 main.py 实际上对应的是 root 一项配置,它指定了 handlers 是 console,即只输出到控制台。另外在 loggers 一项配置里面,我们定义了 main.core 模块,handlers 是 console、file、error 三项,即输出到控制台、输出到普通文件和回滚文件。 这样运行之后,我们便可以看到所有的运行结果输出到了控制台:

1
2
3
4
5
6
2018-06-03 17:07:12,727 - Exec
2018-06-03 17:07:12,727 - Finished
2018-06-03 17:07:12,727 - Core Info
2018-06-03 17:07:12,727 - Core Info
2018-06-03 17:07:12,728 - Core Error
2018-06-03 17:07:12,728 - Core Error

在 debug.log 文件中则包含了 core.py 的运行结果:

1
2
3
2018-06-03 17:07:12,727 - main.core - INFO - Core Info
2018-06-03 17:07:12,727 - main.core - DEBUG - Core Debug
2018-06-03 17:07:12,728 - main.core - ERROR - Core Error

可以看到,通过配置文件,我们可以非常灵活地定义 Handler、Formatter、Logger 等配置,同时也显得非常直观,也非常容易维护,在实际项目中,推荐使用此种方式进行配置。 以上便是 logging 模块的基本使用方法,有了它,我们可以方便地进行日志管理和维护,会给我们的工作带来极大的方便。

日志记录使用常见误区

在日志输出的时候经常我们会用到字符串拼接的形式,很多情况下我们可能会使用字符串的 format() 来构造一个字符串,但这其实并不是一个好的方法,因为还有更好的方法,下面我们对比两个例子:

1
2
3
4
5
6
7
8
import logging

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

# bad
logging.debug('Hello {0}, {1}!'.format('World', 'Congratulations'))
# good
logging.debug('Hello %s, %s!', 'World', 'Congratulations')

这里有两种打印 Log 的方法,第一种使用了字符串的 format() 的方法进行构造,传给 logging 的只用到了第一个参数,实际上 logging 模块提供了字符串格式化的方法,我们只需要在第一个参数写上要打印输出的模板,占位符用 %s、%d 等表示即可,然后在后续参数添加对应的值就可以了,推荐使用这种方法。 运行结果如下:

1
2
2018-06-03 22:27:51,220 - root - DEBUG - Hello World, Congratulations!
2018-06-03 22:27:51,220 - root - DEBUG - Hello World, Congratulations!

另外在进行异常处理的时候,通常我们会直接将异常进行字符串格式化,但其实可以直接指定一个参数将 traceback 打印出来,示例如下:

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

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

try:
result = 5 / 0
except Exception as e:
# bad
logging.error('Error: %s', e)
# good
logging.error('Error', exc_info=True)
# good
logging.exception('Error')

如果我们直接使用字符串格式化的方法将错误输出的话,是不会包含 Traceback 信息的,但如果我们加上 exc_info 参数或者直接使用 exception() 方法打印的话,那就会输出 Traceback 信息了。 运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
2018-06-03 22:24:31,927 - root - ERROR - Error: division by zero
2018-06-03 22:24:31,927 - root - ERROR - Error
Traceback (most recent call last):
File "/private/var/books/aicodes/loggingtest/demo9.py", line 6, in <module>
result = 5 / 0
ZeroDivisionError: division by zero
2018-06-03 22:24:31,928 - root - ERROR - Error
Traceback (most recent call last):
File "/private/var/books/aicodes/loggingtest/demo9.py", line 6, in <module>
result = 5 / 0
ZeroDivisionError: division by zero

以上便是整个对 logging 模块的介绍。嗯,是时候抛弃 print 了,开始体验下 logging 的便利吧!

参考内容

  • https://docs.python.org/3/library/logging.html * http://www.cnblogs.com/dahu-daqing/p/7040764.html