【日常】Geetest滑動驗證碼(三代canvas版)處理小結(以B站登錄驗證爲例)

問題描述

這個問題確實讓我困擾了太長時間,今天花了半天時間,並沒有找到非常完滿的解決方案,只是在解決問題的過程中學會了一些其他知識,我最後還是要通過人工來判斷大致的移動距離,然後根據誤差做微調。大致做個總結了,並且認爲網站如果真心願意反爬蟲,完全可以處理到讓爬蟲無懈可擊。

今年四月份時B站的Geetest驗證碼大致情況可以通過https://blog.csdn.net/CY19980216/article/details/89074771中的知悉,當時檢查元素可以發現驗證圖片是由一塊塊div包裹着的切片,並且標籤中註明了該切片在原圖中對應的座標,這就對我們復原圖片並尋找缺塊位置帶來了極大的便利(事實上可以通過抓包拿到有缺塊的驗證圖片與無缺塊的驗證圖片)。

但是現在的情況是下面這樣的,驗證碼是一張canvas畫布👇

 

雖然在頁面源代碼中並不能找到驗證圖片的鏈接,但是我們通過瀏覽器監聽抓包可以發現有👇

其實抓包依然可以發現被切分並打亂的驗證圖片,並且在JS監聽抓包中可以看到這個圖片鏈接的響應👇

其實看到這裏我是以爲問題已經解決了,顯然是通過JS請求,Geetest響應了圖片鏈接URL,然後訪問圖片鏈接就可以拿到這張驗證圖片。

 

解決設想

首先在頁面源代碼中可以找到這段JS響應的請求URL,經過對頁源中各個script標籤中的檢測發現上圖返回了圖片鏈接URL的JS請求的URL如下圖所示(我將頁源複製到Notepad++中了):

紅框中的東西就是這個👇

https://api.geetest.com/get.php?is_next=true&type=slide3&gt=b6cc0fc51ec7995d8fd3c637af690de3&challenge=ac60b064d702e4f9cc4d728eeca30764&lang=zh-cn&https=false&protocol=https%3A%2F%2F&offline=false&product=embed&api_server=api.geetest.com&isPC=true&area=%23geetest-wrap&width=100%25&callback=geetest_1575204658911

 這顯然是一個帶有查詢字符串的GET請求,它的查詢字符串大致是這樣的👇

 

要命的問題來了,可以看到這裏有一個chanllenge的字段,事實上這個URL只能被訪問一次(即瀏覽器訪問的這一次),二次訪問就會返回給你old challenge的響應(不信你把這段URL複製到瀏覽器裏看看),challenge字段起到驗證作用,且閱後即焚,是個一次性用品,也就是說這張圖片的鏈接URL只能被請求一次。

於是問題在於如何在利用selenium爬蟲時可以順帶監聽瀏覽器本身的抓包情況,這個便需要用到一個叫作browsermobproxy的庫,這個庫在安裝上有個坑點,首先pip install browsermobproxy後你還需要去Github上去把它的源碼下下來,因爲需要源碼裏面的一個bat文件用來操作java,這個庫具體用法Firefox用戶可以參考https://blog.csdn.net/m0_37618247/article/details/85066272,它可以協作selenium把瀏覽器的包以json格式保存下來,經過實驗,確實是可以拿到了圖片的鏈接的了。

但是我無法復原上面截圖中的那張打亂的圖片,於是我乾脆決定直接在網頁上進行canvas元素截圖。

元素截圖不是什麼難事,元素定位可以定位到它的左上角座標,再根據元素尺寸即可拿到元素的具體位置了。坑點在於截屏的尺寸往往與瀏覽器自身尺寸不一樣,即實際操作時需要將selenium截屏後的圖片進行放縮(我的放縮比例是1.25)

問題轉化爲如何尋找缺塊,我確實沒有能夠找到很好的辦法去處理缺塊,先後嘗試了多種方法(基於矩陣的運算,我先後測試了臨近兩列的列向量相似度變化曲線,缺塊),另外提一句利用selenium執行JS腳本可以從canvas中抓取到圖片👇

	JS = 'return document.getElementsByClassName("geetest_canvas_slice geetest_absolute")[0].toDataURL("image/png");'
	img_info = browser.execute_script(JS) 					 # 執行js文件得到帶圖片信息的圖片數據
	img_base64 = img_info.split(',')[1]  					 # 拿到base64編碼的圖片信息
	img_bytes = base64.b64decode(img_base64) 				 # 轉爲bytes類型
	with open("1.png","wb") as f:
		f.write(img_bytes)
	

