前言
最近出于朋友个人需求,需要爬取一个小型的贴纸商品网站,主要目标是商品的名称、税前后价格以及商品的图片,
目标网站:https://www.brickstickershop.com/
在爬虫方面完全是路人级,之前在校做NLP时第一次实际遇到,那时也是糊里糊涂地找师姐协助才爬到的源数据,工作后偶尔爬过零星几个静态网页,都是比较简单的(类似拼URL就能成的)。这回首次应需去爬一个“野生”网站,并且同时用到了POST和GET,因此稍微记录下。
网页探查
商品详情页:
由于目标包括获得商品尽可能清楚的图片,因此需要到每个商品详情页去爬取商品的信息。商品详情页如下图:
需要爬取的部分已经用红框标记了出来,经过定位标签和请求测试,发现该页面通过GET就可以获得包含所需信息的HTML(静态),那么通过解析HTML获取标签内容就可以了。
商品目录页:
已经确定了一个商品信息的爬取的方法,那么我们需要从目录页获得每个商品的链接来进行一一爬取。目录页如下图:
这里有趣的事情就发生了,首先尝试get后发现获取的HTML里并没有控制台Elements里看到的目标标签,其次再点击换页之后网址也没有发生变化,猜测可能是动态网页了,通过控制台Network查看抓包发现,的确是在换页时产生了一个ajax的application/x-www-form-urlencoded请求,并且返回的是HTML,那就好说了,直接POST获得一个HTML解析就好了。查看formdata如下:
接下来只要按formdata的样式POST就可以,这里有个比较坑的地方, 就是这个参数xajaxargs[],urlencode会把中括号编成%5B%5D,但是实际请求里原中括号还是存在的。。。
所以需要注意替换下,即:
form_data=form_data.replace("xajaxargs%5B%5D=","xajaxargs[]=&xajaxargs[]=")
爬取逻辑
探查完网站后,可以梳理出一个爬取流程:
步骤1:post获取每个目录页,并从目录页中提取到所有商品详情页的链接。
步骤2:通过get去从每一个商品详情链接(步骤1获得)中提取需要信息以及下载图片。
两个步骤,因此可以考虑多线程,创建个全局Queue线程通信,步骤1生产,步骤2消费。
代码尝试
目标具体成效:
最终目标是一个商品一个文件夹,了尽量简化,这里将爬取的文本信息(商品名称、税前价格、税后价格)用于命名文件夹,文件夹中只保存商品的相关图片。可以先选取一些具体商品详址进行尝试,基于不同商品特征(比如商品名中有特殊符号、没有图片等情况)做简单的测试。略。
插曲——selenium尝试:
其实因为urlencode编码"[]"的问题一开始没注意到,导致自认为步骤1的post是整不明白了,又是小需求,selenium算了,于是步骤1还写了个selenium版。浏览器驱动的安装这里不再赘述了,贴下粗糙的代码。。。
# -*- coding: utf-8 -*-
from urllib.parse import urlencode
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
import time
pro_list=[]
url="https://www.brickstickershop.com/website/index.php?Show=Search&KeyWord=#filter:7c7c874cc679560ca3e52b99a0114df7"
s=Service("./webdriver")
browser = webdriver.Chrome(service=s)
page=1
try:
browser.get(url)
ww = WebDriverWait(browser,20)
while True:
#一页24个商品,直接拼xpath获取各个商品详情页链接
for n in range(1,25):
xpath='/html/body/div[1]/div/div[5]/div/div/div[{n}]/div/div/a'.format(n=str(n))
#until很重要,等页面加载
product=ww.until(EC.presence_of_element_located((By.XPATH,xpath)))
product_url=product.get_attribute('href')
print(product_url)
pro_list.append(str(product_url))
try:
#until很重要,等页面加载
objBtn=ww.until(EC.presence_of_element_located((By.XPATH,'/html/body/div[1]/div/div[6]/div/div/button[2]')))
#点击按钮(下一页)
objBtn.click()
page+=1
#保守等加载
time.sleep(5)
except:
print("无法获得新的目录页,当前页:%d"%(page))
break
finally:
#关闭浏览器
browser.close()
完整代码
selenium虽然好用且稳,但是使用驱动毕竟还是速率有失,本案例也并不复杂,因此还是使用requests来完成整个任务。
# -*- coding: utf-8 -*-
import re
import os
import time
import requests
from threading import Thread
from queue import Queue,Empty
from lxml import etree
from bs4 import BeautifulSoup
from urllib.parse import urlencode
# import urllib.request
#用于读取目录页,目录页都是ajax返回的(返回的整个HTML),因此POST方式获得每页并解析出商品的URL(每页24个)放入Queue共另一线程读取商品详细页面
def postProductUrl(url,headers,xajaxr):
s=requests.Session()
#可以保留一个pro_list列表,记录所有商品的详情页面URL
pro_list=[]
page=0
cyctag=True
while cyctag:
page+=1
form_data={
"xajax": "ProductFilter",
"xajaxr": xajaxr,
"xajaxargs[]":"",
"xajaxargs[]":"<xjxquery><q>SearchMethod=PARTMATCH&Filter[1171652]=&SortingOrder=ProductName&CurrentPage=%d&CategoryLayoutId=&SecondColorSchemeId=</q></xjxquery>"%(page)
}
#对表单进行url编码
form_data=urlencode(form_data)
#中括号和参数空值编码会出现与网页源请求不同,因此需要替换下(很重要!)
form_data=form_data.replace("xajaxargs%5B%5D=","xajaxargs[]=&xajaxargs[]=")
#表单POST
response = s.post(url= url,headers=headers,data=form_data)
html_data=response.text
try:
#BS准备解析HTML
soup = BeautifulSoup(html_data, 'lxml')
#超过有效页数后会返回一个有'h5'标签的页面,如果有'h5'即认为到头了(大概测试了下,也可以使用别的识别方法)
end_note=soup.find('h5')
if end_note:
print("目录获取完毕,共计%d页"%(page))
cyctag=False
break
else:
#BS解析标签获得HTML中的目标内容(这里是各个商品详情页的链接)
product_list=soup.find_all(attrs={'class':'c-product-block'})
for j in range(len(product_list)):
product_url=product_list[j].find('a').get('href')
pro_list.append(str(product_url))
#product_url_q为全局Queue
product_url_q.put(str(product_url))
#没代理的话sleep一下尽可能避免被拒
time.sleep(5)
except:
print("获取第%d页目录失败"%(page))
cyctag=False
break
# 用于读取商品详情页,详情页是静态的
def getProductInfo(headers,path):
totalcnt=0
s=requests.Session()
cyctag=True
while cyctag:
try:
#获得Queue里的url,等待20秒后认为不会再有新消息
url=product_url_q.get(block=True, timeout=20)
print(url)
#Queue的Empty抛错,结束工作
except Empty:
print("商品URL列表已空")
cyctag=False
break
if url.strip()=='':
continue
#GET即可
response = s.get(url= url,headers=headers)
totalcnt+=1
wb_data = response.text
soup = BeautifulSoup(wb_data, 'lxml')
#获取title
title=soup.find(attrs={'class':'content-page-title product-title'}).text
#title=url[33:] #URL里其实也有title
#获取税后价格
price1_exc=soup.find(attrs={'id':'Price1_exc'}).text[2:]
#获取税前价格
price1_inc=soup.find(attrs={'id':'Price1_inc'}).text[2:]
#获取有图片的标签
product__thumb=soup.find(attrs={'class':'product__images','id':'product__images'})
if not product__thumb:
print("没得图呢")
continue
#想把爬到的文字信息直接放文件夹名里,太天真了,不规范的命名很容易建夹时报错,这里就先嗯替换和截取了,建议使用更好的方案
#PS.最好是对文件夹名用生成的编码,再单独整个全局文件用于记录商品编码、名字(其他爬取到的文字信息)与网页的映射
title=title.replace(' ','-')
title=title.replace('\\','-')
title=title.replace('/','-')
title=title.replace(':','-')
title=title.replace(')','-')
title=title.replace('(','-')
#再倒截下怕超长和重名。这块对title的操作很智障,并且破坏了爬取到信息的可用性,有时间再改了。
title=title[-20:]
#获取图片链接列表
img_list=product__thumb.find_all('img')
#文件夹名:税后价格$商品title$税前价格
path_new=path+price1_inc+'$'+title+'$'+price1_exc
if not os.path.exists(path_new):
os.mkdir(path_new)
#打印新建文件夹
print("新建文件夹:",path_new)
for i in range(len(img_list)):
img_url=img_list[i].get('src')
#图片不是链接时跳过
print(img_url)
if img_url[:4]!='http':
print("无法识别的URI")
continue
#被名字整伤了,同个文件夹里直接用序号了
target=path_new+'\\'+str(i)+'.jgp'
#下载图片到目标路径
#urllib.request.urlretrieve有时会被SSL卡住,时灵时不灵的,决定用最简朴的,但它是真的慢
with open(target,'wb') as f:
img = requests.get(img_url,headers = stage2_headers).content
f.write(img)
print('处理完成url:',url)
totalcnt+=1
#优化可以加日志
#单IP每处理10个商品页面停顿10秒避免请求被拒
#随缘的,这边之前串行时设置的是停8秒没有被卡掉,多线程下似乎8秒是不够的,建议还是挂代理
if totalcnt%10==0:
time.sleep(10)
if __name__ == "__main__":
#创建线程通信的全局Queue
product_url_q = Queue()
#实际中建议挂代理,请求时添加参数proxies=proxies
# proxy = 'ip:port'
# proxies = {
# 'http': 'http://' + proxy,
# 'https': 'https://' + proxy
# }
#part-1-postProductUrl
#post的目标URL
stage1_url="https://www.brickstickershop.com/website/Includes/AjaxFunctions/WebsiteAjaxHandler.php?Show=Search"
stage1_headers= {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
"content-type": "application/x-www-form-urlencoded",
"referer": "https://www.brickstickershop.com/website/index.php?Show=Search&KeyWord=",
"origin": "https://www.brickstickershop.com",
"cookie": "" #手工进遍网站把cookie贴过来
}
stage1_xajaxr="" #手工选一个目录页,看formdata复制个xajaxr过来
#part-2-getProductInfo
#第二个阶段的请求头不必有"content-type": "application/x-www-form-urlencoded"
stage2_headers= {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
"cookie": "" #手工进遍网站把cookie贴过来
}
#假设存到D盘ret文件夹里
target_path='D:\\ret\\'
#起线程
t1 = Thread(target=postProductUrl, args=(stage1_url,stage1_headers,stage1_xajaxr))
t2 = Thread(target=getProductInfo, args=(stage2_headers,target_path))
t1.start()
t2.start()
t1.join()
t2.join()
print('任务结束')
实际上因为该网站的商品规模比较少(截至一月中旬时,大概80来页,每页24个商品),所以在没有使用代理的情况下,当时是直接串行执行而没有使用到多线程的。当时测试,串行情况下,步骤2大概每10个商品“sleep”8秒是不会被拒的。使用多线程后步骤1、2近乎同时在请求,可能更容易被拒,因此实际使用还是建议挂代理。