nous 0.4.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.
@@ -0,0 +1,37 @@
1
+ # Visible extraction failures
2
+
3
+ Status: implemented; run this recipe on the target revision before claiming verification.
4
+ This is the accepted readiness requirement, with pages-only compatibility preserved.
5
+
6
+ **Outcome FP-1:** A caller invokes `Nous.fetch(..., details: true)` after successful
7
+ acquisition of unreadable HTML. The detailed result retains a visible failure for
8
+ that original and must not report `all_succeeded?`. An empty successful page list
9
+ alone is insufficient for Substrate/Autodidact's explicit-outcome handoff.
10
+
11
+ **Entry point:** Public `Nous.fetch`, single-page mode (`limit: 1`), detailed results.
12
+ Default pages-only behavior and legacy runner specs are not changed by this recipe.
13
+
14
+ **Prerequisites:** Follow [launch and doctor](README.md). The opt-in harness at
15
+ `spec/verification/failure_propagation.rb` stubs one HTTP response with WebMock;
16
+ all real network is disabled. It exercises real crawling, parsing and collection.
17
+ No account or persistent record is created.
18
+
19
+ **Drive:**
20
+
21
+ ```bash
22
+ BUNDLE_FROZEN=true bundle exec rake verify:failure_propagation > "$receipt_dir/failure-propagation.log" 2>&1
23
+ printf '%s\n' "$?" > "$receipt_dir/failure-propagation.exit"
24
+ ```
25
+
26
+ **Assertions:** Failed original URL remains visible in `failures`; `all_succeeded?`
27
+ is false and no page is fabricated. Require exit zero. The ordinary integration suite also covers mixed acquisition and
28
+ extraction failures, readable page order, in-flight batch limits with complete attempt
29
+ accounting, original unexpected extractor errors, recursive Windows-1252 response
30
+ decoding with header precedence, and fetch-mode selection at execution after
31
+ construction or configuration changes. Configuration remains global; these checks
32
+ do not establish isolation between concurrent top-level fetch calls.
33
+
34
+ **Evidence and cleanup:** Follow [capture and cleanup](README.md). Retain the failed
35
+ log and exit status. WebMock and Nous configuration reset with the test process.
36
+ This focused check alone does not prove stable error categories,
37
+ unexpected diagnostic causes, resource exhaustion or consumer integration.
@@ -0,0 +1,37 @@
1
+ # Retained HTML
2
+
3
+ Status: implemented; execute on the target source before claiming verification.
4
+
5
+ **Outcomes:** RH-1 accepts retained HTML bytes through `Nous::ExtractHtml.call`
6
+ without network or input mutation, returning readable content, title and source
7
+ metadata. RH-2 selects `:text` or default `:markdown`. RH-3 applies header charset,
8
+ then BOM, then meta in the first 1024 bytes, then UTF-8, rejecting invalid bytes,
9
+ NULs and unsupported requests. RH-4 enforces positive input and combined title/content
10
+ UTF-8 output budgets. RH-5 separates expected failures from safe unexpected failures
11
+ with diagnostic causes; concurrent calls retain their own content and metadata.
12
+ Legacy `Extractor::Default#extract` remains compatible.
13
+
14
+ **Prerequisites:** Follow [launch and doctor](README.md). The synthetic existing
15
+ fixtures and inline examples contain no private captures. No identity or durable
16
+ state is involved. WebMock disables network. The bundled Defuddle parser runs locally under Node; process/dependency failures
17
+ are controlled in diagnostic tests. No real websites are requested.
18
+
19
+ **Drive:**
20
+
21
+ ```bash
22
+ BUNDLE_FROZEN=true bundle exec rake verify:retained_html > "$receipt_dir/retained-html.log" 2>&1
23
+ printf '%s\n' "$?" > "$receipt_dir/retained-html.exit"
24
+ ```
25
+
26
+ **Assertions:** Require exit zero. The task runs public retained-command tests and
27
+ legacy default extractor tests. They check title/content, selector compatibility,
28
+ unreadable failure, header/meta conflict, BOM/meta/default charset, invalid bytes,
29
+ NULs, formats/types, real charset declarations versus examples, byte thresholds, title-inclusive multibyte budgets, safe
30
+ unexpected causes during decoder parsing and client execution, empty final conversion and concurrent isolation. See
31
+ [the public contract](../../README.md#retained-html-extraction) for supported policy.
32
+ There is no durable application write to read back.
33
+
34
+ **Evidence and cleanup:** Follow [capture and cleanup](README.md). Preserve log,
35
+ exit status and source identity. This proves synthetic local parser behavior,
36
+ not all real-world document quality, JavaScript rendering, authentication detection,
37
+ process CPU/memory/wall-time limits or Substrate/Autodidact/hosted compatibility.
@@ -0,0 +1,24 @@
1
+ import { build } from 'esbuild';
2
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
3
+
4
+ await build({
5
+ entryPoints: ['javascript/defuddle.mjs'],
6
+ bundle: true,
7
+ minify: true,
8
+ platform: 'node',
9
+ format: 'esm',
10
+ target: 'node22',
11
+ outfile: 'vendor/defuddle.mjs',
12
+ legalComments: 'linked',
13
+ });
14
+
15
+ // Include upstream license texts, which minified packages may omit from comments.
16
+ const lock = JSON.parse(await readFile('package-lock.json', 'utf8'));
17
+ const notices = [];
18
+ for (const [directory, info] of Object.entries(lock.packages)) {
19
+ if (!directory || info.dev) continue;
20
+ const files = (await readdir(directory)).filter(name => /^(license|licence|copying)(\.|$)/i.test(name));
21
+ if (!files.length) throw new Error(`Missing license text: ${directory}`);
22
+ notices.push(`${directory} (${info.version})\n${(await Promise.all(files.map(name => readFile(`${directory}/${name}`, 'utf8')))).join('\n')}`);
23
+ }
24
+ await writeFile('vendor/THIRD_PARTY_LICENSES.txt', notices.join('\n\n========================================\n\n'));
@@ -0,0 +1,28 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { Defuddle } from 'defuddle/node';
3
+
4
+ // Retained extraction must never reacquire content, including site-specific APIs.
5
+ globalThis.fetch = async () => { throw new Error('Network disabled during extraction'); };
6
+ console.log = console.warn = console.error = () => {};
7
+
8
+ try {
9
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ required');
10
+ process.stdin.setEncoding('utf8');
11
+ let input = '';
12
+ for await (const chunk of process.stdin) input += chunk;
13
+ const { html, url, selector } = JSON.parse(input);
14
+ const { document } = parseHTML(html);
15
+ const result = await Defuddle(document, url, {
16
+ separateMarkdown: true,
17
+ useAsync: false,
18
+ contentSelector: selector || undefined,
19
+ });
20
+ process.stdout.write(JSON.stringify({
21
+ title: result.title || '',
22
+ content: result.content,
23
+ markdown: result.contentMarkdown,
24
+ }));
25
+ } catch {
26
+ process.stderr.write('Defuddle extraction failed\n');
27
+ process.exitCode = 1;
28
+ }
data/lib/nous/cli.rb CHANGED
@@ -40,9 +40,9 @@ module Nous
40
40
  end
41
41
 
42
42
  def extractor
43
- return Extractor::Jina.new if options[:jina]
43
+ return Extractor::Default.new(selector: options[:selector]) if options[:readability]
44
44
 
45
- Extractor::Default.new(selector: options[:selector])
45
+ Extractor::Defuddle.new(selector: options[:selector])
46
46
  end
47
47
 
48
48
  def validate!
@@ -78,7 +78,7 @@ module Nous
78
78
  opts.on("-s", "--selector SELECTOR", "CSS selector to scope extraction") { |v| options[:selector] = v }
79
79
  opts.on("-l", "--limit N", Integer, "Maximum pages to fetch") { |v| options[:limit] = v }
80
80
  opts.on("--timeout N", Integer, "Per-request timeout in seconds (default: 15)") { |v| options[:timeout] = v }
81
- opts.on("--jina", "Use Jina Reader API for extraction (handles JS-rendered sites)") { options[:jina] = true }
81
+ opts.on("--readability", "Use the Ruby Readability backup extractor") { options[:readability] = true }
82
82
  opts.on("-r", "--recursive", "Follow same-host links recursively") { options[:recursive] = true }
83
83
  opts.on("-d", "--debug", "Debug logging to stderr") { options[:debug] = true }
84
84
  opts.on("-v", "--version", "Show version") do
@@ -10,24 +10,34 @@ module Nous
10
10
  @seed_uri = Url.new(seed_url)
11
11
  @http_client = http_client
12
12
  @records = []
13
+ @successful_count = 0
14
+ @total_requested = 0
13
15
  @queue = [url_filter.canonicalize(seed_uri)]
14
16
  @seen = Set.new(queue)
15
17
  end
16
18
 
17
19
  def call
18
- suppress_async_warnings unless config.debug?
19
-
20
- open_connection do |client|
21
- crawl(client)
22
- end
23
-
24
- success(payload: records)
20
+ prepare_crawl_logging
21
+ acquire_linked_records
22
+ report_acquired_records
25
23
  end
26
24
 
27
25
  private
28
26
 
29
27
  attr_reader :seed_uri, :http_client, :records, :queue, :seen
30
28
 
29
+ def prepare_crawl_logging
30
+ suppress_async_warnings unless config.debug?
31
+ end
32
+
33
+ def acquire_linked_records
34
+ open_connection { |client| crawl(client) }
35
+ end
36
+
37
+ def report_acquired_records
38
+ success(payload: records, metadata: {total_requested: @total_requested})
39
+ end
40
+
31
41
  def config
32
42
  Nous.configuration
33
43
  end
@@ -38,25 +48,23 @@ module Nous
38
48
 
39
49
  def fetch_and_enqueue(batch, client)
40
50
  fetch_batch(batch, client).each do |record|
41
- next unless record.ok
42
- break unless within_limit?
51
+ @total_requested += 1
52
+ next if record.ok && !within_limit?
43
53
 
44
54
  records << record
55
+ next unless record.ok
56
+ @successful_count += 1
45
57
  seen << record.final_url
46
58
  enqueue_links(record)
47
59
  end
48
60
  end
49
61
 
50
62
  def fetch_batch(urls, client)
51
- tasks = []
52
-
53
63
  Async do |task|
54
- urls.each do |url|
55
- tasks << task.async { page_fetcher(client).fetch(url) }
64
+ urls.map do |url|
65
+ task.async { page_fetcher(client).fetch(url) }
56
66
  end
57
- end.wait
58
-
59
- tasks.map(&:wait)
67
+ end.wait.map(&:wait)
60
68
  end
61
69
 
62
70
  def enqueue_links(record)
@@ -69,7 +77,7 @@ module Nous
69
77
  end
70
78
 
71
79
  def within_limit?
72
- records.count(&:ok) < config.limit
80
+ @successful_count < config.limit
73
81
  end
74
82
 
75
83
  def open_connection
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Nous
6
+ # Owns charset precedence and byte validation for both HTML entry points.
7
+ class DecodeHtml < Command
8
+ class Error < StandardError; end
9
+ class InvalidContent < Error; end
10
+ class UnsupportedContent < Error; end
11
+
12
+ def initialize(html:, content_type:)
13
+ @html = html
14
+ @content_type = content_type
15
+ end
16
+
17
+ def call
18
+ decode_bytes
19
+ report_decoding
20
+ rescue Error => error
21
+ failure(error)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :html, :content_type
27
+
28
+ def select_encoding(name)
29
+ Encoding.find(name)
30
+ rescue ArgumentError
31
+ raise UnsupportedContent, "Unsupported HTML charset"
32
+ end
33
+
34
+ def decode_bytes
35
+ raise InvalidContent, "HTML must be a byte string" unless html.is_a?(String)
36
+ bytes = html.b
37
+ declared = charset_parameter(content_type.to_s)
38
+ @encoding = select_encoding(declared || bom_encoding(bytes) || meta_encoding(bytes) || "UTF-8")
39
+ encoded = bytes.dup.force_encoding(@encoding)
40
+ raise InvalidContent, "HTML contains invalid encoded bytes" unless encoded.valid_encoding?
41
+
42
+ @decoded = encoded.encode(Encoding::UTF_8)
43
+ @decoded = @decoded.delete_prefix("\uFEFF")
44
+ raise InvalidContent, "HTML contains NUL characters" if @decoded.include?("\0")
45
+ rescue EncodingError
46
+ raise InvalidContent, "HTML cannot be decoded as UTF-8"
47
+ end
48
+
49
+ def bom_encoding(bytes)
50
+ return "UTF-8" if bytes.start_with?("\xEF\xBB\xBF".b)
51
+ return "UTF-32BE" if bytes.start_with?("\x00\x00\xFE\xFF".b)
52
+ return "UTF-32LE" if bytes.start_with?("\xFF\xFE\x00\x00".b)
53
+ return "UTF-16BE" if bytes.start_with?("\xFE\xFF".b)
54
+ "UTF-16LE" if bytes.start_with?("\xFF\xFE".b)
55
+ end
56
+
57
+ def charset_parameter(value)
58
+ value.scan(/(?:\A|;)\s*([^;=\s]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^;\s]*))/).each do |name, quoted, single_quoted, bare|
59
+ return quoted || single_quoted || bare if name.casecmp?("charset")
60
+ end
61
+ nil
62
+ end
63
+
64
+ def meta_encoding(bytes)
65
+ document = Nokogiri::HTML(bytes.byteslice(0, 1024), nil, "ISO-8859-1")
66
+ document.css("meta").each do |meta|
67
+ return meta["charset"] if meta["charset"]
68
+ if meta["http-equiv"].to_s.casecmp?("content-type")
69
+ declared = charset_parameter(meta["content"].to_s)
70
+ return declared if declared
71
+ end
72
+ end
73
+ nil
74
+ end
75
+
76
+ def report_decoding
77
+ success(payload: @decoded, metadata: {encoding: @encoding.name})
78
+ end
79
+ end
80
+ end
@@ -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
@@ -2,8 +2,6 @@
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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nous
4
+ module Extractor
5
+ class ExtractionError < StandardError; end
6
+ end
7
+ end
@@ -11,21 +11,36 @@ module Nous
11
11
  end
12
12
 
13
13
  def call
14
- pages = records.each_slice(Nous.configuration.concurrency).each_with_object([]) do |batch, results|
15
- threads = batch.map { |record| Thread.new { PageExtractor.call(extractor:, record:) } }
14
+ extract_records
15
+ report_extraction_outcomes
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :records, :extractor, :pages, :failures
16
21
 
17
- threads.each do |thread|
18
- result = thread.value
19
- results << result.payload if result.success?
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
- private
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
- attr_reader :records, :extractor
41
+ def report_extraction_outcomes
42
+ success(payload: pages, metadata: {failures: failures})
43
+ end
29
44
  end
30
45
  end
31
46
  end