browser.ts 10.9 KB
Newer Older
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
1
/**
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
2
 * Wechat for Bot. Connecting ChatBots
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
 *
 */
10 11 12
import { EventEmitter } from 'events'

/* tslint:disable:no-var-requires */
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
13
const retryPromise  = require('retry-promise').default // https://github.com/olalonde/retry-promise
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
14

15 16 17 18
import {
    Config
  , HeadName
}                   from '../config'
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
19
import StateMonitor from '../state-monitor'
20
import log          from '../brolog-env'
21

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
22 23 24
import {
    CookieType
  , BrowserCookie
25 26
}                     from './browser-cookie'
import BrowserDriver  from './browser-driver'
27

28
export type BrowserSetting = {
29
  head:         HeadName
30 31 32
  sessionFile?: string
}

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
33
export class Browser extends EventEmitter {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
34

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
35
  private cookie: BrowserCookie
36
  public driver: BrowserDriver
37

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
38
  public state = new StateMonitor<'open', 'close'>('Browser', 'close')
39

40 41 42 43
  constructor(private setting: BrowserSetting = {
      head: Config.head
    , sessionFile: ''
  }) {
44
    super()
45
    log.verbose('PuppetWebBrowser', 'constructor() with head(%s) sessionFile(%s)', setting.head, setting.sessionFile)
46

47 48
    this.driver = new BrowserDriver(this.setting.head)
    this.cookie = new BrowserCookie(this.driver, this.setting.sessionFile)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
49 50
  }

51
  public toString() { return `Browser({head:${this.setting.head})` }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
52

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
53
  public async init(): Promise<void> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
54 55
    this.state.target('open')
    this.state.current('open', false)
56

57
    // jumpUrl is used to open in browser for we can set cookies.
58
    // backup: 'https://res.wx.qq.com/zh_CN/htmledition/v2/images/icon/ico_loading28a2f7.gif'
59
    const jumpUrl = 'https://wx.qq.com/zh_CN/htmledition/v2/images/webwxgeticon.jpg'
60

61
    try {
62
      await this.driver.init()
63
      await this.open(jumpUrl)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
64
      await this.loadCookie()
65
                .catch(e => { // fail safe
66 67 68 69
                  log.verbose('PuppetWeb', 'browser.loadSession(%s) exception: %s'
                                          , this.setting.sessionFile
                                          , e && e.message || e
                  )
70
                })
71
      await this.open()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
72

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
73 74
      /**
       * when open url, there could happen a quit() call.
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
75
       * should check here: if we are in `close` target state, we should clean up
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
76
       */
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
77
      if (this.state.target() !== 'open') {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
78
        throw new Error('init() finished but found state.target() is changed to close. has to quit().')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
79
      }
80

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
81 82
      this.state.current('open')

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
83
      return
84

85
    } catch (e) {
86
      log.error('PuppetWebBrowser', 'init() exception: %s', e.message)
87

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
88
      this.state.current('close')
Huan (李卓桓)'s avatar
fix #51  
Huan (李卓桓) 已提交
89
      await this.quit()
90

91
      throw e
92
    }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
93 94
  }

95
  public async open(url: string = 'https://wx.qq.com'): Promise<void> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
96 97 98
    log.verbose('PuppetWebBrowser', `open(${url})`)

    // TODO: set a timer to guard driver.get timeout, then retry 3 times 201607
99
    try {
100
      await this.driver.get(url)
101 102 103 104
    } catch (e) {
      log.error('PuppetWebBrowser', 'open() exception: %s', e.message)
      throw e
    }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
105 106
  }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
107
  public async refresh(): Promise<void> {
108
    log.verbose('PuppetWebBrowser', 'refresh()')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
109 110 111 112
    await this.driver
              .navigate()
              .refresh()
    return
113 114
  }

115 116
  public async restart(): Promise<void> {
    log.verbose('PuppetWebBrowser', 'restart()')
Huan (李卓桓)'s avatar
fix #51  
Huan (李卓桓) 已提交
117

118
    await this.quit()
Huan (李卓桓)'s avatar
fix #51  
Huan (李卓桓) 已提交
119

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
120 121
    if (this.state.current() === 'open' && this.state.inprocess()) {
      log.warn('PuppetWebBrowser', 'restart() found state.current() === open and inprocess()')
Huan (李卓桓)'s avatar
fix #51  
Huan (李卓桓) 已提交
122
      return
123
    }
Huan (李卓桓)'s avatar
fix #51  
Huan (李卓桓) 已提交
124 125

    await this.init()
126
  }
