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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7636d207654dbf38a64aeec480164c0e57b3c8bf98ac8373e576f692896fb3a3
4
- data.tar.gz: 62ae3b01ec837d71caf104710c42bde82df6d50e6c7acc50252f2902ef9b2046
3
+ metadata.gz: e4371689843d5f5275e56afaaab2630ba2a703e57e29cfb79d6d47d19040d934
4
+ data.tar.gz: 1402e3ab54931a99bbad484002345514142a9ade10453938c8f2feae77ef7742
5
5
  SHA512:
6
- metadata.gz: af52f527a8720d46cd00f3a42814d432730d105aed05ebb2435f1546afb2140bd88fd1e2c6f4e75c0226afd5ef6c9072c6919518bae366047eb022f24b30ffcd
7
- data.tar.gz: '049b133f406f694771617c34d3adfef2aa64ef3aa5608d89d98230b2f596e4cdef831e53f9ee039aa247a6c588900fc51cfd07a7b456dec3ee524e83abe08b93'
6
+ metadata.gz: 14a0487839b29fa46c277e341e4c33b631bb22380cbb2ee91954fcff61f264856baf981f2bc8a6e20b6da3e8a81c7c26c7178db5f96d96f5be0e2a53676d72f6
7
+ data.tar.gz: 8e2eeb9b3e790ea85f79d877c19176569918e3030fff096950c6de7771e3ebe9556cdeb82b16a67cb8d9c6ea166c6634c3fec6c40ea8f72ffa9168b0c4fa9370
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: verify-nous
3
+ description: Verify Nous retained-HTML extraction and detailed fetch outcomes through offline Ruby recipes; use when changing extraction or assessing Substrate and Autodidact readiness.
4
+ ---
5
+
6
+ Read [the verification guide](../../../docs/verification/README.md) from the repository root. Select the affected recipe, execute launch, doctor, drive, evidence capture and cleanup, and report every exit status. Follow the guide's source-identification and blocker rules. A draft or failed recipe does not certify readiness. Keep provisioning in the linked setup guide; do not add live network calls to these checks.
data/CHANGELOG.md CHANGED
@@ -1,4 +1,72 @@
1
- ## [Unreleased]
1
+ ## [0.5.0] - 2026-10-25
2
+
3
+ ### Added
4
+
5
+ - `Nous::ExtractHtml.call` extracts retained HTML without network acquisition, with
6
+ Markdown or plain text, source metadata, explicit decoding errors and caller byte limits.
7
+
8
+ ### Changed
9
+
10
+ - Defuddle is the default for URL and retained-HTML extraction. The gem bundles
11
+ its JavaScript dependencies and requires Node.js 22+ on macOS/Linux.
12
+ - Readability remains available through `Extractor::Default` and `--readability`.
13
+ Extraction failures do not silently switch backends.
14
+
15
+ ### Removed
16
+
17
+ - Jina Reader integration (`Extractor::Jina`, `--jina`, `JINA_API_KEY`). Neither
18
+ remaining backend renders JavaScript pages.
19
+
20
+ ### Fixed
21
+
22
+ - Detailed fetch results retain extraction failures and all attempted failures from
23
+ concurrent crawl batches, with accurate request counts and unchanged page limits.
24
+
25
+ ## [0.4.0] - 2026-04-11
26
+
27
+ ### Added
28
+
29
+ - **New `details: true` option for `Nous.fetch`** - Returns a `FetchResult` object containing both successful pages and failed fetch/extraction attempts. This enables explicit failure handling without exceptions.
30
+ ```ruby
31
+ result = Nous.fetch("https://example.com", details: true)
32
+ result.pages # Array<Page> - successfully extracted
33
+ result.failures # [{requested_url:, error:}, ...]
34
+ ```
35
+
36
+ - **Page metadata** - Every extracted page now includes provenance information:
37
+ - `extractor`: Which extractor backend was used (e.g., "Nous::Extractor::Default")
38
+ - `requested_url`: The original URL before any redirects
39
+ - `content_type`: HTTP Content-Type header from the response
40
+ - `redirected`: Boolean indicating if redirects occurred
41
+
42
+ - **FetchRecord internal primitive** - Unified fetch result representation that captures both success and failure cases with full provenance tracking. Replaces the previous `RawPage` which only handled successful fetches.
43
+
44
+ - **Configuration#single_page? helper** - Convenience predicate method for checking if the current configuration is in single-page (non-recursive) mode.
45
+
46
+ ### Changed
47
+
48
+ - **Improved title extraction** - Title extraction now uses a fallback chain: readability extracted title → HTML `<title>` tag → first `<h1>` element. This significantly improves title reliability on pages where readability fails to identify the title.
49
+
50
+ - **Reduced aggressive DOM stripping** - The default extractor now preserves more content before readability processing. Previously removed elements (`header`, `img`, `video`, `svg`, `link`) are now retained, providing better context for readability scoring and preserving useful content like captions and bylines.
51
+
52
+ - **Unified fetch contract** - Both single-page and recursive crawling now use the same internal `FetchRecord` structure, ensuring consistent provenance tracking and failure handling across all fetch modes.
53
+
54
+ - **Serializer schema updated** - Both text and JSON output formats now include:
55
+ - `pathname`: URL path component
56
+ - `extractor`: Which extractor processed the page
57
+ - Full metadata object (JSON only)
58
+
59
+ ### Fixed
60
+
61
+ - **JSON serialization** - The JSON output now correctly includes the `pathname` field that was documented but missing in previous versions.
62
+
63
+ - **Extraction failure visibility** - Previously, extraction failures were only visible with debug logging enabled. The new `FetchResult` structure makes failures programmatically accessible.
64
+
65
+ ### Internal Changes
66
+
67
+ - **Duck-typed extractor interface** - Extractors now receive the full `FetchRecord` object and can access the fields they need (`Default` uses `record.html`, `Jina` uses `record.final_url`).
68
+
69
+ - **Removed `RawPage` primitive** - Superseded by the richer `FetchRecord` which handles both success and failure uniformly.
2
70
 
