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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/verify-nous/SKILL.md +6 -0
  3. data/CHANGELOG.md +75 -1
  4. data/CODING_STANDARDS.md +203 -0
  5. data/README.md +175 -17
  6. data/Rakefile +2 -0
  7. data/docs/setup/defuddle.md +38 -0
  8. data/docs/setup/verification.md +21 -0
  9. data/docs/verification/README.md +77 -0
  10. data/docs/verification/defuddle.md +45 -0
  11. data/docs/verification/failure-propagation.md +37 -0
  12. data/docs/verification/retained-html.md +37 -0
  13. data/javascript/build.mjs +24 -0
  14. data/javascript/defuddle.mjs +28 -0
  15. data/lib/nous/cli.rb +3 -3
  16. data/lib/nous/crawler/async_page_fetcher.rb +43 -11
  17. data/lib/nous/crawler/recursive_page_fetcher.rb +34 -26
  18. data/lib/nous/crawler/single_page_fetcher.rb +49 -9
  19. data/lib/nous/decode_html.rb +80 -0
  20. data/lib/nous/extract_html.rb +109 -0
  21. data/lib/nous/extractor/default/client.rb +30 -12
  22. data/lib/nous/extractor/default.rb +2 -4
  23. data/lib/nous/extractor/defuddle/client.rb +57 -0
  24. data/lib/nous/extractor/defuddle/run_process.rb +104 -0
  25. data/lib/nous/extractor/defuddle.rb +23 -0
  26. data/lib/nous/extractor/extraction_error.rb +7 -0
  27. data/lib/nous/fetcher/extraction_runner.rb +26 -11
  28. data/lib/nous/fetcher/page_extractor.rb +27 -12
  29. data/lib/nous/fetcher.rb +46 -10
  30. data/lib/nous/primitives/configuration.rb +1 -0
  31. data/lib/nous/primitives/fetch_record.rb +26 -0
  32. data/lib/nous/primitives/fetch_result.rb +21 -0
  33. data/lib/nous/primitives/page.rb +1 -1
  34. data/lib/nous/serializer.rb +9 -1
  35. data/lib/nous/version.rb +1 -1
  36. data/lib/nous.rb +2 -2
  37. data/package-lock.json +858 -0
  38. data/package.json +15 -0
  39. data/tasks/verification.rake +23 -0
  40. data/vendor/THIRD_PARTY_LICENSES.txt +518 -0
  41. data/vendor/defuddle.mjs +403 -0
  42. data/vendor/defuddle.mjs.LEGAL.txt +10 -0
  43. metadata +25 -4
  44. data/lib/nous/extractor/jina/client.rb +0 -59
  45. data/lib/nous/extractor/jina.rb +0 -26
  46. data/lib/nous/primitives/raw_page.rb +0 -5
