playful 0.1.0.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gemtest +0 -0
- data/.gitignore +19 -0
- data/.rspec +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/History.rdoc +3 -0
- data/LICENSE.rdoc +22 -0
- data/README.rdoc +194 -0
- data/Rakefile +20 -0
- data/features/control_point.feature +13 -0
- data/features/device.feature +22 -0
- data/features/device_discovery.feature +9 -0
- data/features/step_definitions/control_point_steps.rb +19 -0
- data/features/step_definitions/device_discovery_steps.rb +40 -0
- data/features/step_definitions/device_steps.rb +28 -0
- data/features/support/common.rb +9 -0
- data/features/support/env.rb +17 -0
- data/features/support/fake_upnp_device_collection.rb +108 -0
- data/features/support/world_extensions.rb +15 -0
- data/lib/core_ext/hash_patch.rb +5 -0
- data/lib/core_ext/socket_patch.rb +16 -0
- data/lib/core_ext/to_upnp_s.rb +65 -0
- data/lib/playful.rb +5 -0
- data/lib/playful/control_point.rb +175 -0
- data/lib/playful/control_point/base.rb +74 -0
- data/lib/playful/control_point/device.rb +511 -0
- data/lib/playful/control_point/error.rb +13 -0
- data/lib/playful/control_point/service.rb +404 -0
- data/lib/playful/device.rb +28 -0
- data/lib/playful/logger.rb +8 -0
- data/lib/playful/ssdp.rb +195 -0
- data/lib/playful/ssdp/broadcast_searcher.rb +114 -0
- data/lib/playful/ssdp/error.rb +6 -0
- data/lib/playful/ssdp/listener.rb +38 -0
- data/lib/playful/ssdp/multicast_connection.rb +112 -0
- data/lib/playful/ssdp/network_constants.rb +17 -0
- data/lib/playful/ssdp/notifier.rb +41 -0
- data/lib/playful/ssdp/searcher.rb +87 -0
- data/lib/playful/version.rb +3 -0
- data/lib/rack/upnp_control_point.rb +70 -0
- data/playful.gemspec +38 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/search_responses.rb +134 -0
- data/spec/unit/core_ext/to_upnp_s_spec.rb +105 -0
- data/spec/unit/playful/control_point/device_spec.rb +7 -0
- data/spec/unit/playful/control_point_spec.rb +45 -0
- data/spec/unit/playful/ssdp/listener_spec.rb +29 -0
- data/spec/unit/playful/ssdp/multicast_connection_spec.rb +157 -0
- data/spec/unit/playful/ssdp/notifier_spec.rb +76 -0
- data/spec/unit/playful/ssdp/searcher_spec.rb +110 -0
- data/spec/unit/playful/ssdp_spec.rb +214 -0
- data/tasks/control_point.html +30 -0
- data/tasks/control_point.thor +43 -0
- data/tasks/search.thor +128 -0
- data/tasks/test_js/FABridge.js +1425 -0
- data/tasks/test_js/WebSocketMain.swf +807 -0
- data/tasks/test_js/swfobject.js +825 -0
- data/tasks/test_js/web_socket.js +1133 -0
- data/test/test_ssdp.rb +298 -0
- data/test/test_ssdp_notification.rb +74 -0
- data/test/test_ssdp_response.rb +31 -0
- data/test/test_ssdp_search.rb +23 -0
- metadata +339 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'log_buddy'
|
3
|
+
require_relative '../../lib/playful/control_point'
|
4
|
+
|
5
|
+
def local_ip_and_port
|
6
|
+
orig, Socket.do_not_reverse_lookup = Socket.do_not_reverse_lookup, true
|
7
|
+
|
8
|
+
UDPSocket.open do |s|
|
9
|
+
s.connect '64.233.187.99', 1
|
10
|
+
s.addr.last
|
11
|
+
[s.addr.last, s.addr[1]]
|
12
|
+
end
|
13
|
+
ensure
|
14
|
+
Socket.do_not_reverse_lookup = orig
|
15
|
+
end
|
16
|
+
|
17
|
+
ENV['RUBY_UPNP_ENV'] = 'testing'
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'socket'
|
3
|
+
require 'ipaddr'
|
4
|
+
|
5
|
+
require_relative '../../lib/playful/ssdp/network_constants'
|
6
|
+
|
7
|
+
class FakeUPnPDeviceCollection
|
8
|
+
include Singleton
|
9
|
+
include UPnP::SSDP::NetworkConstants
|
10
|
+
|
11
|
+
attr_accessor :respond_with
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@response = ''
|
15
|
+
@ssdp_listen_thread = nil
|
16
|
+
@serve_description = false
|
17
|
+
@local_ip, @local_port = local_ip_and_port
|
18
|
+
end
|
19
|
+
|
20
|
+
def expect_discovery(type)
|
21
|
+
case type
|
22
|
+
when :m_search
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
def stop_ssdp_listening
|
29
|
+
puts "<#{self.class}> Stopping..."
|
30
|
+
@ssdp_listen_thread.kill if @ssdp_listen_thread && @ssdp_listen_thread.alive?
|
31
|
+
puts "<#{self.class}> Stopped."
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Thread] The thread that's doing the listening.
|
35
|
+
def start_ssdp_listening
|
36
|
+
multicast_socket = setup_multicast_socket
|
37
|
+
multicast_socket.bind(MULTICAST_IP, MULTICAST_PORT)
|
38
|
+
|
39
|
+
ttl = [4].pack 'i'
|
40
|
+
unicast_socket = UDPSocket.open
|
41
|
+
unicast_socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, ttl)
|
42
|
+
|
43
|
+
@ssdp_listen_thread = Thread.new do
|
44
|
+
loop do
|
45
|
+
text, sender = multicast_socket.recvfrom(1024)
|
46
|
+
#puts "<#{self.class}> received text:\n#{text} from #{sender}"
|
47
|
+
|
48
|
+
#if text =~ /ST: upnp:rootdevice/
|
49
|
+
#if text =~ /#{@response}/m
|
50
|
+
if text =~ /M-SEARCH.*#{@local_ip}/m
|
51
|
+
puts "<#{self.class}> received text:\n#{text} from #{sender}"
|
52
|
+
return_port, return_ip = sender[1], sender[2]
|
53
|
+
|
54
|
+
puts "<#{self.class}> sending response\n#{@response}\n back to: #{return_ip}:#{return_port}"
|
55
|
+
unicast_socket.send(@response, 0, return_ip, return_port)
|
56
|
+
#multicast_socket.close
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def start_serving_description
|
63
|
+
=begin
|
64
|
+
tcp_server = TCPServer.new('0.0.0.0', 4567)
|
65
|
+
@serve_description = true
|
66
|
+
|
67
|
+
while @serve_description
|
68
|
+
@description_serve_thread = Thread.start(tcp_server.accept) do |s|
|
69
|
+
print(s, " is accepted\n")
|
70
|
+
s.write(Time.now)
|
71
|
+
print(s, " is gone\n")
|
72
|
+
s.close
|
73
|
+
end
|
74
|
+
end
|
75
|
+
=end
|
76
|
+
require 'webrick'
|
77
|
+
@description_server = WEBrick::HTTPServer.new(Port: 4567)
|
78
|
+
trap('INT') { @description_server.shutdown }
|
79
|
+
@description_server.mount_proc '/' do |req, res|
|
80
|
+
res.body = "<start>\n</start>"
|
81
|
+
end
|
82
|
+
@description_server.start
|
83
|
+
end
|
84
|
+
|
85
|
+
def stop_serving_description
|
86
|
+
=begin
|
87
|
+
@serve_description = false
|
88
|
+
|
89
|
+
if @description_serve_thread && @description_serve_thread.alive?
|
90
|
+
@description_serve_thread.join
|
91
|
+
end
|
92
|
+
=end
|
93
|
+
@description_server.stop
|
94
|
+
end
|
95
|
+
|
96
|
+
def setup_multicast_socket
|
97
|
+
membership = IPAddr.new(MULTICAST_IP).hton + IPAddr.new('0.0.0.0').hton
|
98
|
+
ttl = [4].pack 'i'
|
99
|
+
|
100
|
+
socket = UDPSocket.new
|
101
|
+
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership)
|
102
|
+
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_LOOP, "\000")
|
103
|
+
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, ttl)
|
104
|
+
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_TTL, ttl)
|
105
|
+
|
106
|
+
socket
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module HelperStuff
|
2
|
+
def control_point
|
3
|
+
@control_point ||= UPnP::ControlPoint.new
|
4
|
+
end
|
5
|
+
|
6
|
+
def fake_device_collection
|
7
|
+
@fake_device_collection ||= FakeUPnPDeviceCollection.instance
|
8
|
+
end
|
9
|
+
|
10
|
+
def local_ip
|
11
|
+
@local_ip ||= local_ip_and_port.first
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
World(HelperStuff)
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
# Workaround for missing constants on Windows
|
4
|
+
module Socket::Constants
|
5
|
+
IP_ADD_MEMBERSHIP = 12 unless defined? IP_ADD_MEMBERSHIP
|
6
|
+
IP_MULTICAST_LOOP = 11 unless defined? IP_MULTICAST_LOOP
|
7
|
+
IP_MULTICAST_TTL = 10 unless defined? IP_MULTICAST_TTL
|
8
|
+
IP_TTL = 4 unless defined? IP_TTL
|
9
|
+
end
|
10
|
+
|
11
|
+
class Socket
|
12
|
+
IP_ADD_MEMBERSHIP = 12 unless defined? IP_ADD_MEMBERSHIP
|
13
|
+
IP_MULTICAST_LOOP = 11 unless defined? IP_MULTICAST_LOOP
|
14
|
+
IP_MULTICAST_TTL = 10 unless defined? IP_MULTICAST_TTL
|
15
|
+
IP_TTL = 4 unless defined? IP_TTL
|
16
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
class Hash
|
2
|
+
|
3
|
+
# Converts Hash search targets to SSDP search target String. Conversions are
|
4
|
+
# as follows:
|
5
|
+
# uuid: "someUUID" # => "uuid:someUUID"
|
6
|
+
# device_type: "someDeviceType:1" # => "urn:schemas-upnp-org:device:someDeviceType:1"
|
7
|
+
# service_type: "someServiceType:2" # => "urn:schemas-upnp-org:service:someServiceType:2"
|
8
|
+
#
|
9
|
+
# You can use custom UPnP domain names too:
|
10
|
+
# { device_type: "someDeviceType:3",
|
11
|
+
# domain_name: "mydomain-com" } # => "urn:my-domain:device:someDeviceType:3"
|
12
|
+
# { service_type: "someServiceType:4",
|
13
|
+
# domain_name: "mydomain-com" } # => "urn:my-domain:service:someDeviceType:4"
|
14
|
+
#
|
15
|
+
# @return [String] The converted String, according to the UPnP spec.
|
16
|
+
def to_upnp_s
|
17
|
+
if self.has_key? :uuid
|
18
|
+
return "uuid:#{self[:uuid]}"
|
19
|
+
elsif self.has_key? :device_type
|
20
|
+
if self.has_key? :domain_name
|
21
|
+
return "urn:#{self[:domain_name]}:device:#{self[:device_type]}"
|
22
|
+
else
|
23
|
+
return "urn:schemas-upnp-org:device:#{self[:device_type]}"
|
24
|
+
end
|
25
|
+
elsif self.has_key? :service_type
|
26
|
+
if self.has_key? :domain_name
|
27
|
+
return "urn:#{self[:domain_name]}:service:#{self[:service_type]}"
|
28
|
+
else
|
29
|
+
return "urn:schemas-upnp-org:service:#{self[:service_type]}"
|
30
|
+
end
|
31
|
+
else
|
32
|
+
self.to_s
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
class Symbol
|
39
|
+
|
40
|
+
# Converts Symbol search targets to SSDP search target String. Conversions are
|
41
|
+
# as follows:
|
42
|
+
# :all # => "ssdp:all"
|
43
|
+
# :root # => "upnp:rootdevice"
|
44
|
+
# "root" # => "upnp:rootdevice"
|
45
|
+
#
|
46
|
+
# @return [String] The converted String, according to the UPnP spec.
|
47
|
+
def to_upnp_s
|
48
|
+
if self == :all
|
49
|
+
'ssdp:all'
|
50
|
+
elsif self == :root
|
51
|
+
'upnp:rootdevice'
|
52
|
+
else
|
53
|
+
self
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
class String
|
60
|
+
# This doesn't do anything to the string; just allows users to call the
|
61
|
+
# method without having to check type first.
|
62
|
+
def to_upnp_s
|
63
|
+
self
|
64
|
+
end
|
65
|
+
end
|
data/lib/playful.rb
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
require 'nori'
|
3
|
+
require 'em-synchrony'
|
4
|
+
require_relative 'logger'
|
5
|
+
require_relative 'ssdp'
|
6
|
+
require_relative 'control_point/service'
|
7
|
+
require_relative 'control_point/device'
|
8
|
+
require_relative 'control_point/error'
|
9
|
+
|
10
|
+
|
11
|
+
module Playful
|
12
|
+
|
13
|
+
# Allows for controlling a UPnP device as defined in the UPnP spec for control
|
14
|
+
# points.
|
15
|
+
#
|
16
|
+
# It uses +Nori+ for parsing the description XML files, which will use +Nokogiri+
|
17
|
+
# if you have it installed.
|
18
|
+
class ControlPoint
|
19
|
+
include LogSwitch::Mixin
|
20
|
+
|
21
|
+
def self.config
|
22
|
+
yield self
|
23
|
+
end
|
24
|
+
|
25
|
+
class << self
|
26
|
+
attr_accessor :raise_on_remote_error
|
27
|
+
end
|
28
|
+
|
29
|
+
@@raise_on_remote_error ||= true
|
30
|
+
|
31
|
+
attr_reader :devices
|
32
|
+
|
33
|
+
# @param [String] search_target The device(s) to control.
|
34
|
+
# @param [Hash] search_options Options to pass on to SSDP search and listen calls.
|
35
|
+
# @option options [Fixnum] response_wait_time
|
36
|
+
# @option options [Fixnum] m_search_count
|
37
|
+
# @option options [Fixnum] ttl
|
38
|
+
def initialize(search_target, search_options = {})
|
39
|
+
@search_target = search_target
|
40
|
+
@search_options = search_options
|
41
|
+
@search_options[:ttl] ||= 4
|
42
|
+
@devices = []
|
43
|
+
@new_device_channel = EventMachine::Channel.new
|
44
|
+
@old_device_channel = EventMachine::Channel.new
|
45
|
+
end
|
46
|
+
|
47
|
+
# Starts the ControlPoint. If an EventMachine reactor is running already,
|
48
|
+
# it'll join that reactor, otherwise it'll start the reactor.
|
49
|
+
#
|
50
|
+
# @yieldparam [EventMachine::Channel] new_device_channel The means through
|
51
|
+
# which clients can get notified when a new device has been discovered
|
52
|
+
# either through SSDP searching or from an +ssdp:alive+ notification.
|
53
|
+
# @yieldparam [EventMachine::Channel] old_device_channel The means through
|
54
|
+
# which clients can get notified when a device has gone offline (have sent
|
55
|
+
# out a +ssdp:byebye+ notification).
|
56
|
+
def start(&blk)
|
57
|
+
@stopping = false
|
58
|
+
|
59
|
+
starter = -> do
|
60
|
+
ssdp_search_and_listen(@search_target, @search_options)
|
61
|
+
blk.call(@new_device_channel, @old_device_channel)
|
62
|
+
@running = true
|
63
|
+
end
|
64
|
+
|
65
|
+
if EM.reactor_running?
|
66
|
+
log 'Joining reactor...'
|
67
|
+
starter.call
|
68
|
+
else
|
69
|
+
log 'Starting reactor...'
|
70
|
+
EM.synchrony(&starter)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def listen(ttl)
|
75
|
+
EM.defer do
|
76
|
+
listener = SSDP.listen(ttl)
|
77
|
+
|
78
|
+
listener.alive_notifications.subscribe do |advertisement|
|
79
|
+
log "Got alive #{advertisement}"
|
80
|
+
|
81
|
+
if @devices.any? { |d| d.usn == advertisement[:usn] }
|
82
|
+
log "Device with USN #{advertisement[:usn]} already exists."
|
83
|
+
else
|
84
|
+
log "Device with USN #{advertisement[:usn]} not found. Creating..."
|
85
|
+
create_device(advertisement)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
listener.byebye_notifications.subscribe do |advertisement|
|
90
|
+
log "Got bye-bye from #{advertisement}"
|
91
|
+
|
92
|
+
@devices.reject! do |device|
|
93
|
+
device.usn == advertisement[:usn]
|
94
|
+
end
|
95
|
+
|
96
|
+
@old_device_channel << advertisement
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def ssdp_search_and_listen(search_for, options = {})
|
102
|
+
searcher = SSDP.search(search_for, options)
|
103
|
+
|
104
|
+
searcher.discovery_responses.subscribe do |notification|
|
105
|
+
create_device(notification)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Do I need to do this?
|
109
|
+
EM.add_timer(options[:response_wait_time]) do
|
110
|
+
searcher.close_connection
|
111
|
+
listen(options[:ttl])
|
112
|
+
end
|
113
|
+
|
114
|
+
EM.add_periodic_timer(5) do
|
115
|
+
log "Time since last timer: #{Time.now - @timer_time}" if @timer_time
|
116
|
+
log "Connections: #{EM.connection_count}"
|
117
|
+
@timer_time = Time.now
|
118
|
+
puts "<#{self.class}> Device count: #{@devices.size}"
|
119
|
+
puts "<#{self.class}> Device unique: #{@devices.uniq.size}"
|
120
|
+
end
|
121
|
+
|
122
|
+
trap_signals
|
123
|
+
end
|
124
|
+
|
125
|
+
def create_device(notification)
|
126
|
+
deferred_device = Device.new(ssdp_notification: notification)
|
127
|
+
|
128
|
+
deferred_device.errback do |partially_built_device, message|
|
129
|
+
log message
|
130
|
+
#add_device(partially_built_device)
|
131
|
+
|
132
|
+
if self.class.raise_on_remote_error
|
133
|
+
raise ControlPoint::Error, message
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
deferred_device.callback do |built_device|
|
138
|
+
log "Device created from #{notification}"
|
139
|
+
add_device(built_device)
|
140
|
+
end
|
141
|
+
|
142
|
+
deferred_device.fetch
|
143
|
+
end
|
144
|
+
|
145
|
+
def add_device(built_device)
|
146
|
+
if (@devices.any? { |d| d.usn == built_device.usn }) ||
|
147
|
+
(@devices.any? { |d| d.udn == built_device.udn })
|
148
|
+
log 'Newly created device already exists in internal list. Not adding.'
|
149
|
+
#if @devices.any? { |d| d.usn == built_device.usn }
|
150
|
+
# log "Newly created device (#{built_device.usn}) already exists in internal list. Not adding."
|
151
|
+
else
|
152
|
+
log 'Adding newly created device to internal list..'
|
153
|
+
@new_device_channel << built_device
|
154
|
+
@devices << built_device
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def stop
|
159
|
+
@running = false
|
160
|
+
@stopping = false
|
161
|
+
|
162
|
+
EM.stop if EM.reactor_running?
|
163
|
+
end
|
164
|
+
|
165
|
+
def running?
|
166
|
+
@running
|
167
|
+
end
|
168
|
+
|
169
|
+
def trap_signals
|
170
|
+
trap('INT') { stop }
|
171
|
+
trap('TERM') { stop }
|
172
|
+
trap('HUP') { stop } if RUBY_PLATFORM !~ /mswin|mingw/
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'nori'
|
2
|
+
require 'em-http-request'
|
3
|
+
require_relative 'error'
|
4
|
+
require_relative '../logger'
|
5
|
+
require_relative '../../playful'
|
6
|
+
|
7
|
+
|
8
|
+
module Playful
|
9
|
+
class ControlPoint
|
10
|
+
class Base
|
11
|
+
include LogSwitch::Mixin
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
def get_description(location, description_getter)
|
16
|
+
log "Getting description with getter ID #{description_getter.object_id} for: #{location}"
|
17
|
+
http = EM::HttpRequest.new(location).aget
|
18
|
+
|
19
|
+
t = EM::Timer.new(30) do
|
20
|
+
http.fail(:timeout)
|
21
|
+
end
|
22
|
+
|
23
|
+
http.errback do |error|
|
24
|
+
if error == :timeout
|
25
|
+
log 'Timed out getting description. Retrying...'
|
26
|
+
http = EM::HttpRequest.new(location).get
|
27
|
+
else
|
28
|
+
log "Unable to retrieve DDF from #{location}", :error
|
29
|
+
log "Request error: #{http.error}"
|
30
|
+
log "Response status: #{http.response_header.status}"
|
31
|
+
|
32
|
+
description_getter.set_deferred_status(:failed)
|
33
|
+
|
34
|
+
if ControlPoint.raise_on_remote_error
|
35
|
+
raise ControlPoint::Error, "Unable to retrieve DDF from #{location}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
http.callback {
|
41
|
+
log "HTTP callback called for #{description_getter.object_id}"
|
42
|
+
response = xml_parser.parse(http.response)
|
43
|
+
description_getter.set_deferred_status(:succeeded, response)
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def build_url(url_base, rest_of_url)
|
48
|
+
if url_base.end_with?('/') && rest_of_url.start_with?('/')
|
49
|
+
rest_of_url.sub!('/', '')
|
50
|
+
end
|
51
|
+
|
52
|
+
url_base + rest_of_url
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return [Nori::Parser]
|
56
|
+
def xml_parser
|
57
|
+
@xml_parser if @xml_parser
|
58
|
+
|
59
|
+
options = {
|
60
|
+
convert_tags_to: lambda { |tag| tag.to_sym }
|
61
|
+
}
|
62
|
+
|
63
|
+
begin
|
64
|
+
require 'nokogiri'
|
65
|
+
options.merge! parser: :nokogiri
|
66
|
+
rescue LoadError
|
67
|
+
warn "Tried loading nokogiri for XML couldn't. This is OK, just letting you know."
|
68
|
+
end
|
69
|
+
|
70
|
+
@xml_parser = Nori.new(options)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|