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 +21 -0
- package/README.md +133 -0
- package/SKILL.md +97 -0
- package/bin/cli.js +211 -0
- package/certs/geotrust-tls-rsa-ca-g1.pem +27 -0
- package/package.json +51 -0
- package/src/install.js +28 -0
- package/src/kamus.js +237 -0
- package/src/lemma.js +134 -0
- package/src/prpm.js +227 -0
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>... <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
|
+
}
|