Skip to content

Commit 078a74a

Browse files
committed
Add OAI-PMH Ingestors
1 parent 88676c1 commit 078a74a

3 files changed

Lines changed: 264 additions & 1 deletion

File tree

lib/ingestors/ingestor_factory.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ def self.ingestors
1010
Ingestors::MaterialCsvIngestor,
1111
Ingestors::TessEventIngestor,
1212
Ingestors::ZenodoIngestor,
13+
Ingestors::OaiPmhIngestor,
14+
Ingestors::OaiPmhBioschemasIngestor
1315
] + taxila_ingestors + llm_ingestors
1416
end
1517

@@ -35,7 +37,7 @@ def self.taxila_ingestors
3537
Ingestors::Taxila::OsciIngestor,
3638
Ingestors::Taxila::DccIngestor,
3739
Ingestors::Taxila::SenseIngestor,
38-
Ingestors::Taxila::VuMaterialIngestor,
40+
Ingestors::Taxila::VuMaterialIngestor
3941
]
4042
end
4143

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
require 'open-uri'
2+
require 'tess_rdf_extractors'
3+
4+
module Ingestors
5+
class OaiPmhBioschemasIngestor < Ingestor
6+
DUMMY_URL = 'https://example.com'
7+
8+
attr_reader :verbose
9+
10+
def self.config
11+
{
12+
key: 'oai_pmh_bioschemas',
13+
title: 'OAI-PMH (Bioschemas RDF)',
14+
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0',
15+
mail: Rails.configuration.tess['contact_email']
16+
}
17+
end
18+
19+
def read(source_url)
20+
provider_events = []
21+
provider_materials = []
22+
totals = Hash.new(0)
23+
24+
client = OAI::Client.new source_url, headers: { 'From' => config[:mail] }
25+
client.list_records(metadata_prefix: 'rdf').full.each do |record|
26+
metadata_tag = Nokogiri::XML(record.metadata.to_s)
27+
bioschemas_xml = metadata_tag.at_xpath('metadata/rdf:RDF', 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#')&.to_s
28+
output = read_content(bioschemas_xml)
29+
next unless output
30+
31+
provider_events += output[:resources][:events]
32+
provider_materials += output[:resources][:materials]
33+
output[:totals].each do |key, value|
34+
totals[key] += value
35+
end
36+
end
37+
38+
if totals.keys.any?
39+
bioschemas_summary = "Bioschemas summary:\n"
40+
totals.each do |type, count|
41+
bioschemas_summary << "\n - #{type}: #{count}"
42+
end
43+
@messages << bioschemas_summary
44+
end
45+
46+
deduplicate(provider_events).each do |event_params|
47+
add_event(event_params)
48+
end
49+
50+
deduplicate(provider_materials).each do |material_params|
51+
add_material(material_params)
52+
end
53+
end
54+
55+
def read_content(content)
56+
output = {
57+
resources: {
58+
events: [],
59+
materials: []
60+
},
61+
totals: Hash.new(0)
62+
}
63+
64+
return output unless content
65+
66+
begin
67+
events = Tess::Rdf::EventExtractor.new(content, :rdfxml).extract do |p|
68+
convert_params(p)
69+
end
70+
courses = Tess::Rdf::CourseExtractor.new(content, :rdfxml).extract do |p|
71+
convert_params(p)
72+
end
73+
course_instances = Tess::Rdf::CourseInstanceExtractor.new(content, :rdfxml).extract do |p|
74+
convert_params(p)
75+
end
76+
learning_resources = Tess::Rdf::LearningResourceExtractor.new(content, :rdfxml).extract do |p|
77+
convert_params(p)
78+
end
79+
output[:totals]['Events'] += events.count
80+
output[:totals]['Courses'] += courses.count
81+
output[:totals]['CourseInstances'] += course_instances.count
82+
output[:totals]['LearningResources'] += learning_resources.count
83+
84+
deduplicate(events + courses + course_instances).each do |event|
85+
output[:resources][:events] << event
86+
end
87+
88+
deduplicate(learning_resources).each do |material|
89+
output[:resources][:materials] << material
90+
end
91+
rescue StandardError => e
92+
Rails.logger.error("#{e.class}: #{e.message}")
93+
Rails.logger.error(e.backtrace.join("\n")) if e.backtrace&.any?
94+
error = 'An error'
95+
comment = nil
96+
if e.is_a?(RDF::ReaderError)
97+
error = 'A parsing error'
98+
comment = 'Please check your page contains valid RDF/XML.'
99+
end
100+
message = "#{error} occurred while reading the source."
101+
message << " #{comment}" if comment
102+
@messages << message
103+
end
104+
105+
output
106+
end
107+
108+
# ---- This is copied unchanged from bioschemas_ingestor.rb and needs to be refactored. ----
109+
110+
# If duplicate resources have been extracted, prefer ones with the most metadata.
111+
def deduplicate(resources)
112+
return [] unless resources.any?
113+
114+
puts "De-duplicating #{resources.count} resources" if verbose
115+
hash = {}
116+
scores = {}
117+
resources.each do |resource|
118+
resource_url = resource[:url]
119+
puts " Considering: #{resource_url}" if verbose
120+
if hash[resource_url]
121+
score = metadata_score(resource)
122+
# Replace the resource if this resource has a higher metadata score
123+
puts " Duplicate! Comparing #{score} vs. #{scores[resource_url]}" if verbose
124+
if score > scores[resource_url]
125+
puts ' Replacing resource' if verbose
126+
hash[resource_url] = resource
127+
scores[resource_url] = score
128+
end
129+
else
130+
puts ' Not present, adding' if verbose
131+
hash[resource_url] = resource
132+
scores[resource_url] = metadata_score(resource)
133+
end
134+
end
135+
136+
puts "#{hash.values.count} resources after de-duplication" if verbose
137+
138+
hash.values
139+
end
140+
141+
# Score based on number of metadata fields available
142+
def metadata_score(resource)
143+
score = 0
144+
resource.each_value do |value|
145+
score += 1 unless value.nil? || value == {} || value == [] || (value.is_a?(String) && value.strip == '')
146+
end
147+
148+
score
149+
end
150+
151+
def convert_params(params)
152+
params[:description] = convert_description(params[:description]) if params.key?(:description)
153+
154+
params
155+
end
156+
end
157+
end

lib/ingestors/oai_pmh_ingestor.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
require 'open-uri'
2+
require 'tess_rdf_extractors'
3+
require 'oai'
4+
require 'nokogiri'
5+
6+
module Ingestors
7+
class OaiPmhIngestor < Ingestor
8+
DUMMY_URL = 'https://example.com'
9+
10+
attr_reader :verbose
11+
12+
def self.config
13+
{
14+
key: 'oai_pmh',
15+
title: 'OAI-PMH',
16+
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0',
17+
mail: Rails.configuration.tess['contact_email']
18+
}
19+
end
20+
21+
def ns
22+
{
23+
'dc' => 'http://purl.org/dc/elements/1.1/',
24+
'oai_dc' => 'http://www.openarchives.org/OAI/2.0/oai_dc/'
25+
}
26+
end
27+
28+
def read(source_url)
29+
client = OAI::Client.new source_url, headers: { 'From' => config[:mail] }
30+
count = 0
31+
client.list_records.full.each do |record|
32+
read_dublin_core(record.metadata.to_s)
33+
count += 1
34+
end
35+
@messages << "found #{count} records"
36+
end
37+
38+
def read_dublin_core(xml_string)
39+
doc = Nokogiri::XML(xml_string)
40+
41+
types = doc.xpath('//dc:type', ns).map(&:text)
42+
if types.include?('http://purl.org/dc/dcmitype/Event')
43+
read_dublin_core_event(doc)
44+
else
45+
read_dublin_core_material(doc)
46+
end
47+
end
48+
49+
def read_dublin_core_material(xml_doc)
50+
material = OpenStruct.new
51+
material.title = xml_doc.at_xpath('//dc:title', ns)&.text
52+
material.description = convert_description(xml_doc.at_xpath('//dc:description', ns)&.text)
53+
material.authors = xml_doc.xpath('//dc:creator', ns).map(&:text)
54+
material.contributors = xml_doc.xpath('//dc:contributor', ns).map(&:text)
55+
material.licence = xml_doc.at_xpath('//dc:rights', ns)&.text
56+
57+
dates = xml_doc.xpath('//dc:date', ns).map(&:text)
58+
parsed_dates = dates.map do |d|
59+
Date.parse(d)
60+
rescue StandardError
61+
nil
62+
end.compact
63+
material.date_created = parsed_dates.first
64+
material.date_modified = parsed_dates.last if parsed_dates.size > 1
65+
66+
identifiers = xml_doc.xpath('//dc:identifier', ns).map(&:text)
67+
doi = identifiers.find { |id| id.start_with?('10.') || id.include?('doi.org') }
68+
if doi
69+
doi = doi&.sub(%r{https?://doi\.org/}, '')
70+
material.doi = "https://doi.org/#{doi}"
71+
end
72+
material.url = identifiers.find { |id| id.start_with?('http://', 'https://') }
73+
74+
material.keywords = xml_doc.xpath('//dc:subject', ns).map(&:text)
75+
material.resource_type = xml_doc.xpath('//dc:type', ns).map(&:text)
76+
material.contact = xml_doc.at_xpath('//dc:publisher', ns)&.text
77+
78+
add_material material
79+
end
80+
81+
def read_dublin_core_event(_xml_doc)
82+
event = OpenStruct.new
83+
84+
event.title = doc.at_xpath('//dc:title', ns)&.text
85+
event.description = convert_description(doc.at_xpath('//dc:description', ns)&.text)
86+
event.url = doc.xpath('//dc:identifier', ns).map(&:text).find { |id| id.start_with?('http://', 'https://') }
87+
event.contact = doc.at_xpath('//dc:publisher', ns)&.text
88+
event.organizer = doc.at_xpath('//dc:creator', ns)&.text
89+
event.keywords = doc.xpath('//dc:subject', ns).map(&:text)
90+
event.event_types = types
91+
92+
dates = doc.xpath('//dc:date', ns).map(&:text)
93+
parsed_dates = dates.map do |d|
94+
Date.parse(d)
95+
rescue StandardError
96+
nil
97+
end.compact
98+
event.start = parsed_dates.first
99+
event.end = parsed_dates.last
100+
101+
add_event event
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)