Python post請求模擬登錄淘寶並爬取商品列表


一、前言

大概是一個月前就開始做淘寶的爬蟲了,從最開始的用selenium用戶配置到selenium模擬登錄,再到這次的post請求模擬登錄。一共是三篇博客,記錄了我爬取淘寶網的經歷。期間也有朋友向我提出了不少問題,比如滑塊失敗,微博登錄失敗等,可以說用selenium模擬登錄這方面,坑特別多,直接加載用戶配置又很笨重,效率低下。所以這次嘗試構造post請求表單,模擬登錄。

往期鏈接:
https://blog.csdn.net/pineapple_C/article/details/107461989
https://blog.csdn.net/pineapple_C/article/details/107641799

github源碼鏈接:
https://github.com/Pineapple666/TaobaoSpider/tree/master/Taobao_face

本文篇幅較長,建議先看代碼,有疑惑的再來看。

二、模擬登錄

1)用瀏覽器走一遍登錄過程

先把淘寶網的cookies全部清除,然后訪問淘寶:https://www.taobao.com,這時候是不需要登錄的。

在搜索框搜索iphone,立即跳出了登錄頁面,它的url是:
https://login.taobao.com/member/login.jhtml?redirectURL=http%3A%2F%2Fs.taobao.com%2Fsearch%3Fq%3Diphone%26imgfile%3D%26commend%3Dall%26ssid%3Ds5-e%26search_type%3Ditem%26sourceId%3Dtb.index%26spm%3Da21bo.2017.201856-taobao-item.1%26ie%3Dutf8%26initiative_id%3Dtbindexz_20170306&uuid=f6dd176ff336683f5d47fc1cb16504af

很長很長,但標紅的這部分url很重要,redirectURL是重定向url,登錄后會跳轉到這個url,當然這個是經過url編碼的。

在這里插入圖片描述
其余后面的參數很亂,不知道有用沒用,先試一下,把后面的參數去掉,訪問https://login.taobao.com/member/login.jhtml?redirectURL=http%3A%2F%2Fs.taobao.com%2Fsearch%3Fq%3Diphone看看能不能行:

在這里插入圖片描述
可以進入登錄頁面,那能不能登錄呢?

在這里插入圖片描述
好,正如上面所說,跳轉到了這個url。

2)用抓包工具分析登錄過程

既然可行,那么接着再來一次,這次看看這個過程都發起了哪些請求,提交了哪些數據。(別忘記清除cookies

可以使用瀏覽器開發者模式也可以使用抓包工具Fiddler,使用瀏覽器的話要打開Preserve log

在這里插入圖片描述
我用的是Fiddler

設置抓取的User-Agents為Chrome

在這里插入圖片描述
直接訪問:https://login.taobao.com/member/login.jhtml?redirectURL=http%3A%2F%2Fs.taobao.com%2Fsearch%3Fq%3Diphone

點擊登錄。查看請求記錄。

在這里插入圖片描述
這是兩個非常重要的url

第一個是最開始訪問的登錄頁面,一個普通的get請求,第二個就不同了,它是一個post請求,其中表單包含了大量的數據信息

在這里插入圖片描述
內容雖然很多,但經過我多次的測試和比對后,發現了如下幾條規律:

1、loginId一眼就可以看出是賬號,ua猜測為一種加密后的用戶標識,password2猜測為加密后的密碼。這三條信息可以當作固定值反復使用

2、_csrf_token, umidToken, hsiz隱藏在登錄頁面里

在這里插入圖片描述
3、其他的都是不變的

3)代碼實戰

文件名為login.py,類名為Login

class Login:
    """
    模擬登錄並獲取cookies
    """

    def __init__(self, ua, loginId, password2):
        """
        初始化用戶參數信息和相關url

        :param ua:
        :param loginId:
        :param password2:
        """
        self.ua = ua
        self.loginId = loginId
        self.password2 = password2

        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36'
        }

        # 模擬輸入商品后跳轉的登錄頁面
        self.login_url = f'https://login.taobao.com/member/login.jhtml?redirectURL=http%3A%2F%2Fs.taobao.com%2Fsearch%3Fq%3D{quote(PRODUCT)}'
        # 提交表單,獲取重定向url
        self.commit_url = 'https://login.taobao.com/newlogin/login.do?appName=taobao&fromSite=0'
        # 默認重定向url
        self.redirect_url = f'https://s.taobao.com/search?q={PRODUCT}'
        urllib3.disable_warnings()

