tdk-api-wrapper 1.3.0 → 1.4.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/README.md +15 -7
- package/dist/{chunk-SLNXKZKR.mjs → chunk-5TYJDVHK.mjs} +695 -25
- package/dist/cli.js +728 -28
- package/dist/cli.mjs +41 -4
- package/dist/index.d.mts +113 -5
- package/dist/index.d.ts +113 -5
- package/dist/index.js +704 -27
- package/dist/index.mjs +17 -3
- package/package.json +3 -2
- package/src/cli.ts +40 -3
- package/src/index.ts +1 -0
- package/src/morphology.ts +214 -0
- package/src/tdk.ts +211 -21
- package/src/types.ts +11 -0
- package/test/morphology.test.js +105 -0
package/dist/index.mjs
CHANGED
|
@@ -2,11 +2,25 @@ import {
|
|
|
2
2
|
TDK,
|
|
3
3
|
TDKError,
|
|
4
4
|
TDKNetworkError,
|
|
5
|
-
TDKValidationError
|
|
6
|
-
|
|
5
|
+
TDKValidationError,
|
|
6
|
+
TURKISH_SUFFIXES,
|
|
7
|
+
TURKISH_VOWELS,
|
|
8
|
+
getStemCandidates,
|
|
9
|
+
isVowel,
|
|
10
|
+
restoreConsonantSoftening,
|
|
11
|
+
restoreInfinitive,
|
|
12
|
+
restoreVowelDrop
|
|
13
|
+
} from "./chunk-5TYJDVHK.mjs";
|
|
7
14
|
export {
|
|
8
15
|
TDK,
|
|
9
16
|
TDKError,
|
|
10
17
|
TDKNetworkError,
|
|
11
|
-
TDKValidationError
|
|
18
|
+
TDKValidationError,
|
|
19
|
+
TURKISH_SUFFIXES,
|
|
20
|
+
TURKISH_VOWELS,
|
|
21
|
+
getStemCandidates,
|
|
22
|
+
isVowel,
|
|
23
|
+
restoreConsonantSoftening,
|
|
24
|
+
restoreInfinitive,
|
|
25
|
+
restoreVowelDrop
|
|
12
26
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tdk-api-wrapper",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "TDK (Türk Dil Kurumu) unofficial live data API wrapper for Node.js",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean --shims"
|
|
19
|
+
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean --shims",
|
|
20
|
+
"test": "node test/morphology.test.js"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
22
23
|
"tdk",
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,9 @@ const KNOWN_COMMANDS = new Set([
|
|
|
12
12
|
"hece",
|
|
13
13
|
"uyum",
|
|
14
14
|
"yazim",
|
|
15
|
+
"kok",
|
|
16
|
+
"stem",
|
|
17
|
+
"deyim",
|
|
15
18
|
"gunun",
|
|
16
19
|
"rastgele",
|
|
17
20
|
"esanlam",
|
|
@@ -55,7 +58,7 @@ async function run() {
|
|
|
55
58
|
if (!command || command === "--help" || command === "-h") {
|
|
56
59
|
console.log("Kullanım: tdk [komut] <kelime> [--json]");
|
|
57
60
|
console.log(
|
|
58
|
-
"Komutlar: ara, anlam, koken, ornek, hece, uyum, yazim, gunun, rastgele, esanlam, karsit, yabanci, kurallar, kural, karsilastir, analiz, oneri, kubbealti, nisanyan, viki"
|
|
61
|
+
"Komutlar: ara, anlam, koken, ornek, hece, uyum, yazim, kok, deyim, gunun, rastgele, esanlam, karsit, yabanci, kurallar, kural, karsilastir, analiz, oneri, kubbealti, nisanyan, viki"
|
|
59
62
|
);
|
|
60
63
|
console.log("Not: Komut belirtilmezse doğrudan kelime anlamı aranır (örn: tdk selam)");
|
|
61
64
|
process.exit(command ? 0 : 1);
|
|
@@ -123,7 +126,11 @@ async function run() {
|
|
|
123
126
|
const spellResult = await TDK.checkSpelling(word);
|
|
124
127
|
printResult(spellResult, () => {
|
|
125
128
|
if (spellResult.isCorrect) {
|
|
126
|
-
|
|
129
|
+
if (spellResult.isInflected && spellResult.root) {
|
|
130
|
+
console.log(`Doğru yazım (çekimli biçim, kök: ${spellResult.root}).`);
|
|
131
|
+
} else {
|
|
132
|
+
console.log("Doğru yazım.");
|
|
133
|
+
}
|
|
127
134
|
} else {
|
|
128
135
|
console.log(`Yanlış yazım.${spellResult.suggestion ? " Doğrusu: " + spellResult.suggestion : ""}`);
|
|
129
136
|
}
|
|
@@ -131,6 +138,35 @@ async function run() {
|
|
|
131
138
|
break;
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
case "kok":
|
|
142
|
+
case "stem": {
|
|
143
|
+
if (!word) throw new Error("Kelime belirtmelisiniz.");
|
|
144
|
+
const stemResult = await TDK.stem(word);
|
|
145
|
+
printResult(stemResult, () => {
|
|
146
|
+
if (!stemResult) {
|
|
147
|
+
console.log("Kök bulunamadı.");
|
|
148
|
+
} else if (stemResult.isInflected) {
|
|
149
|
+
console.log(`Kök: ${stemResult.root} (çekimli biçim)`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(`Kök: ${stemResult.root} (yalın biçim)`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case "deyim": {
|
|
158
|
+
if (!word) throw new Error("Kelime belirtmelisiniz.");
|
|
159
|
+
const proverbs = await TDK.getProverbs(word);
|
|
160
|
+
printResult(proverbs, () => {
|
|
161
|
+
if (proverbs.length === 0) {
|
|
162
|
+
console.log("Atasözü/deyim bulunamadı.");
|
|
163
|
+
} else {
|
|
164
|
+
proverbs.forEach((p, i) => console.log(`${i + 1}. ${p}`));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
case "gunun": {
|
|
135
171
|
const wotd = await TDK.getWordOfTheDay();
|
|
136
172
|
printResult(wotd, () => {
|
|
@@ -239,7 +275,8 @@ async function run() {
|
|
|
239
275
|
} else {
|
|
240
276
|
analysis.forEach((a) => {
|
|
241
277
|
if (a.found) {
|
|
242
|
-
|
|
278
|
+
const rootLabel = a.isInflected && a.root ? ` (kök: ${a.root})` : "";
|
|
279
|
+
console.log(`${a.word}${rootLabel}: ${a.meaning ?? "-"} (${a.origin})`);
|
|
243
280
|
} else {
|
|
244
281
|
console.log(`${a.word}: bulunamadı`);
|
|
245
282
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turkish Morphology Engine & Stem Candidate Generator.
|
|
3
|
+
*
|
|
4
|
+
* Implements heuristic-based progressive suffix stripping (BFS) with:
|
|
5
|
+
* 1. Comprehensive Turkish suffix catalogue (inflectional, derivational, composite)
|
|
6
|
+
* 2. Reverse consonant mutation (ünsüz yumuşaması / sertleşmesi: b->p, c->ç, d->t, ğ->k, g->k)
|
|
7
|
+
* 3. Reverse vowel drop (ünlü düşmesi: akl->akıl, şehr->şehir, omz->omuz)
|
|
8
|
+
* 4. Infinitive restoration (-mek / -mak for verbal stems)
|
|
9
|
+
* 5. Apostrophe stripping for proper nouns (İstanbul'da -> İstanbul)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TURKISH_VOWELS = "aeıioöuü";
|
|
13
|
+
|
|
14
|
+
export function isVowel(ch: string): boolean {
|
|
15
|
+
return TURKISH_VOWELS.includes(ch);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Turkish suffixes ordered strictly by descending length so that longer
|
|
20
|
+
* composite suffixes match before their individual subcomponents.
|
|
21
|
+
*/
|
|
22
|
+
export const TURKISH_SUFFIXES: readonly string[] = [
|
|
23
|
+
// 9-letter composite suffixes
|
|
24
|
+
"lerimizden", "larımızdan", "lerinizden", "larınızdan",
|
|
25
|
+
// 8-letter composite suffixes
|
|
26
|
+
"lerinin", "larının", "lerinde", "larında", "lerinden", "larından",
|
|
27
|
+
"leriyle", "larıyla", "lerini", "larını", "lerimize", "larımıza",
|
|
28
|
+
"lerimizle", "larımızla", "lerinizin", "larınızın", "lerinizde", "larınızda",
|
|
29
|
+
"dığından", "diğinden", "duğundan", "düğünden", "tığından", "tiğinden", "tuğundan", "tüğünden",
|
|
30
|
+
// 7-letter composite suffixes
|
|
31
|
+
"ecektir", "acaktır", "eceğim", "acağım", "eceksin", "acaksın",
|
|
32
|
+
"eceğiz", "acağız", "lerimiz", "larımız", "leriniz", "larınız",
|
|
33
|
+
"umuzdan", "ümüzden", "inizden", "ınızdan", "ünüzden",
|
|
34
|
+
"dığında", "diğinde", "duğunda", "düğünde", "tığında", "tiğinde", "tuğunda", "tüğünde",
|
|
35
|
+
"masına", "mesine", "ıyorsunuz", "iyorsunuz", "uyorsunuz", "üyorsunuz", "yorsunuz",
|
|
36
|
+
// 6-letter composite suffixes
|
|
37
|
+
"iyorsa", "iyorduk", "iyordu", "iyormuş", "ıyorsa", "ıyorduk", "ıyordu", "ıyormuş",
|
|
38
|
+
"uyorsa", "uyorduk", "uyordu", "uyormuş", "üyorsa", "üyorduk", "üyordu", "üyormuş",
|
|
39
|
+
"ıyorsun", "iyorsun", "uyorsun", "üyorsun", "ıyorlar", "iyorlar", "uyorlar", "üyorlar",
|
|
40
|
+
"iyoruz", "ıyoruz", "uyoruz", "üyoruz",
|
|
41
|
+
"imizin", "ımızın", "umuzun", "ümüzün", "imizde", "ımızda", "umuzda", "ümüzde",
|
|
42
|
+
"imizden", "ımızdan", "imizle", "ımızla", "umuzla", "ümüzle",
|
|
43
|
+
"lerdir", "lardır", "muştur", "miştir", "muştur", "müştür",
|
|
44
|
+
"lerden", "lardan", "lerine", "larına", "leriyle", "larıyla",
|
|
45
|
+
"seniz", "sanız", "diniz", "dınız", "dunuz", "dünüz", "tiniz", "tınız", "tunuz", "tünüz",
|
|
46
|
+
"siniz", "sınız", "sunuz", "sünüz",
|
|
47
|
+
"dıkça", "dikçe", "dukça", "dükçe", "tıkça", "tikçe", "tukça", "tükçe",
|
|
48
|
+
"ırken", "irken", "urken", "ürken", "arken", "erken",
|
|
49
|
+
// 5-letter suffixes
|
|
50
|
+
"lerde", "larda", "lerle", "larla", "lerin", "ların", "lerim", "larım",
|
|
51
|
+
"dirler", "dırlar", "dürler", "durlar", "tirler", "tırlar", "türler", "turlar",
|
|
52
|
+
"siniz", "sınız", "sunuz", "sünüz", "yorum", "yorsun", "uyoruz", "yorsunuz", "yorlar",
|
|
53
|
+
"eceks", "acaks", "eyim", "ayım",
|
|
54
|
+
"indik", "ındık", "unduk", "ündük", "ildik", "ıldık", "ulduk", "üldük",
|
|
55
|
+
"meden", "madan", "yınız", "yiniz", "yunuz", "yünüz",
|
|
56
|
+
// 4-letter suffixes
|
|
57
|
+
"imiz", "ımız", "umuz", "ümüz", "iniz", "ınız", "unuz", "ünüz",
|
|
58
|
+
"leri", "ları", "idir", "ıdır", "udur", "üdür", "ecek", "acak",
|
|
59
|
+
"erek", "arak", "ince", "ınca", "unca", "ünce", "ken",
|
|
60
|
+
"meli", "malı", "iyor", "ıyor", "uyor", "üyor",
|
|
61
|
+
"mişti", "mıştı", "muştu", "müştü", "seydi", "saydı",
|
|
62
|
+
"ydim", "ydım", "ydum", "ydüm", "tiler", "tılar", "diler", "dılar",
|
|
63
|
+
"ikten", "ıktan", "uktan", "ükten",
|
|
64
|
+
// 3-letter suffixes
|
|
65
|
+
"ler", "lar", "den", "dan", "ten", "tan", "dir", "dır", "dur", "dür",
|
|
66
|
+
"tir", "tır", "tur", "tür", "nin", "nın", "nun", "nün", "yle", "yla",
|
|
67
|
+
"miş", "mış", "muş", "müş", "dim", "dım", "dum", "düm", "tim", "tım", "tum", "tüm",
|
|
68
|
+
"din", "dın", "dun", "dün", "tin", "tın", "tun", "tün", "dik", "dık", "duk", "dük",
|
|
69
|
+
"tik", "tık", "tuk", "tük", "ydi", "ydı", "ydu", "ydü", "yim", "yım", "yum", "yüm",
|
|
70
|
+
"sin", "sın", "sun", "sün", "siz", "sız", "suz", "süz", "lik", "lık", "luk", "lük",
|
|
71
|
+
"ici", "ıcı", "ucu", "ücü", "gen", "gan", "ken", "kan",
|
|
72
|
+
"len", "lan", "leş", "laş", "mek", "mak", "yor",
|
|
73
|
+
// 2-letter suffixes
|
|
74
|
+
"de", "da", "te", "ta", "im", "ım", "um", "üm", "in", "ın", "un", "ün",
|
|
75
|
+
"iz", "ız", "uz", "üz",
|
|
76
|
+
"si", "sı", "su", "sü", "ye", "ya", "le", "la", "di", "dı", "du", "dü",
|
|
77
|
+
"ti", "tı", "tu", "tü", "se", "sa", "ce", "ca", "çe", "ça", "me", "ma",
|
|
78
|
+
"ip", "ıp", "up", "üp", "en", "an", "iş", "ış", "uş", "üş",
|
|
79
|
+
"li", "lı", "lu", "lü", "ci", "cı", "cu", "cü", "çi", "çı", "çu", "çü",
|
|
80
|
+
// 1-letter suffixes (vowels / basic case endings)
|
|
81
|
+
"e", "a", "i", "ı", "u", "ü"
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reverses Turkish consonant softening (ünsüz yumuşaması):
|
|
86
|
+
* When a root ends with p, ç, t, k, it softens to b, c, d, ğ, g before a vowel.
|
|
87
|
+
* This restores the hardened dictionary headword form.
|
|
88
|
+
*/
|
|
89
|
+
export function restoreConsonantSoftening(stem: string): string[] {
|
|
90
|
+
if (stem.length < 2) return [];
|
|
91
|
+
const last = stem.slice(-1);
|
|
92
|
+
const base = stem.slice(0, -1);
|
|
93
|
+
switch (last) {
|
|
94
|
+
case "b": return [base + "p"];
|
|
95
|
+
case "c": return [base + "ç"];
|
|
96
|
+
case "d": return [base + "t"];
|
|
97
|
+
case "ğ": return [base + "k"];
|
|
98
|
+
case "g": return [base + "k"];
|
|
99
|
+
default: return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reverses Turkish vowel drop (ünlü düşmesi):
|
|
105
|
+
* In words like akıl->aklım, şehir->şehre, burun->burnu, omuz->omzum,
|
|
106
|
+
* the narrow vowel in the second syllable drops when receiving a vowel-initial suffix.
|
|
107
|
+
* This restores the harmonic dropped vowel between the final consonant cluster.
|
|
108
|
+
*/
|
|
109
|
+
export function restoreVowelDrop(stem: string): string[] {
|
|
110
|
+
if (stem.length < 3) return [];
|
|
111
|
+
const c1 = stem[stem.length - 2];
|
|
112
|
+
const c2 = stem[stem.length - 1];
|
|
113
|
+
if (!isVowel(c1) && !isVowel(c2)) {
|
|
114
|
+
// Look for the last vowel prior to the cluster
|
|
115
|
+
const vowelsInBase = stem.slice(0, -2).split("").filter(isVowel);
|
|
116
|
+
if (vowelsInBase.length > 0) {
|
|
117
|
+
const lastVowel = vowelsInBase[vowelsInBase.length - 1];
|
|
118
|
+
let inserted = "i";
|
|
119
|
+
if ("aı".includes(lastVowel)) inserted = "ı";
|
|
120
|
+
else if ("ei".includes(lastVowel)) inserted = "i";
|
|
121
|
+
else if ("ou".includes(lastVowel)) inserted = "u";
|
|
122
|
+
else if ("öü".includes(lastVowel)) inserted = "ü";
|
|
123
|
+
return [stem.slice(0, -1) + inserted + c2];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Restores verb infinitive headword form (-mek / -mak):
|
|
131
|
+
* Since TDK registers verbs in their infinitive form (e.g. okumak, gelmek, yazmak),
|
|
132
|
+
* conjugated verb stems (e.g. oku, gel, yaz) need -mak/-mek appended according to vowel harmony.
|
|
133
|
+
*/
|
|
134
|
+
export function restoreInfinitive(stem: string): string[] {
|
|
135
|
+
if (stem.length < 2) return [];
|
|
136
|
+
const vowelsInBase = stem.split("").filter(isVowel);
|
|
137
|
+
if (vowelsInBase.length === 0) return [];
|
|
138
|
+
const lastVowel = vowelsInBase[vowelsInBase.length - 1];
|
|
139
|
+
return "aıou".includes(lastVowel) ? [stem + "mak"] : [stem + "mek"];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generates candidate roots for a given Turkish word using progressive BFS suffix stripping,
|
|
144
|
+
* consonant mutation restoration, vowel drop restoration, and infinitive restoration.
|
|
145
|
+
*
|
|
146
|
+
* Candidates are sorted so that longer base stems (less aggressive stripping) are checked first,
|
|
147
|
+
* preventing spurious 2-letter roots from overshadowing genuine headwords.
|
|
148
|
+
*
|
|
149
|
+
* @param word The input word to analyze
|
|
150
|
+
* @param minStemLength Minimum allowed length for candidate stems (default: 2)
|
|
151
|
+
* @param maxDepth Maximum levels of progressive suffix stripping (default: 4)
|
|
152
|
+
* @returns Array of unique candidate roots in prioritized order
|
|
153
|
+
*/
|
|
154
|
+
export function getStemCandidates(
|
|
155
|
+
word: string,
|
|
156
|
+
minStemLength: number = 2,
|
|
157
|
+
maxDepth: number = 4
|
|
158
|
+
): string[] {
|
|
159
|
+
if (!word || word.trim().length === 0) return [];
|
|
160
|
+
|
|
161
|
+
const raw = word.trim();
|
|
162
|
+
const normalized = raw.toLocaleLowerCase("tr-TR");
|
|
163
|
+
|
|
164
|
+
const candidatesWithWeight: { candidate: string; baseLength: number }[] = [];
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
|
|
167
|
+
// If proper noun contains apostrophe (e.g. "İstanbul'da", "Ankara'dan"),
|
|
168
|
+
// the part before the apostrophe is an immediate high-priority candidate.
|
|
169
|
+
if (raw.includes("'") || raw.includes("’")) {
|
|
170
|
+
const apostropheStem = normalized.split(/['’]/)[0];
|
|
171
|
+
if (apostropheStem.length >= minStemLength) {
|
|
172
|
+
candidatesWithWeight.push({ candidate: apostropheStem, baseLength: apostropheStem.length + 10 });
|
|
173
|
+
seen.add(apostropheStem);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let frontier = [normalized];
|
|
178
|
+
|
|
179
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
180
|
+
const nextFrontier: string[] = [];
|
|
181
|
+
|
|
182
|
+
for (const current of frontier) {
|
|
183
|
+
for (const suffix of TURKISH_SUFFIXES) {
|
|
184
|
+
if (current.length - suffix.length >= minStemLength && current.endsWith(suffix)) {
|
|
185
|
+
const stem = current.slice(0, -suffix.length);
|
|
186
|
+
|
|
187
|
+
const hardened = restoreConsonantSoftening(stem);
|
|
188
|
+
const vowelDropped = restoreVowelDrop(stem);
|
|
189
|
+
// Infinitives only apply to direct stems or consonant-hardened stems (e.g. gid -> git -> gitmek),
|
|
190
|
+
// NOT to vowel-dropped nouns (nouns like akıl/omuz/şehir don't take infinitive -mek/-mak).
|
|
191
|
+
const verbalBases = [stem, ...hardened];
|
|
192
|
+
const infinitives = verbalBases.flatMap((v) => restoreInfinitive(v));
|
|
193
|
+
const variants = [stem, ...hardened, ...vowelDropped, ...infinitives];
|
|
194
|
+
|
|
195
|
+
for (const variant of variants) {
|
|
196
|
+
if (!seen.has(variant) && variant !== normalized) {
|
|
197
|
+
seen.add(variant);
|
|
198
|
+
nextFrontier.push(variant);
|
|
199
|
+
candidatesWithWeight.push({ candidate: variant, baseLength: stem.length });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (nextFrontier.length === 0) break;
|
|
207
|
+
frontier = nextFrontier;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Sort candidates by baseLength descending (longer stem = higher priority)
|
|
211
|
+
candidatesWithWeight.sort((a, b) => b.baseLength - a.baseLength);
|
|
212
|
+
|
|
213
|
+
return [...new Set(candidatesWithWeight.map((c) => c.candidate))];
|
|
214
|
+
}
|
package/src/tdk.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
WordInfo,
|
|
3
3
|
DailyContent,
|
|
4
4
|
SpellCheckResult,
|
|
5
|
+
StemResult,
|
|
5
6
|
WordOfTheDay,
|
|
6
7
|
DailyPick,
|
|
7
8
|
WordComparison,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
WiktionaryEntry,
|
|
12
13
|
} from "./types";
|
|
13
14
|
import { TDKValidationError, TDKNetworkError } from "./errors";
|
|
15
|
+
import { getStemCandidates } from "./morphology";
|
|
14
16
|
import * as fs from "node:fs";
|
|
15
17
|
import * as path from "node:path";
|
|
16
18
|
import * as os from "node:os";
|
|
@@ -107,6 +109,8 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
107
109
|
private static wordCache = new Map<string, WordInfo[]>();
|
|
108
110
|
private static dailyContentCache: DailyContent | null = null;
|
|
109
111
|
private static autocompleteCache: string[] = [];
|
|
112
|
+
private static autocompleteSet: Set<string> = new Set<string>();
|
|
113
|
+
private static stemCache = new Map<string, string | null>();
|
|
110
114
|
|
|
111
115
|
/**
|
|
112
116
|
* Enables or disables in-memory caching for API requests.
|
|
@@ -125,6 +129,8 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
125
129
|
this.wordCache.clear();
|
|
126
130
|
this.dailyContentCache = null;
|
|
127
131
|
this.autocompleteCache = [];
|
|
132
|
+
this.autocompleteSet.clear();
|
|
133
|
+
this.stemCache.clear();
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
private static delay(ms: number) {
|
|
@@ -241,6 +247,18 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
241
247
|
}
|
|
242
248
|
}
|
|
243
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Ensures TDK's ~81k headword list is loaded in memory for fast O(1) set operations.
|
|
252
|
+
*/
|
|
253
|
+
private static async ensureAutocompleteLoaded(): Promise<void> {
|
|
254
|
+
if (this.autocompleteCache.length === 0) {
|
|
255
|
+
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
256
|
+
this.autocompleteSet = new Set(
|
|
257
|
+
this.autocompleteCache.map((w) => w.toLocaleLowerCase("tr-TR"))
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
244
262
|
/**
|
|
245
263
|
* Returns autocomplete suggestions for a given prefix, searched over TDK's
|
|
246
264
|
* full headword list (see `fetchAutocompleteData`). The list is fetched
|
|
@@ -250,9 +268,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
250
268
|
public static async getSuggestions(prefix: string): Promise<string[]> {
|
|
251
269
|
if (!prefix || prefix.trim() === "") return [];
|
|
252
270
|
|
|
253
|
-
|
|
254
|
-
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
255
|
-
}
|
|
271
|
+
await this.ensureAutocompleteLoaded();
|
|
256
272
|
|
|
257
273
|
const cleanPrefix = prefix.trim().toLocaleLowerCase("tr-TR");
|
|
258
274
|
return this.autocompleteCache
|
|
@@ -260,6 +276,88 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
260
276
|
.slice(0, 10);
|
|
261
277
|
}
|
|
262
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Checks whether a word exists as a known headword in TDK dictionary.
|
|
281
|
+
* Checks in-memory autocompleteSet (81k headwords) if loaded, or queries TDK API.
|
|
282
|
+
*/
|
|
283
|
+
public static async isHeadword(word: string): Promise<boolean> {
|
|
284
|
+
if (!word || word.trim() === "") return false;
|
|
285
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
286
|
+
|
|
287
|
+
await this.ensureAutocompleteLoaded();
|
|
288
|
+
if (this.autocompleteSet.size > 0) {
|
|
289
|
+
return this.autocompleteSet.has(clean);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const results = await this.getWord(clean);
|
|
294
|
+
return results.length > 0;
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Generates candidate roots for a given Turkish word using progressive BFS suffix stripping,
|
|
302
|
+
* consonant mutation restoration, and vowel drop restoration.
|
|
303
|
+
*/
|
|
304
|
+
public static getStemCandidates(word: string): string[] {
|
|
305
|
+
return getStemCandidates(word);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Finds the dictionary root (headword) of a word by checking direct existence
|
|
310
|
+
* and evaluating candidate stems generated by morphological analysis.
|
|
311
|
+
* Returns the root headword string if found, or null if no match in TDK.
|
|
312
|
+
*/
|
|
313
|
+
public static async findRoot(word: string): Promise<string | null> {
|
|
314
|
+
if (!word || word.trim() === "") return null;
|
|
315
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
316
|
+
|
|
317
|
+
if (this.stemCache.has(clean)) {
|
|
318
|
+
return this.stemCache.get(clean)!;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 1. If the word itself is an exact headword, it is its own root
|
|
322
|
+
if (await this.isHeadword(clean)) {
|
|
323
|
+
this.stemCache.set(clean, clean);
|
|
324
|
+
return clean;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 2. Test morphological stem candidates
|
|
328
|
+
const candidates = getStemCandidates(clean);
|
|
329
|
+
for (const candidate of candidates) {
|
|
330
|
+
if (await this.isHeadword(candidate)) {
|
|
331
|
+
this.stemCache.set(clean, candidate);
|
|
332
|
+
return candidate;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.stemCache.set(clean, null);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Performs morphological stemming on a Turkish word.
|
|
342
|
+
* Returns a StemResult containing the original word, resolved root, and whether it is inflected.
|
|
343
|
+
*/
|
|
344
|
+
public static async stem(word: string): Promise<StemResult | null> {
|
|
345
|
+
if (!word || word.trim() === "") return null;
|
|
346
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
347
|
+
const root = await this.findRoot(word);
|
|
348
|
+
|
|
349
|
+
if (!root) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
word,
|
|
355
|
+
root,
|
|
356
|
+
isInflected: root !== clean,
|
|
357
|
+
candidates: getStemCandidates(word),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
263
361
|
/**
|
|
264
362
|
* Returns a list of proverbs and idioms containing the word.
|
|
265
363
|
*/
|
|
@@ -486,7 +584,21 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
486
584
|
}
|
|
487
585
|
}
|
|
488
586
|
|
|
489
|
-
// 3.
|
|
587
|
+
// 3. Morphology Fallback: Check if the word is an inflected form of a known headword
|
|
588
|
+
// (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk")
|
|
589
|
+
const root = await this.findRoot(word);
|
|
590
|
+
if (root) {
|
|
591
|
+
const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
|
|
592
|
+
const isInflected = root !== cleanWord;
|
|
593
|
+
return {
|
|
594
|
+
isCorrect: true,
|
|
595
|
+
word,
|
|
596
|
+
isInflected,
|
|
597
|
+
root,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 4. No exact match or morphology root: fall back to the closest
|
|
490
602
|
// headword (by edit distance) across TDK's full ~81k-word list (the same
|
|
491
603
|
// data `getSuggestions()` uses). Restricted to single-token, lowercase
|
|
492
604
|
// headwords so it doesn't suggest compounds/phrases or proper nouns.
|
|
@@ -713,20 +825,65 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
713
825
|
});
|
|
714
826
|
}
|
|
715
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Kubbealtı indexes headwords with full classical Turkish orthography,
|
|
830
|
+
* including letters that a plain-ASCII-ish query tends to drop — most
|
|
831
|
+
* commonly ü/ö/ç/ğ/ş, but also the circumflex ("düzeltme işareti") used in
|
|
832
|
+
* Arabic/Persian loanwords like "rüzgâr". A search for "ruzgar" misses
|
|
833
|
+
* entirely (verified: even "ruzgâr" alone still misses — it's the missing
|
|
834
|
+
* ü, not the missing â, that actually breaks the match). This generates
|
|
835
|
+
* single-letter-substitution variants to retry, one substitution per
|
|
836
|
+
* variant (not combinatorial) — covers the overwhelmingly common case of
|
|
837
|
+
* one "de-Turkished" letter without an explosion of API calls for words
|
|
838
|
+
* with several.
|
|
839
|
+
*/
|
|
840
|
+
private static readonly TURKISH_DEASCII_MAP: Record<string, string[]> = {
|
|
841
|
+
a: ["â"],
|
|
842
|
+
i: ["ı", "î"],
|
|
843
|
+
o: ["ö"],
|
|
844
|
+
u: ["ü", "û"],
|
|
845
|
+
c: ["ç"],
|
|
846
|
+
g: ["ğ"],
|
|
847
|
+
s: ["ş"],
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
private static generateTurkishVariants(word: string): string[] {
|
|
851
|
+
const lower = word.trim().toLocaleLowerCase("tr-TR");
|
|
852
|
+
const variants: string[] = [];
|
|
853
|
+
for (let i = 0; i < lower.length; i++) {
|
|
854
|
+
for (const replacement of this.TURKISH_DEASCII_MAP[lower[i]] ?? []) {
|
|
855
|
+
variants.push(lower.slice(0, i) + replacement + lower.slice(i + 1));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return variants;
|
|
859
|
+
}
|
|
860
|
+
|
|
716
861
|
/**
|
|
717
862
|
* Returns Kubbealtı Lugatı ("Misalli Büyük Türkçe Sözlük") entries for a
|
|
718
863
|
* word, scraped from the site's own data API — undocumented, and Kubbealtı
|
|
719
864
|
* Lugatı is a commercial dictionary product, unlike TDK's or Wiktionary's
|
|
720
865
|
* openly-published data, so use this in line with their terms. `anlam` is
|
|
721
866
|
* raw HTML (rich typography markup); use `getKubbealtiMeanings()` for
|
|
722
|
-
* plain text.
|
|
723
|
-
*
|
|
867
|
+
* plain text. Falls back to `generateTurkishVariants()` if the exact query
|
|
868
|
+
* comes up empty (see its doc comment). Returns `null` on any fetch/parse
|
|
869
|
+
* failure, `[]` if no variant matches either.
|
|
724
870
|
*/
|
|
725
871
|
public static async getKubbealti(word: string): Promise<KubbealtiEntry[] | null> {
|
|
726
872
|
if (!word || word.trim() === "") return null;
|
|
873
|
+
|
|
727
874
|
const data = await this.fetchKubbealtiJson(`/rest/s/${encodeURIComponent(word.trim())}/`);
|
|
728
875
|
if (!data || !Array.isArray(data.content)) return null;
|
|
729
|
-
|
|
876
|
+
if (data.content.length > 0) {
|
|
877
|
+
return data.content.map((entry: any) => ({ kelime: entry.kelime, anlam: entry.anlam }));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
for (const variant of this.generateTurkishVariants(word)) {
|
|
881
|
+
const variantData = await this.fetchKubbealtiJson(`/rest/s/${encodeURIComponent(variant)}/`);
|
|
882
|
+
if (variantData && Array.isArray(variantData.content) && variantData.content.length > 0) {
|
|
883
|
+
return variantData.content.map((entry: any) => ({ kelime: entry.kelime, anlam: entry.anlam }));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return [];
|
|
730
887
|
}
|
|
731
888
|
|
|
732
889
|
/**
|
|
@@ -777,20 +934,10 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
777
934
|
}
|
|
778
935
|
}
|
|
779
936
|
|
|
780
|
-
|
|
781
|
-
* Returns the Turkish Wiktionary (`tr.wiktionary.org`) entry for a word,
|
|
782
|
-
* via MediaWiki's official Action API (`action=query&prop=extracts`) — no
|
|
783
|
-
* scraping involved, this is a stable, documented public API. `sections`
|
|
784
|
-
* splits the plain-text extract on its `== Heading ==`/`=== Heading ===`
|
|
785
|
-
* markers (e.g. "Köken", "Söyleniş", "Ad") for convenience; `raw` has the
|
|
786
|
-
* unsplit text. Returns `null` if the page doesn't exist or the request
|
|
787
|
-
* fails.
|
|
788
|
-
*/
|
|
789
|
-
public static async getWiktionary(word: string): Promise<WiktionaryEntry | null> {
|
|
790
|
-
if (!word || word.trim() === "") return null;
|
|
937
|
+
private static async fetchWiktionaryEntry(title: string): Promise<WiktionaryEntry | null> {
|
|
791
938
|
try {
|
|
792
939
|
const url = `https://tr.wiktionary.org/w/api.php?action=query&prop=extracts&titles=${encodeURIComponent(
|
|
793
|
-
|
|
940
|
+
title
|
|
794
941
|
)}&format=json&explaintext=1&formatversion=2`;
|
|
795
942
|
const response = await fetch(url, { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } });
|
|
796
943
|
if (!response.ok) return null;
|
|
@@ -814,6 +961,32 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
814
961
|
}
|
|
815
962
|
}
|
|
816
963
|
|
|
964
|
+
/**
|
|
965
|
+
* Returns the Turkish Wiktionary (`tr.wiktionary.org`) entry for a word,
|
|
966
|
+
* via MediaWiki's official Action API (`action=query&prop=extracts`) — no
|
|
967
|
+
* scraping involved, this is a stable, documented public API. `sections`
|
|
968
|
+
* splits the plain-text extract on its `== Heading ==`/`=== Heading ===`
|
|
969
|
+
* markers (e.g. "Köken", "Söyleniş", "Ad") for convenience; `raw` has the
|
|
970
|
+
* unsplit text. This wiki has title capitalization turned off
|
|
971
|
+
* ($wgCapitalLinks=false — common for Wiktionaries, since case is
|
|
972
|
+
* meaningful for a dictionary: "Türkiye" the country vs. a lowercase
|
|
973
|
+
* common word), so an exact-case miss retries with the first letter
|
|
974
|
+
* uppercased (Turkish-locale-aware, so "istanbul" tries "İstanbul", not
|
|
975
|
+
* "Istanbul") before giving up. Returns `null` if neither is found or the
|
|
976
|
+
* request fails.
|
|
977
|
+
*/
|
|
978
|
+
public static async getWiktionary(word: string): Promise<WiktionaryEntry | null> {
|
|
979
|
+
if (!word || word.trim() === "") return null;
|
|
980
|
+
const trimmed = word.trim();
|
|
981
|
+
|
|
982
|
+
const direct = await this.fetchWiktionaryEntry(trimmed);
|
|
983
|
+
if (direct) return direct;
|
|
984
|
+
|
|
985
|
+
const capitalized = trimmed.charAt(0).toLocaleUpperCase("tr-TR") + trimmed.slice(1);
|
|
986
|
+
if (capitalized === trimmed) return null;
|
|
987
|
+
return this.fetchWiktionaryEntry(capitalized);
|
|
988
|
+
}
|
|
989
|
+
|
|
817
990
|
/**
|
|
818
991
|
* Convenience filter over `getWiktionary()`: returns just one section's
|
|
819
992
|
* text (e.g. `getWiktionarySection(word, "Köken")` for etymology), matched
|
|
@@ -936,13 +1109,30 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
936
1109
|
|
|
937
1110
|
const analyses: WordAnalysis[] = [];
|
|
938
1111
|
for (const word of unique) {
|
|
939
|
-
|
|
940
|
-
|
|
1112
|
+
let results = await this.getWord(word);
|
|
1113
|
+
let found = results.length > 0;
|
|
1114
|
+
let root: string | undefined;
|
|
1115
|
+
let isInflected: boolean | undefined;
|
|
1116
|
+
|
|
1117
|
+
if (!found) {
|
|
1118
|
+
const resolvedRoot = await this.findRoot(word);
|
|
1119
|
+
if (resolvedRoot) {
|
|
1120
|
+
results = await this.getWord(resolvedRoot);
|
|
1121
|
+
if (results.length > 0) {
|
|
1122
|
+
found = true;
|
|
1123
|
+
root = resolvedRoot;
|
|
1124
|
+
isInflected = true;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
941
1129
|
analyses.push({
|
|
942
1130
|
word,
|
|
943
1131
|
found,
|
|
944
1132
|
meaning: found ? this.firstMeaning(results) : null,
|
|
945
1133
|
origin: found ? results[0].lisan || "Türkçe" : null,
|
|
1134
|
+
root,
|
|
1135
|
+
isInflected,
|
|
946
1136
|
});
|
|
947
1137
|
await this.delay(200);
|
|
948
1138
|
}
|