puppet-web-browser.js 12.2 KB
Newer Older
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
1 2
/**
 * Wechat for Bot. and for human who can talk with bot/robot
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
3
 *
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
4
 * Interface for puppet
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
5
 *
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
6 7 8 9
 * Licenst: ISC
 * https://github.com/zixia/wechaty
 *
 */
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
10

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
11
const fs            = require('fs')
12
const co            = require('co')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
13
const path          = require('path')
14 15
const util          = require('util')
const EventEmitter  = require('events')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
16 17
const WebDriver     = require('selenium-webdriver')
const retryPromise  = require('retry-promise').default // https://github.com/olalonde/retry-promise
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
18

19 20
const log = require('./npmlog-env')

21
class Browser extends EventEmitter {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
22
  constructor(options) {
23
    super()
24
    log.verbose('PuppetWebBrowser', 'constructor()')
25
    options   = options       || {}
26 27 28 29 30
    if (typeof options.head === 'undefined') {
      this.head = false // default
    } else {
      this.head = options.head
    }
31 32

    this.live = false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
33 34
  }

35
  toString() { return `Browser({head:${this.head})` }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
36 37

  init() {
38
    return this.initDriver()
39 40 41 42
    .then(() => {
      this.live = true
      return this
    })
43 44 45 46 47 48 49
    .catch(e => {
      // XXX: must has a `.catch` here, or promise will hang! 2016/6/7
      // XXX: if no `.catch` here, promise will hang!
      // XXX: https://github.com/SeleniumHQ/selenium/issues/2233
      log.error('PuppetWebBrowser', 'init() exception: %s', e.message)
      throw e
    })
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
50 51
  }

52
  initDriver() {
53
    log.verbose('PuppetWebBrowser', 'initDriver(head: %s)', this.head)
54
    return new Promise((resolve, reject) => {
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
      if (this.head) {
        if (/firefox/i.test(this.head)) {
          this.driver = new WebDriver.Builder()
          .setAlertBehavior('ignore')
          .forBrowser('firefox')
          .build()
        } else if (/chrome/i.test(this.head)) {
          this.driver = new WebDriver.Builder()
          .setAlertBehavior('ignore')
          .forBrowser('chrome')
          .build()
        } else {  // unsupported browser head
          throw new Error('unsupported head: ' + this.head)
        }
      } else { // no head default to phantomjs
        this.driver = this.getPhantomJsDriver()
      }

73 74 75 76 77
      // XXX: if no `setTimeout()` here, promise will hang!
      // XXX: https://github.com/SeleniumHQ/selenium/issues/2233
      setTimeout(() => { resolve(this.driver) }, 0)
      // resolve(this.driver)
    })
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
78
  }
79

80 81
  open(url) {
    url = url || 'https://wx.qq.com'
82
    log.verbose('PuppetWebBrowser', `open(${url})`)
83

84
    return this.driver.get(url)
85
    .catch(e => {
86
      log.error('PuppetWebBrowser', 'open() exception: %s', e.message)
87
      this.dead(e.message)
88
      throw e
89
    })
90 91
  }

92 93 94 95 96
  refresh() {
    log.verbose('PuppetWebBrowser', 'refresh()')
    return this.driver.navigate().refresh()
  }

97
  getPhantomJsDriver() {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
98
    // https://github.com/SeleniumHQ/selenium/issues/2069
99
    // setup custom phantomJS capability
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
100
    const phantomjsExe = require('phantomjs-prebuilt').path
101
    // const phantomjsExe = require('phantomjs2').path
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
102
    const customPhantom = WebDriver.Capabilities.phantomjs()
103
    .setAlertBehavior('ignore')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
104
    .set('phantomjs.binary.path', phantomjsExe)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
105 106
    .set('phantomjs.cli.args', [
      '--ignore-ssl-errors=true' // this help socket.io connect with localhost
Huan (李卓桓)'s avatar
fix  
Huan (李卓桓) 已提交
107
      , '--load-images=false'
108
      , '--remote-debugger-port=8080'
109 110
      // , '--webdriver-logfile=/tmp/wd.log'
      // , '--webdriver-loglevel=DEBUG'
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
111
    ])
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
112

113
    log.silly('PuppetWebBrowser', 'phantomjs binary: ' + phantomjsExe)
114

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
115 116 117 118
    return new WebDriver.Builder()
    .withCapabilities(customPhantom)
    .build()
  }
119

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
120
  quit() {
121
    log.verbose('PuppetWebBrowser', 'quit()')
122
    this.live = false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
123
    if (!this.driver) {
124
      log.verbose('PuppetWebBrowser', 'driver.quit() skipped because no driver')
125
      return Promise.resolve('no driver')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
126
    } else if (!this.driver.getSession()) {
127
      this.driver = null
128
      log.verbose('PuppetWebBrowser', 'driver.quit() skipped because no driver session')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
129
      return Promise.resolve('no driver session')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
130
    }
131
    return this.driver.close() // http://stackoverflow.com/a/32341885/1123955
132
    .then(() => this.driver.quit())
133 134
    .catch(e => {
      // console.log(e)
135
      // log.warn('PuppetWebBrowser', 'err: %s %s %s %s', e.code, e.errno, e.syscall, e.message)
136 137 138 139 140 141
      const crashMsgs = [
        'ECONNREFUSED'
        , 'WebDriverError: .* not reachable'
        , 'NoSuchWindowError: no such window: target window already closed'
      ]
      const crashRegex = new RegExp(crashMsgs.join('|'), 'i')
142 143
      if (crashRegex.test(e.message)) { log.warn('PuppetWebBrowser', 'driver.quit() browser crashed') }
      else                            { log.warn('PuppetWebBrowser', 'driver.quit() exception: %s', e.message) }
144
    })
145 146
    .then(() => { this.driver = null })
    .then(() => this.clean())
147 148 149 150
    .catch(e => {
      log.error('PuppetWebBrowser', 'quit() exception: %s', e.message)
      throw e
    })
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
151
  }
152

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
153
  clean() {
154
    const max = 15
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
155
    const backoff = 100
156

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
157
    // max = (2*totalTime/backoff) ^ (1/2)
158 159
    // timeout = 11250 for {max: 15, backoff: 100}
    // timeout = 45000 for {max: 30, backoff: 100}
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
160
    const timeout = max * (backoff * max) / 2
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
161

162
    return retryPromise({ max: max, backoff: backoff }, attempt => {
163
      log.silly('PuppetWebBrowser', 'clean() retryPromise: attampt %s time for timeout %s'
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
164
        , attempt,  timeout)
165 166

      return new Promise((resolve, reject) => {
167 168 169 170
        this.getBrowserPids()
        .then(pids => {
          if (pids.length === 0) {
            resolve('clean')
171
          } else {
172
            reject(new Error('dirty'))
173 174
          }
        })
175
        .catch(e => reject(e))
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
176 177
      })
    })
178
    .catch(e => {
179
      log.error('PuppetWebBrowser', 'retryPromise failed: %s', e.message)
180 181
      throw e
    })
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
182
  }
183

184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
  getBrowserPids() {
    return new Promise((resolve, reject) => {
      require('ps-tree')(process.pid, (err, children) => {
        if (err) {
          reject(err)
          return
        }
        const pids = children.filter(child => /phantomjs/i.test(child.COMMAND))
        .map(child => child.PID)

        resolve(pids)
        return
      })
    })
  }

200 201 202 203 204 205 206
  /**
   * only wrap addCookies for convinience
   *
   * use this.driver.manage() to call other functions like:
   * deleteCookie / getCookie / getCookies
   */
  addCookies(cookie) {
207
    if (this.dead()) { return Promise.reject(new Error('addCookies() - browser dead'))}
208

209 210
    if (cookie.map) {
      return cookie.map(c => {
211
        return this.addCookies(c)
212 213
      })
    }
214
    // convert expiry from seconds to milliseconds. https://github.com/SeleniumHQ/selenium/issues/2245
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
215 216 217
    // NOTICE: the lastest branch of selenium-webdriver for js has changed the interface of addCookie:
    // https://github.com/SeleniumHQ/selenium/commit/02f407976ca1d516826990f11aca7de3c16ba576
    if (cookie.expiry) { cookie.expiry = cookie.expiry * 1000 /* XXX: be aware of new version of webdriver */}
218

219
    log.silly('PuppetWebBrowser', 'addCookies("%s", "%s", "%s", "%s", "%s", "%s")'
220 221 222 223 224
      , cookie.name, cookie.value, cookie.path, cookie.domain, cookie.secure, cookie.expiry
    )

    return this.driver.manage()
    .addCookie(cookie.name, cookie.value, cookie.path
225
      , cookie.domain, cookie.secure, cookie.expiry)
226
    .catch(e => {
227
      log.warn('PuppetWebBrowser', 'addCookies() exception: %s', e.message)
228 229
      throw e
    })
230
  }
231 232

