@hraness/wordcell 0.24.0 → 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.
Files changed (49) hide show
  1. package/README.md +14 -12
  2. package/dist/authoring.js +2 -1
  3. package/dist/capture.js +2 -2
  4. package/dist/cli.js +12 -11
  5. package/dist/clip/cli.js +2 -2
  6. package/dist/clip/defuddle-worker.js +87 -2
  7. package/dist/clip/jobs.js +5 -1
  8. package/dist/evaluation-builder.js +5 -4
  9. package/dist/evaluation-kb.js +5 -4
  10. package/dist/graph-authority.js +2 -1
  11. package/dist/graph-percolation.js +3 -2
  12. package/dist/{index-44kw9tr8.js → index-1wccy765.js} +18 -12
  13. package/dist/{index-1er88ckw.js → index-3gc2yk4k.js} +10 -5
  14. package/dist/{index-0eacgvpv.js → index-47893rgw.js} +22 -8
  15. package/dist/{index-mvs6f7ne.js → index-59bcxqee.js} +1 -1
  16. package/dist/{index-1w0sp1q7.js → index-7drercgk.js} +1 -1
  17. package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
  18. package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
  19. package/dist/{index-f184qyh1.js → index-hz2wyggk.js} +1 -1
  20. package/dist/{index-5d1rd5v9.js → index-mcpppwbq.js} +2 -2
  21. package/dist/index-qkesh4c6.js +7 -0
  22. package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
  23. package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
  24. package/dist/index.js +6 -5
  25. package/dist/portfolio.js +5 -4
  26. package/dist/sdk.js +4 -3
  27. package/dist/{support-1yhv6vm6.js → support-adj7bbge.js} +11 -10
  28. package/dist/url-intelligence.js +3 -2
  29. package/dist/workflows/decision-context.js +5 -4
  30. package/dist/workflows/index.js +5 -4
  31. package/package.json +13 -8
  32. package/skills/wordcell/SKILL.md +1 -1
  33. package/skills/wordcell/references/publish.md +2 -1
  34. package/src/authoring-platform.ts +3 -0
  35. package/src/cli-program.ts +3 -3
  36. package/src/clip/jobs.ts +2 -1
  37. package/src/clip/metadata-search-tool/Cargo.lock +12 -0
  38. package/src/clip/metadata-search-tool/Cargo.toml +3 -0
  39. package/src/clip/metadata-search-tool/runner.ts +4 -1
  40. package/src/clip/metadata-search-tool/src/bing.rs +539 -0
  41. package/src/clip/metadata-search-tool/src/duckduckgo.rs +562 -0
  42. package/src/clip/metadata-search-tool/src/main.rs +263 -24
  43. package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
  44. package/src/clip/metadata-search.ts +22 -4
  45. package/src/clip/url-intelligence.ts +54 -1
  46. package/src/clip/url-metadata.ts +2 -1
  47. package/src/directory-sync.ts +9 -0
  48. package/src/graph-authority.ts +5 -2
  49. package/src/url-intelligence.ts +2 -0
@@ -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,19 +487,24 @@ 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,
497
506
  });
498
- if (addresses.length === 0 || addresses.length > MAX_ENGINE_ADDRESSES) {
507
+ if (addresses.length === 0) {
499
508
  throw new Error("metadata search engine DNS answer count is invalid");
500
509
  }
501
510
  const unique = new Map<string, ResolvedNetworkAddress>();
@@ -511,8 +520,10 @@ async function resolveEngineHosts(
511
520
  if (unique.size !== addresses.length) throw new Error("metadata search engine DNS answer is duplicated");
512
521
  return Object.freeze({
513
522
  hostname,
523
+ // Validate every DNS answer before selecting the bounded transport candidates.
514
524
  addresses: Object.freeze([...unique.values()].sort((left, right) =>
515
- left.family - right.family || (left.address < right.address ? -1 : left.address > right.address ? 1 : 0))),
525
+ left.family - right.family || (left.address < right.address ? -1 : left.address > right.address ? 1 : 0))
526
+ .slice(0, MAX_ENGINE_ADDRESSES)),
516
527
  });
517
528
  }));
518
529
  return Object.freeze(resolved);
@@ -546,6 +557,10 @@ export function createExactUrlSearchQuery(value: string | URL): string | null {
546
557
 
547
558
  /** Create the isolated adapter for the reviewed Rust metadata-search helper. */
548
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;
549
564
  if (!isAbsolute(options.binaryPath)) {
550
565
  throw new TypeError("The metadata search binary path must be absolute.");
551
566
  }
@@ -607,7 +622,7 @@ export function createRustMetadataSearchProvider(options: RustMetadataSearchProv
607
622
 
608
623
  let engineHosts: readonly ResolvedEngineHost[];
609
624
  try {
610
- engineHosts = await resolveEngineHosts(resolveNetworkTarget, validated.timeoutMs);
625
+ engineHosts = await resolveEngineHosts(resolveNetworkTarget, validated.timeoutMs, enableBingRss, enableOpenAlexDiscovery);
611
626
  } catch {
612
627
  return failure("unavailable", "Metadata search network targets are unavailable.");
613
628
  }
@@ -632,6 +647,9 @@ export function createRustMetadataSearchProvider(options: RustMetadataSearchProv
632
647
  try {
633
648
  const input = JSON.stringify({
634
649
  schema_version: REQUEST_SCHEMA_VERSION,
650
+ diagnostics: true,
651
+ ...(enableBingRss ? { bing_rss: true } : {}),
652
+ ...(enableOpenAlexDiscovery ? { openalex_discovery: true } : {}),
635
653
  query: validated.query,
636
654
  max_results: validated.maxResults,
637
655
  timeout_ms: validated.timeoutMs,