@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.
@@ -0,0 +1,557 @@
1
+ //! Explicit opt-in scholarly discovery. Indexed links are not fetched body evidence.
2
+ use super::FailureCode;
3
+ use metadata_search_engine_rs::models::SearchResult;
4
+ use reqwest::{Client, header};
5
+ use serde_json::Value;
6
+ use std::time::Duration;
7
+ use url::Url;
8
+
9
+ const ENDPOINT: &str = "https://api.openalex.org/works";
10
+ const MAX_BODY: usize = 2 * 1024 * 1024;
11
+ const MAX_TEXT: usize = 16 * 1024;
12
+ pub struct Discovery {
13
+ pub results: Vec<SearchResult>,
14
+ pub reported_cost_usd: Option<f64>,
15
+ }
16
+ impl std::fmt::Debug for Discovery {
17
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18
+ f.write_str("Discovery")
19
+ }
20
+ }
21
+ type Outcome = Result<Discovery, FailureCode>;
22
+
23
+ pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
24
+ search_at(client, ENDPOINT, query, limit, timeout).await
25
+ }
26
+
27
+ async fn search_at(
28
+ client: &Client,
29
+ endpoint: &str,
30
+ query: &str,
31
+ limit: usize,
32
+ timeout: Duration,
33
+ ) -> Outcome {
34
+ tokio::time::timeout(timeout, async {
35
+ let mut response = client
36
+ .get(endpoint)
37
+ .query(&[("search", query), ("per-page", &limit.to_string())])
38
+ .send()
39
+ .await
40
+ .map_err(|error| super::transport_code(&error, FailureCode::Http))?;
41
+ if !response.status().is_success() {
42
+ return Err(super::status_code(response.status().as_u16()));
43
+ }
44
+ let content_type = response
45
+ .headers()
46
+ .get(header::CONTENT_TYPE)
47
+ .and_then(|v| v.to_str().ok())
48
+ .unwrap_or("")
49
+ .split(';')
50
+ .next()
51
+ .unwrap_or("")
52
+ .trim();
53
+ if !["application/json"]
54
+ .iter()
55
+ .any(|v| content_type.eq_ignore_ascii_case(v))
56
+ {
57
+ return Err(FailureCode::ContentType);
58
+ }
59
+ if response.headers().contains_key(header::CONTENT_ENCODING) {
60
+ return Err(FailureCode::ContentEncoding);
61
+ }
62
+ if response
63
+ .content_length()
64
+ .is_some_and(|length| length > MAX_BODY as u64)
65
+ {
66
+ return Err(FailureCode::BodyLimit);
67
+ }
68
+ let mut body = Vec::new();
69
+ while let Some(chunk) = response
70
+ .chunk()
71
+ .await
72
+ .map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
73
+ {
74
+ if chunk.len() > MAX_BODY.saturating_sub(body.len()) {
75
+ return Err(FailureCode::BodyLimit);
76
+ }
77
+ body.extend_from_slice(&chunk);
78
+ }
79
+ parse(
80
+ std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?,
81
+ limit,
82
+ query,
83
+ )
84
+ })
85
+ .await
86
+ .map_err(|_| FailureCode::Timeout)?
87
+ }
88
+
89
+ fn destination(raw: &str) -> Result<Option<String>, FailureCode> {
90
+ if raw.len() > 8192 {
91
+ return Err(FailureCode::BodyLimit);
92
+ }
93
+ let Ok(url) = Url::parse(raw) else {
94
+ return Ok(None);
95
+ };
96
+ let Some(host) = url.host_str() else {
97
+ return Ok(None);
98
+ };
99
+ let host = host.trim_end_matches('.');
100
+ if url.scheme() != "https"
101
+ || !url.username().is_empty()
102
+ || url.password().is_some()
103
+ || url.port().is_some_and(|p| p != 443)
104
+ || !host.contains('.')
105
+ || host.ends_with(".localhost")
106
+ || host.ends_with(".local")
107
+ || host
108
+ .trim_matches(['[', ']'])
109
+ .parse()
110
+ .is_ok_and(super::is_private_or_reserved)
111
+ {
112
+ return Ok(None);
113
+ }
114
+ Ok(Some(url.into()))
115
+ }
116
+
117
+ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
118
+ if body.len() > MAX_BODY {
119
+ return Err(FailureCode::BodyLimit);
120
+ }
121
+ if limit == 0 || limit > 20 {
122
+ return Err(FailureCode::ResultLimit);
123
+ }
124
+ let value: Value = serde_json::from_str(body).map_err(|_| FailureCode::Parse)?;
125
+ let meta = value
126
+ .get("meta")
127
+ .and_then(Value::as_object)
128
+ .ok_or(FailureCode::Parse)?;
129
+ if meta.get("page").and_then(Value::as_u64) != Some(1)
130
+ || meta.get("per_page").and_then(Value::as_u64) != Some(limit as u64)
131
+ || meta.get("count").and_then(Value::as_u64).is_none()
132
+ {
133
+ return Err(FailureCode::Parse);
134
+ }
135
+ let observed = meta
136
+ .get("x_query")
137
+ .and_then(|x| x.get("oqo"))
138
+ .ok_or(FailureCode::QueryMismatch)?;
139
+ let filters = observed
140
+ .get("filter_rows")
141
+ .and_then(Value::as_array)
142
+ .ok_or(FailureCode::QueryMismatch)?;
143
+ if observed.get("get_rows").and_then(Value::as_str) != Some("works")
144
+ || filters.len() != 1
145
+ || filters[0].get("column_id").and_then(Value::as_str) != Some("fulltext.search")
146
+ || filters[0].get("operator").and_then(Value::as_str) != Some("has")
147
+ || filters[0].get("value").and_then(Value::as_str) != Some(query)
148
+ {
149
+ return Err(FailureCode::QueryMismatch);
150
+ }
151
+ let rows = value
152
+ .get("results")
153
+ .and_then(Value::as_array)
154
+ .ok_or(FailureCode::Parse)?;
155
+ if rows.len() > limit {
156
+ return Err(FailureCode::ResultLimit);
157
+ }
158
+ if (rows.is_empty() && meta["count"].as_u64().unwrap() != 0)
159
+ || meta["count"].as_u64().unwrap() < rows.len() as u64
160
+ {
161
+ return Err(FailureCode::Parse);
162
+ }
163
+ let reported_cost_usd = match meta.get("cost_usd") {
164
+ None => None,
165
+ Some(value) => {
166
+ let cost = value.as_f64().ok_or(FailureCode::Parse)?;
167
+ if !cost.is_finite() || !(0.0..=1_000_000.0).contains(&cost) {
168
+ return Err(FailureCode::Parse);
169
+ }
170
+ Some(cost)
171
+ }
172
+ };
173
+ let mut seen = std::collections::HashSet::new();
174
+ let mut seen_ids = std::collections::HashSet::new();
175
+ let mut results = Vec::new();
176
+ for row in rows {
177
+ let id = row
178
+ .get("id")
179
+ .and_then(Value::as_str)
180
+ .ok_or(FailureCode::Parse)?;
181
+ let suffix = id
182
+ .strip_prefix("https://openalex.org/W")
183
+ .ok_or(FailureCode::Parse)?;
184
+ if suffix.is_empty() || suffix.len() > 24 || !suffix.bytes().all(|b| b.is_ascii_digit()) {
185
+ return Err(FailureCode::Parse);
186
+ }
187
+ let title = row
188
+ .get("title")
189
+ .and_then(Value::as_str)
190
+ .ok_or(FailureCode::Parse)?;
191
+ if title.trim().is_empty() || title.len() > MAX_TEXT {
192
+ return Err(FailureCode::Parse);
193
+ }
194
+ let mut candidates = Vec::new();
195
+ let mut locations = Vec::new();
196
+ for field in ["best_oa_location", "primary_location"] {
197
+ match row.get(field) {
198
+ None | Some(Value::Null) => {}
199
+ Some(Value::Object(location)) => {
200
+ if field == "best_oa_location"
201
+ && location.get("is_oa").and_then(Value::as_bool) != Some(true)
202
+ {
203
+ return Err(FailureCode::Parse);
204
+ }
205
+ locations.push(location);
206
+ }
207
+ _ => return Err(FailureCode::Parse),
208
+ }
209
+ }
210
+ match row.get("locations") {
211
+ None | Some(Value::Null) => {}
212
+ Some(Value::Array(values)) if values.len() <= 256 => {
213
+ for value in values {
214
+ let location = value.as_object().ok_or(FailureCode::Parse)?;
215
+ if location.get("is_oa").and_then(Value::as_bool).is_none() {
216
+ return Err(FailureCode::Parse);
217
+ }
218
+ if location["is_oa"] == true {
219
+ locations.push(location);
220
+ }
221
+ }
222
+ }
223
+ _ => return Err(FailureCode::Parse),
224
+ }
225
+ for location in locations {
226
+ for key in ["landing_page_url", "pdf_url"] {
227
+ match location.get(key) {
228
+ None | Some(Value::Null) => {}
229
+ Some(Value::String(raw)) => {
230
+ let Some(url) = destination(raw)? else {
231
+ continue;
232
+ };
233
+ let parsed = Url::parse(&url).unwrap();
234
+ let host = parsed.host_str().unwrap();
235
+ let archive_article = (host == "europepmc.org"
236
+ || host == "www.europepmc.org")
237
+ && parsed.path().starts_with("/articles/")
238
+ || host == "pmc.ncbi.nlm.nih.gov"
239
+ && parsed.path().starts_with("/articles/")
240
+ || (host == "www.ncbi.nlm.nih.gov" || host == "ncbi.nlm.nih.gov")
241
+ && parsed.path().starts_with("/pmc/articles/");
242
+ let priority = if host == "doi.org" {
243
+ 4
244
+ } else if key == "pdf_url"
245
+ || parsed.path().to_ascii_lowercase().ends_with(".pdf")
246
+ {
247
+ 2
248
+ } else if archive_article {
249
+ 0
250
+ } else if location.get("is_oa").and_then(Value::as_bool) == Some(true) {
251
+ 1
252
+ } else {
253
+ 3
254
+ };
255
+ candidates.push((priority, url));
256
+ }
257
+ _ => return Err(FailureCode::Parse),
258
+ }
259
+ }
260
+ }
261
+ match row.get("doi") {
262
+ None | Some(Value::Null) => {}
263
+ Some(Value::String(raw)) => {
264
+ if let Some(url) = destination(raw)? {
265
+ if Url::parse(&url).unwrap().host_str() != Some("doi.org") {
266
+ return Err(FailureCode::Parse);
267
+ }
268
+ candidates.push((4, url));
269
+ }
270
+ }
271
+ _ => return Err(FailureCode::Parse),
272
+ }
273
+ // Prefer observed direct destinations over DOI redirectors; preserve stable location order.
274
+ candidates.sort_by_key(|(priority, _)| *priority);
275
+ let Some((_, url)) = candidates.into_iter().next() else {
276
+ continue;
277
+ };
278
+ if seen_ids.insert(id.to_owned()) && seen.insert(url.clone()) {
279
+ results.push(SearchResult {
280
+ title: title.split_whitespace().collect::<Vec<_>>().join(" "),
281
+ url,
282
+ snippet: Some("Scholarly index metadata; full text not fetched.".into()),
283
+ source_engine: "openalex".into(),
284
+ });
285
+ }
286
+ }
287
+ if !rows.is_empty() && results.is_empty() {
288
+ return Err(FailureCode::UnverifiedEmpty);
289
+ }
290
+ Ok(Discovery {
291
+ results,
292
+ reported_cost_usd,
293
+ })
294
+ }
295
+
296
+ #[cfg(test)]
297
+ mod tests {
298
+ use super::*;
299
+ use serde_json::json;
300
+ fn row() -> Value {
301
+ serde_json::json!({"id":"https://openalex.org/W123", "title":"Microalgae harvesting energy efficiency", "doi":"https://doi.org/10.1234/paper", "best_oa_location":{"is_oa":true,"landing_page_url":"https://repository.example/paper","pdf_url":"https://repository.example/paper.pdf"}})
302
+ }
303
+ fn response(rows: Vec<Value>) -> Value {
304
+ serde_json::json!({"meta":{"page":1,"per_page":3,"count":rows.len(),"x_query":{"oqo":{"get_rows":"works","filter_rows":[{"column_id":"fulltext.search","operator":"has","value":"microalgae"}]}}},"results":rows})
305
+ }
306
+ fn check(value: Value) -> Outcome {
307
+ parse(&value.to_string(), 3, "microalgae")
308
+ }
309
+ #[test]
310
+ fn discovery_prefers_observed_oa_landing_and_deduplicates() {
311
+ let results = check(response(vec![row(), row()])).unwrap().results;
312
+ assert_eq!(results.len(), 1);
313
+ assert_eq!(results[0].url, "https://repository.example/paper");
314
+ assert_eq!(results[0].source_engine, "openalex");
315
+ assert_eq!(
316
+ results[0].snippet.as_deref(),
317
+ Some("Scholarly index metadata; full text not fetched.")
318
+ );
319
+ let mut r = row();
320
+ r["best_oa_location"] = Value::Null;
321
+ assert_eq!(
322
+ check(response(vec![r])).unwrap().results[0].url,
323
+ "https://doi.org/10.1234/paper"
324
+ );
325
+ assert!(check(response(vec![])).unwrap().results.is_empty());
326
+ }
327
+ #[test]
328
+ fn prefers_only_observed_open_archive_html_locations() {
329
+ let mut r = row();
330
+ r["locations"] = json!([{ "is_oa":true,"landing_page_url":"https://www.ncbi.nlm.nih.gov/pmc/articles/3172406","pdf_url":null }]);
331
+ assert_eq!(
332
+ check(response(vec![r.clone()])).unwrap().results[0].url,
333
+ "https://www.ncbi.nlm.nih.gov/pmc/articles/3172406"
334
+ );
335
+ r["locations"][0]["is_oa"] = json!(false);
336
+ assert_eq!(
337
+ check(response(vec![r.clone()])).unwrap().results[0].url,
338
+ "https://repository.example/paper"
339
+ );
340
+ r["locations"][0]["is_oa"] = json!(true);
341
+ r["locations"][0]["landing_page_url"] = json!("https://127.0.0.1/x");
342
+ assert_eq!(
343
+ check(response(vec![r])).unwrap().results[0].url,
344
+ "https://repository.example/paper"
345
+ );
346
+ let mut bad = response(vec![row()]);
347
+ bad["meta"]["cost_usd"] = json!(-1);
348
+ assert!(check(bad).is_err());
349
+ }
350
+ #[test]
351
+ fn rejects_schema_query_and_bounds() {
352
+ for path in [
353
+ "/meta/page",
354
+ "/meta/per_page",
355
+ "/meta/count",
356
+ "/results/0/title",
357
+ "/results/0/id",
358
+ "/results/0/best_oa_location/is_oa",
359
+ ] {
360
+ let mut v = response(vec![row()]);
361
+ *v.pointer_mut(path).unwrap() = Value::Null;
362
+ assert!(check(v).is_err(), "{path}");
363
+ }
364
+ let mut wrong = response(vec![]);
365
+ wrong["meta"]["x_query"]["oqo"]["filter_rows"][0]["value"] = Value::String("other".into());
366
+ assert_eq!(check(wrong).unwrap_err(), FailureCode::QueryMismatch);
367
+ assert_eq!(
368
+ check(response(vec![row(), row(), row(), row()])).unwrap_err(),
369
+ FailureCode::ResultLimit
370
+ );
371
+ assert_eq!(
372
+ parse(&" ".repeat(MAX_BODY + 1), 3, "microalgae").unwrap_err(),
373
+ FailureCode::BodyLimit
374
+ );
375
+ assert!(parse("not JSON", 3, "microalgae").is_err());
376
+ assert_eq!(
377
+ parse("{}", 0, "microalgae").unwrap_err(),
378
+ FailureCode::ResultLimit
379
+ );
380
+ }
381
+ #[test]
382
+ fn filters_unusable_destinations_without_losing_valid_rows_or_promoting_urls() {
383
+ for raw in [
384
+ "http://repository.example/paper",
385
+ "https://127.0.0.1/a",
386
+ "https://10.0.0.1/a",
387
+ "https://user:password@example.com/a",
388
+ "https://[::1]/a",
389
+ "https://localhost/a",
390
+ "https://localhost./a",
391
+ "https://host.local./a",
392
+ "https://host.localhost./a",
393
+ "file:///tmp/a",
394
+ "https://example.com:8443/a",
395
+ "not a URL",
396
+ "",
397
+ ] {
398
+ let mut unusable = row();
399
+ unusable["best_oa_location"]["landing_page_url"] = json!(raw);
400
+ unusable["best_oa_location"]["pdf_url"] = json!(raw);
401
+ unusable["doi"] = json!("http://doi.org/10.1234/old");
402
+ assert_eq!(
403
+ check(response(vec![unusable.clone()])).unwrap_err(),
404
+ FailureCode::UnverifiedEmpty,
405
+ "{raw}"
406
+ );
407
+ let mut good = row();
408
+ good["id"] = json!("https://openalex.org/W456");
409
+ let results = check(response(vec![unusable.clone(), good]))
410
+ .unwrap()
411
+ .results;
412
+ assert_eq!(results.len(), 1, "{raw}");
413
+ assert_eq!(results[0].url, "https://repository.example/paper", "{raw}");
414
+ unusable["locations"] = json!([{"is_oa":true,"landing_page_url":"https://europepmc.org/articles/PMC3172406"}]);
415
+ let results = check(response(vec![unusable])).unwrap().results;
416
+ assert_eq!(results.len(), 1, "{raw}");
417
+ assert_eq!(
418
+ results[0].url, "https://europepmc.org/articles/PMC3172406",
419
+ "{raw}"
420
+ );
421
+ }
422
+ }
423
+ #[test]
424
+ fn keeps_destination_type_and_size_failures_after_valid_candidates() {
425
+ for key in ["landing_page_url", "pdf_url"] {
426
+ for value in [json!(42), json!(false), json!([]), json!({})] {
427
+ let mut r = row();
428
+ r["locations"] = json!([{"is_oa":true,key:value}]);
429
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
430
+ }
431
+ }
432
+ let mut r = row();
433
+ r["doi"] = json!(42);
434
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
435
+ let mut r = row();
436
+ r["doi"] = json!("https://other.example/paper");
437
+ assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
438
+ let mut r = row();
439
+ r["locations"] = json!([{"is_oa":true,"landing_page_url":"x".repeat(8193)}]);
440
+ assert_eq!(
441
+ check(response(vec![r])).unwrap_err(),
442
+ FailureCode::BodyLimit
443
+ );
444
+ }
445
+ #[test]
446
+ fn pins_fixed_json_request_and_bounds_transport() {
447
+ use std::io::{Read, Write};
448
+ for (status, kind, body, expected) in [
449
+ (
450
+ "200 OK",
451
+ "application/json",
452
+ response(vec![row()]).to_string(),
453
+ None,
454
+ ),
455
+ (
456
+ "302 Found",
457
+ "application/json",
458
+ String::new(),
459
+ Some(FailureCode::HttpRedirect),
460
+ ),
461
+ (
462
+ "403 Forbidden",
463
+ "application/json",
464
+ String::new(),
465
+ Some(FailureCode::HttpForbidden),
466
+ ),
467
+ (
468
+ "200 OK",
469
+ "text/html",
470
+ "<html>challenge</html>".into(),
471
+ Some(FailureCode::ContentType),
472
+ ),
473
+ (
474
+ "200 OK",
475
+ "application/json",
476
+ "x".repeat(MAX_BODY + 1),
477
+ Some(FailureCode::BodyLimit),
478
+ ),
479
+ ] {
480
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
481
+ listener.set_nonblocking(true).unwrap();
482
+ let endpoint = format!("http://{}/works", listener.local_addr().unwrap());
483
+ let server = std::thread::spawn(move || {
484
+ let deadline = std::time::Instant::now() + Duration::from_secs(4);
485
+ let (mut socket, _) = loop {
486
+ match listener.accept() {
487
+ Ok(value) => break value,
488
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
489
+ assert!(
490
+ std::time::Instant::now() < deadline,
491
+ "fixture accept deadline"
492
+ );
493
+ std::thread::sleep(Duration::from_millis(5));
494
+ }
495
+ Err(error) => panic!("{error}"),
496
+ }
497
+ };
498
+ socket.set_nonblocking(false).unwrap();
499
+ socket
500
+ .set_write_timeout(Some(Duration::from_secs(3)))
501
+ .unwrap();
502
+ socket
503
+ .set_read_timeout(Some(Duration::from_secs(3)))
504
+ .unwrap();
505
+ let mut bytes = Vec::new();
506
+ let mut byte = [0];
507
+ while !bytes.ends_with(b"\r\n\r\n") {
508
+ assert!(bytes.len() < 8192);
509
+ socket.read_exact(&mut byte).unwrap();
510
+ bytes.push(byte[0]);
511
+ }
512
+ let request = String::from_utf8(bytes).unwrap();
513
+ assert!(
514
+ request.starts_with("GET /works?search=microalgae&per-page=3 HTTP/1.1\r\n")
515
+ );
516
+ assert!(!request.to_lowercase().contains("authorization:"));
517
+ assert!(!request.to_lowercase().contains("cookie:"));
518
+ let head = format!(
519
+ "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nLocation: http://127.0.0.1:1/never\r\nConnection: close\r\n\r\n",
520
+ body.len()
521
+ );
522
+ socket.write_all(head.as_bytes()).unwrap();
523
+ let _ = socket.write_all(body.as_bytes());
524
+ });
525
+ let runtime = tokio::runtime::Builder::new_current_thread()
526
+ .enable_all()
527
+ .build()
528
+ .unwrap();
529
+ let result = runtime.block_on(async {
530
+ let client = Client::builder()
531
+ .no_proxy()
532
+ .redirect(reqwest::redirect::Policy::none())
533
+ .build()
534
+ .unwrap();
535
+ search_at(&client, &endpoint, "microalgae", 3, Duration::from_secs(3)).await
536
+ });
537
+ server.join().unwrap();
538
+ if let Some(code) = expected {
539
+ assert_eq!(result.unwrap_err(), code);
540
+ } else {
541
+ assert_eq!(result.unwrap().results.len(), 1);
542
+ }
543
+ }
544
+ }
545
+ #[test]
546
+ #[ignore = "requires explicitly supplied retained JSON; performs no network requests"]
547
+ fn retained_public_response_offline() {
548
+ let path =
549
+ std::env::var("WORDCELL_OPENALEX_RETAINED_JSON").expect("supply retained JSON path");
550
+ let body = std::fs::read_to_string(path).unwrap();
551
+ let result = parse(&body, 10, "microalgae harvesting energy efficiency").unwrap();
552
+ assert_eq!(result.reported_cost_usd, Some(0.001));
553
+ let rows = result.results;
554
+ assert!(!rows.is_empty());
555
+ assert!(rows.iter().any(|r| r.title.contains("harvesting")));
556
+ }
557
+ }
@@ -92,6 +92,10 @@ export type SearchProvider = (request: SearchProviderRequest) => Promise<SearchP
92
92
  export type RustMetadataSearchProviderOptions = {
93
93
  /** Absolute path to the reviewed binary. The file identity is pinned when the provider is created. */
94
94
  readonly binaryPath: string;
95
+ /** Opt in to the fixed, public Bing RSS endpoint. Disabled by default. */
96
+ readonly enableBingRss?: boolean;
97
+ /** Opt in to public OpenAlex scholarly discovery; indexed links are not fetched evidence. */
98
+ readonly enableOpenAlexDiscovery?: boolean;
95
99
  readonly defaultMaxResults?: number;
96
100
  readonly defaultTimeoutMs?: number;
97
101
  readonly processGraceMs?: number;
@@ -483,14 +487,19 @@ function deterministicResponse(response: MetadataSearchResponse, maximumResults:
483
487
  enginesQueried,
484
488
  enginesFailed: Object.freeze([...response.enginesFailed].sort()),
485
489
  engineStatus: response.engineStatus,
490
+ ...(response.engineFailures === undefined ? {} : { engineFailures: Object.freeze(
491
+ [...response.engineFailures].sort((a, b) => compareText(a.engine, b.engine)),
492
+ ) }),
486
493
  });