  execute(script, ...args) {
233 234
    //log.verbose('PuppetWebBrowser', `Browser.execute(${script})`)
    // log.verbose('PuppetWebBrowser', `Browser.execute() driver.getSession: %s`, util.inspect(this.driver.getSession()))
235
    if (this.dead()) { return Promise.reject(new Error('browser dead')) }
236 237 238

    return this.driver.executeScript.apply(this.driver, arguments)
    .catch(e => {
239
      this.dead(e)
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
      throw e
    })
  }

  dead(forceReason) {
    let errMsg
    let dead = false

    if (forceReason) {
      dead = true
      errMsg = forceReason
    } else if (!this.live) {
      dead = true
      errMsg = 'browser not live'
    } else if (!this.driver || !this.driver.getSession()) {
      dead = true
      errMsg = 'no driver or session'
    }

    if (dead) {
260
      log.warn('PuppetWebBrowser', 'dead() because %s', errMsg)
261
      this.live = false
262
      // must use nextTick here, or promise will hang... 2016/6/10
263
      process.nextTick(() => {
264 265 266 267 268 269 270
        this.emit('dead', errMsg)
      })
    }
    return dead
  }

  checkSession(session) {
271
    log.verbose('PuppetWebBrowser', `checkSession(${session})`)
272
    if (this.dead()) { return Promise.reject(new Error('checkSession() - browser dead'))}
273 274 275 276

    return this.driver.manage().getCookies()
    .then(cookies => {
      // log.silly('PuppetWeb', 'checkSession %s', require('util').inspect(cookies.map(c => { return {name: c.name/*, value: c.value, expiresType: typeof c.expires, expires: c.expires*/} })))
277
      log.silly('PuppetWebBrowser', 'checkSession %s', cookies.map(c => c.name).join(','))
278 279
      return cookies
    })
280 281 282 283
    .catch(e => {
      log.error('PuppetWebBrowser', 'checkSession() getCookies() exception: %s', e.message)
      throw e
    })
284 285 286
  }

