零、背景
公司最近有個爬蟲的項目,先拿小紅書下手,但是小紅書很多內容 web 端沒有,只能用 app 爬,於是了解到 Appium 這個強大的框架,即可以做自動化測試,也可以用來當自動化爬蟲。
本文的代碼只是一個簡單的 spike,沒有太多深入的實踐。后續如果有深挖,我會來補充的。
一、介紹
Appium
實際上繼承了 Selenium(一個流行的 web 瀏覽器自動化測試框架), 也是利用 Webdriver
來實現 App 的自動化測試。
1、其實 Appium 和 WebDriver 在技術上並不是“測試框架”,而是“自動化庫”。
2、WebDriver 已成為自動化Web瀏覽器的事實標准,並且收錄在W3C工作草案里。
Appium是跨平台的:它允許您使用相同的API針對多個平台(iOS,Android,Windows)編寫測試。
但是底層,Appium 通過使用供應商提供的自動化框架來滿足需求:
- iOS 9.3及以上版本:Apple的 XCUITest
- iOS 9.3及更低版本:Apple的 UIAutomation
- Android 4.2+:Google的 UiAutomator / UiAutomator2
- Android 2.3+:Google的 Instrumentation
- Windows:微軟的 WinAppDriver
Appium是開源的。
二、Appium 架構
屬於 C/S 架構,包括:
Appium Server
: 是一個用 Node.js 編寫的公開的 REST API WEB 服務器。
Appium Client
: 有很多客戶端庫(Java,Ruby,Python,PHP,JavaScript 和 C#)。
三、安裝
此章針對 mac 用戶對 android 進行測試。
1、 安裝 Appium Server
推薦安裝:Appium Desktop ,包含 Appium Server + Appium Client(提供的是 UI 界面化的操作),還有豐富的調試功能。
2、安裝 Appium Client
python 開發環境:pip3 install Appium-Python-Client
node.js 開發環境: npm install -g appium
3、安裝 Android SDK
1、可以通過安裝 android studio
來安裝 Android SDK。
下載:https://developer.android.com/studio/index.html?hl=zh-cn
2、添加環境變量
在 ~/.bash_profile (我用的是 zsh,所以是 ~/.zprofile )里添加:
export ANDROID_HOME=/Users/xjnotxj/Library/Android/sdk
export PATH=${PATH}:${ANDROID_HOME}/tools
export PATH=${PATH}:${ANDROID_HOME}/platform-tools
4、驗證安裝
檢驗上述的安裝是否完備,可以通過 appium-doctor 這個包:npm install -g appium-doctor
appium-doctor --android
# or for ios
appium-doctor --iod
四、使用
1、Appium Client 采用 UI 方式
以我的 三星 s9+ 手機為例。
1、手機用數據線連上電腦,同時打開 USB 調試。
2、啟動 appium server,並 start 一個 session(如下圖紅色箭頭)。
3、輸入 desired_caps
配置,並點擊 start session:
{
"platformName": "android",
"deviceName": "SM_G9650",
"appPackage": "com.tencent.mm",
"appActivity": "ui.LauncherUI",
"noReset": true
}
參數解釋:
deviceName:
adb devices -l
命令返回值里的 model 值appPackage / appActivity :獲取方法請看:https://www.cnblogs.com/fnng/p/7350900.html
推薦加上
noReset:true
:可以每次測試不重置應用(我的微信聊天記錄就是這么沒有的……)
4、點擊錄制按鈕
5、使用界面化操作 app
6、結束錄制后可支持導出各種編程語言的代碼
此次錄制的步驟如下:
1、啟動小紅書
2、點擊主頁瀑布流的第一個帖子
3、點擊底部的喜歡按鈕
導出的代碼如下:
el3 = driver.find_element_by_xpath("/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.support.v4.widget.DrawerLayout/android.widget.LinearLayout/android.widget.RelativeLayout/android.support.v4.view.ViewPager/android.widget.LinearLayout/android.widget.FrameLayout/android.support.v4.view.ViewPager/android.widget.FrameLayout/android.view.ViewGroup/android.support.v7.widget.RecyclerView/android.widget.FrameLayout[1]/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.ImageView")
el3.click()
el4 = driver.find_element_by_id("com.xingin.xhs:id/b7f")
el4.click()
2、拓展 - Appium 三種等待元素的方法
Appium 里定位元素是經常的操作,但是有時元素的加載需要時間(如 Ajax 時受到網速的限制),為了避免在元素沒有加載好的時候去定位,從而拋出 NoSuchElementException
的異常,下面介紹三種等待元素的方法。
(1) sleep
import time
# 等待10s
time.slee(10)
簡單粗暴。
(2) 隱式等待
針對所有元素定義的等待時間。
# focus here
driver.implicitly_wait(10)
……
driver.find_element_by_xpath(…A…)
……
driver.find_element_by_xpath(…B…)
implicitly_wait()
只有一個參數,就是最長等待時間,期間會不斷輪詢。
implicitly_wait 適用於所有定位元素的函數。
(3) 顯式等待
可以針對單個元素定義不同的等待方式。
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Remote(server, desired_caps)
# focus here
wait = WebDriverWait(driver, 30)
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/axk')))
temp.click()
1、WebDriverWait()
的參數有四個:
-
driver:瀏覽器驅動。
-
timeout:最長超時時間,默認以秒為單位。
-
poll_frequency:檢測的間隔(步長)時間,默認為 0.5 S。
-
ignored_exceptions:超時后的異常信息,默認情況下拋 Nosuchelementexception 異常。
2、WebDriverWait() 可以調用 until
/ until_not
。前者是當參數為 True 時才觸發,后者是當參數為 False 時觸發。
3、expected_conditions
也就是 EC,提供了預期條件判斷的方法,比如:
presence_of_element_located : 判斷元素是否被加在 DOM 樹里,並不代表該元素一定可見
visibility_of_element_located : 判斷元素是否可見(可見代表元素非隱藏,並且元素的寬和高都不等於 0)
除此之外還有更多種:
3、Appium Client 采用編碼方式
(1) 此次需求
1、打開小紅書
2、點擊頂部搜索欄,鍵入想要搜的用戶名
3、點擊搜出來的第一個用戶,進入個人詳情頁
4、獲取此人的 關注數 / 粉絲 / 獲贊與收藏
5、獲取此人發的所有帖子的 標題/封面/被喜歡數
(2) 重難點
需求1-4很簡單,就是定位元素,然后操控元素和獲取元素的 attribute 就好。
關於定位元素,優先用 id,沒有 id 的時候退而求其次用 xpath。至於 id/xpath 的值,可以用 appium desktop 界面化的去查看,如下圖:
重點是需求5,因為用戶發的帖子是一個瀑布流,即:
1、內容很長,需要頁面往下滑動
解決方案:設置一個 loop,然后一直調用swipe
滾動函數。
2、滑動的過程中會懶加載
解決方案:調用swipe
滾動函數的時候,最后一個參數 duration
值設的大一些,比如個把秒,這樣可以讓滑動變的較慢,從而邊滑動邊觸發懶加載,讓頁面順暢進行。
3、瀑布流的帖子高度不一,參差不齊,可能會取到重復帖子
解決方案:將錯就錯,就取冗余數據,但用比如 set 這種數據結構來避免數據重復。
4、如何判斷內容取盡,滑到底部了?
解決方案:用一個土辦法的方法,即始終記錄上一次取的數據,跟當前取的數據做對比,如果完全一樣,證明頁面沒變化,則退出 loop。
(3) 注意點
在需求5向下滑動帖子的過程中,有以下幾個注意點:
1、每個帖子的 xpath 格式為:
/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.RelativeLayout/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup/android.widget.FrameLayout[n
]/……
上面的n
取正整數(即從1開始)。
2、不管怎樣向下滑動頁面,n
的值不是累加的,而是根據當前顯示的僅有帖子重新排列的,如下圖。
3、且 appium 是可見即可爬的,所以下圖①的帖子內容幾乎都被遮住了,會取不到內容。
4、從下圖看,一個頁面最多只能顯示 8 個帖子。
(4) 代碼
from appium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import NoSuchElementException
import time
server = 'http://localhost:4723/wd/hub'
desired_caps = {
"platformName": "android",
"deviceName": "SM_G9650",
"appPackage": "com.xingin.xhs",
"appActivity": "com.xingin.xhs.activity.SplashActivity",
"noReset": True # 可以每次測試不重置應用(我的微信聊天記錄就是這么沒有的……)
}
# 切換到 unicode 鍵盤,避免例如中文輸入不了的問題
desired_caps["unicodeKeyboard"] = True
desired_caps["resetKeyboard"] = True
driver = webdriver.Remote(server, desired_caps)
driver.implicitly_wait(1)
wait = WebDriverWait(driver, 30)
start = time.time()
print("start")
# 1、目的一:進入個人資料詳情頁
# 點擊搜索框
temp = wait.until(EC.presence_of_element_located(
(By.XPATH, '/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.support.v4.widget.DrawerLayout/android.widget.LinearLayout/android.widget.RelativeLayout/android.support.v4.view.ViewPager/android.widget.LinearLayout/android.widget.LinearLayout/android.widget.FrameLayout[2]/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout')))
temp.click()
# 輸入 search text
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/axk')))
temp.set_text("小蔣")
# 點擊搜索按鈕
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/axn')))
temp.click()
# 切換到用戶 tab
temp = wait.until(EC.presence_of_element_located(
(By.XPATH, '/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.view.ViewGroup/android.widget.LinearLayout/android.widget.HorizontalScrollView/android.widget.LinearLayout/android.support.v7.app.a.b[3]')))
temp.click()
# 所有的用戶里選擇第一個
temp = wait.until(EC.presence_of_element_located(
(By.XPATH, '/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.view.ViewGroup/android.view.ViewGroup/android.widget.FrameLayout/android.view.ViewGroup/android.widget.RelativeLayout[1]')))
temp.click()
# 2、目的二:搜刮想要的信息
# (1)獲取用戶詳情頁的總覽信息
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/fa')))
temp_n = temp.text
print("關注數:" + str(temp_n))
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/a0h')))
temp_n = temp.text
print("粉絲:" + str(temp_n))
temp = wait.until(EC.presence_of_element_located(
(By.ID, 'com.xingin.xhs:id/ah6')))
temp_n = temp.text
print("獲贊與收藏:" + str(temp_n))
# (2)獲取此人所有發帖信息
# 定義滑動函數
def swipeDown(value):
# 獲取屏幕的高
x = driver.get_window_size()['width']
# 獲取屏幕寬
y = driver.get_window_size()['height']
# 滑動頁面(最后一個參數設的比較大,防止下拉懶加載的時候打斷原有滑動應該有的距離)
driver.swipe(1/2*x, 1/2*y, 1/2*x, value * y, 3000)
# 首先把頁面往上拉一些(把總覽信息收上去)
swipeDown(0.28)
# 獲取用戶所有發帖
per_n = 8 # 每次翻頁后抓取個數(依據:一個頁面最大顯示貼子數的個數,包括了顯示不全的)
result = set() # 存獲取到的帖子(會有重復獲取的情況,因此用 set 數據結構)
last_result = "" # 記錄上一次翻頁獲取的數據(為了實現翻到頁底退出循環的功能)
while True:
cur_result = "" # 記錄當前翻頁獲取的數據(為了實現翻到頁底退出循環的功能)
for i in range(1, per_n + 1):
print("正在獲取當前頁的第" + str(i) + "個帖子……")
# 獲取 img
# 待寫:因為普通辦法行不通(找不到圖片的 src),考慮用截圖 api
# 獲取 title
# 待寫:因為普通辦法行不通(很詭異,可見但不可得,元素的 text 屬性值是空的),考慮用 OCR api
# 獲取被 like 數
try:
temp = driver.find_element_by_xpath('/hierarchy/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.RelativeLayout/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup/android.view.ViewGroup/android.widget.FrameLayout['
+ str(i) +
']/android.widget.LinearLayout/android.widget.LinearLayout[2]/android.widget.RelativeLayout/android.widget.TextView')
like_n = temp.text
# save result (其實最好使用 titile 作為 set 的 key,但是因為獲取不到 title,暫時拿 like 數將就下吧)
result.add(like_n)
cur_result = cur_result + str(like_n)
print("第" + str(i) + "個帖子的被 like 數" + " : " + str(like_n))
except NoSuchElementException as e:
# 找不到就不管了(找不到的原因是此帖顯示不全)
pass
# 滑動翻頁(系數太小已經不好計算了,所以分兩次滑動吧)
swipeDown(0.16)
swipeDown(0.16)
# 判斷是否滑到底
if(cur_result == last_result):
break
else:
last_result = cur_result
end = time.time()
print("end")
print("total time:")
print(end - start)
print("result:")
print(result)
print 結果:
關注數:283
粉絲:2.0 萬
獲贊與收藏:7071
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 71
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 79
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 57
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 80
正在獲取當前頁的第5個帖子……
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 57
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 134
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 125
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 77
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
第7個帖子的被 like 數 : 128
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 125
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 116
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 128
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 116
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 124
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 111
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 164
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 170
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 140
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 105
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 170
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 143
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 97
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 93
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 171
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 93
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 90
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 111
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 73
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 80
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 73
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 109
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 87
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 68
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 141
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 68
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 138
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 174
正在獲取當前頁的第5個帖子……
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 109
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 70
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 142
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 76
正在獲取當前頁的第4個帖子……
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 115
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 81
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 74
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 115
正在獲取當前頁的第4個帖子……
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 101
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 75
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 270
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 131
正在獲取當前頁的第4個帖子……
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 132
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 128
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 109
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 107
正在獲取當前頁的第5個帖子……
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 74
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 64
正在獲取當前頁的第2個帖子……
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 72
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 77
正在獲取當前頁的第5個帖子……
正在獲取當前頁的第6個帖子……
第6個帖子的被 like 數 : 60
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 72
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 77
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 60
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 33
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 98
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
正在獲取當前頁的第1個帖子……
第1個帖子的被 like 數 : 72
正在獲取當前頁的第2個帖子……
第2個帖子的被 like 數 : 77
正在獲取當前頁的第3個帖子……
第3個帖子的被 like 數 : 60
正在獲取當前頁的第4個帖子……
第4個帖子的被 like 數 : 33
正在獲取當前頁的第5個帖子……
第5個帖子的被 like 數 : 98
正在獲取當前頁的第6個帖子……
正在獲取當前頁的第7個帖子……
正在獲取當前頁的第8個帖子……
end
total time:
177.27380394935608
result:
{'87 ', '128 ', '97 ', '138 ', '132 ', '81 ', '174 ', '134 ', '33 ', '107 ', '57 ', '80 ', '115 ', '170 ', '171 ', '64 ', '90 ', '60 ', '105 ', '140 ', '164 ', '98 ', '270 ', '125 ', '142 ', '75 ', '68 ', '93 ', '72 ', '131 ', '74 ', '143 ', '116 ', '124 ', '76 ', '77 ', '111 ', '109 ', '79 ', '101 ', '73 ', '70 ', '141 ', '71 '}
(5) 坑
1、搜索框不能輸入中文
解決方案:切換到 unicode 鍵盤。
在desired_caps里加幾行配置:
desired_caps["unicodeKeyboard"] = True
desired_caps["resetKeyboard"] = True
有的手機會出現詭異的問題,比如我的 三星 S9+,加了上面的配置后,輸入中文的成功率是50%,即隔一次好一次,很規律,原因未知。
解決方案:在系統設置里強制把輸入法切換成 unicode 鍵盤。
2、獲取不到帖子的標題
其他的元素獲得內容只要獲取 text 這個 attrbute 就好了,但偏偏標題不行,這個違背了所看即所爬啊。原因未知。
解決方案:用 OCR 吧。
3、獲取不到帖子的封面圖
找不到任何關於圖的信息。
解決方案:用 截圖的 api 吧。
參考資料:
《python 3 網絡爬蟲開發實戰》(主要)
《Selenium 2 自動化測試實戰》(一點點)