nous 0.3.0 → 0.5.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 +4 -4
- data/.agents/skills/verify-nous/SKILL.md +6 -0
- data/CHANGELOG.md +75 -1
- data/CODING_STANDARDS.md +203 -0
- data/README.md +175 -17
- data/Rakefile +2 -0
- data/docs/setup/defuddle.md +38 -0
- data/docs/setup/verification.md +21 -0
- data/docs/verification/README.md +77 -0
- data/docs/verification/defuddle.md +45 -0
- data/docs/verification/failure-propagation.md +37 -0
- data/docs/verification/retained-html.md +37 -0
- data/javascript/build.mjs +24 -0
- data/javascript/defuddle.mjs +28 -0
- data/lib/nous/cli.rb +3 -3
- data/lib/nous/crawler/async_page_fetcher.rb +43 -11
- data/lib/nous/crawler/recursive_page_fetcher.rb +34 -26
- data/lib/nous/crawler/single_page_fetcher.rb +49 -9
- data/lib/nous/decode_html.rb +80 -0
- data/lib/nous/extract_html.rb +109 -0
- data/lib/nous/extractor/default/client.rb +30 -12
- data/lib/nous/extractor/default.rb +2 -4
- data/lib/nous/extractor/defuddle/client.rb +57 -0
- data/lib/nous/extractor/defuddle/run_process.rb +104 -0
- data/lib/nous/extractor/defuddle.rb +23 -0
- data/lib/nous/extractor/extraction_error.rb +7 -0
- data/lib/nous/fetcher/extraction_runner.rb +26 -11
- data/lib/nous/fetcher/page_extractor.rb +27 -12
- data/lib/nous/fetcher.rb +46 -10
- data/lib/nous/primitives/configuration.rb +1 -0
- data/lib/nous/primitives/fetch_record.rb +26 -0
- data/lib/nous/primitives/fetch_result.rb +21 -0
- data/lib/nous/primitives/page.rb +1 -1
- data/lib/nous/serializer.rb +9 -1
- data/lib/nous/version.rb +1 -1
- data/lib/nous.rb +2 -2
- data/package-lock.json +858 -0
- data/package.json +15 -0
- data/tasks/verification.rake +23 -0
- data/vendor/THIRD_PARTY_LICENSES.txt +518 -0
- data/vendor/defuddle.mjs +403 -0
- data/vendor/defuddle.mjs.LEGAL.txt +10 -0
- metadata +25 -4
- data/lib/nous/extractor/jina/client.rb +0 -59
- data/lib/nous/extractor/jina.rb +0 -26
- data/lib/nous/primitives/raw_page.rb +0 -5
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Nous
|
|
6
|
+
class ExtractHtml < Command
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class InvalidLimits < ArgumentError; end
|
|
9
|
+
class InvalidContent < Error; end
|
|
10
|
+
class NoContent < Error; end
|
|
11
|
+
class UnsupportedContent < Error; end
|
|
12
|
+
class InputLimitExceeded < Error; end
|
|
13
|
+
class OutputLimitExceeded < Error; end
|
|
14
|
+
|
|
15
|
+
class UnexpectedFailure < Error
|
|
16
|
+
attr_reader :cause
|
|
17
|
+
|
|
18
|
+
def initialize(cause)
|
|
19
|
+
@cause = cause
|
|
20
|
+
super("HTML extraction failed unexpectedly")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(html:, source_url:, content_type: "text/html", format: :markdown,
|
|
25
|
+
max_input_bytes: 25 * 1024 * 1024, max_output_bytes: 32 * 1024 * 1024)
|
|
26
|
+
@html = html
|
|
27
|
+
@source_url = source_url
|
|
28
|
+
@content_type = content_type
|
|
29
|
+
@format = format
|
|
30
|
+
@max_input_bytes = max_input_bytes
|
|
31
|
+
@max_output_bytes = max_output_bytes
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
verify_extraction_request
|
|
36
|
+
decode_retained_html
|
|
37
|
+
extract_readable_content
|
|
38
|
+
render_content
|
|
39
|
+
verify_extracted_content
|
|
40
|
+
report_extraction
|
|
41
|
+
rescue Error, InvalidLimits => error
|
|
42
|
+
failure(error)
|
|
43
|
+
rescue => error
|
|
44
|
+
failure(UnexpectedFailure.new(error))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :html, :source_url, :content_type, :format, :max_input_bytes, :max_output_bytes
|
|
50
|
+
|
|
51
|
+
def verify_extraction_request
|
|
52
|
+
unless [max_input_bytes, max_output_bytes].all? { |limit| limit.is_a?(Integer) && limit.positive? }
|
|
53
|
+
raise InvalidLimits, "Byte limits must be positive integers"
|
|
54
|
+
end
|
|
55
|
+
raise InvalidContent, "HTML must be a byte string" unless html.is_a?(String)
|
|
56
|
+
raise InputLimitExceeded, "HTML exceeds input byte limit" if html.bytesize > max_input_bytes
|
|
57
|
+
raise UnsupportedContent, "Unsupported output format" unless [:markdown, :text].include?(format)
|
|
58
|
+
unless content_type.is_a?(String) && ["text/html", "application/xhtml+xml"].include?(content_type.split(";", 2).first.to_s.strip.downcase)
|
|
59
|
+
raise UnsupportedContent, "Unsupported HTML content type"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decode_retained_html
|
|
64
|
+
result = DecodeHtml.new(html: html, content_type: content_type).call
|
|
65
|
+
raise result.error if result.failure?
|
|
66
|
+
|
|
67
|
+
@decoded = result.payload
|
|
68
|
+
@encoding = result.metadata.fetch(:encoding)
|
|
69
|
+
rescue DecodeHtml::InvalidContent => error
|
|
70
|
+
raise InvalidContent, error.message
|
|
71
|
+
rescue DecodeHtml::UnsupportedContent => error
|
|
72
|
+
raise UnsupportedContent, error.message
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_readable_content
|
|
76
|
+
result = Extractor::Defuddle::Client.new(html: @decoded, url: source_url).call
|
|
77
|
+
raise result.error if result.failure?
|
|
78
|
+
|
|
79
|
+
@readable = result.payload
|
|
80
|
+
@title = @readable[:title]
|
|
81
|
+
rescue Extractor::Defuddle::Client::NoContent => error
|
|
82
|
+
raise NoContent.new("HTML has no readable content"), cause: error
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_content
|
|
86
|
+
@content = (format == :text) ? render_plain_text : @readable[:markdown]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_plain_text
|
|
90
|
+
Nokogiri::HTML.fragment(@readable[:content]).tap do |fragment|
|
|
91
|
+
fragment.css("p, div, h1, h2, h3, h4, h5, h6, li, tr, br").each do |node|
|
|
92
|
+
node.add_next_sibling(Nokogiri::XML::Text.new(" ", fragment.document))
|
|
93
|
+
end
|
|
94
|
+
end.text.gsub(/\s+/, " ").strip
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def verify_extracted_content
|
|
98
|
+
raise NoContent, "HTML has no readable content" if @content.strip.empty?
|
|
99
|
+
if @content.bytesize + @title.bytesize > max_output_bytes
|
|
100
|
+
raise OutputLimitExceeded, "Extracted content exceeds output byte limit"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def report_extraction
|
|
105
|
+
success(payload: ExtractedContent.new(title: @title, content: @content),
|
|
106
|
+
metadata: {source_url: source_url, content_type: content_type, encoding: @encoding, format: format, limitations: ["static_html_only", "authentication_not_detected"]})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -8,7 +8,7 @@ module Nous
|
|
|
8
8
|
class Client < Command
|
|
9
9
|
class ExtractionError < StandardError; end
|
|
10
10
|
|
|
11
|
-
NOISY_TAGS = %w[script style
|
|
11
|
+
NOISY_TAGS = %w[script style nav footer].freeze
|
|
12
12
|
|
|
13
13
|
def initialize(html:, selector: nil)
|
|
14
14
|
@html = html
|
|
@@ -16,34 +16,52 @@ module Nous
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def call
|
|
19
|
-
|
|
20
|
-
doc = scope_to_selector(doc) if selector
|
|
21
|
-
strip_noisy_tags(doc)
|
|
19
|
+
readable = ::Readability::Document.new(prepared_html)
|
|
22
20
|
|
|
23
|
-
readable = ::Readability::Document.new(doc.to_html)
|
|
24
21
|
text = Nokogiri::HTML(readable.content).text.strip
|
|
25
|
-
|
|
26
22
|
return failure(ExtractionError.new("readability returned no content")) if text.empty?
|
|
27
23
|
|
|
28
|
-
|
|
24
|
+
title = resolve_title(readable)
|
|
25
|
+
success(payload: {title: title, content: readable.content})
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
private
|
|
32
29
|
|
|
33
30
|
attr_reader :html, :selector
|
|
34
31
|
|
|
35
|
-
def
|
|
32
|
+
def prepared_html
|
|
33
|
+
doc = Nokogiri::HTML(html)
|
|
34
|
+
original_title(doc)
|
|
35
|
+
doc = scope(doc, selector) if selector
|
|
36
|
+
strip_tags(doc)
|
|
37
|
+
doc.to_html
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def original_title(doc)
|
|
41
|
+
@original_title ||= doc.at_css("title")&.text.to_s.strip
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scope(doc, selector)
|
|
36
45
|
scoped = doc.at_css(selector)
|
|
37
46
|
return doc unless scoped
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
fragment.root = scoped
|
|
41
|
-
fragment
|
|
48
|
+
Nokogiri::HTML.fragment(scoped.to_html)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
def
|
|
51
|
+
def strip_tags(doc)
|
|
45
52
|
NOISY_TAGS.each { |tag| doc.css(tag).each(&:remove) }
|
|
46
53
|
end
|
|
54
|
+
|
|
55
|
+
def resolve_title(readable)
|
|
56
|
+
title = readable.title.to_s.strip
|
|
57
|
+
title = @original_title if title.empty?
|
|
58
|
+
title = title_from_content(readable.content) if title.empty?
|
|
59
|
+
title
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def title_from_content(content)
|
|
63
|
+
Nokogiri::HTML(content).at_css("h1")&.text.to_s.strip
|
|
64
|
+
end
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
end
|
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Nous
|
|
4
4
|
module Extractor
|
|
5
|
-
class ExtractionError < StandardError; end
|
|
6
|
-
|
|
7
5
|
class Default < Command
|
|
8
6
|
def initialize(selector: nil)
|
|
9
7
|
@selector = selector
|
|
10
8
|
end
|
|
11
9
|
|
|
12
|
-
def extract(
|
|
13
|
-
extracted = extract_content(
|
|
10
|
+
def extract(record)
|
|
11
|
+
extracted = extract_content(record.html)
|
|
14
12
|
markdown = convert_to_markdown(extracted[:content])
|
|
15
13
|
|
|
16
14
|
success(payload: ExtractedContent.new(title: extracted[:title], content: markdown))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Nous
|
|
6
|
+
module Extractor
|
|
7
|
+
class Defuddle
|
|
8
|
+
class Client < Command
|
|
9
|
+
class NoContent < ExtractionError; end
|
|
10
|
+
ProcessError = RunProcess::Error
|
|
11
|
+
|
|
12
|
+
def initialize(html:, url:, selector: nil)
|
|
13
|
+
@html = html
|
|
14
|
+
@url = url
|
|
15
|
+
@selector = selector
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
run_defuddle
|
|
20
|
+
validate_response
|
|
21
|
+
report_content
|
|
22
|
+
rescue NoContent, ProcessError => error
|
|
23
|
+
failure(error)
|
|
24
|
+
rescue JSON::JSONError, EncodingError
|
|
25
|
+
failure(ProcessError.new("Invalid Defuddle process response"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_defuddle
|
|
31
|
+
request = JSON.generate(html: @html, url: @url, selector: @selector)
|
|
32
|
+
result = RunProcess.call(request: request)
|
|
33
|
+
raise result.error if result.failure?
|
|
34
|
+
|
|
35
|
+
@response = JSON.parse(result.payload, symbolize_names: true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_response
|
|
39
|
+
unless @response.is_a?(Hash) && [:title, :content, :markdown].all? { |key| valid_text?(@response[key]) }
|
|
40
|
+
raise ProcessError, "Invalid Defuddle response fields"
|
|
41
|
+
end
|
|
42
|
+
if @response[:markdown].strip.empty?
|
|
43
|
+
raise NoContent, "HTML has no readable content"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid_text?(value)
|
|
48
|
+
value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? && !value.include?("\0")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def report_content
|
|
52
|
+
success(payload: @response)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Nous
|
|
7
|
+
module Extractor
|
|
8
|
+
class Defuddle
|
|
9
|
+
class RunProcess < Command
|
|
10
|
+
class Error < ExtractionError; end
|
|
11
|
+
|
|
12
|
+
BRIDGE = File.expand_path("../../../../vendor/defuddle.mjs", __dir__)
|
|
13
|
+
OUTPUT_LIMIT = 64 * 1024 * 1024
|
|
14
|
+
TIMEOUT = 15
|
|
15
|
+
|
|
16
|
+
def initialize(request:)
|
|
17
|
+
@request = request
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
execute_bridge
|
|
22
|
+
report_output
|
|
23
|
+
rescue Error => error
|
|
24
|
+
failure(error)
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
failure(Error.new("Defuddle requires Node.js 22 or newer on PATH"))
|
|
27
|
+
rescue Timeout::Error
|
|
28
|
+
failure(Error.new("Defuddle extraction timed out"))
|
|
29
|
+
rescue IOError, SystemCallError
|
|
30
|
+
failure(Error.new("Invalid Defuddle process response"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def execute_bridge
|
|
36
|
+
Open3.popen3("node", BRIDGE, pgroup: true) do |stdin, stdout, stderr, child|
|
|
37
|
+
workers = {}
|
|
38
|
+
begin
|
|
39
|
+
Timeout.timeout(TIMEOUT) do
|
|
40
|
+
@output = exchange_content(stdin, stdout, stderr, workers)
|
|
41
|
+
raise Error, "Defuddle extraction failed" unless child.value.success?
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
terminate(child.pid)
|
|
45
|
+
[stdin, stdout, stderr].each { |io| io.close unless io.closed? }
|
|
46
|
+
workers.each_value { |worker| finish_worker(worker) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def exchange_content(stdin, stdout, stderr, workers)
|
|
52
|
+
workers[:input] = start_worker {
|
|
53
|
+
stdin.write(@request)
|
|
54
|
+
stdin.close
|
|
55
|
+
}
|
|
56
|
+
workers[:output] = start_worker { read_bounded(stdout, OUTPUT_LIMIT) }
|
|
57
|
+
workers[:diagnostics] = start_worker { read_bounded(stderr, 64 * 1024) }
|
|
58
|
+
collect_output(workers)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect_output(workers)
|
|
62
|
+
output = workers.fetch(:output).value
|
|
63
|
+
workers.fetch(:input).value
|
|
64
|
+
workers.fetch(:diagnostics).value
|
|
65
|
+
output
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def start_worker(&operation)
|
|
69
|
+
Thread.new do
|
|
70
|
+
Thread.current.report_on_exception = false
|
|
71
|
+
operation.call
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finish_worker(worker)
|
|
76
|
+
worker.join
|
|
77
|
+
rescue
|
|
78
|
+
# Keep the original failure while joining every owned worker.
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def terminate(pid)
|
|
83
|
+
Process.kill("KILL", -pid)
|
|
84
|
+
rescue Errno::ESRCH
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_bounded(io, limit)
|
|
89
|
+
output = +""
|
|
90
|
+
loop do
|
|
91
|
+
output << io.readpartial(16 * 1024)
|
|
92
|
+
raise Error, "Defuddle process output exceeds byte limit" if output.bytesize > limit
|
|
93
|
+
end
|
|
94
|
+
rescue EOFError
|
|
95
|
+
output
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def report_output
|
|
99
|
+
success(payload: @output)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nous
|
|
4
|
+
module Extractor
|
|
5
|
+
class Defuddle < Command
|
|
6
|
+
def initialize(selector: nil)
|
|
7
|
+
@selector = selector
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def extract(record)
|
|
11
|
+
decoded = DecodeHtml.call(html: record.html, content_type: record.content_type)
|
|
12
|
+
raise decoded.error if decoded.failure?
|
|
13
|
+
|
|
14
|
+
result = Client.call(html: decoded.payload, url: record.final_url, selector: @selector)
|
|
15
|
+
return result if result.failure?
|
|
16
|
+
|
|
17
|
+
success(payload: ExtractedContent.new(title: result.payload[:title], content: result.payload[:markdown]))
|
|
18
|
+
rescue DecodeHtml::Error => error
|
|
19
|
+
failure(ExtractionError.new(error.message))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -5,27 +5,42 @@ module Nous
|
|
|
5
5
|
class ExtractionRunner < Command
|
|
6
6
|
class ExtractionError < StandardError; end
|
|
7
7
|
|
|
8
|
-
def initialize(
|
|
9
|
-
@
|
|
8
|
+
def initialize(records:, extractor:)
|
|
9
|
+
@records = records
|
|
10
10
|
@extractor = extractor
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
extract_records
|
|
15
|
+
report_extraction_outcomes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
attr_reader :records, :extractor, :pages, :failures
|
|
21
|
+
|
|
22
|
+
def extract_records
|
|
23
|
+
@pages = []
|
|
24
|
+
@failures = []
|
|
25
|
+
records.each_slice(Nous.configuration.concurrency) do |batch|
|
|
26
|
+
threads = batch.map { |record| Thread.new { PageExtractor.call(extractor:, record:) } }
|
|
27
|
+
batch.zip(threads).each do |record, thread|
|
|
28
|
+
retain_outcome(record, thread.value)
|
|
20
29
|
end
|
|
21
30
|
end
|
|
22
|
-
|
|
23
|
-
success(payload: pages)
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
def retain_outcome(record, result)
|
|
34
|
+
if result.success?
|
|
35
|
+
pages << result.payload
|
|
36
|
+
else
|
|
37
|
+
failures << {requested_url: record.requested_url, error: result.error}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
def report_extraction_outcomes
|
|
42
|
+
success(payload: pages, metadata: {failures: failures})
|
|
43
|
+
end
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|
|
@@ -3,32 +3,47 @@
|
|
|
3
3
|
module Nous
|
|
4
4
|
class Fetcher < Command
|
|
5
5
|
class PageExtractor < Command
|
|
6
|
-
def initialize(extractor:,
|
|
6
|
+
def initialize(extractor:, record:)
|
|
7
7
|
@extractor = extractor
|
|
8
|
-
@
|
|
8
|
+
@record = record
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
|
-
|
|
12
|
+
extract_content
|
|
13
|
+
report_page
|
|
14
|
+
rescue => error
|
|
15
|
+
failure(error)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :extractor, :record
|
|
21
|
+
|
|
22
|
+
def extract_content
|
|
23
|
+
@extraction = extractor.extract(record)
|
|
24
|
+
end
|
|
13
25
|
|
|
26
|
+
def report_page
|
|
27
|
+
result = @extraction
|
|
14
28
|
unless result.success?
|
|
15
|
-
warn("[nous] extract skip #{
|
|
29
|
+
warn("[nous] extract skip #{record.final_url}: #{result.error.message}") if Nous.configuration.debug?
|
|
16
30
|
return failure(result.error)
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
page = Page.new(
|
|
20
34
|
title: result.payload.title,
|
|
21
|
-
url:
|
|
22
|
-
pathname:
|
|
23
|
-
content: result.payload.content
|
|
35
|
+
url: record.final_url,
|
|
36
|
+
pathname: record.pathname,
|
|
37
|
+
content: result.payload.content,
|
|
38
|
+
metadata: {
|
|
39
|
+
extractor: extractor.class.name,
|
|
40
|
+
requested_url: record.requested_url,
|
|
41
|
+
content_type: record.content_type,
|
|
42
|
+
redirected: record.redirected
|
|
43
|
+
}
|
|
24
44
|
)
|
|
25
|
-
|
|
26
45
|
success(payload: page)
|
|
27
46
|
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
attr_reader :extractor, :raw_page
|
|
32
47
|
end
|
|
33
48
|
end
|
|
34
49
|
end
|
data/lib/nous/fetcher.rb
CHANGED
|
@@ -4,34 +4,70 @@ module Nous
|
|
|
4
4
|
class Fetcher < Command
|
|
5
5
|
class FetchError < StandardError; end
|
|
6
6
|
|
|
7
|
-
def initialize(seed_url:, extractor: Extractor::
|
|
7
|
+
def initialize(seed_url:, extractor: Extractor::Defuddle.new, http_client: nil, details: false)
|
|
8
8
|
@seed_url = seed_url
|
|
9
9
|
@extractor = extractor
|
|
10
10
|
@http_client = http_client
|
|
11
|
+
@details = details
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def call
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
resolve_fetch_mode
|
|
16
|
+
acquire_records
|
|
17
|
+
verify_acquisition
|
|
18
|
+
extract_readable_pages
|
|
19
|
+
report_fetch_outcomes
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
private
|
|
20
23
|
|
|
21
|
-
attr_reader :seed_url, :extractor, :http_client
|
|
24
|
+
attr_reader :seed_url, :extractor, :http_client, :single_page, :details
|
|
25
|
+
|
|
26
|
+
def resolve_fetch_mode
|
|
27
|
+
@single_page = Nous.configuration.single_page?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def acquire_records
|
|
31
|
+
@records = crawl
|
|
32
|
+
@successful_records, @failed_records = @records.partition(&:ok)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def verify_acquisition
|
|
36
|
+
if single_page && !details && @successful_records.empty?
|
|
37
|
+
raise FetchError, @failed_records.first&.error || "fetch failed"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_readable_pages
|
|
42
|
+
@extraction = ExtractionRunner.call(records: @successful_records, extractor:)
|
|
43
|
+
raise @extraction.error if @extraction.failure?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def report_fetch_outcomes
|
|
47
|
+
return success(payload: @extraction.payload) unless details
|
|
48
|
+
|
|
49
|
+
success(payload: FetchResult.new(
|
|
50
|
+
pages: @extraction.payload,
|
|
51
|
+
failures: build_failures(@failed_records) + @extraction.metadata.fetch(:failures),
|
|
52
|
+
total_requested: @total_requested
|
|
53
|
+
))
|
|
54
|
+
end
|
|
22
55
|
|
|
23
56
|
def crawl
|
|
24
57
|
result = Crawler.call(seed_url:, http_client:)
|
|
25
58
|
raise FetchError, result.error.message if result.failure?
|
|
26
59
|
|
|
60
|
+
@total_requested = result.metadata.fetch(:total_requested, result.payload.length)
|
|
27
61
|
result.payload
|
|
28
62
|
end
|
|
29
63
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
def build_failures(records)
|
|
65
|
+
records.map do |record|
|
|
66
|
+
{
|
|
67
|
+
requested_url: record.requested_url,
|
|
68
|
+
error: record.error
|
|
69
|
+
}
|
|
70
|
+
end
|
|
35
71
|
end
|
|
36
72
|
end
|
|
37
73
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nous
|
|
4
|
+
FetchRecord = Data.define(
|
|
5
|
+
:requested_url,
|
|
6
|
+
:final_url,
|
|
7
|
+
:pathname,
|
|
8
|
+
:html,
|
|
9
|
+
:content_type,
|
|
10
|
+
:ok,
|
|
11
|
+
:error,
|
|
12
|
+
:redirected
|
|
13
|
+
) do
|
|
14
|
+
def initialize(
|
|
15
|
+
requested_url:,
|
|
16
|
+
pathname:, final_url: nil,
|
|
17
|
+
html: nil,
|
|
18
|
+
content_type: nil,
|
|
19
|
+
ok: true,
|
|
20
|
+
error: nil,
|
|
21
|
+
redirected: false
|
|
22
|
+
)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nous
|
|
4
|
+
FetchResult = Data.define(:pages, :failures, :total_requested) do
|
|
5
|
+
def succeeded
|
|
6
|
+
pages.length
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def failed
|
|
10
|
+
failures.length
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def all_succeeded?
|
|
14
|
+
failures.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def any_succeeded?
|
|
18
|
+
pages.any?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/nous/primitives/page.rb
CHANGED
data/lib/nous/serializer.rb
CHANGED
|
@@ -43,6 +43,8 @@ module Nous
|
|
|
43
43
|
<page>
|
|
44
44
|
<title>#{page.title}</title>
|
|
45
45
|
<url>#{page.url}</url>
|
|
46
|
+
<pathname>#{page.pathname}</pathname>
|
|
47
|
+
<extractor>#{page.metadata[:extractor]}</extractor>
|
|
46
48
|
<content>
|
|
47
49
|
#{page.content}
|
|
48
50
|
</content>
|
|
@@ -51,7 +53,13 @@ module Nous
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def json_page(page)
|
|
54
|
-
{
|
|
56
|
+
{
|
|
57
|
+
title: page.title,
|
|
58
|
+
url: page.url,
|
|
59
|
+
pathname: page.pathname,
|
|
60
|
+
content: page.content,
|
|
61
|
+
metadata: page.metadata
|
|
62
|
+
}
|
|
55
63
|
end
|
|
56
64
|
end
|
|
57
65
|
end
|
data/lib/nous/version.rb
CHANGED