我看了其他人的方法都可以用該方法去找到原圖與有缺塊的圖(事實上頁面上有一個默認不展示display:none的canvas,它理論上包含着的是不含缺塊的原圖),但是這裏我只能拿到如下的圖👇

毫無用處。。。

 

最後用PIL庫中的ImageContrast方法進行圖片對比度增強,可以使得圖片中的缺塊變爲幾乎純黑,於是可以近似找到邊界👇

但是這個方法並不對所有的圖片都生效,比如下面這張👇

對比度增強後並不會出現黑團(不展示增強對比度後的圖片了,實在是太驚悚了,我可憐的2233娘)。

所以最後我也懶得處理這個問題了,我就直接用肉眼看一下大概缺塊的位置所佔整張圖片寬度的比例,然後手動輸入給程序了。。。確實比較蠢,但也不想動腦子了。

最後代碼大致是這樣的👇

	def login_20191130(self,):											 # 用戶登錄(20190712更新)
		"""
		目前登錄方式相對於20190408的區別在於一下幾點:
		 - 次序上是先輸入用戶名密碼, 點擊登錄後纔會出現驗證碼圖片;
		 - 驗證碼圖片的元素結構變化, 沒有小切片因爲是一個canvas畫布標籤, 圖片鏈接仍然可以找到, 但是不在頁面源代碼中, 抓包可得, 抓包還能抓到geetest的返回結果;
		 - 滑動按鈕並未改變, 因此看起來是極驗自身升級了;
		"""

		def get_track(xoffset):											 # 獲取一條路徑
			tracks = []
			x = int(xoffset/2)											 # 先走一半(很關鍵,不走不給過)
			tracks.append(x)
			xoffset -= x
			while xoffset>=10:
				x = random.randint(5,9)
				tracks.append(x)
				xoffset -= x
			for i in range(int(xoffset)): tracks.append(1)				 # 最後幾步慢慢走
			return tracks
				
		while True:
			browser = webdriver.Firefox()								 # 驅動火狐瀏覽器
			browser.maximize_window()									 # 窗口最大化
			browser.get(self.loginURL)									 # 訪問登錄頁面
			interval = 2.												 # 初始化頁面加載時間(如果頁面沒有加載成功,將無法獲取到下面的滑動驗證碼按鈕,林外我意外的發現有時候竟然不是滑動驗證,而是驗證圖片四字母識別,個人感覺處理滑動驗證更有意思)
			while True:													 # 由於可能未成功加載,使用循環確保加載成功
				browser.find_element_by_id("login-username").send_keys(self.username)
				browser.find_element_by_id("login-passwd").send_keys(self.password)
				time.sleep(interval)
				browser.find_element_by_xpath("//a[@class='btn btn-login']").click()
				xpath = "//canvas[@class='geetest_canvas_slice geetest_absolute']"
				#WebDriverWait(browser,15).until(lambda driver: driver.find_element_by_xpath(xpath).is_displayed())
				try:
					time.sleep(interval)								 # 等待加載
					canvas = browser.find_element_by_xpath(xpath)
				except:
					browser.refresh()									 # 刷新頁面
					interval += .5										 # 每失敗一次讓interval增加0.5秒
					print("驗證圖片加載失敗!頁面等待時間更新爲{}".format(interval))
					continue
				browser.save_screenshot("{}/screen.png".format(self.tempFolder))
				location = canvas.location
				size = canvas.size
				left = location["x"]									 # 獲取圖片左邊界
				top = location["y"]										 # 獲取圖片上邊界
				right = left + size["width"]							 # 獲取圖片右邊界
				bottom = top + size["height"]							 # 獲取圖片下邊界
				image = Image.open("{}/screen.png".format(self.tempFolder))
				image = image.crop((									 # 縮放及對右下邊界要多取一格像素, python向來都是包左不包右
					left*self.layout,
					top*self.layout,
					right*self.layout+1,
					bottom*self.layout+1
				))
				image.save("{}/canvas.png".format(self.tempFolder))		 # 保存圖片
				break
			while True:
				div = browser.find_element_by_class_name("geetest_slider_button")
				ActionChains(browser).click_and_hold(on_element=div).perform()

				ratio = input("請輸入肉眼可見的比例: ")
					
				xoffset = 260*float(ratio)									 # 尋找缺塊位置的橫座標
				tracks = get_track(xoffset)
				total = 0
				for track in tracks:
					print(track)
					total += track
					ActionChains(browser).move_by_offset(xoffset=track,yoffset=random.randint(-5,5)).perform()
					time.sleep(random.randint(50,100)/100)
				ActionChains(browser).move_by_offset(xoffset=5,yoffset=random.randint(-5,5)).perform()
				ActionChains(browser).move_by_offset(xoffset=-5,yoffset=random.randint(-5,5)).perform()
				time.sleep(0.5)
				ActionChains(browser).release(on_element=div).perform()
				time.sleep(3)

				html = browser.page_source
				time.sleep(1.)
				soup = BeautifulSoup(html,"lxml")
				title = soup.find("title")
				if str(title.string[4])=="彈":
					print("登錄失敗!準備重新登錄!")

				else:
					print("登錄成功!")
					return browser