@@ -0,0 +1,77 @@
1
+ # Nous verification
2
+
3
+ The project-local [verify-nous skill](../../.agents/skills/verify-nous/SKILL.md)
4
+ uses this guide. Initial scope is the two selected architecture candidates:
5
+ retained-HTML extraction and visible extraction failures. This map does not
6
+ certify all crawling, rendering, serialization or consumer behavior.
7
+
8
+ | Recipe | State | Coverage |
9
+ | --- | --- | --- |
10
+ | [Defuddle](defuddle.md) | Implemented; run required | Bundled parser, selectors, process failures, concurrency and packaging |
11
+ | [Retained HTML](retained-html.md) | Implemented; run required | Offline content/provenance, formats, decoding, byte budgets, errors and legacy compatibility |
12
+ | [Failure propagation](failure-propagation.md) | Implemented; run required | Public detailed result must retain extraction failure |
13
+
14
+ ## Launch
15
+
16
+ Follow [setup](../setup/verification.md). These are one-shot Ruby commands; no
17
+ persistent process is launched. Start an evidence directory for this run:
18
+
19
+ ```bash
20
+ mkdir -p tmp/verification
21
+ receipt_dir="$(mktemp -d "$PWD/tmp/verification/run-XXXXXX")"
22
+ export receipt_dir
23
+ ```
24
+
25
+ Keep the same shell variable for the commands below. Do not pipe through `tee`
26
+ without preserving the original exit status.
27
+
28
+ ## Doctor
29
+
30
+ ```bash
31
+ ruby -v > "$receipt_dir/ruby.log" 2>&1
32
+ printf '%s\n' "$?" > "$receipt_dir/ruby.exit"
33
+ BUNDLE_FROZEN=true bundle check > "$receipt_dir/bundle.log" 2>&1
34
+ printf '%s\n' "$?" > "$receipt_dir/bundle.exit"
35
+ git rev-parse HEAD > "$receipt_dir/head.txt"
36
+ git status --short > "$receipt_dir/status.txt"
37
+ git diff --binary HEAD > "$receipt_dir/working-tree.patch"
38
+ BUNDLE_FROZEN=true bundle exec ruby -Ilib -r nous -e 'puts Nous::VERSION; puts $LOADED_FEATURES.grep(%r{/lib/nous.rb$})' > "$receipt_dir/target.log" 2>&1
39
+ printf '%s\n' "$?" > "$receipt_dir/target.exit"
40
+ git ls-files --others --exclude-standard -z | BUNDLE_FROZEN=true bundle exec ruby -rdigest -e 'STDIN.read.split("\0").each { |path| puts "#{Digest::SHA256.file(path).hexdigest} #{path}" if File.file?(path) }' > "$receipt_dir/untracked.sha256"
41
+ ```
42
+
43
+ Inspect each exit file and the target path before driving: Ruby, bundle and target
44
+ must exit zero and target must resolve this checkout. Untracked hashes identify
45
+ new recipes/harnesses; retain their contents with the change. For committed work,
46
+ HEAD identifies the source; for dirty work retain the patch and hashes too.
47
+
48
+ ## Drive and capture
49
+
50
+ Discover opt-in checks with `bundle exec rake -T`.
51
+ Run each selected recipe's exact command, redirect its output to the named log,
52
+ and immediately record `$?` in its exit file. A nonzero product assertion stays
53
+ failed; do not weaken it to current behavior. For a prerequisite failure, attempt
54
+ one documented setup recovery, retry once, then mark blocked if unavailable.
55
+
56
+ Record a receipt table with criterion, repository/revision, command, expected,
57
+ observed, evidence path and pass/fail/blocked/not-run status. Keep failed logs.
58
+ Do not treat successful neighbors as readiness for a failed or unimplemented goal.
59
+
60
+ For Ruby or harness changes also run `BUNDLE_FROZEN=true bundle exec rspec` and
61
+ `BUNDLE_FROZEN=true bundle exec standardrb`, capturing output and exit status.
62
+ The draft readiness harness is opt-in and is not part of ordinary specs.
63
+
64
+ ## Cleanup
65
+
66
+ These checks leave no server, child process, storage record or fixture changes.
67
+ RSpec may update the ignored `.rspec_status`; it is not evidence. Keep
68
+ `$receipt_dir` and all logs after cleanup; do not delete the evidence directory.
69
+ Never use private captures for the synthetic fixture checks.
70
+
71
+ ## Coverage limits
72
+
73
+ Real-world readability quality, process CPU/memory/wall-time supervision,
74
+ Substrate integration, locked Autodidact compatibility and hosted acceptance remain
75
+ outside this map. Neither local backend renders JavaScript.
76
+ The caller owns immutable storage and any application lifecycle. Passing library
77
+ recipes alone does not certify downstream adoption.
@@ -0,0 +1,45 @@
1
+ # Defuddle extraction
2
+
3
+ Status: implemented; execute on the source under review.
4
+
5
+ Outcomes: Defuddle is the default for `Nous.fetch`, `Fetcher` and `ExtractHtml`.
6
+ The CLI selects Defuddle unless `--readability` is supplied. Jina is removed.
7
+ The extractor consumes already acquired HTML and the final URL, returns title
8
+ and Markdown through the existing result contract, honors selectors, resolves
9
+ relative links, and keeps concurrent calls isolated. Empty content and runtime
10
+ failures remain failures; no automatic Readability fallback occurs.
11
+
12
+ Follow [launch and doctor](README.md) and [Node readiness](../setup/defuddle.md).
13
+ Then run:
14
+
15
+ ```bash
16
+ BUNDLE_FROZEN=true bundle exec rake verify:defuddle > "$receipt_dir/defuddle.log" 2>&1
17
+ printf '%s\n' "$?" > "$receipt_dir/defuddle.exit"
18
+ ```
19
+
20
+ Require exit zero. The extractor specs exercise the actual bundled parser with
21
+ synthetic HTML. Client specs cover JSON/field validation, empty content and propagation of a
22
+ failed process result. `RunProcess` specs independently exercise raw process
23
+ output, nonzero exit, output limits, reaping and timeout cleanup with controlled
24
+ Ruby subprocesses. Missing-runtime and safe-diagnostic errors are covered through `RunProcess`. CLI specs check backend selection and observable selector behavior. Acquired-byte
25
+ specs cover BOM/meta decoding and meaningful invalid-byte/charset failures.
26
+ Run the retained-HTML and failure-propagation recipes, full suite and Standard too.
27
+
28
+ For installed-runtime and packaging compatibility, run the separate opt-in check:
29
+
30
+ ```bash
31
+ BUNDLE_FROZEN=true bundle exec rake verify:defuddle_package > "$receipt_dir/defuddle-package.log" 2>&1
32
+ printf '%s\n' "$?" > "$receipt_dir/defuddle-package.exit"
33
+ ```
34
+
35
+ Require exit zero. The harness in `spec/verification/defuddle_package.rb` builds
36
+ and unpacks the gem in an isolated temporary directory, verifies the loaded Nous
37
+ path, checks Jina is absent, and asserts title/content through `ExtractHtml`.
38
+ It owns and cleans up the temporary package on success and failure. This verifies
39
+ that extraction does not resolve npm dependencies from the checkout.
40
+
41
+ Record source identity, commands, exit statuses and logs using the verification
42
+ guide. Runtime checks establish compatibility only with the Node/platform tested;
43
+ controlled subprocesses do not establish Node compatibility. Synthetic fixtures
44
+ do not establish extraction quality across live sites, JavaScript completeness,
45
+ authentication detection or downstream Substrate acceptance.
@@ -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
@@ -13,20 +13,30 @@ module Nous
13
13
  def fetch(url)
