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.
- checksums.yaml +4 -4
- data/.agents/skills/verify-nous/SKILL.md +6 -0
- data/CHANGELOG.md +23 -1
- data/CODING_STANDARDS.md +203 -0
- data/README.md +104 -15
- 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/recursive_page_fetcher.rb +25 -17
- data/lib/nous/decode_html.rb +80 -0
- data/lib/nous/extract_html.rb +109 -0
- data/lib/nous/extractor/default.rb +0 -2
- 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 +24 -9
- data/lib/nous/fetcher/page_extractor.rb +15 -6
- data/lib/nous/fetcher.rb +35 -25
- data/lib/nous/version.rb +1 -1
- data/lib/nous.rb +1 -1
- 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 +23 -3
- data/lib/nous/extractor/jina/client.rb +0 -59
- data/lib/nous/extractor/jina.rb +0 -26
|
@@ -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::
|
|
43
|
+
return Extractor::Default.new(selector: options[:selector]) if options[:readability]
|
|
44
44
|
|
|
45
|
-
Extractor::
|
|
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("--
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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.
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -11,21 +11,36 @@ module Nous
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
extract_records
|
|
15
|
+
report_extraction_outcomes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :records, :extractor, :pages, :failures
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|