Leetao's Blog

Talk is cheap, show me the code

0%

前言

今天在 windows 下打包 PyQt5 应用出现了 ImportError: unable to find Qt5Core.dll on PATH 错误,搜索了一下找到以下解决办法

解决办法

使用 –path 参数

由于我们打包出错的原因是缺少 Qt5Core.dll,所以在打包过程中,指定路径。使用方法如下

1
pyinstaller --path path_to_\Python\Lib\site-packages\PyQt5\Qt\bin demo.py

这种方法没有成功,从打包信息来看,pyinstaller 并没有使用我指定的路径。github 也有人提到这个问题

1571143796623.jpg

具体信息可以点击 PyQT 5.7 DLLs are not bundled 查看

升级 pyinstaller

也有人提到升级 pyinstaller 到最新版本可以解决,同样没有成功。使用方法:

1
pip install --upgrade pyinstaller

构建导入依赖的脚本

最后在 stackoverflow 中找到了解决办法 How to fix “ImportError: unable to find Qt5Core.dll on PATH” after pyinstaller bundled the python application,通过构建一个依赖脚本,在引入 PyQt5 相关库之前,指定依赖的路径。

1
2
3
4
5
import fix_qt_import_error # 依赖脚本

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *

这个成功解决了我的问题,下面是脚本具体内容 fix_qt_import_error.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
# Fix qt import error
# Include this file before import PyQt5
import os
import sys
import logging


def _append_run_path():
if getattr(sys, 'frozen', False):
pathlist = []

# If the application is run as a bundle, the pyInstaller bootloader
# extends the sys module by a flag frozen=True and sets the app
# path into variable _MEIPASS'.
pathlist.append(sys._MEIPASS)

# the application exe path
_main_app_path = os.path.dirname(sys.executable)
pathlist.append(_main_app_path)

# append to system path enviroment
os.environ["PATH"] += os.pathsep + os.pathsep.join(pathlist)

logging.error("current PATH: %s", os.environ['PATH'])


_append_run_path()

猜想

之前没有安装 Anaconda,打包一切正常,默认会使用虚拟环境的 python 解释器,现在打包的时候,发现默认使用的解释器是 Anaconda 的 base 环境下的,可能这也是问题的原因之一,有待后续考证。。。

前言

listPython 常用的几个基本数据类型之一.正常情况下我们会对 list 有增删改查的操作,显然易见不会有任何问题.那么如果我们试着在多线程下操作list 会有问题吗?

多线程下的 list

安全 or 不安全? 不安全!

通常我们说的线程安全是指针对某个数据结构的所有操作都是线程安全,在这种定义下,Python 常用的数据结构 list,dict,str 等都是线程不安全的

尽管多线程下的 list 是线程不安全的,但是在 append 的操作下是它又是线程安全的.

如何判断线程安全呢?

对于线程安全不安全,我们可以通过极端条件下去复现,从而得出结论。比如说判断 list 是否线程安全

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 threading
import time

# 随意设置 count 的值,值越大错误抛出的越快
count = 1000
l = []

def add():
for i in range(count):
l.append(i)
time.sleep(0.0001)

def remove():

for i in range(count):
l.remove(i)
time.sleep(0.0001)


t1 = threading.Thread(target=add)
t2 = threading.Thread(target=remove)
t1.start()
t2.start()
t1.join()
t2.join()
print(l)

有时候一次运行并不一定就会出错,多次重试之后会出现类似下面的错误

Image.png

很显然这种操作方式不具有普适性,如果要是欧气太强,说不定会一直不出现异常。

那么出了这种方式,有没有比较简单有效的方法吗?答案是有的

dis

dis 库是 Python 自带的一个库,可以用来分析字节码。这里我们需要有这样的认识,字节码的每一行都是一个原子操作,多线程切换就是以原子操作为单位的,如果一个操作需要两行字节码就说明它是线程不安全的

remove

这里我们先看一下上面 listremove 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import dis
>>> def test_remove():
... a = [1]
... a.remove(0)
...
>>> dis.dis(test_remove)
2 0 LOAD_CONST 1 (1)
2 BUILD_LIST 1
4 STORE_FAST 0 (a)