ua, loginId, password2這三個是用戶信息,傳遞這三個參數以初始化Login類。PRODUCT是一個全局變量,代表着商品名,在setting.py里可以設置這個變量。如果商品名帶有中文,則需要用urllib.parse.quote()進行url編碼。

logged函數

    def logged(self):
        """
        模擬登錄

        :return: bool
        """
        if self.load_cookies():
            return False
        post_data = {
            'loginId': self.loginId,
            'password2': self.password2,
            'keepLogin': 'false',
            'ua': self.ua,
            # 'umidGetStatusVal': '255',
            # 'screenPixel': '1536x864',
            # 'navlanguage': 'zh-CN',
            'navUserAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36',
            'navPlatform': 'Win32',
            'appName': 'taobao',
            'appEntrance': 'taobao_pc',
            '_csrf_token': self.get_value('_csrf_token'),
            'umidToken': self.get_value('umidToken'),
            'hsiz': self.get_value('hsiz'),
            'bizParams': None,
            # 'style': 'default',
            'appkey': '00000000',
            'from': 'tb',
            'isMobile': 'false',
            # 'lang': 'zh-CN',
            'returnUrl': self.redirect_url,
            'fromSite': '0'
        }
        headers = {
            'Host': 'login.taobao.com',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36',
            'Accept': 'application/json, text/plain, */*',
            # 'Accept-Language': 'zh-CN,en-US;q=0.7,en;q=0.3',
            # 'Accept-Encoding': 'gzip, deflate, br',
            'Content-Type': 'application/x-www-form-urlencoded',
            'Origin': 'https://login.taobao.com',
            'Connection': 'keep-alive',
            'Referer': self.login_url,

        }
        try:
            response = SESSION.post(url=self.commit_url, headers=headers, data=post_data, verify=False)
            response.raise_for_status()
        except Exception as e:
            logger.error(f'登錄失敗,原因:')
            raise e
        self.queue_cookies()
        self.redirect_url = response.json()['content']['data']['redirectUrl']
        return True

為了方便登錄,每次登錄成功后都會自動保存cookies,所以在登錄之前都先要判斷是否存在cookies,cookies是否正確等問題。如果上述條件都不成立的話,則重新登錄,重新保存cookies。模擬登錄最重要的就是執行post請求,而執行post請求就要構造好一個正確的post字典,對於_csrf_token, umidToken, hsiz這三條數據,可以去登錄頁面提取

這個提取過程主要依靠這兩個函數:

    @property
    def _html(self):
        """
        獲取登錄頁面代碼

        :return: self._html
        """
        response = SESSION.get(url=self.login_url, headers=self.headers, verify=False)
        return response.text

    def get_value(self, key):
        """
        根據傳入的鍵得到對應的值

        :param key: 鍵名
        :return: 鍵所對應的值
        """
        match = re.search(rf'"{key}":"(.*?)"', self._html)
        return match.group(1)

使用Python的@property裝飾器,訪問內部屬性。它相當於又創造了一個和函數名相同的一個屬性。調用此函數即調用此屬性,有點像Java里的get方法。由於_csrf_token, umidToken, hsiz這三個字段都有一個共同點,都可以通過上面的正則表達式匹配到,所以可以歸結為一個函數,不用寫三個函數。

表單構造完后,發起post請求,SESSION是一個全局會話,登錄和爬取都是一個會話,方便處理cookies。

請求沒有問題后,調用queue_cookies(),立即保存cookies

	def queue_cookies(self):
	       """
	       序列化cookies
	
	       :return:
	       """
	       cookies_dict = dict_from_cookiejar(SESSION.cookies)
	       with open(COOKIES_PATH, 'w', encoding='utf-8') as file:
	           json.dump(cookies_dict, file)
	           logger.success('保存cookies文件成功!')

之后有一個self.redirect_url,對重定向url的再次賦值,這個主要是檢查是否會出現滑塊驗證。只有在連續多次相同ip登錄的時候才會跳轉到滑塊驗證,這時候如果還是訪問原先的url,它也會跳轉,所以加不加都行。

