Skip to content

Commit 6c449b4

Browse files
committed
feat: add ArchiveSearch for full-text search across archived snapshots
- Searches text content of archived snapshots for query strings - Case-insensitive by default with case_sensitive option - Returns SearchResult with surrounding context for each match - Configurable max_results limit and date range filtering - SearchResult value object with serialization
1 parent 5a02307 commit 6c449b4

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

lib/archaeo/archive_search.rb

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# frozen_string_literal: true
2+
3+
module Archaeo
4+
# Value object for a single search match within an archived snapshot.
5+
SearchResult = Struct.new(
6+
:url, :snapshot, :context, :match_offset,
7+
keyword_init: true
8+
) do
9+
def to_h
10+
{
11+
url: url,
12+
snapshot: snapshot.as_json,
13+
context: context,
14+
match_offset: match_offset,
15+
}
16+
end
17+
18+
def as_json(*)
19+
to_h
20+
end
21+
end
22+
23+
# Full-text search across archived snapshots.
24+
#
25+
# Fetches snapshots from CDX, downloads their content, and
26+
# searches for the given query string. Returns matches with
27+
# surrounding context for each hit.
28+
class ArchiveSearch
29+
CONTEXT_RADIUS = 80
30+
31+
def initialize(cdx_api: CdxApi.new, fetcher: Fetcher.new)
32+
@cdx = cdx_api
33+
@fetcher = fetcher
34+
end
35+
36+
def search(url, query:, from: nil, to: nil,
37+
max_results: nil, case_sensitive: false)
38+
if query.nil? || query.empty?
39+
raise ArgumentError,
40+
"query must not be empty"
41+
end
42+
43+
url = UrlNormalizer.normalize(url)
44+
opts = build_options(from, to)
45+
46+
snapshots = @cdx.snapshots(url, **opts)
47+
.select { |s| s.success? && s.mimetype.to_s.include?("text") }
48+
.to_a
49+
50+
find_matches(snapshots, query, case_sensitive, max_results)
51+
end
52+
53+
private
54+
55+
def build_options(from, to)
56+
opts = { collapse: ["digest"] }
57+
opts[:from] = Timestamp.coerce(from).to_s if from
58+
opts[:to] = Timestamp.coerce(to).to_s if to
59+
opts
60+
end
61+
62+
def find_matches(snapshots, query, case_sensitive, max_results)
63+
results = []
64+
pattern = build_pattern(query, case_sensitive)
65+
66+
snapshots.each do |snap|
67+
break if max_results && results.size >= max_results
68+
69+
content = fetch_content(snap)
70+
next unless content
71+
72+
scan_content(content, pattern).each do |match_offset|
73+
results << SearchResult.new(
74+
url: snap.original_url,
75+
snapshot: snap,
76+
context: extract_context(content, match_offset, query.length),
77+
match_offset: match_offset,
78+
)
79+
break if max_results && results.size >= max_results
80+
end
81+
end
82+
83+
results
84+
end
85+
86+
def build_pattern(query, case_sensitive)
87+
escaped = Regexp.escape(query)
88+
return /#{escaped}/im unless case_sensitive
89+
90+
/#{escaped}/m
91+
end
92+
93+
def fetch_content(snapshot)
94+
page = @fetcher.fetch(
95+
snapshot.original_url, timestamp: snapshot.timestamp
96+
)
97+
page.content if page.text?
98+
rescue Error
99+
nil
100+
end
101+
102+
def scan_content(content, pattern)
103+
offsets = []
104+
content.scan(pattern) do
105+
offsets << Regexp.last_match.offset(0).first
106+
end
107+
offsets
108+
end
109+
110+
def extract_context(content, offset, length)
111+
start_pos = [0, offset - CONTEXT_RADIUS].max
112+
end_pos = [content.length, offset + length + CONTEXT_RADIUS].min
113+
114+
ctx = content[start_pos...end_pos]
115+
ctx = "...#{ctx}" if start_pos.positive?
116+
ctx = "#{ctx}..." if end_pos < content.length
117+
ctx.tr("\n\r", " ").strip
118+
end
119+
end
120+
end
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Archaeo::ArchiveSearch do
6+
let(:snapshot) do
7+
Archaeo::Snapshot.new(
8+
urlkey: "com,example)/",
9+
timestamp: "20220615000000",
10+
original_url: "https://example.com/",
11+
mimetype: "text/html", status_code: 200,
12+
digest: "SHA1-abc", length: 200
13+
)
14+
end
15+
16+
let(:page) do
17+
Archaeo::Page.new(
18+
content: "<html><body>Contact us at info@example.com</body></html>",
19+
content_type: "text/html",
20+
status_code: 200,
21+
archive_url: "https://web.archive.org/web/20220615000000/https://example.com/",
22+
original_url: "https://example.com/",
23+
timestamp: Archaeo::Timestamp.new(year: 2022, month: 6, day: 15),
24+
)
25+
end
26+
27+
let(:fake_cdx) do
28+
cdx = instance_double(Archaeo::CdxApi)
29+
allow(cdx).to receive(:snapshots).and_return([snapshot])
30+
cdx
31+
end
32+
33+
let(:fake_fetcher) do
34+
fetcher = instance_double(Archaeo::Fetcher)
35+
allow(fetcher).to receive(:fetch).and_return(page)
36+
fetcher
37+
end
38+
39+
let(:searcher) do
40+
described_class.new(cdx_api: fake_cdx, fetcher: fake_fetcher)
41+
end
42+
43+
it "finds matching text in snapshots" do
44+
results = searcher.search("example.com", query: "Contact us")
45+
expect(results.size).to be >= 1
46+
expect(results.first.context).to include("Contact us")
47+
end
48+
49+
it "returns empty results for no matches" do
50+
results = searcher.search("example.com", query: "xyzzy-no-match")
51+
expect(results).to be_empty
52+
end
53+
54+
it "raises on empty query" do
55+
expect { searcher.search("example.com", query: "") }
56+
.to raise_error(ArgumentError)
57+
end
58+
59+
it "respects max_results limit" do
60+
results = searcher.search("example.com", query: "example", max_results: 1)
61+
expect(results.size).to be <= 1
62+
end
63+
64+
it "performs case-insensitive search by default" do
65+
results = searcher.search("example.com", query: "contact us")
66+
expect(results).not_to be_empty
67+
end
68+
69+
it "respects case_sensitive option" do
70+
results = searcher.search("example.com", query: "contact us",
71+
case_sensitive: true)
72+
expect(results).to be_empty
73+
end
74+
end
75+
76+
RSpec.describe Archaeo::SearchResult do
77+
it "serializes to hash" do
78+
snap = Archaeo::Snapshot.new(
79+
urlkey: "com,example)/",
80+
timestamp: "20220615000000",
81+
original_url: "https://example.com/",
82+
mimetype: "text/html", status_code: 200
83+
)
84+
result = described_class.new(
85+
url: "https://example.com/",
86+
snapshot: snap,
87+
context: "...Contact us...",
88+
match_offset: 10,
89+
)
90+
h = result.to_h
91+
expect(h[:url]).to eq("https://example.com/")
92+
expect(h[:context]).to eq("...Contact us...")
93+
end
94+
95+
it "serializes to JSON" do
96+
snap = Archaeo::Snapshot.new(
97+
urlkey: "com,example)/",
98+
timestamp: "20220615000000",
99+
original_url: "https://example.com/",
100+
mimetype: "text/html", status_code: 200
101+
)
102+
result = described_class.new(
103+
url: "https://example.com/",
104+
snapshot: snap,
105+
context: "test",
106+
match_offset: 0,
107+
)
108+
expect { JSON.generate(result.as_json) }.not_to raise_error
109+
end
110+
end

0 commit comments

Comments
 (0)