prpm-dbp 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 afu-it
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # prpm-dbp
2
+
3
+ Semak ejaan Bahasa Melayu dan Jawi terhadap [PRPM Dewan Bahasa dan Pustaka](https://prpm.dbp.gov.my/) sebelum teks itu sampai kepada pembaca.
4
+
5
+ AI model tulis Jawi dengan yakin dan kadangkala salah. Pembaca yang tidak fasih Jawi tidak akan perasan. Ini masalah sebenar untuk bahan sekolah, RPH, dan nota pelajar, di mana ejaan salah diajar sebagai betul.
6
+
7
+ Pakej ini tanya PRPM, bukan model.
8
+
9
+ ```
10
+ $ npx prpm-dbp lookup murid abad
11
+ murid [mu.rid] Jawi: موريد
12
+ - = anak ~ orang atau anak yg sedang belajar atau berguru, pelajar, penuntut...
13
+ (Kamus Dewan Edisi Keempat)
14
+ https://prpm.dbp.gov.my/Cari1?keyword=murid
15
+ abad [a.bad] Jawi: ابد
16
+ ...
17
+ ```
18
+
19
+ ## Kenapa ini wujud
20
+
21
+ Satu kamus rumi ke Jawi yang dijana automatik dari dokumen KPM diperiksa dengan alat ini. Antara yang dijumpai:
22
+
23
+ | kata | dalam glosari | PRPM |
24
+ |---|---|---|
25
+ | abad | ابعاض | ابد |
26
+ | ahzab | اية | tiada entri |
27
+
28
+ Glosari itu dibina dengan mengikis dokumen kurikulum, bukan disemak seorang demi seorang. Kesilapan seperti ini senyap sehingga ada orang yang baca Jawi menyemaknya.
29
+
30
+ ## Pasang
31
+
32
+ Tidak perlu pasang apa-apa untuk guna:
33
+
34
+ ```bash
35
+ npx prpm-dbp lookup sekolah
36
+ ```
37
+
38
+ Untuk pasang sebagai skill Claude Code:
39
+
40
+ ```bash
41
+ npx prpm-dbp install # salin ke ~/.claude/skills/prpm-dbp
42
+ npx prpm-dbp install --link # symlink, untuk pembangun
43
+ ```
44
+
45
+ Perlu Node 18 ke atas. Tiada dependency.
46
+
47
+ ## Perintah
48
+
49
+ | Perintah | Fungsi |
50
+ |---|---|
51
+ | `lookup <kata...>` | ejaan Jawi, sebutan, definisi ikut kamus |
52
+ | `audit <fail>` | semak setiap perkataan Rumi wujud dalam PRPM |
53
+ | `audit-jawi <jawi> <rumi>` | banding teks Jawi dengan teks Rumi sejajar |
54
+ | `warm <senarai>` | isi cache dari senarai perkataan |
55
+ | `check-glosari <json>` | audit kamus rumi ke Jawi sedia ada |
56
+ | `banding <kata...>` | silang semak PRPM lawan KamusDBP |
57
+ | `cache` | status cache, `--buang <kata>` untuk padam satu |
58
+
59
+ Pilihan: `--refresh`, `--had <n>`, `--delay <ms>`, `--json`.
60
+
61
+ ## Guna sebagai library
62
+
63
+ ```js
64
+ import { lookup, loadCache, saveCache } from 'prpm-dbp'
65
+
66
+ const cache = await loadCache()
67
+ const rec = await lookup('murid', cache)
68
+ await saveCache(cache)
69
+
70
+ console.log(rec.jawi) // موريد
71
+ console.log(rec.entri[0].sumber) // Kamus Dewan Edisi Keempat
72
+ ```
73
+
74
+ ## Dua sumber
75
+
76
+ DBP ada dua portal, dan liputan mereka tidak sama.
77
+
78
+ ```
79
+ $ npx prpm-dbp banding murid kemahiran ahzab
80
+ murid PRPM: موريد KamusDBP: موريد sepakat (tinggi)
81
+ kemahiran PRPM: کماهيرن KamusDBP: - satu_sumber (sederhana)
82
+ ahzab PRPM: - KamusDBP: - tiada_langsung (rendah)
83
+ ```
84
+
85
+ Bila dua sumber bercanggah, alat ini tidak pilih pemenang. Medan `jawi` jadi `null` dan kedua-dua ejaan dilaporkan dengan pautan. Tiada cara automatik yang selamat untuk pilih antara dua sumber rasmi.
86
+
87
+ Medan sebutan tidak dibandingkan. PRPM beri pemisahan suku kata (`a.bad`), KamusDBP beri IPA (`ɑ.bɑd`).
88
+
89
+ ## Kata terbitan
90
+
91
+ KamusDBP disusun ikut lemma. Kata terbitan seperti `kewajipan` dan `pengajian` tidak wujud sebagai entri carian, ia tinggal di bawah kata dasar `wajib` dan `kaji`. Carian yang gagal untuk kata terbitan bukan bermakna liputan kurang, cuma soalan ditanya di pintu yang salah.
92
+
93
+ Satu halaman lemma membawa sepuluh atau lebih ejaan Jawi sekali jalan:
94
+
95
+ ```js
96
+ import { parseLemma, petaJawi } from 'prpm-dbp/lemma'
97
+
98
+ const rekod = parseLemma(html)
99
+ petaJawi(rekod)
100
+ // { wajib: 'واجب', kewajiban: 'کواجبن', kewajipan: 'کواجيڤن', ... }
101
+ ```
102
+
103
+ Halaman lemma berpagar. Tanpa sesi ia mengalih ke halaman log masuk. Untuk memuat turunnya, bekalkan cookie sesi anda sendiri:
104
+
105
+ ```bash
106
+ export KAMUS_DBP_COOKIE="kamusdbp_session=..."
107
+ ```
108
+
109
+ Cookie, bukan kata laluan. Kata laluan tidak pernah masuk ke dalam alat ini dan tidak pernah dihantar oleh kodnya. Cookie luput dalam dua jam, jadi kebocoran pun terhad dan sementara.
110
+
111
+ Tanpa cookie, semua perintah lain tetap berfungsi seperti biasa.
112
+
113
+ ## Nota untuk sesiapa yang bina benda serupa
114
+
115
+ PRPM balas HTTP 200 walaupun perkataan langsung tiada dalam kamus. Kod yang percaya status code akan hantar halaman kosong sebagai jawapan yang sah. Penanda sebenar ialah ketiadaan `div.tab-pane` dalam HTML.
116
+
117
+ Selain itu laman ini biasa sahaja: GET terus, tiada JS, tiada login, tiada VIEWSTATE. Playwright atau browser automation tidak diperlukan dan hanya melambatkan kerja.
118
+
119
+ KamusDBP pula Laravel Livewire: GET `/` untuk cookie dan `_token`, POST `/carian`, kemudian ikut 302 ke `/carian/e/v1.<signed>` sebagai GET. Kekalkan POST pada redirect itu dan anda dapat 405. Tiada akaun diperlukan, walaupun laman ini ada halaman log masuk.
120
+
121
+ Sijil `kamus.dbp.gov.my` dihantar tanpa perantaraan, jadi rantaian TLS tidak lengkap dan Node menolaknya. Pakej ini membekalkan intermediate GeoTrust TLS RSA CA G1 yang berantai ke DigiCert Global Root G2. Perlu diingat: memberi `ca` kepada Node menggantikan seluruh trust store, jadi root lalai mesti disertakan semula dengan `tls.rootCertificates`. Pengesahan TLS tidak pernah dimatikan.
122
+
123
+ Jawapan sebenar dicache, kegagalan rangkaian tidak. Satu talian putus tidak sepatutnya menanda perkataan sah sebagai rosak selama-lamanya.
124
+
125
+ ## Adab
126
+
127
+ Ada jeda 400ms antara permintaan secara lalai. Cache bermakna setiap perkataan diambil sekali sahaja seumur hidup. Tolong jangan buang jeda itu untuk kikis seluruh kamus.
128
+
129
+ Kandungan PRPM hak milik DBP. Alat ini untuk semakan dan rujukan, bukan untuk salin kandungan kamus dan edar semula.
130
+
131
+ ## Lesen
132
+
133
+ MIT untuk kod dalam repo ini. Kandungan kamus bukan milik projek ini.
package/SKILL.md ADDED
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: prpm-dbp
3
+ description: "Sahkan ejaan Bahasa Melayu dan Jawi terhadap PRPM Dewan Bahasa dan Pustaka sebelum menghantar output. Guna apabila menulis, menyemak, atau mentransliterasi teks BM/Jawi, terutama bahan pendidikan, RPH, nota, atau apa-apa yang perlu ikut ejaan rasmi DBP. Trigger: jawi, kamus dewan, ejaan rasmi, DBP, PRPM, transliterasi, bahasa melayu baku."
4
+ ---
5
+
6
+ # prpm-dbp
7
+
8
+ Sumber kebenaran tunggal untuk ejaan BM dan Jawi ialah PRPM DBP, bukan ingatan model.
9
+
10
+ Model boleh hasilkan Jawi yang nampak betul tetapi salah, dan pembaca tidak dapat kesan. Skill ini tukar tekaan jadi semakan.
11
+
12
+ ## Peraturan
13
+
14
+ 1. **Jangan hantar Jawi yang belum disemak.** Setiap perkataan Jawi dalam output mesti datang dari PRPM atau ditanda belum disahkan.
15
+ 2. **Tanda, jangan sekat.** Kalau satu perkataan tiada dalam PRPM, hantar kerja itu dan senaraikan perkataan berkenaan di hujung. Jangan tahan seluruh dokumen sebab satu nama khas.
16
+ 3. **Sebut sumber.** PRPM ada beberapa kamus. Bila petik definisi, sebut kamus mana (Kamus Dewan Edisi Keempat, Kamus Pelajar Edisi Kedua, dan lain-lain).
17
+ 4. **Jangan percaya glosari auto.** Kamus rumi ke Jawi yang dijana secara automatik mengandungi kesilapan. Sahkan dengan `check-glosari` sebelum guna.
18
+ 5. **Akronim dan nama khas dikecualikan.** PBD, KBAT, KSSM, nama orang, nama tempat memang tiada dalam PRPM. Ini bukan kegagalan, cuma tiada rujukan.
19
+
20
+ ## Perintah
21
+
22
+ Panggil melalui npx tanpa pemasangan, atau `node bin/cli.js` dari repo.
23
+
24
+ ```bash
25
+ # satu atau beberapa perkataan
26
+ npx prpm-dbp lookup murid sekolah
27
+
28
+ # semak setiap perkataan Rumi dalam fail wujud dalam PRPM
29
+ npx prpm-dbp audit nota.txt
30
+
31
+ # semak ejaan Jawi lawan teks Rumi sejajar (token ke-N lawan token ke-N)
32
+ npx prpm-dbp audit-jawi rph-jawi.txt rph-rumi.txt
33
+
34
+ # panaskan cache dari senarai perkataan, bayar sekali guna selamanya
35
+ npx prpm-dbp warm senarai-kata.txt
36
+
37
+ # audit kamus rumi->jawi sedia ada terhadap PRPM
38
+ npx prpm-dbp check-glosari jawi-dictionary.json
39
+
40
+ # silang semak dua sumber rasmi DBP
41
+ npx prpm-dbp banding murid kemahiran
42
+
43
+ npx prpm-dbp cache
44
+ ```
45
+
46
+ Semua perintah keluarkan JSON kecuali `lookup` tanpa `--json`.
47
+
48
+ Pilihan: `--refresh` abai cache, `--had <n>` hadkan bilangan kata, `--delay <ms>` jarak antara permintaan (default 400ms), `--json`.
49
+
50
+ ## Cara guna dalam kerja menulis
51
+
52
+ Bila menulis teks BM atau Jawi yang akan dihantar kepada orang:
53
+
54
+ 1. Karang dalam Rumi dahulu, penuh dan siap.
55
+ 2. `audit` teks Rumi itu. Perkataan yang tiada dalam PRPM biasanya salah eja atau istilah rekaan.
56
+ 3. Untuk Jawi, ambil ejaan dari medan `jawiRasmi` dalam laporan audit. Jangan transliterasi sendiri.
57
+ 4. Kalau Jawi sudah ditulis, `audit-jawi` bandingkan lawan Rumi sejajar.
58
+ 5. Senaraikan apa-apa dalam `belumDisahkan` atau `tiadaRujukan` kepada pengguna, dengan pautan PRPM.
59
+
60
+ ## Tafsiran keputusan
61
+
62
+ | Medan | Maksud | Tindakan |
63
+ |---|---|---|
64
+ | `lulus` / `padan` | ada dalam PRPM, ejaan sepadan | tiada |
65
+ | `belumDisahkan` | tiada entri dalam PRPM | semak ejaan, atau isytihar sebagai nama khas |
66
+ | `salahEjaan` | Jawi ditulis tidak sama dengan PRPM | ganti dengan medan `sepatutnya` |
67
+ | `tiadaRujukan` | PRPM tiada ejaan Jawi untuk kata itu | kekalkan, tanda kepada pengguna |
68
+ | `gagalSemak` | rangkaian gagal | cuba semula, jangan anggap salah |
69
+
70
+ ## Dua sumber DBP
71
+
72
+ | Sumber | Guna untuk | Nota |
73
+ |---|---|---|
74
+ | PRPM (`prpm.dbp.gov.my`) | utama, semua perintah | liputan luas, banyak kamus, ada tesaurus dan peribahasa |
75
+ | KamusDBP (`kamus.dbp.gov.my`) | silang semak `banding` | definisi lebih moden, liputan lebih kecil |
76
+
77
+ Keputusan `banding`:
78
+
79
+ | Status | Maksud | Keyakinan |
80
+ |---|---|---|
81
+ | `sepakat` | dua sumber beri ejaan Jawi sama | tinggi |
82
+ | `satu_sumber` | satu ada, satu tiada entri | sederhana |
83
+ | `bercanggah` | dua sumber beri ejaan berbeza | rendah, medan `jawi` sengaja `null` |
84
+ | `tiada_langsung` | dua-dua tiada | rendah |
85
+
86
+ Bila `bercanggah`, jangan pilih sendiri. Lapor dua-dua ejaan dengan pautan dan biar manusia putuskan.
87
+
88
+ **Jangan banding medan sebutan.** PRPM beri pemisahan suku kata (`a.bad`), KamusDBP beri transkripsi IPA (`ɑ.bɑd`). Dua-dua betul, cuma benda berlainan.
89
+
90
+ ## Nota teknikal
91
+
92
+ - Endpoint: `GET https://prpm.dbp.gov.my/Cari1?keyword=<kata>`. Tiada auth, tiada VIEWSTATE, tiada JS. Browser automation tidak diperlukan.
93
+ - **PRPM balas HTTP 200 walaupun perkataan tiada dalam kamus.** Status code tidak boleh dipercayai. Penanda sebenar ialah ketiadaan `div.tab-pane`.
94
+ - Ejaan Jawi ada dalam `<font class="cadr">`, satu per tab kamus.
95
+ - Kegagalan rangkaian tidak dicache. Hanya jawapan sebenar dicache.
96
+ - Cache di `~/.cache/prpm-dbp/cache.json`, boleh ubah dengan `PRPM_CACHE`.
97
+ - Kandungan PRPM hak milik DBP. Skill ini untuk semakan dan rujukan. Jangan salin pukal kandungan kamus untuk diedar semula.
package/bin/cli.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises'
3
+ import process from 'node:process'
4
+
5
+ import { pasangSkill } from '../src/install.js'
6
+ import { banding, cariKamus } from '../src/kamus.js'
7
+ import {
8
+ CACHE_PATH,
9
+ auditJawi,
10
+ auditRumi,
11
+ loadCache,
12
+ lookup,
13
+ saveCache,
14
+ semakGlosari,
15
+ } from '../src/prpm.js'
16
+
17
+ const BANTUAN = `prpm-dbp — semak Bahasa Melayu dan Jawi terhadap PRPM DBP
18
+
19
+ npx prpm-dbp lookup <kata...> cari ejaan Jawi, sebutan, definisi
20
+ npx prpm-dbp audit <fail.txt> semak setiap perkataan Rumi wujud dalam PRPM
21
+ npx prpm-dbp audit-jawi <jawi> <rumi> semak ejaan Jawi lawan teks Rumi sejajar
22
+ npx prpm-dbp warm <senarai.txt|json> panaskan cache dari senarai perkataan
23
+ npx prpm-dbp check-glosari <kamus.json> audit kamus rumi->jawi sedia ada
24
+ npx prpm-dbp banding <kata...> silang semak PRPM lawan KamusDBP
25
+ npx prpm-dbp cache [--buang <kata>] status cache
26
+ npx prpm-dbp install [--link] pasang sebagai Claude Code skill
27
+
28
+ Pilihan: --refresh (abai cache) --had <n> --delay <ms> --json
29
+
30
+ Cache: ${CACHE_PATH}
31
+ `
32
+
33
+ function huraiArgs(argv) {
34
+ const pilihan = { refresh: false, had: 0, delay: 400, json: false, link: false, buang: null }
35
+ const baki = []
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i]
38
+ if (a === '--refresh') pilihan.refresh = true
39
+ else if (a === '--json') pilihan.json = true
40
+ else if (a === '--link') pilihan.link = true
41
+ else if (a === '--had') pilihan.had = Number(argv[++i])
42
+ else if (a === '--delay') pilihan.delay = Number(argv[++i])
43
+ else if (a === '--buang') pilihan.buang = argv[++i]
44
+ else baki.push(a)
45
+ }
46
+ return { pilihan, baki }
47
+ }
48
+
49
+ const keluar = (obj) => console.log(JSON.stringify(obj, null, 2))
50
+
51
+ function progres(siap, jumlah) {
52
+ if (siap % 25 === 0 || siap === jumlah) process.stderr.write(` ... ${siap}/${jumlah}\n`)
53
+ }
54
+
55
+ async function main() {
56
+ const [perintah, ...rest] = process.argv.slice(2)
57
+ const { pilihan, baki } = huraiArgs(rest)
58
+
59
+ if (!perintah || perintah === 'help' || perintah === '--help') {
60
+ console.log(BANTUAN)
61
+ return
62
+ }
63
+
64
+ if (perintah === 'install') {
65
+ keluar(await pasangSkill({ link: pilihan.link }))
66
+ return
67
+ }
68
+
69
+ const cache = await loadCache()
70
+ const opts = { refresh: pilihan.refresh, delay: pilihan.delay, onProgress: progres }
71
+
72
+ switch (perintah) {
73
+ case 'lookup': {
74
+ if (!baki.length) throw new Error('bagi sekurang-kurangnya satu perkataan')
75
+ const hasil = []
76
+ for (const kata of baki) hasil.push(await lookup(kata, cache, opts))
77
+ await saveCache(cache)
78
+ if (pilihan.json) keluar(hasil)
79
+ else cetakLookup(hasil)
80
+ break
81
+ }
82
+
83
+ case 'audit': {
84
+ const teks = await readFile(baki[0], 'utf8')
85
+ const laporan = await auditRumi(teks, cache, { ...opts, had: pilihan.had })
86
+ await saveCache(cache)
87
+ keluar(laporan)
88
+ break
89
+ }
90
+
91
+ case 'audit-jawi': {
92
+ const [failJawi, failRumi] = baki
93
+ if (!failJawi || !failRumi) throw new Error('perlu dua fail: <jawi> <rumi>')
94
+ const laporan = await auditJawi(
95
+ await readFile(failJawi, 'utf8'),
96
+ await readFile(failRumi, 'utf8'),
97
+ cache,
98
+ opts,
99
+ )
100
+ await saveCache(cache)
101
+ keluar(laporan)
102
+ break
103
+ }
104
+
105
+ case 'warm': {
106
+ const raw = await readFile(baki[0], 'utf8')
107
+ let kata
108
+ try {
109
+ const data = JSON.parse(raw)
110
+ kata = Array.isArray(data) ? data : Object.keys(data)
111
+ } catch {
112
+ kata = raw.split('\n').map((b) => b.trim()).filter(Boolean)
113
+ }
114
+ if (pilihan.had) kata = kata.slice(0, pilihan.had)
115
+
116
+ let baru = 0
117
+ let adaEntri = 0
118
+ let tiadaEntri = 0
119
+ let gagal = 0
120
+ for (const [i, k] of kata.entries()) {
121
+ const rec = await lookup(k, cache, opts)
122
+ if (!rec.dariCache) baru++
123
+ if (rec.ralat) gagal++
124
+ else if (rec.dijumpai) adaEntri++
125
+ else tiadaEntri++
126
+ if (i % 25 === 0) await saveCache(cache)
127
+ progres(i + 1, kata.length)
128
+ }
129
+ await saveCache(cache)
130
+ keluar({ diproses: kata.length, fetchBaru: baru, adaEntri, tiadaEntri, gagal, cache: CACHE_PATH })
131
+ break
132
+ }
133
+
134
+ case 'check-glosari': {
135
+ const glosari = JSON.parse(await readFile(baki[0], 'utf8'))
136
+ const laporan = await semakGlosari(glosari, cache, opts)
137
+ await saveCache(cache)
138
+ keluar(laporan)
139
+ break
140
+ }
141
+
142
+ case 'banding': {
143
+ if (!baki.length) throw new Error('bagi sekurang-kurangnya satu perkataan')
144
+ const hasil = []
145
+ for (const kata of baki) {
146
+ const rekodPrpm = await lookup(kata, cache, opts)
147
+ let rekodKamus = null
148
+ try {
149
+ rekodKamus = await cariKamus(kata)
150
+ } catch (err) {
151
+ rekodKamus = { kata, dijumpai: null, jawi: null, ralat: err.message }
152
+ }
153
+ hasil.push(banding(rekodPrpm, rekodKamus))
154
+ }
155
+ await saveCache(cache)
156
+ if (pilihan.json) keluar(hasil)
157
+ else
158
+ for (const b of hasil) {
159
+ console.log(
160
+ `${b.kata.padEnd(14)} PRPM: ${(b.prpm.jawi ?? '-').padEnd(12)} ` +
161
+ `KamusDBP: ${(b.kamusdbp.jawi ?? '-').padEnd(12)} ${b.status} (${b.keyakinan})`,
162
+ )
163
+ }
164
+ break
165
+ }
166
+
167
+ case 'cache': {
168
+ if (pilihan.buang) {
169
+ delete cache[pilihan.buang.toLowerCase()]
170
+ await saveCache(cache)
171
+ keluar({ dibuang: pilihan.buang })
172
+ return
173
+ }
174
+ const nilai = Object.values(cache)
175
+ keluar({
176
+ fail: CACHE_PATH,
177
+ jumlah: nilai.length,
178
+ adaEntri: nilai.filter((v) => v.dijumpai).length,
179
+ tiadaEntri: nilai.filter((v) => !v.dijumpai).length,
180
+ })
181
+ break
182
+ }
183
+
184
+ default:
185
+ throw new Error(`perintah tidak dikenali: ${perintah}`)
186
+ }
187
+ }
188
+
189
+ function cetakLookup(hasil) {
190
+ for (const r of hasil) {
191
+ if (r.ralat) {
192
+ console.log(`${r.kata}: GAGAL SEMAK (${r.ralat})`)
193
+ continue
194
+ }
195
+ if (!r.dijumpai) {
196
+ console.log(`${r.kata}: TIADA DALAM PRPM (${r.nota})\n ${r.url}`)
197
+ continue
198
+ }
199
+ console.log(`${r.kata} [${r.entri[0]?.sebutan ?? '-'}] Jawi: ${r.jawi ?? '-'}`)
200
+ if (r.jawiVarian?.length) console.log(` varian: ${r.jawiVarian.join(', ')}`)
201
+ for (const e of r.entri) {
202
+ if (e.definisi) console.log(` - ${e.definisi}\n (${e.sumber ?? 'sumber tidak dinyatakan'})`)
203
+ }
204
+ console.log(` ${r.url}`)
205
+ }
206
+ }
207
+
208
+ main().catch((err) => {
209
+ console.error(`ralat: ${err.message}`)
210
+ process.exit(1)
211
+ })
@@ -0,0 +1,27 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEjTCCA3WgAwIBAgIQDQd4KhM/xvmlcpbhMf/ReTANBgkqhkiG9w0BAQsFADBh
3
+ MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
4
+ d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
5
+ MjAeFw0xNzExMDIxMjIzMzdaFw0yNzExMDIxMjIzMzdaMGAxCzAJBgNVBAYTAlVT
6
+ MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
7
+ b20xHzAdBgNVBAMTFkdlb1RydXN0IFRMUyBSU0EgQ0EgRzEwggEiMA0GCSqGSIb3
8
+ DQEBAQUAA4IBDwAwggEKAoIBAQC+F+jsvikKy/65LWEx/TMkCDIuWegh1Ngwvm4Q
9
+ yISgP7oU5d79eoySG3vOhC3w/3jEMuipoH1fBtp7m0tTpsYbAhch4XA7rfuD6whU
10
+ gajeErLVxoiWMPkC/DnUvbgi74BJmdBiuGHQSd7LwsuXpTEGG9fYXcbTVN5SATYq
11
+ DfbexbYxTMwVJWoVb6lrBEgM3gBBqiiAiy800xu1Nq07JdCIQkBsNpFtZbIZhsDS
12
+ fzlGWP4wEmBQ3O67c+ZXkFr2DcrXBEtHam80Gp2SNhou2U5U7UesDL/xgLK6/0d7
13
+ 6TnEVMSUVJkZ8VeZr+IUIlvoLrtjLbqugb0T3OYXW+CQU0kBAgMBAAGjggFAMIIB
14
+ PDAdBgNVHQ4EFgQUlE/UXYvkpOKmgP792PkA76O+AlcwHwYDVR0jBBgwFoAUTiJU
15
+ IBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG
16
+ AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMDQGCCsGAQUFBwEB
17
+ BCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEIGA1Ud
18
+ HwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds
19
+ b2JhbFJvb3RHMi5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEW
20
+ HGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDQYJKoZIhvcNAQELBQADggEB
21
+ AIIcBDqC6cWpyGUSXAjjAcYwsK4iiGF7KweG97i1RJz1kwZhRoo6orU1JtBYnjzB
22
+ c4+/sXmnHJk3mlPyL1xuIAt9sMeC7+vreRIF5wFBC0MCN5sbHwhNN1JzKbifNeP5
23
+ ozpZdQFmkCo+neBiKR6HqIA+LMTMCMMuv2khGGuPHmtDze4GmEGZtYLyF8EQpa5Y
24
+ jPuV6k2Cr/N3XxFpT3hRpt/3usU/Zb9wfKPtWpoznZ4/44c1p9rzFcZYrWkj3A+7
25
+ TNBJE0GmP2fhXhP1D/XVfIW/h0yCJGEiV9Glm/uGOa3DXHlmbAcxSyCRraG+ZBkA
26
+ 7h4SeM6Y8l/7MBRpPCz6l8Y=
27
+ -----END CERTIFICATE-----
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "prpm-dbp",
3
+ "version": "0.1.0",
4
+ "description": "Semak ejaan Bahasa Melayu dan Jawi terhadap PRPM Dewan Bahasa dan Pustaka. CLI dan skill untuk AI agent.",
5
+ "keywords": [
6
+ "bahasa-melayu",
7
+ "jawi",
8
+ "dbp",
9
+ "prpm",
10
+ "kamus-dewan",
11
+ "claude-code",
12
+ "agent-skill",
13
+ "malaysia"
14
+ ],
15
+ "homepage": "https://github.com/afu-it/prpm-dbp#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/afu-it/prpm-dbp/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/afu-it/prpm-dbp.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "afu-it",
25
+ "type": "module",
26
+ "bin": {
27
+ "prpm-dbp": "./bin/cli.js"
28
+ },
29
+ "exports": {
30
+ ".": "./src/prpm.js",
31
+ "./kamus": "./src/kamus.js",
32
+ "./lemma": "./src/lemma.js"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "certs",
38
+ "SKILL.md",
39
+ "README.md"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test \"tests/**/*.test.js\"",
43
+ "lookup": "node bin/cli.js lookup"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
package/src/install.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pasang pakej ini sebagai skill Claude Code di ~/.claude/skills/prpm-dbp.
3
+ * Mod salin (default) untuk pengguna npx, mod --link untuk pembangun.
4
+ */
5
+
6
+ import { cp, mkdir, rm, symlink } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const AKAR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const SASARAN = join(homedir(), '.claude', 'skills', 'prpm-dbp')
13
+
14
+ export async function pasangSkill({ link = false } = {}) {
15
+ await mkdir(dirname(SASARAN), { recursive: true })
16
+ await rm(SASARAN, { recursive: true, force: true })
17
+
18
+ if (link) {
19
+ await symlink(AKAR, SASARAN, 'dir')
20
+ return { dipasang: SASARAN, mod: 'symlink', sumber: AKAR }
21
+ }
22
+
23
+ await mkdir(SASARAN, { recursive: true })
24
+ for (const item of ['SKILL.md', 'src', 'bin', 'package.json']) {
25
+ await cp(join(AKAR, item), join(SASARAN, item), { recursive: true })
26
+ }
27
+ return { dipasang: SASARAN, mod: 'salin', sumber: AKAR }
28
+ }
package/src/kamus.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Sumber kedua: KamusDBP (https://kamus.dbp.gov.my/), Laravel + Livewire.
3
+ *
4
+ * Aliran carian (disahkan 2026-07-29, tiada akaun diperlukan):
5
+ * 1. GET / -> set cookie sesi + XSRF, dan bawa <input name="_token">
6
+ * 2. POST /carian -> 302 ke /carian/e/v1.<signed>
7
+ * 3. GET <signed> -> HTML hasil
8
+ *
9
+ * Pelayan tidak hantar sijil perantaraan, jadi rantaian TLS tidak lengkap.
10
+ * Kita bekalkan intermediate GeoTrust TLS RSA CA G1 (berantai ke DigiCert
11
+ * Global Root G2 yang sedia dipercayai). Pengesahan TLS kekal hidup.
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs'
15
+ import https from 'node:https'
16
+ import { dirname, join } from 'node:path'
17
+ import tls from 'node:tls'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const AKAR = dirname(dirname(fileURLToPath(import.meta.url)))
21
+
22
+ // Memberi `ca` kepada Node MENGGANTIKAN trust store, bukan menambah. Kalau
23
+ // intermediate dihantar sendirian, root DigiCert hilang dan handshake gagal
24
+ // dengan UNABLE_TO_GET_ISSUER_CERT. Root lalai mesti disertakan semula.
25
+ const CA = [...tls.rootCertificates, readFileSync(join(AKAR, 'certs', 'geotrust-tls-rsa-ca-g1.pem'), 'utf8')]
26
+
27
+ export const HOST = 'kamus.dbp.gov.my'
28
+ const UA =
29
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
30
+
31
+ // Sauh parse ialah onclick aplikasi, bukan kelas Tailwind. Kelas berubah
32
+ // setiap kali reka bentuk dikemas kini, syncLemmaId terikat pada logik.
33
+ const KAD = /onclick="syncLemmaId\('([^']*)',\s*'(\d+)'\)"([\s\S]*?)(?=onclick="syncLemmaId\(|$)/g
34
+ const TAJUK = /<h3[^>]*>\s*([^<]+?)\s*<\/h3>/
35
+ const JAWI = /<p class="[^"]*font-serif[^"]*"[^>]*>\s*([؀-ۿݐ-ݿﭐ-﷿\s]+?)\s*<\/p>/
36
+ // Mesti dihadkan pada kandungan teks antara tag. Corak [..] yang longgar
37
+ // tersasar ke kelas Tailwind seperti max-w-[calc(100vw-60px)].
38
+ const SUKUKATA = />\s*\[([^\]<]+)\]\s*</
39
+ const JENIS = /rounded-full[^>]*>\s*([^<]+?)\s*<\/span>/
40
+ const DEFINISI = /<p class="mt-3[^"]*">\s*([\s\S]*?)\s*<\/p>/
41
+ const TOKEN = /name="_token"\s+value="([^"]+)"/
42
+
43
+ export function mintaMentah(pilihan, badan = null) {
44
+ return new Promise((resolve, reject) => {
45
+ const req = https.request({ ...pilihan, ca: CA, host: HOST, port: 443 }, (res) => {
46
+ const cebis = []
47
+ res.on('data', (c) => cebis.push(c))
48
+ res.on('end', () =>
49
+ resolve({
50
+ status: res.statusCode,
51
+ headers: res.headers,
52
+ body: Buffer.concat(cebis).toString('utf8'),
53
+ }),
54
+ )
55
+ })
56
+ req.on('error', reject)
57
+ req.setTimeout(pilihan.timeout ?? 25000, () => req.destroy(new Error('timeout')))
58
+ if (badan) req.write(badan)
59
+ req.end()
60
+ })
61
+ }
62
+
63
+ const ambilCookie = (headers, jar) => {
64
+ for (const baris of headers['set-cookie'] ?? []) {
65
+ const [pasangan] = baris.split(';')
66
+ const [nama, ...nilai] = pasangan.split('=')
67
+ jar.set(nama.trim(), nilai.join('='))
68
+ }
69
+ return jar
70
+ }
71
+
72
+ const rangkumCookie = (jar) => [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
73
+
74
+ const nyahEntiti = (s) =>
75
+ s
76
+ .replace(/<[^>]+>/g, ' ')
77
+ .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, e) =>
78
+ ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' })[e],
79
+ )
80
+ .replace(/\s+/g, ' ')
81
+ .trim()
82
+
83
+ export function parseKamus(kata, html) {
84
+ const hasil = []
85
+ for (const [, lemma, id, blok] of html.matchAll(KAD)) {
86
+ const tajuk = blok.match(TAJUK)
87
+ const jawi = blok.match(JAWI)
88
+ const suku = blok.match(SUKUKATA)
89
+ const jenis = blok.match(JENIS)
90
+ const def = blok.match(DEFINISI)
91
+ hasil.push({
92
+ lemma: tajuk ? nyahEntiti(tajuk[1]) : lemma,
93
+ lemmaId: id,
94
+ jawi: jawi ? jawi[1].trim() : null,
95
+ // AWAS: medan ini BUKAN sama dengan `sebutan` PRPM. KamusDBP bagi
96
+ // transkripsi IPA (abad -> ɑ.bɑd), PRPM bagi pemisahan suku kata
97
+ // (abad -> a.bad). Membandingkan dua-dua akan lapor percanggahan palsu.
98
+ // Titik tengah (mu·rid) dinormalisasi kepada noktah untuk paparan sahaja.
99
+ sebutanIpa: suku ? suku[1].replace(/·/g, '.').trim() : null,
100
+ jenis: jenis ? nyahEntiti(jenis[1]) : null,
101
+ definisi: def ? nyahEntiti(def[1]) : null,
102
+ })
103
+ }
104
+
105
+ const tepat = hasil.filter((h) => h.lemma.toLowerCase() === kata.toLowerCase())
106
+ const jawiTepat = tepat.map((h) => h.jawi).filter(Boolean)
107
+ const unik = [...new Set(jawiTepat)]
108
+
109
+ return {
110
+ kata,
111
+ sumber: 'kamusdbp',
112
+ dijumpai: tepat.length > 0,
113
+ jawi: unik[0] ?? null,
114
+ jawiVarian: unik.slice(1),
115
+ entri: tepat,
116
+ berkaitan: hasil.filter((h) => h.lemma.toLowerCase() !== kata.toLowerCase()),
117
+ url: `https://${HOST}/`,
118
+ nota: tepat.length ? null : hasil.length ? 'hanya padanan berkaitan' : 'tiada entri dalam KamusDBP',
119
+ }
120
+ }
121
+
122
+ export async function cariKamus(kata, { timeout = 25000 } = {}) {
123
+ const jar = new Map()
124
+
125
+ const laman = await mintaMentah({ method: 'GET', path: '/', timeout, headers: { 'User-Agent': UA } })
126
+ ambilCookie(laman.headers, jar)
127
+ const token = laman.body.match(TOKEN)?.[1]
128
+ if (!token) throw new Error('token CSRF tidak dijumpai di halaman utama')
129
+
130
+ const badan = new URLSearchParams({ _token: token, query: kata }).toString()
131
+ const hantar = await mintaMentah(
132
+ {
133
+ method: 'POST',
134
+ path: '/carian',
135
+ timeout,
136
+ headers: {
137
+ 'User-Agent': UA,
138
+ 'Content-Type': 'application/x-www-form-urlencoded',
139
+ 'Content-Length': Buffer.byteLength(badan),
140
+ Cookie: rangkumCookie(jar),
141
+ Referer: `https://${HOST}/`,
142
+ Origin: `https://${HOST}`,
143
+ },
144
+ },
145
+ badan,
146
+ )
147
+ ambilCookie(hantar.headers, jar)
148
+
149
+ const lokasi = hantar.headers.location
150
+ if (!lokasi) throw new Error(`tiada redirect selepas POST (HTTP ${hantar.status})`)
151
+
152
+ // Redirect mesti diikut sebagai GET. Kekalkan POST di sini dapat 405.
153
+ // Carian tanpa hasil boleh redirect sekali lagi, jadi ikut beberapa lompatan.
154
+ let semasa = lokasi
155
+ let hasil
156
+ for (let lompat = 0; lompat < 4; lompat++) {
157
+ hasil = await mintaMentah({
158
+ method: 'GET',
159
+ path: new URL(semasa).pathname + new URL(semasa).search,
160
+ timeout,
161
+ headers: { 'User-Agent': UA, Cookie: rangkumCookie(jar), Referer: `https://${HOST}/` },
162
+ })
163
+ ambilCookie(hasil.headers, jar)
164
+ if (hasil.status === 200) return { ...parseKamus(kata, hasil.body), url: semasa }
165
+ if (!hasil.headers.location) break
166
+ semasa = new URL(hasil.headers.location, `https://${HOST}`).href
167
+ }
168
+
169
+ // Redirect keluar dari /carian bermakna KamusDBP tiada apa-apa untuk kata ini.
170
+ if (hasil?.status === 302 || hasil?.status === 301) {
171
+ return { ...parseKamus(kata, ''), url: semasa, nota: 'tiada entri dalam KamusDBP' }
172
+ }
173
+ throw new Error(`HTTP ${hasil?.status} pada halaman hasil`)
174
+ }
175
+
176
+ /**
177
+ * Lipatan kodpoint Jawi untuk PERBANDINGAN sahaja.
178
+ *
179
+ * PRPM dan KamusDBP guna kodpoint berlainan untuk huruf yang sama. Contoh
180
+ * sebenar: kaji ditulis کاجي (U+06A9 Keheh) di PRPM dan كاجي (U+0643 Arabic
181
+ * Kaf) di KamusDBP. Unicode NFC dan NFKC TIDAK menyatukan dua ini kerana
182
+ * Unicode menganggapnya huruf berlainan, bukan varian bentuk.
183
+ *
184
+ * Lipatan ini sengaja konservatif. Ta marbutah (ة) tidak dilipat ke ha (ه)
185
+ * kerana dalam sesetengah perkataan ia benar-benar membezakan makna.
186
+ */
187
+ const LIPATAN = new Map([
188
+ ['ك', 'ک'], // ك Arabic Kaf -> ک Keheh
189
+ ['ڪ', 'ک'], // ڪ Swash Kaf -> ک Keheh
190
+ ['ی', 'ي'], // ی Farsi Yeh -> ي Arabic Yeh
191
+ ['ى', 'ي'], // ى Alef Maksura -> ي Arabic Yeh
192
+ ['ە', 'ه'], // ە Ae -> ه Ha
193
+ ['ہ', 'ه'], // ہ Heh Goal -> ه Ha
194
+ ])
195
+
196
+ const BUANG = /[ـً-ْٰ​-‏]/g // tatweel, harakat, kawalan
197
+
198
+ export function normalisasiJawi(teks) {
199
+ if (!teks) return teks
200
+ return [...teks.normalize('NFC').replace(BUANG, '')]
201
+ .map((c) => LIPATAN.get(c) ?? c)
202
+ .join('')
203
+ .trim()
204
+ }
205
+
206
+ /**
207
+ * Bandingkan PRPM dengan KamusDBP. Bila dua sumber rasmi bercanggah pada
208
+ * ejaan sebenar, lapor kedua-dua. Jangan pilih satu secara senyap.
209
+ */
210
+ export function banding(recPrpm, recKamus) {
211
+ const a = recPrpm?.jawi ?? null
212
+ const b = recKamus?.jawi ?? null
213
+ const samaSelepasLipatan = a && b && normalisasiJawi(a) === normalisasiJawi(b)
214
+
215
+ let status
216
+ if (a && b) status = a === b || samaSelepasLipatan ? 'sepakat' : 'bercanggah'
217
+ else if (a || b) status = 'satu_sumber'
218
+ else status = 'tiada_langsung'
219
+
220
+ // Entri wujud tetapi tiada medan Jawi adalah keadaan berbeza daripada
221
+ // tiada entri langsung. Menyamakan dua ini menyembunyikan liputan sebenar.
222
+ const nota = []
223
+ if (samaSelepasLipatan && a !== b) nota.push('ejaan sama, kodpoint berbeza')
224
+ if (!a && recPrpm?.dijumpai) nota.push('PRPM ada entri tanpa ejaan Jawi')
225
+ if (!b && recKamus?.dijumpai) nota.push('KamusDBP ada entri tanpa ejaan Jawi')
226
+
227
+ return {
228
+ kata: recPrpm?.kata ?? recKamus?.kata,
229
+ status,
230
+ prpm: { jawi: a, dijumpai: recPrpm?.dijumpai ?? null, url: recPrpm?.url },
231
+ kamusdbp: { jawi: b, dijumpai: recKamus?.dijumpai ?? null, url: recKamus?.url },
232
+ keyakinan: status === 'sepakat' ? 'tinggi' : status === 'satu_sumber' ? 'sederhana' : 'rendah',
233
+ // PRPM kekal sumber utama bila dua-dua ada, jadi bentuknya yang dipulangkan.
234
+ jawi: status === 'bercanggah' ? null : (a ?? b),
235
+ nota: nota.length ? nota : null,
236
+ }
237
+ }
package/src/lemma.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Parser halaman lemma KamusDBP (/kata/e/v1.<signed>).
3
+ *
4
+ * Halaman ini BERPAGAR: tanpa sesi ia 302 ke /login. Ia dipisahkan daripada
5
+ * kamus.js supaya parser boleh diuji sepenuhnya tanpa sebarang kredential.
6
+ *
7
+ * Nilainya: KamusDBP disusun ikut lemma. Kata terbitan seperti kewajipan dan
8
+ * pengajian tidak wujud sebagai entri carian sendiri, ia tinggal di bawah kata
9
+ * dasar. Satu halaman lemma boleh menuai sepuluh atau lebih ejaan Jawi.
10
+ *
11
+ * Sauh parse sengaja atribut semantik (data-value, title="Makna"), bukan kelas
12
+ * Tailwind, kerana kelas berubah setiap kali reka bentuk dikemas kini.
13
+ */
14
+
15
+ const AKORDION = /data-value="([^"]+)"[\s\S]*?(?=data-value="|$)/g
16
+ const JAWI = /<p class="[^"]*font-sakkal-majalla[^"]*">([\s\S]*?)<\/p>/
17
+ const IPA = /<span class="[^"]*font-sil[^"]*"[^>]*>\s*\[([^\]]+)\]\s*<\/span>/
18
+ const TAJUK_LEMMA = /<div id="lemma"[\s\S]*?<h1[^>]*>\s*([^<]+?)\s*(?:<sup[^>]*>(\d+)<\/sup>)?\s*<\/h1>/
19
+ const MAKNA = /title="Makna"[^>]*>([\s\S]*?)<\/span>/g
20
+ const CONTOH = /title="Contoh penggunaan"[^>]*>([\s\S]*?)<\/li>/g
21
+ const KELAS_KATA = /title="(kata [^"]+)"[^>]*>\s*([a-z]+)\s*</g
22
+ const ASAL_USUL = /id="word_history"[\s\S]*?<div class="text-lg[^"]*">([\s\S]*?)<\/div>/
23
+ const BLOK_TERBITAN = /<div id="sub_entries"[\s\S]*?(?=<div id="(?:sub_entry_phrases|word_history|proverb_entries)"|$)/
24
+
25
+ const ENTITI = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' }
26
+
27
+ /**
28
+ * Jawi boleh mengandungi tag di tengah rentetan, contohnya
29
+ * سواجب<span class="jawi-hyphen">-</span>واجبڽ bagi sewajib-wajibnya.
30
+ * Tag mesti dibuang TANPA memasukkan ruang, jika tidak sempang hilang
31
+ * atau perkataan terpisah menjadi dua.
32
+ */
33
+ export function bersihJawi(raw) {
34
+ return raw
35
+ .replace(/<[^>]+>/g, '')
36
+ .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, e) => ENTITI[e])
37
+ .trim()
38
+ }
39
+
40
+ export function bersihTeks(raw) {
41
+ return raw
42
+ .replace(/<[^>]+>/g, ' ')
43
+ .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, e) => ENTITI[e])
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ }
47
+
48
+ function ambilDefinisi(blok) {
49
+ const makna = [...blok.matchAll(MAKNA)].map((m) => bersihTeks(m[1])).filter(Boolean)
50
+ const kelas = [...blok.matchAll(KELAS_KATA)].map((m) => ({ penuh: m[1], singkat: m[2] }))
51
+ const contoh = [...blok.matchAll(CONTOH)].map((m) => bersihTeks(m[1])).filter(Boolean)
52
+ return makna.map((teks, i) => ({
53
+ makna: teks,
54
+ kelasKata: kelas[i]?.penuh ?? kelas[0]?.penuh ?? null,
55
+ singkatan: kelas[i]?.singkat ?? kelas[0]?.singkat ?? null,
56
+ })).map((d, i) => (i === 0 && contoh.length ? { ...d, contoh } : d))
57
+ }
58
+
59
+ export function parseLemma(html) {
60
+ const tajuk = html.match(TAJUK_LEMMA)
61
+ const bahagianLemma = html.slice(html.indexOf('id="lemma"'), html.indexOf('id="sub_entries"') + 1 || undefined)
62
+ const jawi = bahagianLemma.match(JAWI)
63
+ const ipa = bahagianLemma.match(IPA)
64
+ const asal = html.match(ASAL_USUL)
65
+
66
+ const blokTerbitan = html.match(BLOK_TERBITAN)?.[0] ?? ''
67
+ const terbitan = []
68
+ for (const [blok, nama] of blokTerbitan.matchAll(AKORDION)) {
69
+ const j = blok.match(JAWI)
70
+ const p = blok.match(IPA)
71
+ terbitan.push({
72
+ lemma: nama,
73
+ jawi: j ? bersihJawi(j[1]) : null,
74
+ ipa: p ? p[1].replace(/·/g, '.').trim() : null,
75
+ definisi: ambilDefinisi(blok),
76
+ })
77
+ }
78
+
79
+ return {
80
+ lemma: tajuk ? bersihTeks(tajuk[1]) : null,
81
+ homograf: tajuk?.[2] ? Number(tajuk[2]) : null,
82
+ jawi: jawi ? bersihJawi(jawi[1]) : null,
83
+ ipa: ipa ? ipa[1].replace(/·/g, '.').trim() : null,
84
+ definisi: ambilDefinisi(bahagianLemma),
85
+ asalUsul: asal ? bersihTeks(asal[1]) : null,
86
+ terbitan,
87
+ }
88
+ }
89
+
90
+ /** Ratakan lemma dan semua kata terbitannya menjadi peta rumi -> jawi. */
91
+ export function petaJawi(rekod) {
92
+ const peta = {}
93
+ if (rekod.lemma && rekod.jawi) peta[rekod.lemma.toLowerCase()] = rekod.jawi
94
+ for (const t of rekod.terbitan) {
95
+ if (t.lemma && t.jawi) peta[t.lemma.toLowerCase()] = t.jawi
96
+ }
97
+ return peta
98
+ }
99
+
100
+ /**
101
+ * Ambil halaman lemma berpagar guna cookie sesi yang pengguna bekalkan.
102
+ *
103
+ * Sengaja cookie, bukan email dan kata laluan. Kata laluan tidak pernah masuk
104
+ * ke dalam alat ini, tidak pernah dihantar oleh kod ini, dan tidak pernah
105
+ * wujud dalam mana-mana fail. Cookie luput dalam dua jam, jadi kebocoran pun
106
+ * terhad dan sementara.
107
+ *
108
+ * Dapatkan cookie: log masuk di kamus.dbp.gov.my, buka DevTools, Application,
109
+ * Cookies, salin kamusdbp_session.
110
+ * export KAMUS_DBP_COOKIE="kamusdbp_session=...; XSRF-TOKEN=..."
111
+ */
112
+ export async function ambilLemma(url, { cookie = process.env.KAMUS_DBP_COOKIE, timeout = 25000 } = {}) {
113
+ if (!cookie) {
114
+ throw new Error(
115
+ 'halaman lemma memerlukan sesi. Set KAMUS_DBP_COOKIE, atau guna carian biasa yang tidak berpagar.',
116
+ )
117
+ }
118
+
119
+ const { HOST, mintaMentah } = await import('./kamus.js')
120
+ const u = new URL(url, `https://${HOST}`)
121
+ const res = await mintaMentah({
122
+ method: 'GET',
123
+ path: u.pathname + u.search,
124
+ timeout,
125
+ headers: { Cookie: cookie, Referer: `https://${HOST}/` },
126
+ })
127
+
128
+ if (res.status === 302 || res.status === 301) {
129
+ throw new Error('dialih ke log masuk: cookie tamat tempoh atau tidak sah')
130
+ }
131
+ if (res.status !== 200) throw new Error(`HTTP ${res.status}`)
132
+
133
+ return { ...parseLemma(res.body), url: u.href }
134
+ }
package/src/prpm.js ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Enjin semakan PRPM DBP.
3
+ * Sumber rasmi: https://prpm.dbp.gov.my/Cari1?keyword=<kata>
4
+ * Tiada dependency. Node 18+ (guna global fetch).
5
+ */
6
+
7
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
8
+ import { homedir } from 'node:os'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ export const BASE_URL = 'https://prpm.dbp.gov.my/Cari1'
12
+
13
+ const UA =
14
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
15
+
16
+ // npx jalan dari folder sementara yang dipadam selepas guna, jadi cache
17
+ // wajib duduk di luar pakej supaya kekal merentas run dan merentas projek.
18
+ export const CACHE_PATH =
19
+ process.env.PRPM_CACHE ??
20
+ join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'prpm-dbp', 'cache.json')
21
+
22
+ const JAWI_RANGE = '؀-ۿݐ-ݿﭐ-﷿ﹰ-'
23
+ export const JAWI_TOKEN = new RegExp(`[${JAWI_RANGE}]+`, 'g')
24
+ export const RUMI_TOKEN = /[A-Za-z][A-Za-z'-]*/g
25
+
26
+ // Struktur PRPM disahkan 2026-07-29:
27
+ // <div class="tab-pane fade in active" id="1"><br>[mu.rid] | <font class="cadr">موريد</font>
28
+ // <br><br><b>Definisi : </b>...&nbsp;<b>(Kamus Dewan Edisi Keempat)</b></div>
29
+ const TAB_PANE = /<div class="tab-pane[^"]*" id="(\d+)">([\s\S]*?)<\/div>/g
30
+ const CADR = /<font class=["']?cadr["']?[^>]*>([\s\S]*?)<\/font>/
31
+ const SEBUTAN = /\[([a-z.à-ÿ-]+)\]/i
32
+ const DEFINISI = /<b>Definisi\s*:\s*<\/b>([\s\S]*?)(?=<b>\(|$)/
33
+ const SUMBER = /<b>\(([^)]+)\)<\/b>/
34
+ const TIADA_TESAURUS = /Tiada maklumat tesaurus untuk kata/
35
+
36
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' }
37
+
38
+ export function stripTags(raw) {
39
+ return raw
40
+ .replace(/<br\s*\/?>/gi, ' ')
41
+ .replace(/<[^>]+>/g, '')
42
+ .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, e) => ENTITIES[e])
43
+ .replace(/ /g, ' ')
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ }
47
+
48
+ /**
49
+ * PRPM balas HTTP 200 walaupun perkataan tiada dalam kamus, jadi status code
50
+ * tidak boleh dipercayai. Ketiadaan tab-pane adalah satu-satunya penanda sah.
51
+ */
52
+ export function parse(kata, page) {
53
+ const entri = []
54
+ for (const [, , body] of page.matchAll(TAB_PANE)) {
55
+ const jawi = body.match(CADR)
56
+ const sebutan = stripTags(body.slice(0, 160)).match(SEBUTAN)
57
+ const definisi = body.match(DEFINISI)
58
+ const sumber = body.match(SUMBER)
59
+ entri.push({
60
+ jawi: jawi ? stripTags(jawi[1]) : null,
61
+ sebutan: sebutan ? sebutan[1] : null,
62
+ definisi: definisi ? stripTags(definisi[1]) : null,
63
+ sumber: sumber ? sumber[1].trim() : null,
64
+ })
65
+ }
66
+
67
+ const semuaJawi = entri.map((e) => e.jawi).filter(Boolean)
68
+ const unik = [...new Set(semuaJawi)]
69
+ return {
70
+ kata,
71
+ dijumpai: entri.length > 0,
72
+ jawi: unik[0] ?? null,
73
+ jawiVarian: unik.slice(1),
74
+ entri,
75
+ url: `${BASE_URL}?keyword=${encodeURIComponent(kata)}`,
76
+ nota: entri.length
77
+ ? null
78
+ : TIADA_TESAURUS.test(page)
79
+ ? 'tiada entri dalam PRPM'
80
+ : 'halaman tidak dikenali, semak manual',
81
+ }
82
+ }
83
+
84
+ export async function loadCache() {
85
+ try {
86
+ return JSON.parse(await readFile(CACHE_PATH, 'utf8'))
87
+ } catch {
88
+ return {}
89
+ }
90
+ }
91
+
92
+ export async function saveCache(cache) {
93
+ await mkdir(dirname(CACHE_PATH), { recursive: true })
94
+ const tmp = `${CACHE_PATH}.tmp`
95
+ await writeFile(tmp, JSON.stringify(cache, null, 1), 'utf8')
96
+ await rename(tmp, CACHE_PATH)
97
+ }
98
+
99
+ export async function fetchPage(kata, { timeout = 20000 } = {}) {
100
+ const res = await fetch(`${BASE_URL}?keyword=${encodeURIComponent(kata)}`, {
101
+ headers: { 'User-Agent': UA, 'Accept-Language': 'ms-MY,ms;q=0.9' },
102
+ signal: AbortSignal.timeout(timeout),
103
+ })
104
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
105
+ return res.text()
106
+ }
107
+
108
+ const tidur = (ms) => new Promise((r) => setTimeout(r, ms))
109
+
110
+ export async function lookup(kata, cache, { refresh = false, delay = 400 } = {}) {
111
+ const key = String(kata).trim().toLowerCase()
112
+ if (!key) return null
113
+ if (!refresh && key in cache) return { ...cache[key], dariCache: true }
114
+
115
+ try {
116
+ const rec = parse(key, await fetchPage(key))
117
+ cache[key] = rec
118
+ await tidur(delay)
119
+ return { ...rec, dariCache: false }
120
+ } catch (err) {
121
+ // Kegagalan rangkaian TIDAK dicache. Kalau dicache, satu talian putus
122
+ // akan menanda perkataan sah sebagai gagal buat selama-lamanya.
123
+ return {
124
+ kata: key,
125
+ dijumpai: null,
126
+ jawi: null,
127
+ entri: [],
128
+ ralat: err.message,
129
+ url: `${BASE_URL}?keyword=${encodeURIComponent(key)}`,
130
+ }
131
+ }
132
+ }
133
+
134
+ export function kataUnik(teks, had = 0) {
135
+ const seen = new Set()
136
+ const out = []
137
+ for (const tok of teks.matchAll(RUMI_TOKEN)) {
138
+ const k = tok[0].toLowerCase()
139
+ if (k.length < 2 || seen.has(k)) continue
140
+ seen.add(k)
141
+ out.push(k)
142
+ }
143
+ return had > 0 ? out.slice(0, had) : out
144
+ }
145
+
146
+ /** Semak teks Rumi: setiap perkataan wujud dalam PRPM, dan ambil ejaan Jawi rasminya. */
147
+ export async function auditRumi(teks, cache, opts = {}) {
148
+ const kata = kataUnik(teks, opts.had ?? 0)
149
+ const lulus = []
150
+ const ragu = []
151
+ const gagal = []
152
+
153
+ for (const k of kata) {
154
+ const rec = await lookup(k, cache, opts)
155
+ if (rec.ralat) gagal.push(rec)
156
+ else if (rec.dijumpai) lulus.push(rec)
157
+ else ragu.push(rec)
158
+ opts.onProgress?.(lulus.length + ragu.length + gagal.length, kata.length)
159
+ }
160
+
161
+ return {
162
+ jumlahKataUnik: kata.length,
163
+ lulus: lulus.length,
164
+ belumDisahkan: ragu.map((r) => r.kata),
165
+ gagalSemak: gagal.map((r) => ({ kata: r.kata, ralat: r.ralat })),
166
+ jawiRasmi: Object.fromEntries(lulus.filter((r) => r.jawi).map((r) => [r.kata, r.jawi])),
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Semak teks Jawi lawan teks Rumi sejajar. Token ke-N dalam Jawi mesti padan
172
+ * dengan ejaan rasmi bagi perkataan Rumi ke-N.
173
+ */
174
+ export async function auditJawi(jawiTeks, rumiTeks, cache, opts = {}) {
175
+ const jawiTok = [...jawiTeks.matchAll(JAWI_TOKEN)].map((m) => m[0])
176
+ const rumiTok = [...rumiTeks.matchAll(RUMI_TOKEN)].map((m) => m[0].toLowerCase())
177
+
178
+ const salahEjaan = []
179
+ const tiadaRujukan = []
180
+ let padan = 0
181
+
182
+ for (const [i, rumi] of rumiTok.entries()) {
183
+ const ditulis = jawiTok[i] ?? null
184
+ const rec = await lookup(rumi, cache, opts)
185
+ const rasmi = rec.jawi
186
+ if (!rasmi) {
187
+ tiadaRujukan.push({
188
+ kedudukan: i,
189
+ rumi,
190
+ ditulis,
191
+ sebab: rec.nota ?? rec.ralat ?? 'tiada ejaan Jawi dalam PRPM',
192
+ })
193
+ } else if (ditulis === rasmi || (rec.jawiVarian ?? []).includes(ditulis)) {
194
+ padan++
195
+ } else {
196
+ salahEjaan.push({ kedudukan: i, rumi, ditulis, sepatutnya: rasmi, url: rec.url })
197
+ }
198
+ opts.onProgress?.(i + 1, rumiTok.length)
199
+ }
200
+
201
+ return {
202
+ tokenJawi: jawiTok.length,
203
+ tokenRumi: rumiTok.length,
204
+ kiraanTakSama: jawiTok.length !== rumiTok.length,
205
+ padan,
206
+ salahEjaan,
207
+ tiadaRujukan,
208
+ }
209
+ }
210
+
211
+ /** Bandingkan glosari rumi->jawi sedia ada dengan ejaan rasmi PRPM. */
212
+ export async function semakGlosari(glosari, cache, opts = {}) {
213
+ const bercanggah = []
214
+ const tiadaDalamPrpm = []
215
+ let sepadan = 0
216
+ const senarai = Object.entries(glosari)
217
+
218
+ for (const [i, [rumi, jawi]] of senarai.entries()) {
219
+ const rec = await lookup(rumi, cache, opts)
220
+ if (!rec.jawi) tiadaDalamPrpm.push(rumi)
221
+ else if (rec.jawi === jawi || (rec.jawiVarian ?? []).includes(jawi)) sepadan++
222
+ else bercanggah.push({ rumi, dalamGlosari: jawi, prpm: rec.jawi, url: rec.url })
223
+ opts.onProgress?.(i + 1, senarai.length)
224
+ }
225
+
226
+ return { jumlah: senarai.length, sepadan, bercanggah, tiadaDalamPrpm }
227
+ }