如果登錄成功了,可以輸出一下當前的網頁標題來驗證一下

    def print_title(self):
        """
        輸出重定向頁面后的標題,以驗證登錄

        :return:
        """
        try:
            response = SESSION.get(url=self.redirect_url, headers=self.headers, verify=False)
            response.raise_for_status()
            content = response.text
            # 有必要時保存第一頁代碼,便於調試
            # with open('success.html', 'w', encoding='utf-8')as file:
            #     file.write(content)
            match = re.search(r'<title>(.*?)</title>', content, re.S)
            title = match.group(1)
            if title != f'{PRODUCT}_淘寶搜索':
                raise TitleError(f'標題錯誤,標題:{title}')
        except TitleError as e:
            raise e
        else:
            logger.info(f'網頁標題為:{title}')

TitleErrors是個自定義異常,用來捕捉標題錯誤。出現滑塊驗證時候的標題為:security-X5這個時候要等待一會才能登錄成功
這個拋出異常分為兩種情況,如果是加載cookies失敗,則重新登錄,如果是登錄失敗,則退出程序,這是在load_cookies()函數內實現的

    def load_cookies(self):
        """
        加載cookies

        :return: bool
        """
        if os.path.exists(COOKIES_PATH):
            try:
                logger.info('加載cookies')
                SESSION.cookies = self.unqueue_cookies()
                self.print_title()
            except EXCEPTION as e:
                logger.error(f'登錄失敗,原因:{e}')
                os.remove(COOKIES_PATH)
                return False
            else:
                return True
        else:
            return False

加載cookies首先要將保存的cookies取出來

    def unqueue_cookies(self):
        """
        反序列化cookies

        :return:
        """
        try:
            with open(COOKIES_PATH, 'r', encoding='utf-8') as file:
                cookies_dict = json.load(file)
        except JSONDecodeError as e:
            raise e
        else:
            return cookiejar_from_dict(cookies_dict)

根據load_cookies()的返回值判斷是否不需要登錄。

這就是整個登錄的流程,本來很簡單的被我這么一說反而變復雜了。再概括一下整個流程吧,首先一上來先加載cookies,如果沒有cookies文件,或者加載cookies失敗,則再登錄一遍並保存cookies,輸出當前頁面標題,符合條件則登錄成功,不符合則失敗退出程序。

三、爬取商品列表

借助全局的SESSION來處理cookies,就可以實現連續翻頁,訪問詳情頁面的操作。當然詳情頁面的爬取還有帶開發,先爬取商品列表。

1)分析url

https://s.taobao.com/search?q=iphone&bcoffset=6&p4ppushleft=1%2C48&ntoffset=6&s=0
https://s.taobao.com/search?q=iphone&bcoffset=3&p4ppushleft=1%2C48&ntoffset=3&s=44
https://s.taobao.com/search?q=iphone&bcoffset=0&p4ppushleft=1%2C48&ntoffset=6&s=88
https://s.taobao.com/search?q=iphone&bcoffset=-3&p4ppushleft=1%2C48&ntoffset=-3&s=132
https://s.taobao.com/search?q=iphone&bcoffset=-6&p4ppushleft=1%2C48&ntoffset=-6&s=176

這是前五頁的url,雖然參數很多,但也能窺探到其中的規律。

bcoffset和ntoffset判斷為偏移量,從6開始逐頁遞增-3。s判斷為已觀看的商品數,從0開始逐頁遞增44

等一下,第三頁的兩個偏移量不相等啊?先別急,訪問歸我納出的url試一下:https://s.taobao.com/search?q=iphone&bcoffset=0&p4ppushleft=1%2C48&ntoffset=0&s=88

在這里插入圖片描述
很好,根據上述歸納,把代碼寫下來:

    def get_url(self):
        """
        構造url

        :return: url
        """
        for page in range(MAX_PAGE):
            offset = 6 - page * 3
            detali = 44 * page
            yield f'http://s.taobao.com/search?q={PRODUCT}&bcoffset={offset}&ntoffset={offset}&p4ppushleft=1%2C48&s={detali}'

PRODUCT前面說過了,是商品名。

因為畢竟這不是一個小項目,淘寶的反爬也是非常厲害,所以按照可以添加代理的方式編寫代碼,為以后的代理,異步操作做准備。