127

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
128
  public async quit(): Promise<void> {
129 130
    log.verbose('PuppetWebBrowser', 'quit()')

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
131 132
    if (this.state.current() === 'close' && this.state.inprocess()) {
      const e = new Error('quit() be called when state.current() is close with inprocess()?')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
133 134 135 136
      log.warn('PuppetWebBrowser', e.message)
      throw e
    }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
137
    this.state.current('close', false)
138

139
    try {
140
      await this.driver.close().catch(e => { /* fail safe */ }) // http://stackoverflow.com/a/32341885/1123955
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
141
      log.silly('PuppetWebBrowser', 'quit() driver.close()-ed')
142
      await this.driver.quit()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
143
      log.silly('PuppetWebBrowser', 'quit() driver.quit()-ed')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
144

145
      /**
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
146
       *
147
       * if we use AVA test runner, then this.clean might cause problems
148
       * because there will be more than one instance of browser with the same nodejs process id
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
149
       *
150
       */
151
      await this.clean()
152

153
    } catch (e) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
154 155
      // console.log(e)
      // log.warn('PuppetWebBrowser', 'err: %s %s %s %s', e.code, e.errno, e.syscall, e.message)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
156
      log.warn('PuppetWebBrowser', 'quit() exception: %s', e.message)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
157 158 159 160 161 162 163 164 165 166

      const crashMsgs = [
        'ECONNREFUSED'
        , 'WebDriverError: .* not reachable'
        , 'NoSuchWindowError: no such window: target window already closed'
      ]
      const crashRegex = new RegExp(crashMsgs.join('|'), 'i')

      if (crashRegex.test(e.message)) { log.warn('PuppetWebBrowser', 'driver.quit() browser crashed') }
      else                            { log.warn('PuppetWebBrowser', 'driver.quit() exception: %s', e.message) }
167

Huan (李卓桓)'s avatar
bug fix  
Huan (李卓桓) 已提交
168
      /* fail safe */
169 170
    }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
171 172
    // this.currentState('close')
    this.state.current('close')
173

174
    return
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
175
  }
176

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
177
  public clean(): Promise<void> {
178
    const max = 30
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
179
    const backoff = 100
180

181 182 183
    /**
     * max = (2*totalTime/backoff) ^ (1/2)
     * timeout = 45000 for {max: 30, backoff: 100}
184
     * timeout = 11250 for {max: 15, backoff: 100}
185
     */
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
186
    const timeout = max * (backoff * max) / 2
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
187

188
    return retryPromise({ max: max, backoff: backoff }, attempt => {
189
      log.silly('PuppetWebBrowser', 'clean() retryPromise: attempt %s time for timeout %s'
190 191
                                  , attempt,  timeout
      )
192 193

      return new Promise((resolve, reject) => {
194 195 196
        this.getBrowserPids()
        .then(pids => {
          if (pids.length === 0) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
197
            log.verbose('PuppetWebBrowser', 'clean() retryPromise() resolved')
198
            resolve('clean() browser process not found, at attemp#' + attempt)
199
          } else {
200
            reject(new Error('clean() found browser process, not clean, dirty'))
201 202
          }
        })
203
        .catch(e => reject(e))
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
204 205
      })
    })
206
    .catch(e => {
207
      log.error('PuppetWebBrowser', 'retryPromise failed: %s', e.message)
208 209
      throw e
    })
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
210
  }
211

212
  public getBrowserPids(): Promise<string[]> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
213
    log.silly('PuppetWebBrowser', 'getBrowserPids()')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
214

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
215
    const head = this.setting.head
216

217 218 219 220 221 222
    return new Promise((resolve, reject) => {
      require('ps-tree')(process.pid, (err, children) => {
        if (err) {
          reject(err)
          return
        }
223
        let browserRe
224

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
225 226
        switch (head) {
          case 'phantomjs':
227 228 229
            browserRe = 'phantomjs'
            break

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
230
          case 'chrome':
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
231
            browserRe = 'chrome(?!driver)|chromium'
232 233
            break

234
          default:
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
235 236 237
            const e = new Error('unsupported head: ' + head)
            log.warn('PuppetWebBrowser', 'getBrowserPids() for %s', e.message)
            throw e
238
        }
239

240
        let matchRegex = new RegExp(browserRe, 'i')
241
        const pids: string[] = children.filter(child => {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
242
          log.silly('PuppetWebBrowser', 'getBrowserPids() child: %s', JSON.stringify(child))
243 244
          // https://github.com/indexzero/ps-tree/issues/18
          return matchRegex.test('' + child.COMMAND + child.COMM)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
245 246
        }).map(child => child.PID)

247 248 249 250 251 252
        resolve(pids)
        return
      })
    })
  }

