dsh-plugin-litsearch 0.0.0-stage → 0.2.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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -2
  3. package/client/client.js +9036 -0
  4. package/cordis.patch.yml +10 -0
  5. package/lib/host/events.d.ts +19 -0
  6. package/lib/host/events.js +58 -0
  7. package/lib/host/export/bibtex.d.ts +3 -0
  8. package/lib/host/export/bibtex.js +42 -0
  9. package/lib/host/export/common.d.ts +18 -0
  10. package/lib/host/export/common.js +25 -0
  11. package/lib/host/export/csv.d.ts +8 -0
  12. package/lib/host/export/csv.js +68 -0
  13. package/lib/host/export/index.d.ts +5 -0
  14. package/lib/host/export/index.js +25 -0
  15. package/lib/host/export/markdown.d.ts +6 -0
  16. package/lib/host/export/markdown.js +120 -0
  17. package/lib/host/export/ris.d.ts +3 -0
  18. package/lib/host/export/ris.js +35 -0
  19. package/lib/host/paths.d.ts +43 -0
  20. package/lib/host/paths.js +128 -0
  21. package/lib/host/pdf/store.d.ts +57 -0
  22. package/lib/host/pdf/store.js +291 -0
  23. package/lib/host/router.d.ts +20 -0
  24. package/lib/host/router.js +852 -0
  25. package/lib/host/tasks/compare.d.ts +8 -0
  26. package/lib/host/tasks/compare.js +49 -0
  27. package/lib/host/tasks/filter.d.ts +42 -0
  28. package/lib/host/tasks/filter.js +100 -0
  29. package/lib/host/tasks/rounds.d.ts +28 -0
  30. package/lib/host/tasks/rounds.js +74 -0
  31. package/lib/host/tasks/runner.d.ts +29 -0
  32. package/lib/host/tasks/runner.js +208 -0
  33. package/lib/host/tasks/service.d.ts +171 -0
  34. package/lib/host/tasks/service.js +976 -0
  35. package/lib/host/tasks/states.d.ts +28 -0
  36. package/lib/host/tasks/states.js +223 -0
  37. package/lib/host/tasks/stats.d.ts +13 -0
  38. package/lib/host/tasks/stats.js +233 -0
  39. package/lib/host/tasks/store.d.ts +64 -0
  40. package/lib/host/tasks/store.js +280 -0
  41. package/lib/host/tasks/summary.d.ts +9 -0
  42. package/lib/host/tasks/summary.js +54 -0
  43. package/lib/host/tools.d.ts +47 -0
  44. package/lib/host/tools.js +1138 -0
  45. package/lib/host/zotero/connector.d.ts +46 -0
  46. package/lib/host/zotero/connector.js +263 -0
  47. package/lib/index.d.ts +107 -0
  48. package/lib/index.js +153 -0
  49. package/lib/search/aggregate.d.ts +17 -0
  50. package/lib/search/aggregate.js +60 -0
  51. package/lib/search/config.d.ts +96 -0
  52. package/lib/search/config.js +96 -0
  53. package/lib/search/errors.d.ts +22 -0
  54. package/lib/search/errors.js +24 -0
  55. package/lib/search/health.d.ts +13 -0
  56. package/lib/search/health.js +53 -0
  57. package/lib/search/http.d.ts +13 -0
  58. package/lib/search/http.js +81 -0
  59. package/lib/search/identity.d.ts +27 -0
  60. package/lib/search/identity.js +154 -0
  61. package/lib/search/normalize.d.ts +17 -0
  62. package/lib/search/normalize.js +47 -0
  63. package/lib/search/oa.d.ts +21 -0
  64. package/lib/search/oa.js +149 -0
  65. package/lib/search/providers/arxiv.d.ts +9 -0
  66. package/lib/search/providers/arxiv.js +99 -0
  67. package/lib/search/providers/crossref.d.ts +8 -0
  68. package/lib/search/providers/crossref.js +107 -0
  69. package/lib/search/providers/europepmc.d.ts +8 -0
  70. package/lib/search/providers/europepmc.js +105 -0
  71. package/lib/search/providers/index.d.ts +13 -0
  72. package/lib/search/providers/index.js +34 -0
  73. package/lib/search/providers/openalex.d.ts +8 -0
  74. package/lib/search/providers/openalex.js +109 -0
  75. package/lib/search/providers/pubmed.d.ts +10 -0
  76. package/lib/search/providers/pubmed.js +143 -0
  77. package/lib/search/providers/pubtator.d.ts +9 -0
  78. package/lib/search/providers/pubtator.js +79 -0
  79. package/lib/search/providers/semanticscholar.d.ts +9 -0
  80. package/lib/search/providers/semanticscholar.js +97 -0
  81. package/lib/search/pubtator-entities.d.ts +9 -0
  82. package/lib/search/pubtator-entities.js +19 -0
  83. package/lib/search/pubtator-parse.d.ts +3 -0
  84. package/lib/search/pubtator-parse.js +42 -0
  85. package/lib/search/query-adapter.d.ts +15 -0
  86. package/lib/search/query-adapter.js +69 -0
  87. package/lib/search/rate-limit.d.ts +31 -0
  88. package/lib/search/rate-limit.js +219 -0
  89. package/lib/search/types.d.ts +577 -0
  90. package/lib/search/types.js +15 -0
  91. package/lib/search/xml.d.ts +20 -0
  92. package/lib/search/xml.js +63 -0
  93. package/package.json +75 -4
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 开放获取全文定位:候选 PDF 与 Europe PMC HTML 全文。
3
+ *
4
+ * 候选只负责“可能在哪”,真正的下载与安全校验在 Host 侧(`host/pdf/store.ts`)。
5
+ */
6
+ const EPMC_REST = 'https://www.ebi.ac.uk/europepmc/webservices/rest';
7
+ const EPMC_ARTICLE = 'https://europepmc.org/articles';
8
+ const PMC_ARTICLE = 'https://www.ncbi.nlm.nih.gov/pmc/articles';
9
+ const ARXIV_PDF = 'https://arxiv.org/pdf';
10
+ const UNPAYWALL_API = 'https://api.unpaywall.org/v2';
11
+ /** 去重后的 PDF 候选地址(顺序即尝试优先级)。 */
12
+ export function pdfCandidates(paper) {
13
+ const candidates = new Set();
14
+ if (paper.pdfUrl !== '')
15
+ candidates.add(paper.pdfUrl);
16
+ const pmcid = normalizePmcid(paper.ids.pmcid);
17
+ if (pmcid !== '') {
18
+ candidates.add(`${EPMC_ARTICLE}/${pmcid}?pdf=render`);
19
+ candidates.add(`${PMC_ARTICLE}/${pmcid}/pdf/`);
20
+ }
21
+ if (paper.ids.arxivId !== '')
22
+ candidates.add(`${ARXIV_PDF}/${paper.ids.arxivId}`);
23
+ return [...candidates];
24
+ }
25
+ /** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
26
+ export function epmcXmlUrl(paper) {
27
+ const pmcid = normalizePmcid(paper.ids.pmcid);
28
+ return pmcid === '' ? null : `${EPMC_REST}/${pmcid}/fullTextXML`;
29
+ }
30
+ /** Unpaywall 查询(需要联系邮箱,遵守其 polite pool)。 */
31
+ export async function unpaywallPdfCandidates(doi, email, timeoutMs, signal) {
32
+ if (doi === '' || email === '')
33
+ return [];
34
+ const url = `${UNPAYWALL_API}/${encodeURIComponent(doi)}?email=${encodeURIComponent(email)}`;
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ const onAbort = () => controller.abort();
38
+ signal?.addEventListener('abort', onAbort);
39
+ try {
40
+ const response = await fetch(url, { signal: controller.signal });
41
+ if (!response.ok)
42
+ return [];
43
+ const body = (await response.json());
44
+ const urls = new Set();
45
+ const best = asRecord(body['best_oa_location']);
46
+ const bestPdf = asString(best['url_for_pdf']);
47
+ if (bestPdf !== '')
48
+ urls.add(bestPdf);
49
+ for (const raw of asArray(body['oa_locations'])) {
50
+ const pdf = asString(asRecord(raw)['url_for_pdf']);
51
+ if (pdf !== '')
52
+ urls.add(pdf);
53
+ }
54
+ return [...urls];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ signal?.removeEventListener('abort', onAbort);
62
+ }
63
+ }
64
+ function normalizePmcid(value) {
65
+ const trimmed = value.trim();
66
+ if (trimmed === '')
67
+ return '';
68
+ return /^\d+$/u.test(trimmed) ? `PMC${trimmed}` : trimmed;
69
+ }
70
+ /**
71
+ * JATS XML → 可读 HTML 片段。
72
+ *
73
+ * 只保留标题、段落、列表等结构,其余标签一律剥离;所有文本先转义,
74
+ * 因此上游 XML 里的脚本/属性不会进入阅读页。
75
+ */
76
+ export function jatsToHtml(xml) {
77
+ let body = xml;
78
+ const start = body.indexOf('<body');
79
+ const end = body.lastIndexOf('</body>');
80
+ if (start >= 0 && end > start)
81
+ body = body.slice(start, end + '</body>'.length);
82
+ body = body.replace(/<back[\s\S]*?<\/back>/gu, '');
83
+ body = body.replace(/<ref-list[\s\S]*?<\/ref-list>/gu, '');
84
+ const P_OPEN = '\u0001popen\u0001';
85
+ const P_CLOSE = '\u0001pclose\u0001';
86
+ const T_OPEN = '\u0001h3open\u0001';
87
+ const T_CLOSE = '\u0001h3close\u0001';
88
+ const LI_OPEN = '\u0001li_open\u0001';
89
+ const LI_CLOSE = '\u0001li_close\u0001';
90
+ const UL_OPEN = '\u0001ul_open\u0001';
91
+ const UL_CLOSE = '\u0001ul_close\u0001';
92
+ let marked = body.replace(/<(\/?)p\b[^>]*>/gu, (_match, close) => close === '/' ? P_CLOSE : P_OPEN);
93
+ marked = marked.replace(/<(\/?)title\b[^>]*>/gu, (_match, close) => close === '/' ? T_CLOSE : T_OPEN);
94
+ marked = marked.replace(/<(\/?)list-item\b[^>]*>/gu, (_match, close) => close === '/' ? LI_CLOSE : LI_OPEN);
95
+ marked = marked.replace(/<(\/?)list\b[^>]*>/gu, (_match, close) => close === '/' ? UL_CLOSE : UL_OPEN);
96
+ const text = marked.replace(/<[^>]+>/gu, ' ').replace(/[ \t]+/gu, ' ');
97
+ const escaped = escapeHtml(text);
98
+ return escaped
99
+ .replace(/\u0001popen\u0001/gu, '<p>')
100
+ .replace(/\u0001pclose\u0001/gu, '</p>')
101
+ .replace(/\u0001h3open\u0001/gu, '<h3>')
102
+ .replace(/\u0001h3close\u0001/gu, '</h3>')
103
+ .replace(/\u0001li_open\u0001/gu, '<li>')
104
+ .replace(/\u0001li_close\u0001/gu, '</li>')
105
+ .replace(/\u0001ul_open\u0001/gu, '<ul>')
106
+ .replace(/\u0001ul_close\u0001/gu, '</ul>');
107
+ }
108
+ /** 包一层最小阅读页样式。 */
109
+ export function fullTextHtml(paper, fragment, source) {
110
+ return `<!doctype html>
111
+ <html lang="zh-CN">
112
+ <head>
113
+ <meta charset="utf-8">
114
+ <meta name="viewport" content="width=device-width, initial-scale=1">
115
+ <title>${escapeHtml(paper.title)}</title>
116
+ <style>
117
+ body { margin: 0; padding: 16px; font: 14px/1.7 -apple-system, "PingFang SC", sans-serif; color: #222; }
118
+ h1 { font-size: 18px; line-height: 1.4; margin: 0 0 4px; }
119
+ h3 { font-size: 15px; margin: 18px 0 6px; }
120
+ p { margin: 0 0 10px; }
121
+ .meta { font-size: 12px; color: #666; margin-bottom: 16px; }
122
+ a { color: #2563eb; }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <h1>${escapeHtml(paper.title)}</h1>
127
+ <div class="meta">${escapeHtml(source)} · <a href="${escapeHtml(paper.url)}" target="_blank" rel="noreferrer">原文</a></div>
128
+ ${fragment}
129
+ </body>
130
+ </html>`;
131
+ }
132
+ function escapeHtml(value) {
133
+ return value
134
+ .replace(/&/gu, '&amp;')
135
+ .replace(/</gu, '&lt;')
136
+ .replace(/>/gu, '&gt;')
137
+ .replace(/"/gu, '&quot;');
138
+ }
139
+ function asRecord(value) {
140
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
141
+ ? value
142
+ : {};
143
+ }
144
+ function asArray(value) {
145
+ return Array.isArray(value) ? value : [];
146
+ }
147
+ function asString(value) {
148
+ return typeof value === 'string' ? value.trim() : '';
149
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * arXiv provider —— 预印本检索。
3
+ *
4
+ * 文档:https://info.arxiv.org/help/api/user-manual.html
5
+ * 检索式需带 `all:` 等字段前缀(由 query-adapter 处理);API 要求 3s 间隔。
6
+ * 年份与 OA 只能后置过滤(arXiv 全部开放获取)。
7
+ */
8
+ import type { Provider } from '../types.js';
9
+ export declare const arxiv: Provider;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * arXiv provider —— 预印本检索。
3
+ *
4
+ * 文档:https://info.arxiv.org/help/api/user-manual.html
5
+ * 检索式需带 `all:` 等字段前缀(由 query-adapter 处理);API 要求 3s 间隔。
6
+ * 年份与 OA 只能后置过滤(arXiv 全部开放获取)。
7
+ */
8
+ import { asNumber, asString, normalizeDoi } from '../normalize.js';
9
+ import { limitedText } from '../rate-limit.js';
10
+ import { xmlAttr, xmlBlocks, xmlTag, xmlText, xmlTexts } from '../xml.js';
11
+ const ENDPOINT = 'https://export.arxiv.org/api/query';
12
+ export const arxiv = {
13
+ info: {
14
+ id: 'arxiv',
15
+ label: 'arXiv',
16
+ homepage: 'https://arxiv.org',
17
+ capabilities: { abstract: true, citationCount: false, openAccess: true, pdf: true },
18
+ rateLimit: '3s 间隔(自限)',
19
+ syntax: '需要 all:/ti:/abs: 等前缀;通用关键词会自动转换,年份为后置筛选',
20
+ optionalKey: false,
21
+ },
22
+ async search(query, options) {
23
+ const params = new URLSearchParams({
24
+ search_query: query,
25
+ start: '0',
26
+ max_results: String(options.limit),
27
+ sortBy: 'relevance',
28
+ sortOrder: 'descending',
29
+ });
30
+ const xml = await limitedText('arxiv', `${ENDPOINT}?${params.toString()}`, {
31
+ timeoutMs: options.timeoutMs,
32
+ signal: options.signal,
33
+ bypassCooldown: options.bypassCooldown === true,
34
+ headers: { accept: 'application/atom+xml' },
35
+ });
36
+ const papers = xmlBlocks(xml, 'entry')
37
+ .map(mapEntry)
38
+ .filter((paper) => paper.title !== '');
39
+ return filterByYear(papers, options);
40
+ },
41
+ };
42
+ function mapEntry(entry) {
43
+ const arxivId = normalizeArxiv(xmlText(entry, 'id'));
44
+ const doi = normalizeDoi(xmlText(entry, 'arxiv:doi'));
45
+ const published = xmlText(entry, 'published');
46
+ const pdfUrl = findPdfUrl(entry);
47
+ const categoryTag = xmlTag(entry, 'category');
48
+ const category = xmlAttr(categoryTag, 'term');
49
+ return {
50
+ source: 'arxiv',
51
+ sourceId: arxivId,
52
+ ids: { doi, pmid: '', pmcid: '', arxivId },
53
+ title: xmlText(entry, 'title'),
54
+ authors: xmlTexts(entry, 'name').filter((name) => name !== ''),
55
+ year: readYear(published),
56
+ venue: xmlText(entry, 'arxiv:journal_ref') || 'arXiv',
57
+ doi,
58
+ url: arxivId === '' ? '' : `https://arxiv.org/abs/${arxivId}`,
59
+ abstract: xmlText(entry, 'summary'),
60
+ citedBy: null,
61
+ openAccess: true,
62
+ pdfUrl,
63
+ type: category === '' ? 'preprint' : `preprint/${category}`,
64
+ };
65
+ }
66
+ function findPdfUrl(entry) {
67
+ for (const match of entry.matchAll(/<link\b[^>]*>/gu)) {
68
+ const tag = match[0];
69
+ const title = xmlAttr(tag, 'title').toLowerCase();
70
+ const type = xmlAttr(tag, 'type').toLowerCase();
71
+ if (title === 'pdf' || type === 'application/pdf') {
72
+ return xmlAttr(tag, 'href').replace(/^http:/u, 'https:');
73
+ }
74
+ }
75
+ return '';
76
+ }
77
+ function normalizeArxiv(value) {
78
+ const trimmed = asString(value).trim().replace(/\/+$/u, '');
79
+ if (trimmed === '')
80
+ return '';
81
+ const segment = trimmed.split('/').pop() ?? '';
82
+ return segment.replace(/v\d+$/u, '');
83
+ }
84
+ function readYear(value) {
85
+ const match = /(\d{4})/u.exec(value);
86
+ return match === null ? null : asNumber(Number(match[1]));
87
+ }
88
+ function filterByYear(papers, options) {
89
+ if (options.yearFrom === undefined && options.yearTo === undefined)
90
+ return papers;
91
+ return papers.filter((paper) => {
92
+ if (options.yearFrom !== undefined && (paper.year ?? 0) < options.yearFrom)
93
+ return false;
94
+ if (options.yearTo !== undefined && (paper.year ?? Number.MAX_SAFE_INTEGER) > options.yearTo) {
95
+ return false;
96
+ }
97
+ return true;
98
+ });
99
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Crossref provider —— DOI 注册元数据,几乎覆盖全部正式出版物。
3
+ *
4
+ * 文档:https://www.crossref.org/documentation/retrieve-metadata/rest-api/
5
+ * 无需密钥;带 `mailto` 可进入 polite pool。
6
+ */
7
+ import type { Provider } from '../types.js';
8
+ export declare const crossref: Provider;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Crossref provider —— DOI 注册元数据,几乎覆盖全部正式出版物。
3
+ *
4
+ * 文档:https://www.crossref.org/documentation/retrieve-metadata/rest-api/
5
+ * 无需密钥;带 `mailto` 可进入 polite pool。
6
+ */
7
+ import { limitedJson } from '../rate-limit.js';
8
+ import { asArray, asNumber, asRecord, asString, doiUrl, firstString, normalizeDoi, stripTags, } from '../normalize.js';
9
+ const ENDPOINT = 'https://api.crossref.org/works';
10
+ const SELECT_FIELDS = [
11
+ 'DOI',
12
+ 'title',
13
+ 'author',
14
+ 'issued',
15
+ 'container-title',
16
+ 'publisher',
17
+ 'URL',
18
+ 'abstract',
19
+ 'is-referenced-by-count',
20
+ 'link',
21
+ 'license',
22
+ 'type',
23
+ ].join(',');
24
+ function userAgent(contactEmail) {
25
+ return contactEmail === ''
26
+ ? 'dsh-plugin-litsearch/0.1.0'
27
+ : `dsh-plugin-litsearch/0.1.0 (mailto:${contactEmail})`;
28
+ }
29
+ export const crossref = {
30
+ info: {
31
+ id: 'crossref',
32
+ label: 'Crossref',
33
+ homepage: 'https://www.crossref.org',
34
+ capabilities: { abstract: true, citationCount: true, openAccess: false, pdf: true },
35
+ rateLimit: '约 1 req/s(保守自限);带邮箱进入 polite pool',
36
+ syntax: '自然语言;按年份下推,OA 由许可证粗略判断',
37
+ optionalKey: false,
38
+ },
39
+ async search(query, options) {
40
+ const params = new URLSearchParams({
41
+ query,
42
+ rows: String(options.limit),
43
+ select: SELECT_FIELDS,
44
+ });
45
+ const filters = [];
46
+ if (options.yearFrom !== undefined)
47
+ filters.push(`from-pub-date:${options.yearFrom}-01-01`);
48
+ if (options.yearTo !== undefined)
49
+ filters.push(`until-pub-date:${options.yearTo}-12-31`);
50
+ if (filters.length > 0)
51
+ params.set('filter', filters.join(','));
52
+ if (options.contactEmail !== '')
53
+ params.set('mailto', options.contactEmail);
54
+ const payload = await limitedJson('crossref', `${ENDPOINT}?${params.toString()}`, {
55
+ timeoutMs: options.timeoutMs,
56
+ signal: options.signal,
57
+ bypassCooldown: options.bypassCooldown === true,
58
+ headers: {
59
+ accept: 'application/json',
60
+ 'user-agent': userAgent(options.contactEmail),
61
+ },
62
+ });
63
+ const items = asArray(asRecord(asRecord(payload)['message'])['items']);
64
+ const papers = items.map(mapItem).filter((paper) => paper.title !== '');
65
+ return options.openAccessOnly ? papers.filter((paper) => paper.openAccess) : papers;
66
+ },
67
+ };
68
+ function mapItem(raw) {
69
+ const item = asRecord(raw);
70
+ const doi = normalizeDoi(item['DOI']);
71
+ const links = asArray(item['link']).map(asRecord);
72
+ const pdfLink = links.find((link) => asString(link['content-type']) === 'application/pdf');
73
+ const licenseText = asArray(item['license'])
74
+ .map((entry) => asString(asRecord(entry)['URL']))
75
+ .join(' ');
76
+ const issued = asArray(asRecord(item['issued'])['date-parts'])[0];
77
+ const language = asString(item['language']);
78
+ return {
79
+ source: 'crossref',
80
+ sourceId: doi,
81
+ ids: { doi, pmid: '', pmcid: '', arxivId: '' },
82
+ title: stripTags(firstString(item['title'])),
83
+ authors: asArray(item['author'])
84
+ .map(authorName)
85
+ .filter((name) => name !== ''),
86
+ year: asNumber(asArray(issued)[0]),
87
+ venue: stripTags(firstString(item['container-title'])) || asString(item['publisher']),
88
+ doi,
89
+ url: asString(item['URL']) || doiUrl(doi),
90
+ abstract: stripTags(item['abstract']),
91
+ ...(language === '' ? {} : { language }),
92
+ citedBy: asNumber(item['is-referenced-by-count']),
93
+ // Crossref 没有统一的 OA 标志,这里按许可证 URL 粗略判断。
94
+ openAccess: /creativecommons|open\s*access/iu.test(licenseText),
95
+ pdfUrl: pdfLink === undefined ? '' : asString(pdfLink['URL']),
96
+ type: asString(item['type']),
97
+ };
98
+ }
99
+ function authorName(raw) {
100
+ const author = asRecord(raw);
101
+ const name = asString(author['name']);
102
+ if (name !== '')
103
+ return name;
104
+ return [asString(author['given']), asString(author['family'])]
105
+ .filter((part) => part !== '')
106
+ .join(' ');
107
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Europe PMC provider —— 生命科学文献聚合,覆盖 PubMed、PMC、预印本。
3
+ *
4
+ * 文档:https://europepmc.org/RestfulWebService
5
+ * 无需密钥;自限约 5 req/s(队列里按 200ms 间隔)。
6
+ */
7
+ import type { Provider } from '../types.js';
8
+ export declare const europepmc: Provider;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Europe PMC provider —— 生命科学文献聚合,覆盖 PubMed、PMC、预印本。
3
+ *
4
+ * 文档:https://europepmc.org/RestfulWebService
5
+ * 无需密钥;自限约 5 req/s(队列里按 200ms 间隔)。
6
+ */
7
+ import { asArray, asNumber, asRecord, asString, normalizeDoi } from '../normalize.js';
8
+ import { limitedJson } from '../rate-limit.js';
9
+ const ENDPOINT = 'https://www.ebi.ac.uk/europepmc/webservices/rest/search';
10
+ /**
11
+ * `resultType=core` 加上较大的 pageSize 时响应明显偏慢,15s 会把正常请求
12
+ * 误判成超时(全文接口本来就用 30s)。检索请求固定给 25s 下限,设置里
13
+ * 调高全局超时仍然生效。
14
+ */
15
+ const SEARCH_TIMEOUT_FLOOR_MS = 25_000;
16
+ export const europepmc = {
17
+ info: {
18
+ id: 'europepmc',
19
+ label: 'Europe PMC',
20
+ homepage: 'https://europepmc.org',
21
+ capabilities: { abstract: true, citationCount: true, openAccess: true, pdf: true },
22
+ rateLimit: '约 5 req/s(自限)',
23
+ syntax: '支持布尔语法,查询式与 PubMed 接近;可按 PUB_YEAR / OPEN_ACCESS 下推',
24
+ optionalKey: false,
25
+ },
26
+ async search(query, options) {
27
+ const params = new URLSearchParams({
28
+ query: buildQuery(query, options),
29
+ format: 'json',
30
+ pageSize: String(options.limit),
31
+ resultType: 'core',
32
+ });
33
+ const payload = await limitedJson('europepmc', `${ENDPOINT}?${params.toString()}`, {
34
+ timeoutMs: Math.max(options.timeoutMs, SEARCH_TIMEOUT_FLOOR_MS),
35
+ signal: options.signal,
36
+ bypassCooldown: options.bypassCooldown === true,
37
+ headers: { accept: 'application/json' },
38
+ });
39
+ const results = asArray(asRecord(asRecord(payload)['resultList'])['result']);
40
+ return results.map(mapResult).filter((paper) => paper.title !== '');
41
+ },
42
+ };
43
+ function buildQuery(query, options) {
44
+ const clauses = [];
45
+ if (options.yearFrom !== undefined || options.yearTo !== undefined) {
46
+ clauses.push(`PUB_YEAR:[${options.yearFrom ?? 1800} TO ${options.yearTo ?? 3000}]`);
47
+ }
48
+ if (options.openAccessOnly)
49
+ clauses.push('OPEN_ACCESS:y');
50
+ if (clauses.length === 0)
51
+ return query;
52
+ return `(${query}) AND ${clauses.join(' AND ')}`;
53
+ }
54
+ function mapResult(raw) {
55
+ const result = asRecord(raw);
56
+ const id = asString(result['id']);
57
+ const source = asString(result['source']);
58
+ const pmid = asString(result['pmid']);
59
+ const pmcid = normalizePmcid(asString(result['pmcid']));
60
+ const doi = normalizeDoi(result['doi']);
61
+ const language = asString(result['language']);
62
+ const authors = asArray(asRecord(result['authorList'])['author'])
63
+ .map((entry) => asString(asRecord(entry)['fullName']))
64
+ .filter((name) => name !== '');
65
+ const pdfUrl = pickPdfUrl(result['fullTextUrlList']);
66
+ const journal = asRecord(asRecord(result['journalInfo'])['journal']);
67
+ return {
68
+ source: 'europepmc',
69
+ sourceId: id,
70
+ ids: { doi, pmid, pmcid, arxivId: '' },
71
+ title: asString(result['title']),
72
+ authors,
73
+ year: readYear(result['pubYear']),
74
+ venue: asString(journal['title']),
75
+ doi,
76
+ url: source === '' || id === '' ? '' : `https://europepmc.org/article/${source}/${id}`,
77
+ abstract: asString(result['abstractText']),
78
+ ...(language === '' ? {} : { language }),
79
+ citedBy: asNumber(result['citedByCount']),
80
+ openAccess: asString(result['isOpenAccess']).toUpperCase() === 'Y',
81
+ pdfUrl,
82
+ type: asArray(asRecord(result['pubTypeList'])['pubType']).map(asString)[0] ?? '',
83
+ };
84
+ }
85
+ function pickPdfUrl(value) {
86
+ const urls = asArray(asRecord(value)['fullTextUrl']).map(asRecord);
87
+ const pdf = urls.find((entry) => {
88
+ const style = asString(entry['documentStyle']).toLowerCase();
89
+ const url = asString(entry['url']).toLowerCase();
90
+ const availability = asString(entry['availabilityCode']).toUpperCase();
91
+ return (style === 'pdf' || url.endsWith('.pdf')) && (availability === 'OA' || availability === 'F');
92
+ });
93
+ return pdf === undefined ? '' : asString(pdf['url']);
94
+ }
95
+ function normalizePmcid(value) {
96
+ const trimmed = value.trim().toUpperCase();
97
+ if (trimmed === '')
98
+ return '';
99
+ return trimmed.startsWith('PMC') ? trimmed : `PMC${trimmed}`;
100
+ }
101
+ function readYear(value) {
102
+ const text = asString(value);
103
+ const match = /(\d{4})/u.exec(text);
104
+ return match === null ? null : asNumber(Number(match[1]));
105
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 数据源注册表。
3
+ *
4
+ * 新增数据源:实现 `Provider` → 在 {@link PROVIDERS} 登记 → 把 id 加进
5
+ * `types.ts` 的 `SOURCE_IDS`,并补中英文案。
6
+ */
7
+ import type { Provider, ProviderInfo, SourceId } from '../types.js';
8
+ export declare const PROVIDERS: Record<SourceId, Provider>;
9
+ /** 注册顺序即聚合展示顺序。 */
10
+ export declare const PROVIDER_INFOS: readonly ProviderInfo[];
11
+ export declare function isSourceId(value: string): value is SourceId;
12
+ /** 把外部输入解析为合法 id 列表;空输入表示全部数据源。 */
13
+ export declare function resolveSources(requested: readonly string[] | undefined): SourceId[];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 数据源注册表。
3
+ *
4
+ * 新增数据源:实现 `Provider` → 在 {@link PROVIDERS} 登记 → 把 id 加进
5
+ * `types.ts` 的 `SOURCE_IDS`,并补中英文案。
6
+ */
7
+ import { SOURCE_IDS } from '../types.js';
8
+ import { arxiv } from './arxiv.js';
9
+ import { crossref } from './crossref.js';
10
+ import { europepmc } from './europepmc.js';
11
+ import { openalex } from './openalex.js';
12
+ import { pubmed } from './pubmed.js';
13
+ import { pubtator } from './pubtator.js';
14
+ import { semanticscholar } from './semanticscholar.js';
15
+ export const PROVIDERS = {
16
+ pubmed,
17
+ europepmc,
18
+ openalex,
19
+ crossref,
20
+ semanticscholar,
21
+ arxiv,
22
+ pubtator,
23
+ };
24
+ /** 注册顺序即聚合展示顺序。 */
25
+ export const PROVIDER_INFOS = SOURCE_IDS.map((id) => PROVIDERS[id].info);
26
+ export function isSourceId(value) {
27
+ return SOURCE_IDS.includes(value);
28
+ }
29
+ /** 把外部输入解析为合法 id 列表;空输入表示全部数据源。 */
30
+ export function resolveSources(requested) {
31
+ if (requested === undefined || requested.length === 0)
32
+ return [...SOURCE_IDS];
33
+ return SOURCE_IDS.filter((id) => requested.includes(id));
34
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * OpenAlex provider —— 覆盖面最广的免费开放学术图谱。
3
+ *
4
+ * 文档:https://docs.openalex.org
5
+ * 无需密钥;带 `mailto` 进入 polite pool,可获得更稳定的限流额度。
6
+ */
7
+ import type { Provider } from '../types.js';
8
+ export declare const openalex: Provider;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * OpenAlex provider —— 覆盖面最广的免费开放学术图谱。
3
+ *
4
+ * 文档:https://docs.openalex.org
5
+ * 无需密钥;带 `mailto` 进入 polite pool,可获得更稳定的限流额度。
6
+ */
7
+ import { limitedJson } from '../rate-limit.js';
8
+ import { asArray, asNumber, asRecord, asString, doiUrl, normalizeDoi } from '../normalize.js';
9
+ const ENDPOINT = 'https://api.openalex.org/works';
10
+ function userAgent(contactEmail) {
11
+ return contactEmail === ''
12
+ ? 'dsh-plugin-litsearch/0.1.0'
13
+ : `dsh-plugin-litsearch/0.1.0 (mailto:${contactEmail})`;
14
+ }
15
+ export const openalex = {
16
+ info: {
17
+ id: 'openalex',
18
+ label: 'OpenAlex',
19
+ homepage: 'https://openalex.org',
20
+ capabilities: { abstract: true, citationCount: true, openAccess: true, pdf: true },
21
+ rateLimit: '约 10 req/s;带邮箱进入 polite pool',
22
+ syntax: '自然语言;支持按年份与 OA 下推,不支持字段标签',
23
+ optionalKey: false,
24
+ },
25
+ async search(query, options) {
26
+ const params = new URLSearchParams({
27
+ search: query,
28
+ 'per-page': String(options.limit),
29
+ sort: 'relevance_score:desc',
30
+ });
31
+ const filters = [];
32
+ if (options.yearFrom !== undefined)
33
+ filters.push(`from_publication_date:${options.yearFrom}-01-01`);
34
+ if (options.yearTo !== undefined)
35
+ filters.push(`to_publication_date:${options.yearTo}-12-31`);
36
+ if (options.openAccessOnly)
37
+ filters.push('is_oa:true');
38
+ if (filters.length > 0)
39
+ params.set('filter', filters.join(','));
40
+ if (options.contactEmail !== '')
41
+ params.set('mailto', options.contactEmail);
42
+ const payload = await limitedJson('openalex', `${ENDPOINT}?${params.toString()}`, {
43
+ timeoutMs: options.timeoutMs,
44
+ signal: options.signal,
45
+ bypassCooldown: options.bypassCooldown === true,
46
+ headers: {
47
+ accept: 'application/json',
48
+ 'user-agent': userAgent(options.contactEmail),
49
+ },
50
+ });
51
+ return asArray(asRecord(payload)['results'])
52
+ .map(mapWork)
53
+ .filter((paper) => paper.title !== '');
54
+ },
55
+ };
56
+ function mapWork(raw) {
57
+ const work = asRecord(raw);
58
+ const primary = asRecord(work['primary_location']);
59
+ const host = asRecord(primary['source']);
60
+ const bestOa = asRecord(work['best_oa_location']);
61
+ const ids = asRecord(work['ids']);
62
+ const doi = normalizeDoi(work['doi']);
63
+ const openAlexId = asString(work['id']);
64
+ const landing = asString(primary['landing_page_url']);
65
+ const language = asString(work['language']);
66
+ return {
67
+ source: 'openalex',
68
+ sourceId: openAlexId,
69
+ ids: {
70
+ doi,
71
+ pmid: normalizeId(ids['pmid']),
72
+ pmcid: normalizeId(ids['pmcid']),
73
+ arxivId: '',
74
+ },
75
+ title: asString(work['title']) || asString(work['display_name']),
76
+ authors: asArray(work['authorships'])
77
+ .map((entry) => asString(asRecord(asRecord(entry)['author'])['display_name']))
78
+ .filter((name) => name !== ''),
79
+ year: asNumber(work['publication_year']),
80
+ venue: asString(host['display_name']),
81
+ doi,
82
+ url: landing || doiUrl(doi) || openAlexId,
83
+ abstract: abstractFromInverted(work['abstract_inverted_index']),
84
+ ...(language === '' ? {} : { language }),
85
+ citedBy: asNumber(work['cited_by_count']),
86
+ openAccess: asRecord(work['open_access'])['is_oa'] === true,
87
+ pdfUrl: asString(bestOa['pdf_url']),
88
+ type: asString(work['type']),
89
+ };
90
+ }
91
+ /** OpenAlex 的 ids 是完整 URL,取末段作为裸 id。 */
92
+ function normalizeId(value) {
93
+ return asString(value).replace(/\/+$/u, '').split('/').pop() ?? '';
94
+ }
95
+ /** OpenAlex 的摘要是「词 → 位置数组」,按位置还原成原文。 */
96
+ function abstractFromInverted(value) {
97
+ const index = asRecord(value);
98
+ const entries = [];
99
+ for (const [word, positions] of Object.entries(index)) {
100
+ for (const position of asArray(positions)) {
101
+ if (typeof position === 'number')
102
+ entries.push([position, word]);
103
+ }
104
+ }
105
+ return entries
106
+ .sort((a, b) => a[0] - b[0])
107
+ .map(([, word]) => word)
108
+ .join(' ');
109
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * PubMed / NCBI E-utilities provider。
3
+ *
4
+ * 两次请求:ESearch 拿 PMID 列表 → EFetch 取 XML 详情。ESearch/EFetch 各自
5
+ * 走 PubMed 限流队列(默认 350ms,带 key 可在设置里调低)。
6
+ *
7
+ * 文档:docs/eutilities(使用规范、ESearch、EFetch)
8
+ */
9
+ import type { Provider } from '../types.js';
10
+ export declare const pubmed: Provider;