這是個類函數,原類在https://blog.csdn.net/CY19980216/article/details/89074771

總之這次經歷之後我覺得只要WEB開發者願意,絕對可以做到讓你什麼東西也拿不到。

推薦一篇別人做成功的https://blog.csdn.net/z434890/article/details/93631888,但是我用這個方法確實不管用,唉算了,沒必要跟自己過不去。

分享學習,共同進步!


20191202更新

好吧,我承認是我蠢了,頁面上其實有三個canvas,其中一個是默認不展示的(即原圖),一個是展示的有缺塊的圖,一個是滑塊的圖,我之所以用browser.execute_script()拿到的只有滑塊是我太蠢了,這樣問題其實就轉化爲前一篇模擬登錄裏面的情況了,很容易就可以找到邊界了:

重新附上代碼

	def login_20191202(self,):
		"""
			頁面上有三個canvas, 原圖與缺塊圖都可以很容易拿到的;
			這三個canvas的class屬性分別爲:
				"geetest_canvas_fullbg geetest_fade geetest_absolute"	 # 無缺塊的圖片
				"geetest_canvas_slice geetest_absolute"					 # 滑塊圖片
				"geetest_canvas_bg geetest_absolute"					 # 有缺塊的圖片
			問題由此轉化爲20190408的情況;
		"""

		def download_verifying_picture(canvas_class,name):				 # 下載滑動驗證圖片(從canvas標籤中轉化得到)
			JS = 'return document.getElementsByClassName("{}")[0].toDataURL("image/png");'.format(canvas_class)
			image_data = browser.execute_script(JS) 					 # 執行js代碼得到帶圖片信息的圖片數據
			image_base64 = image_data.split(",")[1]  					 # 摘取base64編碼的圖片信息
			image_bytes = base64.b64decode(image_base64) 					 # 將base64代碼轉爲bytes類型
			with open("{}\\{}\\{}.png".format(self.workspace,self.tempFolder,name),"wb") as f: f.write(image_bytes)

		def find_block_space(width=64,zoo=1.0,plot=True):				 # 尋找缺塊位置(默認參數width爲缺塊的列寬像素,zoo這邊用1.15基本上大概率能過了,但是我查了一下兩個圖片的屬性應該是1.2,設爲1.2應該要改常數項了)
			image1 = numpy.asarray(Image.open("{}\\{}\\1.png".format(self.workspace,self.tempFolder)))
			image2 = numpy.asarray(Image.open("{}\\{}\\2.png".format(self.workspace,self.tempFolder)))
			Xaxis,Yaxis,Zaxis = image1.shape							 # 獲取圖片三維信息(160×15×3)
			errors = []													 # 記錄260列寬上每個列向量的非相似度值
			for i in range(Yaxis):
				total = 0
				for j in range(Zaxis):
					X = numpy.array([image1[:,i,j]]).astype("int64")
					Y = numpy.array([image2[:,i,j]]).astype("int64").T
					normX = numpy.linalg.norm(X,2)
					normY = numpy.linalg.norm(Y,2)
					dotXY = numpy.dot(X,Y)[0,0]
					error = 1.- (dotXY/normX/normY)						 # 這裏我選擇累積RGB在(1-餘弦相似度)上的值
					total += error
				errors.append(total)
			tempErrors = errors[:]
			tempErrors.sort(reverse=True)
			index = [errors.index(i) for i in tempErrors[:width]]		 # 計算排序後對應的索引排序(根據圖像的結果來看應該前width的索引是至少近似連續的自然數)
			if plot:
				plt.plot([i for i in range(len(errors))],errors)
				plt.savefig("{}\\{}\\error.jpg".format(self.workspace,self.tempFolder))
			return min(index[:10])/zoo-10
			
		def get_track(xoffset):											 # 獲取一條路徑(給定需要移動的距離)
			tracks = []
			x = int(xoffset/2)											 # 先走一半(很關鍵,不走不給過)
			tracks.append(x)
			xoffset -= x
			while xoffset>=10:
				x = random.randint(5,9)
				tracks.append(x)
				xoffset -= x
			for i in range(int(xoffset)): tracks.append(1)				 # 最後幾步慢慢走
			return tracks
				
		while True:
			browser = webdriver.Firefox()								 # 驅動火狐瀏覽器
			browser.maximize_window()									 # 窗口最大化
			browser.get(self.loginURL)									 # 訪問登錄頁面
			interval = 2.												 # 初始化頁面加載時間(如果頁面沒有加載成功,將無法獲取到下面的滑動驗證碼按鈕,林外我意外的發現有時候竟然不是滑動驗證,而是驗證圖片四字母識別,個人感覺處理滑動驗證更有意思)

			class_bg = "geetest_canvas_bg geetest_absolute"
			class_fullbg = "geetest_canvas_fullbg geetest_fade geetest_absolute"
			xpath_slice = "//canvas[@class='geetest_canvas_slice geetest_absolute']"
			xpath_bg = "//canvas[@class='{}']".format(class_bg)
			xpath_fullbg = "//canvas[@class='{}']".format(class_fullbg)

			while True:													 # 由於可能未成功加載,使用循環確保加載成功
				browser.find_element_by_id("login-username").send_keys(self.username)
				browser.find_element_by_id("login-passwd").send_keys(self.password)
				time.sleep(interval)
				browser.find_element_by_xpath("//a[@class='btn btn-login']").click()
				try:													 # 我擔心加載不出來
					time.sleep(interval)								 # 等待加載
					canvas = browser.find_element_by_xpath(xpath_slice)
					break
				except:													 # 驗證圖片canvas未加載出來
					browser.refresh()									 # 刷新頁面
					interval += .5										 # 每失敗一次讓interval增加0.5秒
					print("驗證圖片加載失敗!頁面等待時間更新爲{}".format(interval))
					continue
			html = browser.page_source
			soup = BeautifulSoup(html,"lxml")
			download_verifying_picture(class_bg,1)					 # 有缺塊
			download_verifying_picture(class_fullbg,2)				 # 無缺塊
			xoffset = find_block_space()							 # 尋找缺塊位置的橫座標
			tracks = get_track(xoffset)								 # 生成軌跡
			div = browser.find_element_by_class_name("geetest_slider_button")
			ActionChains(browser).click_and_hold(on_element=div).perform()
			total = 0
			for track in tracks:
				print(track)
				total += track
				ActionChains(browser).move_by_offset(xoffset=track,yoffset=random.randint(-5,5)).perform()
				time.sleep(random.randint(50,100)/100)
			ActionChains(browser).move_by_offset(xoffset=5,yoffset=random.randint(-5,5)).perform()
			ActionChains(browser).move_by_offset(xoffset=-5,yoffset=random.randint(-5,5)).perform()
			time.sleep(0.5)
			ActionChains(browser).release(on_element=div).perform()
			time.sleep(3)

			html = browser.page_source
			time.sleep(1.)
			soup = BeautifulSoup(html,"lxml")
			title = soup.find("title")
			if str(title.string[4])=="彈":
				print("登錄失敗!準備重新登錄!")
				browser.quit()
			else:
				print("登錄成功!")
				return browser	

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章