用Selenium获取淘宝联盟Cookie

最近由于工作原因,需要从淘宝联盟获取cookie。一步步人工点击实在繁琐,加之各种验证码不时出现,故打算用Selenium完成大部分重复的点击,只在需要验证时才人工参与。

在讲解代码前,先让我们先大概了解下Selenium的版本。

Selenium的版本

Selenium有作为火狐插件的Selenium IDE及通过代码调用的Selenium WebDriver版本。

Selenium IDE是熟悉Selenium基本操作的最好工具,Selenium IDE的大多操作(称为Command)都有对应的Selenium WebDriver API。它的功能也很强大,可以录制用户的操作,重放,还可以导出为Java, Python等语言的单元测试代码。

Selenium IDE

使用Selenium WebDriver最简单的方式是用Maven的方式引入,可以参考链接

使用ChromeDriver

从这里下载ChromeDriver,本文发表时的最新版本是2.25。这里要注意系统的Google Chrome版本不能太低,我用的是54。如果是更早的版本就可以有兼容问题。

基于Kotlin的实现

package com.selen.tutorial

import org.openqa.selenium.By
import org.openqa.selenium.WebDriver
import org.openqa.selenium.WebElement
import org.openqa.selenium.chrome.ChromeDriver
import org.openqa.selenium.chrome.ChromeOptions
import org.openqa.selenium.remote.RemoteWebDriver
import org.openqa.selenium.support.ui.ExpectedCondition
import org.openqa.selenium.support.ui.ExpectedConditions
import org.openqa.selenium.support.ui.WebDriverWait
import java.util.concurrent.TimeUnit

fun main(args: Array<String>) {
    // 配置路径
    System.setProperty("webdriver.chrome.driver", "/home/garfield/projects/selenium/chromedriver")

    var opts = ChromeOptions()
    opts.addArguments("user-data-dir=/home/garfield/.config/google-chrome/Default")
    var driver = ChromeDriver(opts)

    // 前往主页
    driver.get("http://pub.alimama.com/")

    // 登录框在页面iframe里
    // 对iframe元素操作时,需要先选择它:
    driver.switchTo().frame(waitToFindElement(driver, By.cssSelector("#mx_n_17 > div > iframe")))
    // 淘宝默认扫码登录,选择并点击用密码登录
    var pwdLoginIcon = waitForLoginToggle(driver)
    println("登录开始")
    pwdLoginIcon.click()

    // 开启淘宝登录旅程
    aliLogin(driver)
    while(!waitForLogin(driver)){
        println("登录/验证失败,重试....")
        aliLogin(driver)
    }

    println("login success")
    var cookie = printCookiesForDriver(driver)

    // 至此就登录成功了,你还可以做其它操作了,比如搜产品表

    var quit = cmdPrompt("q(uit)/c(ontinue)?")
    when(quit){
        "y" -> driver.quit()
    }
}

/**
 * 登录操作
 */
fun  aliLogin(driver: RemoteWebDriver) {
    // 输错密码时,后面获取`uidField, pwdField`可能获取到页面刷新前的元素,造成stale element错误
    // 这里等上一小段时间可以防止此问题
    // 用户名密码从环境变量读时,可认为不会出现此问题
    // waitForSecs(2)

    // 以下分别获取用户名及密码框,登录按钮
    var uidField = driver.findElement(By.cssSelector("#TPL_username_1"))
    var pwdField = driver.findElement(By.cssSelector("#TPL_password_1"))
    var loginBtn = driver.findElement(By.cssSelector("#J_SubmitStatic"))

    // 用户名和密码从环境变量里读取
    var uid = getSysPropValue("uid")
    var pwd = getSysPropValue("pwd")

    // 清除input里现有内容,再输入用户名与密码
    clearAndSend(uidField,uid)
    clearAndSend(pwdField,pwd)

    loginBtn.click()
}