3 6 LOAD_FAST 0 (a)
8 LOAD_ATTR 0 (remove)
10 LOAD_CONST 2 (0)
12 CALL_FUNCTION 1
14 POP_TOP
16 LOAD_CONST 0 (None)
18 RETURN_VALUE

从上面不难看出,整个 remove 操作被分成了好几条指令,这就意味着在多线程情况下会出现错乱的情况,试想一下,如果多线程下都去 remove 列表的话,并且不按照顺序,很容易出现问题。

append

在最上面我们说到,listappend 操作是线程安全的,那么究竟是为什么呢?我们同样来用 dis 查看一下

1
2
3
4
5
8          19 LOAD_GLOBAL              0 (a)
22 LOAD_ATTR 2 (append)
25 LOAD_CONST 2 (1)
28 CALL_FUNCTION 1
31 POP_TOP

这里显然,append 也是有几条指令,势必在多线程执行的情况下也会发生交错,但是对于多线程下我们操作 append, 我们肯定也不会在乎这个时候 list 到顺序问题了,所以我们说它的 append 是线程安全的

参考

what-kinds-of-global-value-mutation-are-thread-safe

are lists thread safe

前言

今天随手翻 stackoverflow,看到问题叫 How do I check if a list is empty? 一看这个问题,不难猜到到这是一个刚学 Python 的人提问的,因为这个问题实在是太基础了,那么如何判断呢?

写法

写法一

1
2
3
a = []
if len(a) == 0:
print("empty list")

写法二

1
2
3
a = []
if not a:
print("empty list")

这两种写法都很常见。那么问题来了,第一种写法用列表长度去判断列表为空可以理解,那么第二种写法直接去判断,这是怎么判断出来的呢?

if

为了解决这个问题,我们首先需要重新认识一下 if。关于 if 是用来做条件执行的这一点,大家肯定都知道。

这里我们顺便看一下官方文档的描述

The if statement is used for conditional execution:

1
2
3
if_stmt ::=  "if" expression ":" suite
("elif" expression ":" suite)*
["else" ":" suite]

It selects exactly one of the suites by evaluating the expressions one by one until one is found to be true; then that suite is executed

注意上面加粗的地方 true, 很明显 if 后面表达式预期值应该为 true 或者 false,也就是说写法二中的 a 最后的值也应该为 true 或者 false,那是怎么做到的呢?

bool

最简单的办法去判断 a 是否为 true 或者 false,就是使用内置函数 bool()

1
2
3
>>> a = []
>>> bool(a)
False

正常调用 bool() 这个函数的时候,会去调用变量的内建方法 __bool__(),那如何看一个变量有哪些内建方法呢?在上一篇__name__是什么提到了一个的函数 dir(),在这里也同样可以用来使用