14
14
  Async::Task.current.with_timeout(config.timeout) do
15
15
  result = RedirectFollower.call(client:, seed_host:, url:)
16
- return skip(url, result.error.message) if result.failure?
16
+ return build_failed_record(url, result.error.message) if result.failure?
17
17
 
18
18
  response, final_url = result.payload
19
- return skip(url, "status #{response.status}") unless response.status == 200
20
- return skip(url, "non-html content") unless html?(response)
19
+ content_type = response.headers["content-type"].to_s
20
+ redirected = final_url.to_s != url
21
21
 
22
- RawPage.new(url: final_url.to_s, pathname: final_url.path, html: response.read)
22
+ return build_failed_record(url, "status #{response.status}") unless response.status == 200
23
+ return build_failed_record(url, "non-html content") unless html?(content_type)
24
+
25
+ build_success_record(
26
+ url: url,
27
+ final_url: final_url.to_s,
28
+ pathname: final_url.path,
29
+ html: response.read,
30
+ content_type: content_type,
31
+ redirected: redirected
32
+ )
23
33
  ensure
24
34
  response&.close
25
35
  end
26
36
  rescue Async::TimeoutError
27
- skip(url, "timeout after #{config.timeout}s")
37
+ build_failed_record(url, "timeout after #{config.timeout}s")
28
38
  rescue IOError, SocketError, Errno::ECONNREFUSED => e
29
- skip(url, e.message)
39
+ build_failed_record(url, e.message)
30
40
  end
31
41
 
32
42
  private
@@ -37,14 +47,36 @@ module Nous
37
47
  Nous.configuration
38
48
  end
39
49
 
40
- def html?(response)
41
- content_type = response.headers["content-type"].to_s
50
+ def html?(content_type)
42
51
  HTML_CONTENT_TYPES.any? { |type| content_type.include?(type) }
43
52
  end
44
53
 
45
- def skip(url, reason)
46
- warn("[nous] skip #{url}: #{reason}") if config.debug?
47
- nil
54
+ def build_success_record(url:, final_url:, pathname:, html:, content_type:, redirected:)
55
+ FetchRecord.new(
56
+ requested_url: url,
57
+ final_url: final_url,
58
+ pathname: pathname,
59
+ html: html,
60
+ content_type: content_type,
61
+ ok: true,
62
+ error: nil,
63
+ redirected: redirected
64
+ )
65
+ end
66
+
67
+ def build_failed_record(url, error)
68
+ FetchRecord.new(
69
+ requested_url: url,
70
+ final_url: nil,
71
+ pathname: Url.new(url).path,
72
+ html: nil,
73
+ content_type: nil,
74
+ ok: false,
75
+ error: error,
76
+ redirected: false
77
+ ).tap do |record|
78
+ warn("[nous] skip #{url}: #{error}") if config.debug?
79
+ end
48
80
  end
49
81
  end
50
82
  end
@@ -9,24 +9,34 @@ module Nous
9
9
  def initialize(seed_url:, http_client: nil)
10
10
  @seed_uri = Url.new(seed_url)
11
11
  @http_client = http_client
12
- @pages = []
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?
20
+ prepare_crawl_logging
21
+ acquire_linked_records
22
+ report_acquired_records
23
+ end
19
24
 
20
- open_connection do |client|
21
- crawl(client)
22
- end
25
+ private
23
26
 
24
- success(payload: pages)
27
+ attr_reader :seed_uri, :http_client, :records, :queue, :seen
28
+
29
+ def prepare_crawl_logging
30
+ suppress_async_warnings unless config.debug?
25
31
  end
26
32
 
