ferrum 0.3 → 0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +92 -62
- data/lib/ferrum/browser.rb +2 -8
- data/lib/ferrum/network.rb +141 -0
- data/lib/ferrum/network/error.rb +18 -16
- data/lib/ferrum/network/exchange.rb +43 -0
- data/lib/ferrum/network/intercepted_request.rb +57 -48
- data/lib/ferrum/network/request.rb +39 -26
- data/lib/ferrum/network/response.rb +46 -39
- data/lib/ferrum/page.rb +25 -104
- data/lib/ferrum/page/dom.rb +17 -0
- data/lib/ferrum/page/frame.rb +20 -8
- data/lib/ferrum/page/screenshot.rb +1 -5
- data/lib/ferrum/version.rb +1 -1
- metadata +4 -3
- data/lib/ferrum/page/net.rb +0 -83
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6f524fb695add05ab04355a4f694710f0cda6ce317048f234cd6d597a6db5b54
|
4
|
+
data.tar.gz: 4f755ea3ce87efa0a92b2689188ec8ce78979b716e8053e9687fe4f9e97baf2e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f52f33ea6d6292ab9f829f9e4870ba7be5fe0f9c045f4b4fe1e540b5363440027ae8890987e92e95612b91e1d82d28a19ff3d204cabeac0bebde96943d399baa
|
7
|
+
data.tar.gz: 3156ae9cc12403dd2b497119371b895d7f37865318ffa2d424676df91346403d85386615abe2db020dffe77b055de72b5e44ccf69137d9bd1fa01a9ae75ffa0f
|
data/README.md
CHANGED
@@ -159,15 +159,6 @@ browser.goto("https://github.com/")
|
|
159
159
|
browser.refresh
|
160
160
|
```
|
161
161
|
|
162
|
-
#### status : `Integer`
|
163
|
-
|
164
|
-
Contains the status code of the response (e.g., 200 for a success).
|
165
|
-
|
166
|
-
```ruby
|
167
|
-
browser.goto("https://github.com/")
|
168
|
-
browser.status # => 200
|
169
|
-
```
|
170
|
-
|
171
162
|
|
172
163
|
## Finders
|
173
164
|
|
@@ -261,8 +252,8 @@ browser.body # => '<html itemscope="" itemtype="http://schema.org/WebPage" lang=
|
|
261
252
|
Saves screenshot on a disk or returns it as base64.
|
262
253
|
|
263
254
|
* options `Hash`
|
264
|
-
* :path `String` to save a screenshot on the disk.
|
265
|
-
|
255
|
+
* :path `String` to save a screenshot on the disk. `:encoding` will be set to
|
256
|
+
`:binary` automatically
|
266
257
|
* :encoding `Symbol` `:base64` | `:binary` you can set it to return image as
|
267
258
|
Base64
|
268
259
|
* :format `String` "jpeg" | "png"
|
@@ -286,8 +277,8 @@ browser.screenshot(full: true, quality: 60) # "iVBORw0KGgoAAAANSUhEUgAABAAAAAMAC
|
|
286
277
|
Saves PDF on a disk or returns it as base64.
|
287
278
|
|
288
279
|
* options `Hash`
|
289
|
-
* :path `String` to save a
|
290
|
-
|
280
|
+
* :path `String` to save a pdf on the disk. `:encoding` will be set to
|
281
|
+
`:binary` automatically
|
291
282
|
* :encoding `Symbol` `:base64` | `:binary` you can set it to return pdf as
|
292
283
|
Base64
|
293
284
|
* :landscape `Boolean` paper orientation. Defaults to false.
|
@@ -305,17 +296,98 @@ browser.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => 14983
|
|
305
296
|
|
306
297
|
## Network
|
307
298
|
|
308
|
-
|
299
|
+
browser.network
|
300
|
+
|
301
|
+
#### traffic `Array<Network::Exchange>`
|
302
|
+
|
303
|
+
Returns all information about network traffic as `Network::Exchange` instance
|
304
|
+
which in general is a wrapper around `request`, `response` and `error`.
|
305
|
+
|
306
|
+
```ruby
|
307
|
+
browser.goto("https://github.com/")
|
308
|
+
browser.network.traffic # => [#<Ferrum::Network::Exchange, ...]
|
309
|
+
```
|
310
|
+
|
311
|
+
#### request : `Network::Request`
|
312
|
+
|
313
|
+
Page request of the main frame.
|
314
|
+
|
315
|
+
```ruby
|
316
|
+
browser.goto("https://github.com/")
|
317
|
+
browser.network.request # => #<Ferrum::Network::Request...
|
318
|
+
```
|
319
|
+
|
320
|
+
#### response : `Network::Response`
|
321
|
+
|
322
|
+
Page response of the main frame.
|
323
|
+
|
324
|
+
```ruby
|
325
|
+
browser.goto("https://github.com/")
|
326
|
+
browser.network.response # => #<Ferrum::Network::Response...
|
327
|
+
```
|
309
328
|
|
310
|
-
|
329
|
+
#### status : `Integer`
|
330
|
+
|
331
|
+
Contains the status code of the main page response (e.g., 200 for a
|
332
|
+
success). This is just a shortcut for `response.status`.
|
333
|
+
|
334
|
+
```ruby
|
335
|
+
browser.goto("https://github.com/")
|
336
|
+
browser.network.status # => 200
|
337
|
+
```
|
338
|
+
|
339
|
+
#### clear(type)
|
340
|
+
|
341
|
+
Clear browser's cache or collected traffic.
|
342
|
+
|
343
|
+
* type `Symbol` it is either `:traffic` or `:cache`
|
344
|
+
|
345
|
+
```ruby
|
346
|
+
traffic = browser.network.traffic # => []
|
347
|
+
browser.goto("https://github.com/")
|
348
|
+
traffic.size # => 51
|
349
|
+
browser.network.clear(:traffic)
|
350
|
+
traffic.size # => 0
|
351
|
+
```
|
352
|
+
|
353
|
+
#### intercept(\*\*options)
|
354
|
+
|
355
|
+
Set request interception for given options. This method is only sets request
|
356
|
+
interception, you should use `on` callback to catch requests and abort or
|
357
|
+
continue them.
|
358
|
+
|
359
|
+
* options `Hash`
|
360
|
+
* :pattern `String` \* by default
|
361
|
+
* :resource_type `Symbol` one of the [resource types](https://chromedevtools.github.io/devtools-protocol/tot/Network#type-ResourceType)
|
362
|
+
|
363
|
+
```ruby
|
364
|
+
browser = Ferrum::Browser.new
|
365
|
+
browser.network.intercept
|
366
|
+
browser.on(:request) do |request|
|
367
|
+
if request.match?(/bla-bla/)
|
368
|
+
request.abort
|
369
|
+
else
|
370
|
+
request.continue
|
371
|
+
end
|
372
|
+
end
|
373
|
+
browser.goto("https://google.com")
|
374
|
+
```
|
311
375
|
|
312
|
-
####
|
376
|
+
#### authorize(\*\*options)
|
313
377
|
|
314
|
-
|
378
|
+
If site uses authorization you can provide credentials using this method.
|
315
379
|
|
316
|
-
|
380
|
+
* options `Hash`
|
381
|
+
* :type `Symbol` `:server` | `:proxy` site or proxy authorization
|
382
|
+
* :user `String`
|
383
|
+
* :password `String`
|
317
384
|
|
318
|
-
|
385
|
+
```ruby
|
386
|
+
browser.network.authorize(user: "login", password: "pass")
|
387
|
+
browser.goto("http://example.com/authenticated")
|
388
|
+
puts browser.network.status # => 200
|
389
|
+
puts browser.body # => Welcome, authenticated client
|
390
|
+
```
|
319
391
|
|
320
392
|
|
321
393
|
### Mouse
|
@@ -540,23 +612,12 @@ Play around inside given frame
|
|
540
612
|
browser.goto("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
|
541
613
|
frame = browser.at_xpath("//iframe")
|
542
614
|
browser.within_frame(frame) do
|
615
|
+
puts browser.frame_title # => HTML Demo: <iframe>
|
543
616
|
puts browser.frame_url # => https://interactive-examples.mdn.mozilla.net/pages/tabbed/iframe.html
|
544
617
|
end
|
545
618
|
```
|
546
619
|
|
547
620
|
|
548
|
-
## Authorization
|
549
|
-
|
550
|
-
#### authorize(\*\*options)
|
551
|
-
|
552
|
-
If site uses authorization you can provide credentials using this method.
|
553
|
-
|
554
|
-
* options `Hash`
|
555
|
-
* :type `Symbol` `:server` | `:proxy` site or proxy authorization
|
556
|
-
* :user `String`
|
557
|
-
* :password `String`
|
558
|
-
|
559
|
-
|
560
621
|
## Dialog
|
561
622
|
|
562
623
|
#### accept(text)
|
@@ -580,34 +641,3 @@ browser.on(:dialog) do |dialog|
|
|
580
641
|
end
|
581
642
|
browser.goto("https://google.com")
|
582
643
|
```
|
583
|
-
|
584
|
-
|
585
|
-
## Interception
|
586
|
-
|
587
|
-
#### intercept_request(\*\*options)
|
588
|
-
|
589
|
-
Set request interception for given options. This method is only sets request
|
590
|
-
interception, you should use `on` callback to catch it.
|
591
|
-
|
592
|
-
* options `Hash`
|
593
|
-
* :pattern `String` \* by default
|
594
|
-
* :resource_type `Symbol` one of the [resource types](https://chromedevtools.github.io/devtools-protocol/tot/Network#type-ResourceType)
|
595
|
-
|
596
|
-
#### on(event)
|
597
|
-
|
598
|
-
Set callback for given event.
|
599
|
-
|
600
|
-
* event `Symbol`
|
601
|
-
|
602
|
-
```ruby
|
603
|
-
browser = Ferrum::Browser.new
|
604
|
-
browser.intercept_request
|
605
|
-
browser.on(:request_intercepted) do |request|
|
606
|
-
if request.match?(/bla-bla/)
|
607
|
-
request.abort
|
608
|
-
else
|
609
|
-
request.continue
|
610
|
-
end
|
611
|
-
end
|
612
|
-
browser.goto("https://google.com")
|
613
|
-
```
|
data/lib/ferrum/browser.rb
CHANGED
@@ -16,15 +16,13 @@ module Ferrum
|
|
16
16
|
extend Forwardable
|
17
17
|
delegate %i[window_handle window_handles switch_to_window
|
18
18
|
open_new_window close_window within_window page] => :targets
|
19
|
-
delegate %i[goto back forward refresh
|
19
|
+
delegate %i[goto back forward refresh
|
20
20
|
at_css at_xpath css xpath current_url title body
|
21
|
-
headers cookies
|
22
|
-
intercept_request continue_request abort_request
|
21
|
+
headers cookies network
|
23
22
|
mouse keyboard
|
24
23
|
screenshot pdf
|
25
24
|
evaluate evaluate_on evaluate_async execute
|
26
25
|
frame_url frame_title within_frame
|
27
|
-
authorize
|
28
26
|
on] => :page
|
29
27
|
|
30
28
|
attr_reader :client, :process, :logger, :js_errors, :slowmo, :base_url,
|
@@ -82,10 +80,6 @@ module Ferrum
|
|
82
80
|
raise
|
83
81
|
end
|
84
82
|
|
85
|
-
def clear_memory_cache
|
86
|
-
page.command("Network.clearBrowserCache")
|
87
|
-
end
|
88
|
-
|
89
83
|
def reset
|
90
84
|
@window_size = @original_window_size
|
91
85
|
targets.reset
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ferrum/network/exchange"
|
4
|
+
require "ferrum/network/intercepted_request"
|
5
|
+
|
6
|
+
module Ferrum
|
7
|
+
class Network
|
8
|
+
CLEAR_TYPE = %i[traffic cache].freeze
|
9
|
+
AUTHORIZE_TYPE = %i[server proxy].freeze
|
10
|
+
RESOURCE_TYPES = %w[Document Stylesheet Image Media Font Script TextTrack
|
11
|
+
XHR Fetch EventSource WebSocket Manifest
|
12
|
+
SignedExchange Ping CSPViolationReport Other].freeze
|
13
|
+
|
14
|
+
attr_reader :traffic
|
15
|
+
|
16
|
+
def initialize(page)
|
17
|
+
@page = page
|
18
|
+
@traffic = []
|
19
|
+
@exchange = nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def request
|
23
|
+
@exchange&.request
|
24
|
+
end
|
25
|
+
|
26
|
+
def response
|
27
|
+
@exchange&.response
|
28
|
+
end
|
29
|
+
|
30
|
+
def status
|
31
|
+
response&.status
|
32
|
+
end
|
33
|
+
|
34
|
+
def clear(type)
|
35
|
+
unless CLEAR_TYPE.include?(type)
|
36
|
+
raise ArgumentError, ":type should be in #{CLEAR_TYPE}"
|
37
|
+
end
|
38
|
+
|
39
|
+
if type == :traffic
|
40
|
+
@traffic.clear
|
41
|
+
else
|
42
|
+
@page.command("Network.clearBrowserCache")
|
43
|
+
end
|
44
|
+
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
def intercept(pattern: "*", resource_type: nil)
|
49
|
+
pattern = { urlPattern: pattern }
|
50
|
+
if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
|
51
|
+
pattern[:resourceType] = resource_type
|
52
|
+
end
|
53
|
+
|
54
|
+
@page.command("Network.setRequestInterception", patterns: [pattern])
|
55
|
+
end
|
56
|
+
|
57
|
+
def authorize(user:, password:, type: :server)
|
58
|
+
unless AUTHORIZE_TYPE.include?(type)
|
59
|
+
raise ArgumentError, ":type should be in #{AUTHORIZE_TYPE}"
|
60
|
+
end
|
61
|
+
|
62
|
+
@authorized_ids ||= {}
|
63
|
+
@authorized_ids[type] ||= []
|
64
|
+
|
65
|
+
intercept
|
66
|
+
|
67
|
+
@page.on(:request) do |request, index, total|
|
68
|
+
if request.auth_challenge?(type)
|
69
|
+
response = authorized_response(@authorized_ids[type],
|
70
|
+
request.interception_id,
|
71
|
+
user, password)
|
72
|
+
|
73
|
+
@authorized_ids[type] << request.interception_id
|
74
|
+
request.continue(authChallengeResponse: response)
|
75
|
+
elsif index + 1 < total
|
76
|
+
next # There are other callbacks that can handle this, skip
|
77
|
+
else
|
78
|
+
request.continue
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def subscribe
|
84
|
+
@page.on("Network.requestWillBeSent") do |params|
|
85
|
+
# On redirects Chrome doesn't change `requestId` and there's no
|
86
|
+
# `Network.responseReceived` event for such request. If there's already
|
87
|
+
# exchange object with this id then we got redirected and params has
|
88
|
+
# `redirectResponse` key which contains the response.
|
89
|
+
if exchange = first_by(params["requestId"])
|
90
|
+
exchange.build_response(params)
|
91
|
+
end
|
92
|
+
|
93
|
+
exchange = Network::Exchange.new(params)
|
94
|
+
@exchange = exchange if exchange.navigation_request?(@page.frame_id)
|
95
|
+
@traffic << exchange
|
96
|
+
end
|
97
|
+
|
98
|
+
@page.on("Network.responseReceived") do |params|
|
99
|
+
if exchange = last_by(params["requestId"])
|
100
|
+
exchange.build_response(params)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
@page.on("Network.loadingFinished") do |params|
|
105
|
+
exchange = last_by(params["requestId"])
|
106
|
+
if exchange && exchange.response
|
107
|
+
exchange.response.body_size = params["encodedDataLength"]
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
@page.on("Log.entryAdded") do |params|
|
112
|
+
entry = params["entry"] || {}
|
113
|
+
if entry["source"] == "network" &&
|
114
|
+
entry["level"] == "error" &&
|
115
|
+
exchange = last_by(entry["networkRequestId"])
|
116
|
+
exchange.build_error(entry)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def authorized_response(ids, interception_id, username, password)
|
122
|
+
if ids.include?(interception_id)
|
123
|
+
{ response: "CancelAuth" }
|
124
|
+
elsif username && password
|
125
|
+
{ response: "ProvideCredentials",
|
126
|
+
username: username,
|
127
|
+
password: password }
|
128
|
+
else
|
129
|
+
{ response: "CancelAuth" }
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def first_by(request_id)
|
134
|
+
@traffic.find { |e| e.request.id == request_id }
|
135
|
+
end
|
136
|
+
|
137
|
+
def last_by(request_id)
|
138
|
+
@traffic.select { |e| e.request.id == request_id }.last
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
data/lib/ferrum/network/error.rb
CHANGED
@@ -1,25 +1,27 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module Ferrum
|
4
|
-
class
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
module Ferrum
|
4
|
+
class Network
|
5
|
+
class Error
|
6
|
+
def initialize(data)
|
7
|
+
@data = data
|
8
|
+
end
|
8
9
|
|
9
|
-
|
10
|
-
|
11
|
-
|
10
|
+
def id
|
11
|
+
@data["networkRequestId"]
|
12
|
+
end
|
12
13
|
|
13
|
-
|
14
|
-
|
15
|
-
|
14
|
+
def url
|
15
|
+
@data["url"]
|
16
|
+
end
|
16
17
|
|
17
|
-
|
18
|
-
|
19
|
-
|
18
|
+
def description
|
19
|
+
@data["text"]
|
20
|
+
end
|
20
21
|
|
21
|
-
|
22
|
-
|
22
|
+
def time
|
23
|
+
@time ||= Time.strptime(@data["timestamp"].to_s, "%s")
|
24
|
+
end
|
23
25
|
end
|
24
26
|
end
|
25
27
|
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ferrum/network/error"
|
4
|
+
require "ferrum/network/request"
|
5
|
+
require "ferrum/network/response"
|
6
|
+
|
7
|
+
module Ferrum
|
8
|
+
class Network
|
9
|
+
class Exchange
|
10
|
+
attr_reader :request, :response, :error
|
11
|
+
|
12
|
+
def initialize(params)
|
13
|
+
@response = @error = nil
|
14
|
+
build_request(params)
|
15
|
+
end
|
16
|
+
|
17
|
+
def build_request(params)
|
18
|
+
@request = Network::Request.new(params)
|
19
|
+
end
|
20
|
+
|
21
|
+
def build_response(params)
|
22
|
+
@response = Network::Response.new(params)
|
23
|
+
end
|
24
|
+
|
25
|
+
def build_error(params)
|
26
|
+
@error = Network::Error.new(params)
|
27
|
+
end
|
28
|
+
|
29
|
+
def navigation_request?(frame_id)
|
30
|
+
request.type?(:document) &&
|
31
|
+
request.frame_id == frame_id
|
32
|
+
end
|
33
|
+
|
34
|
+
def blocked?
|
35
|
+
response.nil?
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_a
|
39
|
+
[request, response, error]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -1,53 +1,62 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module Ferrum
|
4
|
-
class
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
3
|
+
module Ferrum
|
4
|
+
class Network
|
5
|
+
class InterceptedRequest
|
6
|
+
attr_accessor :interception_id, :frame_id, :resource_type
|
7
|
+
|
8
|
+
def initialize(page, params)
|
9
|
+
@page, @params = page, params
|
10
|
+
@interception_id = params["interceptionId"]
|
11
|
+
@frame_id = params["frameId"]
|
12
|
+
@resource_type = params["resourceType"]
|
13
|
+
@request = params["request"]
|
14
|
+
end
|
15
|
+
|
16
|
+
def navigation_request?
|
17
|
+
@params["isNavigationRequest"]
|
18
|
+
end
|
19
|
+
|
20
|
+
def auth_challenge?(source)
|
21
|
+
@params.dig("authChallenge", "source")&.downcase&.to_s == source.to_s
|
22
|
+
end
|
23
|
+
|
24
|
+
def match?(regexp)
|
25
|
+
!!url.match(regexp)
|
26
|
+
end
|
27
|
+
|
28
|
+
def continue(**options)
|
29
|
+
options = options.merge(interceptionId: interception_id)
|
30
|
+
@page.command("Network.continueInterceptedRequest", **options)
|
31
|
+
end
|
32
|
+
|
33
|
+
def abort
|
34
|
+
continue(errorReason: "Aborted")
|
35
|
+
end
|
36
|
+
|
37
|
+
def url
|
38
|
+
@request["url"]
|
39
|
+
end
|
40
|
+
|
41
|
+
def method
|
42
|
+
@request["method"]
|
43
|
+
end
|
44
|
+
|
45
|
+
def headers
|
46
|
+
@request["headers"]
|
47
|
+
end
|
48
|
+
|
49
|
+
def initial_priority
|
50
|
+
@request["initialPriority"]
|
51
|
+
end
|
52
|
+
|
53
|
+
def referrer_policy
|
54
|
+
@request["referrerPolicy"]
|
55
|
+
end
|
56
|
+
|
57
|
+
def inspect
|
58
|
+
%(#<#{self.class} @interception_id=#{@interception_id.inspect} @frame_id=#{@frame_id.inspect} @resource_type=#{@resource_type.inspect} @request=#{@request.inspect}>)
|
59
|
+
end
|
51
60
|
end
|
52
61
|
end
|
53
62
|
end
|
@@ -2,32 +2,45 @@
|
|
2
2
|
|
3
3
|
require "time"
|
4
4
|
|
5
|
-
module Ferrum
|
6
|
-
class
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
5
|
+
module Ferrum
|
6
|
+
class Network
|
7
|
+
class Request
|
8
|
+
def initialize(params)
|
9
|
+
@params = params
|
10
|
+
@request = @params["request"]
|
11
|
+
end
|
12
|
+
|
13
|
+
def id
|
14
|
+
@params["requestId"]
|
15
|
+
end
|
16
|
+
|
17
|
+
def type
|
18
|
+
@params["type"]
|
19
|
+
end
|
20
|
+
|
21
|
+
def type?(value)
|
22
|
+
type.downcase == value.to_s.downcase
|
23
|
+
end
|
24
|
+
|
25
|
+
def frame_id
|
26
|
+
@params["frameId"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def url
|
30
|
+
@request["url"]
|
31
|
+
end
|
32
|
+
|
33
|
+
def method
|
34
|
+
@request["method"]
|
35
|
+
end
|
36
|
+
|
37
|
+
def headers
|
38
|
+
@request["headers"]
|
39
|
+
end
|
40
|
+
|
41
|
+
def time
|
42
|
+
@time ||= Time.strptime(@params["wallTime"].to_s, "%s")
|
43
|
+
end
|
31
44
|
end
|
32
45
|
end
|
33
46
|
end
|
@@ -1,44 +1,51 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module Ferrum
|
4
|
-
class
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
3
|
+
module Ferrum
|
4
|
+
class Network
|
5
|
+
class Response
|
6
|
+
attr_reader :body_size
|
7
|
+
|
8
|
+
def initialize(params)
|
9
|
+
@params = params
|
10
|
+
@response = params["response"] || @params["redirectResponse"]
|
11
|
+
end
|
12
|
+
|
13
|
+
def id
|
14
|
+
@params["requestId"]
|
15
|
+
end
|
16
|
+
|
17
|
+
def url
|
18
|
+
@response["url"]
|
19
|
+
end
|
20
|
+
|
21
|
+
def status
|
22
|
+
@response["status"]
|
23
|
+
end
|
24
|
+
|
25
|
+
def status_text
|
26
|
+
@response["statusText"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def headers
|
30
|
+
@response["headers"]
|
31
|
+
end
|
32
|
+
|
33
|
+
def headers_size
|
34
|
+
@response["encodedDataLength"]
|
35
|
+
end
|
36
|
+
|
37
|
+
def content_type
|
38
|
+
@content_type ||= @response.dig("headers", "contentType")&.sub(/;.*\z/, "")
|
39
|
+
end
|
40
|
+
|
41
|
+
# See https://crbug.com/883475
|
42
|
+
# Sometimes we never get the Network.responseReceived event.
|
43
|
+
# See https://crbug.com/764946
|
44
|
+
# `Network.loadingFinished` encodedDataLength contains both body and
|
45
|
+
# headers sizes received by wire.
|
46
|
+
def body_size=(size)
|
47
|
+
@body_size = size - headers_size
|
48
|
+
end
|
42
49
|
end
|
43
50
|
end
|
44
51
|
end
|
data/lib/ferrum/page.rb
CHANGED
@@ -5,34 +5,13 @@ require "ferrum/keyboard"
|
|
5
5
|
require "ferrum/headers"
|
6
6
|
require "ferrum/cookies"
|
7
7
|
require "ferrum/dialog"
|
8
|
+
require "ferrum/network"
|
8
9
|
require "ferrum/page/dom"
|
9
10
|
require "ferrum/page/runtime"
|
10
11
|
require "ferrum/page/frame"
|
11
|
-
require "ferrum/page/net"
|
12
12
|
require "ferrum/page/screenshot"
|
13
13
|
require "ferrum/browser/client"
|
14
|
-
|
15
|
-
require "ferrum/network/request"
|
16
|
-
require "ferrum/network/response"
|
17
|
-
require "ferrum/network/intercepted_request"
|
18
|
-
|
19
|
-
# RemoteObjectId is from a JavaScript world, and corresponds to any JavaScript
|
20
|
-
# object, including JS wrappers for DOM nodes. There is a way to convert between
|
21
|
-
# node ids and remote object ids (DOM.requestNode and DOM.resolveNode).
|
22
|
-
#
|
23
|
-
# NodeId is used for inspection, when backend tracks the node and sends updates to
|
24
|
-
# the frontend. If you somehow got NodeId over protocol, backend should have
|
25
|
-
# pushed to the frontend all of it's ancestors up to the Document node via
|
26
|
-
# DOM.setChildNodes. After that, frontend is always kept up-to-date about anything
|
27
|
-
# happening to the node.
|
28
|
-
#
|
29
|
-
# BackendNodeId is just a unique identifier for a node. Obtaining it does not send
|
30
|
-
# any updates, for example, the node may be destroyed without any notification.
|
31
|
-
# This is a way to keep a reference to the Node, when you don't necessarily want
|
32
|
-
# to keep track of it. One example would be linking to the node from performance
|
33
|
-
# data (e.g. relayout root node). BackendNodeId may be either resolved to
|
34
|
-
# inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
|
35
|
-
# details (DOM.describeNode).
|
14
|
+
|
36
15
|
module Ferrum
|
37
16
|
class Page
|
38
17
|
NEW_WINDOW_WAIT = ENV.fetch("FERRUM_NEW_WINDOW_WAIT", 0.3).to_f
|
@@ -51,17 +30,15 @@ module Ferrum
|
|
51
30
|
end
|
52
31
|
end
|
53
32
|
|
54
|
-
include DOM, Runtime, Frame,
|
33
|
+
include DOM, Runtime, Frame, Screenshot
|
55
34
|
|
56
35
|
attr_accessor :referrer
|
57
|
-
attr_reader :target_id, :
|
58
|
-
:headers, :cookies, :
|
59
|
-
:mouse, :keyboard
|
60
|
-
:browser
|
36
|
+
attr_reader :target_id, :browser,
|
37
|
+
:headers, :cookies, :network,
|
38
|
+
:mouse, :keyboard
|
61
39
|
|
62
40
|
def initialize(target_id, browser, new_window = false)
|
63
41
|
@target_id, @browser = target_id, browser
|
64
|
-
@network_traffic = []
|
65
42
|
@event = Event.new.tap(&:set)
|
66
43
|
|
67
44
|
@frames = {}
|
@@ -80,6 +57,7 @@ module Ferrum
|
|
80
57
|
|
81
58
|
@mouse, @keyboard = Mouse.new(self), Keyboard.new(self)
|
82
59
|
@headers, @cookies = Headers.new(self), Cookies.new(self)
|
60
|
+
@network = Network.new(self)
|
83
61
|
|
84
62
|
subscribe
|
85
63
|
prepare_page
|
@@ -131,21 +109,6 @@ module Ferrum
|
|
131
109
|
command("Page.reload", wait: timeout)
|
132
110
|
end
|
133
111
|
|
134
|
-
def network_traffic(type = nil)
|
135
|
-
case type.to_s
|
136
|
-
when "all"
|
137
|
-
@network_traffic
|
138
|
-
when "blocked"
|
139
|
-
@network_traffic.select { |r| r.response.nil? } # when request blocked
|
140
|
-
else
|
141
|
-
@network_traffic.select { |r| r.response } # when request isn't blocked
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
def clear_network_traffic
|
146
|
-
@network_traffic = []
|
147
|
-
end
|
148
|
-
|
149
112
|
def back
|
150
113
|
history_navigate(delta: -1)
|
151
114
|
end
|
@@ -171,7 +134,7 @@ module Ferrum
|
|
171
134
|
dialog = Dialog.new(self, params)
|
172
135
|
block.call(dialog, index, total)
|
173
136
|
end
|
174
|
-
when :
|
137
|
+
when :request
|
175
138
|
@client.on("Network.requestIntercepted") do |params, index, total|
|
176
139
|
request = Network::InterceptedRequest.new(self, params)
|
177
140
|
block.call(request, index, total)
|
@@ -186,87 +149,45 @@ module Ferrum
|
|
186
149
|
def subscribe
|
187
150
|
super
|
188
151
|
|
152
|
+
network.subscribe
|
153
|
+
|
154
|
+
on("Network.loadingFailed") do |params|
|
155
|
+
id, canceled = params.values_at("requestId", "canceled")
|
156
|
+
# Set event as we aborted main request we are waiting for
|
157
|
+
if network.request&.id == id && canceled == true
|
158
|
+
@event.set
|
159
|
+
@document_id = get_document_id
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
189
163
|
if @browser.logger
|
190
|
-
|
164
|
+
on("Runtime.consoleAPICalled") do |params|
|
191
165
|
params["args"].each { |r| @browser.logger.puts(r["value"]) }
|
192
166
|
end
|
193
167
|
end
|
194
168
|
|
195
169
|
if @browser.js_errors
|
196
|
-
|
170
|
+
on("Runtime.exceptionThrown") do |params|
|
197
171
|
# FIXME https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
|
198
172
|
Thread.main.raise JavaScriptError.new(params.dig("exceptionDetails", "exception"))
|
199
173
|
end
|
200
174
|
end
|
201
175
|
|
202
|
-
|
176
|
+
on("Page.windowOpen") do
|
203
177
|
@browser.targets.refresh
|
204
178
|
end
|
205
179
|
|
206
|
-
|
180
|
+
on("Page.navigatedWithinDocument") do
|
207
181
|
@event.set if @waiting_frames.empty?
|
208
182
|
end
|
209
183
|
|
210
|
-
|
184
|
+
on("Page.domContentEventFired") do |params|
|
211
185
|
# `frameStoppedLoading` doesn't occur if status isn't success
|
212
|
-
if
|
186
|
+
if network.status != 200
|
213
187
|
@event.set
|
214
188
|
@document_id = get_document_id
|
215
189
|
end
|
216
190
|
end
|
217
|
-
|
218
|
-
@client.on("Network.requestWillBeSent") do |params|
|
219
|
-
if params["frameId"] == @frame_id
|
220
|
-
# Possible types:
|
221
|
-
# Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
|
222
|
-
# Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
|
223
|
-
# CSPViolationReport, Other
|
224
|
-
if params["type"] == "Document"
|
225
|
-
@event.reset
|
226
|
-
@request_id = params["requestId"]
|
227
|
-
end
|
228
|
-
end
|
229
|
-
|
230
|
-
id, time = params.values_at("requestId", "wallTime")
|
231
|
-
params = params["request"].merge("id" => id, "time" => time)
|
232
|
-
@network_traffic << Network::Request.new(params)
|
233
|
-
end
|
234
|
-
|
235
|
-
@client.on("Network.responseReceived") do |params|
|
236
|
-
if params["requestId"] == @request_id
|
237
|
-
@response_headers = params.dig("response", "headers")
|
238
|
-
@status = params.dig("response", "status")
|
239
|
-
end
|
240
|
-
|
241
|
-
if request = @network_traffic.find { |r| r.id == params["requestId"] }
|
242
|
-
params = params["response"].merge("id" => params["requestId"])
|
243
|
-
request.response = Network::Response.new(params)
|
244
|
-
end
|
245
|
-
end
|
246
|
-
|
247
|
-
@client.on("Network.loadingFinished") do |params|
|
248
|
-
if request = @network_traffic.find { |r| r.id == params["requestId"] }
|
249
|
-
# Sometimes we never get the Network.responseReceived event.
|
250
|
-
# See https://crbug.com/883475
|
251
|
-
#
|
252
|
-
# Network.loadingFinished's encodedDataLength contains both body and headers
|
253
|
-
# sizes received by wire. See https://crbug.com/764946
|
254
|
-
if response = request.response
|
255
|
-
response.body_size = params["encodedDataLength"] - response.headers_size
|
256
|
-
end
|
257
|
-
end
|
258
|
-
end
|
259
|
-
|
260
|
-
@client.on("Log.entryAdded") do |params|
|
261
|
-
source = params.dig("entry", "source")
|
262
|
-
level = params.dig("entry", "level")
|
263
|
-
if source == "network" && level == "error"
|
264
|
-
id = params.dig("entry", "networkRequestId")
|
265
|
-
if request = @network_traffic.find { |r| r.id == id }
|
266
|
-
request.error = Network::Error.new(params["entry"])
|
267
|
-
end
|
268
|
-
end
|
269
|
-
end
|
270
191
|
end
|
271
192
|
|
272
193
|
def prepare_page
|
data/lib/ferrum/page/dom.rb
CHANGED
@@ -1,5 +1,22 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# RemoteObjectId is from a JavaScript world, and corresponds to any JavaScript
|
4
|
+
# object, including JS wrappers for DOM nodes. There is a way to convert between
|
5
|
+
# node ids and remote object ids (DOM.requestNode and DOM.resolveNode).
|
6
|
+
#
|
7
|
+
# NodeId is used for inspection, when backend tracks the node and sends updates to
|
8
|
+
# the frontend. If you somehow got NodeId over protocol, backend should have
|
9
|
+
# pushed to the frontend all of it's ancestors up to the Document node via
|
10
|
+
# DOM.setChildNodes. After that, frontend is always kept up-to-date about anything
|
11
|
+
# happening to the node.
|
12
|
+
#
|
13
|
+
# BackendNodeId is just a unique identifier for a node. Obtaining it does not send
|
14
|
+
# any updates, for example, the node may be destroyed without any notification.
|
15
|
+
# This is a way to keep a reference to the Node, when you don't necessarily want
|
16
|
+
# to keep track of it. One example would be linking to the node from performance
|
17
|
+
# data (e.g. relayout root node). BackendNodeId may be either resolved to
|
18
|
+
# inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
|
19
|
+
# details (DOM.describeNode).
|
3
20
|
module Ferrum
|
4
21
|
class Page
|
5
22
|
module DOM
|
data/lib/ferrum/page/frame.rb
CHANGED
@@ -3,6 +3,8 @@
|
|
3
3
|
module Ferrum
|
4
4
|
class Page
|
5
5
|
module Frame
|
6
|
+
attr_reader :frame_id
|
7
|
+
|
6
8
|
def execution_context_id
|
7
9
|
context_id = current_execution_context_id
|
8
10
|
raise NoExecutionContextError unless context_id
|
@@ -42,28 +44,28 @@ module Ferrum
|
|
42
44
|
def subscribe
|
43
45
|
super if defined?(super)
|
44
46
|
|
45
|
-
|
47
|
+
on("Page.frameAttached") do |params|
|
46
48
|
@frames[params["frameId"]] = { "parent_id" => params["parentFrameId"] }
|
47
49
|
end
|
48
50
|
|
49
|
-
|
51
|
+
on("Page.frameStartedLoading") do |params|
|
50
52
|
@waiting_frames << params["frameId"]
|
51
53
|
@event.reset
|
52
54
|
end
|
53
55
|
|
54
|
-
|
56
|
+
on("Page.frameNavigated") do |params|
|
55
57
|
id = params["frame"]["id"]
|
56
58
|
if frame = @frames[id]
|
57
59
|
frame.merge!(params["frame"].select { |k, _| k == "name" || k == "url" })
|
58
60
|
end
|
59
61
|
end
|
60
62
|
|
61
|
-
|
63
|
+
on("Page.frameScheduledNavigation") do |params|
|
62
64
|
@waiting_frames << params["frameId"]
|
63
65
|
@event.reset
|
64
66
|
end
|
65
67
|
|
66
|
-
|
68
|
+
on("Page.frameStoppedLoading") do |params|
|
67
69
|
# `DOM.performSearch` doesn't work without getting #document node first.
|
68
70
|
# It returns node with nodeId 1 and nodeType 9 from which descend the
|
69
71
|
# tree and we save it in a variable because if we call that again root
|
@@ -79,7 +81,17 @@ module Ferrum
|
|
79
81
|
end
|
80
82
|
end
|
81
83
|
|
82
|
-
|
84
|
+
on("Network.requestWillBeSent") do |params|
|
85
|
+
if params["frameId"] == @frame_id
|
86
|
+
# Possible types:
|
87
|
+
# Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
|
88
|
+
# Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
|
89
|
+
# CSPViolationReport, Other
|
90
|
+
@event.reset if params["type"] == "Document"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
on("Runtime.executionContextCreated") do |params|
|
83
95
|
context_id = params.dig("context", "id")
|
84
96
|
@execution_context_id ||= context_id
|
85
97
|
|
@@ -93,7 +105,7 @@ module Ferrum
|
|
93
105
|
end
|
94
106
|
end
|
95
107
|
|
96
|
-
|
108
|
+
on("Runtime.executionContextDestroyed") do |params|
|
97
109
|
context_id = params["executionContextId"]
|
98
110
|
|
99
111
|
if @execution_context_id == context_id
|
@@ -104,7 +116,7 @@ module Ferrum
|
|
104
116
|
frame["execution_context_id"] = nil if frame
|
105
117
|
end
|
106
118
|
|
107
|
-
|
119
|
+
on("Runtime.executionContextsCleared") do
|
108
120
|
# If we didn't have time to set context id at the beginning we have
|
109
121
|
# to set lock and release it when we set something.
|
110
122
|
@execution_context_id = nil
|
@@ -23,17 +23,13 @@ module Ferrum
|
|
23
23
|
|
24
24
|
def save_file(path, data)
|
25
25
|
bin = Base64.decode64(data)
|
26
|
+
return bin unless path
|
26
27
|
File.open(path.to_s, "wb") { |f| f.write(bin) }
|
27
28
|
end
|
28
29
|
|
29
30
|
def common_options(encoding: :base64, path: nil, **_)
|
30
31
|
encoding = encoding.to_sym
|
31
32
|
encoding = :binary if path
|
32
|
-
|
33
|
-
if encoding == :binary && !path
|
34
|
-
raise "You have to provide `:path` for `:binary` encoding"
|
35
|
-
end
|
36
|
-
|
37
33
|
[path, encoding]
|
38
34
|
end
|
39
35
|
|
data/lib/ferrum/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ferrum
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.4'
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dmitry Vorotilin
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-09-
|
11
|
+
date: 2019-09-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: websocket-driver
|
@@ -205,7 +205,9 @@ files:
|
|
205
205
|
- lib/ferrum/keyboard.json
|
206
206
|
- lib/ferrum/keyboard.rb
|
207
207
|
- lib/ferrum/mouse.rb
|
208
|
+
- lib/ferrum/network.rb
|
208
209
|
- lib/ferrum/network/error.rb
|
210
|
+
- lib/ferrum/network/exchange.rb
|
209
211
|
- lib/ferrum/network/intercepted_request.rb
|
210
212
|
- lib/ferrum/network/request.rb
|
211
213
|
- lib/ferrum/network/response.rb
|
@@ -213,7 +215,6 @@ files:
|
|
213
215
|
- lib/ferrum/page.rb
|
214
216
|
- lib/ferrum/page/dom.rb
|
215
217
|
- lib/ferrum/page/frame.rb
|
216
|
-
- lib/ferrum/page/net.rb
|
217
218
|
- lib/ferrum/page/runtime.rb
|
218
219
|
- lib/ferrum/page/screenshot.rb
|
219
220
|
- lib/ferrum/targets.rb
|
data/lib/ferrum/page/net.rb
DELETED
@@ -1,83 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Ferrum
|
4
|
-
class Page
|
5
|
-
module Net
|
6
|
-
AUTHORIZE_TYPE = %i[server proxy]
|
7
|
-
RESOURCE_TYPES = %w[Document Stylesheet Image Media Font Script TextTrack
|
8
|
-
XHR Fetch EventSource WebSocket Manifest
|
9
|
-
SignedExchange Ping CSPViolationReport Other]
|
10
|
-
|
11
|
-
def authorize(user:, password:, type: :server)
|
12
|
-
unless AUTHORIZE_TYPE.include?(type)
|
13
|
-
raise ArgumentError, ":type should be in #{AUTHORIZE_TYPE}"
|
14
|
-
end
|
15
|
-
|
16
|
-
@authorized_ids ||= {}
|
17
|
-
@authorized_ids[type] ||= []
|
18
|
-
|
19
|
-
intercept_request
|
20
|
-
|
21
|
-
on(:request_intercepted) do |request, index, total|
|
22
|
-
if request.auth_challenge?(type)
|
23
|
-
response = authorized_response(@authorized_ids[type],
|
24
|
-
request.interception_id,
|
25
|
-
user, password)
|
26
|
-
|
27
|
-
@authorized_ids[type] << request.interception_id
|
28
|
-
request.continue(authChallengeResponse: response)
|
29
|
-
elsif index + 1 < total
|
30
|
-
next # There are other callbacks that can handle this, skip
|
31
|
-
else
|
32
|
-
request.continue
|
33
|
-
end
|
34
|
-
end
|
35
|
-
end
|
36
|
-
|
37
|
-
def intercept_request(pattern: "*", resource_type: nil)
|
38
|
-
pattern = { urlPattern: pattern }
|
39
|
-
if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
|
40
|
-
pattern[:resourceType] = resource_type
|
41
|
-
end
|
42
|
-
|
43
|
-
command("Network.setRequestInterception", patterns: [pattern])
|
44
|
-
end
|
45
|
-
|
46
|
-
def continue_request(interception_id, options = nil)
|
47
|
-
options ||= {}
|
48
|
-
options = options.merge(interceptionId: interception_id)
|
49
|
-
command("Network.continueInterceptedRequest", **options)
|
50
|
-
end
|
51
|
-
|
52
|
-
def abort_request(interception_id)
|
53
|
-
continue_request(interception_id, errorReason: "Aborted")
|
54
|
-
end
|
55
|
-
|
56
|
-
private
|
57
|
-
|
58
|
-
def subscribe
|
59
|
-
super if defined?(super)
|
60
|
-
|
61
|
-
@client.on("Network.loadingFailed") do |params|
|
62
|
-
# Free mutex as we aborted main request we are waiting for
|
63
|
-
if params["requestId"] == @request_id && params["canceled"] == true
|
64
|
-
@event.set
|
65
|
-
@document_id = get_document_id
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
def authorized_response(ids, interception_id, username, password)
|
71
|
-
if ids.include?(interception_id)
|
72
|
-
{ response: "CancelAuth" }
|
73
|
-
elsif username && password
|
74
|
-
{ response: "ProvideCredentials",
|
75
|
-
username: username,
|
76
|
-
password: password }
|
77
|
-
else
|
78
|
-
{ response: "CancelAuth" }
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|