3
71
  ## [0.3.0] - 2026-02-23
4
72
 
@@ -29,3 +97,9 @@
29
97
  ## [0.1.0] - 2026-02-21
30
98
 
31
99
  - Initial release
100
+
101
+ [Unreleased]: https://github.com/danfrenette/nous/compare/v0.4.0...HEAD
102
+ [0.4.0]: https://github.com/danfrenette/nous/compare/v0.3.0...v0.4.0
103
+ [0.3.0]: https://github.com/danfrenette/nous/compare/v0.2.0...v0.3.0
104
+ [0.2.0]: https://github.com/danfrenette/nous/compare/v0.1.0...v0.2.0
105
+ [0.1.0]: https://github.com/danfrenette/nous/releases/tag/v0.1.0
@@ -0,0 +1,203 @@
1
+ # Coding standards
2
+
3
+ Read these standards before implementation and include them in Anaphor's Standards
4
+ review. They apply to this Ruby gem and its development tools. Existing code is not
5
+ a blanket exception: when changing an affected boundary, address deviations or
6
+ record a concrete compatibility reason in the PR. This document does not itself
7
+ change existing public APIs.
8
+
9
+ ## Responsibilities and service objects
10
+
11
+ Keep crawling, readable-content extraction, conversion, and serialization distinct.
12
+ Commands perform operations; fetch records describe acquired originals; pages describe
13
+ extracted content and provenance. Keep HTTP details in crawler and remote extractor
14
+ adapters. Follow the existing Zeitwerk layout; `lib/nous/primitives/` is collapsed
15
+ into the `Nous` namespace.
16
+
17
+ A command exposes one public instance operation, `call`, invoked through inherited
18
+ `.call`. Its initializer accepts explicit inputs and dependencies and stores state;
19
+ construction must not launch processes, perform network requests, or do business
20
+ work. Resolve environment-dependent defaults when the operation runs.
21
+
22
+ Application-service instance `call` methods that describe business operations must
23
+ contain only bareword calls to private methods named for business functions. Read
24
+ them as prose: one named step per line, without arguments, explicit receivers,
25
+ assignments, conditionals, loops, blocks, or explicit returns. A `rescue` clause
26
+ is the only exception; it may map an expected exception to a failure result.
27
+ Put decisions, resource scopes, collaborator calls, and result construction inside
28
+ the private steps. The final step returns the service result. For example:
29
+
30
+ ```ruby
31
+ def call
32
+ prepare_retained_content
33
+ extract_readable_content
34
+ build_extraction_result
35
+ rescue ArgumentError => error
36
+ failure(error)
37
+ end
38
+ ```
39
+
40
+ A failed prerequisite must stop the sequence: raise a meaningful exception handled
41
+ by the service or the existing command boundary. Returning a failure from a private
42
+ step alone does not stop later steps. Keep resource acquisition, use and cleanup
43
+ in one private scope rather than spreading temporary paths across orchestration
44
+ steps. Do not add instance state merely to split an indivisible operation.
45
+
46
+ This rule governs business-operation services, including extraction workflows. The shared class-level dispatcher and low-level value/protocol helpers
47
+ are not business orchestration. Apply the rule when changing an existing service;
48
+ this change does not claim that all historical commands have been migrated.
49
+
50
+ Before extracting an object, name the policy, resource, or collaborator contract
51
+ it will own. If no separate owner exists, keep the steps private in the current
52
+ service. Do not extract forwarding layers to satisfy a line count: they add places
53
+ to navigate without reducing the caller's obligations. Name an extraction for its
54
+ operation, not Manager, Processor, or Utils. Keep duplication when sharing would
55
+ require unrelated behavior flags; those flags couple operations that change apart.
56
+
57
+ Use Sandi Metz's five-line methods, 100-line classes, and four-parameter guideline
58
+ as review prompts, not mechanical limits. Count independent keyword options as
59
+ parameters. A growing constructor suggests mixed responsibilities; hiding those
60
+ options in a hash does not fix that. Introduce an input object only when its data
61
+ has cohesive behavior or invariants. Explain justified exceptions locally in the PR.
62
+
63
+ Apply tell, don't ask: put an operation with the object that owns its behavior.
64
+ Use a known collaborator's interface instead of branching on `is_a?`, `kind_of?`,
65
+ or `respond_to?`. At an untrusted provider boundary, validate the actual protocol
66
+ and required data before producing normalized content. Do not spread speculative
67
+ type checks through in-process callers.
68
+
69
+ ## Building and configuring objects
70
+
71
+ Prefer `tap` when configuring and returning the same object, and
72
+ `each_with_object` when accumulating into a mutable collection, instead of a
73
+ temporary local used only to initialize, mutate, and return that object. This
74
+ keeps construction and its result in one expression. Use `map` for one-to-one
75
+ transformations; keep named locals when they explain an intermediate result or
76
+ are reused. With `each_with_object`, mutate the accumulator rather than reassign
77
+ the block variable. These blocks belong inside private service steps, preserving
78
+ the bareword-only business `call` convention.
79
+
80
+ ## Arguments and defaults
81
+
82
+ For each proposed argument, inspect its callers and choose in this order:
83
+
84
+ 1. If no caller or accepted use case needs the choice, do not add the argument.
85
+ A test convenience alone does not justify expanding the public API.
86
+ 2. If every caller must supply a value, require it.
87
+ 3. If omission selects a normal value, give it a concrete default or default
88
+ expression. Do not use `nil` merely to replace it with that value later.
89
+ 4. If absence changes the operation, document it and permit `nil`. For example,
90
+ `selector: nil` leaves extraction unscoped.
91
+ 5. If automatic selection differs from an explicit override, represent that mode
92
+ explicitly and test both paths. Discover environment dependencies during `call`,
93
+ not construction, so creating the service does not launch work.
94
+
95
+ For an existing argument, record the behavior of omission, explicit `nil`, and
96
+ explicit values before editing. Preserve supported cases and test the changed
97
+ boundary. Do not silently make a strict executable override fall back to another
98
+ installation. Add a sentinel or null object only when those behaviors must differ;
99
+ otherwise it adds another state every caller must understand.
100
+
101
+ ## Results, failures, and resources
102
+
103
+ Use the existing `Nous::Command` and `Nous::Command::Result` contract in
104
+ [lib/nous/command.rb](lib/nous/command.rb): commands return `payload`, `error`, and
105
+ `metadata`; callers inspect `success?` or `failure?`. Extractors expose `extract(record)`
106
+ and return the same result contract; do not rename that protocol as a style change. Do not copy Rails `ApplicationService`, named
107
+ result fields, HTTP error codes, or reporting infrastructure into this gem.
108
+
109
+ The inherited class `.call` currently converts `StandardError` into a failure
110
+ result, wrapping post-construction errors as `CommandError`. Preserve that behavior
111
+ unless an explicit API change is accepted. Direct instance `call` and extractor
112
+ `extract` do not receive that dispatcher protection. A failed child command does not
113
+ short-circuit its parent automatically: inspect its result and stop before any
114
+ step that depends on success. Keep stage-specific errors useful without including
115
+ credentials or sensitive source content. Do not turn a failure into empty success.
116
+
117
+ One owner acquires, uses, and releases each temporary resource. Keep cleanup in a
118
+ block or `ensure` that covers success and failure, including parsing. Do not return
119
+ a temporary path after its owner has released it. Never use the original input as
120
+ an output destination. Invoke subprocesses with argument arrays, not interpolated
121
+ shell strings. Make partial external effects explicit; cleanup cannot undo them all.
122
+
123
+ ## Development and verification commands
124
+
125
+ Choose the command's owner before adding a file:
126
+
127
+ 1. For a repeatable project check, add a named Rake task. Put live checks under
128
+ `verify:` and document inputs in the owning recipe so agents can discover them
129
+ with `bundle exec rake -T`. Create these directories when a check needs them.
130
+ 2. Put verification assertions, fixture preparation, and cleanup in a focused
131
+ harness under `spec/verification/`. Keep `tasks/*.rake` limited to task inputs
132
+ and dispatch, so command plumbing does not obscure what the check proves.
133
+ 3. Use `bin/` for setup and console; the existing public command lives in `exe/nous`. Do not
134
+ add a generic scripts directory for verification commands; it creates a second
135
+ entry-point convention to discover and maintain.
136
+
137
+ Keep production behavior in the library and exercise its public API from the
138
+ harness. Never add downloads or live provider calls to default `bundle exec rake`;
139
+ ordinary verification must run without external services or model provisioning.
140
+
141
+ Document required binaries, models, credentials, supported runtime versions, and
142
+ readiness checks in `docs/setup/`. Keep executable verification steps and evidence
143
+ limits in `docs/verification/`. Link both from onboarding. Install dependencies through explicit setup
144
+ steps, never silently during a check; readiness and behavior verification are separate results. Retain evidence with
145
+ the tested source revision; never claim that simulated processes prove a local
146
+ runtime works.
147
+
148
+ ## Tests and review
149
+
150
+ Test observable outcomes through command `.call` or the owning source interface.
151
+ Do not assert private helper order. When a workflow has prerequisites, cover failure before dependent steps. When it
152
+ owns files or processes, cover cleanup and input preservation on success and
153
+ failure. When calls can overlap, prove their resources stay isolated. Use
154
+ controlled provider/process fixtures for ordinary tests and separate opt-in live
155
+ checks for actual compatibility. Keep fixtures' provenance and limitations visible.
156
+
157
+ For Ruby changes, run affected specs, the full RSpec suite, and Standard using
158
+ the Development commands in [README.md](README.md). Include an affected verification
159
+ recipe once one exists. Documentation-only changes need
160
+ link, command, and contract checks; they do not require pretending runtime behavior
161
+ changed. Anaphor reviews must distinguish documented rules, design heuristics, and
162
+ accepted requirements. A green suite does not establish compliance with every
163
+ principle or prove live-provider behavior.
164
+
165
+ ## Retained HTML convention
166
+
167
+ `ExtractHtml` keeps six explicit inputs: original bytes, source URL, content type,
168
+ output representation and two independent caller budgets. This is a deliberate
169
+ exception to the parameter heuristic: a hash or options object without cohesive
170
+ behavior would only hide the same choices. The command orchestrates decoding, readable
171
+ selection, rendering and bounded output. `DecodeHtml` owns the byte interpretation
172
+ policy shared with acquired Defuddle HTML. Within `ExtractHtml`, invoke `DecodeHtml`
173
+ and `Defuddle::Client` with `new(...).call`: this deliberate exception to inherited
174
+ `.call` preserves the original unexpected exception as `UnexpectedFailure#cause`.
175
+ The shared dispatcher wraps those exceptions and loses their identity. Keep this
176
+ exception local to the retained command's diagnostic contract and cover failures
177
+ during dependency execution. Trust the client's normalized title and Markdown;
178
+ the retained command owns final rendering, emptiness and combined-byte budgets.
179
+ Legacy shared extractors must
180
+ not acquire per-record mutable state: the fetch runner calls them concurrently.
181
+
182
+ ## Sources and adaptation
183
+
184
+ Adapted from Substrate's `CODING_STANDARDS.md` on October 25, 2026, which was
185
+ adapted from Autodidact's standards. Nous-specific responsibilities, result names,
186
+ extractor protocol, executable locations, and verification commands replace their
187
+ Substrate equivalents. Local audio conventions do not apply to Nous.
188
+ Retained service orchestration, explicit inputs, failure sequencing, ownership,
189
+ and public-interface testing. Rails controllers, Active Record, transactions,
190
+ Jbuilder, Sentry, Next.js, and that application's result shape are outside this
191
+ project's conventions. The source document provides context, not inherited
192
+ instructions to provision those systems.
193
+
194
+ - [Thoughtbot: Tell, Don't Ask](https://thoughtbot.com/blog/tell-dont-ask)
195
+ informs behavior ownership and collaborator interfaces.
196
+ - [Thoughtbot: Sandi Metz' Rules for Developers](https://thoughtbot.com/blog/sandi-metz-rules-for-developers)
197
+ supplies the size and parameter heuristics; their use as review prompts here is
198
+ a local convention.
199
+ - [Sandi Metz: The Wrong Abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction)
200
+ informs the preference for cohesive objects over premature generalization.
201
+
202
+ Nullable-default policy and Rake placement are project decisions, not rules
203
+ attributed to these authors.
data/README.md CHANGED
@@ -2,10 +2,14 @@
2
2
 
3
3
  Crawl websites and extract readable Markdown, optimized for LLM consumption. Inspired by [sitefetch](https://github.com/egoist/sitefetch).
4
4
 
5
- Nous fetches same-host pages starting from a seed URL, extracts readable content, and outputs clean Markdown as XML-tagged text or JSON. It supports concurrent crawling, glob-based URL filtering, and two extraction backends: a local parser (ruby-readability) and the Jina Reader API for JS-rendered sites.
5
+ Nous fetches same-host pages starting from a seed URL, extracts readable content, and outputs clean Markdown as XML-tagged text or JSON. It supports concurrent crawling, glob-based URL filtering, and local extraction with Defuddle by default and an explicit ruby-readability backup. Both parse acquired HTML without rendering JavaScript. Defuddle decodes acquired bytes using the same header/BOM/meta/UTF-8 precedence as retained HTML; invalid bytes produce extraction failures.
6
6
 
7
7
  ## Installation
8
8
 
9
+ Default extraction requires Node.js 22+ on PATH (macOS/Linux). The gem includes
10
+ the bundled Defuddle adapter; npm is only needed to rebuild it during development.
11
+ See [Defuddle setup](docs/setup/defuddle.md).
12
+
9
13
  Add to your Gemfile:
10
14
 
11
15
  ```ruby
@@ -39,8 +43,8 @@ nous https://example.com -m "/blog/*"
39
43
  # Scope extraction to a CSS selector
40
44
  nous https://example.com -s "article.post"
41
45
 
42
- # Use Jina Reader API for JS-rendered sites (Next.js, SPAs)
43
- nous https://example.com --jina
46
+ # Use the Ruby-only Readability backup
47
+ nous https://example.com --readability
44
48
 
45
49
  # Debug logging
46
50
  nous https://example.com -d
@@ -57,20 +61,22 @@ nous https://example.com -d
57
61
  | `-s`, `--selector SELECTOR` | CSS selector to scope extraction | none |
58
62
  | `-l`, `--limit N` | Maximum pages to fetch | `100` |
59
63
  | `--timeout N` | Per-request timeout in seconds | `15` |
60
- | `--jina` | Use Jina Reader API for extraction | off |
64
+ | `--readability` | Use the Ruby-only Readability backup | off |
61
65
  | `-v`, `--version` | Print version and exit | off |
62
66
  | `-h`, `--help` | Print usage and exit | off |
63
67
  | `-d`, `--debug` | Debug logging to stderr | off |
64
68
 
65
69
  ## Ruby API
66
70
 
71
+ ### Basic Usage
72
+
67
73
  ```ruby
68
74
  require "nous"
69
75
 
70
76
  # Fetch pages with the default extractor
71
77
  pages = Nous.fetch("https://example.com", limit: 10, concurrency: 3)
72
78
 
73
- # Each page is a Nous::Page with title, url, pathname, content
79
+ # Each page is a Nous::Page with title, url, pathname, content, metadata
74
80
  pages.each do |page|
75
81
  puts "#{page.title} (#{page.url})"
76
82
  puts page.content
@@ -82,22 +88,161 @@ text = Nous.serialize(pages, format: :text)
82
88
  # Serialize to JSON
83
89
  json = Nous.serialize(pages, format: :json)
84
90
 
85
- # Use the Jina extractor for JS-heavy sites
86
- pages = Nous.fetch("https://spa-site.com",
87
- extractor: Nous::Extractor::Jina.new,
91
+ # Select the Readability backup explicitly
92
+ pages = Nous.fetch("https://example.com",
93
+ extractor: Nous::Extractor::Default.new,
88
94
  limit: 5
89
95
  )
90
96
  ```
91
97
 
98
+ ### Detailed Results
99
+
100
+ Use the `details: true` option to receive full fetch results including failures:
101
+
102
+ ```ruby
103
+ result = Nous.fetch("https://example.com", details: true)
104
+
105
+ result.pages # Array<Nous::Page> - successfully extracted pages
106
+ result.failures # Array<{requested_url:, error:}> - failed acquisition or extraction
107
+ result.total_requested # Integer - total URLs attempted
108
+ result.all_succeeded? # Boolean - true if no failures
109
+ result.any_succeeded? # Boolean - true if at least one page extracted
110
+ ```
111
+
112
+ Acquisition failures retain their existing messages; extraction failures retain their
113
+ exception objects. Detailed results include failed recursive requests as well as
114
+ failed extraction. Pages-only calls still return successful pages in source order.
115
+ A concurrent batch may already have attempted more successful pages than the limit
116
+ allows returning. `total_requested` counts every attempted request in that batch;
117
+ failures remain visible, while excess successful pages are omitted from `pages`.
118
+ The limit still governs retained successful pages, not failed attempts.
119
+
120
+ This is useful when you need to handle failures explicitly:
121
+
122
+ ```ruby
123
+ result = Nous.fetch("https://example.com/api-docs", details: true)
124
+
125
+ if result.failures.any?
126
+ puts "Failed to fetch:"
127
+ result.failures.each do |failure|
128
+ puts " #{failure[:requested_url]}: #{failure[:error]}"
129
+ end
130
+ end
131
+
132
+ result.pages.each do |page|
133
+ puts "Successfully extracted: #{page.title}"
134
+ end
135
+ ```
136
+
137
+ ### Retained HTML extraction
138
+
139
+ Use saved bytes directly, without crawling or reacquiring a URL:
140
+
141
+ ```ruby
142
+ result = Nous::ExtractHtml.call(
143
+ html: File.binread("saved.html"),
144
+ source_url: "https://example.com/article",
145
+ content_type: "text/html; charset=utf-8",
146
+ format: :text
147
+ )
148
+ raise result.error if result.failure?
149
+ puts result.payload.title
150
+ puts result.payload.content
151
+ puts result.metadata
152
+ ```
153
+
154
+ `html:` and `source_url:` are required. The URL is provenance only; this command
155
+ never requests it. `content_type:` defaults to `"text/html"`; HTML and XHTML are
156
+ supported. `format:` defaults to `:markdown`; `:text` returns readable text with
157
+ collapsed whitespace and separated blocks. Defuddle is used for both formats. Legacy `Extractor::Default#extract`
158
+ continues producing Markdown with its existing selector behavior.
159
+
160
+ Character decoding uses a declared content-type charset first, then a UTF BOM,
161
+ then an HTML meta charset in the first 1024 bytes, then UTF-8. Input is treated as
162
+ bytes regardless of its Ruby encoding label and is never modified. Invalid byte
163
+ sequences and decoded NULs fail; unsupported charset names fail explicitly. A
164
+ conflicting meta declaration does not override a declared header charset.
165
+
166
+ The result payload remains `Nous::ExtractedContent` (`title`, `content`). Metadata
167
+ contains `source_url`, original `content_type`, selected `encoding`, `format`, and
168
+ limitations `static_html_only` and `authentication_not_detected`. Static readable
169
+ content does not prove that an authenticated or JavaScript-rendered page is complete.
170
+
171
+ `max_input_bytes:` defaults to 25 MiB; `max_output_bytes:` defaults to 32 MiB.
172
+ Both require positive integers. Input is checked before copying/parsing. Output
173
+ counts the combined UTF-8 bytes of title and content, excluding caller-supplied
174
+ provenance metadata. These are input/result guards, not peak-memory, CPU or wall-time
175
+ limits; callers needing those protections must retain process supervision.
176
+
177
+ Expected errors are nested under `Nous::ExtractHtml`:
178
+
179
+ | Error | Meaning |
180
+ | --- | --- |
181
+ | `InvalidContent` | Invalid encoded bytes, NULs, or invalid decoded output |
182
+ | `UnsupportedContent` | Unsupported media type, output format or charset |
183
+ | `NoContent` | No readable content or empty converted output |
184
+ | `InputLimitExceeded` / `OutputLimitExceeded` | Caller byte budget exceeded |
185
+ | `InvalidLimits` (`ArgumentError`) | Nonpositive or noninteger byte limit |
186
+ | `UnexpectedFailure` | Safe outward message; original exception in `cause` |
187
+
188
+ Treat diagnostic causes as private debugging data; their messages can contain
189
+ source content. Rails acquisition, storage, lifecycle and process supervision are
190
+ outside this command. See the [verification map](docs/verification/README.md) for
191
+ executed surfaces and remaining consumer acceptance.
192
+
193
+ ### Page Structure
194
+
195
+ Each extracted page contains:
196
+
197
+ | Field | Type | Description |
198
+ |-------|------|-------------|
199
+ | `title` | String | Page title selected by the extraction backend |
200
+ | `url` | String | Final URL after redirects |
201
+ | `pathname` | String | URL path component |
202
+ | `content` | String | Extracted content as Markdown |
203
+ | `metadata` | Hash | Provenance information (see below) |
204
+
205
+ ### Page Metadata
206
+
207
+ ```ruby
208
+ page.metadata # => {
209
+ # extractor: "Nous::Extractor::Defuddle", # Which extractor was used
210
+ # requested_url: "https://example.com/blog", # Original URL before redirects
211
+ # content_type: "text/html; charset=utf-8", # HTTP Content-Type header
212
+ # redirected: true # Whether redirects occurred
213
+ # }
214
+ ```
215
+
92
216
  ## Extraction Backends
93
217
 
94
- ### Default (ruby-readability)
218
+ ### Defuddle (default)
219
+
220
+ Runs the bundled Defuddle parser in Node.js against the acquired HTML and final
221
+ URL. Returns Markdown directly, retaining structure such as code blocks, tables,
222
+ and links. Supports `--selector`. Each extraction owns its process, with a
223
+ 15-second deadline, a 64 MiB JSON response limit and a 64 KiB diagnostic limit.
224
+ These are separate from HTTP timeouts and retained-command byte budgets.
225
+ Page scripts do not execute and third-party fallback fetching is disabled.
226
+ Failures remain visible through `details: true`; there is no automatic switch
227
+ to Readability. `ExtractHtml` uses this same engine while retaining its decoding,
228
+ plain-text rendering and byte-budget contract.
95
229
 
96
- Parses static HTML using [ruby-readability](https://github.com/cantino/ruby-readability), strips noisy elements (nav, footer, script, header), and converts to Markdown via [reverse_markdown](https://github.com/xijo/reverse_markdown). Fast and requires no external services, but cannot extract content from JS-rendered pages.
230
+ ### Readability (explicit backup)
97
231
 
98
- ### Jina Reader API
232
+ Parses static HTML using [ruby-readability](https://github.com/cantino/ruby-readability), strips noisy elements (script, style, nav, footer), and converts to Markdown via [reverse_markdown](https://github.com/xijo/reverse_markdown). Fast and requires no external services, but cannot extract content from JS-rendered pages.
99
233
 
100
- Uses the [Jina Reader API](https://jina.ai/reader/) which renders pages with headless Chrome. Handles Next.js App Router, React Server Components, SPAs, and other JS-heavy sites. Free tier allows 20 requests/minute without a key, or 500 RPM with a `JINA_API_KEY` environment variable.
234
+ Title extraction uses a fallback chain:
235
+ 1. Readability's extracted title
236
+ 2. Original `<title>` tag from HTML
237
+ 3. First `<h1>` from extracted content
238
+
239
+ ### Migrating from Jina
240
+
241
+ `Extractor::Jina` and `--jina` have been removed. Remove the extractor argument or
242
+ flag to use Defuddle; `JINA_API_KEY` is no longer used. This retires remote browser
243
+ rendering: pages requiring JavaScript may yield incomplete or no readable content.
244
+ Use `Extractor::Default.new` or `--readability` for the Ruby-only backup.
245
+ `ExtractHtml` always uses Defuddle; its result and error shapes remain unchanged.
101
246
 
102
247
  ## Output Formats
103
248
 
@@ -107,13 +252,15 @@ XML-tagged output designed for LLM context windows:
107
252
 
108
253
  ```xml
109
254
  <page>
110
- <title>Page Title</title>
111
- <url>https://example.com/page</url>
112
- <content>
255
+ <title>Page Title</title>
256
+ <url>https://example.com/page</url>
257
+ <pathname>/page</pathname>
258
+ <extractor>Nous::Extractor::Defuddle</extractor>
259
+ <content>
113
260
  # Heading
114
261
 
115
262
  Extracted markdown content...
116
- </content>
263
+ </content>
117
264
  </page>
118
265
  ```
119
266
 
@@ -125,13 +272,24 @@ Extracted markdown content...
125
272
  "title": "Page Title",
126
273
  "url": "https://example.com/page",
127
274
  "pathname": "/page",
128
- "content": "# Heading\n\nExtracted markdown content..."
275
+ "content": "# Heading\n\nExtracted markdown content...",
276
+ "metadata": {
277
+ "extractor": "Nous::Extractor::Defuddle",
278
+ "requested_url": "https://example.com/page",
279
+ "content_type": "text/html; charset=utf-8",
280
+ "redirected": false
281
+ }
129
282
  }
130
283
  ]
131
284
  ```
132
285
 
133
286
  ## Development
134
287
 
288
+ Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before implementation and include it
289
+ in Anaphor Standards reviews. Use the [verification map](docs/verification/README.md)
290
+ and project-local [verify-nous skill](.agents/skills/verify-nous/SKILL.md) for the
291
+ retained-HTML and failure-propagation readiness checks.
292
+
135
293
  ```bash
136
294
  bin/setup # Install dependencies
137
295
  bundle exec rspec # Run tests
data/Rakefile CHANGED
@@ -8,3 +8,5 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  require "standard/rake"
9
9
 
10
10
  task default: %i[spec standard]
11
+
12
+ Dir.glob("tasks/*.rake").sort.each { |path| load path }
@@ -0,0 +1,38 @@
1
+ # Defuddle runtime
2
+
3
+ Default URL and retained-HTML extraction requires Node.js 22 or newer on PATH.
4
+ The process adapter currently targets macOS/Linux (POSIX process groups). Install
5
+ Node explicitly using your normal runtime manager. No API key or service is used.
6
+ Check readiness from the repository root:
7
+
8
+ ```bash
9
+ node --version
10
+ node -e 'if (Number(process.versions.node.split(".")[0]) < 22) process.exit(1)'
11
+ printf '%s' '{"html":"<html><head><title>Probe</title></head><body><p>Readable probe.</p></body></html>","url":"https://example.com"}' | node vendor/defuddle.mjs
12
+ ```
13
+
14
+ Require exit zero and JSON containing title `Probe` and Markdown `Readable probe.`.
15
+ The published gem includes `vendor/defuddle.mjs` and third-party license notices;
16
+ users do not run npm. A missing Node binary produces an extraction failure.
17
+ `Nous.fetch(..., extractor: Nous::Extractor::Default.new)` and `--readability`
18
+ select the Ruby-only backup explicitly. Retained `ExtractHtml` requires Node.
19
+
20
+ ## Rebuilding the bundled adapter
21
+
22
+ Maintainers use the pinned dependencies in `package-lock.json`. From the repository
23
+ root, provision dependencies explicitly and rebuild:
24
+
25
+ ```bash
26
+ npm ci --ignore-scripts
27
+ npm run build
28
+ ```
29
+
30
+ Commit the source, package manifest/lockfile, generated bundle and license notices
31
+ together. `javascript/build.mjs` regenerates the bundle and gathers dependency
32
+ license texts. Runtime extraction and verification never install dependencies.
33
+ Run the [Defuddle recipe](../verification/defuddle.md) after rebuilding.
34
+
35
+ The bridge uses a non-executing DOM parser, disables async extractors and rejects
36
+ fetch calls. It is not a browser renderer. The adapter uses a 15-second process
37
+ deadline and bounded output, but does not enforce a process memory limit. Callers
38
+ with stronger isolation requirements still own external process supervision.
@@ -0,0 +1,21 @@
1
+ # Verification setup
2
+
3
+ Work from the Nous repository root. Use the Ruby declared in `.ruby-version`
4
+ (currently 4.0.1), the Bundler version in `Gemfile.lock` (currently 4.0.6), and
5
+ that lockfile's gems. With rbenv, `rbenv exec ruby -v` must identify the intended
6
+ Ruby; shell/system Ruby 2.6 is unsuitable. Put rbenv shims on PATH or prefix the
7
+ commands in the verification guide with `rbenv exec`.
8
+
9
+ Run `BUNDLE_FROZEN=true bundle check`. If dependencies are absent, run
10
+ `BUNDLE_FROZEN=true bundle install` once, then repeat the check. Installation may
11
+ need network and permission to write the gem installation. If Ruby/Bundler is
12
+ missing, provision those declared versions using your runtime manager first.
13
+ Do not update or regenerate the lockfile to make verification work. Frozen mode
14
+ also prevents Bundler from opportunistically rewriting platform metadata.
15
+
16
+ The recipes use RSpec, WebMock, Node.js 22+ and the bundled Defuddle adapter.
17
+ Follow [Defuddle setup and readiness](defuddle.md) before running them. No server,
18
+ browser, remote account or model is needed. HTTP in the failure recipe is a
19
+ controlled response fixture. WebMock blocks Ruby HTTP; the Node adapter disables
20
+ Defuddle async fetching and rejects fetch calls.
21
+ No credentials or retained private content belong in evidence.