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