Module: Jekyll::AgentAudit::Rules::Provenance
- Defined in:
- lib/jekyll/agent_audit/rules/provenance.rb
Constant Summary collapse
- ARTICLE_TYPES =
%w[Article BlogPosting NewsArticle].freeze
- INTENT_KEYS =
{ published: [[:date], ["date"]], modified: [[:seo, :date_modified], ["seo", "date_modified"], [:last_modified_at], ["last_modified_at"]], author: [[:author], ["author"]] }.freeze
Class Method Summary collapse
- .ambiguous_article?(document, context) ⇒ Boolean
- .article?(node) ⇒ Boolean
- .author_identity_kind(value) ⇒ Object
- .author_set(value) ⇒ Object
- .author_sources(document, context) ⇒ Object
- .canonical_urls(document, context) ⇒ Object
- .collect_nodes(value, result, line_number = nil) ⇒ Object
- .comparable_date(values, context) ⇒ Object
- .date_order(document, context) ⇒ Object
- .disjoint?(a, b) ⇒ Boolean
- .draft(document, message, observation, line = nil, related_locations = []) ⇒ Object
- .evaluate(id, document, context) ⇒ Object
- .explicit_sources(document, field) ⇒ Object
- .infer_seo_rendered_fallback!(values, document, context) ⇒ Object
- .intent_value(data, field) ⇒ Object
- .interval_before?(a, b) ⇒ Boolean
- .invalid_dates(document, context) ⇒ Object
- .invalid_jsonld(document) ⇒ Object
- .jsonld_nodes(document) ⇒ Object
- .line(script) ⇒ Object
- .locations_for(sources) ⇒ Object
- .metadata_comparable?(document, context) ⇒ Boolean
- .metadata_conflict(document, context) ⇒ Object
- .metadata_sources(document, field) ⇒ Object
- .normalize_name(value) ⇒ Object
- .overlaps?(a, b) ⇒ Boolean
- .parse_date(value, context) ⇒ Object
- .primary_article(document, context) ⇒ Object
- .provenance_sources(document, context) ⇒ Object
- .publisher_sources(document, context) ⇒ Object
- .resolved_value(value, document, context) ⇒ Object
- .scripts(document) ⇒ Object
- .seo_fallback_date?(graph, document, context) ⇒ Boolean
- .seo_plugin_active?(context) ⇒ Boolean
- .source(value, origin, entity, line = nil) ⇒ Object
- .source_observation(source) ⇒ Object
- .timezone(context) ⇒ Object
- .timezone_free_timestamp?(value, context) ⇒ Boolean
Class Method Details
.ambiguous_article?(document, context) ⇒ Boolean
226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 226 def ambiguous_article?(document, context) nodes = jsonld_nodes(document) articles = nodes.values.select { |node| article?(node) } return false if articles.length < 2 url = document[:url] || document["url"] canonical = canonical_urls(document, context) articles.count do |article| [article["url"], article["mainEntityOfPage"]].any? do |candidate| candidate = candidate["@id"] if candidate.is_a?(Hash) candidate && [url, *canonical].include?(candidate) end end != 1 end |
.article?(node) ⇒ Boolean
389 390 391 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 389 def article?(node) Array(node["@type"]).any? { |type| ARTICLE_TYPES.include?(type) } end |
.author_identity_kind(value) ⇒ Object
281 282 283 284 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 281 def (value) = value.is_a?(Array) ? value : [value] .any? { || .is_a?(Hash) && (["@id"] || ["url"]) } ? :id : :name end |
.author_set(value) ⇒ Object
269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 269 def (value) = value.is_a?(Array) ? value : [value] .map do || = [:value] if .is_a?(Hash) && .key?(:value) if .is_a?(Hash) ["@id"] || ["url"] || normalize_name(["name"]) else normalize_name() end end.compact.to_set end |
.author_sources(document, context) ⇒ Object
160 161 162 163 164 165 166 167 168 169 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 160 def (document, context) sources = explicit_sources(document, :author) + (document, :author) graph = primary_article(document, context) return sources unless graph = graph["author"] = resolved_value(, document, context) entity = .is_a?(Hash) ? (["@id"] || ["@id"] || graph["@id"]) : graph["@id"] sources << source(, :jsonld, entity, graph["__audit_line"]) if sources end |
.canonical_urls(document, context) ⇒ Object
286 287 288 289 290 291 292 293 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 286 def canonical_urls(document, context) Array(document[:canonicals] || document["canonicals"]).map do |item| href = item[:url] || item["url"] || item[:href] || item["href"] next href unless href && context[:resolver] resolved = context[:resolver].resolve(href, document) resolved.is_a?(Hash) ? (resolved[:url] || resolved["url"]) : nil end.compact end |
.collect_nodes(value, result, line_number = nil) ⇒ Object
250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 250 def collect_nodes(value, result, line_number = nil) case value when Array then value.each { |item| collect_nodes(item, result, line_number) } when Hash if value["@graph"] collect_nodes(value["@graph"], result, line_number) elsif value["@id"] result[value["@id"]] = value.merge("__audit_line" => line_number) elsif value["@type"] result["__anonymous_#{result.length}"] = value.merge("__audit_line" => line_number) end end end |
.comparable_date(values, context) ⇒ Object
321 322 323 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 321 def comparable_date(values, context) values.map { |item| next unless item[:explicit]; parsed = parse_date(item[:value], context); parsed && item.merge(interval: parsed) }.compact.first end |
.date_order(document, context) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 73 def date_order(document, context) sources = provenance_sources(document, context) published = comparable_date(sources[:published], context) modified = comparable_date(sources[:modified], context) return [] unless published && modified return [] unless interval_before?(modified[:interval], published[:interval]) [draft(document, "Explicit dateModified precedes datePublished for the primary article.", { published: published[:value], modified: modified[:value], published_origin: published[:origin], modified_origin: modified[:origin] }, modified[:line])] end |
.disjoint?(a, b) ⇒ Boolean
325 326 327 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 325 def disjoint?(a, b) !overlaps?(a, b) end |
.draft(document, message, observation, line = nil, related_locations = []) ⇒ Object
406 407 408 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 406 def draft(document, , observation, line = nil, = []) {document: document, message: , observation: observation, line: line, related_locations: } end |
.evaluate(id, document, context) ⇒ Object
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 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 21 def evaluate(id, document, context) return {status: :not_applicable, findings: [], reason: "unknown rule"} unless %w[ provenance.jsonld_invalid provenance.date_invalid provenance.date_order provenance.metadata_conflict ].include?(id) return {status: :not_applicable, findings: [], reason: "no document"} unless document if %w[provenance.date_order provenance.metadata_conflict].include?(id) && ambiguous_article?(document, context) return {status: :skipped, findings: [], reason: "ambiguous primary Article entity"} end if id == "provenance.date_order" dates = provenance_sources(document, context) unless dates[:published].any? { |source| source[:explicit] } && dates[:modified].any? { |source| source[:explicit] } return {status: :skipped, findings: [], reason: "no comparable explicit publication and modification dates"} end end if id == "provenance.metadata_conflict" && !(document, context) return {status: :skipped, findings: [], reason: "no comparable provenance metadata pair"} end findings = case id when "provenance.jsonld_invalid" then invalid_jsonld(document) when "provenance.date_invalid" then invalid_dates(document, context) when "provenance.date_order" then date_order(document, context) when "provenance.metadata_conflict" then (document, context) end {status: :evaluated, findings: findings} end |
.explicit_sources(document, field) ⇒ Object
185 186 187 188 189 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 185 def explicit_sources(document, field) value, present = intent_value(document[:explicit_data] || document["explicit_data"], field) return [] unless present [source(value, :explicit, nil)] end |
.infer_seo_rendered_fallback!(values, document, context) ⇒ Object
150 151 152 153 154 155 156 157 158 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 150 def infer_seo_rendered_fallback!(values, document, context) return unless seo_plugin_active?(context) && explicit_sources(document, :modified).empty? published = values[:published].map { |item| parse_date(item[:value], context) }.compact return if published.empty? values[:modified].each do |modified| parsed = parse_date(modified[:value], context) modified[:explicit] = false if parsed && published.any? { |interval| overlaps?(interval, parsed) } end end |
.intent_value(data, field) ⇒ Object
344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 344 def intent_value(data, field) return [nil, false] unless data.is_a?(Hash) paths = INTENT_KEYS[field] || [] paths.each do |path| current = data path.each do |key| current = current[key] || current[key.to_s] if current.is_a?(Hash) end return [current, true] unless current.nil? end [nil, false] end |
.interval_before?(a, b) ⇒ Boolean
338 339 340 341 342 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 338 def interval_before?(a, b) return a[0] < b[0] if a[0] == a[1] return a[1] <= b[0] if b[0] == b[1] a[1] <= b[0] end |
.invalid_dates(document, context) ⇒ Object
59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 59 def invalid_dates(document, context) sources = provenance_sources(document, context) sources.map do |field, values| values.map do |source| next unless source[:explicit] next if parse_date(source[:value], context) next if (source[:value], context) draft(document, "#{field} contains an unparseable explicit machine date.", {field: field, value: source[:value], origin: source[:origin]}, source[:line]) end.compact end.flatten end |
.invalid_jsonld(document) ⇒ Object
49 50 51 52 53 54 55 56 57 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 49 def invalid_jsonld(document) scripts(document).map do |script| JSON.parse(script[:text] || script["text"]) nil rescue JSON::ParserError => e draft(document, "Invalid JSON in an application/ld+json script.", {error: e..to_s.gsub(/[\x00-\x1f]/, " ")[0, 200], source: :jsonld}, line(script)) end.compact end |
.jsonld_nodes(document) ⇒ Object
240 241 242 243 244 245 246 247 248 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 240 def jsonld_nodes(document) scripts(document).each_with_object({}) do |script, result| begin collect_nodes(JSON.parse(script[:text] || script["text"]), result, line(script)) rescue JSON::ParserError next end end end |
.line(script) ⇒ Object
385 386 387 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 385 def line(script) script[:line] || script["line"] end |
.locations_for(sources) ⇒ Object
377 378 379 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 377 def locations_for(sources) sources.map { |source| {line: source[:line], entity: source[:entity], origin: source[:origin]} } end |
.metadata_comparable?(document, context) ⇒ Boolean
126 127 128 129 130 131 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 126 def (document, context) sources = provenance_sources(document, context) sources.values.any? { |values| values.count { |source| source[:explicit] } >= 2 } || (document, context).count { |source| source[:explicit] } >= 2 || publisher_sources(document, context).count { |source| source[:explicit] } >= 2 end |
.metadata_conflict(document, context) ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 86 def (document, context) sources = provenance_sources(document, context) findings = [] %i[published modified].each do |field| parsed = sources[field].map do |source| next unless source[:explicit] value = parse_date(source[:value], context) value && source.merge(interval: value) end.compact next if parsed.length < 2 next unless parsed.combination(2).any? { |a, b| disjoint?(a[:interval], b[:interval]) } findings << draft(document, "Explicit #{field} metadata values conflict.", { field: field, values: parsed.map { |value| source_observation(value) } }, parsed.last[:line], locations_for(parsed)) end = (document, context).map do |source| ids = (source[:value]) ids.empty? ? nil : source.merge(set: ids, identity_kind: (source[:value])) end.compact if .length >= 2 && .map { |source| source[:identity_kind] }.uniq.length == 1 && .combination(2).any? { |a, b| a[:set] != b[:set] } findings << draft(document, "Explicit author metadata values conflict.", { field: :author, values: .map { |source| source_observation(source).merge(authors: source[:set].to_a.sort) } }, .last[:line], locations_for()) end publisher_sets = publisher_sources(document, context).map do |source| set = (source[:value]) set.empty? ? nil : source.merge(set: set, identity_kind: (source[:value])) end.compact if publisher_sets.length >= 2 && publisher_sets.map { |source| source[:identity_kind] }.uniq.length == 1 && publisher_sets.combination(2).any? { |a, b| a[:set] != b[:set] } findings << draft(document, "Explicit publisher metadata values conflict.", { field: :publisher, values: publisher_sets.map { |source| source_observation(source).merge(publishers: source[:set].to_a.sort) } }, publisher_sets.last[:line], locations_for(publisher_sets)) end findings end |
.metadata_sources(document, field) ⇒ Object
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 191 def (document, field) nodes = document[:metadata_nodes] || document["metadata_nodes"] || {} candidates = Array(nodes[field] || nodes[field.to_s]) if field == :author && candidates.length > 1 values = candidates.map { |candidate| candidate.is_a?(Hash) ? (candidate[:value] || candidate["value"]) : candidate } first = candidates.first return [source(values, first.is_a?(Hash) ? (first[:origin] || first["origin"] || :rendered) : :rendered, nil, first.is_a?(Hash) ? (first[:line] || first["line"]) : nil)] end candidates.map do |candidate| candidate = {value: candidate} unless candidate.is_a?(Hash) source(candidate[:value] || candidate["value"], candidate[:origin] || candidate["origin"] || :rendered, nil, candidate[:line] || candidate["line"]).tap do |entry| entry[:entity] = candidate[:entity] || candidate["entity"] if entry end end.compact end |
.normalize_name(value) ⇒ Object
393 394 395 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 393 def normalize_name(value) value.to_s.strip.gsub(/\s+/, " ") unless value.nil? end |
.overlaps?(a, b) ⇒ Boolean
329 330 331 332 333 334 335 336 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 329 def overlaps?(a, b) a_point = a[0] == a[1] b_point = b[0] == b[1] return a[0] == b[0] if a_point && b_point return a[0] >= b[0] && a[0] < b[1] if a_point return b[0] >= a[0] && b[0] < a[1] if b_point a[0] < b[1] && b[0] < a[1] end |
.parse_date(value, context) ⇒ Object
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 295 def parse_date(value, context) return nil unless value.is_a?(String) || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime) if value.is_a?(Date) && !value.is_a?(DateTime) next_date = value + 1 return [Time.utc(value.year, value.month, value.day), Time.utc(next_date.year, next_date.month, next_date.day)] end if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/) date = Date.parse(value) next_date = date + 1 return [Time.utc(date.year, date.month, date.day), Time.utc(next_date.year, next_date.month, next_date.day)] end return [value.utc, value.utc] if value.respond_to?(:utc) return nil unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/) return nil if value.match?(/T[^Z+\-]*\z/) && timezone(context).nil? = value if .match?(/T[^Z+\-]*\z/) offset = timezone(context) return nil unless offset.is_a?(String) && offset.match?(/\A[+-]\d{2}:?\d{2}\z/) = "#{}#{offset}" end instant = Time.iso8601() [instant, instant] rescue ArgumentError, Date::Error nil end |
.primary_article(document, context) ⇒ Object
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 209 def primary_article(document, context) nodes = jsonld_nodes(document) articles = nodes.values.select { |node| article?(node) } return nil if articles.empty? url = document[:url] || document["url"] canonical = canonical_urls(document, context) matches = articles.select do |article| [article["url"], article["mainEntityOfPage"]].any? do |candidate| candidate = candidate["@id"] if candidate.is_a?(Hash) candidate && [url, *canonical].compact.include?(candidate) end end return matches.first if matches.length == 1 return nil if articles.length == 1 && (articles.first["url"] || articles.first["mainEntityOfPage"]) articles.length == 1 ? articles.first : nil end |
.provenance_sources(document, context) ⇒ Object
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 133 def provenance_sources(document, context) graph = primary_article(document, context) values = {published: [], modified: []} values[:published].concat(explicit_sources(document, :published)) values[:modified].concat(explicit_sources(document, :modified)) values[:published].concat((document, :published)) values[:modified].concat((document, :modified)) if graph values[:published] << source(graph["datePublished"], :jsonld, graph["@id"], graph["__audit_line"]) modified = source(graph["dateModified"], :jsonld, graph["@id"], graph["__audit_line"]) modified[:explicit] = false if modified && seo_fallback_date?(graph, document, context) values[:modified] << modified end infer_seo_rendered_fallback!(values, document, context) values.transform_values { |items| items.compact } end |
.publisher_sources(document, context) ⇒ Object
171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 171 def publisher_sources(document, context) sources = (document, :publisher) graph = primary_article(document, context) return sources unless graph publisher_reference = graph["publisher"] publisher = resolved_value(publisher_reference, document, context) if publisher value = publisher.is_a?(Hash) && publisher["name"] ? publisher["name"] : publisher entity = publisher.is_a?(Hash) ? (publisher["@id"] || publisher_reference["@id"] || graph["@id"]) : graph["@id"] sources << source(value, :jsonld, entity, graph["__audit_line"]) end sources end |
.resolved_value(value, document, context) ⇒ Object
264 265 266 267 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 264 def resolved_value(value, document, context) return value unless value.is_a?(Hash) && value["@id"] jsonld_nodes(document)[value["@id"]] end |
.scripts(document) ⇒ Object
381 382 383 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 381 def scripts(document) Array(document[:jsonld_scripts] || document["jsonld_scripts"]) end |
.seo_fallback_date?(graph, document, context) ⇒ Boolean
362 363 364 365 366 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 362 def seo_fallback_date?(graph, document, context) return false unless seo_plugin_active?(context) return false if explicit_sources(document, :modified).any? graph["datePublished"] && graph["dateModified"] && graph["datePublished"] == graph["dateModified"] end |
.seo_plugin_active?(context) ⇒ Boolean
368 369 370 371 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 368 def seo_plugin_active?(context) plugins = context[:plugins] || context.dig(:site_config, :plugins) || context.dig(:site_config, "plugins") || [] Array(plugins).any? { |plugin| plugin.to_s == "jekyll-seo-tag" } end |
.source(value, origin, entity, line = nil) ⇒ Object
357 358 359 360 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 357 def source(value, origin, entity, line = nil) return nil if value.nil? {value: value, origin: origin, entity: entity, line: line, explicit: true} end |
.source_observation(source) ⇒ Object
373 374 375 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 373 def source_observation(source) {value: source[:value], origin: source[:origin], entity: source[:entity], line: source[:line]} end |
.timezone(context) ⇒ Object
397 398 399 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 397 def timezone(context) context.dig(:site_config, :timezone) || context.dig(:site_config, "timezone") end |
.timezone_free_timestamp?(value, context) ⇒ Boolean
401 402 403 404 |
# File 'lib/jekyll/agent_audit/rules/provenance.rb', line 401 def (value, context) value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/) && value.match?(/T[^Z+\-]*\z/) && timezone(context).nil? end |