/**
 * 等待登录响应
 *
 * - 成功时返回true;
 * - 不成功时,通常是需要人工验证:
 *   1. 选择已购商品
 *   2. 短信验证
 *   3. 滑动验证(未实现)
 */
fun waitForLogin(driver: WebDriver, switchFrame: Boolean=true): Boolean{
    if(switchFrame){
        try{
            driver.switchTo()
                    .frame(waitToFindElement(driver,
                            By.cssSelector("#J_LoginCheck > div.bd > div.login-check-static > iframe"),
                            timeout=10))
        }catch (e: Exception){
            // 页面验证框部分其实是iframe,如果加载不到,就表示不需要额外验证,这个时间已经登录成功了
            e.printStackTrace()
            println("iframe not found? ignoring ${e.message}")
        }
    }

    val byLogout = By.cssSelector("#J_menu_login_out > div > span") // login success
    val byEnterLianmeng = By.cssSelector("#mx_n_17 > a") // login success, 进入我的联盟
    val byError = By.cssSelector("#J_Message > p") // login fail, cannot continue
    val byItemSelectValidator = By.cssSelector("#J_Midun > div.ui-tiptext.ui-tiptext-message") // 最近购买验证
    val bySmsValidator= By.cssSelector("#J_Checkcode") // SMS验证
    val bySlideValidator= By.cssSelector("#nc_1__scale_text > span") // 滑块验证

    var conds = ExpectedConditions.or(
            ExpectedCondition {d -> d?.findElement(byError) },
            ExpectedCondition {d -> d?.findElement(byLogout) },            
            ExpectedCondition {d -> d?.findElement(byItemSelectValidator) },
            ExpectedCondition {d -> d?.findElement(bySmsValidator) },
            ExpectedCondition {d -> d?.findElement(byEnterLianmeng)},
            ExpectedCondition {d -> d?.findElement(bySlideValidator)}
    )
    WebDriverWait(driver, 30).until(conds)
    var logoutElement = driver.findElements(byLogout)?.firstOrNull()

    var itemSelectValidator = driver.findElements(byItemSelectValidator)?.firstOrNull()
    var smsValidator = driver.findElements(bySmsValidator)?.firstOrNull()
    var slideValidator = driver.findElements(bySlideValidator)?.firstOrNull()
    if(logoutElement!=null){
        return true
    }else if(itemSelectValidator!=null){
        selectAnItem(driver)
        return waitForLogin(driver, false)
    }else if(smsValidator != null ){
        enterSms(driver)
        return waitForLogin(driver, false)
    }else if(slideValidator != null){
        slideValidate(driver)
        return waitForLogin(driver, false)
    }else {
        throw IllegalStateException("Unknown taobao validator?")
    }
}

fun waitForLoginToggle(driver: RemoteWebDriver): WebElement {
    var cond = ExpectedCondition { d->d?.findElement(By.cssSelector("#J_LoginBox > div.hd > div.login-switch")) }
    WebDriverWait(driver, 50).until(cond)
    return driver.findElement(By.cssSelector("#J_LoginBox > div.hd > div.login-switch"))
}

/**
 * 淘宝的验证:短信验证码
 *
 * 说明:出现提示后输入你手机收到的验证码即可
 */

fun enterSms(driver: WebDriver) {
    waitToFindElement(driver, By.cssSelector("#J_GetCode")).click()
    var codeInput = waitToFindElement(driver, By.cssSelector("#J_Checkcode"))
    clearAndSend(codeInput, cmdPrompt("sms code?"))
    var confirm = waitToFindElement(driver, By.cssSelector("#J_Form > div > div.submit > button"))
    confirm.click()
}

/**
 * 淘宝的验证:通过选择最近购买过的商品图片
 *
 * 说明:
 *   - u/d 分别表示上翻页,下翻页
 *   - 1/2/3/4 分别表示第几张图片
 *   - q 表示选好了,提交验证
 */
