jekyll-agent-audit 0.1.0
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/CHANGELOG.md +8 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +17 -0
- data/README.md +180 -0
- data/Rakefile +38 -0
- data/doc/Jekyll/AgentAudit/BuildInventory.md +29 -0
- data/doc/Jekyll/AgentAudit/Configuration.md +42 -0
- data/doc/Jekyll/AgentAudit/ConfigurationError.md +6 -0
- data/doc/Jekyll/AgentAudit/Error.md +6 -0
- data/doc/Jekyll/AgentAudit/Extractor.md +20 -0
- data/doc/Jekyll/AgentAudit/Finding.md +30 -0
- data/doc/Jekyll/AgentAudit/InputError.md +6 -0
- data/doc/Jekyll/AgentAudit/LinkGraph.md +26 -0
- data/doc/Jekyll/AgentAudit/Registry.md +38 -0
- data/doc/Jekyll/AgentAudit/Report.md +27 -0
- data/doc/Jekyll/AgentAudit/ReportError.md +6 -0
- data/doc/Jekyll/AgentAudit/Reporters/Console.md +19 -0
- data/doc/Jekyll/AgentAudit/Reporters/JSON.md +9 -0
- data/doc/Jekyll/AgentAudit/Reporters.md +5 -0
- data/doc/Jekyll/AgentAudit/Rules/Provenance.md +127 -0
- data/doc/Jekyll/AgentAudit/Rules/Publication.md +73 -0
- data/doc/Jekyll/AgentAudit/Rules.md +5 -0
- data/doc/Jekyll/AgentAudit/Runner.md +13 -0
- data/doc/Jekyll/AgentAudit/UrlResolver.md +13 -0
- data/doc/Jekyll/AgentAudit.md +35 -0
- data/doc/Jekyll/Commands/AgentAudit/CommandParser.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit/ParserErrors.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit.md +13 -0
- data/doc/Jekyll/Commands.md +5 -0
- data/doc/Jekyll.md +5 -0
- data/doc/README.md +180 -0
- data/doc/index.csv +154 -0
- data/docs/example-report.json +281 -0
- data/docs/implementation-plan.md +26 -0
- data/docs/limitations.md +31 -0
- data/docs/verification.md +52 -0
- data/lib/jekyll/agent_audit/build_inventory.rb +196 -0
- data/lib/jekyll/agent_audit/command.rb +179 -0
- data/lib/jekyll/agent_audit/configuration.rb +196 -0
- data/lib/jekyll/agent_audit/errors.rb +10 -0
- data/lib/jekyll/agent_audit/extractor.rb +230 -0
- data/lib/jekyll/agent_audit/finding.rb +57 -0
- data/lib/jekyll/agent_audit/link_graph.rb +82 -0
- data/lib/jekyll/agent_audit/registry.rb +133 -0
- data/lib/jekyll/agent_audit/report.rb +79 -0
- data/lib/jekyll/agent_audit/reporters/console.rb +144 -0
- data/lib/jekyll/agent_audit/reporters/json.rb +16 -0
- data/lib/jekyll/agent_audit/rules/provenance.rb +412 -0
- data/lib/jekyll/agent_audit/rules/publication.rb +186 -0
- data/lib/jekyll/agent_audit/runner.rb +266 -0
- data/lib/jekyll/agent_audit/url_resolver.rb +98 -0
- data/lib/jekyll/agent_audit/version.rb +7 -0
- data/lib/jekyll-agent-audit.rb +24 -0
- data/llms.txt +35 -0
- data/schema/report-1.0.json +66 -0
- data/script/benchmark.rb +68 -0
- metadata +133 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module AgentAudit
|
|
5
|
+
module Reporters
|
|
6
|
+
module Console
|
|
7
|
+
module_function
|
|
8
|
+
SEVERITIES = %w[error warning info experimental].freeze
|
|
9
|
+
MAX_VALUE_LENGTH = 240
|
|
10
|
+
|
|
11
|
+
def render(report, verbose: false)
|
|
12
|
+
data = report.to_h
|
|
13
|
+
summary = data.fetch("summary", {})
|
|
14
|
+
lines = ["AI publishing audit — local build", "", "Documents inspected: #{safe(summary["documents_inspected"])}"]
|
|
15
|
+
lines << "Active findings: #{severity_summary(summary["active"])}"
|
|
16
|
+
lines << "Suppressed findings: #{safe(summary["suppressed"], "0")}"
|
|
17
|
+
lines << coverage_line(data.fetch("coverage", {}), data.dig("run", "complete"))
|
|
18
|
+
lines << ""
|
|
19
|
+
lines << "Discovery: Not assessed — jekyll-agent-discovery"
|
|
20
|
+
append_findings(lines, report.findings, verbose)
|
|
21
|
+
append_diagnostics(lines, data.fetch("diagnostics", []), verbose)
|
|
22
|
+
lines << ""
|
|
23
|
+
lines << result_line(report, data)
|
|
24
|
+
lines.join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def append_findings(lines, findings, verbose)
|
|
28
|
+
visible = findings.reject(&:suppressed?)
|
|
29
|
+
grouped = visible.group_by { |finding| [finding["category"].to_s, finding["rule_id"].to_s] }
|
|
30
|
+
grouped.sort_by { |(category, rule), _| [category, rule] }.each do |(category, rule), members|
|
|
31
|
+
lines << ""
|
|
32
|
+
lines << "#{label(category)} — #{escape(rule)}#{members.length > 1 ? " (#{members.length} occurrences)" : ""}"
|
|
33
|
+
members.each { |finding| append_finding(lines, finding, verbose) }
|
|
34
|
+
end
|
|
35
|
+
return unless verbose
|
|
36
|
+
findings.select(&:suppressed?).group_by { |finding| finding["rule_id"] }.each do |rule, members|
|
|
37
|
+
lines << ""
|
|
38
|
+
lines << "Suppressed — #{escape(rule)} (#{members.length})"
|
|
39
|
+
members.each { |finding| append_finding(lines, finding, true, suppressed: true) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def append_finding(lines, finding, verbose, suppressed: false)
|
|
44
|
+
severity = finding["severity"].to_s.upcase
|
|
45
|
+
evidence = finding["evidence_level"]
|
|
46
|
+
lines << " #{severity} #{escape(finding["rule_id"])}#{evidence.to_s.empty? ? "" : " [#{escape(evidence)}]"}"
|
|
47
|
+
lines << " #{bounded(finding["message"])}"
|
|
48
|
+
lines << " URL: #{bounded(finding["document_url"])}" if finding["document_url"]
|
|
49
|
+
lines << " Source: #{location(finding["source_path"], finding["source_line"])}" if finding["source_path"] || finding["source_line"]
|
|
50
|
+
rendered = finding["rendered_location"] || {}
|
|
51
|
+
if rendered["path"] || rendered[:path]
|
|
52
|
+
lines << " Rendered: #{location(rendered["path"] || rendered[:path], rendered["line"] || rendered[:line])}"
|
|
53
|
+
end
|
|
54
|
+
observation = finding["observation"] || {}
|
|
55
|
+
lines << " Observation: #{bounded(format_value(observation))}" unless observation.empty?
|
|
56
|
+
lines << " Remediation: #{bounded(finding["remediation"])}" unless finding["remediation"].to_s.empty?
|
|
57
|
+
append_related(lines, finding["related_locations"]) unless Array(finding["related_locations"]).empty?
|
|
58
|
+
if suppressed
|
|
59
|
+
lines << " Suppression: #{bounded(format_value(finding["suppression"] || {}))}"
|
|
60
|
+
elsif verbose && !observation.empty?
|
|
61
|
+
lines << " Metadata: #{bounded(format_value(observation))}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def append_related(lines, locations)
|
|
66
|
+
Array(locations).each do |related|
|
|
67
|
+
related = related.transform_keys(&:to_s) if related.respond_to?(:transform_keys)
|
|
68
|
+
path = related["path"] || related["source_path"] || related["url"] || related["entity"]
|
|
69
|
+
line = related["line"] || related["source_line"]
|
|
70
|
+
origin = related["origin"] ? " (#{escape(related["origin"])})" : ""
|
|
71
|
+
lines << " Related: #{location(path, line)}#{origin}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def append_diagnostics(lines, diagnostics, verbose)
|
|
76
|
+
items = Array(diagnostics)
|
|
77
|
+
return if items.empty? && !verbose
|
|
78
|
+
lines << ""
|
|
79
|
+
lines << "Diagnostics#{items.empty? ? "" : " (#{items.length})"}:"
|
|
80
|
+
if items.empty?
|
|
81
|
+
lines << " None"
|
|
82
|
+
else
|
|
83
|
+
items.each do |diagnostic|
|
|
84
|
+
diagnostic = diagnostic.transform_keys(&:to_s) if diagnostic.respond_to?(:transform_keys)
|
|
85
|
+
code = diagnostic["code"] || "diagnostic"
|
|
86
|
+
message = diagnostic["message"] || format_value(diagnostic)
|
|
87
|
+
lines << " #{escape(code)}: #{bounded(message)}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def coverage_line(coverage, run_complete = true)
|
|
93
|
+
rules = coverage.fetch("rules", {})
|
|
94
|
+
incomplete = rules.values.count { |item| item["status"].to_s == "incomplete" || item[:status].to_s == "incomplete" }
|
|
95
|
+
skipped = rules.values.sum { |item| (item["skipped"] || item[:skipped] || 0).to_i }
|
|
96
|
+
state = !run_complete || !incomplete.zero? ? "incomplete" : "complete"
|
|
97
|
+
"Coverage: #{state} for enabled checks; #{skipped} applications skipped"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def result_line(report, data)
|
|
101
|
+
code = Array(data["diagnostics"]).any? { |item| (item["code"] || item[:code]).to_s == "interrupted" } ? 130 : report.exit_code
|
|
102
|
+
result = {0 => "passed", 1 => "failed policy", 130 => "interrupted"}.fetch(code, "incomplete")
|
|
103
|
+
"Result: #{result} (fail_on=#{escape(data.dig("policy", "fail_on"))}). Exit #{code}."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def severity_summary(active)
|
|
107
|
+
active = active || {}
|
|
108
|
+
SEVERITIES.map { |severity| "#{safe(active[severity], "0")} #{severity}" }.join(", ")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def location(path, line_number)
|
|
112
|
+
value = bounded(path || "unknown")
|
|
113
|
+
line_number ? "#{value}:#{safe(line_number)}" : value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def label(value)
|
|
117
|
+
value.to_s.empty? ? "Findings" : value.to_s.capitalize
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def bounded(value)
|
|
121
|
+
escape(value.to_s)[0, MAX_VALUE_LENGTH]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def format_value(value)
|
|
125
|
+
case value
|
|
126
|
+
when Hash then value.map { |key, item| "#{key}=#{format_value(item)}" }.join(", ")
|
|
127
|
+
when Array then value.map { |item| format_value(item) }.join(", ")
|
|
128
|
+
else value.to_s
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def safe(value, fallback = "")
|
|
133
|
+
value.nil? ? fallback : escape(value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def escape(value)
|
|
137
|
+
value.to_s.gsub(/[\x00-\x1f\x7f]/) { |char| char == "\e" ? "\\e" : "\\x%02x" % char.ord }
|
|
138
|
+
end
|
|
139
|
+
private_class_method :append_diagnostics, :append_finding, :append_findings, :append_related, :bounded,
|
|
140
|
+
:coverage_line, :escape, :format_value, :label, :location, :safe, :severity_summary
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Jekyll
|
|
9
|
+
module AgentAudit
|
|
10
|
+
module Rules
|
|
11
|
+
module Provenance
|
|
12
|
+
ARTICLE_TYPES = %w[Article BlogPosting NewsArticle].freeze
|
|
13
|
+
INTENT_KEYS = {
|
|
14
|
+
published: [[:date], ["date"]],
|
|
15
|
+
modified: [[:seo, :date_modified], ["seo", "date_modified"], [:last_modified_at], ["last_modified_at"]],
|
|
16
|
+
author: [[:author], ["author"]]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def evaluate(id, document, context)
|
|
22
|
+
return {status: :not_applicable, findings: [], reason: "unknown rule"} unless %w[
|
|
23
|
+
provenance.jsonld_invalid provenance.date_invalid provenance.date_order provenance.metadata_conflict
|
|
24
|
+
].include?(id)
|
|
25
|
+
|
|
26
|
+
return {status: :not_applicable, findings: [], reason: "no document"} unless document
|
|
27
|
+
|
|
28
|
+
if %w[provenance.date_order provenance.metadata_conflict].include?(id) && ambiguous_article?(document, context)
|
|
29
|
+
return {status: :skipped, findings: [], reason: "ambiguous primary Article entity"}
|
|
30
|
+
end
|
|
31
|
+
if id == "provenance.date_order"
|
|
32
|
+
dates = provenance_sources(document, context)
|
|
33
|
+
unless dates[:published].any? { |source| source[:explicit] } && dates[:modified].any? { |source| source[:explicit] }
|
|
34
|
+
return {status: :skipped, findings: [], reason: "no comparable explicit publication and modification dates"}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
if id == "provenance.metadata_conflict" && !metadata_comparable?(document, context)
|
|
38
|
+
return {status: :skipped, findings: [], reason: "no comparable provenance metadata pair"}
|
|
39
|
+
end
|
|
40
|
+
findings = case id
|
|
41
|
+
when "provenance.jsonld_invalid" then invalid_jsonld(document)
|
|
42
|
+
when "provenance.date_invalid" then invalid_dates(document, context)
|
|
43
|
+
when "provenance.date_order" then date_order(document, context)
|
|
44
|
+
when "provenance.metadata_conflict" then metadata_conflict(document, context)
|
|
45
|
+
end
|
|
46
|
+
{status: :evaluated, findings: findings}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def invalid_jsonld(document)
|
|
50
|
+
scripts(document).map do |script|
|
|
51
|
+
JSON.parse(script[:text] || script["text"])
|
|
52
|
+
nil
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
draft(document, "Invalid JSON in an application/ld+json script.",
|
|
55
|
+
{error: e.message.to_s.gsub(/[\x00-\x1f]/, " ")[0, 200], source: :jsonld}, line(script))
|
|
56
|
+
end.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invalid_dates(document, context)
|
|
60
|
+
sources = provenance_sources(document, context)
|
|
61
|
+
sources.map do |field, values|
|
|
62
|
+
values.map do |source|
|
|
63
|
+
next unless source[:explicit]
|
|
64
|
+
next if parse_date(source[:value], context)
|
|
65
|
+
next if timezone_free_timestamp?(source[:value], context)
|
|
66
|
+
|
|
67
|
+
draft(document, "#{field} contains an unparseable explicit machine date.",
|
|
68
|
+
{field: field, value: source[:value], origin: source[:origin]}, source[:line])
|
|
69
|
+
end.compact
|
|
70
|
+
end.flatten
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def date_order(document, context)
|
|
74
|
+
sources = provenance_sources(document, context)
|
|
75
|
+
published = comparable_date(sources[:published], context)
|
|
76
|
+
modified = comparable_date(sources[:modified], context)
|
|
77
|
+
return [] unless published && modified
|
|
78
|
+
return [] unless interval_before?(modified[:interval], published[:interval])
|
|
79
|
+
|
|
80
|
+
[draft(document, "Explicit dateModified precedes datePublished for the primary article.", {
|
|
81
|
+
published: published[:value], modified: modified[:value],
|
|
82
|
+
published_origin: published[:origin], modified_origin: modified[:origin]
|
|
83
|
+
}, modified[:line])]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def metadata_conflict(document, context)
|
|
87
|
+
sources = provenance_sources(document, context)
|
|
88
|
+
findings = []
|
|
89
|
+
%i[published modified].each do |field|
|
|
90
|
+
parsed = sources[field].map do |source|
|
|
91
|
+
next unless source[:explicit]
|
|
92
|
+
value = parse_date(source[:value], context)
|
|
93
|
+
value && source.merge(interval: value)
|
|
94
|
+
end.compact
|
|
95
|
+
next if parsed.length < 2
|
|
96
|
+
next unless parsed.combination(2).any? { |a, b| disjoint?(a[:interval], b[:interval]) }
|
|
97
|
+
|
|
98
|
+
findings << draft(document, "Explicit #{field} metadata values conflict.", {
|
|
99
|
+
field: field, values: parsed.map { |value| source_observation(value) }
|
|
100
|
+
}, parsed.last[:line], locations_for(parsed))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
author_sets = author_sources(document, context).map do |source|
|
|
104
|
+
ids = author_set(source[:value])
|
|
105
|
+
ids.empty? ? nil : source.merge(set: ids, identity_kind: author_identity_kind(source[:value]))
|
|
106
|
+
end.compact
|
|
107
|
+
if author_sets.length >= 2 && author_sets.map { |source| source[:identity_kind] }.uniq.length == 1 &&
|
|
108
|
+
author_sets.combination(2).any? { |a, b| a[:set] != b[:set] }
|
|
109
|
+
findings << draft(document, "Explicit author metadata values conflict.", {
|
|
110
|
+
field: :author, values: author_sets.map { |source| source_observation(source).merge(authors: source[:set].to_a.sort) }
|
|
111
|
+
}, author_sets.last[:line], locations_for(author_sets))
|
|
112
|
+
end
|
|
113
|
+
publisher_sets = publisher_sources(document, context).map do |source|
|
|
114
|
+
set = author_set(source[:value])
|
|
115
|
+
set.empty? ? nil : source.merge(set: set, identity_kind: author_identity_kind(source[:value]))
|
|
116
|
+
end.compact
|
|
117
|
+
if publisher_sets.length >= 2 && publisher_sets.map { |source| source[:identity_kind] }.uniq.length == 1 &&
|
|
118
|
+
publisher_sets.combination(2).any? { |a, b| a[:set] != b[:set] }
|
|
119
|
+
findings << draft(document, "Explicit publisher metadata values conflict.", {
|
|
120
|
+
field: :publisher, values: publisher_sets.map { |source| source_observation(source).merge(publishers: source[:set].to_a.sort) }
|
|
121
|
+
}, publisher_sets.last[:line], locations_for(publisher_sets))
|
|
122
|
+
end
|
|
123
|
+
findings
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def metadata_comparable?(document, context)
|
|
127
|
+
sources = provenance_sources(document, context)
|
|
128
|
+
sources.values.any? { |values| values.count { |source| source[:explicit] } >= 2 } ||
|
|
129
|
+
author_sources(document, context).count { |source| source[:explicit] } >= 2 ||
|
|
130
|
+
publisher_sources(document, context).count { |source| source[:explicit] } >= 2
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def provenance_sources(document, context)
|
|
134
|
+
graph = primary_article(document, context)
|
|
135
|
+
values = {published: [], modified: []}
|
|
136
|
+
values[:published].concat(explicit_sources(document, :published))
|
|
137
|
+
values[:modified].concat(explicit_sources(document, :modified))
|
|
138
|
+
values[:published].concat(metadata_sources(document, :published))
|
|
139
|
+
values[:modified].concat(metadata_sources(document, :modified))
|
|
140
|
+
if graph
|
|
141
|
+
values[:published] << source(graph["datePublished"], :jsonld, graph["@id"], graph["__audit_line"])
|
|
142
|
+
modified = source(graph["dateModified"], :jsonld, graph["@id"], graph["__audit_line"])
|
|
143
|
+
modified[:explicit] = false if modified && seo_fallback_date?(graph, document, context)
|
|
144
|
+
values[:modified] << modified
|
|
145
|
+
end
|
|
146
|
+
infer_seo_rendered_fallback!(values, document, context)
|
|
147
|
+
values.transform_values { |items| items.compact }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def infer_seo_rendered_fallback!(values, document, context)
|
|
151
|
+
return unless seo_plugin_active?(context) && explicit_sources(document, :modified).empty?
|
|
152
|
+
published = values[:published].map { |item| parse_date(item[:value], context) }.compact
|
|
153
|
+
return if published.empty?
|
|
154
|
+
values[:modified].each do |modified|
|
|
155
|
+
parsed = parse_date(modified[:value], context)
|
|
156
|
+
modified[:explicit] = false if parsed && published.any? { |interval| overlaps?(interval, parsed) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def author_sources(document, context)
|
|
161
|
+
sources = explicit_sources(document, :author) + metadata_sources(document, :author)
|
|
162
|
+
graph = primary_article(document, context)
|
|
163
|
+
return sources unless graph
|
|
164
|
+
author_reference = graph["author"]
|
|
165
|
+
author = resolved_value(author_reference, document, context)
|
|
166
|
+
entity = author.is_a?(Hash) ? (author["@id"] || author_reference["@id"] || graph["@id"]) : graph["@id"]
|
|
167
|
+
sources << source(author, :jsonld, entity, graph["__audit_line"]) if author
|
|
168
|
+
sources
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def publisher_sources(document, context)
|
|
172
|
+
sources = metadata_sources(document, :publisher)
|
|
173
|
+
graph = primary_article(document, context)
|
|
174
|
+
return sources unless graph
|
|
175
|
+
publisher_reference = graph["publisher"]
|
|
176
|
+
publisher = resolved_value(publisher_reference, document, context)
|
|
177
|
+
if publisher
|
|
178
|
+
value = publisher.is_a?(Hash) && publisher["name"] ? publisher["name"] : publisher
|
|
179
|
+
entity = publisher.is_a?(Hash) ? (publisher["@id"] || publisher_reference["@id"] || graph["@id"]) : graph["@id"]
|
|
180
|
+
sources << source(value, :jsonld, entity, graph["__audit_line"])
|
|
181
|
+
end
|
|
182
|
+
sources
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def explicit_sources(document, field)
|
|
186
|
+
value, present = intent_value(document[:explicit_data] || document["explicit_data"], field)
|
|
187
|
+
return [] unless present
|
|
188
|
+
[source(value, :explicit, nil)]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def metadata_sources(document, field)
|
|
192
|
+
nodes = document[:metadata_nodes] || document["metadata_nodes"] || {}
|
|
193
|
+
candidates = Array(nodes[field] || nodes[field.to_s])
|
|
194
|
+
if field == :author && candidates.length > 1
|
|
195
|
+
values = candidates.map { |candidate| candidate.is_a?(Hash) ? (candidate[:value] || candidate["value"]) : candidate }
|
|
196
|
+
first = candidates.first
|
|
197
|
+
return [source(values, first.is_a?(Hash) ? (first[:origin] || first["origin"] || :rendered) : :rendered,
|
|
198
|
+
nil, first.is_a?(Hash) ? (first[:line] || first["line"]) : nil)]
|
|
199
|
+
end
|
|
200
|
+
candidates.map do |candidate|
|
|
201
|
+
candidate = {value: candidate} unless candidate.is_a?(Hash)
|
|
202
|
+
source(candidate[:value] || candidate["value"], candidate[:origin] || candidate["origin"] || :rendered, nil,
|
|
203
|
+
candidate[:line] || candidate["line"]).tap do |entry|
|
|
204
|
+
entry[:entity] = candidate[:entity] || candidate["entity"] if entry
|
|
205
|
+
end
|
|
206
|
+
end.compact
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def primary_article(document, context)
|
|
210
|
+
nodes = jsonld_nodes(document)
|
|
211
|
+
articles = nodes.values.select { |node| article?(node) }
|
|
212
|
+
return nil if articles.empty?
|
|
213
|
+
url = document[:url] || document["url"]
|
|
214
|
+
canonical = canonical_urls(document, context)
|
|
215
|
+
matches = articles.select do |article|
|
|
216
|
+
[article["url"], article["mainEntityOfPage"]].any? do |candidate|
|
|
217
|
+
candidate = candidate["@id"] if candidate.is_a?(Hash)
|
|
218
|
+
candidate && [url, *canonical].compact.include?(candidate)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
return matches.first if matches.length == 1
|
|
222
|
+
return nil if articles.length == 1 && (articles.first["url"] || articles.first["mainEntityOfPage"])
|
|
223
|
+
articles.length == 1 ? articles.first : nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def ambiguous_article?(document, context)
|
|
227
|
+
nodes = jsonld_nodes(document)
|
|
228
|
+
articles = nodes.values.select { |node| article?(node) }
|
|
229
|
+
return false if articles.length < 2
|
|
230
|
+
url = document[:url] || document["url"]
|
|
231
|
+
canonical = canonical_urls(document, context)
|
|
232
|
+
articles.count do |article|
|
|
233
|
+
[article["url"], article["mainEntityOfPage"]].any? do |candidate|
|
|
234
|
+
candidate = candidate["@id"] if candidate.is_a?(Hash)
|
|
235
|
+
candidate && [url, *canonical].include?(candidate)
|
|
236
|
+
end
|
|
237
|
+
end != 1
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def jsonld_nodes(document)
|
|
241
|
+
scripts(document).each_with_object({}) do |script, result|
|
|
242
|
+
begin
|
|
243
|
+
collect_nodes(JSON.parse(script[:text] || script["text"]), result, line(script))
|
|
244
|
+
rescue JSON::ParserError
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def collect_nodes(value, result, line_number = nil)
|
|
251
|
+
case value
|
|
252
|
+
when Array then value.each { |item| collect_nodes(item, result, line_number) }
|
|
253
|
+
when Hash
|
|
254
|
+
if value["@graph"]
|
|
255
|
+
collect_nodes(value["@graph"], result, line_number)
|
|
256
|
+
elsif value["@id"]
|
|
257
|
+
result[value["@id"]] = value.merge("__audit_line" => line_number)
|
|
258
|
+
elsif value["@type"]
|
|
259
|
+
result["__anonymous_#{result.length}"] = value.merge("__audit_line" => line_number)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def resolved_value(value, document, context)
|
|
265
|
+
return value unless value.is_a?(Hash) && value["@id"]
|
|
266
|
+
jsonld_nodes(document)[value["@id"]]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def author_set(value)
|
|
270
|
+
authors = value.is_a?(Array) ? value : [value]
|
|
271
|
+
authors.map do |author|
|
|
272
|
+
author = author[:value] if author.is_a?(Hash) && author.key?(:value)
|
|
273
|
+
if author.is_a?(Hash)
|
|
274
|
+
author["@id"] || author["url"] || normalize_name(author["name"])
|
|
275
|
+
else
|
|
276
|
+
normalize_name(author)
|
|
277
|
+
end
|
|
278
|
+
end.compact.to_set
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def author_identity_kind(value)
|
|
282
|
+
authors = value.is_a?(Array) ? value : [value]
|
|
283
|
+
authors.any? { |author| author.is_a?(Hash) && (author["@id"] || author["url"]) } ? :id : :name
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def canonical_urls(document, context)
|
|
287
|
+
Array(document[:canonicals] || document["canonicals"]).map do |item|
|
|
288
|
+
href = item[:url] || item["url"] || item[:href] || item["href"]
|
|
289
|
+
next href unless href && context[:resolver]
|
|
290
|
+
resolved = context[:resolver].resolve(href, document)
|
|
291
|
+
resolved.is_a?(Hash) ? (resolved[:url] || resolved["url"]) : nil
|
|
292
|
+
end.compact
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_date(value, context)
|
|
296
|
+
return nil unless value.is_a?(String) || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
|
|
297
|
+
if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
298
|
+
next_date = value + 1
|
|
299
|
+
return [Time.utc(value.year, value.month, value.day), Time.utc(next_date.year, next_date.month, next_date.day)]
|
|
300
|
+
end
|
|
301
|
+
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
302
|
+
date = Date.parse(value)
|
|
303
|
+
next_date = date + 1
|
|
304
|
+
return [Time.utc(date.year, date.month, date.day), Time.utc(next_date.year, next_date.month, next_date.day)]
|
|
305
|
+
end
|
|
306
|
+
return [value.utc, value.utc] if value.respond_to?(:utc)
|
|
307
|
+
return nil unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/)
|
|
308
|
+
return nil if value.match?(/T[^Z+\-]*\z/) && timezone(context).nil?
|
|
309
|
+
timestamp = value
|
|
310
|
+
if timestamp.match?(/T[^Z+\-]*\z/)
|
|
311
|
+
offset = timezone(context)
|
|
312
|
+
return nil unless offset.is_a?(String) && offset.match?(/\A[+-]\d{2}:?\d{2}\z/)
|
|
313
|
+
timestamp = "#{timestamp}#{offset}"
|
|
314
|
+
end
|
|
315
|
+
instant = Time.iso8601(timestamp)
|
|
316
|
+
[instant, instant]
|
|
317
|
+
rescue ArgumentError, Date::Error
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def comparable_date(values, context)
|
|
322
|
+
values.map { |item| next unless item[:explicit]; parsed = parse_date(item[:value], context); parsed && item.merge(interval: parsed) }.compact.first
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def disjoint?(a, b)
|
|
326
|
+
!overlaps?(a, b)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def overlaps?(a, b)
|
|
330
|
+
a_point = a[0] == a[1]
|
|
331
|
+
b_point = b[0] == b[1]
|
|
332
|
+
return a[0] == b[0] if a_point && b_point
|
|
333
|
+
return a[0] >= b[0] && a[0] < b[1] if a_point
|
|
334
|
+
return b[0] >= a[0] && b[0] < a[1] if b_point
|
|
335
|
+
a[0] < b[1] && b[0] < a[1]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def interval_before?(a, b)
|
|
339
|
+
return a[0] < b[0] if a[0] == a[1]
|
|
340
|
+
return a[1] <= b[0] if b[0] == b[1]
|
|
341
|
+
a[1] <= b[0]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def intent_value(data, field)
|
|
345
|
+
return [nil, false] unless data.is_a?(Hash)
|
|
346
|
+
paths = INTENT_KEYS[field] || []
|
|
347
|
+
paths.each do |path|
|
|
348
|
+
current = data
|
|
349
|
+
path.each do |key|
|
|
350
|
+
current = current[key] || current[key.to_s] if current.is_a?(Hash)
|
|
351
|
+
end
|
|
352
|
+
return [current, true] unless current.nil?
|
|
353
|
+
end
|
|
354
|
+
[nil, false]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def source(value, origin, entity, line = nil)
|
|
358
|
+
return nil if value.nil?
|
|
359
|
+
{value: value, origin: origin, entity: entity, line: line, explicit: true}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def seo_fallback_date?(graph, document, context)
|
|
363
|
+
return false unless seo_plugin_active?(context)
|
|
364
|
+
return false if explicit_sources(document, :modified).any?
|
|
365
|
+
graph["datePublished"] && graph["dateModified"] && graph["datePublished"] == graph["dateModified"]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def seo_plugin_active?(context)
|
|
369
|
+
plugins = context[:plugins] || context.dig(:site_config, :plugins) || context.dig(:site_config, "plugins") || []
|
|
370
|
+
Array(plugins).any? { |plugin| plugin.to_s == "jekyll-seo-tag" }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def source_observation(source)
|
|
374
|
+
{value: source[:value], origin: source[:origin], entity: source[:entity], line: source[:line]}
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def locations_for(sources)
|
|
378
|
+
sources.map { |source| {line: source[:line], entity: source[:entity], origin: source[:origin]} }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def scripts(document)
|
|
382
|
+
Array(document[:jsonld_scripts] || document["jsonld_scripts"])
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def line(script)
|
|
386
|
+
script[:line] || script["line"]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def article?(node)
|
|
390
|
+
Array(node["@type"]).any? { |type| ARTICLE_TYPES.include?(type) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def normalize_name(value)
|
|
394
|
+
value.to_s.strip.gsub(/\s+/, " ") unless value.nil?
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def timezone(context)
|
|
398
|
+
context.dig(:site_config, :timezone) || context.dig(:site_config, "timezone")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def timezone_free_timestamp?(value, context)
|
|
402
|
+
value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/) &&
|
|
403
|
+
value.match?(/T[^Z+\-]*\z/) && timezone(context).nil?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def draft(document, message, observation, line = nil, related_locations = [])
|
|
407
|
+
{document: document, message: message, observation: observation, line: line, related_locations: related_locations}
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|