@hraness/wordcell 0.24.1 → 0.25.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.
- package/README.md +4 -4
- package/dist/capture.js +2 -2
- package/dist/cli.js +5 -5
- package/dist/clip/cli.js +2 -2
- package/dist/clip/defuddle-worker.js +87 -2
- package/dist/{index-jqkkb4fr.js → index-1wccy765.js} +5 -5
- package/dist/{index-t0fh8a1q.js → index-47893rgw.js} +15 -5
- package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
- package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
- package/dist/{support-07jpt1z4.js → support-adj7bbge.js} +4 -4
- package/dist/url-intelligence.js +2 -2
- package/package.json +9 -7
- package/skills/wordcell/SKILL.md +1 -1
- package/src/cli-program.ts +3 -3
- package/src/clip/metadata-search-tool/Cargo.lock +10 -0
- package/src/clip/metadata-search-tool/Cargo.toml +1 -0
- package/src/clip/metadata-search-tool/src/bing.rs +539 -0
- package/src/clip/metadata-search-tool/src/duckduckgo.rs +61 -33
- package/src/clip/metadata-search-tool/src/main.rs +238 -12
- package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
- package/src/clip/metadata-search.ts +18 -2
- package/src/clip/url-intelligence.ts +54 -1
- package/src/url-intelligence.ts +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//! Bounded parsing of the fixed DuckDuckGo HTML endpoint. An unrecognized page
|
|
2
2
|
//! is unavailable evidence, never proof that a query has no results.
|
|
3
|
+
use super::FailureCode;
|
|
3
4
|
use std::time::Duration;
|
|
4
5
|
|
|
5
6
|
use metadata_search_engine_rs::models::SearchResult;
|
|
@@ -10,7 +11,7 @@ use url::Url;
|
|
|
10
11
|
const ENDPOINT: &str = "https://html.duckduckgo.com/html/";
|
|
11
12
|
const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
|
|
12
13
|
|
|
13
|
-
type Outcome = Result<Vec<SearchResult>,
|
|
14
|
+
type Outcome = Result<Vec<SearchResult>, FailureCode>;
|
|
14
15
|
|
|
15
16
|
pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
|
|
16
17
|
search_at(client, ENDPOINT, query, limit, timeout).await
|
|
@@ -29,30 +30,34 @@ async fn search_at(
|
|
|
29
30
|
.query(&[("q", query)])
|
|
30
31
|
.send()
|
|
31
32
|
.await
|
|
32
|
-
.map_err(|
|
|
33
|
+
.map_err(|error| super::transport_code(&error, FailureCode::Http))?;
|
|
33
34
|
validate_response(
|
|
34
35
|
response.status(),
|
|
35
36
|
response.headers(),
|
|
36
37
|
response.content_length(),
|
|
37
38
|
)?;
|
|
38
39
|
let mut body = Vec::new();
|
|
39
|
-
while let Some(chunk) = response
|
|
40
|
+
while let Some(chunk) = response
|
|
41
|
+
.chunk()
|
|
42
|
+
.await
|
|
43
|
+
.map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
|
|
44
|
+
{
|
|
40
45
|
append_body(&mut body, &chunk)?;
|
|
41
46
|
}
|
|
42
|
-
let html = std::str::from_utf8(&body).map_err(|_|
|
|
47
|
+
let html = std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?;
|
|
43
48
|
parse(html, limit)
|
|
44
49
|
})
|
|
45
50
|
.await
|
|
46
|
-
.map_err(|_|
|
|
51
|
+
.map_err(|_| FailureCode::Timeout)?
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
fn validate_response(
|
|
50
55
|
status: reqwest::StatusCode,
|
|
51
56
|
headers: &header::HeaderMap,
|
|
52
57
|
length: Option<u64>,
|
|
53
|
-
) -> Result<(),
|
|
58
|
+
) -> Result<(), FailureCode> {
|
|
54
59
|
if !status.is_success() {
|
|
55
|
-
return Err(
|
|
60
|
+
return Err(super::status_code(status.as_u16()));
|
|
56
61
|
}
|
|
57
62
|
let content_type = headers
|
|
58
63
|
.get(header::CONTENT_TYPE)
|
|
@@ -65,21 +70,21 @@ fn validate_response(
|
|
|
65
70
|
if !content_type.eq_ignore_ascii_case("text/html")
|
|
66
71
|
&& !content_type.eq_ignore_ascii_case("application/xhtml+xml")
|
|
67
72
|
{
|
|
68
|
-
return Err(
|
|
73
|
+
return Err(FailureCode::ContentType);
|
|
69
74
|
}
|
|
70
75
|
// reqwest removes this header after a supported decoding operation.
|
|
71
76
|
if headers.get(header::CONTENT_ENCODING).is_some() {
|
|
72
|
-
return Err(
|
|
77
|
+
return Err(FailureCode::ContentEncoding);
|
|
73
78
|
}
|
|
74
79
|
if length.is_some_and(|length| length > MAX_BODY_BYTES as u64) {
|
|
75
|
-
return Err(
|
|
80
|
+
return Err(FailureCode::BodyLimit);
|
|
76
81
|
}
|
|
77
82
|
Ok(())
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(),
|
|
85
|
+
fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), FailureCode> {
|
|
81
86
|
if chunk.len() > MAX_BODY_BYTES.saturating_sub(body.len()) {
|
|
82
|
-
return Err(
|
|
87
|
+
return Err(FailureCode::BodyLimit);
|
|
83
88
|
}
|
|
84
89
|
body.extend_from_slice(chunk);
|
|
85
90
|
Ok(())
|
|
@@ -124,10 +129,10 @@ fn destination(href: &str) -> Option<String> {
|
|
|
124
129
|
|
|
125
130
|
fn parse(html: &str, limit: usize) -> Outcome {
|
|
126
131
|
if html.len() > MAX_BODY_BYTES {
|
|
127
|
-
return Err(
|
|
132
|
+
return Err(FailureCode::BodyLimit);
|
|
128
133
|
}
|
|
129
134
|
if limit == 0 || limit > 20 {
|
|
130
|
-
return Err(
|
|
135
|
+
return Err(FailureCode::ResultLimit);
|
|
131
136
|
}
|
|
132
137
|
let document = Html::parse_document(html);
|
|
133
138
|
if document
|
|
@@ -137,7 +142,7 @@ fn parse(html: &str, limit: usize) -> Outcome {
|
|
|
137
142
|
.next()
|
|
138
143
|
.is_some()
|
|
139
144
|
{
|
|
140
|
-
return Err(
|
|
145
|
+
return Err(FailureCode::Challenge);
|
|
141
146
|
}
|
|
142
147
|
let result_selector = selector("div.result");
|
|
143
148
|
let title_selector = selector("a.result__a");
|
|
@@ -209,7 +214,7 @@ fn parse(html: &str, limit: usize) -> Outcome {
|
|
|
209
214
|
if result_containers == 0 && no_results {
|
|
210
215
|
Ok(Vec::new())
|
|
211
216
|
} else {
|
|
212
|
-
Err(
|
|
217
|
+
Err(FailureCode::UnrecognizedHtml)
|
|
213
218
|
}
|
|
214
219
|
}
|
|
215
220
|
|
|
@@ -251,7 +256,7 @@ mod tests {
|
|
|
251
256
|
] {
|
|
252
257
|
assert_eq!(
|
|
253
258
|
parse(&format!("{marker}{}", result("https://example.org")), 5).unwrap_err(),
|
|
254
|
-
|
|
259
|
+
FailureCode::Challenge
|
|
255
260
|
);
|
|
256
261
|
}
|
|
257
262
|
}
|
|
@@ -274,7 +279,7 @@ mod tests {
|
|
|
274
279
|
"<div class='result'><h2>unrecognized</h2></div>",
|
|
275
280
|
"<div class='no-results'>No results found for query</div><div class='result'></div>",
|
|
276
281
|
] {
|
|
277
|
-
assert_eq!(parse(html, 5).unwrap_err(),
|
|
282
|
+
assert_eq!(parse(html, 5).unwrap_err(), FailureCode::UnrecognizedHtml);
|
|
278
283
|
}
|
|
279
284
|
}
|
|
280
285
|
|
|
@@ -288,7 +293,10 @@ mod tests {
|
|
|
288
293
|
"/l/?uddg=https%3A%2F%2Fa.org&uddg=https%3A%2F%2Fb.org",
|
|
289
294
|
] {
|
|
290
295
|
assert!(destination(href).is_none(), "{href}");
|
|
291
|
-
assert_eq!(
|
|
296
|
+
assert_eq!(
|
|
297
|
+
parse(&result(href), 5).unwrap_err(),
|
|
298
|
+
FailureCode::UnrecognizedHtml
|
|
299
|
+
);
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
302
|
|
|
@@ -300,15 +308,18 @@ mod tests {
|
|
|
300
308
|
.len(),
|
|
301
309
|
2
|
|
302
310
|
);
|
|
303
|
-
assert_eq!(parse("", 0).unwrap_err(),
|
|
304
|
-
assert_eq!(parse("", 21).unwrap_err(),
|
|
311
|
+
assert_eq!(parse("", 0).unwrap_err(), FailureCode::ResultLimit);
|
|
312
|
+
assert_eq!(parse("", 21).unwrap_err(), FailureCode::ResultLimit);
|
|
305
313
|
let mut bytes = vec![0; MAX_BODY_BYTES - 1];
|
|
306
314
|
append_body(&mut bytes, &[0]).unwrap();
|
|
307
|
-
assert_eq!(
|
|
315
|
+
assert_eq!(
|
|
316
|
+
append_body(&mut bytes, &[1]).unwrap_err(),
|
|
317
|
+
FailureCode::BodyLimit
|
|
318
|
+
);
|
|
308
319
|
assert_eq!(bytes.len(), MAX_BODY_BYTES);
|
|
309
320
|
assert_eq!(
|
|
310
321
|
parse(&"x".repeat(MAX_BODY_BYTES + 1), 1).unwrap_err(),
|
|
311
|
-
|
|
322
|
+
FailureCode::BodyLimit
|
|
312
323
|
);
|
|
313
324
|
}
|
|
314
325
|
#[test]
|
|
@@ -326,7 +337,7 @@ mod tests {
|
|
|
326
337
|
&headers,
|
|
327
338
|
None
|
|
328
339
|
),
|
|
329
|
-
Err(
|
|
340
|
+
Err(super::super::status_code(status))
|
|
330
341
|
);
|
|
331
342
|
}
|
|
332
343
|
assert_eq!(
|
|
@@ -335,7 +346,7 @@ mod tests {
|
|
|
335
346
|
&headers,
|
|
336
347
|
Some(MAX_BODY_BYTES as u64 + 1)
|
|
337
348
|
),
|
|
338
|
-
Err(
|
|
349
|
+
Err(FailureCode::BodyLimit)
|
|
339
350
|
);
|
|
340
351
|
headers.insert(
|
|
341
352
|
header::CONTENT_ENCODING,
|
|
@@ -343,7 +354,7 @@ mod tests {
|
|
|
343
354
|
);
|
|
344
355
|
assert_eq!(
|
|
345
356
|
validate_response(reqwest::StatusCode::OK, &headers, None),
|
|
346
|
-
Err(
|
|
357
|
+
Err(FailureCode::ContentEncoding)
|
|
347
358
|
);
|
|
348
359
|
headers.remove(header::CONTENT_ENCODING);
|
|
349
360
|
headers.insert(
|
|
@@ -352,7 +363,7 @@ mod tests {
|
|
|
352
363
|
);
|
|
353
364
|
assert_eq!(
|
|
354
365
|
validate_response(reqwest::StatusCode::OK, &headers, None),
|
|
355
|
-
Err(
|
|
366
|
+
Err(FailureCode::ContentType)
|
|
356
367
|
);
|
|
357
368
|
}
|
|
358
369
|
|
|
@@ -409,6 +420,9 @@ mod tests {
|
|
|
409
420
|
Err(error) => panic!("fixture accept: {error}"),
|
|
410
421
|
}
|
|
411
422
|
};
|
|
423
|
+
// macOS may inherit the listener nonblocking flag on accept.
|
|
424
|
+
// Use the explicit bounded read/write timeouts for fixture I/O.
|
|
425
|
+
stream.set_nonblocking(false).unwrap();
|
|
412
426
|
stream
|
|
413
427
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
414
428
|
.unwrap();
|
|
@@ -472,31 +486,31 @@ mod tests {
|
|
|
472
486
|
"403 Forbidden",
|
|
473
487
|
"Content-Type: text/html\r\n",
|
|
474
488
|
Duration::ZERO,
|
|
475
|
-
|
|
489
|
+
FailureCode::HttpForbidden,
|
|
476
490
|
),
|
|
477
491
|
(
|
|
478
492
|
"302 Found",
|
|
479
493
|
"Content-Type: text/html\r\nLocation: http://127.0.0.1:1/\r\n",
|
|
480
494
|
Duration::ZERO,
|
|
481
|
-
|
|
495
|
+
FailureCode::HttpRedirect,
|
|
482
496
|
),
|
|
483
497
|
(
|
|
484
498
|
"200 OK",
|
|
485
499
|
"Content-Type: application/json\r\n",
|
|
486
500
|
Duration::ZERO,
|
|
487
|
-
|
|
501
|
+
FailureCode::ContentType,
|
|
488
502
|
),
|
|
489
503
|
(
|
|
490
504
|
"200 OK",
|
|
491
505
|
"Content-Type: text/html\r\nContent-Encoding: deflate\r\n",
|
|
492
506
|
Duration::ZERO,
|
|
493
|
-
|
|
507
|
+
FailureCode::ContentEncoding,
|
|
494
508
|
),
|
|
495
509
|
(
|
|
496
510
|
"200 OK",
|
|
497
511
|
"Content-Type: text/html\r\n",
|
|
498
512
|
Duration::from_millis(150),
|
|
499
|
-
|
|
513
|
+
FailureCode::Timeout,
|
|
500
514
|
),
|
|
501
515
|
] {
|
|
502
516
|
let (url, server) = fixture(status, headers, b"some body".to_vec(), delay);
|
|
@@ -529,6 +543,20 @@ mod tests {
|
|
|
529
543
|
);
|
|
530
544
|
let outcome = search_at(&client(), &url, "fixture", 5, Duration::from_secs(2)).await;
|
|
531
545
|
server.join().unwrap();
|
|
532
|
-
assert_eq!(outcome.unwrap_err(),
|
|
546
|
+
assert_eq!(outcome.unwrap_err(), FailureCode::BodyLimit);
|
|
547
|
+
}
|
|
548
|
+
#[tokio::test]
|
|
549
|
+
async fn reqwest_timeout_is_not_reported_as_generic_body_failure() {
|
|
550
|
+
let (url, server) = fixture(
|
|
551
|
+
"200 OK",
|
|
552
|
+
"Content-Type: text/html\r\n",
|
|
553
|
+
b"delayed body".to_vec(),
|
|
554
|
+
Duration::from_millis(300),
|
|
555
|
+
);
|
|
556
|
+
let client =
|
|
557
|
+
super::super::build_pinned_http_client(&[], Duration::from_millis(100)).unwrap();
|
|
558
|
+
let outcome = search_at(&client, &url, "fixture", 3, Duration::from_secs(2)).await;
|
|
559
|
+
server.join().unwrap();
|
|
560
|
+
assert_eq!(outcome.unwrap_err(), FailureCode::Timeout);
|
|
533
561
|
}
|
|
534
562
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
mod bing;
|
|
2
|
+
mod openalex;
|
|
1
3
|
mod duckduckgo;
|
|
2
4
|
|
|
3
5
|
use std::alloc::{GlobalAlloc, Layout, System};
|
|
@@ -18,7 +20,66 @@ use reqwest::{
|
|
|
18
20
|
header::{self, HeaderMap, HeaderValue},
|
|
19
21
|
redirect::Policy,
|
|
20
22
|
};
|
|
21
|
-
use serde::Deserialize;
|
|
23
|
+
use serde::{Deserialize, Serialize};
|
|
24
|
+
|
|
25
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
26
|
+
#[serde(rename_all = "kebab-case")]
|
|
27
|
+
enum FailureCode {
|
|
28
|
+
Timeout,
|
|
29
|
+
Http,
|
|
30
|
+
HttpBody,
|
|
31
|
+
HttpStatus,
|
|
32
|
+
HttpForbidden,
|
|
33
|
+
HttpRateLimited,
|
|
34
|
+
HttpRedirect,
|
|
35
|
+
Parse,
|
|
36
|
+
UnverifiedEmpty,
|
|
37
|
+
Challenge,
|
|
38
|
+
ContentType,
|
|
39
|
+
ContentEncoding,
|
|
40
|
+
BodyLimit,
|
|
41
|
+
BodyEncoding,
|
|
42
|
+
ResultLimit,
|
|
43
|
+
UnrecognizedHtml,
|
|
44
|
+
QueryMismatch,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Only bounded categorical diagnostics cross the protocol boundary, never error text.
|
|
48
|
+
#[derive(Serialize)]
|
|
49
|
+
struct EngineFailure {
|
|
50
|
+
engine: String,
|
|
51
|
+
code: FailureCode,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[derive(Serialize)]
|
|
55
|
+
struct EngineUsage { engine: String, reported_cost_usd: f64, billing_status: &'static str }
|
|
56
|
+
|
|
57
|
+
#[derive(Serialize)]
|
|
58
|
+
struct DiagnosticResponse {
|
|
59
|
+
#[serde(flatten)]
|
|
60
|
+
response: SearchResponse,
|
|
61
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
62
|
+
engine_failures: Option<Vec<EngineFailure>>,
|
|
63
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
64
|
+
engine_usage: Option<Vec<EngineUsage>>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn transport_code(error: &reqwest::Error, otherwise: FailureCode) -> FailureCode {
|
|
68
|
+
if error.is_timeout() {
|
|
69
|
+
FailureCode::Timeout
|
|
70
|
+
} else {
|
|
71
|
+
otherwise
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn status_code(status: u16) -> FailureCode {
|
|
76
|
+
match status {
|
|
77
|
+
403 => FailureCode::HttpForbidden,
|
|
78
|
+
429 => FailureCode::HttpRateLimited,
|
|
79
|
+
300..=399 => FailureCode::HttpRedirect,
|
|
80
|
+
_ => FailureCode::HttpStatus,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
22
83
|
|
|
23
84
|
const MAX_INPUT_BYTES: u64 = 16 * 1024;
|
|
24
85
|
const MAX_QUERY_BYTES: usize = 4 * 1024;
|
|
@@ -137,12 +198,27 @@ struct EngineHost {
|
|
|
137
198
|
#[serde(deny_unknown_fields)]
|
|
138
199
|
struct SearchRequest {
|
|
139
200
|
schema_version: u8,
|
|
201
|
+
#[serde(default)]
|
|
202
|
+
diagnostics: bool,
|
|
203
|
+
#[serde(default)]
|
|
204
|
+
bing_rss: bool,
|
|
205
|
+
#[serde(default)]
|
|
206
|
+
openalex_discovery: bool,
|
|
140
207
|
query: String,
|
|
141
208
|
max_results: usize,
|
|
142
209
|
timeout_ms: u64,
|
|
143
210
|
engine_hosts: Vec<EngineHost>,
|
|
144
211
|
}
|
|
145
212
|
|
|
213
|
+
fn expected_hosts(bing_rss: bool, openalex_discovery: bool) -> Vec<&'static str> {
|
|
214
|
+
let mut hosts = ENGINE_HOSTS.to_vec();
|
|
215
|
+
if bing_rss {
|
|
216
|
+
hosts.push("www.bing.com");
|
|
217
|
+
}
|
|
218
|
+
if openalex_discovery { hosts.push("api.openalex.org"); }
|
|
219
|
+
hosts
|
|
220
|
+
}
|
|
221
|
+
|
|
146
222
|
fn read_request() -> Result<SearchRequest, &'static str> {
|
|
147
223
|
let mut bytes = Vec::new();
|
|
148
224
|
io::stdin()
|
|
@@ -165,10 +241,16 @@ fn read_request() -> Result<SearchRequest, &'static str> {
|
|
|
165
241
|
if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&request.timeout_ms) {
|
|
166
242
|
return Err("invalid-timeout");
|
|
167
243
|
}
|
|
168
|
-
|
|
244
|
+
validate_engine_hosts(&request)?;
|
|
245
|
+
Ok(request)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fn validate_engine_hosts(request: &SearchRequest) -> Result<(), &'static str> {
|
|
249
|
+
let expected_hosts = expected_hosts(request.bing_rss, request.openalex_discovery);
|
|
250
|
+
if request.engine_hosts.len() != expected_hosts.len() {
|
|
169
251
|
return Err("invalid-engine-hosts");
|
|
170
252
|
}
|
|
171
|
-
for (host, expected) in request.engine_hosts.iter().zip(
|
|
253
|
+
for (host, expected) in request.engine_hosts.iter().zip(expected_hosts) {
|
|
172
254
|
if host.hostname != expected
|
|
173
255
|
|| host.addresses.is_empty()
|
|
174
256
|
|| host.addresses.len() > MAX_ENGINE_ADDRESSES
|
|
@@ -176,7 +258,7 @@ fn read_request() -> Result<SearchRequest, &'static str> {
|
|
|
176
258
|
return Err("invalid-engine-hosts");
|
|
177
259
|
}
|
|
178
260
|
}
|
|
179
|
-
Ok(
|
|
261
|
+
Ok(())
|
|
180
262
|
}
|
|
181
263
|
|
|
182
264
|
fn fail(code: &'static str) -> ! {
|
|
@@ -332,7 +414,8 @@ async fn run() {
|
|
|
332
414
|
Ok(request) => request,
|
|
333
415
|
Err(code) => fail(code),
|
|
334
416
|
};
|
|
335
|
-
let timeout =
|
|
417
|
+
let timeout =
|
|
418
|
+
Duration::from_millis(request.timeout_ms / expected_hosts(request.bing_rss, request.openalex_discovery).len() as u64);
|
|
336
419
|
let client = Arc::new(
|
|
337
420
|
build_pinned_http_client(&request.engine_hosts, timeout).unwrap_or_else(|code| fail(code)),
|
|
338
421
|
);
|
|
@@ -348,11 +431,41 @@ async fn run() {
|
|
|
348
431
|
engines_queried.insert(0, "duckduckgo".to_string());
|
|
349
432
|
let mut successes = Vec::new();
|
|
350
433
|
let mut engines_failed = Vec::new();
|
|
434
|
+
let mut engine_failures = Vec::new();
|
|
435
|
+
let mut engine_usage = Vec::new();
|
|
436
|
+
// Optional independent backend runs first within the existing total time budget.
|
|
437
|
+
if request.openalex_discovery {
|
|
438
|
+
engines_queried.insert(0, "openalex".to_string());
|
|
439
|
+
match openalex::search(&client, request.query.trim(), request.max_results, timeout).await {
|
|
440
|
+
Ok(discovery) => {
|
|
441
|
+
if let Some(cost)=discovery.reported_cost_usd { engine_usage.push(EngineUsage {engine:"openalex".into(),reported_cost_usd:cost,billing_status:"not-established"}); }
|
|
442
|
+
successes.push(("openalex".to_string(), discovery.results));
|
|
443
|
+
},
|
|
444
|
+
Err(code) => { engines_failed.push("openalex".to_string()); engine_failures.push(EngineFailure {engine:"openalex".to_string(),code}); }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if request.bing_rss {
|
|
448
|
+
engines_queried.insert(0, "bing".to_string());
|
|
449
|
+
match bing::search(&client, request.query.trim(), request.max_results, timeout).await {
|
|
450
|
+
Ok(results) => successes.push(("bing".to_string(), results)),
|
|
451
|
+
Err(code) => {
|
|
452
|
+
engines_failed.push("bing".to_string());
|
|
453
|
+
engine_failures.push(EngineFailure {
|
|
454
|
+
engine: "bing".to_string(),
|
|
455
|
+
code,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
351
460
|
match duckduckgo::search(&client, request.query.trim(), request.max_results, timeout).await {
|
|
352
461
|
Ok(results) => successes.push(("duckduckgo".to_string(), results)),
|
|
353
462
|
Err(code) => {
|
|
354
|
-
eprintln!("metadata search engine duckduckgo: {code}");
|
|
463
|
+
eprintln!("metadata search engine duckduckgo: {code:?}");
|
|
355
464
|
engines_failed.push("duckduckgo".to_string());
|
|
465
|
+
engine_failures.push(EngineFailure {
|
|
466
|
+
engine: "duckduckgo".to_string(),
|
|
467
|
+
code,
|
|
468
|
+
});
|
|
356
469
|
}
|
|
357
470
|
}
|
|
358
471
|
// Run one upstream engine at a time so its unbounded internal `text()`
|
|
@@ -367,17 +480,25 @@ async fn run() {
|
|
|
367
480
|
Ok(results) if !results.is_empty() => successes.push((name, results)),
|
|
368
481
|
Ok(_) => {
|
|
369
482
|
eprintln!("metadata search engine {name}: unverified-empty");
|
|
483
|
+
engine_failures.push(EngineFailure {
|
|
484
|
+
engine: name.clone(),
|
|
485
|
+
code: FailureCode::UnverifiedEmpty,
|
|
486
|
+
});
|
|
370
487
|
engines_failed.push(name);
|
|
371
488
|
}
|
|
372
489
|
Err(error) => {
|
|
373
490
|
use metadata_search_engine_rs::error::EngineError;
|
|
374
491
|
let code = match error {
|
|
375
|
-
EngineError::Timeout { .. } =>
|
|
376
|
-
EngineError::Http { .. } =>
|
|
377
|
-
EngineError::BadStatus { .. } =>
|
|
378
|
-
EngineError::ParseFailed { .. } =>
|
|
492
|
+
EngineError::Timeout { .. } => FailureCode::Timeout,
|
|
493
|
+
EngineError::Http { source, .. } => transport_code(&source, FailureCode::Http),
|
|
494
|
+
EngineError::BadStatus { status, .. } => status_code(status),
|
|
495
|
+
EngineError::ParseFailed { .. } => FailureCode::Parse,
|
|
379
496
|
};
|
|
380
|
-
eprintln!("metadata search engine {name}: {code}");
|
|
497
|
+
eprintln!("metadata search engine {name}: {code:?}");
|
|
498
|
+
engine_failures.push(EngineFailure {
|
|
499
|
+
engine: name.clone(),
|
|
500
|
+
code,
|
|
501
|
+
});
|
|
381
502
|
engines_failed.push(name);
|
|
382
503
|
}
|
|
383
504
|
}
|
|
@@ -396,7 +517,112 @@ async fn run() {
|
|
|
396
517
|
engines_queried,
|
|
397
518
|
engines_failed,
|
|
398
519
|
};
|
|
399
|
-
if serde_json::to_writer(
|
|
520
|
+
if serde_json::to_writer(
|
|
521
|
+
io::stdout().lock(),
|
|
522
|
+
&DiagnosticResponse {
|
|
523
|
+
response,
|
|
524
|
+
engine_failures: request.diagnostics.then_some(engine_failures),
|
|
525
|
+
engine_usage: (request.diagnostics && !engine_usage.is_empty()).then_some(engine_usage),
|
|
526
|
+
},
|
|
527
|
+
)
|
|
528
|
+
.is_err()
|
|
529
|
+
{
|
|
400
530
|
fail("stdout-write");
|
|
401
531
|
}
|
|
402
532
|
}
|
|
533
|
+
|
|
534
|
+
#[cfg(test)]
|
|
535
|
+
mod diagnostic_tests {
|
|
536
|
+
use super::*;
|
|
537
|
+
#[test]
|
|
538
|
+
fn emits_categorical_diagnostics_inside_search_protocol() {
|
|
539
|
+
let response = DiagnosticResponse {
|
|
540
|
+
response: SearchResponse {
|
|
541
|
+
query: "query".into(),
|
|
542
|
+
results: vec![],
|
|
543
|
+
engines_queried: vec!["brave".into()],
|
|
544
|
+
engines_failed: vec!["brave".into()],
|
|
545
|
+
},
|
|
546
|
+
engine_usage: None,
|
|
547
|
+
engine_failures: Some(vec![EngineFailure {
|
|
548
|
+
engine: "brave".into(),
|
|
549
|
+
code: FailureCode::HttpForbidden,
|
|
550
|
+
}]),
|
|
551
|
+
};
|
|
552
|
+
let value = serde_json::to_value(response).unwrap();
|
|
553
|
+
assert_eq!(
|
|
554
|
+
value["engine_failures"],
|
|
555
|
+
serde_json::json!([{ "engine": "brave", "code": "http-forbidden" }])
|
|
556
|
+
);
|
|
557
|
+
assert_eq!(value.as_object().unwrap().len(), 5);
|
|
558
|
+
assert_eq!(status_code(429), FailureCode::HttpRateLimited);
|
|
559
|
+
assert_eq!(status_code(302), FailureCode::HttpRedirect);
|
|
560
|
+
assert_eq!(status_code(503), FailureCode::HttpStatus);
|
|
561
|
+
}
|
|
562
|
+
#[test]
|
|
563
|
+
fn diagnostics_are_an_explicit_boolean_opt_in() {
|
|
564
|
+
let legacy = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,"engine_hosts":[]});
|
|
565
|
+
let old: SearchRequest = serde_json::from_value(legacy.clone()).unwrap();
|
|
566
|
+
assert!(!old.diagnostics);
|
|
567
|
+
for requested in [false, true] {
|
|
568
|
+
let mut input = legacy.clone();
|
|
569
|
+
input["diagnostics"] = serde_json::json!(requested);
|
|
570
|
+
let parsed: SearchRequest = serde_json::from_value(input).unwrap();
|
|
571
|
+
let output = DiagnosticResponse {
|
|
572
|
+
response: SearchResponse {
|
|
573
|
+
query: "query".into(),
|
|
574
|
+
results: vec![],
|
|
575
|
+
engines_queried: vec![],
|
|
576
|
+
engines_failed: vec![],
|
|
577
|
+
},
|
|
578
|
+
engine_failures: parsed.diagnostics.then_some(vec![]),
|
|
579
|
+
engine_usage: None,
|
|
580
|
+
};
|
|
581
|
+
let encoded = serde_json::to_value(output).unwrap();
|
|
582
|
+
assert_eq!(
|
|
583
|
+
encoded.as_object().unwrap().len(),
|
|
584
|
+
if requested { 5 } else { 4 }
|
|
585
|
+
);
|
|
586
|
+
assert_eq!(encoded.get("engine_failures").is_some(), requested);
|
|
587
|
+
}
|
|
588
|
+
for invalid in [
|
|
589
|
+
serde_json::json!(null),
|
|
590
|
+
serde_json::json!(1),
|
|
591
|
+
serde_json::json!("true"),
|
|
592
|
+
] {
|
|
593
|
+
let mut input = legacy.clone();
|
|
594
|
+
input["diagnostics"] = invalid;
|
|
595
|
+
assert!(serde_json::from_value::<SearchRequest>(input).is_err());
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
#[test]
|
|
599
|
+
fn optional_bing_preserves_exact_legacy_host_contract() {
|
|
600
|
+
let mut value = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
|
|
601
|
+
"engine_hosts": expected_hosts(false, false).into_iter().map(|hostname| serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>() });
|
|
602
|
+
let legacy: SearchRequest = serde_json::from_value(value.clone()).unwrap();
|
|
603
|
+
assert!(!legacy.bing_rss);
|
|
604
|
+
assert!(validate_engine_hosts(&legacy).is_ok());
|
|
605
|
+
value["bing_rss"] = serde_json::json!(true);
|
|
606
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
|
|
607
|
+
value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"www.bing.com","addresses":[{"address":"93.184.216.34","family":4}]}));
|
|
608
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
|
|
609
|
+
value["bing_rss"] = serde_json::json!(false);
|
|
610
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
|
|
611
|
+
value["bing_rss"] = serde_json::json!("true");
|
|
612
|
+
assert!(serde_json::from_value::<SearchRequest>(value).is_err());
|
|
613
|
+
}
|
|
614
|
+
#[test]
|
|
615
|
+
fn optional_openalex_preserves_legacy_hosts_and_boolean_contract() {
|
|
616
|
+
let mut value=serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
|
|
617
|
+
"engine_hosts":expected_hosts(false,false).into_iter().map(|hostname|serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>()});
|
|
618
|
+
let legacy:SearchRequest=serde_json::from_value(value.clone()).unwrap();assert!(!legacy.openalex_discovery);
|
|
619
|
+
value["openalex_discovery"]=serde_json::json!(true);
|
|
620
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
|
|
621
|
+
value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"api.openalex.org","addresses":[{"address":"93.184.216.34","family":4}]}));
|
|
622
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
|
|
623
|
+
value["openalex_discovery"]=serde_json::json!(false);
|
|
624
|
+
assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
|
|
625
|
+
value["openalex_discovery"]=serde_json::json!("true");assert!(serde_json::from_value::<SearchRequest>(value).is_err());
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
}
|