487
494
  }
488
495
 
489
496
  async function resolveEngineHosts(
490
497
  resolver: MetadataSearchNetworkResolver,
491
498
  timeoutMs: number,
499
+ enableBingRss: boolean,
500
+ enableOpenAlexDiscovery: boolean,
492
501
  ): Promise<readonly ResolvedEngineHost[]> {
493
- const resolved = await Promise.all(METADATA_SEARCH_ENGINE_HOSTS.map(async (hostname) => {
502
+ const resolved = await Promise.all([...METADATA_SEARCH_ENGINE_HOSTS, ...(enableBingRss ? ["www.bing.com"] : []), ...(enableOpenAlexDiscovery ? ["api.openalex.org"] : [])].map(async (hostname) => {
494
503
  const addresses = await resolver(new URL(`https://${hostname}/`), {
495
504
  allowPrivateNetwork: false,
496
505
  timeoutMs,
@@ -548,6 +557,10 @@ export function createExactUrlSearchQuery(value: string | URL): string | null {
548
557
 
549
558
  /** Create the isolated adapter for the reviewed Rust metadata-search helper. */
550
559
  export function createRustMetadataSearchProvider(options: RustMetadataSearchProviderOptions): SearchProvider {
560
+ if (options.enableBingRss !== undefined && typeof options.enableBingRss !== "boolean") throw new TypeError("enableBingRss must be boolean.");
561
+ const enableBingRss = options.enableBingRss === true;
562
+ if (options.enableOpenAlexDiscovery !== undefined && typeof options.enableOpenAlexDiscovery !== "boolean") throw new TypeError("enableOpenAlexDiscovery must be boolean.");
563
+ const enableOpenAlexDiscovery = options.enableOpenAlexDiscovery === true;
551
564
  if (!isAbsolute(options.binaryPath)) {
552
565
  throw new TypeError("The metadata search binary path must be absolute.");
553
566
  }
@@ -609,7 +622,7 @@ export function createRustMetadataSearchProvider(options: RustMetadataSearchProv
609
622
 
610
623
  let engineHosts: readonly ResolvedEngineHost[];
611
624
  try {
612
- engineHosts = await resolveEngineHosts(resolveNetworkTarget, validated.timeoutMs);
625
+ engineHosts = await resolveEngineHosts(resolveNetworkTarget, validated.timeoutMs, enableBingRss, enableOpenAlexDiscovery);
613
626
  } catch {
614
627
  return failure("unavailable", "Metadata search network targets are unavailable.");
615
628
  }
@@ -634,6 +647,9 @@ export function createRustMetadataSearchProvider(options: RustMetadataSearchProv
634
647
  try {
635
648
  const input = JSON.stringify({
636
649
  schema_version: REQUEST_SCHEMA_VERSION,
650
+ diagnostics: true,
651
+ ...(enableBingRss ? { bing_rss: true } : {}),
652
+ ...(enableOpenAlexDiscovery ? { openalex_discovery: true } : {}),
637
653
  query: validated.query,
638
654
  max_results: validated.maxResults,
639
655
  timeout_ms: validated.timeoutMs,