27
- private
33
+ def acquire_linked_records
34
+ open_connection { |client| crawl(client) }
35
+ end
28
36
 
29
- attr_reader :seed_uri, :http_client, :pages, :queue, :seen
37
+ def report_acquired_records
38
+ success(payload: records, metadata: {total_requested: @total_requested})
39
+ end
30
40
 
31
41
  def config
32
42
  Nous.configuration
@@ -37,30 +47,28 @@ module Nous
37
47
  end
38
48
 
39
49
  def fetch_and_enqueue(batch, client)
40
- fetch_batch(batch, client).each do |page|
41
- next unless page
42
- break unless within_limit?
43
-
44
- pages << page
45
- seen << page.url
46
- enqueue_links(page)
50
+ fetch_batch(batch, client).each do |record|
51
+ @total_requested += 1
52
+ next if record.ok && !within_limit?
53
+
54
+ records << record
55
+ next unless record.ok
56
+ @successful_count += 1
57
+ seen << record.final_url
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
- def enqueue_links(page)
63
- link_extractor.extract(page.url, page.html).each do |url|
70
+ def enqueue_links(record)
71
+ link_extractor.extract(record.final_url, record.html).each do |url|
64
72
  next if seen.include?(url)
65
73
 
66
74
  seen << url
@@ -69,7 +77,7 @@ module Nous
69
77
  end
70
78
 
71
79
  def within_limit?
72
- pages.length < config.limit
80
+ @successful_count < config.limit
73
81
  end
74
82
 
75
83
  def open_connection
@@ -91,7 +99,7 @@ module Nous
91
99
  end
92
100
 
93
101
  def link_extractor
94
- @link_extractor ||= LinkExtractor.new(url_filter:)
102
+ @link_filter ||= LinkExtractor.new(url_filter:)
95
103
  end
96
104
 
97
105
  def suppress_async_warnings
@@ -20,16 +20,26 @@ module Nous
20
20
  def call
21
21
  response = connection.get(url)
22
22
  final_url = resolve_final_url(response)
23
+ content_type = response.headers["content-type"].to_s
24
+ redirected = final_url.to_s != url
25
+
26
+ record = build_record(
27
+ final_url: final_url.to_s,
28
+ pathname: final_url.path,
29
+ html: response.body,
30
+ content_type: content_type,
31
+ redirected: redirected
32
+ )
23
33
 
24
- validate_host!(final_url)
25
- validate_html!(response)
34
+ validate!(record)
26
35
 
27
- raw_page = RawPage.new(url: final_url.to_s, pathname: final_url.path, html: response.body)
28
- success(payload: [raw_page])
36
+ success(payload: [record])
29
37
  rescue FetchError => e
30
- failure(e)
38
+ record = build_failed_record(error: e.message)
39
+ success(payload: [record])
31
40
  rescue Faraday::Error => e
32
- failure(FetchError.new(e.message))
41
+ record = build_failed_record(error: e.message)
42
+ success(payload: [record])
33
43
  end
34
44
 
35
45
  private
@@ -40,19 +50,49 @@ module Nous
40
50
  Nous.configuration
41
51
  end
42
52
 
53
+ def build_record(final_url:, pathname:, html:, content_type:, redirected:)
54
+ FetchRecord.new(
55
+ requested_url: url,
56
+ final_url: final_url,
57
+ pathname: pathname,
58
+ html: html,
59
+ content_type: content_type,
60
+ ok: true,
61
+ error: nil,
62
+ redirected: redirected
63
+ )
64
+ end
65
+
66
+ def build_failed_record(error:)
67
+ FetchRecord.new(
68
+ requested_url: url,
69
+ final_url: nil,
70
+ pathname: Url.new(url).path,
71
+ html: nil,
72
+ content_type: nil,
73
+ ok: false,
74
+ error: error,
75
+ redirected: false
76
+ )
77
+ end
78
+
79
+ def validate!(record)
80
+ validate_host!(record.final_url)
81
+ validate_html!(record.content_type)
82
+ end
83
+
43
84
  def resolve_final_url(response)
44
85
  location = response.env.url.to_s
45
86
  Url.new(location)
46
87
  end
47
88
 
48
89
  def validate_host!(final_url)
49
- return if final_url.host == seed_host
90
+ return if Url.new(final_url).host == seed_host
50
91
 
51
92
  raise FetchError, "redirected to #{final_url} outside #{seed_host}"
52
93
  end
53
94
 
54
- def validate_html!(response)
55
- content_type = response.headers["content-type"].to_s
95
+ def validate_html!(content_type)
56
96
  return if HTML_CONTENT_TYPES.any? { |type| content_type.include?(type) }
57
97
 
58
98
  raise FetchError, "non-html content: #{content_type}"
@@ -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