abrupt 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rubocop.yml +16 -0
- data/.travis.yml +34 -0
- data/Gemfile +4 -0
- data/Guardfile +51 -0
- data/LICENSE.txt +22 -0
- data/README.md +36 -0
- data/Rakefile +7 -0
- data/abrupt.gemspec +41 -0
- data/assets/rules/datatypes/cax-RequiredFormElement.ttl +34 -0
- data/assets/rules/datatypes/cax-readability.ttl +18 -0
- data/assets/rules/datatypes/cax-required.ttl +15 -0
- data/assets/rules/list/prp-hasState.ttl +10 -0
- data/assets/rules/production/non_required_form_element.ttl +24 -0
- data/assets/rules/production/state_has_no_html_element.ttl +21 -0
- data/assets/schema/schema.json +49 -0
- data/assets/schema/v1/complexity.json +142 -0
- data/assets/schema/v1/input.json +1136 -0
- data/assets/schema/v1/link.json +41 -0
- data/assets/schema/v1/picture.json +47 -0
- data/assets/schema/v1/readability.json +51 -0
- data/assets/schema/v1/subject.json +88 -0
- data/assets/voc/tbox.ttl +1632 -0
- data/bin/abrupt +63 -0
- data/doc/paper/listings/datatype_rule.ttl +0 -0
- data/doc/paper/listings/description_logic_infered.ttl +3 -0
- data/doc/paper/listings/description_logic_rule.ttl +15 -0
- data/doc/paper/listings/inconsistency_rule.ttl +0 -0
- data/doc/paper/listings/limitations.ttl +10 -0
- data/doc/paper/listings/production_rule.ttl +0 -0
- data/doc/paper/listings/propositional_logic_infered.ttl +6 -0
- data/doc/paper/listings/propositional_logic_rule.ttl +15 -0
- data/doc/paper/listings/unique_nested_uris.ttl +10 -0
- data/doc/paper/literature.bib +56 -0
- data/doc/paper/main.tex +322 -0
- data/doc/poster/Poster.key +0 -0
- data/doc/poster/Poster.pdf +0 -0
- data/doc/poster/poster.indd +0 -0
- data/doc/poster/resources/graph.graffle +0 -0
- data/doc/poster/resources/graph.png +0 -0
- data/doc/poster/resources/graph_crop.png +0 -0
- data/lib/abrupt.rb +90 -0
- data/lib/abrupt/converter.rb +130 -0
- data/lib/abrupt/crawler.rb +125 -0
- data/lib/abrupt/service/absolute_url.rb +32 -0
- data/lib/abrupt/service/base.rb +75 -0
- data/lib/abrupt/service/complexity.rb +27 -0
- data/lib/abrupt/service/input.rb +15 -0
- data/lib/abrupt/service/link.rb +15 -0
- data/lib/abrupt/service/picture.rb +19 -0
- data/lib/abrupt/service/readability.rb +26 -0
- data/lib/abrupt/service/subject.rb +19 -0
- data/lib/abrupt/transformation/base.rb +145 -0
- data/lib/abrupt/transformation/client/base.rb +8 -0
- data/lib/abrupt/transformation/client/page_view.rb +27 -0
- data/lib/abrupt/transformation/client/visit.rb +56 -0
- data/lib/abrupt/transformation/client/visitor.rb +19 -0
- data/lib/abrupt/transformation/website/base.rb +8 -0
- data/lib/abrupt/transformation/website/complexity.rb +20 -0
- data/lib/abrupt/transformation/website/input.rb +42 -0
- data/lib/abrupt/transformation/website/link.rb +27 -0
- data/lib/abrupt/transformation/website/picture.rb +26 -0
- data/lib/abrupt/transformation/website/readability.rb +15 -0
- data/lib/abrupt/transformation/website/subject.rb +22 -0
- data/lib/abrupt/version.rb +7 -0
- data/spec/cassettes/Abrupt_Crawler/outputs_correct_hash.yml +91250 -0
- data/spec/converter_spec.rb +34 -0
- data/spec/crawler_spec.rb +11 -0
- data/spec/factories/crawled_hashes.rb +468 -0
- data/spec/fixtures/rikscha-mainz.owl +17456 -0
- data/spec/fixtures/rikscha.ohneBilder.2013-04-30_2013-08-17.xml +51759 -0
- data/spec/fixtures/rikscha.ohneBilder.2013-04-30_2013-08-17_min.xml +81 -0
- data/spec/fixtures/rikscha_Result.xml +11594 -0
- data/spec/fixtures/rikscha_Result_min.xml +574 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/transformation/base_spec.rb +18 -0
- data/spec/transformation/website/complexity_spec.rb +188 -0
- data/spec/transformation/website/input_spec.rb +181 -0
- data/spec/transformation/website/link_spec.rb +13 -0
- data/spec/transformation/website/picture_spec.rb +20 -0
- data/spec/transformation/website/readability_spec.rb +22 -0
- data/spec/transformation/website/subject_spec.rb +40 -0
- metadata +424 -0
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
data/lib/abrupt.rb
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# @author Manuel Dudda
|
2
|
+
Dir[File.dirname(__FILE__) + '/abrupt/*.rb'].each do |file|
|
3
|
+
require file
|
4
|
+
end
|
5
|
+
require 'pp'
|
6
|
+
|
7
|
+
# Extension for String class
|
8
|
+
class String
|
9
|
+
def remove_last_slashes
|
10
|
+
gsub(/([\/]*)$/, '')
|
11
|
+
end
|
12
|
+
|
13
|
+
def append_last_slash
|
14
|
+
gsub(/([^\/])$/, '\1/')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Extension for all objects
|
19
|
+
class Object
|
20
|
+
def ensure_to_a
|
21
|
+
[self].flatten.compact
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# This module is cool
|
26
|
+
# @abstract
|
27
|
+
module Abrupt
|
28
|
+
VOC = RDF::Vocabulary.new('http://wba.cs.hs-rm.de/AbRUPt/')
|
29
|
+
VOC_FILE = File.join File.dirname(__dir__), 'assets', 'voc', 'tbox.ttl'
|
30
|
+
RULES_DIR = File.join File.dirname(__dir__), 'assets', 'rules', '*'
|
31
|
+
DELIMITER = '/'
|
32
|
+
PREFIXES = {
|
33
|
+
abrupt: VOC.to_s,
|
34
|
+
rdf: RDF.to_s,
|
35
|
+
rdfs: RDF::RDFS.to_s,
|
36
|
+
xsd: RDF::XSD.to_s,
|
37
|
+
owl: RDF::OWL.to_s
|
38
|
+
}
|
39
|
+
|
40
|
+
TIME_INPUT_FORMAT = '%d/%b/%Y:%H:%M:%S'
|
41
|
+
TIME_OUTPUT_FORMAT = '%Y-%m-%d_%H%M%S'
|
42
|
+
|
43
|
+
def self.parse_time(time)
|
44
|
+
DateTime.strptime(time, TIME_INPUT_FORMAT)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.format_time(time)
|
48
|
+
parse_time(time).strftime(TIME_OUTPUT_FORMAT)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.root
|
52
|
+
File.dirname __dir__
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.log(msg)
|
56
|
+
print msg
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.crawl(uri, *args)
|
60
|
+
opts = args.first
|
61
|
+
crawler = Abrupt::Crawler.new uri, opts
|
62
|
+
start_time = Time.now
|
63
|
+
log "begin: #{start_time}\n"
|
64
|
+
result = crawler.crawl
|
65
|
+
end_time = Time.now
|
66
|
+
log "\nfinished in #{(end_time - start_time).round} sec.\n\n"
|
67
|
+
case opts[:format]
|
68
|
+
when 'xml'
|
69
|
+
puts Converter.xml(result)
|
70
|
+
else # owl as default
|
71
|
+
puts Converter.owl(result)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.convert(file, *args)
|
76
|
+
converter = Converter.instance
|
77
|
+
assertions = args.last[:assertions].split ','
|
78
|
+
converter.init(args[1]) # options
|
79
|
+
append file, args.first, assertions
|
80
|
+
converter.result
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.append(file, user_file, assertions)
|
84
|
+
converter = Converter.instance
|
85
|
+
converter.append_tbox if assertions.include?('tbox')
|
86
|
+
converter.append_website_data(file) if assertions.include?('website')
|
87
|
+
converter.append_user_data(user_file) if assertions.include?('user')
|
88
|
+
converter.append_rules if assertions.include?('rules')
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# @author Manuel Dudda
|
2
|
+
require 'singleton'
|
3
|
+
require 'rest_client'
|
4
|
+
require 'gyoku'
|
5
|
+
require 'rdf'
|
6
|
+
require 'linkeddata'
|
7
|
+
require 'active_support'
|
8
|
+
require 'active_support/core_ext'
|
9
|
+
Dir[File.dirname(__FILE__) + '/transformation/*.rb',
|
10
|
+
File.dirname(__FILE__) + '/transformation/website/*.rb',
|
11
|
+
File.dirname(__FILE__) + '/transformation/client/*.rb'].each do |file|
|
12
|
+
require file
|
13
|
+
end
|
14
|
+
|
15
|
+
# Abrupt Converter
|
16
|
+
module Abrupt
|
17
|
+
# Converter
|
18
|
+
class Converter
|
19
|
+
include Singleton
|
20
|
+
include RDF
|
21
|
+
attr_accessor :hsh, :values, :result, :format, :uri
|
22
|
+
|
23
|
+
def init(options = {})
|
24
|
+
@format = options[:format].try(:to_sym) || :turtle
|
25
|
+
@result = Repository.new
|
26
|
+
end
|
27
|
+
|
28
|
+
def append_tbox
|
29
|
+
@result << Repository.load(VOC_FILE)
|
30
|
+
end
|
31
|
+
|
32
|
+
def append_website_data(hsh)
|
33
|
+
init_hsh(hsh)
|
34
|
+
@uri = URI(@hsh[:website][:domain])
|
35
|
+
init_website
|
36
|
+
perform
|
37
|
+
end
|
38
|
+
|
39
|
+
def init_website
|
40
|
+
domain = RDF::URI("#{VOC}Website/#{@uri}")
|
41
|
+
@result << Statement.new(domain, RDF.type, VOC.Website)
|
42
|
+
@result << Statement.new(domain, VOC.hostName, @uri.host)
|
43
|
+
end
|
44
|
+
|
45
|
+
def init_hsh(hsh)
|
46
|
+
hsh = Hash.from_xml(File.read(hsh)) unless hsh.is_a?(Hash)
|
47
|
+
@hsh = hsh.deep_symbolize_keys
|
48
|
+
return unless @hsh[:website]
|
49
|
+
@hsh[:website][:url].each_with_index do |value, i|
|
50
|
+
Transformation::Website::Base.subclasses.each do |transformation_class|
|
51
|
+
@hsh[:website][:url][i] =
|
52
|
+
transformation_class.customize_to_schema(value)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.xml(hsh)
|
58
|
+
Gyoku.xml hsh
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.json(hsh)
|
62
|
+
hsh.to_json
|
63
|
+
end
|
64
|
+
|
65
|
+
def add_to_result(statements)
|
66
|
+
statements.each { |stmt| @result << stmt }
|
67
|
+
end
|
68
|
+
|
69
|
+
def perform
|
70
|
+
website = ['Website', @uri.to_s]
|
71
|
+
@hsh[:website][:url].each do |url|
|
72
|
+
page = ['Page', url[:name].append_last_slash]
|
73
|
+
page_transformator = Transformation::Base.new(website, page)
|
74
|
+
add_to_result page_transformator.add_individuals # add Page
|
75
|
+
next unless url[:state]
|
76
|
+
perform_states url[:state], website + page
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def perform_states(states, parent_uri)
|
81
|
+
states = states.is_a?(Array) ? states : [states]
|
82
|
+
states.each do |value|
|
83
|
+
state = ['State', value[:name]]
|
84
|
+
# MAYBE empty?
|
85
|
+
add_to_result Transformation::Base.new(parent_uri, state).result
|
86
|
+
Transformation::Website::Base.subclasses.each do |transformation_class|
|
87
|
+
t = transformation_class.new(parent_uri + state, nil, value)
|
88
|
+
add_to_result t.add_individuals
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def append_user_data(file)
|
94
|
+
return unless file.is_a?(String) && File.exist?(file)
|
95
|
+
xml = Hash.from_xml(File.read(file)).deep_symbolize_keys
|
96
|
+
xml[:database][:visitor].ensure_to_a.each do |values|
|
97
|
+
ip = values[:ip]
|
98
|
+
next unless ip
|
99
|
+
visitor = Transformation::Client::Visitor.new(
|
100
|
+
['Website', @uri.to_s], ['Visitor', ip], values)
|
101
|
+
add_to_result visitor.add_individuals
|
102
|
+
append_pages_for_visitor(visitor)
|
103
|
+
end
|
104
|
+
@result
|
105
|
+
end
|
106
|
+
|
107
|
+
def append_pages_for_visitor(visitor)
|
108
|
+
pages = visitor.values[:pages][:page].ensure_to_a
|
109
|
+
pages.each do |page|
|
110
|
+
time = ::Abrupt.format_time(page[:entertime])
|
111
|
+
Transformation::Client::Base.subclasses.each do |transformation_class|
|
112
|
+
transformator = transformation_class.new(
|
113
|
+
visitor.parent_uri + visitor.uri,
|
114
|
+
['Visit', time], page
|
115
|
+
)
|
116
|
+
add_to_result transformator.add_individuals
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def append_rules
|
122
|
+
Dir.glob(RULES_DIR).each do |rule_directory|
|
123
|
+
Dir.glob(File.join(rule_directory, '*')).each do |rule_file|
|
124
|
+
rule = Repository.load(rule_file)
|
125
|
+
add_to_result(rule.statements)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# @author Manuel Dudda
|
2
|
+
require 'rest_client'
|
3
|
+
require 'addressable/uri'
|
4
|
+
%w( base
|
5
|
+
readability
|
6
|
+
subject
|
7
|
+
input
|
8
|
+
complexity
|
9
|
+
picture
|
10
|
+
link
|
11
|
+
absolute_url).each do |f|
|
12
|
+
require_relative "service/#{f}"
|
13
|
+
end
|
14
|
+
module Abrupt
|
15
|
+
# Crawler for a website including all followed urls
|
16
|
+
# with performing abrupt services
|
17
|
+
# BETA!!!
|
18
|
+
class Crawler
|
19
|
+
SERVICE_MAPPING = {
|
20
|
+
r: Service::Readability,
|
21
|
+
i: Service::Input,
|
22
|
+
s: Service::Subject,
|
23
|
+
c: Service::Complexity,
|
24
|
+
l: Service::Link,
|
25
|
+
p: Service::Picture
|
26
|
+
}
|
27
|
+
|
28
|
+
def initialize(uri, *args)
|
29
|
+
@uri = Addressable::URI.parse(uri).normalize
|
30
|
+
opts = args.first
|
31
|
+
@options = {
|
32
|
+
lang: 'en',
|
33
|
+
services: %w(r i s c l p),
|
34
|
+
depth: '3',
|
35
|
+
word_limit: 20
|
36
|
+
}
|
37
|
+
@options[:services] = opts[:services] if opts[:services]
|
38
|
+
@options[:lang] = opts[:lang] if opts[:lang]
|
39
|
+
@follow_links = !opts[:nofollow]
|
40
|
+
@result = {}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Crawls a page, saves the service results in result hash
|
44
|
+
# and returns an array with the existing uris of this page.
|
45
|
+
#
|
46
|
+
# @param uri [String] the uri to crawl
|
47
|
+
# @return [JSON] result
|
48
|
+
def crawl(uri = nil)
|
49
|
+
Abrupt.log '.'
|
50
|
+
uri ||= @uri.to_str.append_last_slash
|
51
|
+
unless @result[uri]
|
52
|
+
html = fetch_html(uri)
|
53
|
+
@result[uri] ||= {}
|
54
|
+
@result[uri] = perform_services(html) if html
|
55
|
+
# new_uris.select! { |url| same_host?(url) } # filter
|
56
|
+
uris_with_same_host(uri).uniq.each { |url| crawl(url) } if @follow_links
|
57
|
+
end
|
58
|
+
Service::Base.transform_hash(@result)
|
59
|
+
end
|
60
|
+
|
61
|
+
# TODO: maybe as class method
|
62
|
+
def uris_with_same_host(uri)
|
63
|
+
if @result[uri][:link] && @result[uri][:link]['a']
|
64
|
+
@result[uri][:link]['a'].to_a.map do |link|
|
65
|
+
link['href'] if same_host?(link['href'])
|
66
|
+
end.compact
|
67
|
+
else
|
68
|
+
[]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def fetch_html(uri)
|
73
|
+
uri = Addressable::URI.parse(uri.strip).normalize.to_str
|
74
|
+
begin
|
75
|
+
response = ::RestClient.get uri, accept: :html
|
76
|
+
content_type = response.headers[:content_type].to_s
|
77
|
+
case response.code
|
78
|
+
when 200...400
|
79
|
+
response.to_str if html?(content_type)
|
80
|
+
else
|
81
|
+
false
|
82
|
+
end
|
83
|
+
rescue => e
|
84
|
+
puts "error fetching html on #{uri}"
|
85
|
+
puts e
|
86
|
+
nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def html?(content_type)
|
91
|
+
content_type.start_with?('text/html')
|
92
|
+
end
|
93
|
+
|
94
|
+
def same_host?(uri)
|
95
|
+
!uri.to_s.empty? && Addressable::URI.parse(uri).host.eql?(@uri.host)
|
96
|
+
end
|
97
|
+
|
98
|
+
def init_services_hash(html)
|
99
|
+
@options[:services].map do |s|
|
100
|
+
s = s.to_sym
|
101
|
+
service_class = SERVICE_MAPPING[s]
|
102
|
+
available_options = service_class.available_options
|
103
|
+
opts = available_options.map { |o| [o, @options[o.to_sym]] }.to_h
|
104
|
+
service = service_class.new(html, opts)
|
105
|
+
[service_class.keyname, service]
|
106
|
+
end.to_h
|
107
|
+
end
|
108
|
+
|
109
|
+
def canonize_html(html)
|
110
|
+
baseurl = "#{@uri.scheme}://#{@uri.host}"
|
111
|
+
converter = Service::AbsoluteUrl.new(html, baseurl: baseurl)
|
112
|
+
converter.execute
|
113
|
+
end
|
114
|
+
|
115
|
+
def perform_services(html)
|
116
|
+
result = {}
|
117
|
+
html = canonize_html(html)
|
118
|
+
services_hash = init_services_hash(html)
|
119
|
+
services_hash.each do |json_field, service_class|
|
120
|
+
result[json_field.to_sym] = service_class.execute
|
121
|
+
end
|
122
|
+
result
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# @author Manuel Dudda
|
2
|
+
module Abrupt
|
3
|
+
module Service
|
4
|
+
# Complexity service
|
5
|
+
# documentation see 'http://wba.cs.hs-rm.de/AbRUPt/service/absoluteurl/'
|
6
|
+
class AbsoluteUrl < Base
|
7
|
+
# TODO: outsource service uri to module Service
|
8
|
+
SERVICE_URI = 'http://wba.cs.hs-rm.de/AbRUPt/service/absoluteurl/'
|
9
|
+
|
10
|
+
def service_uri
|
11
|
+
SERVICE_URI
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute
|
15
|
+
options = {
|
16
|
+
method: :post,
|
17
|
+
timeout: 6000,
|
18
|
+
open_timeout: 6000,
|
19
|
+
accept: :html
|
20
|
+
}
|
21
|
+
options.merge!(url: @url, payload: @html)
|
22
|
+
begin
|
23
|
+
RestClient::Request.execute(options).to_str
|
24
|
+
rescue => e
|
25
|
+
puts "some problems with #{@url}"
|
26
|
+
puts e
|
27
|
+
nil
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# @author Manuel Dudda
|
2
|
+
require 'rest_client'
|
3
|
+
module Abrupt
|
4
|
+
module Service
|
5
|
+
# base class
|
6
|
+
class Base
|
7
|
+
attr_accessor :url, :abbr, :options, :response
|
8
|
+
# TODO: outsource service uri to module Service
|
9
|
+
SERVICE_URI = 'http://wba.cs.hs-rm.de/AbRUPt/service/complexity/public/index.php/api/v1/complexity'
|
10
|
+
|
11
|
+
def service_uri
|
12
|
+
SERVICE_URI
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(html, options = {})
|
16
|
+
@html = html
|
17
|
+
@options = options
|
18
|
+
query_params = if @options.count > 0
|
19
|
+
options_arr = @options.map { |k, v| "#{k}=#{v}" }
|
20
|
+
'?' + options_arr.reduce { |a, e| "#{a}&#{e}" }
|
21
|
+
else
|
22
|
+
''
|
23
|
+
end
|
24
|
+
@url = service_uri + query_params
|
25
|
+
@abbr = self.class.name[0].downcase
|
26
|
+
@options = []
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.available_options
|
30
|
+
[]
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.keyname
|
34
|
+
name.split('::').last.downcase
|
35
|
+
end
|
36
|
+
|
37
|
+
# TODO: naming of interface execute
|
38
|
+
def execute
|
39
|
+
options = {
|
40
|
+
method: :post,
|
41
|
+
timeout: 6000,
|
42
|
+
open_timeout: 6000,
|
43
|
+
accept: :schema
|
44
|
+
}
|
45
|
+
options.merge!(url: @url, payload: @html)
|
46
|
+
begin
|
47
|
+
res = RestClient::Request.execute(options).to_str
|
48
|
+
@response = JSON.parse(res)
|
49
|
+
rescue => e
|
50
|
+
puts "some problems with #{@url}"
|
51
|
+
puts e
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.transform_hash(hsh)
|
57
|
+
uri = Addressable::URI.parse(hsh.keys.first).normalize
|
58
|
+
result = {
|
59
|
+
website: {
|
60
|
+
domain: "#{uri.scheme}://#{uri.host}",
|
61
|
+
url: []
|
62
|
+
}
|
63
|
+
}
|
64
|
+
hsh.each_with_index do |(key, value), _index|
|
65
|
+
page = {
|
66
|
+
name: key,
|
67
|
+
state: value
|
68
|
+
}
|
69
|
+
result[:website][:url] << page
|
70
|
+
end
|
71
|
+
result.deep_symbolize_keys
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|