Skip to content

Commit ec6bbe3

Browse files
committed
feat(github-ingestor): source can be a sitemap of|or github.{io|com}
1 parent 402dce3 commit ec6bbe3

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

lib/ingestors/github_ingestor.rb

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
require 'open-uri'
2+
require 'json'
3+
require 'redis'
4+
require 'httparty'
5+
require 'nokogiri'
6+
7+
module Ingestors
8+
# GithubIngestor fetches repository information from GitHub to populate the materials' metadata.
9+
# API requests counter:
10+
# 1. Get the repo general metadata (json) from #{GITHUB_API_BASE}/#{full_name}
11+
# 2. Get the doi using '#{GITHUB_API_BASE}/#{full_name}/contents/README.md'
12+
# 3. Get the version using '#{GITHUB_API_BASE}/#{full_name}/releases'
13+
# 4. Get the list of contributors using '#{GITHUB_API_BASE}/#{full_name}/contributors'
14+
class GithubIngestor < Ingestor
15+
GITHUB_API_BASE = 'https://api.github.com/repos'.freeze
16+
GITHUB_COM_BASE = 'https://github.com'.freeze
17+
REDIS = Redis.new(url: TeSS::Config.redis_url)
18+
19+
def self.config
20+
{
21+
key: 'github',
22+
title: 'GitHub API',
23+
category: :materials,
24+
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0'
25+
}
26+
end
27+
28+
# Reads from direct GitHub URLs, .xml sitemaps, and .txt sitemaps.
29+
# Fetches repository metadata, contributors, releases, and DOIs (from CITATION.cff).
30+
# It handles automatically GitHub Pages URLs (github.io) and standard github.com URLs.
31+
# It caches API requests to avoid repeated calls.
32+
def read(source_url)
33+
@verbose = false
34+
sitemap_regex = /(github\.com|github\.io)/i
35+
# Returns either a map of unique URL entries, either the URL itself
36+
sources = if source_url.downcase.match?(/sitemap(.*)?.xml\Z/)
37+
urls = SitemapParser.new(source_url, {
38+
recurse: true,
39+
url_regex: sitemap_regex,
40+
headers: { 'User-Agent' => config[:user_agent] }
41+
}).to_a.uniq.map(&:strip)
42+
@messages << "Parsing .xml sitemap: #{source_url}\n - #{urls.count} URLs found"
43+
urls
44+
elsif source_url.downcase.match?(/sitemap(.*)?.txt\Z/)
45+
urls = open_url(source_url).to_a.uniq.map(&:strip)
46+
@messages << "Parsing .txt sitemap: #{source_url}\n - #{urls.count} URLs found"
47+
urls
48+
else
49+
[source_url]
50+
end
51+
52+
sources.each do |url|
53+
# Reads each source, if github.{com|io}, gets the repo's api, if not, next
54+
repo_api_url = to_github_api(url)
55+
next unless repo_api_url
56+
57+
# Gets the cached repo data or reads and sets it
58+
repo_data = cache_or_set(repo_api_url.gsub(%r{https?://}, '').gsub('/', '_')) do
59+
content = open_url(repo_api_url)
60+
content ? JSON.parse(content.read) : nil
61+
end
62+
next unless repo_data
63+
64+
# From the data, pass it to materials
65+
to_material(repo_data)
66+
rescue StandardError => e
67+
@messages << "#{self.class.name} failed for #{url}, #{e.message}"
68+
end
69+
end
70+
71+
# private
72+
73+
# Fetch cached data or opens webpage/api and cache it
74+
# I chose to cache because GitHub limmits up to 60 requests per hour for unauthenticated user
75+
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users
76+
# key: string key for the cache
77+
# ttl: time-to-live in seconds (default 30 days)
78+
def cache_or_set(key, ttl: 30 * 24 * 60 * 60)
79+
Rails.logger.info "[Github Cache] GET cache #{key}"
80+
cached = REDIS.get(key)
81+
return JSON.parse(cached) if cached
82+
83+
data = yield
84+
Rails.logger.info "[GithubCache] SET cache #{key}\nData: #{data.to_json}"
85+
REDIS.set(key, data.to_json, ex: ttl) if data
86+
data
87+
rescue StandardError => e
88+
warn "Cache fetch error: #{e.message}"
89+
yield
90+
end
91+
92+
def to_github_api(url)
93+
uri = URI(url)
94+
return nil unless uri.host =~ /github\.com|github\.io/i
95+
96+
if uri.host.end_with?('github.io')
97+
parts = uri.path.split('/')
98+
repo = parts[1]
99+
owner = uri.host.split('.').first
100+
github_api = "#{GITHUB_API_BASE}/#{owner}/#{repo}"
101+
return github_api
102+
elsif uri.host.end_with?('github.com')
103+
parts = uri.path.split('/')
104+
github_api = "#{GITHUB_API_BASE}/#{parts[1]}/#{parts[2]}"
105+
return github_api
106+
end
107+
nil
108+
end
109+
110+
# Sets material hash keys and values and add them to material
111+
def to_material(repo_data)
112+
homepage_url = get_redirected_url(repo_data['homepage'])
113+
response = HTTParty.get(homepage_url, follow_redirects: true, headers: { 'User-Agent' => config[:user_agent] })
114+
doc = Nokogiri::HTML(response.body)
115+
116+
material = OpenStruct.new
117+
material.title = "GitHub #{repo_data['homepage'].to_s.strip.empty? ? 'repo' : 'page'}: #{repo_data['name'].titleize} (#{repo_data['owner']['login']})"
118+
material.url = repo_data['homepage'].to_s.strip.empty? ? repo_data['html_url'] : homepage_url
119+
material.description = repo_data['homepage'].to_s.strip.empty? ? repo_data['description'] : fetch_definition(doc, homepage_url)
120+
material.keywords = repo_data['topics']
121+
material.licence = fetch_licence(repo_data['license'])
122+
material.status = repo_data['archived'] ? 'Archived' : 'Active'
123+
124+
material.doi = fetch_doi(repo_data['full_name'])
125+
material.version = fetch_latest_release(repo_data['full_name'])
126+
material.date_created = repo_data['created_at']
127+
material.date_published = repo_data['pushed_at']
128+
material.date_modified = repo_data['updated_at']
129+
material.contributors = fetch_contributors(repo_data['contributors_url'])
130+
material.resource_type = 'GitHub repository'
131+
material.prerequisites = fetch_prerequisites(doc)
132+
133+
add_material material
134+
end
135+
136+
# URL – Some github homepages automatically redirects the user to another webpage
137+
# This method will get the last redirected URL (as shown by a 30X response or a `meta[http-equiv="Refresh"]` tag)
138+
def get_redirected_url(url, limit = 5)
139+
raise 'Too many redirects' if limit.zero?
140+
141+
response = HTTParty.get(url, follow_redirects: true, headers: { 'User-Agent' => config[:user_agent] })
142+
return url unless response.headers['content-type']&.include?('html')
143+
144+
doc = Nokogiri::HTML(response.body)
145+
meta = doc.at('meta[http-equiv="Refresh"]')
146+
if meta && meta.to_s =~ /url=(.+)/i
147+
content = meta['content']
148+
relative_path = content[/url=(.+)/i, 1]
149+
base = url.end_with?('/') ? url : "#{url}/"
150+
escaped_path = URI::DEFAULT_PARSER.escape(relative_path).to_s
151+
new_url = "#{base}#{escaped_path}"
152+
return get_redirected_url(new_url, limit - 1)
153+
end
154+
url
155+
end
156+
157+
# DEFINITION – Opens the GitHub homepage, fetches the 3 first >25 char <p> tags'text
158+
# and joins them with a 'Read more...' link at the end of the description
159+
# Some of the first <p> tags were not descriptive, thus skipping them
160+
def fetch_definition(doc, url)
161+
desc = ''
162+
round = 3
163+
doc.css('p').each do |p|
164+
p_txt = p&.text&.strip&.gsub(/\s+/, ' ')
165+
next if (p_txt.length < 25) || round.zero?
166+
167+
desc = "#{desc}#{p_txt}
168+
"
169+
round -= 1
170+
end
171+
"#{desc}(...) [Read more...](#{url})"
172+
end
173+
174+
# LICENCE – Get proper licence
175+
# the licence must match the format of config/dictionaries/licences.yml
176+
def fetch_licence(licence)
177+
return 'notspecified' if licence.nil? || licence == 'null'
178+
return 'other-at' if licence['key'] == 'other'
179+
180+
licence['key'].upcase_first
181+
end
182+
183+
# DOI – Fetches DOI from various sources in a repo
184+
# I chose to only read the `README.md` as it seems to have the DOI badge almost everytime.
185+
# Whereas enabling the fetching of CITATION.cff or CITATION.md would result in increasing
186+
# the number of api request.
187+
def fetch_doi(full_name)
188+
doi = fetch_doi_from_file(full_name, 'README.md')
189+
return doi if doi
190+
191+
# doi = fetch_doi_from_file(full_name, 'CITATION.cff')
192+
# return doi if doi
193+
194+
# doi = fetch_doi_from_file(full_name, 'CITATION.md')
195+
# return doi if doi
196+
rescue StandardError
197+
nil
198+
end
199+
200+
# DOI – Fetches DOI from a specific file in repo (via GitHub API)
201+
def fetch_doi_from_file(full_name, filename)
202+
url = "#{GITHUB_API_BASE}/#{full_name}/contents/#{filename}"
203+
data = cache_or_set("doi_#{full_name.gsub('/', '_')}_#{filename.downcase}") do
204+
content = open_url(url)
205+
content ? JSON.parse(content.read) : nil
206+
end
207+
return nil unless data && data['content']
208+
209+
decoded = Base64.decode64(data['content'])
210+
doi_match = decoded.match(/doi.org\/\s*([^\s,\)]+)/i)
211+
doi_match ? "https://doi.org/#{doi_match[1]}" : nil
212+
rescue StandardError
213+
nil
214+
end
215+
216+
# RELEASE – Opens releases API address and returns last release
217+
def fetch_latest_release(full_name)
218+
url = "#{GITHUB_API_BASE}/#{full_name}/releases"
219+
releases = cache_or_set("releases_#{full_name.gsub('/', '_')}") do
220+
content = open_url(url)
221+
content ? JSON.parse(content.read) : nil
222+
end
223+
releases.is_a?(Array) && releases.first ? releases.first['tag_name'] : nil
224+
rescue StandardError
225+
nil
226+
end
227+
228+
# CONTRIBUTORS – Opens contributors API address and returns list of contributors
229+
def fetch_contributors(contributors_url)
230+
contributors = cache_or_set("contributors_#{contributors_url.gsub(%r{https?://}, '').gsub('/', '_')}") do
231+
content = open_url(contributors_url)
232+
content ? JSON.parse(content.read) : nil
233+
end
234+
contributors.map { |c| c['login'] }
235+
rescue StandardError
236+
nil
237+
end
238+
239+
# PREREQUISITES – From the homepage HTML, looks for <p>'s ...
240+
def fetch_prerequisites(doc)
241+
prereq_paragraphs = []
242+
243+
# ... after any heading tag (h1–h6) or span tag with text "prereq" (EN) or "prerreq" (ES)
244+
doc.xpath('//h1|//h2|//h3|//h4|//h5|//h6|//span').each do |h|
245+
next unless h.text =~ /prereq|prerreq/i # if prereq in text
246+
247+
paras = h.xpath('following-sibling::*')
248+
.take_while { |sib| %w[p ul ol].include?(sib.name) } # take either p, ul or ol
249+
prereq_paragraphs.concat(paras) if paras
250+
end
251+
252+
# ... after any tag with id containing "prereq" (EN) or "prerreq" (ES)
253+
if prereq_paragraphs.empty?
254+
doc.xpath('//*[@id]').each do |node|
255+
next unless node['id'] =~ /prereq|prerreq/i || node['class'] =~ /prereq|prerreq/i # if prereq in id or class
256+
257+
paras = node.xpath('following-sibling::*')
258+
.take_while { |sib| %w[p ul ol].include?(sib.name) } # take either p, ul or ol
259+
prereq_paragraphs.concat(paras) if paras
260+
261+
next unless prereq_paragraphs.empty? # else
262+
263+
paras = node.xpath('.//p | .//ul | .//ol') # get all <p>, <ul>, <ol> inside this node – more 'destructive'
264+
prereq_paragraphs.concat(paras) if paras.any?
265+
end
266+
end
267+
268+
prereq_paragraphs&.join("\n")&.gsub(/\n\n+/, "\n")&.to_s&.strip
269+
end
270+
end
271+
end

lib/ingestors/ingestor_factory.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def self.ingestors
1010
Ingestors::MaterialCsvIngestor,
1111
Ingestors::TessEventIngestor,
1212
Ingestors::ZenodoIngestor,
13+
Ingestors::GithubIngestor,
1314
] + taxila_ingestors + llm_ingestors
1415
end
1516

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
require 'test_helper'
2+
3+
class GithubIngestorTest < ActiveSupport::TestCase
4+
setup do
5+
end
6+
7+
teardown do
8+
end
9+
10+
test 'should read sitemap of github.com and github.io and avoid test.com' do
11+
end
12+
13+
test 'should read github.com source' do
14+
end
15+
16+
test 'should read github.io source' do
17+
end
18+
19+
test 'should set redis cache when first time with material' do
20+
end
21+
22+
test 'should get redis cache when not first time with material' do
23+
end
24+
25+
test 'should fetch github metadata' do
26+
end
27+
28+
test 'should get definitions from <p> tags and skip if <p> has <= 25 char' do
29+
end
30+
31+
test 'should get proper license' do
32+
end
33+
34+
test 'should get doi from API /contents/README.md' do
35+
end
36+
37+
test 'should get latest release from API /releases' do
38+
end
39+
40+
test 'should get contributors list from contributors_url' do
41+
end
42+
43+
test 'should get prerequisites from HTML page' do
44+
# if prereq is written in h2 tag
45+
# if prereq is written in span tag
46+
# if prereq are in p, ul, ol
47+
# if prereq is as an id in any tag
48+
# can be prerreq (es)
49+
end

0 commit comments

Comments
 (0)