  cleanSession(session) {
287
    log.verbose('PuppetWebBrowser', `cleanSession(${session})`)
288 289
    if (this.dead())  { return Promise.reject(new Error('cleanSession() - browser dead'))}
    if (!session)     { return Promise.reject(new Error('cleanSession() no session')) }
290 291 292 293 294

    const filename = session
    return new Promise((resolve, reject) => {
      require('fs').unlink(filename, err => {
        if (err && err.code!=='ENOENT') {
295
          log.silly('PuppetWebBrowser', 'cleanSession() unlink session file %s fail: %s', filename, err.message)
296 297 298 299 300 301
        }
        resolve()
      })
    })
  }
  saveSession(session) {
302
    log.verbose('PuppetWebBrowser', `saveSession(${session})`)
303
    if (this.dead()) { return Promise.reject(new Error('saveSession() - browser dead'))}
304

305
    if (!session) { return Promise.reject(new Error('saveSession() no session')) }
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323
    const filename = session

    return new Promise((resolve, reject) => {
      this.driver.manage().getCookies()
      .then(allCookies => {
        const skipNames = [
          'ChromeDriver'
          , 'MM_WX_SOUND_STATE'
          , 'MM_WX_NOTIFY_STATE'
        ]
        const skipNamesRegex = new RegExp(skipNames.join('|'), 'i')
        const cookies = allCookies.filter(c => {
          if (skipNamesRegex.test(c.name)) { return false }
          // else if (!/wx\.qq\.com/i.test(c.domain))  { return false }
          else                             { return true }
        })
        // log.silly('PuppetWeb', 'saving %d cookies for session: %s', cookies.length
        //   , util.inspect(cookies.map(c => { return {name: c.name /*, value: c.value, expiresType: typeof c.expires, expires: c.expires*/} })))
324
        log.silly('PuppetWebBrowser', 'saving %d cookies for session: %s', cookies.length, cookies.map(c => c.name).join(','))
325 326 327 328

        const jsonStr = JSON.stringify(cookies)
        fs.writeFile(filename, jsonStr, function(err) {
          if(err) {
329
            log.error('PuppetWebBrowser', 'saveSession() fail to write file %s: %s', filename, err.Error)
330 331
            return reject(err)
          }
332
          log.verbose('PuppetWebBrowser', 'saved session(%d cookies) to %s', cookies.length, session)
333
          return resolve(cookies)
334 335 336 337 338
        })
      })
      .catch(e => {
        log.error('PuppetWebBrowser', 'saveSession() getCookies() exception: %s', e.message)
        reject(e)
339 340 341 342 343
      })
    })
  }

  loadSession(session) {
344
    log.verbose('PuppetWebBrowser', `loadSession(${session})`)
345
    if (this.dead()) { return Promise.reject(new Error('loadSession() - browser dead'))}
346

347
    if (!session) { return Promise.reject(new Error('loadSession() no session')) }
348 349 350 351 352
    const filename = session

    return new Promise((resolve, reject) => {
      fs.readFile(filename, (err, jsonStr) => {
        if (err) {
353
          if (err) { log.silly('PuppetWebBrowser', 'loadSession(%s) skipped because error code: %s', session, err.code) }
354
          return reject(new Error('error code:' + err.code))
355 356 357 358
        }
        const cookies = JSON.parse(jsonStr)

        const ps = this.addCookies(cookies)
359
        Promise.all(ps)
360
        .then(() => {
361
          log.verbose('PuppetWebBrowser', 'loaded session(%d cookies) from %s', cookies.length, session)
362 363 364
          resolve(cookies)
        })
        .catch(e => {
365
          log.error('PuppetWebBrowser', 'loadSession() addCookies() exception: %s', e.message)
366 367
          reject(e)
        })
368 369 370
      })
    })
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
371 372 373
}

module.exports = Browser