@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 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
- export { AcquireError, type FetchLike, type FoundPaper, NcbiClient, type NcbiOptions, type OutgroupCandidate, type OutgroupLadder, type OutgroupStep, type PaperSearch, type TaxonStep, classifyAccessions, genomesHoldingMarkerQuery, linkedAccessions, markerTerm, mergePapers, searchEuropePmc, searchPapers, searchPubMed, selectionQuery, studiesFromRecords, suggestOutgroups };
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
- return { scientificName: name, ancestors };
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.5",
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.5"
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",