253
  public async execute(script, ...args): Promise<any> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
254 255 256 257 258 259
    log.silly('PuppetWebBrowser', 'Browser.execute("%s")'
                                , (
                                    script.slice(0, 80)
                                          .replace(/[\n\s]+/g, ' ')
                                    + (script.length > 80 ? ' ... ' : '')
                                )
260
            )
261
    // log.verbose('PuppetWebBrowser', `Browser.execute() driver.getSession: %s`, util.inspect(this.driver.getSession()))
262
    if (this.dead()) { throw new Error('browser dead') }
263

264
    try {
265
      return await this.driver.executeScript.apply(this.driver, arguments)
266
    } catch (e) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
267
      // this.dead(e)
268
      log.warn('PuppetWebBrowser', 'execute() exception: %s', e.message.substr(0, 99))
269
      throw e
270
    }
271 272
  }

273
  public async executeAsync(script, ...args): Promise<any> {
274
    log.silly('PuppetWebBrowser', 'Browser.executeAsync(%s)', script.slice(0, 80))
275 276 277
    if (this.dead()) { throw new Error('browser dead') }

    try {
278
      return await this.driver.executeAsyncScript.apply(this.driver, arguments)
279 280 281 282 283
    } catch (e) {
      // this.dead(e)
      log.warn('PuppetWebBrowser', 'executeAsync() exception: %s', e.message.slice(0, 99))
      throw e
    }
284 285
  }

286 287 288 289 290
  /**
   *
   * check whether browser is full functional
   *
   */
291
  public async readyLive(): Promise<boolean> {
292
    log.verbose('PuppetWebBrowser', 'readyLive()')
293

294
    if (this.dead()) {
295 296
      log.silly('PuppetWebBrowser', 'readyLive() dead() is true')
      return false
297
    }
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314

    let two

    try {
      two = await this.execute('return 1+1')
    } catch (e) {
      two = e && e.message
    }

    if (two === 2) {
      return true // browser ok, living
    }

    const errMsg = 'found dead browser coz 1+1 = ' + two + ' (not 2)'
    log.warn('PuppetWebBrowser', 'readyLive() %s', errMsg)
    this.dead(errMsg)
    return false // browser not ok, dead
315
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
316

317
  public dead(forceReason?: string): boolean {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
318 319
    // too noisy!
    // log.silly('PuppetWebBrowser', 'dead() checking ... ')
320 321

    let msg
322 323 324 325
    let dead = false

    if (forceReason) {
      dead = true
326
      msg = forceReason
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
327
    } else if (this.state.target() !== 'open') {
328
      dead = true
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
329
      msg = 'state.target() not open'
330
    } else if (!this.driver) { // FIXME: this.driver is BrowserDriver, should add a method to check if availble 201610
331
      dead = true
332
      msg = 'no driver or session'
333 334 335
    }

    if (dead) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
336 337 338 339 340 341
      log.warn('PuppetWebBrowser', 'dead(%s) because %s'
                                  , forceReason
                                    ? forceReason
                                    : ''
                                  , msg
      )
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
342

343 344 345 346
      if (   this.state.target()  === 'open'
          && this.state.current() === 'open'
          && this.state.stable()
      ) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
347 348 349
        log.verbose('PuppetWebBrowser', 'dead() emit a `dead` event because %s', msg)
        this.emit('dead', msg)
      } else {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
350 351
        log.warn('PuppetWebBrowser', 'dead() wil not emit `dead` event because states are: target(%s), current(%s), stable(%s)'
                                    , this.state.target(), this.state.current(), this.state.stable()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
352 353
        )
      }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
354

355 356 357 358
    }
    return dead
  }

359 360 361 362
  public addCookie(cookies: CookieType[]):            Promise<void>
  public addCookie(cookie:  CookieType):              Promise<void>
  public addCookie(cookie:  CookieType|CookieType[]): Promise<void> {
    return this.cookie.add(cookie)
363 364
  }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
365 366 367 368
  public saveCookie()   { return this.cookie.save()   }
  public loadCookie()   { return this.cookie.load()   }
  public readCookie()   { return this.cookie.read()   }
  public cleanCookie()  { return this.cookie.clean()  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
369 370
}

371
export default Browser