1
2
>>> dir(a)
['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

似乎没有看到 __bool__() 这个函数,那怎么办呢? 不用着急,官方文档中还说了这样的一句话

When this method (__bool__()) is not defined, __len__() is called, if it is defined, and the object is considered true if its result is nonzero. If a class defines neither __len__() nor __bool__(), all its instances are considered true.

当一个变量没有定义 __bool__ 的时候,不用着急,如果定义了 __len()__ 这个函数也是可以的,当长度不为 0 的时候则为 true

总结

之所以在写法二中可以用 if 直接判断列表 a 是否为空,是因为对于 list 来说,它没有内建方法 __bool__(),而有内建方法 __len__(),最后通过判断 list 长度是否为 0 来得出 true 或者 false 的,一旦为空,则判断结果为 false

给定一个大小为 n 的数组,找到其中的众数。众数是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素。

你可以假设数组是非空的,并且给定的数组总是存在众数。

示例 1:

1
2
输入: [3,2,3]
输出: 3

示例 2:

1
2
输入: [2,2,1,1,1,2,2]
输出: 2

思路

常规思路,先遍历数组然后统计每个数组的元素出现的个数,然后找出个数最多的

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::collections::HashMap;

impl Solution {
pub fn majority_element(nums: Vec<i32>) -> i32 {
let mut numsMap = HashMap::new();
for num in nums {
let count = numsMap.entry(num).or_insert(0);
*count += 1
}
let mut maxValue = 0;
let mut maxKey = 0;
for(key, value) in numsMap {
if value > maxValue {
maxKey = key;
maxValue = value;
}
}
return maxKey;
}
}

思路二: 不借用其他空间,由众数定义可知,众数出现次数大于 n/2, 则意味着 众数 m 的出现的次数 - 数组中其他数字的次数 > 0

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
impl Solution {
pub fn majority_element(nums: Vec<i32>) -> i32 {
let mut num = 0;
let mut count = 0;
for i in 0..nums.len()
{
if(count == 0)
{
num = nums[i];
count = 1;
}
else if nums[i] != num
{
count-=1;
}
else
{
count+=1;
}
}
num
}
}

发布-订阅模式

发布-订阅模式,顾名思义,就像大家订报纸一样,出版社发布不同类型的报纸杂志不同的读者根据不同的需求预定符合自己口味的的报纸杂志,付费之后由邮局安排人员统一派送.

上面一段话,提到了发布-订阅模式三个比较重要的点:

  1. 发布者:报社
  2. 订阅者:读者
  3. 调度中心:邮局

不难看出上述过程中出版社和读者完全没有任何接触,在他们没有感知到对方的情况下通过邮局完成了整个流程,邮局就是传说中的中介(Broker)

那么使用发布-订阅模式的有什么优点呢?这里就简单的说两点:松耦合,可拓展性,稍后通过例子进行讲解. 关于更深入的理解可以参考:

  1. Publish/Subscribe-MSDN
  2. Publish/Subscribe-Wikipedia

对这个模式有所了解之后,让我们再回到 Flask 的 Signals

Flask - Signals

说明

有了前面这个铺垫,不难意识到 Flask 的 Singals 其实就是我们上面说的发布-订阅模式的实现.官方文档对 Signals 的介绍过于简单,容易让初学者直接忽略过去,但是实际上这知识点十分重要,尤其在开发比较复杂的系统中,正确地使用 Singals 能够帮助我们实现系统的松耦合.

这种松耦合是通过某些行为被触发时,自动发送定义好的一种信号,与这个信号绑定的一些业务逻辑或行为,接收到这个信号后,会自动执行各自相应的业务逻辑。这些行为的产生者就是我们在发布订阅模式中发布者,通过调度中心,消息被转发到相应的订阅者,然后每个订阅者执行自己的逻辑,互不干扰.

就像我们在发布-订阅模式看到的那样,我们可以随时添加订阅者. 同样地,与该信号绑定的业务逻辑,可以是我们事先预定义好的,也可以是在后续开发中随需求变动新增上去的. 在基于 Signals 的机制下,系统会更加稳定和可扩展,也使得系统的业务逻辑更加清晰.

既然有这么多好处,那么该怎么使用呢?别着急,看一下几个例子.

例子

Signal 的创建

两行代码就可以创建 Singals

1
2
from blinker import signal
test= signal('test')

不过 Flask 文档中有另外一种写法

1
2
3
from blinker import Namespace
my_signals = Namespace()
model_saved = my_signals.signal('model-saved')

两者本质上是没有任何区别的,原因我们可以看一下 blinker 的源码

1
2
# https://github.com/jek/blinker/blob/master/blinker/base.py
signal = Namespace().signal

很显然从源码看两者基本上可以等价起来,前者只是帮助我们简化了一个步骤

Signal 的发送

signal 创建好了之后,接下来就是使用了,使用很简单通过调用 send() 函数.需要注意的是,官方文档给了一个建议:

Try to always pick a good sender. If you have a class that is emitting a signal, pass self as sender. If you are emitting a signal from a random function, you can pass current_app._get_current_object() as sender.

也就说明我们在实际使用过程中,最好将 send() 函数的第一个参数为 signal 的发送者

  1. 在类中发送者(sender) 为 self
1
2
3
class Model(object):
def save(self):
model_saved.send(self)
  1. 在函数中发送者(sender) 为 current_app._get_current_object()
1
2
def save():
model_saved.send(current_app._get_current_object())

发送完消息,消息需要有人看,自然需要订阅者了.

Signal 的订阅

订阅指定的 signal 可以通过使用 connect() 函数,当通过 send() 发送 signal 时,会自动触发这些订阅者,然后执行相应逻辑,从而完成相应的功能. 使用起来很简单,只需要给指定的函数加上一个 connect_via 或者 connect 的装饰器就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# connect_via
from flask import Flask,current_app
app = Flask(__name__)

from blinker import Namespace
my_signals = Namespace()
test = my_signals.signal('test')

@test.connect_via(app)
def subscriber(sender,**kwargs):
print(f'Got a signal sent by {sender},{kwargs}')

@app.route('/')
def hello_world():
test.send(current_app._get_current_object(),data=3)
test.send('test')
return 'Hello, World!'

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

让我们执行一下然后看一下结果:

1
2
3
4
5
6
7
8
9
10
11
 * Serving Flask app "test" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Restarting with stat
* Debugger is active!
* Debugger PIN: 326-510-904
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
Got a signal sent by <Flask 'test'>,{'data': 3}
127.0.0.1 - - [24/Jun/2019 15:07:31] "GET / HTTP/1.1" 200 -

似乎少了依次输出?别着急,我们修改一下这个例子,使用 connect

1
2
3
4
#  connect
@test.connect
def subscriber(sender,**kwargs):
print(f'Got a signal sent by {sender},{kwargs}')

再次执行看一下结果

1
2
3
4
5
6
7
8
9
10
11
12
 * Serving Flask app "test" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: on
* Restarting with stat
* Debugger is active!
* Debugger PIN: 326-510-904
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
Got a signal sent by <Flask 'test'>,{'data': 3}
Got a signal sent by test,{}
127.0.0.1 - - [24/Jun/2019 15:10:43] "GET / HTTP/1.1" 200 -

connect_via 和 connect

从上面最后一次输出,不难发现输出两次了,为什么第一次的时候只输出了一次呢? 很显然这就是 connect_viaconnect 的区别,从上面的例子,我们不难看到, connect_via 多了一个参数,这个参数就是 sender,使用 connect 的订阅方式并不支持订阅指定的发布者,如果我们需要订阅指定的发布者需要使用 connect_via(sender)

最后

Signals 是个好东西,大家应该学会使用它.

参考

  1. 发布-订阅模式解释

  2. Publish/Subscribe-MSDN

  3. Publish/Subscribe-Wikipedia

  4. bliker

  5. Signals

介绍

Seleuim 是一个用于 Web 应用程序测试的工具. Seleuim 可以直接运行在浏览器中,就像真正的用户一样. 因为有这种特性,所有有时候会被用来作为爬虫的一大杀器.

使用

通常我们使用 Seleuim 基本上的逻辑都是这样的:

  1. 打开一个网页
  2. 关闭当前网页,然后打开另外一个网页
  3. 然后重复步骤 1,2

从上面我们不难看出,在上述使用情景下,浏览器始终有一个 tab 页存活,当然这种使用方式已经能够满足我们绝大数的需求.

新的需求

那么如果作到打开多个 tab 页呢?

别着急,让我们看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from selenium import webdriver

main_url = 'https://www.leetao94.cn' # URL A
tab_url = 'https://www.baidu.com' # URL B

# 打开 URL A
browser= webdriver.Chrome())
browser.get(main_url)
print("Current Page Title is : %s" %browser.title)

