@longtable/scholar-research 0.1.60
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 +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/protocol.d.ts +56 -0
- package/dist/protocol.js +159 -0
- package/dist/publisher-access.d.ts +21 -0
- package/dist/publisher-access.js +564 -0
- package/dist/query.d.ts +6 -0
- package/dist/query.js +179 -0
- package/dist/rank.d.ts +2 -0
- package/dist/rank.js +173 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.js +114 -0
- package/dist/sources.d.ts +5 -0
- package/dist/sources.js +537 -0
- package/dist/types.d.ts +179 -0
- package/dist/types.js +16 -0
- package/package.json +47 -0
package/dist/sources.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { SEARCH_SOURCES } from "./types.js";
|
|
2
|
+
function endpoint(url, params) {
|
|
3
|
+
const parsed = new URL(url);
|
|
4
|
+
for (const [key, value] of Object.entries(params)) {
|
|
5
|
+
if (value !== undefined && value !== "") {
|
|
6
|
+
parsed.searchParams.set(key, String(value));
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return parsed.toString();
|
|
10
|
+
}
|
|
11
|
+
function cleanText(value) {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const cleaned = value
|
|
16
|
+
.replace(/<[^>]+>/g, " ")
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, "\"")
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim();
|
|
24
|
+
return cleaned || undefined;
|
|
25
|
+
}
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
28
|
+
}
|
|
29
|
+
function asArray(value) {
|
|
30
|
+
return Array.isArray(value) ? value : [];
|
|
31
|
+
}
|
|
32
|
+
function asString(value) {
|
|
33
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
34
|
+
}
|
|
35
|
+
function asNumber(value) {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
function normalizeDoi(value) {
|
|
39
|
+
if (!value) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "")
|
|
44
|
+
.replace(/^doi:\s*/i, "")
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase() || undefined;
|
|
47
|
+
}
|
|
48
|
+
function firstString(value) {
|
|
49
|
+
const entries = asArray(value);
|
|
50
|
+
return asString(entries[0]);
|
|
51
|
+
}
|
|
52
|
+
function yearFromParts(value) {
|
|
53
|
+
const parts = asRecord(value)["date-parts"];
|
|
54
|
+
const first = asArray(parts)[0];
|
|
55
|
+
const year = asArray(first)[0];
|
|
56
|
+
return asNumber(year);
|
|
57
|
+
}
|
|
58
|
+
function inferEvidenceDepth(abstract, legalFullTextAvailable = false) {
|
|
59
|
+
if (legalFullTextAvailable)
|
|
60
|
+
return "legal_full_text_available";
|
|
61
|
+
if (abstract)
|
|
62
|
+
return "abstract_only";
|
|
63
|
+
return "metadata_only";
|
|
64
|
+
}
|
|
65
|
+
function inferAccessStatus(abstract, legalFullTextAvailable = false) {
|
|
66
|
+
if (legalFullTextAvailable)
|
|
67
|
+
return "legal_full_text_available";
|
|
68
|
+
if (abstract)
|
|
69
|
+
return "abstract_available";
|
|
70
|
+
return "metadata_only";
|
|
71
|
+
}
|
|
72
|
+
function inferVerificationDepth(abstract) {
|
|
73
|
+
if (abstract)
|
|
74
|
+
return "abstract";
|
|
75
|
+
return "metadata";
|
|
76
|
+
}
|
|
77
|
+
function verificationNote(abstract, legalFullTextAvailable = false) {
|
|
78
|
+
if (legalFullTextAvailable && abstract) {
|
|
79
|
+
return "Legal full text URL was found, but this card is abstract-based and not full-paper verified.";
|
|
80
|
+
}
|
|
81
|
+
if (legalFullTextAvailable) {
|
|
82
|
+
return "Legal full text URL was found, but LongTable did not retrieve or verify the full text.";
|
|
83
|
+
}
|
|
84
|
+
if (abstract) {
|
|
85
|
+
return "Abstract is available; citation support is abstract-based, not full-paper verified.";
|
|
86
|
+
}
|
|
87
|
+
return "Metadata exists; citation support has not been verified against abstract or full text.";
|
|
88
|
+
}
|
|
89
|
+
function inferResearchDesign(abstract) {
|
|
90
|
+
const normalized = abstract?.toLowerCase() ?? "";
|
|
91
|
+
if (!normalized)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (/\bsystematic review\b|\bmeta-analysis\b/.test(normalized))
|
|
94
|
+
return "review or meta-analysis";
|
|
95
|
+
if (/\brandomi[sz]ed\b|\bexperiment\b|\btrial\b/.test(normalized))
|
|
96
|
+
return "experimental or trial design";
|
|
97
|
+
if (/\bsurvey\b|\bquestionnaire\b|\bscale\b/.test(normalized))
|
|
98
|
+
return "survey or scale-based design";
|
|
99
|
+
if (/\binterview\b|\bqualitative\b|\bcase study\b/.test(normalized))
|
|
100
|
+
return "qualitative design";
|
|
101
|
+
if (/\blongitudinal\b|\bpanel data\b/.test(normalized))
|
|
102
|
+
return "longitudinal design";
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
function inferMainFinding(abstract) {
|
|
106
|
+
if (!abstract)
|
|
107
|
+
return undefined;
|
|
108
|
+
const sentence = abstract.split(/(?<=[.!?])\s+/).find((entry) => entry.length > 40);
|
|
109
|
+
return sentence ? sentence.slice(0, 360) : undefined;
|
|
110
|
+
}
|
|
111
|
+
function baseCard(input) {
|
|
112
|
+
const title = cleanText(input.title) ?? "Untitled scholarly record";
|
|
113
|
+
const abstract = cleanText(input.abstract);
|
|
114
|
+
const legalFullTextAvailable = input.legalFullTextAvailable === true;
|
|
115
|
+
const limitations = [
|
|
116
|
+
abstract ? "" : "No abstract was available from this source.",
|
|
117
|
+
legalFullTextAvailable ? "" : "LongTable did not retrieve full text for this card."
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
return {
|
|
120
|
+
id: `${input.source}:${input.sourceRecordId ?? normalizeDoi(input.doi) ?? title.toLowerCase().replace(/\s+/g, "-").slice(0, 80)}`,
|
|
121
|
+
title,
|
|
122
|
+
authors: input.authors ?? [],
|
|
123
|
+
year: input.year,
|
|
124
|
+
venue: cleanText(input.venue),
|
|
125
|
+
doi: normalizeDoi(input.doi),
|
|
126
|
+
pmid: input.pmid,
|
|
127
|
+
arxivId: input.arxivId,
|
|
128
|
+
openAlexId: input.openAlexId,
|
|
129
|
+
semanticScholarId: input.semanticScholarId,
|
|
130
|
+
ericId: input.ericId,
|
|
131
|
+
url: input.url,
|
|
132
|
+
sourceRoute: input.source,
|
|
133
|
+
sourceRoutes: [input.source],
|
|
134
|
+
sourceRecordId: input.sourceRecordId,
|
|
135
|
+
abstract,
|
|
136
|
+
abstractAvailable: Boolean(abstract),
|
|
137
|
+
evidenceDepth: inferEvidenceDepth(abstract, legalFullTextAvailable),
|
|
138
|
+
accessStatus: inferAccessStatus(abstract, legalFullTextAvailable),
|
|
139
|
+
verificationDepth: inferVerificationDepth(abstract),
|
|
140
|
+
verificationNote: verificationNote(abstract, legalFullTextAvailable),
|
|
141
|
+
legalFullTextAvailable,
|
|
142
|
+
fullTextUrl: input.fullTextUrl,
|
|
143
|
+
citationCount: input.citationCount,
|
|
144
|
+
researchDesign: inferResearchDesign(abstract),
|
|
145
|
+
constructsOrMeasures: undefined,
|
|
146
|
+
mainFinding: inferMainFinding(abstract),
|
|
147
|
+
relevanceToProject: undefined,
|
|
148
|
+
citationSupportStatus: "not_verified",
|
|
149
|
+
limitations,
|
|
150
|
+
matchedKeywords: [],
|
|
151
|
+
relevanceScore: 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function fetchJson(context, url) {
|
|
155
|
+
const response = await context.fetch(url, {
|
|
156
|
+
headers: {
|
|
157
|
+
"accept": "application/json",
|
|
158
|
+
"user-agent": "LongTable/0.1.60 (https://github.com/HosungYou/LongTable)"
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
163
|
+
}
|
|
164
|
+
return response.json();
|
|
165
|
+
}
|
|
166
|
+
async function fetchText(context, url) {
|
|
167
|
+
const response = await context.fetch(url, {
|
|
168
|
+
headers: {
|
|
169
|
+
"accept": "application/xml, text/xml, application/atom+xml, text/plain",
|
|
170
|
+
"user-agent": "LongTable/0.1.60 (https://github.com/HosungYou/LongTable)"
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
175
|
+
}
|
|
176
|
+
return response.text();
|
|
177
|
+
}
|
|
178
|
+
function queryForSource(intent) {
|
|
179
|
+
return intent.queryVariants[0] ?? intent.query;
|
|
180
|
+
}
|
|
181
|
+
function getCapability(source, env) {
|
|
182
|
+
if (source === "openalex" && !env.OPENALEX_API_KEY) {
|
|
183
|
+
return {
|
|
184
|
+
source,
|
|
185
|
+
enabled: false,
|
|
186
|
+
requiredEnv: ["OPENALEX_API_KEY"],
|
|
187
|
+
missingEnv: ["OPENALEX_API_KEY"],
|
|
188
|
+
reason: "OpenAlex route is disabled because OPENALEX_API_KEY is missing.",
|
|
189
|
+
setupHint: "Set OPENALEX_API_KEY to enable reliable OpenAlex API use."
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (source === "unpaywall" && !env.LONGTABLE_CONTACT_EMAIL) {
|
|
193
|
+
return {
|
|
194
|
+
source,
|
|
195
|
+
enabled: false,
|
|
196
|
+
requiredEnv: ["LONGTABLE_CONTACT_EMAIL"],
|
|
197
|
+
missingEnv: ["LONGTABLE_CONTACT_EMAIL"],
|
|
198
|
+
reason: "Unpaywall route is disabled because LONGTABLE_CONTACT_EMAIL is missing.",
|
|
199
|
+
setupHint: "Set LONGTABLE_CONTACT_EMAIL so Unpaywall can receive the required email parameter."
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
source,
|
|
204
|
+
enabled: true,
|
|
205
|
+
requiredEnv: [],
|
|
206
|
+
missingEnv: []
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function assessSearchSourceCapabilities(sources, env = process.env) {
|
|
210
|
+
return sources.map((source) => getCapability(source, env));
|
|
211
|
+
}
|
|
212
|
+
export function enabledSearchSources(sources, env = process.env) {
|
|
213
|
+
return assessSearchSourceCapabilities(sources, env)
|
|
214
|
+
.filter((capability) => capability.enabled)
|
|
215
|
+
.map((capability) => capability.source);
|
|
216
|
+
}
|
|
217
|
+
function authorNameFromCrossref(value) {
|
|
218
|
+
const author = asRecord(value);
|
|
219
|
+
const given = asString(author.given);
|
|
220
|
+
const family = asString(author.family);
|
|
221
|
+
return [given, family].filter(Boolean).join(" ") || asString(author.name);
|
|
222
|
+
}
|
|
223
|
+
async function searchCrossref(request, context) {
|
|
224
|
+
const url = endpoint("https://api.crossref.org/works", {
|
|
225
|
+
"query.bibliographic": queryForSource(request.intent),
|
|
226
|
+
rows: request.limit,
|
|
227
|
+
mailto: context.env.LONGTABLE_CONTACT_EMAIL
|
|
228
|
+
});
|
|
229
|
+
const payload = asRecord(await fetchJson(context, url));
|
|
230
|
+
const message = asRecord(payload.message);
|
|
231
|
+
const cards = asArray(message.items).map((item) => {
|
|
232
|
+
const record = asRecord(item);
|
|
233
|
+
const authors = asArray(record.author)
|
|
234
|
+
.map(authorNameFromCrossref)
|
|
235
|
+
.filter((author) => Boolean(author));
|
|
236
|
+
return baseCard({
|
|
237
|
+
source: "crossref",
|
|
238
|
+
title: firstString(record.title),
|
|
239
|
+
authors,
|
|
240
|
+
year: yearFromParts(record.issued),
|
|
241
|
+
venue: firstString(record["container-title"]),
|
|
242
|
+
doi: asString(record.DOI),
|
|
243
|
+
url: asString(record.URL),
|
|
244
|
+
sourceRecordId: asString(record.DOI),
|
|
245
|
+
abstract: asString(record.abstract),
|
|
246
|
+
citationCount: asNumber(record["is-referenced-by-count"])
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return { source: "crossref", endpoint: url, cards };
|
|
250
|
+
}
|
|
251
|
+
function extractXmlBlocks(xml, tag) {
|
|
252
|
+
const pattern = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "gi");
|
|
253
|
+
const blocks = [];
|
|
254
|
+
let match;
|
|
255
|
+
while ((match = pattern.exec(xml)) !== null) {
|
|
256
|
+
blocks.push(match[1] ?? "");
|
|
257
|
+
}
|
|
258
|
+
return blocks;
|
|
259
|
+
}
|
|
260
|
+
function extractXmlTag(xml, tag) {
|
|
261
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
262
|
+
const pattern = new RegExp(`<${escaped}\\b[^>]*>([\\s\\S]*?)</${escaped}>`, "i");
|
|
263
|
+
return cleanText(pattern.exec(xml)?.[1]);
|
|
264
|
+
}
|
|
265
|
+
async function searchArxiv(request, context) {
|
|
266
|
+
const url = endpoint("https://export.arxiv.org/api/query", {
|
|
267
|
+
search_query: `all:${queryForSource(request.intent)}`,
|
|
268
|
+
start: 0,
|
|
269
|
+
max_results: request.limit,
|
|
270
|
+
sortBy: "relevance"
|
|
271
|
+
});
|
|
272
|
+
const xml = await fetchText(context, url);
|
|
273
|
+
const cards = extractXmlBlocks(xml, "entry").map((entry) => {
|
|
274
|
+
const idUrl = extractXmlTag(entry, "id");
|
|
275
|
+
const arxivId = idUrl?.split("/abs/")[1];
|
|
276
|
+
const authors = extractXmlBlocks(entry, "author")
|
|
277
|
+
.map((author) => extractXmlTag(author, "name"))
|
|
278
|
+
.filter((author) => Boolean(author));
|
|
279
|
+
const published = extractXmlTag(entry, "published");
|
|
280
|
+
return baseCard({
|
|
281
|
+
source: "arxiv",
|
|
282
|
+
title: extractXmlTag(entry, "title"),
|
|
283
|
+
authors,
|
|
284
|
+
year: published ? Number(published.slice(0, 4)) : undefined,
|
|
285
|
+
venue: "arXiv",
|
|
286
|
+
doi: extractXmlTag(entry, "arxiv:doi"),
|
|
287
|
+
arxivId,
|
|
288
|
+
url: idUrl,
|
|
289
|
+
sourceRecordId: arxivId,
|
|
290
|
+
abstract: extractXmlTag(entry, "summary")
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
return { source: "arxiv", endpoint: url, cards };
|
|
294
|
+
}
|
|
295
|
+
async function searchOpenAlex(request, context) {
|
|
296
|
+
const url = endpoint("https://api.openalex.org/works", {
|
|
297
|
+
search: queryForSource(request.intent),
|
|
298
|
+
"per-page": request.limit,
|
|
299
|
+
api_key: context.env.OPENALEX_API_KEY,
|
|
300
|
+
mailto: context.env.LONGTABLE_CONTACT_EMAIL
|
|
301
|
+
});
|
|
302
|
+
const payload = asRecord(await fetchJson(context, url));
|
|
303
|
+
const cards = asArray(payload.results).map((item) => {
|
|
304
|
+
const record = asRecord(item);
|
|
305
|
+
const primaryLocation = asRecord(record.primary_location);
|
|
306
|
+
const source = asRecord(primaryLocation.source);
|
|
307
|
+
const oaLocation = asRecord(record.open_access);
|
|
308
|
+
const fullTextUrl = asString(primaryLocation.pdf_url) ?? asString(primaryLocation.landing_page_url);
|
|
309
|
+
const authors = asArray(record.authorships)
|
|
310
|
+
.map((authorship) => asString(asRecord(asRecord(authorship).author).display_name))
|
|
311
|
+
.filter((author) => Boolean(author));
|
|
312
|
+
return baseCard({
|
|
313
|
+
source: "openalex",
|
|
314
|
+
title: asString(record.display_name),
|
|
315
|
+
authors,
|
|
316
|
+
year: asNumber(record.publication_year),
|
|
317
|
+
venue: asString(source.display_name),
|
|
318
|
+
doi: asString(record.doi),
|
|
319
|
+
openAlexId: asString(record.id),
|
|
320
|
+
url: asString(record.id),
|
|
321
|
+
sourceRecordId: asString(record.id),
|
|
322
|
+
abstract: invertedIndexToAbstract(record.abstract_inverted_index),
|
|
323
|
+
legalFullTextAvailable: asString(oaLocation.is_oa) === "true" || oaLocation.is_oa === true,
|
|
324
|
+
fullTextUrl,
|
|
325
|
+
citationCount: asNumber(record.cited_by_count)
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
return { source: "openalex", endpoint: url, cards };
|
|
329
|
+
}
|
|
330
|
+
function invertedIndexToAbstract(value) {
|
|
331
|
+
const index = asRecord(value);
|
|
332
|
+
const positions = [];
|
|
333
|
+
for (const [word, rawPositions] of Object.entries(index)) {
|
|
334
|
+
for (const position of asArray(rawPositions)) {
|
|
335
|
+
const numeric = asNumber(position);
|
|
336
|
+
if (numeric !== undefined) {
|
|
337
|
+
positions.push([numeric, word]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (positions.length === 0) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return positions.sort((a, b) => a[0] - b[0]).map(([, word]) => word).join(" ");
|
|
345
|
+
}
|
|
346
|
+
async function searchSemanticScholar(request, context) {
|
|
347
|
+
const url = endpoint("https://api.semanticscholar.org/graph/v1/paper/search", {
|
|
348
|
+
query: queryForSource(request.intent),
|
|
349
|
+
limit: request.limit,
|
|
350
|
+
fields: "title,authors,year,venue,externalIds,abstract,citationCount,openAccessPdf,url"
|
|
351
|
+
});
|
|
352
|
+
const headers = {};
|
|
353
|
+
if (context.env.SEMANTIC_SCHOLAR_API_KEY) {
|
|
354
|
+
headers["x-api-key"] = context.env.SEMANTIC_SCHOLAR_API_KEY;
|
|
355
|
+
}
|
|
356
|
+
const response = await context.fetch(url, {
|
|
357
|
+
headers: {
|
|
358
|
+
accept: "application/json",
|
|
359
|
+
...headers
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
364
|
+
}
|
|
365
|
+
const payload = asRecord(await response.json());
|
|
366
|
+
const cards = asArray(payload.data).map((item) => {
|
|
367
|
+
const record = asRecord(item);
|
|
368
|
+
const externalIds = asRecord(record.externalIds);
|
|
369
|
+
const oaPdf = asRecord(record.openAccessPdf);
|
|
370
|
+
const authors = asArray(record.authors)
|
|
371
|
+
.map((author) => asString(asRecord(author).name))
|
|
372
|
+
.filter((author) => Boolean(author));
|
|
373
|
+
return baseCard({
|
|
374
|
+
source: "semantic_scholar",
|
|
375
|
+
title: asString(record.title),
|
|
376
|
+
authors,
|
|
377
|
+
year: asNumber(record.year),
|
|
378
|
+
venue: asString(record.venue),
|
|
379
|
+
doi: asString(externalIds.DOI),
|
|
380
|
+
pmid: asString(externalIds.PubMed),
|
|
381
|
+
arxivId: asString(externalIds.ArXiv),
|
|
382
|
+
semanticScholarId: asString(record.paperId),
|
|
383
|
+
url: asString(record.url),
|
|
384
|
+
sourceRecordId: asString(record.paperId),
|
|
385
|
+
abstract: asString(record.abstract),
|
|
386
|
+
legalFullTextAvailable: Boolean(asString(oaPdf.url)),
|
|
387
|
+
fullTextUrl: asString(oaPdf.url),
|
|
388
|
+
citationCount: asNumber(record.citationCount)
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
return { source: "semantic_scholar", endpoint: url, cards };
|
|
392
|
+
}
|
|
393
|
+
async function searchPubMed(request, context) {
|
|
394
|
+
const searchUrl = endpoint("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi", {
|
|
395
|
+
db: "pubmed",
|
|
396
|
+
retmode: "json",
|
|
397
|
+
term: queryForSource(request.intent),
|
|
398
|
+
retmax: request.limit,
|
|
399
|
+
api_key: context.env.NCBI_API_KEY
|
|
400
|
+
});
|
|
401
|
+
const searchPayload = asRecord(await fetchJson(context, searchUrl));
|
|
402
|
+
const ids = asArray(asRecord(searchPayload.esearchresult).idlist)
|
|
403
|
+
.map(asString)
|
|
404
|
+
.filter((id) => Boolean(id));
|
|
405
|
+
if (ids.length === 0) {
|
|
406
|
+
return { source: "pubmed", endpoint: searchUrl, cards: [] };
|
|
407
|
+
}
|
|
408
|
+
const summaryUrl = endpoint("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi", {
|
|
409
|
+
db: "pubmed",
|
|
410
|
+
retmode: "json",
|
|
411
|
+
id: ids.join(","),
|
|
412
|
+
api_key: context.env.NCBI_API_KEY
|
|
413
|
+
});
|
|
414
|
+
const summaryPayload = asRecord(await fetchJson(context, summaryUrl));
|
|
415
|
+
const result = asRecord(summaryPayload.result);
|
|
416
|
+
const cards = ids.map((id) => {
|
|
417
|
+
const record = asRecord(result[id]);
|
|
418
|
+
const authors = asArray(record.authors)
|
|
419
|
+
.map((author) => asString(asRecord(author).name))
|
|
420
|
+
.filter((author) => Boolean(author));
|
|
421
|
+
const pubdate = asString(record.pubdate);
|
|
422
|
+
return baseCard({
|
|
423
|
+
source: "pubmed",
|
|
424
|
+
title: asString(record.title),
|
|
425
|
+
authors,
|
|
426
|
+
year: pubdate ? Number(pubdate.slice(0, 4)) : undefined,
|
|
427
|
+
venue: asString(record.fulljournalname) ?? asString(record.source),
|
|
428
|
+
pmid: id,
|
|
429
|
+
url: `https://pubmed.ncbi.nlm.nih.gov/${id}/`,
|
|
430
|
+
sourceRecordId: id
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
return { source: "pubmed", endpoint: summaryUrl, cards };
|
|
434
|
+
}
|
|
435
|
+
async function searchEric(request, context) {
|
|
436
|
+
const url = endpoint("https://api.ies.ed.gov/eric/", {
|
|
437
|
+
search: queryForSource(request.intent),
|
|
438
|
+
format: "json",
|
|
439
|
+
rows: request.limit
|
|
440
|
+
});
|
|
441
|
+
const payload = asRecord(await fetchJson(context, url));
|
|
442
|
+
const docs = asArray(asRecord(payload.response).docs);
|
|
443
|
+
const cards = docs.map((item) => {
|
|
444
|
+
const record = asRecord(item);
|
|
445
|
+
const id = asString(record.id) ?? asString(record.ericNumber);
|
|
446
|
+
return baseCard({
|
|
447
|
+
source: "eric",
|
|
448
|
+
title: asString(record.title),
|
|
449
|
+
authors: asArray(record.author).map(asString).filter((author) => Boolean(author)),
|
|
450
|
+
year: asNumber(record.publicationdateyear) ?? asNumber(record.year),
|
|
451
|
+
venue: asString(record.source),
|
|
452
|
+
ericId: id,
|
|
453
|
+
url: id ? `https://eric.ed.gov/?id=${id}` : undefined,
|
|
454
|
+
sourceRecordId: id,
|
|
455
|
+
abstract: asString(record.description) ?? asString(record.abstract)
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
return { source: "eric", endpoint: url, cards };
|
|
459
|
+
}
|
|
460
|
+
async function searchDoaj(request, context) {
|
|
461
|
+
const url = endpoint(`https://doaj.org/api/v4/search/articles/${encodeURIComponent(queryForSource(request.intent))}`, {
|
|
462
|
+
page: 1,
|
|
463
|
+
pageSize: request.limit
|
|
464
|
+
});
|
|
465
|
+
const payload = asRecord(await fetchJson(context, url));
|
|
466
|
+
const cards = asArray(payload.results).map((item) => {
|
|
467
|
+
const record = asRecord(item);
|
|
468
|
+
const bibjson = asRecord(record.bibjson);
|
|
469
|
+
const journal = asRecord(bibjson.journal);
|
|
470
|
+
const identifiers = asArray(bibjson.identifier).map(asRecord);
|
|
471
|
+
const doi = identifiers.find((identifier) => asString(identifier.type)?.toLowerCase() === "doi");
|
|
472
|
+
const links = asArray(bibjson.link).map(asRecord);
|
|
473
|
+
const fullText = links.find((link) => asString(link.type)?.toLowerCase().includes("fulltext"));
|
|
474
|
+
return baseCard({
|
|
475
|
+
source: "doaj",
|
|
476
|
+
title: asString(bibjson.title),
|
|
477
|
+
authors: asArray(bibjson.author).map((author) => asString(asRecord(author).name)).filter((author) => Boolean(author)),
|
|
478
|
+
year: asNumber(bibjson.year),
|
|
479
|
+
venue: asString(journal.title),
|
|
480
|
+
doi: asString(doi?.id),
|
|
481
|
+
url: asString(record.id) ? `https://doaj.org/article/${record.id}` : undefined,
|
|
482
|
+
sourceRecordId: asString(record.id),
|
|
483
|
+
abstract: asString(bibjson.abstract),
|
|
484
|
+
legalFullTextAvailable: Boolean(fullText),
|
|
485
|
+
fullTextUrl: asString(fullText?.url)
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
return { source: "doaj", endpoint: url, cards };
|
|
489
|
+
}
|
|
490
|
+
async function searchUnpaywall(request, context) {
|
|
491
|
+
const url = endpoint("https://api.unpaywall.org/v2/search/", {
|
|
492
|
+
query: queryForSource(request.intent),
|
|
493
|
+
email: context.env.LONGTABLE_CONTACT_EMAIL
|
|
494
|
+
});
|
|
495
|
+
const payload = asRecord(await fetchJson(context, url));
|
|
496
|
+
const cards = asArray(payload.results).map((item) => {
|
|
497
|
+
const result = asRecord(item);
|
|
498
|
+
const response = asRecord(result.response);
|
|
499
|
+
const best = asRecord(response.best_oa_location);
|
|
500
|
+
return baseCard({
|
|
501
|
+
source: "unpaywall",
|
|
502
|
+
title: asString(response.title),
|
|
503
|
+
authors: [],
|
|
504
|
+
year: asNumber(response.year),
|
|
505
|
+
venue: asString(response.journal_name),
|
|
506
|
+
doi: asString(response.doi),
|
|
507
|
+
url: asString(response.doi_url),
|
|
508
|
+
sourceRecordId: asString(response.doi),
|
|
509
|
+
legalFullTextAvailable: Boolean(asString(best.url)),
|
|
510
|
+
fullTextUrl: asString(best.url)
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
return { source: "unpaywall", endpoint: url, cards };
|
|
514
|
+
}
|
|
515
|
+
export async function runSourceSearch(request, context) {
|
|
516
|
+
switch (request.source) {
|
|
517
|
+
case "crossref":
|
|
518
|
+
return searchCrossref(request, context);
|
|
519
|
+
case "arxiv":
|
|
520
|
+
return searchArxiv(request, context);
|
|
521
|
+
case "openalex":
|
|
522
|
+
return searchOpenAlex(request, context);
|
|
523
|
+
case "semantic_scholar":
|
|
524
|
+
return searchSemanticScholar(request, context);
|
|
525
|
+
case "pubmed":
|
|
526
|
+
return searchPubMed(request, context);
|
|
527
|
+
case "eric":
|
|
528
|
+
return searchEric(request, context);
|
|
529
|
+
case "doaj":
|
|
530
|
+
return searchDoaj(request, context);
|
|
531
|
+
case "unpaywall":
|
|
532
|
+
return searchUnpaywall(request, context);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
export function allSearchSources() {
|
|
536
|
+
return [...SEARCH_SOURCES];
|
|
537
|
+
}
|