@captiveorigins/onephylogeny-acquire 0.1.0-alpha.5 → 0.1.0-alpha.6
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/dist/index.d.ts +96 -2
- package/dist/index.js +131 -2
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StudyPaper, Selection, GenBankRecord, LinkedRecord, Marker } from '@captiveorigins/onephylogeny-core';
|
|
1
|
+
import { StudyPaper, Selection, GenBankRecord, LinkedRecord, FieldSource, Marker } from '@captiveorigins/onephylogeny-core';
|
|
2
2
|
|
|
3
3
|
/** A paper as a search found it. Nothing joins a Study until someone adds it. */
|
|
4
4
|
type FoundPaper = StudyPaper & {
|
|
@@ -35,6 +35,63 @@ declare function classifyAccessions(client: NcbiClient, accessions: string[], se
|
|
|
35
35
|
hasMarker: Set<string>;
|
|
36
36
|
}>;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Paper lookup (roadmap stage 3): for a study known only by its GenBank title, find the published paper's DOI and
|
|
40
|
+
* PubMed IDs in Crossref and Europe PMC, and the paper's open supplements. Every result is a proposal with its source
|
|
41
|
+
* and how well the title matched; nothing is attached until a person approves it, and the approval records who and when.
|
|
42
|
+
*
|
|
43
|
+
* Both services are free and keyless. We keep to their published etiquette: one request at a time, at least a second
|
|
44
|
+
* apart by default, a User-Agent naming this tool and its repository (no personal email), and no retries on refusal.
|
|
45
|
+
*/
|
|
46
|
+
type LinkProposal = {
|
|
47
|
+
service: 'crossref' | 'europepmc';
|
|
48
|
+
title: string;
|
|
49
|
+
year?: number;
|
|
50
|
+
journal?: string;
|
|
51
|
+
doi?: string;
|
|
52
|
+
pmid?: string;
|
|
53
|
+
pmcid?: string;
|
|
54
|
+
/** Shared title words over all title words (0–1), and whether the titles are the same after normalising. */
|
|
55
|
+
titleMatch: number;
|
|
56
|
+
exactTitle: boolean;
|
|
57
|
+
/** Europe PMC's list of a paper's supplementary files, for open-access papers with a PMC ID. A link, not a download. */
|
|
58
|
+
supplementsUrl?: string;
|
|
59
|
+
source: FieldSource;
|
|
60
|
+
};
|
|
61
|
+
type ApprovedLink = Pick<LinkProposal, 'doi' | 'pmid'> & {
|
|
62
|
+
source: FieldSource & {
|
|
63
|
+
approvedBy?: string;
|
|
64
|
+
approvedAt: string;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
declare const LOOKUP_USER_AGENT = "OnePhylogeny (https://github.com/CaptiveOrigins/onephylogeny)";
|
|
68
|
+
declare function titleMatch(a: string, b: string): {
|
|
69
|
+
score: number;
|
|
70
|
+
exact: boolean;
|
|
71
|
+
};
|
|
72
|
+
/** Sends requests one at a time, at least `gapMs` apart. */
|
|
73
|
+
declare function paced(fetch: FetchLike, gapMs?: number, sleep?: (ms: number) => Promise<void>, now?: () => number): FetchLike;
|
|
74
|
+
declare function lookupCrossref(fetch: FetchLike, paper: Pick<StudyPaper, 'title' | 'authors'>, at: string, rows?: number): Promise<LinkProposal[]>;
|
|
75
|
+
declare function lookupEuropePmc(fetch: FetchLike, paper: Pick<StudyPaper, 'title'>, at: string, pageSize?: number): Promise<LinkProposal[]>;
|
|
76
|
+
/**
|
|
77
|
+
* Both services, best title match first. A service that fails is listed in `failed`, so a short list never looks
|
|
78
|
+
* complete. Proposals below `minMatch` are dropped as noise.
|
|
79
|
+
*/
|
|
80
|
+
declare function lookupPaper(fetch: FetchLike, paper: Pick<StudyPaper, 'title' | 'authors'>, options?: {
|
|
81
|
+
at?: string;
|
|
82
|
+
minMatch?: number;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
proposals: LinkProposal[];
|
|
85
|
+
failed: string[];
|
|
86
|
+
}>;
|
|
87
|
+
/** A person's approval of one proposal: the paper gains its DOI and PMID, each with where it came from, who and when. */
|
|
88
|
+
declare function approveLink(paper: StudyPaper, proposal: LinkProposal, meta: {
|
|
89
|
+
by?: string;
|
|
90
|
+
at: string;
|
|
91
|
+
}): StudyPaper & {
|
|
92
|
+
linkSource: ApprovedLink;
|
|
93
|
+
};
|
|
94
|
+
|
|
38
95
|
type FetchLike = (url: string, init?: {
|
|
39
96
|
method?: string;
|
|
40
97
|
headers?: Record<string, string>;
|
|
@@ -88,6 +145,7 @@ declare class NcbiClient {
|
|
|
88
145
|
lineage(taxid: string): Promise<{
|
|
89
146
|
scientificName: string;
|
|
90
147
|
ancestors: TaxonStep[];
|
|
148
|
+
mitoGeneticCode?: number;
|
|
91
149
|
}>;
|
|
92
150
|
}
|
|
93
151
|
type TaxonStep = {
|
|
@@ -134,4 +192,40 @@ declare function suggestOutgroups(client: NcbiClient, selection: Selection, opti
|
|
|
134
192
|
perSpecies?: number;
|
|
135
193
|
}): Promise<OutgroupLadder>;
|
|
136
194
|
|
|
137
|
-
|
|
195
|
+
type ReferenceCandidate = {
|
|
196
|
+
accession: string;
|
|
197
|
+
organism: string;
|
|
198
|
+
length: number;
|
|
199
|
+
title: string;
|
|
200
|
+
};
|
|
201
|
+
type ReferenceSuggestion = {
|
|
202
|
+
/** The nearest level with a RefSeq mitochondrial genome: the taxon itself, or an ancestor. */
|
|
203
|
+
from: {
|
|
204
|
+
id: string;
|
|
205
|
+
name: string;
|
|
206
|
+
rank: string;
|
|
207
|
+
sameTaxon: boolean;
|
|
208
|
+
} | null;
|
|
209
|
+
candidates: ReferenceCandidate[];
|
|
210
|
+
/** From NCBI Taxonomy. Only codes the frame check supports are offered; others say why not. */
|
|
211
|
+
geneticCode: {
|
|
212
|
+
code: number;
|
|
213
|
+
supported: true;
|
|
214
|
+
label: string;
|
|
215
|
+
} | {
|
|
216
|
+
code?: number;
|
|
217
|
+
supported: false;
|
|
218
|
+
reason: string;
|
|
219
|
+
};
|
|
220
|
+
notes: string[];
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Guided setup (roadmap stage 3): a reference a newcomer can start from. The nearest RefSeq complete mitochondrial genome,
|
|
224
|
+
* from the taxon itself or up to `maxSteps` ancestors, and the taxon's mitochondrial genetic code. Suggestions only: a
|
|
225
|
+
* person chooses, and can change both.
|
|
226
|
+
*/
|
|
227
|
+
declare function suggestReference(client: NcbiClient, selection: Selection, options?: {
|
|
228
|
+
maxSteps?: number;
|
|
229
|
+
}): Promise<ReferenceSuggestion>;
|
|
230
|
+
|
|
231
|
+
export { AcquireError, type ApprovedLink, type FetchLike, type FoundPaper, LOOKUP_USER_AGENT, type LinkProposal, NcbiClient, type NcbiOptions, type OutgroupCandidate, type OutgroupLadder, type OutgroupStep, type PaperSearch, type ReferenceCandidate, type ReferenceSuggestion, type TaxonStep, approveLink, classifyAccessions, genomesHoldingMarkerQuery, linkedAccessions, lookupCrossref, lookupEuropePmc, lookupPaper, markerTerm, mergePapers, paced, searchEuropePmc, searchPapers, searchPubMed, selectionQuery, studiesFromRecords, suggestOutgroups, suggestReference, titleMatch };
|
package/dist/index.js
CHANGED
|
@@ -155,6 +155,105 @@ async function classifyAccessions(client, accessions, selection) {
|
|
|
155
155
|
return { inTarget, hasMarker };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// src/lookup.ts
|
|
159
|
+
var CROSSREF = "https://api.crossref.org/works";
|
|
160
|
+
var EUROPE_PMC2 = "https://www.ebi.ac.uk/europepmc/webservices/rest";
|
|
161
|
+
var LOOKUP_USER_AGENT = "OnePhylogeny (https://github.com/CaptiveOrigins/onephylogeny)";
|
|
162
|
+
var words = (t) => new Set(t.toLowerCase().replace(/<[^>]+>/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean));
|
|
163
|
+
function titleMatch(a, b) {
|
|
164
|
+
const x = words(a);
|
|
165
|
+
const y = words(b);
|
|
166
|
+
const shared = [...x].filter((w) => y.has(w)).length;
|
|
167
|
+
const all = (/* @__PURE__ */ new Set([...x, ...y])).size;
|
|
168
|
+
return { score: all ? Math.round(shared / all * 1e3) / 1e3 : 0, exact: all > 0 && shared === all };
|
|
169
|
+
}
|
|
170
|
+
function paced(fetch, gapMs = 1e3, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now()) {
|
|
171
|
+
let last = -Infinity;
|
|
172
|
+
let queue = Promise.resolve();
|
|
173
|
+
return (url, init) => {
|
|
174
|
+
const run = queue.then(async () => {
|
|
175
|
+
const wait = last + gapMs - now();
|
|
176
|
+
if (wait > 0) await sleep(wait);
|
|
177
|
+
last = now();
|
|
178
|
+
return fetch(url, { ...init, headers: { "User-Agent": LOOKUP_USER_AGENT, ...init?.headers } });
|
|
179
|
+
});
|
|
180
|
+
queue = run.then(() => void 0, () => void 0);
|
|
181
|
+
return run;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function getJson(fetch, url, service) {
|
|
185
|
+
const res = await fetch(url);
|
|
186
|
+
if (!res.ok) throw new AcquireError(`${service} answered HTTP ${res.status}`);
|
|
187
|
+
return JSON.parse(await res.text());
|
|
188
|
+
}
|
|
189
|
+
async function lookupCrossref(fetch, paper, at, rows = 5) {
|
|
190
|
+
const params = new URLSearchParams({ "query.bibliographic": paper.title, rows: String(rows), select: "DOI,title,issued,container-title" });
|
|
191
|
+
if (paper.authors) params.set("query.author", paper.authors.split(/[,;]| and /)[0].trim());
|
|
192
|
+
const url = `${CROSSREF}?${params}`;
|
|
193
|
+
const body = await getJson(fetch, url, "Crossref");
|
|
194
|
+
return (body.message?.items ?? []).filter((i) => i.title?.[0] && i.DOI).map((i) => {
|
|
195
|
+
const m = titleMatch(paper.title, i.title[0]);
|
|
196
|
+
const year = i.issued?.["date-parts"]?.[0]?.[0];
|
|
197
|
+
return {
|
|
198
|
+
service: "crossref",
|
|
199
|
+
title: i.title[0],
|
|
200
|
+
doi: String(i.DOI).toLowerCase(),
|
|
201
|
+
titleMatch: m.score,
|
|
202
|
+
exactTitle: m.exact,
|
|
203
|
+
...year ? { year: Number(year) } : {},
|
|
204
|
+
...i["container-title"]?.[0] ? { journal: i["container-title"][0] } : {},
|
|
205
|
+
source: { kind: "publication", citation: `Crossref ${url}`, retrievedAt: at }
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function lookupEuropePmc(fetch, paper, at, pageSize = 5) {
|
|
210
|
+
const title = paper.title.replace(/["\\]/g, " ");
|
|
211
|
+
const url = `${EUROPE_PMC2}/search?${new URLSearchParams({ query: `TITLE:"${title}"`, format: "json", resultType: "lite", pageSize: String(pageSize) })}`;
|
|
212
|
+
const body = await getJson(fetch, url, "Europe PMC");
|
|
213
|
+
return (body.resultList?.result ?? []).filter((r) => r.title).map((r) => {
|
|
214
|
+
const m = titleMatch(paper.title, r.title);
|
|
215
|
+
const open = r.isOpenAccess === "Y" && r.pmcid;
|
|
216
|
+
return {
|
|
217
|
+
service: "europepmc",
|
|
218
|
+
title: String(r.title).replace(/\.$/, ""),
|
|
219
|
+
titleMatch: m.score,
|
|
220
|
+
exactTitle: m.exact,
|
|
221
|
+
...r.pubYear ? { year: Number(r.pubYear) } : {},
|
|
222
|
+
...r.journalTitle ? { journal: r.journalTitle } : {},
|
|
223
|
+
...r.doi ? { doi: String(r.doi).toLowerCase() } : {},
|
|
224
|
+
...r.pmid ? { pmid: String(r.pmid) } : {},
|
|
225
|
+
...r.pmcid ? { pmcid: String(r.pmcid) } : {},
|
|
226
|
+
...open ? { supplementsUrl: `${EUROPE_PMC2}/${r.pmcid}/supplementaryFiles` } : {},
|
|
227
|
+
source: { kind: "publication", citation: `Europe PMC ${url}`, retrievedAt: at }
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async function lookupPaper(fetch, paper, options = {}) {
|
|
232
|
+
const at = options.at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
233
|
+
const failed = [];
|
|
234
|
+
const attempt = async (name, work) => {
|
|
235
|
+
try {
|
|
236
|
+
return await work();
|
|
237
|
+
} catch (e) {
|
|
238
|
+
failed.push(`${name}: ${e.message}`);
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const all = [...await attempt("Crossref", () => lookupCrossref(fetch, paper, at)), ...await attempt("Europe PMC", () => lookupEuropePmc(fetch, paper, at))];
|
|
243
|
+
const proposals = all.filter((p) => p.titleMatch >= (options.minMatch ?? 0.5)).sort((a, b) => Number(b.exactTitle) - Number(a.exactTitle) || b.titleMatch - a.titleMatch || a.service.localeCompare(b.service));
|
|
244
|
+
return { proposals, failed };
|
|
245
|
+
}
|
|
246
|
+
function approveLink(paper, proposal, meta) {
|
|
247
|
+
if (paper.doi && proposal.doi && paper.doi !== proposal.doi) throw new AcquireError(`This study already has DOI ${paper.doi}; remove it first to replace it`);
|
|
248
|
+
if (paper.pmid && proposal.pmid && paper.pmid !== proposal.pmid) throw new AcquireError(`This study already has PMID ${paper.pmid}; remove it first to replace it`);
|
|
249
|
+
return {
|
|
250
|
+
...paper,
|
|
251
|
+
...proposal.doi ? { doi: proposal.doi } : {},
|
|
252
|
+
...proposal.pmid ? { pmid: proposal.pmid } : {},
|
|
253
|
+
linkSource: { ...proposal.doi ? { doi: proposal.doi } : {}, ...proposal.pmid ? { pmid: proposal.pmid } : {}, source: { ...proposal.source, ...meta.by ? { approvedBy: meta.by } : {}, approvedAt: meta.at } }
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
158
257
|
// src/index.ts
|
|
159
258
|
var AcquireError = class extends Error {
|
|
160
259
|
};
|
|
@@ -240,7 +339,8 @@ var NcbiClient = class {
|
|
|
240
339
|
if (!name) throw new AcquireError(`Taxon ${taxid} was not found`);
|
|
241
340
|
const lineage = /<LineageEx>([\s\S]*?)<\/LineageEx>/.exec(xml)?.[1] ?? "";
|
|
242
341
|
const ancestors = [...lineage.matchAll(/<Taxon>\s*<TaxId>(\d+)<\/TaxId>\s*<ScientificName>([^<]+)<\/ScientificName>\s*<Rank>([^<]*)<\/Rank>/g)].map((m) => ({ id: m[1], name: m[2], rank: m[3] })).reverse();
|
|
243
|
-
|
|
342
|
+
const mgc = /<MitoGeneticCode>\s*<MGCId>(\d+)<\/MGCId>/.exec(xml.replace(/<LineageEx>[\s\S]*?<\/LineageEx>/, ""))?.[1];
|
|
343
|
+
return { scientificName: name, ancestors, ...mgc ? { mitoGeneticCode: Number(mgc) } : {} };
|
|
244
344
|
}
|
|
245
345
|
};
|
|
246
346
|
var quote = (s) => `"${s.replace(/"/g, "")}"`;
|
|
@@ -298,18 +398,47 @@ async function suggestOutgroups(client, selection, options = {}) {
|
|
|
298
398
|
}
|
|
299
399
|
return { ingroup: scientificName, steps, suggested, caveat: "Taxonomic rank is not evidence of ancestry. Check that the ingroup is monophyletic and these outgroups fall outside it in the finished tree." };
|
|
300
400
|
}
|
|
401
|
+
var CODE_LABELS = { 1: "Standard (1)", 2: "Vertebrate mitochondrial (2)" };
|
|
402
|
+
async function suggestReference(client, selection, options = {}) {
|
|
403
|
+
const notes = [];
|
|
404
|
+
const mito = selection.marker.kind === "mitogenome" || selection.marker.kind === "gene" && selection.marker.organelle === "mitochondrion";
|
|
405
|
+
const taxid = selection.taxa.find((t) => t.source === "ncbi-taxonomy")?.id;
|
|
406
|
+
if (!taxid) return { from: null, candidates: [], geneticCode: { supported: false, reason: "Choose an NCBI taxon first" }, notes };
|
|
407
|
+
const lin = await client.lineage(taxid);
|
|
408
|
+
const code = lin.mitoGeneticCode;
|
|
409
|
+
const geneticCode = !mito ? { supported: false, reason: "Only mitochondrial markers get a suggested genetic code" } : code && CODE_LABELS[code] ? { code, supported: true, label: CODE_LABELS[code] } : { ...code ? { code } : {}, supported: false, reason: code ? `NCBI gives mitochondrial code ${code}, which the frame check doesn't support yet. Leave it unchecked.` : "NCBI gives no mitochondrial code for this taxon" };
|
|
410
|
+
if (!mito) return { from: null, candidates: [], geneticCode, notes: ["Only mitochondrial markers get a suggested reference so far. Enter one by hand."] };
|
|
411
|
+
const levels = [{ id: taxid, name: lin.scientificName, rank: "taxon" }, ...lin.ancestors.slice(0, options.maxSteps ?? 3)];
|
|
412
|
+
for (const [i, level] of levels.entries()) {
|
|
413
|
+
const found = await client.summaries(`txid${level.id}[Organism:exp] AND mitochondrion[Filter] AND refseq[Filter] AND 10000:25000[SLEN]`, 20);
|
|
414
|
+
if (!found.length) continue;
|
|
415
|
+
const candidates = found.map(({ accession, organism, length, title }) => ({ accession, organism, length, title })).sort((a, b) => Number(b.organism === lin.scientificName) - Number(a.organism === lin.scientificName) || b.length - a.length || a.accession.localeCompare(b.accession));
|
|
416
|
+
if (i > 0) notes.push(`No RefSeq mitochondrial genome for ${lin.scientificName} itself; these are from the ${level.rank} ${level.name}. Records are anchored to the reference's coordinates, so a relative works, but check that it holds the marker.`);
|
|
417
|
+
notes.push("A RefSeq genome is a convention, not proof it is typical of the species. Check that its marker is complete and annotated.");
|
|
418
|
+
return { from: { ...level, sameTaxon: i === 0 }, candidates, geneticCode, notes };
|
|
419
|
+
}
|
|
420
|
+
return { from: null, candidates: [], geneticCode, notes: [`No RefSeq mitochondrial genome within ${levels.length - 1} levels above ${lin.scientificName}. Enter a reference by hand, such as the longest complete record of the marker.`] };
|
|
421
|
+
}
|
|
301
422
|
export {
|
|
302
423
|
AcquireError,
|
|
424
|
+
LOOKUP_USER_AGENT,
|
|
303
425
|
NcbiClient,
|
|
426
|
+
approveLink,
|
|
304
427
|
classifyAccessions,
|
|
305
428
|
genomesHoldingMarkerQuery,
|
|
306
429
|
linkedAccessions,
|
|
430
|
+
lookupCrossref,
|
|
431
|
+
lookupEuropePmc,
|
|
432
|
+
lookupPaper,
|
|
307
433
|
markerTerm,
|
|
308
434
|
mergePapers,
|
|
435
|
+
paced,
|
|
309
436
|
searchEuropePmc,
|
|
310
437
|
searchPapers,
|
|
311
438
|
searchPubMed,
|
|
312
439
|
selectionQuery,
|
|
313
440
|
studiesFromRecords,
|
|
314
|
-
suggestOutgroups
|
|
441
|
+
suggestOutgroups,
|
|
442
|
+
suggestReference,
|
|
443
|
+
titleMatch
|
|
315
444
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@captiveorigins/onephylogeny-acquire",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "OnePhylogeny acquire: fetches sequence records by taxon and marker with safe retries and no duplicates, keeps raw files with hashes, and suggests outgroups outward through the taxonomy.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"phylogenetics",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=22"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@captiveorigins/onephylogeny-core": "^0.1.0-alpha.
|
|
39
|
+
"@captiveorigins/onephylogeny-core": "^0.1.0-alpha.6"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsup src/index.ts --format esm --dts --clean --target es2022",
|