# 打开新的 Tab 页
browser.execute_script("window.open('');")

# 切换到新的 Tab 页,然后打开连接
browser.switch_to.window(browser.window_handles[1])
browser.get(tab_url)

# 新页面的逻辑
print("Current Page Title is : %s" %browser.title)
browser.close()

# 重新返回 URL A
browser.switch_to.window(browser.window_handles[0])
print("Current Page Title is : %s" %browser.title)

最后看一下效果:

在 Python 中一切都可以看作为对象。每个对象都有各自的 id, type 和 value。

  1. id: 当一个对象被创建后,它的 id 就不会在改变,这里的 id 其实就是对象在内存中的地址,可以使用 id() 去查看对象在内存中地址。
  2. type: 和 id 一样当对象呗创建之后,它的 type 也不能再被改变,type 决定了该对象所能够支持的操作
  3. value: 对象的值

一个对象可变与否就在于 value 值是否支持改变。

不可变对象

常见的不可变对象(immutable objects):

  1. Number: int, float, complex
  2. string
  3. tuple
  4. frozenset:set 的另外一种类型

让我们通过例子感受一下

例子

1
2
3
4
5
6
>>> x=1
>>> y=x
>>> id(x)==id(y)
True
>>> id(1)==id(y)
True

这里创建了两个 int 对象,并且让 x,y 指向同一个对象,然后我们试着修改一下 x,让 x+1

1
>>>x=x+1

这个时候 xid 值和 yid 以及 1 的 id 还一样吗?

1
2
3
4
5
6
7
>>> x=x+1
>>> id(x)==id(y)
False
>>> id(x)==id(1)
False
>>> id(1)==id(y)
True

从结果不难看出来,x 所指向的对象已经改变了,但是 1 并没有改变。这就是不可变对象,对于不可变对象来说,一旦创建之后就不允许被改变。

注意: 不可变并不严格等同绝对的不可变

1
2
3
4
5
6
7
8
9
10
>>> a=([1],2,3)
>>> id(a)
4338862696
>>> id(a[0])
4338949128
>>> a[0][0]=0
>>> id(a)
4338862696
>>> id(a[0])
4338949128

从上面的例子不难看出,我们改变了 a[0] 的值,但是并没有影响它的 id 值。一个不可变容器对象如果包含对可变对象的引用,当后者的值改变时,前者的值也会改变;但是该容器仍属于不可变对象,因为它的 id 值不会改变的。

可变对象

常见的可变对象(mutable objects):

  1. list
  2. dict
  3. set

同样的通过例子感受一下

例子

1
2
3
4
>>> c = [1,2,3]
>>> b = c
>>> id(b)==id(c)
True

创建两个 list 对象,并且让 c,d 指向同一个对象,然后我们试着修改一下 c

1
c[0]=4

这个时候在看一下两个对象的 id 值是否一致

1
2
>>> id(c)==id(b)
True

修改之后,c,d 仍然指向同一个对象,所以不难得出结论,对于一个可变对象来说,修改 value 值并不会对它的 id 造成任何影响

函数参数

对于可变对象和不可变对象,他们两者在函数当作参数传递的时候表现也有所不同

例子

eg0

1
2
3
4
5
6
7
8
9
10
def update_list(nums_list):
print(f'2 id:{id(nums_list)}')
nums_list += [10]
print(f'3 id:{id(nums_list)}')


number_list = [5, 6]
print(f'1:{number_list}, id:{id(number_list)}')
update_list(number_list)
print(f'4:{number_list}, id:{id(number_list)}')

看一下结果

1
2
3
4
1:[5, 6], id:4457707912
2 id:4457707912
3 id:4457707912
4:[5, 6, 10], id:4457707912

在看另外一个例子

eg1

1
2
3
4
5
6
7
8
9
10
def add_num(number):
print(f'2:{id(number)}')
number += 1
print(f'3:{id(number)}')


num = 1
print(f'1:{num}, id:{id(num)}')
add_num(num)
print(f'4:{num}, id:{id(num)}')

看一下结果

1
2
3
4
1:1, id:4343768416
2 id:4343768416
3 id:4343768448
4:1, id:4343768416

有其他有些文章中,上面 eg0 被称为引用传递,eg1 被称为值传递。然而在官方文档中,并没有找到有关的这两个名词的任何内容,或者这种方式并不是上述的两者任意一种。

这里的重点是:对于可变对象,在函数中的值的改变,会导致对象改变;对于不可变对象,在函数中值的改变,对其不会造成任何影响

参考

Does Python pass by value, or pass by reference?

Is Python call-by-value or call-by-reference? Neither.

前言

在 Python 3.6 之后,除了原有的 % 和 str.format() 字符串格式化的方法, 又新增了一种 f-strings. 官方也更加推荐使用 f-strings. 那么 f-strings 有哪些喜人的特性呢?

特性

支持 Python 表达式

运算表达式

1
2
3
>>> num=1
>>> f'add={num+1}'
'add=2'

函数

1
2
3
>>> a=[1,2,3]
>>> f'len:{len(a)}'
'len:3'

除了调用内置函数,自然也是支持自定义函数

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> class Test:
... def __str__(self):
... return "Hello I'm __str__"
... def __repr__(self):
... return "Hello I'm __repr__"
...
>>> test = Test()
>>> f'{test}'
"Hello I'm __str__"
>>> f'{repr(test)}'
"Hello I'm __repr__"
>>> test
Hello I'm __repr__

支持 lambdas 表达式

1
2
>>> f'{(lambda x: x*2)(3)}'
'6'

支持 Triple quotes

1
2
 f'''{test}'''
"Hello I'm __str__"

支持 Raw 字符串

1
2
3
4

>>> header = 'Subject'
>>> fr'{header}:\s+'
'Subject:\\s+'

注意: 虽然支持 fr’’ 但是并不支持 fb’’ 和 fu’’

说完了特性,那么接下来就需要说一说 f-strings 和 str.format() 的区别了

f-strings 和 str.format() 的区别

全表达式和有限制表达式

str.format() 和 f-strings 在一定程度上都支持表达式,但是前者有一定的局限性

例子

1
2
3
4
5
6
>>> points = 19
>>> total = 22
>>> 'Correct answers: {:.2%}'.format(points/total)
'Correct answers: 86.36%'
>>> "repr() shows quotes: {!r}; str() doesn't: {!s}".format('test1', 'test2')
"repr() shows quotes: 'test1'; str() doesn't: test2"

索引的查找

在 str.format() 中,对于索引值看起来不像数字的都会被转换为字符串

1
2
3
>>> d = {'a': 10, 'b': 20}
>>> 'a={d[a]}'.format(d=d)
'a=10'

注意:索引值直接被转换成字符串 ‘a’ 了,但是在 f-strings, 你需要使用 “a”

1
2
>>> f'a={d["a"]}'
'a=10'

是不是觉得很多余?实际上并不是,如果不这样子要求的话,你是没有办法使用变量作为索引的

1
2
3
>>> a = 'b'
>>> f'a={d[a]}'
'a=20'

最后

自 3.6 之后,官方推荐使用 f-strings,相较于上面的说提到的特性,f-strings 的可读性也比 str.format() 更高,尤其当你的字符串比较长的时候更加明显,所以如果你在使用 3.6 之后的版本,可以尝试这从 str.format() 投入 f-strings 的怀抱了

前言

学 Rust 也有一段时间了,网上也有不少官方文档的中文翻译版,但是似乎只有 Rust中文网站 文档一直是最新的,奈何并没有 PDF 供直接下载,是在是不太方便,为了方便阅读以及方便后续文档更新,决定用 Python 写一个爬虫将网页下载下来保持为 PDF. 最后完成结果如下:

是的没错,将官网样式也保留下来成功转为 PDF,接下来分享一下整个爬虫的过程,最终的爬虫可以导出任意 VuePress 搭建的网站为 PDF.

爬虫

依赖库的选定

  1. requests
  2. BeautifulSoup4
  3. pdfkit

关于 requests 和 BeautifulSoup4 库这里就不做介绍了, 写过爬虫的基本上都接触过, 重点说一下 pdfkit 库, 毫无疑问,它就是导出 PDF 的关键,简单说一下它的用法

PdfKit

PdfKit 库是对 Wkhtmltopdf 工具包的封装类,所以在使用之前,需要去官网下载相应的安装包安装到电脑上, 下载地址

可选: 安装完成之后可以 Windows 下可以将安装路径添加到系统环境变量中

安装完成之后,说一下 PdfKit 的常用方法,常用方法有三个

from_url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def from_url(url, output_path, options=None, toc=None, cover=None,
configuration=None, cover_first=False):
"""
Convert file of files from URLs to PDF document

:param url: URL or list of URLs to be saved
:param output_path: path to output PDF file. False means file will be returned as string.
:param options: (optional) dict with wkhtmltopdf global and page options, with or w/o '--'
:param toc: (optional) dict with toc-specific wkhtmltopdf options, with or w/o '--'
:param cover: (optional) string with url/filename with a cover html page
:param configuration: (optional) instance of pdfkit.configuration.Configuration()
:param configuration_first: (optional) if True, cover always precedes TOC

Returns: True on success
"""

r = PDFKit(url, 'url', options=options, toc=toc, cover=cover,
configuration=configuration, cover_first=cover_first)

return r.to_pdf(output_path)

从函数名上就很容易理解这个函数的作用,没错就是根据 url 下载网页为 PDF

from_file()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def from_file(input, output_path, options=None, toc=None, cover=None, css=None,
configuration=None, cover_first=False):
"""
Convert HTML file or files to PDF document

:param input: path to HTML file or list with paths or file-like object
:param output_path: path to output PDF file. False means file will be returned as string.
:param options: (optional) dict with wkhtmltopdf options, with or w/o '--'
:param toc: (optional) dict with toc-specific wkhtmltopdf options, with or w/o '--'
:param cover: (optional) string with url/filename with a cover html page
:param css: (optional) string with path to css file which will be added to a single input file
:param configuration: (optional) instance of pdfkit.configuration.Configuration()
:param configuration_first: (optional) if True, cover always precedes TOC

Returns: True on success
"""

r = PDFKit(input, 'file', options=options, toc=toc, cover=cover, css=css,
configuration=configuration, cover_first=cover_first)

return r.to_pdf(output_path)

这个则是从文件中生成 PDF, 也是我最后选择的方案,至于为什么没有选择 from_url(),稍后等我分析完,就会明白了.

from_string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def from_string(input, output_path, options=None, toc=None, cover=None, css=None,
configuration=None, cover_first=False):
"""
Convert given string or strings to PDF document

:param input: string with a desired text. Could be a raw text or a html file
:param output_path: path to output PDF file. False means file will be returned as string.
:param options: (optional) dict with wkhtmltopdf options, with or w/o '--'
:param toc: (optional) dict with toc-specific wkhtmltopdf options, with or w/o '--'
:param cover: (optional) string with url/filename with a cover html page
:param css: (optional) string with path to css file which will be added to a input string
:param configuration: (optional) instance of pdfkit.configuration.Configuration()
:param configuration_first: (optional) if True, cover always precedes TOC

Returns: True on success
"""

r = PDFKit(input, 'string', options=options, toc=toc, cover=cover, css=css,
configuration=configuration, cover_first=cover_first)

return r.to_pdf(output_path)

这个方法则是从字符串中生成 PDF,很明显没有办法保持网页样式,所以不考虑.关于更多 PdfKit 的用法,可以去 wkhtmltopdf文档 查看

分析目标网页

依赖库选定完毕,接下来就是分析目标网页,开始写爬虫的过程了.

测试 PdfKit

PdfKit 自带一个 from_url 生成 PDF 的功能,如果可以生成合适的 PDF,那我们只需要获取所有网页链接就可以了,可以节省很多时间,先测试一下生成的效果

1
2
3
import pdfkit
pdfkit.from_url("https://rustlang-cn.org/office/rust/book/", 'out.pdf', configuration=pdfkit.configuration(
wkhtmltopdf="path/to/wkhtmltopdf.exe"))

导出结果如下:

从结果不难看出,网页的样式保存下来了,但是侧边栏,顶部和底边导航栏也都被保留下来了,并且侧边栏还挡住了主要内容,所以使用 from_url 这个方法就被排除了.

最终方案

通过测试,我们得知不能使用 from_url 那么只能通过使用 from_file 去导出了, 并且在我们将网页下载下来保存到本地之前,我们需要修改网页内容,移除顶部导航栏,侧边栏,以及底部导航栏

获取相应元素

现在让我们先获取页面下一页链接,打开浏览器调试模式,审查一下网页元素,不难发现所有下一页导航,都处于 之下的超链接 中,如下图:

通过同样的方法,不难发现顶部导航栏,侧边栏,以及底部导航栏对应的元素,依次为

,,
, 找到对应的元素接着就是获取链接和销毁不必要元素

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
class DownloadVuePress2Pdf:

def get_content_and_next_url(self, content): # content 为网页内容
# 获取链接和销毁不必要元素
navbar = soup.select('.navbar')
if len(navbar):
navbar[0].decompose()

sidebar = soup.select('.sidebar')
if len(sidebar):
sidebar[0].decompose()

page_edit = soup.select('.page-edit')
if len(page_edit):
page_edit[0].decompose()

