dsh-paper-search 0.1.2 → 0.1.4

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  | dsh-paper-search | DeepSeek Harness | Diuji pada |
6
6
  |---|---|---|
7
- | 0.1.2 | 0.1.7-rc.2 | 28 Sep 2026, Windows, Node 24 |
7
+ | 0.1.4 | 0.1.7-rc.2 | 28 Sep 2026, Windows, Node 24 |
8
8
  | ~~0.1.0~~ | — | **JANGAN DIPAKAI — plugin gagal dimuat, lihat 0.1.1** |
9
9
 
10
10
  Paket ini tidak memakai API internal DSH selain `ctx.tools.register` dan
@@ -12,6 +12,104 @@ Paket ini tidak memakai API internal DSH selain `ctx.tools.register` dan
12
12
  berjalan di profil ini (`dsh-search-cascade`), jadi titik patahnya sama dan sudah
13
13
  diketahui.
14
14
 
15
+ ## [0.1.4] - 2026-09-28
16
+
17
+ Empat perbaikan yang **dipelajari dari Issues & PR repo upstream
18
+ `openags/paper-search-mcp`** — masing-masing menunjuk issue-nya, bukan dikarang.
19
+
20
+ ### Fixed
21
+
22
+ - **PubMed & PMC diurutkan menurut TANGGAL, bukan relevansi** (upstream issue #29).
23
+ `esearch` tidak pernah diberi `sort`, dan bawaan PubMed adalah tanggal-terbaru.
24
+ Untuk kueri luas itu buruk: yang muncul paper baru yang tangensial, bukan paper
25
+ fondasional. Sekarang memakai `sort=relevance`.
26
+
27
+ Terukur pada `"ocular microbiome"`: puncaknya berubah dari paper 2026 yang
28
+ tangensial menjadi *The Microbiome, Ocular Surface, and Corneal Disorders*
29
+ (2023) dan *The Ocular Microbiome in Stevens-Johnson Syndrome* (2021) — persis
30
+ seperti yang diprediksi issue itu.
31
+
32
+ - **Nama penulis bisa terpecah PER HURUF** (upstream issue #109/#110/#112).
33
+ Konektor mengoper **string** ke medan bertipe daftar, lalu pemakainya
34
+ meng-`join` string itu — `"TAN, Kwan Hong"` menjadi
35
+ `"T; A; N; ,; ; K; w; a; n; …"`. Ditambahkan `daftarPenulis()` yang menerima
36
+ string, array string, array objek (`{name}`, `{display_name}`,
37
+ `{given,family}`), bentuk bersarang OpenAlex (`{author:{display_name}}`), dan
38
+ medan yang hilang — **tidak pernah melempar**.
39
+
40
+ `daftarPenulis()` **sengaja TIDAK memecah pada koma**: `"TAN, Kwan Hong"`
41
+ adalah SATU penulis. Hanya titik koma yang jadi pemisah. Konektor yang
42
+ sumbernya memang memakai daftar berpemisah koma (DOAJ `authorString`, IACR)
43
+ memecahnya lebih dulu, baru menormalkan.
44
+
45
+ - **arXiv: kueri multi-kata kini presisi dulu, longgar sebagai cadangan**
46
+ (upstream issue #6/#101/#113). Bentuk `all:kata1 AND all:kata2` dipakai lebih
47
+ dulu; hanya kalau kosong, bentuk longgar dijalankan.
48
+
49
+ **Kenapa TIDAK sekadar meniru perbaikan upstream** yang mengutip frasa:
50
+ **diukur 28 Sep 2026** — untuk kueri Indonesia `"literasi digital"`, bentuk
51
+ terkutip dan bentuk AND sama-sama memberi **0 hasil**, sedangkan bentuk longgar
52
+ memberi **50**. arXiv adalah korpus berbahasa Inggris; mengutip membabi-buta
53
+ akan menghapus SELURUH hasil untuk topik non-Inggris. Jadi yang dipakai adalah
54
+ presisi-**dengan**-cadangan.
55
+
56
+ - **HTTP 406 arXiv kadang masih membawa Atom feed yang SAH** (upstream issue #121).
57
+ Sebelumnya 406 langsung dianggap gagal. Sekarang isi balasannya dicoba diurai
58
+ lebih dulu — issue itu melaporkan 406 dengan body 5250 byte berisi 2 entri sah.
59
+ Untuk itu `getText()` kini menyimpan `err.bodyPenuh` terpisah dari `err.body`
60
+ (yang tetap pendek untuk pesan galat).
61
+
62
+ ### Added
63
+
64
+ - **15 asersi regresi baru**: 9 bentuk penulis (termasuk kasus bug #109), 3 bentuk
65
+ kueri arXiv, penjaga `sort=relevance`, dan penjaga penyelamatan body 406.
66
+
67
+ ### Catatan — yang sudah ada dan TIDAK perlu diubah
68
+
69
+ Diperiksa terhadap issue upstream, dan ternyata sudah tertangani sejak 0.1.0:
70
+ timeout 25 detik per permintaan (issue #55), pelaporan sumber tidak dikenal
71
+ (issue #105), dedup lintas sumber (issue #33), dan prinsip pelaporan kegagalan
72
+ alih-alih "0 hasil" (issue #100/#111).
73
+
74
+ **PR upstream #97** (`feat: DeepSeek Harness (dsh) integration`) memakai jalur
75
+ **MCP bridge** yang mengekspos **57 tool** — persis rute yang kami buang karena
76
+ biayanya **12.342 token per permintaan**. Plugin ini mendaftarkan **2 tool**.
77
+ Jadi PR itu mengukuhkan keputusan kami, bukan mengubahnya.
78
+
79
+ ## [0.1.3] - 2026-09-28
80
+
81
+ ### Fixed
82
+
83
+ - **Portal Indonesia melaporkan GAGAL padahal hasilnya cuma KOSONG.** Ketiga
84
+ konektor Indonesia melempar `markup ... berubah` setiap kali penanda hasil tidak
85
+ ditemukan — termasuk saat portal itu **sah-sah saja menjawab nol hasil**. Itu
86
+ keliru dengan arah berlawanan dari prinsip paket ini: melaporkan kegagalan di
87
+ tempat yang seharusnya kosong sama menyesatkannya dengan menyembunyikan
88
+ kegagalan.
89
+
90
+ Ketahuan dari **uji orkestrasi**, bukan dari uji unit: `Sinta` gagal untuk
91
+ `"literasi digital remaja"` padahal berhasil untuk `"pendidikan karakter"`.
92
+ Setelah halaman kosong dan halaman ber-hasil dibandingkan, ternyata ketiga
93
+ portal punya penanda nol-hasilnya sendiri:
94
+
95
+ | Portal | Penanda nol hasil | Halaman kosong |
96
+ |---|---|---|
97
+ | Garuda | `Found 0 documents` | 11.181 huruf |
98
+ | Sinta | header `Results for "<q>"` tetap ada, tautan profil nol | 34.063 huruf |
99
+ | IOS | `No Results!` | 23.041 huruf |
100
+
101
+ `portalNolHasil()` sekarang membedakan **tiga** kemungkinan — nol hasil (→ `[]`),
102
+ markup berubah (→ error), dan diblokir (→ error) — bukan dua.
103
+
104
+ Akibatnya untuk Sinta: kueri spesifik yang memang nol hasil tidak lagi tampak
105
+ seperti kerusakan. Sinta mendaftar **jurnal**, bukan artikel, jadi nol hasil itu
106
+ wajar dan sekarang dilaporkan sebagai nol.
107
+
108
+ ### Added
109
+
110
+ - 3 asersi regresi: kueri omong kosong ke ketiga portal Indonesia harus
111
+ menghasilkan **0 hasil tanpa error**.
112
+
15
113
  ## [0.1.2] - 2026-09-28
16
114
 
17
115
  ### Fixed
package/lib/sources.js CHANGED
@@ -34,7 +34,11 @@ async function getText(url, { headers = {}, accept, timeoutMs = TIMEOUT_MS } = {
34
34
  if (!res.ok) {
35
35
  const err = new Error(`HTTP ${res.status}`);
36
36
  err.status = res.status;
37
+ // `body` untuk PESAN (pendek). `bodyPenuh` disimpan TERPISAH supaya isi
38
+ // balasan yang masih berguna bisa diselamatkan — terukur di arXiv:
39
+ // HTTP 406 kadang justru membawa Atom feed yang SAH (issue #121).
37
40
  err.body = teks.slice(0, 160).replace(/\s+/g, ' ');
41
+ err.bodyPenuh = teks.slice(0, 512 * 1024);
38
42
  throw err;
39
43
  }
40
44
  return teks;
@@ -66,6 +70,47 @@ function potong(s, n) {
66
70
  return t.length > n ? `${t.slice(0, n - 1)}…` : t;
67
71
  }
68
72
 
73
+ /**
74
+ * Normalkan daftar penulis dari bentuk apa pun menjadi ARRAY nama. Tidak pernah
75
+ * melempar; selalu mengembalikan array (boleh kosong).
76
+ *
77
+ * Ini jawaban atas tiga bug upstream yang sudah benar-benar terjadi
78
+ * (issue #109, #110, #112): konektor mengoper **string** ke medan yang bertipe
79
+ * daftar, lalu pemakainya meng-`join` string itu sehingga setiap nama terpecah
80
+ * per huruf — `"TAN, Kwan Hong"` menjadi `"T; A; N; ,; ; K; w; a; n; …"`.
81
+ * Bentuk lain yang juga muncul di lapangan: array objek (`{name}`,
82
+ * `{display_name}`, `{given, family}`), dan medan yang hilang sama sekali.
83
+ *
84
+ * SENGAJA TIDAK memecah pada KOMA: `"TAN, Kwan Hong"` adalah SATU penulis, dan
85
+ * memecahnya justru merusak nama. Hanya titik koma yang dipakai sebagai pemisah.
86
+ */
87
+ export function daftarPenulis(x) {
88
+ const keluaran = [];
89
+ const tambah = (v) => {
90
+ if (v === null || v === undefined) return;
91
+ if (Array.isArray(v)) { v.forEach(tambah); return; }
92
+ if (typeof v === 'object') {
93
+ // Bentuk bersarang yang nyata di lapangan: OpenAlex membungkus penulis
94
+ // sebagai `{ author: { display_name } }`.
95
+ if (v.author) { tambah(v.author); return; }
96
+ tambah(v.name ?? v.display_name ?? v.raw_author_name ?? v.fullName
97
+ ?? [v.given, v.family].filter(Boolean).join(' '));
98
+ return;
99
+ }
100
+ if (typeof v === 'string') {
101
+ for (const bagian of v.split(';')) {
102
+ const b = bersihTeks(bagian);
103
+ if (b) keluaran.push(b);
104
+ }
105
+ return;
106
+ }
107
+ const s = bersihTeks(v);
108
+ if (s) keluaran.push(s);
109
+ };
110
+ tambah(x);
111
+ return keluaran.slice(0, 50);
112
+ }
113
+
69
114
  /** DOI dinormalkan tanpa awalan URL — supaya dedup bekerja. */
70
115
  function normDoi(doi) {
71
116
  if (!doi) return null;
@@ -132,7 +177,7 @@ function buatPaper(p) {
132
177
  const doi = normDoi(p.doi);
133
178
  return {
134
179
  title: potong(runtuhkanJudul(bersihTeks(p.title) ?? ''), 300),
135
- authors: Array.isArray(p.authors) ? p.authors.map(bersihTeks).filter(Boolean).slice(0, 15) : [],
180
+ authors: daftarPenulis(p.authors).slice(0, 15),
136
181
  year: Number.isFinite(Number(p.year)) && Number(p.year) > 1500 ? Number(p.year) : null,
137
182
  source: p.source,
138
183
  doi,
@@ -154,7 +199,7 @@ async function crossref(query, { rows, email }) {
154
199
  const j = await getJson(u.toString());
155
200
  return (j?.message?.items ?? []).map((it) => buatPaper({
156
201
  title: it.title?.[0],
157
- authors: (it.author ?? []).map((a) => [a.given, a.family].filter(Boolean).join(' ') || a.name),
202
+ authors: daftarPenulis(it.author),
158
203
  year: it.issued?.['date-parts']?.[0]?.[0],
159
204
  source: 'crossref',
160
205
  doi: it.DOI,
@@ -180,7 +225,7 @@ async function openalex(query, { rows, email }) {
180
225
  const j = await getJson(u.toString());
181
226
  return (j?.results ?? []).map((w) => buatPaper({
182
227
  title: w.title,
183
- authors: (w.authorships ?? []).map((a) => a.author?.display_name),
228
+ authors: daftarPenulis(w.authorships),
184
229
  year: w.publication_year,
185
230
  source: 'openalex',
186
231
  doi: w.doi,
@@ -198,7 +243,7 @@ async function doaj(query, { rows }) {
198
243
  const b = r.bibjson ?? {};
199
244
  return buatPaper({
200
245
  title: b.title,
201
- authors: (b.author ?? []).map((a) => a.name),
246
+ authors: daftarPenulis(b.author),
202
247
  year: b.year,
203
248
  source: 'doaj',
204
249
  doi: (b.identifier ?? []).find((x) => x.type === 'doi')?.id,
@@ -215,7 +260,10 @@ async function europepmc(query, { rows }) {
215
260
  const j = await getJson(u);
216
261
  return (j?.resultList?.result ?? []).map((r) => buatPaper({
217
262
  title: r.title,
218
- authors: r.authorString ? r.authorString.split(/,\s*/) : [],
263
+ // DOAJ memberi `authorString` yang MEMANG dipisah koma ("A, B, C"), jadi
264
+ // pemecahan dilakukan di sini dulu — `daftarPenulis` sendiri sengaja tidak
265
+ // memecah koma, supaya "TAN, Kwan Hong" (satu penulis) tidak rusak.
266
+ authors: daftarPenulis((r.authorString ?? '').split(/,\s*/)),
219
267
  year: r.pubYear,
220
268
  source: 'europepmc',
221
269
  doi: r.doi,
@@ -229,7 +277,7 @@ async function europepmc(query, { rows }) {
229
277
  async function pubmed(query, { rows }) {
230
278
  // E-utilities butuh DUA panggilan: esearch (id) lalu esummary (metadata).
231
279
  const base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
232
- const s = await getJson(`${base}/esearch.fcgi?db=pubmed&retmode=json&retmax=${rows}&term=${encodeURIComponent(query)}`);
280
+ const s = await getJson(`${base}/esearch.fcgi?db=pubmed&retmode=json&retmax=${rows}&sort=relevance&term=${encodeURIComponent(query)}`);
233
281
  const ids = s?.esearchresult?.idlist ?? [];
234
282
  if (ids.length === 0) return [];
235
283
  const d = await getJson(`${base}/esummary.fcgi?db=pubmed&retmode=json&id=${ids.join(',')}`);
@@ -238,7 +286,7 @@ async function pubmed(query, { rows }) {
238
286
  if (!r) return null;
239
287
  return buatPaper({
240
288
  title: r.title,
241
- authors: (r.authors ?? []).map((a) => a.name),
289
+ authors: daftarPenulis(r.authors),
242
290
  year: String(r.pubdate ?? '').slice(0, 4),
243
291
  source: 'pubmed',
244
292
  doi: (r.articleids ?? []).find((a) => a.idtype === 'doi')?.value,
@@ -251,32 +299,89 @@ async function pubmed(query, { rows }) {
251
299
  // arXiv meminta TIDAK LEBIH dari 1 permintaan / 3 detik (TOU mereka). Karena
252
300
  // sumber dijalankan paralel, permintaan arXiv diserialkan sendiri di sini.
253
301
  let arxivTerakhir = 0;
254
- async function arxiv(query, { rows }) {
302
+ async function arxivTunggu() {
255
303
  const jeda = 3000 - (Date.now() - arxivTerakhir);
256
304
  if (jeda > 0) await new Promise((r) => setTimeout(r, jeda));
305
+ arxivTerakhir = Date.now();
306
+ }
307
+
308
+ /** Uraikan Atom feed arXiv menjadi daftar paper. */
309
+ function uraikanArxiv(xml, rows) {
310
+ const out = [];
311
+ for (const m of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
312
+ const blok = m[1];
313
+ const ambil = (tag) => {
314
+ const r = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(blok);
315
+ return r ? r[1] : null;
316
+ };
317
+ out.push(buatPaper({
318
+ title: ambil('title'),
319
+ authors: [...blok.matchAll(/<author>\s*<name>([^<]+)<\/name>/g)].map((x) => x[1]),
320
+ year: String(ambil('published') ?? '').slice(0, 4),
321
+ source: 'arxiv',
322
+ doi: ambil('arxiv:doi'),
323
+ url: ambil('id'),
324
+ journal: 'arXiv (preprint)',
325
+ abstract: ambil('summary'),
326
+ openAccess: true,
327
+ }));
328
+ }
329
+ return out.filter((p) => p.title).slice(0, rows);
330
+ }
331
+
332
+ /**
333
+ * Ambil Atom feed arXiv, dengan DUA perbaikan yang diambil dari issue upstream:
334
+ *
335
+ * 1. **HTTP 406 kadang masih membawa feed yang SAH** (issue #121). Dilaporkan
336
+ * terukur: 406 dengan body 5250 byte ber-`Content-Type: application/atom+xml`
337
+ * berisi 2 entri. Jadi 406 tidak langsung digagalkan — isinya dicoba dibaca
338
+ * dulu. Kalau body-nya benar-benar kosong, barulah itu kegagalan sungguhan.
339
+ * 2. **Kueri multi-kata tanpa kutip bisa MENGGANTUNG** di backend arXiv
340
+ * (issue #101). Karena itu bentuk presisi dipakai lebih dulu (lihat `arxiv`).
341
+ */
342
+ async function ambilArxiv(kueri, rows) {
343
+ const u = `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(kueri)}&max_results=${rows}&sortBy=relevance`;
257
344
  try {
258
- const u = `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(`all:${query}`)}&max_results=${rows}&sortBy=relevance`;
259
- const xml = await getText(u, { accept: 'application/atom+xml' });
260
- const out = [];
261
- for (const m of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
262
- const blok = m[1];
263
- const ambil = (tag) => {
264
- const r = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(blok);
265
- return r ? r[1] : null;
266
- };
267
- out.push(buatPaper({
268
- title: ambil('title'),
269
- authors: [...blok.matchAll(/<author>\s*<name>([^<]+)<\/name>/g)].map((x) => x[1]),
270
- year: String(ambil('published') ?? '').slice(0, 4),
271
- source: 'arxiv',
272
- doi: ambil('arxiv:doi'),
273
- url: ambil('id'),
274
- journal: 'arXiv (preprint)',
275
- abstract: ambil('summary'),
276
- openAccess: true,
277
- }));
278
- }
279
- return out.filter((p) => p.title);
345
+ return uraikanArxiv(await getText(u, { accept: 'application/atom+xml' }), rows);
346
+ } catch (e) {
347
+ if (e.status === 406 && /<entry>/.test(e.bodyPenuh ?? '')) return uraikanArxiv(e.bodyPenuh, rows);
348
+ throw e;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Bentuk kueri arXiv. Presisi dulu (`all:kata1 AND all:kata2`), longgar
354
+ * (`all:kata1 kata2`) sebagai cadangan.
355
+ *
356
+ * Kenapa TIDAK sekadar meniru perbaikan upstream yang mengutip frasa:
357
+ * **diukur 28 Sep 2026** — untuk kueri Indonesia `"literasi digital"`, bentuk
358
+ * terkutip dan bentuk AND sama-sama memberi **0 hasil**, sedangkan bentuk
359
+ * longgar memberi **50**. arXiv memang korpus berbahasa Inggris. Mengutip buta
360
+ * akan menghapus SELURUH hasil untuk topik non-Inggris, jadi yang dipakai adalah
361
+ * presisi-dengan-cadangan, bukan kutip-membabi-buta.
362
+ */
363
+ export function kueriArxiv(query, presisi) {
364
+ const kata = String(query).trim().split(/\s+/).filter(Boolean);
365
+ if (kata.length === 0) return `all:${query}`;
366
+ if (kata.length === 1) return `all:${kata[0]}`;
367
+ return presisi ? kata.map((k) => `all:${k}`).join(' AND ') : `all:${kata.join(' ')}`;
368
+ }
369
+
370
+ async function arxiv(query, { rows }) {
371
+ await arxivTunggu();
372
+ let hasil;
373
+ try {
374
+ hasil = await ambilArxiv(kueriArxiv(query, true), rows);
375
+ } finally {
376
+ arxivTerakhir = Date.now();
377
+ }
378
+ if (hasil.length > 0) return hasil;
379
+
380
+ // Cadangan longgar. Hanya dijalankan kalau bentuk presisi benar-benar kosong,
381
+ // supaya permintaan arXiv tetap hemat (TOU mereka: 1 permintaan / 3 detik).
382
+ await arxivTunggu();
383
+ try {
384
+ return await ambilArxiv(kueriArxiv(query, false), rows);
280
385
  } finally {
281
386
  arxivTerakhir = Date.now();
282
387
  }
@@ -287,7 +392,7 @@ async function semantic(query, { rows, apiKey }) {
287
392
  const j = await getJson(u, apiKey ? { headers: { 'x-api-key': apiKey } } : {});
288
393
  return (j?.data ?? []).map((d) => buatPaper({
289
394
  title: d.title,
290
- authors: (d.authors ?? []).map((a) => a.name),
395
+ authors: daftarPenulis(d.authors),
291
396
  year: d.year,
292
397
  source: 'semantic',
293
398
  doi: d.externalIds?.DOI,
@@ -307,7 +412,7 @@ async function pmc(query, { rows }) {
307
412
  // PubMed Central memakai E-utilities yang sama dengan PubMed:
308
413
  // esearch (id) lalu esummary (metadata).
309
414
  const base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
310
- const s = await getJson(`${base}/esearch.fcgi?db=pmc&retmode=json&retmax=${rows}&term=${encodeURIComponent(query)}`);
415
+ const s = await getJson(`${base}/esearch.fcgi?db=pmc&retmode=json&retmax=${rows}&sort=relevance&term=${encodeURIComponent(query)}`);
311
416
  const ids = s?.esearchresult?.idlist ?? [];
312
417
  if (ids.length === 0) return [];
313
418
  const d = await getJson(`${base}/esummary.fcgi?db=pmc&retmode=json&id=${ids.join(',')}`);
@@ -317,7 +422,7 @@ async function pmc(query, { rows }) {
317
422
  const pmcid = (r.articleids ?? []).find((a) => a.idtype === 'pmcid')?.value ?? `PMC${id}`;
318
423
  return buatPaper({
319
424
  title: r.title,
320
- authors: (r.authors ?? []).map((a) => a.name),
425
+ authors: daftarPenulis(r.authors),
321
426
  year: String(r.pubdate ?? '').slice(0, 4),
322
427
  source: 'pmc',
323
428
  doi: (r.articleids ?? []).find((a) => a.idtype === 'doi')?.value,
@@ -341,7 +446,7 @@ async function core(query, { rows, coreApiKey }) {
341
446
  });
342
447
  return (j?.results ?? []).map((c) => buatPaper({
343
448
  title: c.title,
344
- authors: (c.authors ?? []).map((a) => a.name),
449
+ authors: daftarPenulis(c.authors),
345
450
  year: c.yearPublished,
346
451
  source: 'core',
347
452
  doi: c.doi,
@@ -356,7 +461,7 @@ async function zenodo(query, { rows }) {
356
461
  const j = await getJson(`https://zenodo.org/api/records?q=${encodeURIComponent(query)}&size=${rows}`);
357
462
  return (j?.hits?.hits ?? []).map((z) => buatPaper({
358
463
  title: z.metadata?.title,
359
- authors: (z.metadata?.creators ?? []).map((c) => c.name),
464
+ authors: daftarPenulis(z.metadata?.creators),
360
465
  year: String(z.metadata?.publication_date ?? '').slice(0, 4),
361
466
  source: 'zenodo',
362
467
  doi: z.metadata?.doi ?? z.doi,
@@ -372,7 +477,7 @@ async function hal(query, { rows }) {
372
477
  const j = await getJson(`https://api.archives-ouvertes.fr/search/?q=${encodeURIComponent(query)}&wt=json&rows=${rows}&fl=${fl}`);
373
478
  return (j?.response?.docs ?? []).map((a) => buatPaper({
374
479
  title: pertama(a.title_s),
375
- authors: a.authFullName_s ?? [],
480
+ authors: daftarPenulis(a.authFullName_s),
376
481
  year: a.producedDateY_i,
377
482
  source: 'hal',
378
483
  doi: pertama(a.doiId_s),
@@ -400,7 +505,7 @@ async function iacr(query, { rows }) {
400
505
  const id = tautan[i][1];
401
506
  out.push(buatPaper({
402
507
  title: ambil(/<strong>([\s\S]*?)<\/strong>/),
403
- authors: (ambil(/<span class="fst-italic">([\s\S]*?)<\/span>/) ?? '').split(/,\s*/).filter(Boolean),
508
+ authors: daftarPenulis((ambil(/<span class="fst-italic">([\s\S]*?)<\/span>/) ?? '').split(/,\s*/)),
404
509
  year: id.slice(0, 4),
405
510
  source: 'iacr',
406
511
  url: `https://eprint.iacr.org/${id}`,
@@ -430,7 +535,7 @@ async function unpaywall(query, { rows, email }) {
430
535
  const oa = j.best_oa_location ?? null;
431
536
  const hasil = buatPaper({
432
537
  title: j.title,
433
- authors: (j.z_authors ?? []).map((a) => a.raw_author_name ?? [a.given, a.family].filter(Boolean).join(' ')),
538
+ authors: daftarPenulis(j.z_authors),
434
539
  year: j.year,
435
540
  source: 'unpaywall',
436
541
  doi: j.doi ?? doi,
@@ -459,6 +564,29 @@ function ambilHtml(html, pola, batas) {
459
564
  return out;
460
565
  }
461
566
 
567
+ /**
568
+ * Saat penanda hasil tidak ditemukan, ada TIGA kemungkinan yang BERBEDA:
569
+ * 1. portal menjawab "nol hasil" -> `[]`, itu hasil yang SAH
570
+ * 2. markup portal berubah -> error
571
+ * 3. portal memblokir/mengalihkan -> error
572
+ *
573
+ * Sebelum ini (1) dan (2) diperlakukan sama, sehingga portal yang benar-benar
574
+ * kosong dilaporkan sebagai **GAGAL**. Itu keliru dengan arah berlawanan:
575
+ * melaporkan kegagalan padahal hasilnya sah-sah saja kosong — sama
576
+ * menyesatkannya dengan menyembunyikan kegagalan.
577
+ *
578
+ * Penanda nol-hasil diukur 28 Sep 2026 memakai kueri omong kosong, lalu
579
+ * dibandingkan dengan kueri yang ada hasilnya:
580
+ *
581
+ * Garuda : "Found 0 documents" (halaman 11.181 huruf)
582
+ * Sinta : header `Results for "<q>"` tetap ada, tautan profil nol
583
+ * (halaman 34.063 huruf) — Sinta TIDAK menulis pesan kosong
584
+ * IOS : "No Results!" (halaman 23.041 huruf)
585
+ */
586
+ function portalNolHasil(html, ...penanda) {
587
+ return penanda.some((re) => re.test(html));
588
+ }
589
+
462
590
  const GARUDA_BASE = 'https://garuda.kemdiktisaintek.go.id';
463
591
 
464
592
  /** Garuda — indeks artikel jurnal Indonesia (Kemdiktisaintek). */
@@ -472,9 +600,10 @@ async function garuda(query, { rows }) {
472
600
  journal: 'Garuda (jurnal Indonesia)',
473
601
  openAccess: true,
474
602
  })).filter((p) => p.title);
475
- // Penguraian HTML yang tidak menemukan satu pun pola = kemungkinan markup
476
- // berubah. Laporkan sebagai error, JANGAN sebagai "tidak ada hasil".
603
+ // Penguraian HTML yang tidak menemukan satu pun pola bisa berarti DUA hal yang
604
+ // berlawanan. Bedakan: Garuda menulis "Found 0 documents" saat memang kosong.
477
605
  if (hasil.length === 0 && !/title-article/.test(html)) {
606
+ if (portalNolHasil(html, /Found\s+0\s+document/i)) return [];
478
607
  throw new Error('markup Garuda berubah: penanda "title-article" tidak ditemukan lagi');
479
608
  }
480
609
  return hasil;
@@ -491,7 +620,12 @@ async function sinta(query, { rows }) {
491
620
  journal: 'Sinta (jurnal terakreditasi)',
492
621
  openAccess: true,
493
622
  })).filter((p) => p.title);
623
+ // Sinta TIDAK menulis pesan kosong — ia tetap menampilkan header
624
+ // `Results for "<kueri>"` lalu daftar yang kosong. Jadi header itu yang
625
+ // dipakai sebagai bukti "halaman dimuat, hasilnya memang nol".
626
+ // (Sinta mendaftar JURNAL, bukan artikel, jadi kueri spesifik memang sering nol.)
494
627
  if (hasil.length === 0 && !/journals\/profile\//.test(html)) {
628
+ if (portalNolHasil(html, /Results for/i)) return [];
495
629
  throw new Error('markup Sinta berubah: tautan "journals/profile" tidak ditemukan lagi');
496
630
  }
497
631
  return hasil;
@@ -509,6 +643,7 @@ async function ios(query, { rows }) {
509
643
  openAccess: true,
510
644
  })).filter((p) => p.title);
511
645
  if (hasil.length === 0 && !/class="title"/.test(html)) {
646
+ if (portalNolHasil(html, /No Results!/i)) return [];
512
647
  throw new Error('markup IOS berubah: tautan hasil ber-class "title" tidak ditemukan lagi');
513
648
  }
514
649
  return hasil;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-paper-search",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
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
5
  "license": "MIT",
6
6
  "type": "module",