fun selectAnItem(driver: WebDriver) {
    var validCmds = arrayOf("u", "d", "1", "2", "3", "4", "q")
    var hint = validCmds.joinToString(separator = "/")
    var cmd = cmdPrompt("${hint}?").trim()
    while (cmd!="q") {
        if(! (cmd in validCmds))
            continue
        var el = when (cmd) {
            "u" -> waitToFindElement(driver, By.cssSelector("#prev"))
            "d" -> waitToFindElement(driver, By.cssSelector("#next"))
            else -> waitToFindElement(driver,
                    By.cssSelector("#J_Midun > div.md-content > div > div:nth-child(3) > div:nth-child(${cmd}) > img"))
        }
        el.click()
        cmd = cmdPrompt("${hint}?")
    }
    var submit = waitToFindElement(driver, By.cssSelector("#J_Midun > div.submit > button"))
    submit.click()
}

/**
 * 淘宝的验证:滑动条
 * 没实现,请google搜索相关代码
 */
fun slideValidate(driver: WebDriver) {
    TODO()
}

fun waitForSecs(secs: Long=1){
    TimeUnit.SECONDS.sleep(secs)
}

fun getSysPropValue(key: String): String{
    return System.getenv(key)
}

/**
 * 等待元素加载,默认超时时间为40秒。
 * 超时后会抛出异常
 */
fun  waitToFindElement(driver: WebDriver, by: By, timeout: Long=40): WebElement {
    var cond = ExpectedCondition { d -> d?.findElement(by) }
    WebDriverWait(driver, timeout).until(cond)
    return driver.findElement(by)
}

/**
 * 命令行问答框,返回用户的输入
 */
fun cmdPrompt(hint: String): String{
    println(hint)
    var s = readLine()
    return if(s==null) "" else s
}

/**
 * 从WebDriver中提取Cookie并返回
 */
fun printCookiesForDriver(driver: WebDriver): String {
    var cookies = driver.manage().cookies
    var cookieStr = cookies.joinToString (separator = ";", transform = {cookie-> "${cookie.name}=${cookie.value}" })
    println(cookieStr)
    return cookieStr
}

fun clearAndSend(el: WebElement, text: String){
    el.clear()
    el.sendKeys(text)
}

几点说明

本文的cssSelector参数是如果获取的

Chrome浏览器里,打开Developer Tools面板,点击Selenium IDE,然后选择元素。回到Developer Tools窗口的Elements面板,在刚选择的元素处右键,菜单Copy|Copy selector

如果用Firefox,则可以用firepath插件。

使用ChromeOptions:让Chrome使用已有配置

创建ChromeDriver时,可选参数ChromeOptions可用来加载本地已有的用户资料、窗口最大化、添加Chrome浏览器插件等。如果不提供,运行时会新建资料,以无插件的形式运行。

详细说明在这里

findElement vs findElements

相同点 两者在超时时间内查找元素

差异 前者返回匹配的第一个元素,后者返回匹配的所有元素。前者未找到元素时返回异常,后者未找到时返回空集合。

为什么不使用implicitlyWait

Selenium WebDriver没有直接提供类似Selenium IDE的clickAndWait API,故需要我们自己实现,好在实现并不难,正如上面的waitToFindElement()方法。

WebDriver#findElement,WebDriver#findElements的超时间可以通过WebDriver#manage().timeouts().implicitlyWait(long, TimeUnit)配置[默认值为0,即无等待]。

但使用implicitlyWait配置大于0的默认超时时间后,每次调用WebDriver#findeElements也会等上这个时间。造成我们的代码运行过于缓慢。这也正是我们不用implicitlyWait的原因。

如何用Firefox测试

下载geckodriver后,只需在创建WebDriver时稍修改即可:

System.setProperty("webdriver.gecko.driver", "/home/garfield/projects/selenium/geckodriver")
var driver = FirefoxDriver()

Ref

Comment