@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
|
@@ -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,
|