dsh-paper-search 0.1.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/CHANGELOG.md +107 -0
- package/LICENSE +21 -0
- package/NOTICE.md +50 -0
- package/README.md +151 -0
- package/cordis.patch.yml +17 -0
- package/lib/index.js +381 -0
- package/lib/sources.js +576 -0
- package/package.json +50 -0
- package/skills/paper-search/SKILL.md +148 -0
package/lib/sources.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// lib/sources.js — konektor sumber literatur untuk dsh-paper-search.
|
|
2
|
+
//
|
|
3
|
+
// DUA JENIS SUMBER, dan bedanya penting (jangan disamarkan):
|
|
4
|
+
//
|
|
5
|
+
// kind: 'api' — REST API publik yang mengembalikan JSON. Stabil, ada skema.
|
|
6
|
+
// kind: 'html' — halaman web publik yang HARUS diurai. Rapuh menurut sifatnya:
|
|
7
|
+
// operator bisa mengubah markup kapan saja dan pengurai ini
|
|
8
|
+
// akan mengembalikan 0 hasil. Karena itu kegagalannya WAJIB
|
|
9
|
+
// dilaporkan apa adanya (lihat `searchSource`), bukan diubah
|
|
10
|
+
// menjadi "tidak ada hasil".
|
|
11
|
+
//
|
|
12
|
+
// ATURAN YANG DIPAKAI SELURUH BERKAS INI: kegagalan tidak boleh menyamar jadi
|
|
13
|
+
// kekosongan. Semua non-2xx melempar error beserta status dan cuplikan body;
|
|
14
|
+
// `searchSource` menangkapnya dan mengembalikannya sebagai `error`, terpisah
|
|
15
|
+
// dari `papers`. Ini pelajaran dari `paper-search-mcp` upstream, yang
|
|
16
|
+
// mengembalikan `[]` untuk SETIAP status != 200 tanpa jejak — sehingga arXiv
|
|
17
|
+
// yang menjawab 406 tampak seperti "tidak ada paper".
|
|
18
|
+
|
|
19
|
+
const UA = 'dsh-paper-search/0.1 (+https://github.com/rasyidmmz/dsh-paper-search)';
|
|
20
|
+
const TIMEOUT_MS = 25000;
|
|
21
|
+
|
|
22
|
+
// ── HTTP ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
async function getText(url, { headers = {}, accept, timeoutMs = TIMEOUT_MS } = {}) {
|
|
25
|
+
const ctl = new AbortController();
|
|
26
|
+
const jam = setTimeout(() => ctl.abort(), timeoutMs);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url, {
|
|
29
|
+
redirect: 'follow',
|
|
30
|
+
signal: ctl.signal,
|
|
31
|
+
headers: { 'User-Agent': UA, ...(accept ? { Accept: accept } : {}), ...headers },
|
|
32
|
+
});
|
|
33
|
+
const teks = await res.text();
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
36
|
+
err.status = res.status;
|
|
37
|
+
err.body = teks.slice(0, 160).replace(/\s+/g, ' ');
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
return teks;
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(jam);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function getJson(url, opts) {
|
|
47
|
+
return JSON.parse(await getText(url, { ...opts, accept: 'application/json' }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Bentuk hasil seragam ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const ENTITAS = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
53
|
+
|
|
54
|
+
function bersihTeks(s) {
|
|
55
|
+
if (!s) return null;
|
|
56
|
+
return String(s)
|
|
57
|
+
.replace(/<[^>]*>/g, ' ')
|
|
58
|
+
.replace(/&(#\d+|[a-z]+);/gi, (m, k) => ENTITAS[k.toLowerCase()] ?? (k[0] === '#' ? String.fromCharCode(Number(k.slice(1))) : m))
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim() || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function potong(s, n) {
|
|
64
|
+
const t = bersihTeks(s);
|
|
65
|
+
if (!t) return null;
|
|
66
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** DOI dinormalkan tanpa awalan URL — supaya dedup bekerja. */
|
|
70
|
+
function normDoi(doi) {
|
|
71
|
+
if (!doi) return null;
|
|
72
|
+
const d = String(doi).trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, '').replace(/^doi:\s*/i, '');
|
|
73
|
+
return d.length > 3 ? d.toLowerCase() : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Beberapa portal menulis judul di dalam elemen <xmp> dan MENGULANGNYA untuk
|
|
78
|
+
* mengisi lebar tampilan. Terukur di Garuda 28 Sep 2026: judul
|
|
79
|
+
* "PENDIDIKAN KARAKTER ANAK USIA SEKOLAH DASAR" (44 huruf) muncul sekitar enam
|
|
80
|
+
* kali dalam satu <xmp> sehingga panjangnya 255 huruf.
|
|
81
|
+
*
|
|
82
|
+
* Fungsi ini mencari periode terkecil dan menyisakan SATU salinan. Ia hanya
|
|
83
|
+
* bertindak bila pengulangannya PERSIS dari awal sampai akhir — kalau tidak,
|
|
84
|
+
* teks dibiarkan apa adanya. Sengaja konservatif: lebih baik judul berlebihan
|
|
85
|
+
* daripada judul yang terpotong salah.
|
|
86
|
+
*/
|
|
87
|
+
function runtuhkanPeriodik(s) {
|
|
88
|
+
const t = (s ?? '').trim();
|
|
89
|
+
const n = t.length;
|
|
90
|
+
if (n < 30) return t;
|
|
91
|
+
for (let p = 10; p <= Math.floor(n / 2); p++) {
|
|
92
|
+
let cocok = true;
|
|
93
|
+
for (let i = p; i < n; i++) {
|
|
94
|
+
if (t[i] !== t[i % p]) { cocok = false; break; }
|
|
95
|
+
}
|
|
96
|
+
if (cocok) return t.slice(0, p).trim();
|
|
97
|
+
}
|
|
98
|
+
return t;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buatPaper(p) {
|
|
102
|
+
const doi = normDoi(p.doi);
|
|
103
|
+
return {
|
|
104
|
+
title: potong(runtuhkanPeriodik(bersihTeks(p.title) ?? ''), 300),
|
|
105
|
+
authors: Array.isArray(p.authors) ? p.authors.map(bersihTeks).filter(Boolean).slice(0, 15) : [],
|
|
106
|
+
year: Number.isFinite(Number(p.year)) && Number(p.year) > 1500 ? Number(p.year) : null,
|
|
107
|
+
source: p.source,
|
|
108
|
+
doi,
|
|
109
|
+
url: p.url || (doi ? `https://doi.org/${doi}` : null),
|
|
110
|
+
journal: bersihTeks(p.journal),
|
|
111
|
+
abstract: potong(p.abstract, 900),
|
|
112
|
+
openAccess: !!p.openAccess,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Sumber INTERNASIONAL (API JSON) ──────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async function crossref(query, { rows, email }) {
|
|
119
|
+
const u = new URL('https://api.crossref.org/works');
|
|
120
|
+
u.searchParams.set('query', query);
|
|
121
|
+
u.searchParams.set('rows', String(rows));
|
|
122
|
+
u.searchParams.set('select', 'DOI,title,container-title,author,issued,abstract,URL');
|
|
123
|
+
if (email) u.searchParams.set('mailto', email);
|
|
124
|
+
const j = await getJson(u.toString());
|
|
125
|
+
return (j?.message?.items ?? []).map((it) => buatPaper({
|
|
126
|
+
title: it.title?.[0],
|
|
127
|
+
authors: (it.author ?? []).map((a) => [a.given, a.family].filter(Boolean).join(' ') || a.name),
|
|
128
|
+
year: it.issued?.['date-parts']?.[0]?.[0],
|
|
129
|
+
source: 'crossref',
|
|
130
|
+
doi: it.DOI,
|
|
131
|
+
url: it.URL,
|
|
132
|
+
journal: it['container-title']?.[0],
|
|
133
|
+
abstract: it.abstract,
|
|
134
|
+
})).filter((p) => p.title);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** OpenAlex menyimpan abstrak sebagai indeks terbalik — harus disusun ulang. */
|
|
138
|
+
function abstrakOpenAlex(inv) {
|
|
139
|
+
if (!inv || typeof inv !== 'object') return null;
|
|
140
|
+
const pos = [];
|
|
141
|
+
for (const [kata, idxs] of Object.entries(inv)) for (const i of idxs) pos[i] = kata;
|
|
142
|
+
return pos.filter(Boolean).join(' ') || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function openalex(query, { rows, email }) {
|
|
146
|
+
const u = new URL('https://api.openalex.org/works');
|
|
147
|
+
u.searchParams.set('search', query);
|
|
148
|
+
u.searchParams.set('per-page', String(rows));
|
|
149
|
+
if (email) u.searchParams.set('mailto', email);
|
|
150
|
+
const j = await getJson(u.toString());
|
|
151
|
+
return (j?.results ?? []).map((w) => buatPaper({
|
|
152
|
+
title: w.title,
|
|
153
|
+
authors: (w.authorships ?? []).map((a) => a.author?.display_name),
|
|
154
|
+
year: w.publication_year,
|
|
155
|
+
source: 'openalex',
|
|
156
|
+
doi: w.doi,
|
|
157
|
+
url: w.doi || w.id,
|
|
158
|
+
journal: w.primary_location?.source?.display_name,
|
|
159
|
+
abstract: abstrakOpenAlex(w.abstract_inverted_index),
|
|
160
|
+
openAccess: !!w.open_access?.is_oa,
|
|
161
|
+
})).filter((p) => p.title);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function doaj(query, { rows }) {
|
|
165
|
+
// Query DOAJ ada di PATH, bukan query string.
|
|
166
|
+
const j = await getJson(`https://doaj.org/api/search/articles/${encodeURIComponent(query)}?pageSize=${rows}`);
|
|
167
|
+
return (j?.results ?? []).map((r) => {
|
|
168
|
+
const b = r.bibjson ?? {};
|
|
169
|
+
return buatPaper({
|
|
170
|
+
title: b.title,
|
|
171
|
+
authors: (b.author ?? []).map((a) => a.name),
|
|
172
|
+
year: b.year,
|
|
173
|
+
source: 'doaj',
|
|
174
|
+
doi: (b.identifier ?? []).find((x) => x.type === 'doi')?.id,
|
|
175
|
+
url: (b.link ?? []).find((x) => x.type === 'fulltext')?.url,
|
|
176
|
+
journal: b.journal?.title,
|
|
177
|
+
abstract: b.abstract,
|
|
178
|
+
openAccess: true,
|
|
179
|
+
});
|
|
180
|
+
}).filter((p) => p.title);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function europepmc(query, { rows }) {
|
|
184
|
+
const u = `https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=${encodeURIComponent(query)}&format=json&pageSize=${rows}`;
|
|
185
|
+
const j = await getJson(u);
|
|
186
|
+
return (j?.resultList?.result ?? []).map((r) => buatPaper({
|
|
187
|
+
title: r.title,
|
|
188
|
+
authors: r.authorString ? r.authorString.split(/,\s*/) : [],
|
|
189
|
+
year: r.pubYear,
|
|
190
|
+
source: 'europepmc',
|
|
191
|
+
doi: r.doi,
|
|
192
|
+
url: r.doi ? `https://doi.org/${r.doi}` : `https://europepmc.org/article/${r.source ?? 'MED'}/${r.id}`,
|
|
193
|
+
journal: r.journalTitle,
|
|
194
|
+
abstract: r.abstractText,
|
|
195
|
+
openAccess: r.isOpenAccess === 'Y',
|
|
196
|
+
})).filter((p) => p.title);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pubmed(query, { rows }) {
|
|
200
|
+
// E-utilities butuh DUA panggilan: esearch (id) lalu esummary (metadata).
|
|
201
|
+
const base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
|
|
202
|
+
const s = await getJson(`${base}/esearch.fcgi?db=pubmed&retmode=json&retmax=${rows}&term=${encodeURIComponent(query)}`);
|
|
203
|
+
const ids = s?.esearchresult?.idlist ?? [];
|
|
204
|
+
if (ids.length === 0) return [];
|
|
205
|
+
const d = await getJson(`${base}/esummary.fcgi?db=pubmed&retmode=json&id=${ids.join(',')}`);
|
|
206
|
+
return ids.map((id) => {
|
|
207
|
+
const r = d?.result?.[id];
|
|
208
|
+
if (!r) return null;
|
|
209
|
+
return buatPaper({
|
|
210
|
+
title: r.title,
|
|
211
|
+
authors: (r.authors ?? []).map((a) => a.name),
|
|
212
|
+
year: String(r.pubdate ?? '').slice(0, 4),
|
|
213
|
+
source: 'pubmed',
|
|
214
|
+
doi: (r.articleids ?? []).find((a) => a.idtype === 'doi')?.value,
|
|
215
|
+
url: `https://pubmed.ncbi.nlm.nih.gov/${id}/`,
|
|
216
|
+
journal: r.fulljournalname || r.source,
|
|
217
|
+
});
|
|
218
|
+
}).filter((p) => p && p.title);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// arXiv meminta TIDAK LEBIH dari 1 permintaan / 3 detik (TOU mereka). Karena
|
|
222
|
+
// sumber dijalankan paralel, permintaan arXiv diserialkan sendiri di sini.
|
|
223
|
+
let arxivTerakhir = 0;
|
|
224
|
+
async function arxiv(query, { rows }) {
|
|
225
|
+
const jeda = 3000 - (Date.now() - arxivTerakhir);
|
|
226
|
+
if (jeda > 0) await new Promise((r) => setTimeout(r, jeda));
|
|
227
|
+
try {
|
|
228
|
+
const u = `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(`all:${query}`)}&max_results=${rows}&sortBy=relevance`;
|
|
229
|
+
const xml = await getText(u, { accept: 'application/atom+xml' });
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const m of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
|
|
232
|
+
const blok = m[1];
|
|
233
|
+
const ambil = (tag) => {
|
|
234
|
+
const r = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(blok);
|
|
235
|
+
return r ? r[1] : null;
|
|
236
|
+
};
|
|
237
|
+
out.push(buatPaper({
|
|
238
|
+
title: ambil('title'),
|
|
239
|
+
authors: [...blok.matchAll(/<author>\s*<name>([^<]+)<\/name>/g)].map((x) => x[1]),
|
|
240
|
+
year: String(ambil('published') ?? '').slice(0, 4),
|
|
241
|
+
source: 'arxiv',
|
|
242
|
+
doi: ambil('arxiv:doi'),
|
|
243
|
+
url: ambil('id'),
|
|
244
|
+
journal: 'arXiv (preprint)',
|
|
245
|
+
abstract: ambil('summary'),
|
|
246
|
+
openAccess: true,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
return out.filter((p) => p.title);
|
|
250
|
+
} finally {
|
|
251
|
+
arxivTerakhir = Date.now();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function semantic(query, { rows, apiKey }) {
|
|
256
|
+
const u = `https://api.semanticscholar.org/graph/v1/paper/search?query=${encodeURIComponent(query)}&limit=${rows}&fields=title,year,authors,externalIds,venue,abstract,openAccessPdf`;
|
|
257
|
+
const j = await getJson(u, apiKey ? { headers: { 'x-api-key': apiKey } } : {});
|
|
258
|
+
return (j?.data ?? []).map((d) => buatPaper({
|
|
259
|
+
title: d.title,
|
|
260
|
+
authors: (d.authors ?? []).map((a) => a.name),
|
|
261
|
+
year: d.year,
|
|
262
|
+
source: 'semantic',
|
|
263
|
+
doi: d.externalIds?.DOI,
|
|
264
|
+
url: d.openAccessPdf?.url || (d.externalIds?.DOI ? `https://doi.org/${d.externalIds.DOI}` : null),
|
|
265
|
+
journal: d.venue,
|
|
266
|
+
abstract: d.abstract,
|
|
267
|
+
openAccess: !!d.openAccessPdf,
|
|
268
|
+
})).filter((p) => p.title);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Beberapa API mengembalikan medan yang bisa berupa nilai tunggal ATAU array. */
|
|
272
|
+
function pertama(x) {
|
|
273
|
+
return Array.isArray(x) ? x[0] : x;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function pmc(query, { rows }) {
|
|
277
|
+
// PubMed Central memakai E-utilities yang sama dengan PubMed:
|
|
278
|
+
// esearch (id) lalu esummary (metadata).
|
|
279
|
+
const base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
|
|
280
|
+
const s = await getJson(`${base}/esearch.fcgi?db=pmc&retmode=json&retmax=${rows}&term=${encodeURIComponent(query)}`);
|
|
281
|
+
const ids = s?.esearchresult?.idlist ?? [];
|
|
282
|
+
if (ids.length === 0) return [];
|
|
283
|
+
const d = await getJson(`${base}/esummary.fcgi?db=pmc&retmode=json&id=${ids.join(',')}`);
|
|
284
|
+
return ids.map((id) => {
|
|
285
|
+
const r = d?.result?.[id];
|
|
286
|
+
if (!r) return null;
|
|
287
|
+
const pmcid = (r.articleids ?? []).find((a) => a.idtype === 'pmcid')?.value ?? `PMC${id}`;
|
|
288
|
+
return buatPaper({
|
|
289
|
+
title: r.title,
|
|
290
|
+
authors: (r.authors ?? []).map((a) => a.name),
|
|
291
|
+
year: String(r.pubdate ?? '').slice(0, 4),
|
|
292
|
+
source: 'pmc',
|
|
293
|
+
doi: (r.articleids ?? []).find((a) => a.idtype === 'doi')?.value,
|
|
294
|
+
url: `https://www.ncbi.nlm.nih.gov/pmc/articles/${pmcid}/`,
|
|
295
|
+
journal: r.fulljournalname || r.source,
|
|
296
|
+
openAccess: true,
|
|
297
|
+
});
|
|
298
|
+
}).filter((p) => p && p.title);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function core(query, { rows, coreApiKey }) {
|
|
302
|
+
// CORE TIDAK bisa dipakai tanpa kunci: terukur 28 Sep 2026, permintaan tanpa
|
|
303
|
+
// `Bearer` dijawab HTTP 429 dengan body KOSONG — bukan 401. Karena itu kunci
|
|
304
|
+
// yang hilang dilaporkan sebagai pesan yang bisa ditindaklanjuti, bukan
|
|
305
|
+
// dibiarkan menjadi 429 yang membingungkan.
|
|
306
|
+
if (!coreApiKey) {
|
|
307
|
+
throw new Error('butuh CORE_API_KEY — tanpa kunci CORE menjawab HTTP 429 dengan body kosong');
|
|
308
|
+
}
|
|
309
|
+
const j = await getJson(`https://api.core.ac.uk/v3/search/works?q=${encodeURIComponent(query)}&limit=${rows}`, {
|
|
310
|
+
headers: { Authorization: `Bearer ${coreApiKey}` },
|
|
311
|
+
});
|
|
312
|
+
return (j?.results ?? []).map((c) => buatPaper({
|
|
313
|
+
title: c.title,
|
|
314
|
+
authors: (c.authors ?? []).map((a) => a.name),
|
|
315
|
+
year: c.yearPublished,
|
|
316
|
+
source: 'core',
|
|
317
|
+
doi: c.doi,
|
|
318
|
+
url: c.downloadUrl || (c.doi ? `https://doi.org/${c.doi}` : null),
|
|
319
|
+
journal: c.publisher,
|
|
320
|
+
abstract: c.abstract,
|
|
321
|
+
openAccess: !!c.downloadUrl,
|
|
322
|
+
})).filter((p) => p.title);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function zenodo(query, { rows }) {
|
|
326
|
+
const j = await getJson(`https://zenodo.org/api/records?q=${encodeURIComponent(query)}&size=${rows}`);
|
|
327
|
+
return (j?.hits?.hits ?? []).map((z) => buatPaper({
|
|
328
|
+
title: z.metadata?.title,
|
|
329
|
+
authors: (z.metadata?.creators ?? []).map((c) => c.name),
|
|
330
|
+
year: String(z.metadata?.publication_date ?? '').slice(0, 4),
|
|
331
|
+
source: 'zenodo',
|
|
332
|
+
doi: z.metadata?.doi ?? z.doi,
|
|
333
|
+
url: z.links?.self_html ?? z.links?.self,
|
|
334
|
+
journal: z.metadata?.imprint?.publisher ?? 'Zenodo',
|
|
335
|
+
abstract: z.metadata?.description,
|
|
336
|
+
openAccess: true,
|
|
337
|
+
})).filter((p) => p.title);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function hal(query, { rows }) {
|
|
341
|
+
const fl = 'title_s,authFullName_s,producedDateY_i,doiId_s,uri_s,journalTitle_s,abstract_s';
|
|
342
|
+
const j = await getJson(`https://api.archives-ouvertes.fr/search/?q=${encodeURIComponent(query)}&wt=json&rows=${rows}&fl=${fl}`);
|
|
343
|
+
return (j?.response?.docs ?? []).map((a) => buatPaper({
|
|
344
|
+
title: pertama(a.title_s),
|
|
345
|
+
authors: a.authFullName_s ?? [],
|
|
346
|
+
year: a.producedDateY_i,
|
|
347
|
+
source: 'hal',
|
|
348
|
+
doi: pertama(a.doiId_s),
|
|
349
|
+
url: pertama(a.uri_s),
|
|
350
|
+
journal: pertama(a.journalTitle_s),
|
|
351
|
+
abstract: pertama(a.abstract_s),
|
|
352
|
+
openAccess: true,
|
|
353
|
+
})).filter((p) => p.title);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** IACR Cryptology ePrint Archive — arsip pra-cetak kriptologi (HTML). */
|
|
357
|
+
async function iacr(query, { rows }) {
|
|
358
|
+
const html = await getText(`https://eprint.iacr.org/search?q=${encodeURIComponent(query)}`, { headers: { 'User-Agent': UA_BROWSER } });
|
|
359
|
+
const tautan = [...html.matchAll(/<a title="(\d{4}\/\d+)" class="paperlink" href="\/(\d{4}\/\d+)"/g)];
|
|
360
|
+
if (tautan.length === 0) {
|
|
361
|
+
if (!/class="paperlink"/.test(html)) throw new Error('markup IACR berubah: tautan ber-class "paperlink" tidak ditemukan lagi');
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
const out = [];
|
|
365
|
+
for (let i = 0; i < tautan.length && out.length < rows; i++) {
|
|
366
|
+
// Potong per hasil memakai posisi tautan berikutnya sebagai batas, supaya
|
|
367
|
+
// regex tidak menyeberang ke hasil lain.
|
|
368
|
+
const blok = html.slice(tautan[i].index, i + 1 < tautan.length ? tautan[i + 1].index : html.length);
|
|
369
|
+
const ambil = (re) => { const m = re.exec(blok); return m ? m[1] : null; };
|
|
370
|
+
const id = tautan[i][1];
|
|
371
|
+
out.push(buatPaper({
|
|
372
|
+
title: ambil(/<strong>([\s\S]*?)<\/strong>/),
|
|
373
|
+
authors: (ambil(/<span class="fst-italic">([\s\S]*?)<\/span>/) ?? '').split(/,\s*/).filter(Boolean),
|
|
374
|
+
year: id.slice(0, 4),
|
|
375
|
+
source: 'iacr',
|
|
376
|
+
url: `https://eprint.iacr.org/${id}`,
|
|
377
|
+
journal: 'IACR Cryptology ePrint Archive',
|
|
378
|
+
abstract: ambil(/<p class="mb-0 mt-1 search-abstract">([\s\S]*?)<\/p>/),
|
|
379
|
+
openAccess: true,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
return out.filter((p) => p.title);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Unpaywall — pencari akses-terbuka **berdasarkan DOI**, BUKAN pencari kata
|
|
387
|
+
* kunci. Diverifikasi 28 Sep 2026: dengan DOI ia menjawab HTTP 200 dan
|
|
388
|
+
* menemukan papernya; dengan kata kunci ia memang mengembalikan 0.
|
|
389
|
+
*
|
|
390
|
+
* Query yang bukan DOI dikembalikan KOSONG TANPA error — itu bukan kegagalan,
|
|
391
|
+
* itu memang cara kerjanya. Kalau ini dilempar sebagai error, setiap pencarian
|
|
392
|
+
* kata kunci akan menulis baris GAGAL palsu.
|
|
393
|
+
*/
|
|
394
|
+
async function unpaywall(query, { rows, email }) {
|
|
395
|
+
const doi = normDoi(query);
|
|
396
|
+
if (!doi) return [];
|
|
397
|
+
if (!email) throw new Error('butuh UNPAYWALL_EMAIL — Unpaywall menolak permintaan tanpa email');
|
|
398
|
+
const j = await getJson(`https://api.unpaywall.org/v2/${encodeURIComponent(doi)}?email=${encodeURIComponent(email)}`);
|
|
399
|
+
if (!j?.title) return [];
|
|
400
|
+
const oa = j.best_oa_location ?? null;
|
|
401
|
+
const hasil = buatPaper({
|
|
402
|
+
title: j.title,
|
|
403
|
+
authors: (j.z_authors ?? []).map((a) => a.raw_author_name ?? [a.given, a.family].filter(Boolean).join(' ')),
|
|
404
|
+
year: j.year,
|
|
405
|
+
source: 'unpaywall',
|
|
406
|
+
doi: j.doi ?? doi,
|
|
407
|
+
url: oa?.url_for_pdf ?? oa?.url ?? (j.doi ? `https://doi.org/${j.doi}` : null),
|
|
408
|
+
journal: j.journal_name,
|
|
409
|
+
openAccess: !!j.is_oa,
|
|
410
|
+
});
|
|
411
|
+
return hasil.title ? [hasil].slice(0, Math.max(1, Math.min(rows, 1))) : [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Sumber INDONESIA (penguraian HTML) ───────────────────────────────────────
|
|
415
|
+
//
|
|
416
|
+
// Pola di bawah DIUKUR langsung dari halaman hidupnya (28 Sep 2026), bukan
|
|
417
|
+
// ditebak. Kalau operator mengubah markup, sumbernya akan melaporkan 0 hasil —
|
|
418
|
+
// dan itu akan terlihat, bukan tersamar, karena kita pisahkan `error` dari
|
|
419
|
+
// `papers` (lihat `searchSource`).
|
|
420
|
+
|
|
421
|
+
const UA_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36';
|
|
422
|
+
|
|
423
|
+
function ambilHtml(html, pola, batas) {
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const m of html.matchAll(pola)) {
|
|
426
|
+
if (out.length >= batas) break;
|
|
427
|
+
out.push(m);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const GARUDA_BASE = 'https://garuda.kemdiktisaintek.go.id';
|
|
433
|
+
|
|
434
|
+
/** Garuda — indeks artikel jurnal Indonesia (Kemdiktisaintek). */
|
|
435
|
+
async function garuda(query, { rows }) {
|
|
436
|
+
const html = await getText(`${GARUDA_BASE}/documents?q=${encodeURIComponent(query)}`, { headers: { 'User-Agent': UA_BROWSER } });
|
|
437
|
+
const pola = /<a[^>]*class="title-article"[^>]*href="\/documents\/detail\/(\d+)"[^>]*>\s*<xmp>([\s\S]*?)<\/xmp>/g;
|
|
438
|
+
const hasil = ambilHtml(html, pola, rows).map((m) => buatPaper({
|
|
439
|
+
title: m[2],
|
|
440
|
+
source: 'garuda',
|
|
441
|
+
url: `${GARUDA_BASE}/documents/detail/${m[1]}`,
|
|
442
|
+
journal: 'Garuda (jurnal Indonesia)',
|
|
443
|
+
openAccess: true,
|
|
444
|
+
})).filter((p) => p.title);
|
|
445
|
+
// Penguraian HTML yang tidak menemukan satu pun pola = kemungkinan markup
|
|
446
|
+
// berubah. Laporkan sebagai error, JANGAN sebagai "tidak ada hasil".
|
|
447
|
+
if (hasil.length === 0 && !/title-article/.test(html)) {
|
|
448
|
+
throw new Error('markup Garuda berubah: penanda "title-article" tidak ditemukan lagi');
|
|
449
|
+
}
|
|
450
|
+
return hasil;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Sinta — daftar JURNAL Indonesia terakreditasi (bukan artikel). */
|
|
454
|
+
async function sinta(query, { rows }) {
|
|
455
|
+
const html = await getText(`https://sinta.kemdiktisaintek.go.id/journals?q=${encodeURIComponent(query)}`, { headers: { 'User-Agent': UA_BROWSER } });
|
|
456
|
+
const pola = /href="https:\/\/sinta\.kemdiktisaintek\.go\.id\/journals\/profile\/(\d+)"[^>]*>([\s\S]{3,220}?)<\/a>/g;
|
|
457
|
+
const hasil = ambilHtml(html, pola, rows).map((m) => buatPaper({
|
|
458
|
+
title: m[2],
|
|
459
|
+
source: 'sinta',
|
|
460
|
+
url: `https://sinta.kemdiktisaintek.go.id/journals/profile/${m[1]}`,
|
|
461
|
+
journal: 'Sinta (jurnal terakreditasi)',
|
|
462
|
+
openAccess: true,
|
|
463
|
+
})).filter((p) => p.title);
|
|
464
|
+
if (hasil.length === 0 && !/journals\/profile\//.test(html)) {
|
|
465
|
+
throw new Error('markup Sinta berubah: tautan "journals/profile" tidak ditemukan lagi');
|
|
466
|
+
}
|
|
467
|
+
return hasil;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** IOS OneSearch — agregator repositori Indonesia (Perpusnas). */
|
|
471
|
+
async function ios(query, { rows }) {
|
|
472
|
+
const html = await getText(`https://onesearch.id/Search/Results?lookfor=${encodeURIComponent(query)}&type=AllFields`, { headers: { 'User-Agent': UA_BROWSER } });
|
|
473
|
+
const pola = /<a[^>]*href="\/Record\/([^"]+)"[^>]*class="title"[^>]*>([\s\S]*?)<\/a>/g;
|
|
474
|
+
const hasil = ambilHtml(html, pola, rows).map((m) => buatPaper({
|
|
475
|
+
title: m[2],
|
|
476
|
+
source: 'ios',
|
|
477
|
+
url: `https://onesearch.id/Record/${m[1]}`,
|
|
478
|
+
journal: 'IOS OneSearch (repositori Indonesia)',
|
|
479
|
+
openAccess: true,
|
|
480
|
+
})).filter((p) => p.title);
|
|
481
|
+
if (hasil.length === 0 && !/class="title"/.test(html)) {
|
|
482
|
+
throw new Error('markup IOS berubah: tautan hasil ber-class "title" tidak ditemukan lagi');
|
|
483
|
+
}
|
|
484
|
+
return hasil;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
export const SUMBER = [
|
|
490
|
+
// Internasional
|
|
491
|
+
{ id: 'crossref', label: 'Crossref', region: 'international', kind: 'api', credit: 'disarankan (polite pool)', fn: crossref },
|
|
492
|
+
{ id: 'openalex', label: 'OpenAlex', region: 'international', kind: 'api', credit: 'disarankan (polite pool)', fn: openalex },
|
|
493
|
+
{ id: 'doaj', label: 'DOAJ', region: 'international', kind: 'api', credit: 'opsional', fn: doaj, catatan: 'mencakup jurnal akses-terbuka Indonesia juga' },
|
|
494
|
+
{ id: 'europepmc', label: 'Europe PMC', region: 'international', kind: 'api', credit: null, fn: europepmc },
|
|
495
|
+
{ id: 'pubmed', label: 'PubMed', region: 'international', kind: 'api', credit: null, fn: pubmed, catatan: 'dua panggilan per pencarian (esearch + esummary)' },
|
|
496
|
+
{ id: 'arxiv', label: 'arXiv', region: 'international', kind: 'api', credit: null, fn: arxiv, catatan: 'preprint; dibatasi sendiri 1 permintaan / 3 detik' },
|
|
497
|
+
{ id: 'semantic', label: 'Semantic Scholar', region: 'international', kind: 'api', credit: 'menaikkan batas laju', fn: semantic },
|
|
498
|
+
{ id: 'pmc', label: 'PubMed Central', region: 'international', kind: 'api', credit: null, fn: pmc, catatan: 'teks penuh akses-terbuka; dua panggilan per pencarian' },
|
|
499
|
+
{ id: 'core', label: 'CORE', region: 'international', kind: 'api', credit: 'WAJIB — tanpa kunci balas 429', fn: core, catatan: 'agregator repositori akses-terbuka' },
|
|
500
|
+
{ id: 'zenodo', label: 'Zenodo', region: 'international', kind: 'api', credit: null, fn: zenodo, catatan: 'repositori umum: data, perangkat lunak, preprint' },
|
|
501
|
+
{ id: 'hal', label: 'HAL', region: 'international', kind: 'api', credit: null, fn: hal, catatan: 'repositori akses-terbuka Prancis' },
|
|
502
|
+
{ id: 'iacr', label: 'IACR ePrint', region: 'international', kind: 'html', credit: null, fn: iacr, catatan: 'arsip pra-cetak kriptologi' },
|
|
503
|
+
{ id: 'unpaywall', label: 'Unpaywall', region: 'international', kind: 'api', input: 'doi', credit: 'WAJIB email', fn: unpaywall, catatan: 'butuh DOI — bukan pencari kata kunci' },
|
|
504
|
+
// Indonesia
|
|
505
|
+
{ id: 'garuda', label: 'Garuda', region: 'indonesia', kind: 'html', credit: null, fn: garuda, catatan: 'artikel jurnal Indonesia' },
|
|
506
|
+
{ id: 'sinta', label: 'Sinta', region: 'indonesia', kind: 'html', credit: null, fn: sinta, catatan: 'daftar JURNAL terakreditasi, bukan artikel' },
|
|
507
|
+
{ id: 'ios', label: 'IOS OneSearch', region: 'indonesia', kind: 'html', credit: null, fn: ios, catatan: 'agregator repositori Indonesia' },
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
export const ID_SUMBER = SUMBER.map((s) => s.id);
|
|
511
|
+
export const ID_INTERNASIONAL = SUMBER.filter((s) => s.region === 'international').map((s) => s.id);
|
|
512
|
+
export const ID_INDONESIA = SUMBER.filter((s) => s.region === 'indonesia').map((s) => s.id);
|
|
513
|
+
export const ID_DOI = SUMBER.filter((s) => s.input === 'doi').map((s) => s.id);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Apakah query ini berbentuk DOI? Dipakai untuk memutuskan apakah sumber
|
|
517
|
+
* ber-input DOI (unpaywall) ikut dijalankan saat pengguna memilih "all".
|
|
518
|
+
* Tanpa ini, setiap pencarian kata kunci membayar satu permintaan yang pasti
|
|
519
|
+
* kosong — dan menampilkan baris yang tidak menjelaskan apa pun.
|
|
520
|
+
*/
|
|
521
|
+
export function apakahDoi(q) {
|
|
522
|
+
return /^(?:doi:\s*)?10\.\d{4,9}\/\S+$/i.test(String(q ?? '').trim());
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function cariSumber(id) {
|
|
526
|
+
return SUMBER.find((s) => s.id === id) ?? null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Jalankan satu sumber. SELALU mengembalikan { id, papers, error, ms } —
|
|
531
|
+
* tidak pernah melempar — supaya satu sumber yang mati tidak membatalkan
|
|
532
|
+
* seluruh pencarian, dan kegagalannya tetap terlihat oleh pemanggil.
|
|
533
|
+
*/
|
|
534
|
+
export async function searchSource(id, query, opts) {
|
|
535
|
+
const s = cariSumber(id);
|
|
536
|
+
if (!s) return { id, papers: [], error: `sumber tidak dikenal: ${id}`, ms: 0 };
|
|
537
|
+
const mulai = Date.now();
|
|
538
|
+
try {
|
|
539
|
+
const papers = await s.fn(query, opts);
|
|
540
|
+
return { id, papers: Array.isArray(papers) ? papers : [], error: null, ms: Date.now() - mulai };
|
|
541
|
+
} catch (e) {
|
|
542
|
+
const pesan = e?.name === 'AbortError'
|
|
543
|
+
? 'timeout'
|
|
544
|
+
: `${e?.message ?? 'gagal'}${e?.status ? ` (HTTP ${e.status})` : ''}${e?.body ? ` — ${e.body}` : ''}`;
|
|
545
|
+
return { id, papers: [], error: pesan, ms: Date.now() - mulai };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Dedup ────────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
const kunciJudul = (t) => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 120);
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Buang duplikat: DOI lebih dulu, lalu judul yang dinormalkan.
|
|
555
|
+
* `sources` mencatat SEMUA sumber yang menemukan paper itu — supaya pengguna
|
|
556
|
+
* tahu sebuah paper muncul di beberapa basis, bukan disembunyikan.
|
|
557
|
+
*/
|
|
558
|
+
export function dedup(papers) {
|
|
559
|
+
const peta = new Map();
|
|
560
|
+
for (const p of papers) {
|
|
561
|
+
const kunci = p.doi ? `doi:${p.doi}` : `t:${kunciJudul(p.title)}`;
|
|
562
|
+
const ada = peta.get(kunci);
|
|
563
|
+
if (!ada) {
|
|
564
|
+
peta.set(kunci, { ...p, sources: [p.source] });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!ada.sources.includes(p.source)) ada.sources.push(p.source);
|
|
568
|
+
// Lengkapi medan yang kosong dari salinan lain — jangan buang informasinya.
|
|
569
|
+
for (const f of ['abstract', 'journal', 'doi', 'url', 'year']) {
|
|
570
|
+
if (!ada[f] && p[f]) ada[f] = p[f];
|
|
571
|
+
}
|
|
572
|
+
if (!ada.openAccess && p.openAccess) ada.openAccess = true;
|
|
573
|
+
if (p.authors?.length > (ada.authors?.length ?? 0)) ada.authors = p.authors;
|
|
574
|
+
}
|
|
575
|
+
return [...peta.values()];
|
|
576
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-paper-search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Literature search for DeepSeek Harness: thirteen international sources (Crossref, OpenAlex, DOAJ, Europe PMC, PubMed, PubMed Central, arXiv, Semantic Scholar, CORE, Zenodo, HAL, IACR ePrint, Unpaywall) and three Indonesian journal portals (Garuda, Sinta, IOS OneSearch), all native HTTP with no external CLI required, plus a status tool that reports which sources answer and which credentials are configured.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib/",
|
|
13
|
+
"skills/",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"NOTICE.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/rasyidmmz/dsh-paper-search.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/rasyidmmz/dsh-paper-search#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/rasyidmmz/dsh-paper-search/issues"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh-plugin",
|
|
30
|
+
"deepseek-harness",
|
|
31
|
+
"dsh",
|
|
32
|
+
"cordis",
|
|
33
|
+
"paper-search",
|
|
34
|
+
"literature-search",
|
|
35
|
+
"academic",
|
|
36
|
+
"journal",
|
|
37
|
+
"crossref",
|
|
38
|
+
"openalex",
|
|
39
|
+
"doaj",
|
|
40
|
+
"pubmed",
|
|
41
|
+
"arxiv",
|
|
42
|
+
"garuda",
|
|
43
|
+
"sinta"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|