這其中就有構造一個淘寶請求類,儲存請求類,獲取代理,設置超時時間,代理異常捕捉等問題。聽我一一道來。

2)獲取代理

    def get_proxy(self):
        """
        從代理池獲取代理

        :return: proxy
        """
        try:
            response = requests.get(PROXY_POOL_URL)
            if response.status_code == 200:
                logger.info('Get Proxy', response.text)
                return response.text
            return None
        except requests.ConnectionError:
            return None

PROXY_POOL_URL是獲取代理的url,這個要配合代理池的使用。即使是付費代理,最好也是在代理池走一遍流程,以提高代理的正確率。

3)分析網頁代碼

在這里插入圖片描述
定位一下結點,看上去好像只要用代碼定位到這里就可以提取數據了,其實不然,上圖的頁面和代碼都是異步加載出來的,和真實的請求結果很不一樣。我把代碼請求獲得的代碼和瀏覽器看到的代碼比對一下,你就知道。

瀏覽器看到的代碼

在這里插入圖片描述
請求返回的代碼

在這里插入圖片描述
id為main的結點才剛開始,就到結尾了!!!

既然在html里找不到,那干脆就搜索吧,點擊NetWork,刷新一下頁面,搜索任意商品標題

在這里插入圖片描述
在這里插入圖片描述
果然是有的,它保存在一個名為g_page_config的變量里,而且是json格式的。回過頭來發現響應的結果也有這個東西:

在這里插入圖片描述
原來如此,數據藏在這個地方,直接用正則表達式就可以匹配出來:

4)解析頁面

    def parse_detail(self, response):
        """
        解析頁面

        :return: 商品信息列表
        """
        # 匹配全部信息
        # match = re.findall(
        #     r'"nid":"(.*?)","category":"(.*?)","pid":"(.*?)","title":"(.*?)","raw_title":"(.*?)","pic_url":"(.*?)",'
        #     r'"detail_url":"(.*?)","view_price":"(.*?)","view_fee":"(.*?)","item_loc":"(.*?)","view_sales":"(.*?)",'
        #     r'"comment_count":"(.*?)","user_id":"(.*?)","nick":"(.*?)"', response.text, re.S)
        # keys = ('nid', 'category', 'pid', 'title', 'raw_title', 'pic_url', 'detail_url', 'view_price',
        #         'view_fee', 'item_loc', 'view_sales', 'comment_count', 'user_id', 'nick')

        # 匹配重要信息
        match = re.findall(
            r'"nid":"(.*?)",.*?,"raw_title":"(.*?)",.*?,"view_price":"(.*?)","view_fee":"(.*?)","item_loc":"(.*?)",'
            r'"view_sales":"(.*?)人付款","comment_count":"(.*?)",.*?,"nick":"(.*?)"', response.text, re.S)
        keys = ('id', 'name', 'price', 'fee', 'location', 'sales', 'comments', 'shop')
        return [dict(zip(keys, value)) for value in match if len(value[4]) < 50]

因為要保存到mysql里面,所以匹配結果的每一組都應該是一個字典,都放在一個列表里。對於這個列表怎么構造,在這里說明一下:

re.findall()返回的結果是一個列表,列表內的每個元素都是一個元組,一個元組就是一個商品的信息(標題,價格,成交人數等等),keys也是一個元組,代表着mysql里的鍵名,運用dict(zip(keys,value))的方式創建字典,最后外面套上個列表推導式,這個列表就搞定了。

有時候,因為一個商品少了view_sales這個鍵,導致item_loc的值非常長,直接匹配到下一個商品的item_loc,這種情況是不允許的,所以加上長度限制,過長則直接跳過。

根據以往的套路,有了url,代理,解析函數,基本上就可以完成這次的爬蟲了。但這次不同,要做到一個高效穩定的爬蟲僅僅考這些是不夠的。就好比代理,萬一這次的請求失敗了怎么辦,會不會出現異常,這頁的數據就不要了嗎?當然是不行的,不到萬不得已,絕不放過任何一條有價值的數據。所以要建立一個高穩定的高容錯率的機制。

用redis去配合mysql的存儲

5)淘寶請求類:

class TaobaoRequest(Request):
    """
    淘寶請求
    """

    def __init__(self, url, callback, method='GET', headers=None, need_proxy=NEED_PROXY, timeout=TIMEOUT, fail_time=0):
        """

        :param url: url
        :param callback: 回調函數
        :param method: 請求方法
        :param headers: 請求頭
        :param need_proxy: 是否需要代理
        :param timeout: 超時時間
        :param fail_time: 請求失敗次數
        """
        Request.__init__(self, method, url, headers)
        self.callback = callback
        self.need_proxy = need_proxy
        self.timeout = timeout
        self.fail_time = fail_time

上面構造了一個請求類,目的就是把本次請求的相關參數比如失敗次數,超時時間,是否需要代理等整合到一起,統統放到redis數據庫內。然后統一調度,若請求失敗則再放入redis中,等待下一次的調度。這樣就不會丟失數據。

6)存儲

    def start(self):
        """
        儲存全部url,等待調度

        :return: None
        """
        for url in self.get_url():
            taobao_request = TaobaoRequest(url=url, callback=self.parse_detail, headers=self.headers)
            self.queue.add(taobao_request)
            logger.info(f'Add {taobao_request.url} to redis.')

存好url,等待后面的調度

7)調度

    def schedule(self):
        """
        調度請求

        :return: None
        """
        while not self.queue.empty():
            taobao_request = self.queue.pop()
            callback = taobao_request.callback
            logger.info(f'Schedule {taobao_request.url}')
            response = self.request(taobao_request)
            if response and response.status_code in VALID_STATUSES:
                results = callback(response)
                if results:
                    for result in results:
                        if isinstance(result, dict):
                            self.mysql.insert(MYSQL_TABLE, result)
                            logger.success(f'successful parse {taobao_request.url}')
                else:
                    self.error(taobao_request)
            else:
                self.error(taobao_request)

首先判斷是否還有請求類等待調度,有則取出這個請求類,拿出來的這個類只是一空盒子,里面沒有任何東西,只有表面的信息(捆綁在一起的參數)。所以要請求這個類里面的url,才能得到響應,盒子里才會有內容。

8)請求

    def request(self, taobao_request):
        """
        執行請求

        :param taobao_request: 請求
        :return: 響應
        """
        try:
            if taobao_request.need_proxy:
                proxy = self.get_proxy()
                if proxy:
                    proxies = {
                        'http': 'http://' + proxy,
                        'https': 'https://' + proxy
                    }
                    logger.info(f'Get proxy {proxies}')
                    return SESSION.get(url=taobao_request.url, headers=self.headers, timeout=taobao_request.timeout,
                                       proxies=proxies)
            return SESSION.get(url=taobao_request.url, headers=self.headers, timeout=taobao_request.timeout)
        except (ConnectionError, ReadTimeout) as e:
            print(e.args)
            return False

在請求之前先判斷是否需要代理,need_proxy這個屬性是根據setting.py里的NEED_PROXY設置的。代理這個東西,有可能上一秒測試的時候還是好好的,下一秒就不行了,壽命非常有限。所以還是要有相應異常捕捉機制。

調度函數里的callback就是解析函數parse_detail(),如果這個請求返回的是個False,parse_detail()自然就不能解析出數據,解析不到數據怎么辦?

這時候就用到容錯函數了

9)錯誤處理

    def error(self, taobao_request):
        """
        錯誤處理

        :param taobao_request: 請求
        :return: None
        """
        taobao_request.fail_time += 1
        logger.debug(f'Url {taobao_request.url} faile_time + 1, current fail_time: {taobao_request.fail_time}')
        if taobao_request.fail_time < MAX_FAIL_TIME:
            self.queue.add(taobao_request)
        else:
            logger.debug(f'Url {taobao_request.url} delete!')

在解析的數據出現異常的時候,便會調用這個函數,將失敗次數+1,到了最大失敗次數MAX_FAIL_TIME時則從redis中徹底刪除這個請求,MAX_FAIL_TIME在setting.py中設置。

如果解析數據成功,就直接插入mysql里。

有關redis和mysql的代碼,都是些套路問題,記下來就好,需要的時候直接拿出來用,我就不在博客里詳細介紹了。

更多詳細內容,完整代碼見github:https://github.com/Pineapple666/TaobaoSpider/tree/master/Taobao_face


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM