目录
Object.getOwnPropertyDescriptor
Scrapy去重效率优化
Scrapy有自动去重功能,它的去重使用了 Python 中的集合。这个集合记录了 Scrapy 中每个Request 的指纹,这个指纹实际上就是 Request 的散列值。(散列值就是转化成一一对应的东西,比如天上的太阳对应“太阳”这两个字,“太阳”这两个字就是)
request_fingerprint 就是计算 Request 指纹的方法,其方法内部使用的是 hashlib 的 sha1 方法(sha1加密,对请求的方法、链接、请求体加密)。计算的字段包括 Request的 Method、URL、Body、Headers 这几部分内容,这里只要有一点不同,那么计算的结果就不同。计算得到的结果是加密后的字符串,也就是指纹。
我们可以看一下散列:
不管运行几次hello world出来都是这一串,可以把一个字符串转成另一个字符串,我们在抓一个新的小说时,把标题之类的拼起来,再用sha1加密,就可以把长文章变成40位字符串,节省空间,利用这个可以做一个去重操作,方便去重
Scrapy-Redis去重机制
Redis相当于一个调度器,多台电脑可以共享Redis数据库,比如Redis数据库在远程服务器上,知道远程服务器ip地址,知道端口号(Redis一般是6379),就可以连到数据库,这样几个电脑就可以连同一个数据库,实现共享,Redis里存的是任务,这样一个电脑抓取一个任务,由于任务池共享,任务从Redis里拿出来,其他电脑就不能抓这个任务,Redis不光可以装任务,还可以存数据的去重记录,可以用来做增量爬虫。
安装
pip install scrapy-redis
配置
修改 Scheduler
在 settings.py 里面添加如下代码,将 Scheduler 的类修改为 Scrapy-Redis 提供的 Scheduler 类,这样在我们运行爬虫时,Request 队列就会出现在 Redis 中
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
修改 Redis 连接信息
REDIS_URL = 'redis://[user:pass]@hostname:9001'
#本地运行不用填写用户名密码,如下
REDIS_URL = 'redis://localhost:6379'
修改去重类
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
配置持久化
开启了 Redis 分布式队列之后,我们不希望爬虫在关闭时将整个队列和去重信息全部删除,因 为很有可能在某个情况下我们会手动关闭爬虫或者爬虫遭遇意外终止,为了解决这个问题,我们可以配 置 Redis 队列的持久化
SCHEDULER_PERSIST = True
上面我们完成的实际上并不是真正意义的分布式爬虫,因为 Redis 队列我们使用的是本地的Redis,所 以多个爬虫需要运行在本地才可以,如果想实现真正意义的分布式爬虫,可以使用远程 Redis,这样我 们就能在多台主机运行爬虫连接此 Redis 从而实现真正意义上的分布式爬虫了。
获取源码
可以把源码克隆下来,执行如下命令:
git clone https://github.com/rmax/scrapy-redis.git
核心源码在 scrapy-redis/src/scrapy_redis 目录下。
解析







class DemoItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
text = scrapy.Field()
author = scrapy.Field()
tags = scrapy.Field()
pass
class RFPDupeFilter(BaseDupeFilter):
"""Redis-based request duplicates filter.
This class can also be used with default Scrapy's scheduler.
"""
logger = logger
def __init__(self, server, key, debug=False):
"""Initialize the duplicates filter.
Parameters
----------
server : redis.StrictRedis
The redis server instance.
key : str
Redis key Where to store fingerprints.
debug : bool, optional
Whether to log filtered requests.
"""
self.server = server
self.key = key
self.debug = debug
self.logdupes = True
@classmethod
def from_settings(cls, settings):
"""Returns an instance from given settings.
This uses by default the key ``dupefilter:<timestamp>``. When using the
``scrapy_redis.scheduler.Scheduler`` class, this method is not used as
it needs to pass the spider name in the key.
Parameters
----------
settings : scrapy.settings.Settings
Returns
-------
RFPDupeFilter
A RFPDupeFilter instance.
"""
server = get_redis_from_settings(settings)
key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(server, key=key, debug=debug)
@classmethod
def from_crawler(cls, crawler):
"""Returns instance from crawler.
Parameters
----------
crawler : scrapy.crawler.Crawler
Returns
-------
RFPDupeFilter
Instance of RFPDupeFilter.
"""
return cls.from_settings(crawler.settings)
def request_seen(self, request):
"""Returns True if request was already seen.
Parameters
----------
request : scrapy.http.Request
Returns
-------
bool
"""
fp = self.request_fingerprint(request)
added = self.server.sadd(self.key, fp)
return added == 0
def request_fingerprint(self, request):
"""Returns a fingerprint for a given request.
Parameters
----------
request : scrapy.http.Request
Returns
-------
str
"""
return request_fingerprint(request)
def close(self, reason=''):
"""Delete data on close. Called by Scrapy's scheduler.
Parameters
----------
reason : str, optional
"""
self.clear()
def clear(self):
"""Clears fingerprints data."""
self.server.delete(self.key)
def log(self, request, spider):
"""Logs given request.
Parameters
----------
request : scrapy.http.Request
spider : scrapy.spiders.Spider
"""
if self.debug:
msg = "Filtered duplicate request: %(request) s"
self.logger.debug(msg, {'request': request}, extra={'spider': spider
elif self.logdupes:
msg = ("Filtered duplicate request %(request) s"
"- no more duplicates will be shown"
"(see DUPEFILTER_DEBUG to show all duplicates)")
self.logger.debug(msg, {'request': request}, extra={'spider': spider
self.logdupes = False

scrapyd
后期补充说明
了解Bloom Filter
当爬取达到亿级别规模时,Scrapy-Redis提供的集合去重已经不能满足我们的要求。所以我们需要使用一个更加节省内存的去重算法Bloom Filter。
Bloom Filter,中文名称叫作布隆过滤器,可以被用来检测一个元素是否在一个集合中。Bloom Filter的空间利用效率很高,使用它可以大大节省存储空间。Bloom Filter使用位数组表示一个待检测集合,并可以快速地通过概率算法判断一个元素是否 存在于这个集合中。利用这个算法我们可以实现去重效果。
Bloom Filter的算法
在Bloom Filter中使用位数组来辅助实现检测判断。在初始状态下,我们声明一个包含m位的






对接Scrapy-Redis
基本的散列算法


Bloom Filter实现

exists()方法和insert()方法

实例
conn = StrictRedis(host='localhost', port=6379, password='foobared')
bf = BloomFilter(conn, 'testbf', 5, 6)
bf.insert('Hello')
bf.insert('World')
result = bf.exists('Hello')
print(bool(result))
result = bf.exists('Python')
print(bool(result))
首先定义了一个Redis连接对象,然后传递给Bloom Filter。为了避免内存占用过大,这里传的位数bit比较小,设置为5,散列函数的个数设置为6。调用insert()方法插入Hello和World两个字符串,随后判断Hello和Python这两个字符串是否存在,最后输出它的结果,运行结果如下:
def request_seen(self, request):
fp = self.request_fingerprint(request)
if self.bf.exists(fp):
self.bf.insert(fp)
return False
def __init__(self, server, key, debug, bit, hash_number):
self.server = server
self.key = key
self.debug = debug
self.bit = bit
self.hash_number = hash_number
self.logdupes = True
self.bf = BloomFilter(server, self.key, bit, hash_number)

BLOOMFILTER_HASH_NUMBER = 6BLOOMFILTER_BIT = 30
模拟登录基础
网站登录验证主要有两种实现,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证
准备工作
在本课时开始之前,请你确保已经做好了如下准备工作:
-
安装好了 Python (最好 3.6 及以上版本)并能成功运行 Python 程序;
-
安装好了 requests 请求库并学会了其基本用法;
-
安装好了 Selenium 库并学会了其基本用法。
案例
这里有两个需要登录才能抓取的网站,链接为Scrape | Movie和Scrape | Book,前者是基于 Session + Cookies 认证的网站,后者是基于 JWT 认证的网站。
首先看下第一个网站,打开后会看到如图所示的页面。
它直接跳转到了登录页面,这里用户名和密码都是 admin,我们输入之后登录。
登录成功之后,我们便看到了电影网站的展示页面,如图所示。
这个网站是基于传统的 MVC 模式开发的,因此也比较适合 Session + Cookies 的认证。
第二个网站打开后同样会跳到登录页面,如图所示。
用户名和密码是一样的,都输入 admin 即可登录。
登录之后会跳转到首页,展示了一些书籍信息,如图所示。
这个页面是前后端分离式的页面,数据的加载都是通过 Ajax 请求后端 API 接口获取,登录的校验是基于 JWT 的,同时后端每个 API 都会校验 JWT 是否是有效的,如果无效则不会返回数据。
session登录
我们如果要模拟登录,就需要先分析下登录过程究竟发生了什么,首先我们打开Scrape | Movie,然后执行登录操作,查看其登录过程中发生的请求,如图所示
这里我们可以看到其登录的瞬间是发起了一个 POST 请求,目标 URL 为 https://login2.scrape.cuiqingcai.com/login,通过表单提交的方式提交了登录数据,包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段是根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。
由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了,登录完成之后获取 Response 设置的 Cookies,将 Cookies 保存好,以后后续的请求带上 Cookies 就可以正常访问了。
requests 默认情况下每次请求都是独立互不干扰的,比如我们第一次先调用了 post 方法模拟登录,然后紧接着再调用 get 方法请求下主页面,其实这是两个完全独立的请求,第一次请求获取的 Cookies 并不能传给第二次请求,因此说,常规的顺序调用是不能起到模拟登录的效果的。
我们先来看一个无效的代码:
import requests
from urllib.parse import urljoin
BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'
response_login = requests.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
})
response_index = requests.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
这里我们先定义了几个基本的 URL 和用户名、密码,接下来分别用 requests 请求了登录的 URL 进行模拟登录,然后紧接着请求了首页来获取页面内容,但是能正常获取数据吗?
由于 requests 可以自动处理重定向,我们最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么就证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。
我们通过结果来验证一下,运行结果如下:
Response Status 200
Response URL https://login2.scrape.cuiqingcai.com/login?next=/page/1
这里可以看到,其最终的页面 URL 是登录页面的 URL,另外这里也可以通过 response 的 text 属性来验证页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。
总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 post、get 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,这两次请求对应的 Session 并不是同一个,因此这里我们模拟了第一个 Session 登录,而这并不能影响第二个 Session 的状态,因此模拟登录也就无效了。 那么怎样才能实现正确的模拟登录呢?
我们知道 Cookies 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面是有 set-cookie 字段,实际上这就是让浏览器生成了 Cookies。
Cookies 里面包含了 Session ID 的信息,所以只要后续的请求携带这些 Cookies,服务器便能通过 Cookies 里的 Session ID 信息找到对应的 Session,因此服务端对于这两次请求就会使用同一个 Session 了。而因为第一次我们已经完成了模拟登录,所以第一次模拟登录成功后,Session 里面就记录了用户的登录信息,第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,就可以返回正确的结果而不再是跳转到登录页面了。
所以,这里的关键就在于两次请求的 Cookies 的传递。所以这里我们可以把第一次模拟登录后的 Cookies 保存下来,在第二次请求的时候加上这个 Cookies 就好了,所以代码可以改写如下:
import requests
from urllib.parse import urljoin
BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'
response_login = requests.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
}, allow_redirects=False)
cookies = response_login.cookies
print('Cookies', cookies)
response_index = requests.get(INDEX_URL, cookies=cookies)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
由于 requests 可以自动处理重定向,所以模拟登录的过程我们要加上 allow_redirects 参数并设置为 False,使其不自动处理重定向,这里登录之后返回的 Response 我们赋值为 response_login,这样通过调用 response_login 的 cookies 就可以获取到网站的 Cookies 信息了,这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookies,所以我们不需要手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 属性即可获取 Cookies。
接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL,不过这里和之前不同,get 方法多加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookies,这样第二次请求就能携带第一次模拟登录获取的 Cookies 信息了,此时网站会根据 Cookies 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。
这里我们还是输出了最终的 URL,如果其是 INDEX_URL,那就代表模拟登录成功并获取到了有效数据,否则就代表模拟登录失败。运行结果如下:
Cookies <RequestsCookieJar[<Cookie sessionid=psnu8ij69f0ltecd5wasccyzc6ud41tc for login2.scrape.cuiqingcai.com/>]>
Response Status 200
Response URL https://login2.scrape.cuiqingcai.com/page/1
这下就没有问题了,这次我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_index 的 text 属性看下是否获取成功。接下来后续的爬取用同样的方式爬取即可。
但是我们发现其实这种实现方式比较烦琐,每次还需要处理 Cookies 并进行一次传递,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookies,使用了 Session 对象之后,requests 会将每次请求后需要设置的 Cookies 自动保存好,并在下次请求时自动携带上去,就相当于帮我们维持了一个 Session 对象,这样就更方便了。
所以,刚才的代码可以简化如下:
import requests
from urllib.parse import urljoin
BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'
session = requests.Session()
response_login = session.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
})
cookies = session.cookies
print('Cookies', cookies)
response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
可以看到,这里我们无需再关心 Cookies 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 post 或 get 方法就好了。
因此,为了简化写法,这里建议直接使用 Session 对象来进行请求,这样我们就无需关心 Cookies 的操作了,实现起来会更加方便。
如果碰上复杂一点的网站,如带有验证码,带有加密参数等等,直接用 requests 并不好处理模拟登录我们可以使用 Selenium 来通过模拟浏览器的方式实现模拟登录,然后获取模拟登录成功后的 Cookies,再把获取的 Cookies 交由 requests 等来爬取就好了。
from urllib.parse import urljoin
from selenium import webdriver
import requests
import time
BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'
browser = webdriver.Chrome()
browser.get(BASE_URL)
browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)
browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)
browser.find_element_by_css_selector('input[type="submit"]').click()
time.sleep(10)
# get cookies from selenium
cookies = browser.get_cookies()
print('Cookies', cookies)
browser.close()
# set cookies to requests
session = requests.Session()
for cookie in cookies:
session.cookies.set(cookie['name'], cookie['value'])
response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
这里我们使用 Selenium 先打开了 Chrome 浏览器,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,这时候我们可以发现浏览器里面就提示登录成功,然后成功跳转到了主页面。
这时候,我们通过调用 get_cookies 方法便能获取到当前浏览器所有的 Cookies,这就是模拟登录成功之后的 Cookies,用这些 Cookies 我们就能访问其他的数据了。
接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookies 并设置到 Session 对象的 cookies 上面去,接着再拿着这个 Session 对象去请求 INDEX_URL,也就能够获取到对应的信息而不会跳转到登录页面了。运行结果如下:
Cookies [{'domain': 'login2.scrape.cuiqingcai.com', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]
Response Status 200
Response URL https://login2.scrape.cuiqingcai.com/page/1
可以看到这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 或 Pyppeteer 等模拟浏览器操作的方式来实现,其目的就是取到登录后的 Cookies,有了 Cookies 之后,我们再用这些 Cookies 爬取其他页面就好了。
所以这里我们也可以发现,对于基于 Session + Cookies 验证的网站,模拟登录的核心要点就是获取 Cookies,这个 Cookies 可以被保存下来或传递给其他的程序继续使用。甚至说可以将 Cookies 持久化存储或传输给其他终端来使用。另外,为了提高 Cookies 利用率或降低封号几率,可以搭建一个 Cookies 池实现 Cookies 的随机取用。
jwt登录
基于 JWT 的网站,其通常都是采用前后端分离式的,前后端的数据传输依赖于 Ajax,登录验证依赖于 JWT 本身这个 token 的值,如果 JWT 这个 token 是有效的,那么服务器就能返回想要的数据。下面我们先来在浏览器里面操作登录,观察下其网络请求过程,如图所示。
这里我们发现登录时其请求的 URL 为Scrape | Book,是通过 Ajax 请求的,同时其 Request Body 是 JSON 格式的数据,而不是 Form Data,返回状态码为 200。然后再看下返回结果,如图所示。 可以看到返回结果是一个 JSON 格式的数据,包含一个 token 字段,其结果为:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc3OTQ2LCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM0NzQ2fQ.ujEXXAZcCDyIfRLs44i_jdfA3LIp5Jc74n-Wq2udCR8
那么有了这个 JWT 之后,后续的数据怎么获取呢?下面我们再来观察下后续的请求内容,如图所示。
这里我们可以发现,后续获取数据的 Ajax 请求中的 Request Headers 里面就多了一个 Authorization 字段,其结果为 jwt 然后加上刚才的 JWT 的内容,返回结果就是 JSON 格式的数据。 没有问题,那模拟登录的整个思路就简单了: 模拟请求登录结果,带上必要的登录信息,获取 JWT 的结果。
后续的请求在 Request Headers 里面加上 Authorization 字段,值就是 JWT 对应的内容。 好,接下来我们用代码实现如下:
import requests
from urllib.parse import urljoin
BASE_URL = 'https://login3.scrape.cuiqingcai.com/'
LOGIN_URL = urljoin(BASE_URL, '/api/login')
INDEX_URL = urljoin(BASE_URL, '/api/book')
USERNAME = 'admin'
PASSWORD = 'admin'
response_login = requests.post(LOGIN_URL, json={
'username': USERNAME,
'password': PASSWORD
})
data = response_login.json()
print('Response JSON', data)
jwt = data.get('token')
print('JWT', jwt)
headers = {
'Authorization': f'jwt {jwt}'
}
response_index = requests.get(INDEX_URL, params={
'limit': 18,
'offset': 0
}, headers=headers)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
print('Response Data', response_index.json())
这里我们同样是定义了登录接口和获取数据的接口,分别为 LOGIN_URL 和 INDEX_URL,接着通过 post 请求进行了模拟登录,这里提交的数据由于是 JSON 格式,所以这里使用 json 参数来传递。接着获取了返回结果中包含的 JWT 的结果。第二步就可以构造 Request Headers,然后设置 Authorization 字段并传入 JWT 即可,这样就能成功获取数据了。
运行结果如下:
Response JSON {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4'}
JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4
Response Status 200
Response URL https://login3.scrape.cuiqingcai.com/api/book/?limit=18&offset=0
Response Data {'count': 9200, 'results': [{'id': '27135877', 'name': '校园市场:布局未来消费群,决战年轻人市场', 'authors': ['单兴华', '李烨'], 'cover': 'https://img9.doubanio.com/view/subject/l/public/s29539805.jpg', 'score': '5.5'},
...
{'id': '30289316', 'name': '就算這樣,還是喜歡你,笠原先生', 'authors': ['おまる'], 'cover': 'https://img3.doubanio.com/view/subject/l/public/s29875002.jpg', 'score': '7.5'}]}
可以看到,这里成功输出了 JWT 的内容,同时最终也获取到了对应的数据,模拟登录成功!
类似的思路,如果我们遇到 JWT 认证的网站,也可以通过类似的方式来实现模拟登录。当然可能某些页面比较复杂,需要具体情况具体分析。
超级鹰的使用
一个打码平台,可以解决验证码的问题https://www.chaojiying.com
后期补
反屏蔽
注意一下以下所有练习网站可能有已经失效的参考Python爬虫案例 | Scrape Center即可
现在很多网站都加上了对 Selenium 的检测,来防止一些爬虫的恶意爬取。即如果检测到有人在使用 Selenium 打开浏览器,那就直接屏蔽。
其大多数情况下,检测基本原理是检测当前浏览器窗口下的 window.navigator 对象是否包含 webdriver 这个属性。因为在正常使用浏览器的情况下,这个属性是 undefined,然而一旦我们使用了 Selenium,Selenium 会给 window.navigator 设置 webdriver 属性。很多网站就通过 JavaScript 判断如果 webdriver 属性存在,那就直接屏蔽。
这边有一个典型的案例网站:https://antispider1.scrape.cuiqingcai.com/,这个网站就是使用了上述原理实现了 WebDriver 的检测,如果使用 Selenium 直接爬取的话,那就会返回如下页面:
这时候我们可能想到直接使用 JavaScript 直接把这个 webdriver 属性置空,比如通过调用 execute_script 方法来执行如下代码:
Object.defineProperty(navigator, "webdriver", {get: () => undefined})
Pyppeteer 介绍
在前面我们学习了 Selenium 的基本用法,它功能的确非常强大,但很多时候我们会发现 Selenium 有一些不太方便的地方,比如环境的配置,得安装好相关浏览器,比如 Chrome、Firefox 等等,然后还要到官方网站去下载对应的驱动,最重要的还需要安装对应的 Python Selenium 库,而且版本也得好好看看是否对应,确实不是很方便,另外如果要做大规模部署的话,环境配置的一些问题也是个头疼的事情。
那么本课时我们就介绍另一个类似的替代品,叫作 Pyppeteer。注意,是叫作 Pyppeteer,而不是 Puppeteer。
Puppeteer 是 Google 基于 Node.js 开发的一个工具,有了它我们可以通过 JavaScript 来控制 Chrome 浏览器的一些操作,当然也可以用作网络爬虫上,其 API 极其完善,功能非常强大,Selenium 当然同样可以做到。
而 Pyppeteer 又是什么呢?它实际上是 Puppeteer 的 Python 版本的实现,但它不是 Google 开发的,是一位来自于日本的工程师依据 Puppeteer 的一些功能开发出来的非官方版本。
在 Pyppetter 中,实际上它背后也是有一个类似 Chrome 浏览器的 Chromium 浏览器在执行一些动作进行网页渲染,首先说下 Chrome 浏览器和 Chromium 浏览器的渊源。
安装
首先就是安装问题了,由于 Pyppeteer 采用了 Python 的 async 机制,所以其运行要求的 Python 版本为 3.5 及以上。
pip3 install pyppeteer
基本的页面渲染操作
接下来我们测试基本的页面渲染操作,这里我们选用的网址为:https://dynamic2.scrape.cuiqingcai.com/,如图所示。
整个页面是用 JavaScript 渲染出来的,同时一些 Ajax 接口还带有加密参数,所以这个网站的页面我们无法直接使用 requests 来抓取看到的数据,同时我们也不太好直接模拟 Ajax 来获取数据。
所以前面一课时我们介绍了使用 Selenium 爬取的方式,其原理就是模拟浏览器的操作,直接用浏览器把页面渲染出来,然后再直接获取渲染后的结果。同样的原理,用 Pyppeteer 也可以做到。
下面我们用 Pyppeteer 来试试,代码就可以写为如下形式:
import asyncio
from pyppeteer import launch
from pyquery import PyQuery as pq
async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
await page.waitForSelector('.item .name')
doc = pq(await page.content())
names = [item.text() for item in doc('.item .name').items()]
print('Names:', names)
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
接下来我们再看看另外一个例子,这个例子设定了浏览器窗口大小,然后模拟了网页截图,另外还可以执行自定义的 JavaScript 获得特定的内容,代码如下:
import asyncio
from pyppeteer import launch
width, height = 1366, 768
async def main():
browser = await launch()
page = await browser.newPage()
await page.setViewport({'width': width, 'height': height})
await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
await page.waitForSelector('.item .name')
await asyncio.sleep(2)
await page.screenshot(path='example.png')
dimensions = await page.evaluate('''() => {
return {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
deviceScaleFactor: window.devicePixelRatio,
}
}''')
print(dimensions)
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
这里我们又用到了几个新的 API,完成了页面窗口大小设置、网页截图保存、执行 JavaScript 并返回对应数据。
首先 screenshot 方法可以传入保存的图片路径,另外还可以指定保存格式 type、清晰度 quality、是否全屏 fullPage、裁切 clip 等各个参数实现截图。
可以看到它返回的就是 JavaScript 渲染后的页面,和我们在浏览器中看到的结果是一模一样的。
最后我们又调用了 evaluate 方法执行了一些 JavaScript,JavaScript 传入的是一个函数,使用 return 方法返回了网页的宽高、像素大小比率三个值,最后得到的是一个 JSON 格式的对象,内容如下:
{'width': 1366, 'height': 768, 'deviceScaleFactor': 1}
详细用法
防止检测
如果你只是把提示关闭了,有些网站还是会检测到是 WebDriver 吧,比如拿之前的检测 WebDriver 的案例 https://antispider1.scrape.cuiqingcai.com/ 来验证下
import asyncio
from pyppeteer import launch
async def main():
browser = await launch(headless=False, args=['--disable-infobars'])
page = await browser.newPage()
await page.goto('https://antispider1.scrape.cuiqingcai.com/')
await asyncio.sleep(100)
asyncio.get_event_loop().run_until_complete(main())
果然还是被检测到了,页面如下:
这说明 Pyppeteer 开启 Chromium 照样还是能被检测到 WebDriver 的存在。
那么此时如何规避呢?Pyppeteer 的 Page 对象有一个方法叫作 evaluateOnNewDocument,意思就是在每次加载网页的时候执行某个语句,所以这里我们可以执行一下将 WebDriver 隐藏的命令,改写如下:
import asyncio
from pyppeteer import launch
async def main():
browser = await launch(headless=False, args=['--disable-infobars'])
page = await browser.newPage()
await page.evaluateOnNewDocument('Object.defineProperty(navigator, "webdriver", {get: () => undefined})')
await page.goto('https://antispider1.scrape.cuiqingcai.com/')
await asyncio.sleep(100)
asyncio.get_event_loop().run_until_complete(main())
消除指纹
加载一个js脚本,把不一样的地方全改过来
pip install pyppeteer-stealth
from pyppeteer_stealth import stealth
#震坤行案例
import asyncio
from pyppeteer import launch
from pyppeteer_stealth import stealth
import os
from openpyxl import load_workbook
import random
width, height = 1920, 1080
import re
import openpyxl
import pymysql
db = pymysql.connect(user='root',password='123456',db='test')
cursor = db.cursor()
async def run(url):
d = re.compile('https://www\.zkh\.com/list/c-(\d+)\.html')
num = d.findall(url)[0]
current_page = 1
# dicts = {}
# dicts[num]=[]
browser = await launch(headless=False, args=['--disable-infobars'])
# 开启一个页面对象
page = await browser.newPage()
# 消除指纹
await stealth(page) # <-- Here
# 设置浏览器宽高
await page.setViewport({'width': width, 'height': height})
await page.goto(url)
# await asyncio.sleep(1000)
while True:
# await asyncio.sleep(2)
# 等待id=key的这个元素出现,等9秒,超过不出现报超时错误
await page.waitForXPath('//*[@class="goods-item-wrap-new clearfix common-item-wrap"]', {'timeout': 9000})
await page.evaluate('window.scrollBy(10000, document.body.scrollHeight)')
await asyncio.sleep(1)
await page.evaluate('window.scrollBy(10000, document.body.scrollHeight)')
await asyncio.sleep(1)
await page.evaluate('window.scrollBy(10000, document.body.scrollHeight)')
await asyncio.sleep(1)
li_list = await page.xpath('//div[@class="goods-item-wrap-new clearfix common-item-wrap"]/a[1]')
for content_url in li_list:
the_url = await (await content_url.getProperty("href")).jsonValue()
# dicts[num].append(the_url)
sql = 'insert into task(code,urls,status) values ("{}","{}","{}")'.format(num,the_url,1)
cursor.execute(sql)
db.commit()
# await (await a[0].getProperty("textContent")).jsonValue()
a = await page.xpath('//b[@class="pagination-page-total"]')
if len(a) == 0:
break
all_page = await (await a[0].getProperty("textContent")).jsonValue()
print('当前页数为', current_page)
if current_page == int(all_page):
break
else:
current_page += 1
await page.click('.nextbtn')
async def main():
# 设置启动时是否开启浏览器可视,消除控制条信息
# 访问某个页面
task_url = ['https://www.zkh.com/list/c-10290173.html','https://www.zkh.com/list/c-10290175.html']
await asyncio.gather(*[run(_) for _ in task_url])
asyncio.get_event_loop().run_until_complete(main())
Pyppeteer模拟登录
后边补
node.js安装
js逆向
网易云案例
js基础
var let const 区别
区别
var声明的作用域


var声明的变量提升
let声明


var 与let(作用域)
var 声明
let 声明
const 声明


条件语句
if else结构
if
结构先判断一个表达式的布尔值,然后根据布尔值的真伪,执行不同的语句。所谓布尔值,指的是 JavaScript 的两个特殊值,true
表示“真”,false
表示“伪”。
if (布尔值)
语句;
// 或者
if (布尔值) 语句;
如果想执行多个语句,必须在if
的条件判断之后,加上大括号,表示代码块(多个语句合并成一个语句)。
if (m !== 1) {
if (n === 2) {
console.log('hello');
}
} else {
console.log('world');
}
// world
switch 结构
switch (x) {
case 1:
console.log('x 等于1');
break;
case 2:
console.log('x 等于2');
break;
default:
console.log('x 等于其他值');
}
三元运算符
JavaScript 还有一个三元运算符(即该运算符需要三个运算子)?:
,也可以用于逻辑判断。
(条件) ? 表达式1 : 表达式2
上面代码中,如果“条件”为true
,则返回“表达式1”的值,否则返回“表达式2”的值。
var even = (n % 2 === 0) ? true : false;
//例子一
var myVar;
console.log(
myVar ?
'myVar has a value' :
'myVar does not have a value'
)
// myVar does not have a value
//例子二
var msg = '数字' + n + '是' + (n % 2 === 0 ? '偶数' : '奇数');
循环语句
while循环
var i = 0;
while (i < 100) {
console.log('i 当前为:' + i);
i = i + 1;
}
下面的例子是一个无限循环,因为循环条件总是为真。
while (true) {
console.log('Hello, world');
}
for 循环
var x = 3;
for (var i = 0; i < x; i++) {
console.log(i);
}
// 0
// 1
// 2
var x = 3;
var i = 0;
while (i < x) {
console.log(i);
i++;
}
for ( ; ; ){
console.log('Hello World');
}
do...while 循环
do
语句
while (条件);
// 或者
do {
语句
} while (条件);
不管条件是否为真,do...while
循环至少运行一次,这是这种结构最大的特点。另外,while
语句后面的分号注意不要省略。
var x = 3;
var i = 0;
do {
console.log(i);
i++;
} while(i < x);
break 语句和 continue 语句
break
语句和continue
语句都具有跳转作用,可以让代码不按既有的顺序执行。break
语句用于跳出代码块或循环。
var i = 0;
while(i < 100) {
console.log('i 当前为:' + i);
i++;
if (i === 10) break;
}
上面代码只会执行10次循环,一旦i
等于10,就会跳出循环。
for
循环也可以使用break
语句跳出循环。
var i = 0;
while (i < 100){
i++;
if (i % 2 === 0) continue;
console.log('i 当前为:' + i);
}
上面代码只有在i
为奇数时,才会输出i
的值。如果i
为偶数,则直接进入下一轮循环。
数据类型
字符串与数组
var s = 'hello';
s[0] // "h"
s[1] // "e"
s[4] // "o"
// 直接对字符串使用方括号运算符
'hello'[1] // "e"
如果方括号中的数字超过字符串的长度,或者方括号中根本不是数字,则返回`undefined`。
'abc'[3] // undefined
'abc'[-1] // undefined
'abc'['x'] // undefined
但是,字符串与数组的相似性仅此而已。实际上,无法改变字符串之中的单个字符。
var s = 'hello';
delete s[0];
s // "hello"
s[1] = 'a';
s // "hello"
s[5] = '!';
s // "hello"
上面代码表示,字符串内部的单个字符无法改变和增删,这些操作会默默地失败。
length 属性
length
属性返回字符串的长度,该属性也是无法改变的
var s = 'hello';
s.length // 5
s.length = 3;
s.length // 5
s.length = 7;
s.length // 5
上面代码表示字符串的length
属性无法改变,但是不会报错。
Base64 转码 (该知识点比较重要)
有时,文本里面包含一些不可打印的符号,比如 ASCII 码0到31的符号都无法打印出来,这时可以使用 Base64 编码,将它们转成可以打印的字符。另一个场景是,有时需要以文本格式传递二进制数据,那么也可以使用 Base64 编码。
所谓 Base64 就是一种编码方法,可以将任意值转成 0~9、A~Z、a-z、+
和/
这64个字符组成的可打印字符。使用它的主要目的,不是为了加密,而是为了不出现特殊字符,简化程序的处理。
JavaScript 原生提供两个 Base64 相关的方法。
-
btoa()
:任意值转为 Base64 编码 -
atob()
:Base64 编码转为原来的值
var string = 'Hello World!';
btoa(string) // "SGVsbG8gV29ybGQh"
atob('SGVsbG8gV29ybGQh') // "Hello World!"
注意,这两个方法不适合非 ASCII 码的字符,会报错。
btoa('你好') // 报错
要将非 ASCII 码字符转为 Base64 编码,必须中间插入一个转码环节,再使用这两个方法。
function b64Encode(str) {
return btoa(encodeURIComponent(str));
}
function b64Decode(str) {
return decodeURIComponent(atob(str));
}
b64Encode('你好') // "JUU0JUJEJUEwJUU1JUE1JUJE"
b64Decode('JUU0JUJEJUEwJUU1JUE1JUJE') // "你好"
对象
你其实完全可以理解为python里的字典
var obj = {
foo: 'Hello',
bar: 'World'
};
如果键名是数值,会被自动转为字符串。如果键名不符合标识名的条件(比如第一个字符为数字,或者含有空格或运算符),且也不是数字,则必须加上引号,否则会报错,记不住就一律加引号得了。
// 报错
var obj = {
1p: 'Hello World'
};
// 不报错
var obj = {
'1p': 'Hello World',
'h w': 'Hello World',
'p+q': 'Hello World'
};
对象的每一个键名又称为“属性”(property),它的“键值”可以是任何数据类型。如果一个属性的值为函数,通常把这个属性称为“方法”,它可以像函数那样调用。
var obj = {
p: function (x) {
return 2 * x;
}
};
obj.p(1) // 2
对象的读值与赋值
var obj = {};
obj.foo = 'Hello';
obj['bar'] = 'World';
属性的查看
var obj = {
key1: 1,
key2: 2
};
Object.keys(obj);
// ['key1', 'key2']
属性的删除:delete 命令
var obj = { p: 1 };
Object.keys(obj) // ["p"]
delete obj.p // true
obj.p // undefined
Object.keys(obj) // []
delete
命令用于删除对象的属性,删除成功后返回true
。
注意,删除一个不存在的属性,delete
不报错,而且返回true
。
var obj = {};
delete obj.p // true
上面代码中,对象obj
并没有p
属性,但是delete
命令照样返回true
。因此,不能根据delete
命令的结果,认定某个属性是存在的。
只有一种情况,delete
命令会返回false
,那就是该属性存在,且不得删除。
var obj = Object.defineProperty({}, 'p', {
value: 123,
configurable: false
});
obj.p // 123
delete obj.p // false
属性是否存在:in 运算符
in
运算符用于检查对象是否包含某个属性(注意,检查的是键名,不是键值),如果包含就返回true
,否则返回false
。它的左边是一个字符串,表示属性名,右边是一个对象。
var obj = { p: 1 };
'p' in obj // true
'toString' in obj // true
in
运算符的一个问题是,它不能识别哪些属性是对象自身的,哪些属性是继承的。就像上面代码中,对象obj
本身并没有toString
属性,但是in
运算符会返回true
,因为这个属性是继承的。
这时,可以使用对象的hasOwnProperty
方法判断一下,是否为对象自身的属性。
var obj = {};
if ('toString' in obj) {
console.log(obj.hasOwnProperty('toString')) // false
}
属性的遍历:for...in 循环
var obj = {a: 1, b: 2, c: 3};
for (var i in obj) {
console.log('键名:', i);
console.log('键值:', obj[i]);
}
// 键名: a
// 键值: 1
// 键名: b
// 键值: 2
// 键名: c
// 键值: 3
for...in
循环有两个使用注意点。
-
它遍历的是对象所有可遍历(enumerable)的属性,会跳过不可遍历的属性。
-
它不仅遍历对象自身的属性,还遍历继承的属性。
举例来说,对象都继承了toString
属性,但是for...in
循环不会遍历到这个属性。
var obj = {};
// toString 属性是存在的
obj.toString // toString() { [native code] }
for (var p in obj) {
console.log(p);
} // 没有任何输出
上面代码中,对象obj
继承了toString
属性,该属性不会被for...in
循环遍历到,因为它默认是“不可遍历”的。
如果继承的属性是可遍历的,那么就会被for...in
循环遍历到。但是,一般情况下,都是只想遍历对象自身的属性,所以使用for...in
的时候,应该结合使用hasOwnProperty
方法,在循环内部判断一下,某个属性是否为对象自身的属性。
var person = { name: '老张' };
for (var key in person) {
if (person.hasOwnProperty(key)) {
console.log(key);
}
}
// name
函数
js三种声明函数的方式
方法一
function print(s) {
console.log(s);
}
方法二
var print = function(s) {
console.log(s);
};
方法三 (这种没人用)
var add = new Function(
'x',
'y',
'return x + y'
);
// 等同于
function add(x, y) {
return x + y;
}
第一等公民
JavaScript 语言将函数看作一种值,与其它值(数值、字符串、布尔值等等)地位相同。凡是可以使用值的地方,就能使用函数。比如,可以把函数赋值给变量和对象的属性,也可以当作参数传入其他函数,或者作为函数的结果返回。函数只是一个可以执行的值,此外并无特殊之处。
由于函数与其他数据类型地位平等,所以在 JavaScript 语言中又称函数为第一等公民。
function add(x, y) {
return x + y;
}
// 将函数赋值给一个变量
var operator = add;
// 将函数作为参数和返回值
function a(op){
return op;
}
a(add)(1, 1)
// 2
函数名的提升
JavaScript 引擎将函数名视同变量名,所以采用function
命令声明函数时,整个函数会像变量声明一样,被提升到代码头部。所以,下面的代码不会报错。
f();
function f() {}
表面上,上面代码好像在声明之前就调用了函数f
。但是实际上,由于“变量提升”,函数f
被提升到了代码头部,也就是在调用之前已经声明了。但是,如果采用赋值语句定义函数,JavaScript 就会报错。
f();
var f = function (){};
// TypeError: undefined is not a function
上面的代码等同于下面的形式。
var f;
f();
f = function () {};
上面代码第二行,调用f
的时候,f
只是被声明了,还没有被赋值,等于undefined
,所以会报错。
注意,如果像下面例子那样,采用function
命令和var
赋值语句声明同一个函数,由于存在函数提升,最后会采用var
赋值语句的定义。
var f = function () {
console.log('1');
}
function f() {
console.log('2');
}
f() // 1
上面例子中,表面上后面声明的函数f
,应该覆盖前面的var
赋值语句,但是由于存在函数提升,实际上正好反过来。
函数作用域
定义
作用域(scope)指的是变量存在的范围。在 ES5 的规范中,JavaScript 只有两种作用域:一种是全局作用域,变量在整个程序中一直存在,所有地方都可以读取;另一种是函数作用域,变量只在函数内部存在。
函数内部定义的变量,会在该作用域内覆盖同名全局变量。
var v = 1;
function f(){
var v = 2;
console.log(v);
}
f() // 2
v // 1
上面代码中,变量v
同时在函数的外部和内部有定义。结果,在函数内部定义,局部变量v
覆盖了全局变量v
。
注意,对于var
命令来说,局部变量只能在函数内部声明,在其他区块中声明,一律都是全局变量。
if (true) {
var x = 5;
}
console.log(x); // 5
上面代码中,变量x
在条件判断区块之中声明,结果就是一个全局变量,可以在区块之外读取。
函数本身也是一个值,也有自己的作用域。它的作用域与变量一样,就是其声明时所在的作用域,与其运行时所在的作用域无关。
var a = 1;
var x = function () {
console.log(a);
};
function f() {
var a = 2;
x();
}
f() // 1
上面代码中,函数x
是在函数f
的外部声明的,所以它的作用域绑定外层,内部变量a
不会到函数f
体内取值,所以输出1
,而不是2
。
总之,函数执行时所在的作用域,是定义时的作用域,而不是调用时所在的作用域。
参数
js里的函数的参数,不用跟实际传过来的值一一对应
arguments 对象
由于 JavaScript 允许函数有不定数目的参数,所以需要一种机制,可以在函数体内部读取所有参数。这就是arguments
对象的由来。
arguments
对象包含了函数运行时的所有参数,arguments[0]
就是第一个参数,arguments[1]
就是第二个参数,以此类推。这个对象只有在函数体内部,才可以使用
var f = function (one) {
console.log(arguments[0]);
console.log(arguments[1]);
console.log(arguments[2]);
}
f(1, 2, 3)
需要注意的是,虽然arguments
很像数组,但它是一个对象。数组专有的方法(比如slice
和forEach
),不能在arguments
对象上直接使用。
如果要让arguments
对象使用数组方法,真正的解决方法是将arguments
转为真正的数组。下面是两种常用的转换方法:slice
方法和逐一填入新数组。
var args = Array.prototype.slice.call(arguments);
// 或者
var args = [];
for (var i = 0; i < arguments.length; i++) {
args.push(arguments[i]);
}
关于slice函数的语法
let fruits = ['apple', 'banana', 'cherry', 'date', 'fig'];
// 提取从索引1开始到索引3的元素
let slicedFruits = fruits.slice(1, 3); // 返回 ["banana", "cherry"]
// 如果不指定end参数,则提取从start到数组末尾的元素
let slicedFruitsToEnd = fruits.slice(1); // 返回 ["banana", "cherry", "date", "fig"]
// 如果start参数是负数,则表示从数组末尾开始计算的位置
let slicedFruitsFromEnd = fruits.slice(-3); // 返回 ["date", "fig"]
// 如果省略start和end参数,则返回数组的一个完整副本
let fruitsCopy = fruits.slice(); // 返回 ["apple", "banana", "cherry", "date", "fig"]
闭包
闭包(closure)是 JavaScript 语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现。
理解闭包,首先必须理解变量作用域。前面提到,JavaScript 有两种作用域:全局作用域和函数作用域。函数内部可以直接读取全局变量。
var n = 999;
function f1() {
console.log(n);
}
f1() // 999
上面代码中,函数f1
可以读取全局变量n
。
但是,正常情况下,函数外部无法读取函数内部声明的变量。
function f1() {
var n = 999;
}
console.log(n)
// Uncaught ReferenceError: n is not defined(
上面代码中,函数f1
内部声明的变量n
,函数外是无法读取的。
如果出于种种原因,需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过变通方法才能实现。那就是在函数的内部,再定义一个函数。
function f1() {
var n = 999;
function f2() {
console.log(n); // 999
}
}
上面代码中,函数f2
就在函数f1
内部,这时f1
内部的所有局部变量,对f2
都是可见的。但是反过来就不行,f2
内部的局部变量,对f1
就是不可见的。这就是 JavaScript 语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。
既然f2
可以读取f1
的局部变量,那么只要把f2
作为返回值,我们不就可以在f1
外部读取它的内部变量了吗!
function f1() {
var n = 999;
function f2() {
console.log(n);
}
return f2;
}
var result = f1();
result(); // 999
上面代码中,函数f1
的返回值就是函数f2
,由于f2
可以读取f1
的内部变量,所以就可以在外部获得f1
的内部变量了。
闭包就是函数f2
,即能够读取其他函数内部变量的函数。由于在 JavaScript 语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境,比如f2
记住了它诞生的环境f1
,所以从f2
可以得到f1
的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
闭包的最大用处有两个,一个是可以读取外层函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。请看下面的例子,闭包使得内部变量记住上一次调用时的运算结果。
function createIncrementor(start) {
return function () {
return start++;
};
}
var inc = createIncrementor(5);
inc() // 5
inc() // 6
inc() // 7
上面代码中,start
是函数createIncrementor
的内部变量。通过闭包,start
的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包inc
使得函数createIncrementor
的内部环境,一直存在。所以,闭包可以看作是函数内部作用域的一个接口。
为什么闭包能够返回外层函数的内部变量?原因是闭包(上例的inc
)用到了外层变量(start
),导致外层函数(createIncrementor
)不能从内存释放。只要闭包没有被垃圾回收机制清除,外层函数提供的运行环境也不会被清除,它的内部变量就始终保存着当前值,供闭包读取。
闭包的另一个用处,是封装对象的私有属性和私有方法。
function Person(name) {
var _age;
function setAge(n) {
_age = n;
}
function getAge() {
return _age;
}
return {
name: name,
getAge: getAge,
setAge: setAge
};
}
var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25
上面代码中,函数Person
的内部变量_age
,通过闭包getAge
和setAge
,变成了返回对象p1
的私有变量。
注意,外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。
语句与表达式的区别
语句(Statement)
语句是执行动作的指令。它告诉JavaScript执行某些操作,通常会有副作用(即改变程序的状态或环境)。语句的结束通常需要一个分号(;
),但这不是强制性的,因为在JavaScript中,分号是语句的可选结束符。例如:
// 赋值语句
let x = 10;
// 函数调用语句
doSomething();
// 控制流语句
if (condition) {
// ...
}
// 循环语句
for (let i = 0; i < 10; i++) {
// ...
}
表达式(Expression)
表达式是一个计算并返回值的代码片段。它可以是一个变量、一个函数调用、一个算术运算或者任何能够产生值的组合。表达式可以是语句的一部分,但单独的表达式不能作为语句存在(除非它是返回值的函数或语句末尾有分号)。 例如:
// 变量名是一个表达式
let y = 20;
// 算术运算是一个表达式
let sum = 10 + 5; // 这里的 10 + 5 是一个表达式
// 函数调用也是一个表达式,特别是当它返回一个值时
let result = doSomething(); // doSomething() 是一个返回值的表达式
立即调用的函数表达式(IIFE)
根据 JavaScript 的语法,圆括号()
跟在函数名之后,表示调用该函数。比如,print()
就表示调用print
函数。
有时,我们需要在定义函数之后,立即调用该函数。这时,你不能在函数的定义之后加上圆括号,这会产生语法错误。
function(){ /* code */ }();
// SyntaxError: Unexpected token (
产生这个错误的原因是,function
这个关键字既可以当作语句,也可以当作表达式。
// 语句
function f() {}
// 表达式
var f = function f() {}
当作表达式时,函数可以定义后直接加圆括号调用。
var f = function f(){ return 1}();
f // 1
上面的代码中,函数定义后直接加圆括号调用,没有报错。原因就是function
作为表达式,引擎就把函数定义当作一个值。这种情况下,就不会报错。
为了避免解析的歧义,JavaScript 规定,如果function
关键字出现在行首,一律解释成语句。因此,引擎看到行首是function
关键字之后,认为这一段都是函数的定义,不应该以圆括号结尾,所以就报错了。
函数定义后立即调用的解决方法,就是不要让function
出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面。
(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();
上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表达式,而不是函数定义语句,所以就避免了错误。这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),简称 IIFE。
注意,上面两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。
// 报错
(function(){ /* code */ }())
(function(){ /* code */ }())
上面代码的两行之间没有分号,JavaScript 会将它们连在一起解释,将第二行解释为第一行的参数。
推而广之,任何让解释器以表达式来处理函数定义的方法,都能产生同样的效果,比如下面三种写法。
var i = function(){ return 10; }();
true && function(){ /* code */ }();
0, function(){ /* code */ }();
甚至像下面这样写,也是可以的。
!function () { /* code */ }();
~function () { /* code */ }();
-function () { /* code */ }();
+function () { /* code */ }();
(function () {
var tmp = newData;
processData(tmp);
storeData(tmp);
}());
eval 命令
基本用法
eval
命令接受一个字符串作为参数,并将这个字符串当作语句执行。
eval('var a = 1;');
a // 1
数组的本质
本质上,数组属于一种特殊的对象。typeof
运算符会返回数组的类型是object
。
typeof [1, 2, 3] // "object"
但是,对于数值的键名,不能使用点结构
var arr = [1, 2, 3];
arr.0 // SyntaxError
上面代码中,arr.0
的写法不合法,因为单独的数值不能作为标识符(identifier)。所以,数组成员只能用方括号arr[0]
表示(方括号是运算符,可以接受数值)。
length 属性
数组的length
属性,返回数组的成员数量。
['a', 'b', 'c'].length // 3
JavaScript 使用一个32位整数,保存数组的元素个数。这意味着,数组成员最多只有 4294967295 个(232 - 1)个,也就是说length
属性的最大值就是 4294967295。
只要是数组,就一定有length
属性。该属性是一个动态的值,等于键名中的最大整数加上1
。
var arr = ['a', 'b'];
arr.length // 2
arr[2] = 'c';
arr.length // 3
arr[9] = 'd';
arr.length // 10
arr[1000] = 'e';
arr.length // 1001
上面代码表示,数组的数字键不需要连续,length
属性的值总是比最大的那个整数键大1
。另外,这也表明数组是一种动态的数据结构,可以随时增减数组的成员。
length
属性是可写的。如果人为设置一个小于当前成员个数的值,该数组的成员数量会自动减少到length
设置的值。
in 运算符
检查某个键名是否存在的运算符in
,适用于对象,也适用于数组。
var arr = [ 'a', 'b', 'c' ];
2 in arr // true
'2' in arr // true
4 in arr // false
上面代码表明,数组存在键名为2
的键。由于键名都是字符串,所以数值2
会自动转成字符串。
注意,如果数组的某个位置是空位,in
运算符返回false
。
var arr = [];
arr[100] = 'a';
100 in arr // true
1 in arr // false
上面代码中,数组arr
只有一个成员arr[100]
,其他位置的键名都会返回false
。
数组遍历
var a = [1, 2, 3];
// for循环
for(var i = 0; i < a.length; i++) {
console.log(a[i]);
}
// while循环
var i = 0;
while (i < a.length) {
console.log(a[i]);
i++;
}
var l = a.length;
while (l--) {
console.log(a[l]);
}
上面代码是三种遍历数组的写法。最后一种写法是逆向遍历,即从最后一个元素向第一个元素遍历。
数组的forEach
方法,也可以用来遍历数组
var colors = ['red', 'green', 'blue'];
colors.forEach(function (color) {
console.log(color);
});
// red
// green
// blue
数据类型转换
强制转换
强制转换主要指使用Number()
、String()
和Boolean()
三个函数,手动将各种类型的值,分别转换成数字、字符串或者布尔值。
Number()
// 数值:转换后还是原来的值
Number(324) // 324
// 字符串:如果可以被解析为数值,则转换为相应的数值
Number('324') // 324
// 字符串:如果不可以被解析为数值,返回 NaN
Number('324abc') // NaN
// 空字符串转为0
Number('') // 0
// 布尔值:true 转成 1,false 转成 0
Number(true) // 1
Number(false) // 0
// undefined:转成 NaN
Number(undefined) // NaN
// null:转成0
Number(null) // 0
类似函数parseInt函数的使用
parseInt('42 cats') // 42
Number('42 cats') // NaN
String()
String(123) // "123"
String('abc') // "abc"
String(true) // "true"
String(undefined) // "undefined"
String(null) // "null"
String({a: 1}) // "[object Object]"
String([1, 2, 3]) // "1,2,3"
Boolean()
Boolean()
函数可以将任意类型的值转为布尔值。
它的转换规则相对简单:除了以下五个值的转换结果为false
,其他的值全部为true
。
-
undefined
-
null
-
0
(包含-0
和+0
) -
NaN
-
''
(空字符串)
Boolean(undefined) // false
Boolean(null) // false
Boolean(0) // false
Boolean(NaN) // false
Boolean('') // false
Boolean(true) // true
Boolean(false) // false
Boolean({}) // true
Boolean([]) // true
Boolean(new Boolean(false)) // true
自动转换
遇到以下三种情况时,JavaScript 会自动转换数据类型,即转换是自动完成的,用户不可见。
第一种情况,不同类型的数据互相运算。
123 + 'abc' // "123abc"
第二种情况,对非布尔值类型的数据求布尔值。
if ('abc') {
console.log('hello')
} // "hello"
第三种情况,对非数值类型的值使用一元运算符(即+
和-
)。
+ {foo: 'bar'} // NaN
- [1, 2, 3] // NaN
自动转换的规则是这样的:预期什么类型的值,就调用该类型的转换函数。比如,某个位置预期为字符串,就调用String()
函数进行转换。如果该位置既可以是字符串,也可能是数值,那么默认转为数值。
由于自动转换具有不确定性,而且不易除错,建议在预期为布尔值、数值、字符串的地方,全部使用Boolean()
、Number()
和String()
函数进行显式转换。
标准库
Object实例对象的方法
Object实例对象的方法,主要有以下六个。
-
Object.prototype.valueOf()
:返回当前对象对应的值。 -
Object.prototype.toString()
:返回当前对象对应的字符串形式。 -
Object.prototype.toLocaleString()
:返回当前对象对应的本地字符串形式。 -
Object.prototype.hasOwnProperty()
:判断某个属性是否为当前对象自身的属性,还是继承自原型对象的属性。 -
Object.prototype.isPrototypeOf()
:判断当前对象是否为另一个对象的原型。 -
Object.prototype.propertyIsEnumerable()
:判断某个属性是否可枚举。
//这里用来toString来判断数据类型
var o1 = new Object();
o1.toString() // "[object Object]"
var o2 = {a:1};
o2.toString() // "[object Object]"
var obj = {
p: 123
};
//Object.prototype.hasOwnProperty方法接受一个字符串作为参数,返回一个布尔值,表示该实例对象自身是否具有该属性。
obj.hasOwnProperty('p') // true
obj.hasOwnProperty('toString') // false
object对象函数
Object.setPrototypeOf
在 JavaScript 中用于设置一个对象的原型(即内部的 [[Prototype]]
属性)。这个方法通常用于改变对象的继承链,或者将一个对象的原型设置为 null
来防止进一步的原型继承。
下面是一个使用 Object.setPrototypeOf
方法的例子:
// 构造函数
function Animal(name) {
this.name = name;
}
// 添加一个方法到 Animal 原型上
Animal.prototype.speak = function() {
console.log(this.name + ' makes a sound.');
};
// 使用构造函数创建一个实例
const animal = new Animal('Generic animal');
// 创建另一个构造函数
function Vehicle(make, model) {
this.make = make;
this.model = model;
}
// 添加一个方法到 Vehicle 原型上
Vehicle.prototype.describe = function() {
console.log('This is a ' + this.make + ' ' + this.model + ' vehicle.');
};
// 使用构造函数创建一个 Vehicle 实例
const car = new Vehicle('Toyota', 'Corolla');
// 现在,我们将 car 实例的原型设置为 animal 实例
// 这意味着 car 将继承 animal 实例的所有属性和方法
Object.setPrototypeOf(car, animal);
// 验证原型链是否已改变
console.log(Object.getPrototypeOf(car) === animal); // 输出: true
// 由于 car 的原型现在是 animal,它继承了 speak 方法
car.speak(); // 输出: Generic animal makes a sound.
// 为了演示,我们也可以将 car 的原型设置为 null,这样它就不再有原型
Object.setPrototypeOf(car, null);
// 再次验证原型链
console.log(Object.getPrototypeOf(car) === null); // 输出: true
// 现在 car 没有原型,因此不能访问 speak 方法
car.speak(); // 输出: TypeError: car.speak is not a function
在这个例子中,我们首先定义了两个构造函数 Animal
和 Vehicle
,并分别为它们的原型添加了方法 speak
和 describe
。然后,我们分别使用这两个构造函数创建了 animal
和 car
实例。
接下来,我们使用 Object.setPrototypeOf
方法将 car
实例的原型设置为 animal
实例。这意味着 car
现在继承了 animal
实例的所有属性和方法。我们通过调用 car.speak()
来验证这一点,它输出了 animal
实例的名字和声音。
最后,我们再次使用 Object.setPrototypeOf
方法将 car
的原型设置为 null
,这意味着 car
将不再继承任何属性和方法。当我们尝试调用 car.speak()
时,由于 car
没有 speak
方法,因此抛出了一个类型错误。
这个例子展示了 Object.setPrototypeOf
方法如何用于修改对象的原型链,以及如何通过改变原型来控制对象继承的属性和方法
Object.getOwnPropertyDescriptor
`Object.getOwnPropertyDescriptor` 方法在 JavaScript 中用于获取指定对象上给定属性的描述信息。描述信息是一个包含属性的各种元数据的对象,例如属性是否可枚举、是否可写、默认值以及 getter 和 setter 函数等。
这个方法对于深入了解对象属性的具体行为和特征非常有用,尤其是在调试或者需要精确控制对象属性时。
下面是一个使用 `Object.getOwnPropertyDescriptor` 方法的例子:
```javascript
// 创建一个对象,并定义一个属性
const myObject = {
myProperty: 'Hello, World!'
};
// 定义一个 getter 和一个 setter 函数
const getter = function () {
return this.myProperty + ' (accessed via getter)';
};
const setter = function (newValue) {
this.myProperty = newValue + ' (updated via setter)';
};
// 使用 Object.defineProperty 为对象添加一个带有 getter 和 setter 的属性
Object.defineProperty(myObject, 'accessorProperty', {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
// 使用 Object.getOwnPropertyDescriptor 获取属性描述信息
const descriptor = Object.getOwnPropertyDescriptor(myObject, 'accessorProperty');
// 打印属性描述信息
console.log(descriptor);
// 输出:
// {
// value: 'Hello, World! (accessed via getter)',
// writable: false,
// enumerable: true,
// configurable: true,
// get: [Function: getter],
// set: [Function: setter]
// }
// 直接访问属性值
console.log(myObject.accessorProperty); // 输出: 'Hello, World! (accessed via getter)'
// 修改属性值
myObject.accessorProperty = 'Goodbye, World!';
console.log(myObject.accessorProperty); // 输出: 'Goodbye, World! (updated via setter)'
```
在这个例子中,我们首先创建了一个对象 `myObject` 并给它定义了一个普通的属性 `myProperty`。接着,我们定义了一个 getter 函数和一个 setter 函数,并将它们与 `Object.defineProperty` 方法一起使用,为 `myObject` 添加了一个带有访问器(accessor)的属性 `accessorProperty`。
使用 `Object.getOwnPropertyDescriptor` 方法,我们获取了 `accessorProperty` 的描述信息,并打印出来。描述信息对象包含了属性的值、是否可写、是否可枚举、是否可配置以及 getter 和 setter 函数的引用。
然后,我们通过直接访问 `accessorProperty` 来触发 getter 函数,并打印出它的返回值。之后,我们尝试修改 `accessorProperty` 的值,这将触发 setter 函数,并将修改后的值打印出来。
通过这个例子,我们可以看到 `Object.getOwnPropertyDescriptor` 方法如何帮助我们获取和理解对象属性的详细信息。