# 注意下一页链接在底部导航栏元素中,
# 要先获取链接后,才能销毁元素,顺序不能颠倒
next_span = soup.select(".next")
if len(next_span):
next_span_href = next_span[0].a['href']
else:
next_span_href = None

page_nav = soup.select('.page-nav')
if len(page_nav):
page_nav[0].decompose()

保持导出 PDF 样式

为了使得导出 PDF 的样式和网页一致,我们有俩种方法:

  1. 根据源码在对应目录建立本地 css 文件,显然这种方法不具有普遍性,不能每导出一个网站,我们就新建一个 css 文件
  2. 既然本地的不行,那我们就将网页中的 css 链接 href 地址指向远程 css

在上述代码中添加如下代码:

1
2
3
for link in links:
if not link['href'].startswith("http"):
link['href'] = css_domain + link['href'] # css_domain 为 css 默认域名,需要设置,获取方式可见下图

导出

通过上述的方式,我们将网页下载下来保存到本地,全部下载完成之后,最后就是导出为 PDF 了,通过 from_file() 方法很容易完成导出这个操作

1
pdfkit.from_file([文件列表], "导出的文件名称.pdf", options=options, configuration=config)

至此导出 Rust 官网文档为 PDF 的过程全部完成,效果如开头展示的那样

注意: 由于 VuePress 搭建的网站基本上布局格式一样, 所以上面的代码同样可以用来导出其他由 VuePress 构建的网站

完整代码

可以关注我的公众号,回复 20190509 获得

关于 计算1至N中X出现的个数

编程之美上给出的规律:
1.如果第i位(自右至左,从1开始标号)上的数字为0,则第i位可能出现1的次数由更高位决定(若没有高位,视高位为0),等于更高位数字X当前位数的权重10^(i-1)。
2.如果第i位上的数字为1,则第i位上可能出现1的次数不仅受更高位影响,还受低位影响(若没有低位,视低位为0),等于更高位数字X当前位数的权重10^(i-1)+(低位数字+1)。
3.如果第i位上的数字大于1,则第i位上可能出现1的次数仅由更高位决定(若没有高位,视高位为0),等于(更高位数字+1)X当前位数的权重10^(i-1)。

通用结论:

  1. 从 1 至 10,在它们的个位数中,任意的 X 都出现了 1 次
  2. 从 1 至 100,在它们的十位数中,任意的 X 都出现了 10 次
  3. 从 1 至 1000,在它们的千位数中,任意的 X 都出现了 100 次
  4. 依此类推,从 1 至 10^i,在它们的左数第二位(右数第 i 位)中,任意的 X 都出现了10^(i−1) 次

得出的算法如下:

当计算右数第 i 位包含的 X 的个数时:

  1. 取第 i 位左边(高位)的数字,乘以 10^(i−1),得到基础值 a

  2. 取第 i 位数字,计算修正值:

    1. 如果大于 X,则结果为 a+10^(i−1)
    2. 如果小于 X,则结果为 a
    3. 如果等 X,则取第 i 位右边(低位)数字,设为 b,最后结果为 a+b+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
def count_x_between_one_and_n(n,x):
if n < 0 or x < 1 or x > 9:
return 0
high,low,current,tmp,i = 1,1,1,1,1
high = n
total = 0
while high !=0:
high = int(n/int(math.pow(10,i)))
tmp = int(n%int(math.pow(10,i)))
current = int(tmp/int(math.pow(10,i-1)))
low = int(tmp%int(math.pow(10,i-1)))
if current == x:
total += high*int(math.pow(10,i-1))+low+1
elif current < x:
total += high*int(math.pow(10,i-1))
else:
total += (high+1)*int(math.pow(10, i-1))
i+=1
return total

res =count_x_between_one_and_n(866278171,3)) # 796741437
# 求为奇数的时候,当个位为3的时候,均为奇数,除去这一部分,这剩下的
# 奇偶数各占一半
a = 796741437 # 总个数
b = 866278170/10 # 个位为 3 的个数
c = (a - b)/2 + b # 再加上个位为3的个数,得出奇数列中3出现的个数 441684627

结尾

Leetcode 也有类似的题目是求 1 出现的个数,相对比较简单一些,有兴趣可以尝试一下 数字1的个数