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.
- checksums.yaml +4 -4
- data/.agents/skills/verify-nous/SKILL.md +6 -0
- data/CHANGELOG.md +75 -1
- data/CODING_STANDARDS.md +203 -0
- data/README.md +175 -17
- 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/async_page_fetcher.rb +43 -11
- data/lib/nous/crawler/recursive_page_fetcher.rb +34 -26
- data/lib/nous/crawler/single_page_fetcher.rb +49 -9
- data/lib/nous/decode_html.rb +80 -0
- data/lib/nous/extract_html.rb +109 -0
- data/lib/nous/extractor/default/client.rb +30 -12
- data/lib/nous/extractor/default.rb +2 -4
- 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 +26 -11
- data/lib/nous/fetcher/page_extractor.rb +27 -12
- data/lib/nous/fetcher.rb +46 -10
- data/lib/nous/primitives/configuration.rb +1 -0
- data/lib/nous/primitives/fetch_record.rb +26 -0
- data/lib/nous/primitives/fetch_result.rb +21 -0
- data/lib/nous/primitives/page.rb +1 -1
- data/lib/nous/serializer.rb +9 -1
- data/lib/nous/version.rb +1 -1
- data/lib/nous.rb +2 -2
- 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 +25 -4
- data/lib/nous/extractor/jina/client.rb +0 -59
- data/lib/nous/extractor/jina.rb +0 -26
- 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::
|
|
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
|
|
@@ -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
|
|
16
|
+
return build_failed_record(url, result.error.message) if result.failure?
|
|
17
17
|
|
|
18
18
|
response, final_url = result.payload
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
content_type = response.headers["content-type"].to_s
|
|
20
|
+
redirected = final_url.to_s != url
|
|
21
21
|
|
|
22
|
-
|
|
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
|
-
|
|
37
|
+
build_failed_record(url, "timeout after #{config.timeout}s")
|
|
28
38
|
rescue IOError, SocketError, Errno::ECONNREFUSED => e
|
|
29
|
-
|
|
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?(
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
20
|
+
prepare_crawl_logging
|
|
21
|
+
acquire_linked_records
|
|
22
|
+
report_acquired_records
|
|
23
|
+
end
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
crawl(client)
|
|
22
|
-
end
|
|
25
|
+
private
|
|
23
26
|
|
|
24
|
-
|
|
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
|
-
|
|
33
|
+
def acquire_linked_records
|
|
34
|
+
open_connection { |client| crawl(client) }
|
|
35
|
+
end
|
|
28
36
|
|
|
29
|
-
|
|
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 |
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
def enqueue_links(
|
|
63
|
-
link_extractor.extract(
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
25
|
-
validate_html!(response)
|
|
34
|
+
validate!(record)
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
success(payload: [raw_page])
|
|
36
|
+
success(payload: [record])
|
|
29
37
|
rescue FetchError => e
|
|
30
|
-
|
|
38
|
+
record = build_failed_record(error: e.message)
|
|
39
|
+
success(payload: [record])
|
|
31
40
|
rescue Faraday::Error => e
|
|
32
|
-
|
|
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!(
|
|
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
|