tdk-api-wrapper 1.0.1 → 1.2.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 +53 -6
- package/dist/chunk-2TA5PMVZ.mjs +741 -0
- package/dist/cli.js +683 -85
- package/dist/cli.mjs +222 -37
- package/dist/index.d.mts +173 -5
- package/dist/index.d.ts +173 -5
- package/dist/index.js +471 -52
- package/dist/index.mjs +9 -3
- package/package.json +1 -1
- package/src/cli.ts +227 -36
- package/src/errors.ts +38 -0
- package/src/index.ts +1 -0
- package/src/tdk.ts +449 -61
- package/src/types.ts +36 -0
- package/dist/chunk-MGSXCUAX.mjs +0 -325
package/src/tdk.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
WordInfo,
|
|
3
|
+
DailyContent,
|
|
4
|
+
SpellCheckResult,
|
|
5
|
+
WordOfTheDay,
|
|
6
|
+
DailyPick,
|
|
7
|
+
WordComparison,
|
|
8
|
+
WordAnalysis,
|
|
9
|
+
TDKRule,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import { TDKValidationError, TDKNetworkError } from "./errors";
|
|
2
12
|
import * as fs from "node:fs";
|
|
3
13
|
import * as path from "node:path";
|
|
4
14
|
import * as os from "node:os";
|
|
15
|
+
import * as https from "node:https";
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
18
|
* TDK (Türk Dil Kurumu) API Wrapper
|
|
8
19
|
*/
|
|
9
20
|
export class TDK {
|
|
10
21
|
private static readonly BASE_URL = "https://sozluk.gov.tr";
|
|
11
|
-
|
|
22
|
+
private static readonly AUDIO_API_HOST = "api.sozluk.gov.tr";
|
|
23
|
+
|
|
12
24
|
// Cache Mechanism
|
|
13
25
|
private static isCacheEnabled = false;
|
|
14
26
|
private static wordCache = new Map<string, WordInfo[]>();
|
|
@@ -43,10 +55,10 @@ export class TDK {
|
|
|
43
55
|
*/
|
|
44
56
|
public static async getWord(word: string): Promise<WordInfo[]> {
|
|
45
57
|
if (!word || word.trim() === "") {
|
|
46
|
-
throw new
|
|
58
|
+
throw new TDKValidationError("Word parameter cannot be empty.");
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
const cleanWord = word.trim().
|
|
61
|
+
const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
|
|
50
62
|
|
|
51
63
|
if (this.isCacheEnabled && this.wordCache.has(cleanWord)) {
|
|
52
64
|
return this.wordCache.get(cleanWord)!;
|
|
@@ -54,28 +66,38 @@ export class TDK {
|
|
|
54
66
|
|
|
55
67
|
const url = `${this.BASE_URL}/gts?ara=${encodeURIComponent(cleanWord)}`;
|
|
56
68
|
|
|
69
|
+
let response: Response;
|
|
57
70
|
try {
|
|
58
|
-
|
|
71
|
+
response = await fetch(url, {
|
|
59
72
|
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
60
73
|
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new TDKNetworkError("Failed to fetch word from TDK: request failed.", { cause: error });
|
|
76
|
+
}
|
|
61
77
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return [];
|
|
68
|
-
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new TDKNetworkError(`Failed to fetch word from TDK: HTTP ${response.status}.`, {
|
|
80
|
+
status: response.status,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
return results;
|
|
84
|
+
let data: unknown;
|
|
85
|
+
try {
|
|
86
|
+
data = await response.json();
|
|
75
87
|
} catch (error) {
|
|
76
|
-
|
|
77
|
-
throw new Error("Failed to fetch word from TDK: Unknown error");
|
|
88
|
+
throw new TDKNetworkError("Failed to fetch word from TDK: invalid JSON response.", { cause: error });
|
|
78
89
|
}
|
|
90
|
+
|
|
91
|
+
if (!Array.isArray(data) && data && "error" in (data as Record<string, unknown>)) {
|
|
92
|
+
if (this.isCacheEnabled) this.wordCache.set(cleanWord, []);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const results = data as WordInfo[];
|
|
97
|
+
if (this.isCacheEnabled) {
|
|
98
|
+
this.wordCache.set(cleanWord, results);
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/**
|
|
@@ -97,26 +119,63 @@ export class TDK {
|
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
/**
|
|
100
|
-
*
|
|
122
|
+
* `sozluk.gov.tr`'s dedicated `/autocomplete.json` (and `/data/autocomplete.json`)
|
|
123
|
+
* routes no longer serve JSON — they fall through to the SPA's `index.html`.
|
|
124
|
+
* The full ~81k-word headword list the site's own autocomplete UI uses is
|
|
125
|
+
* instead bundled directly into its main JS asset as a
|
|
126
|
+
* `JSON.parse(\`[{"madde":"..."}]\`)` literal, so this fetches the home
|
|
127
|
+
* page to find that asset's current hashed filename, downloads it (a few
|
|
128
|
+
* MB, only once per process), and extracts the literal out of it. Fragile
|
|
129
|
+
* scraping of an implementation detail — if TDK's build stops embedding
|
|
130
|
+
* this, this fails closed to `[]` rather than throwing.
|
|
131
|
+
*/
|
|
132
|
+
private static async fetchAutocompleteData(): Promise<string[]> {
|
|
133
|
+
try {
|
|
134
|
+
const homeResponse = await fetch(`${this.BASE_URL}/`, {
|
|
135
|
+
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
136
|
+
});
|
|
137
|
+
if (!homeResponse.ok) return [];
|
|
138
|
+
const html = await homeResponse.text();
|
|
139
|
+
|
|
140
|
+
const scriptMatch = html.match(/src="(\/assets\/index-[^"]+\.js)"/);
|
|
141
|
+
if (!scriptMatch) return [];
|
|
142
|
+
|
|
143
|
+
const bundleResponse = await fetch(`${this.BASE_URL}${scriptMatch[1]}`, {
|
|
144
|
+
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
145
|
+
});
|
|
146
|
+
if (!bundleResponse.ok) return [];
|
|
147
|
+
const bundleJs = await bundleResponse.text();
|
|
148
|
+
|
|
149
|
+
const startMarker = 'JSON.parse(`[{"madde":';
|
|
150
|
+
const startIdx = bundleJs.indexOf(startMarker);
|
|
151
|
+
if (startIdx === -1) return [];
|
|
152
|
+
const jsonStart = startIdx + "JSON.parse(".length + 1;
|
|
153
|
+
const jsonEnd = bundleJs.indexOf("`)", jsonStart);
|
|
154
|
+
if (jsonEnd === -1) return [];
|
|
155
|
+
|
|
156
|
+
const data = JSON.parse(bundleJs.slice(jsonStart, jsonEnd)) as { madde: string }[];
|
|
157
|
+
return data.map((item) => item.madde).filter(Boolean);
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns autocomplete suggestions for a given prefix, searched over TDK's
|
|
165
|
+
* full headword list (see `fetchAutocompleteData`). The list is fetched
|
|
166
|
+
* and cached once per process regardless of `enableCache()` — the same
|
|
167
|
+
* caching behavior as before — and only cleared by `clearCache()`.
|
|
101
168
|
*/
|
|
102
169
|
public static async getSuggestions(prefix: string): Promise<string[]> {
|
|
170
|
+
if (!prefix || prefix.trim() === "") return [];
|
|
171
|
+
|
|
103
172
|
if (this.autocompleteCache.length === 0) {
|
|
104
|
-
|
|
105
|
-
const response = await fetch(`${this.BASE_URL}/autocomplete.json`, {
|
|
106
|
-
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
107
|
-
});
|
|
108
|
-
if (response.ok) {
|
|
109
|
-
const data = await response.json() as { madde: string }[];
|
|
110
|
-
this.autocompleteCache = data.map(item => item.madde);
|
|
111
|
-
}
|
|
112
|
-
} catch (e) {
|
|
113
|
-
return [];
|
|
114
|
-
}
|
|
173
|
+
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
115
174
|
}
|
|
116
|
-
|
|
117
|
-
const cleanPrefix = prefix.
|
|
175
|
+
|
|
176
|
+
const cleanPrefix = prefix.trim().toLocaleLowerCase("tr-TR");
|
|
118
177
|
return this.autocompleteCache
|
|
119
|
-
.filter(w => w.
|
|
178
|
+
.filter(w => w.toLocaleLowerCase("tr-TR").startsWith(cleanPrefix))
|
|
120
179
|
.slice(0, 10);
|
|
121
180
|
}
|
|
122
181
|
|
|
@@ -139,14 +198,39 @@ export class TDK {
|
|
|
139
198
|
}
|
|
140
199
|
|
|
141
200
|
/**
|
|
142
|
-
* Returns the etymological origin of the word
|
|
201
|
+
* Returns the etymological origin of the word, or "Türkçe" if TDK doesn't
|
|
202
|
+
* record a foreign origin for it. Returns `null` only when the word itself
|
|
203
|
+
* isn't found in the dictionary at all.
|
|
143
204
|
*/
|
|
144
205
|
public static async getOrigin(word: string): Promise<string | null> {
|
|
145
206
|
const results = await this.getWord(word);
|
|
146
|
-
if (results.length
|
|
147
|
-
|
|
207
|
+
if (results.length === 0) return null;
|
|
208
|
+
return results[0].lisan || "Türkçe";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returns whether the word has a recorded foreign etymological origin.
|
|
213
|
+
* Returns `null` (instead of a boolean) when the word isn't found at all.
|
|
214
|
+
*/
|
|
215
|
+
public static async isForeignWord(word: string): Promise<boolean | null> {
|
|
216
|
+
const origin = await this.getOrigin(word);
|
|
217
|
+
if (origin === null) return null;
|
|
218
|
+
return origin !== "Türkçe";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Groups a list of words by their etymological origin. Words not found in
|
|
223
|
+
* the dictionary are grouped under "Bilinmiyor". Throttled like getWordsBatch.
|
|
224
|
+
*/
|
|
225
|
+
public static async groupByOrigin(words: string[]): Promise<Record<string, string[]>> {
|
|
226
|
+
const groups: Record<string, string[]> = {};
|
|
227
|
+
for (const word of words) {
|
|
228
|
+
const origin = (await this.getOrigin(word)) ?? "Bilinmiyor";
|
|
229
|
+
if (!groups[origin]) groups[origin] = [];
|
|
230
|
+
groups[origin].push(word);
|
|
231
|
+
await this.delay(200);
|
|
148
232
|
}
|
|
149
|
-
return
|
|
233
|
+
return groups;
|
|
150
234
|
}
|
|
151
235
|
|
|
152
236
|
/**
|
|
@@ -172,23 +256,109 @@ export class TDK {
|
|
|
172
256
|
}
|
|
173
257
|
|
|
174
258
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
259
|
+
* Calls the `api.sozluk.gov.tr/gts-yeni` endpoint the official web UI uses
|
|
260
|
+
* internally (richer than the public `/gts`: includes `seskod`,
|
|
261
|
+
* `anlamEsAnlam`/`anlamKarsitAnlam`, etc). That endpoint 403s unless the
|
|
262
|
+
* request looks like it came from a browser tab on sozluk.gov.tr: it needs
|
|
263
|
+
* an `Origin`/`Referer` pair matching that site AND a browser-like
|
|
264
|
+
* `User-Agent` (our usual `TDK-API-Nodejs-Wrapper/…` UA gets rejected).
|
|
265
|
+
* `fetch` (undici) also strips a manually-set `Origin` header as a
|
|
266
|
+
* forbidden header name, so this uses `node:https` directly instead.
|
|
267
|
+
* This is inherently fragile scraping of an undocumented endpoint — if
|
|
268
|
+
* TDK tightens this check further, this should fail closed to `null`
|
|
269
|
+
* rather than throw.
|
|
270
|
+
*/
|
|
271
|
+
private static fetchGtsYeni(word: string): Promise<any[] | null> {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const req = https.request(
|
|
274
|
+
{
|
|
275
|
+
hostname: this.AUDIO_API_HOST,
|
|
276
|
+
path: `/gts-yeni?ara=${encodeURIComponent(word)}`,
|
|
277
|
+
method: "GET",
|
|
278
|
+
headers: {
|
|
279
|
+
"User-Agent":
|
|
280
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
281
|
+
Origin: this.BASE_URL,
|
|
282
|
+
Referer: `${this.BASE_URL}/`,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
(res) => {
|
|
286
|
+
let body = "";
|
|
287
|
+
res.on("data", (chunk) => (body += chunk));
|
|
288
|
+
res.on("end", () => {
|
|
289
|
+
try {
|
|
290
|
+
const data = JSON.parse(body);
|
|
291
|
+
resolve(Array.isArray(data) ? data : null);
|
|
292
|
+
} catch {
|
|
293
|
+
resolve(null);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
req.on("error", () => resolve(null));
|
|
299
|
+
req.end();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private static async fetchSeskod(word: string): Promise<string | null> {
|
|
304
|
+
const data = await this.fetchGtsYeni(word);
|
|
305
|
+
const seskod = data?.[0]?.seskod;
|
|
306
|
+
return seskod ? String(seskod) : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Returns synonyms ("eş anlamlı kelimeler") recorded for the word, pooled
|
|
311
|
+
* across all of its meanings. Uses the same undocumented `gts-yeni`
|
|
312
|
+
* endpoint as `getAudioUrl` — returns `[]` if the lookup fails.
|
|
313
|
+
*/
|
|
314
|
+
public static async getSynonyms(word: string): Promise<string[]> {
|
|
315
|
+
if (!word || word.trim() === "") return [];
|
|
316
|
+
const data = await this.fetchGtsYeni(word.trim().toLocaleLowerCase("tr-TR"));
|
|
317
|
+
if (!data) return [];
|
|
318
|
+
|
|
319
|
+
const synonyms: string[] = [];
|
|
320
|
+
for (const entry of data) {
|
|
321
|
+
for (const anlam of entry.anlamlarListe ?? []) {
|
|
322
|
+
for (const es of anlam.anlamEsAnlam ?? []) {
|
|
323
|
+
if (es.deger) synonyms.push(es.deger);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [...new Set(synonyms)];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Returns antonyms ("zıt anlamlı kelimeler") recorded for the word, pooled
|
|
332
|
+
* across all of its meanings. Uses the same undocumented `gts-yeni`
|
|
333
|
+
* endpoint as `getAudioUrl` — returns `[]` if the lookup fails.
|
|
334
|
+
*/
|
|
335
|
+
public static async getAntonyms(word: string): Promise<string[]> {
|
|
336
|
+
if (!word || word.trim() === "") return [];
|
|
337
|
+
const data = await this.fetchGtsYeni(word.trim().toLocaleLowerCase("tr-TR"));
|
|
338
|
+
if (!data) return [];
|
|
339
|
+
|
|
340
|
+
const antonyms: string[] = [];
|
|
341
|
+
for (const entry of data) {
|
|
342
|
+
for (const anlam of entry.anlamlarListe ?? []) {
|
|
343
|
+
for (const ka of anlam.anlamKarsitAnlam ?? []) {
|
|
344
|
+
if (ka.deger) antonyms.push(ka.deger);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return [...new Set(antonyms)];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Returns the direct URL of the audio pronunciation, if TDK has one recorded for this word.
|
|
177
353
|
*/
|
|
178
354
|
public static async getAudioUrl(word: string): Promise<string | null> {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// For simplicity without reversing their full hash, we provide a placeholder or return a pattern.
|
|
182
|
-
// However, if we assume 'ses/' + word + '.wav' works (it doesn't usually), we can just say it's not fully public.
|
|
183
|
-
// Since we must implement this, we'll try a common pattern.
|
|
184
|
-
if (results.length > 0) {
|
|
185
|
-
// Actually, TDK audio endpoint is often: https://sozluk.gov.tr/ses/ + md5(word) + .wav
|
|
186
|
-
// We will just return null for now if TDK has restricted audio access, but let's implement the interface.
|
|
187
|
-
// We'll return a hypothetical audio link based on standard TDK audio patterns.
|
|
188
|
-
// Wait, TDK audio uses 'yazim?ara=' sometimes or 'ses/'. Let's return a basic structure.
|
|
189
|
-
return `https://sozluk.gov.tr/ses/${encodeURIComponent(word)}.wav`;
|
|
355
|
+
if (!word || word.trim() === "") {
|
|
356
|
+
throw new TDKValidationError("Word parameter cannot be empty.");
|
|
190
357
|
}
|
|
191
|
-
|
|
358
|
+
|
|
359
|
+
const seskod = await this.fetchSeskod(word.trim().toLocaleLowerCase("tr-TR"));
|
|
360
|
+
if (!seskod) return null;
|
|
361
|
+
return `https://${this.AUDIO_API_HOST}/ses/${encodeURIComponent(seskod)}.wav`;
|
|
192
362
|
}
|
|
193
363
|
|
|
194
364
|
/**
|
|
@@ -223,14 +393,34 @@ export class TDK {
|
|
|
223
393
|
// 2. If not, check "sıkça yapılan yanlışlar" from DailyContent
|
|
224
394
|
const daily = await this.getDailyContent();
|
|
225
395
|
if (daily) {
|
|
226
|
-
const syydMatch = daily.syyd.find(s => s.yanliskelime.
|
|
396
|
+
const syydMatch = daily.syyd.find(s => s.yanliskelime.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
|
|
227
397
|
if (syydMatch) {
|
|
228
398
|
return { isCorrect: false, word, suggestion: syydMatch.dogrukelime };
|
|
229
399
|
}
|
|
230
|
-
const mixMatch = daily.karistirma.find(s => s.yanlis.
|
|
400
|
+
const mixMatch = daily.karistirma.find(s => s.yanlis.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
|
|
231
401
|
if (mixMatch) {
|
|
232
402
|
return { isCorrect: false, word, suggestion: mixMatch.dogru };
|
|
233
403
|
}
|
|
404
|
+
|
|
405
|
+
// 3. No exact match in TDK's fixed lists: fall back to the closest word
|
|
406
|
+
// (by edit distance) within that same small pool. This is NOT a search
|
|
407
|
+
// over the full dictionary — TDK exposes no such lookup — just a
|
|
408
|
+
// best-effort nudge using the "sık yapılan yanlışlar" data we already have.
|
|
409
|
+
const candidates = [
|
|
410
|
+
...daily.syyd.map((s) => s.dogrukelime),
|
|
411
|
+
...daily.karistirma.flatMap((s) => [s.yanlis, s.dogru]),
|
|
412
|
+
...daily.kelime.map((k) => k.madde),
|
|
413
|
+
];
|
|
414
|
+
let best: { candidate: string; distance: number } | null = null;
|
|
415
|
+
for (const candidate of candidates) {
|
|
416
|
+
const distance = this.levenshtein(word.toLocaleLowerCase("tr-TR"), candidate.toLocaleLowerCase("tr-TR"));
|
|
417
|
+
if (distance > 0 && (!best || distance < best.distance)) {
|
|
418
|
+
best = { candidate, distance };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (best && best.distance <= 2) {
|
|
422
|
+
return { isCorrect: false, word, suggestion: best.candidate };
|
|
423
|
+
}
|
|
234
424
|
}
|
|
235
425
|
return { isCorrect: false, word };
|
|
236
426
|
}
|
|
@@ -256,6 +446,109 @@ export class TDK {
|
|
|
256
446
|
return null;
|
|
257
447
|
}
|
|
258
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Returns today's word of the day along with all of its listed meanings.
|
|
451
|
+
*/
|
|
452
|
+
public static async getWordOfTheDay(): Promise<WordOfTheDay | null> {
|
|
453
|
+
const daily = await this.getDailyContent();
|
|
454
|
+
if (!daily || daily.kelime.length === 0) return null;
|
|
455
|
+
|
|
456
|
+
const word = daily.kelime[0].madde;
|
|
457
|
+
const meanings = daily.kelime.filter((k) => k.madde === word).map((k) => k.anlam);
|
|
458
|
+
return { word, meanings };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Picks a random entry (word or proverb) from today's daily content.
|
|
463
|
+
* Note: this samples from today's `getDailyContent()` picks, not the full dictionary.
|
|
464
|
+
*/
|
|
465
|
+
public static async getRandomWord(): Promise<DailyPick | null> {
|
|
466
|
+
const daily = await this.getDailyContent();
|
|
467
|
+
if (!daily) return null;
|
|
468
|
+
|
|
469
|
+
const pool: DailyPick[] = [
|
|
470
|
+
...daily.kelime.map((k) => ({ type: "kelime" as const, madde: k.madde, anlam: k.anlam })),
|
|
471
|
+
...daily.atasoz.map((a) => ({ type: "atasoz" as const, madde: a.madde, anlam: a.anlam })),
|
|
472
|
+
];
|
|
473
|
+
if (pool.length === 0) return null;
|
|
474
|
+
|
|
475
|
+
return pool[Math.floor(Math.random() * pool.length)];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Returns the spelling-rule page(s) ("yazım kuralları") linked from TDK's
|
|
480
|
+
* `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
|
|
481
|
+
* Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
|
|
482
|
+
* appears to hand back a single randomly-rotated rule per request, so two
|
|
483
|
+
* calls a second apart can return entirely different rules.
|
|
484
|
+
*/
|
|
485
|
+
public static async getKurallar(): Promise<TDKRule[]> {
|
|
486
|
+
const daily = await this.getDailyContent();
|
|
487
|
+
return daily?.kural ?? [];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Fetches the full plain-text content of a named spelling rule (matched
|
|
492
|
+
* case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
|
|
493
|
+
* hands back a single randomly-rotated rule per request (out of a pool of
|
|
494
|
+
* roughly twenty) rather than a fixed catalog, a single `getKurallar()`
|
|
495
|
+
* draw would rarely match a given name — this re-draws (bounded, with a
|
|
496
|
+
* short delay) until it finds a match or gives up. Returns `null` if no
|
|
497
|
+
* match turns up within the attempt budget or the matched page can't be
|
|
498
|
+
* parsed.
|
|
499
|
+
*/
|
|
500
|
+
public static async getRule(name: string): Promise<string | null> {
|
|
501
|
+
if (!name || name.trim() === "") return null;
|
|
502
|
+
const target = name.trim().toLocaleLowerCase("tr-TR");
|
|
503
|
+
|
|
504
|
+
for (let attempt = 0; attempt < 25; attempt++) {
|
|
505
|
+
const rules = await this.getKurallar();
|
|
506
|
+
const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
|
|
507
|
+
if (match) return this.fetchRuleText(match.url);
|
|
508
|
+
await this.delay(100);
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* `tdk.gov.tr` rule pages are WordPress/Avada-themed. The actual article
|
|
515
|
+
* text lives in `<div ... itemprop="text">...</div>` right before a
|
|
516
|
+
* `<footer class="entry...">` (share buttons, author box, structured-data
|
|
517
|
+
* spans) — cutting there avoids that trailing cruft.
|
|
518
|
+
*/
|
|
519
|
+
private static async fetchRuleText(url: string): Promise<string | null> {
|
|
520
|
+
try {
|
|
521
|
+
const response = await fetch(url, { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } });
|
|
522
|
+
if (!response.ok) return null;
|
|
523
|
+
const html = await response.text();
|
|
524
|
+
|
|
525
|
+
const marker = html.indexOf('itemprop="text"');
|
|
526
|
+
if (marker === -1) return null;
|
|
527
|
+
const contentStart = html.indexOf(">", marker) + 1;
|
|
528
|
+
const contentEnd = html.indexOf("<footer", contentStart);
|
|
529
|
+
if (contentEnd === -1) return null;
|
|
530
|
+
|
|
531
|
+
return this.htmlToPlainText(html.slice(contentStart, contentEnd));
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private static htmlToPlainText(html: string): string {
|
|
538
|
+
return html
|
|
539
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
540
|
+
.replace(/<\/(p|div)>/gi, "\n\n")
|
|
541
|
+
.replace(/<[^>]+>/g, "")
|
|
542
|
+
.replace(/ /gi, " ")
|
|
543
|
+
.replace(/&/gi, "&")
|
|
544
|
+
.replace(/"/gi, '"')
|
|
545
|
+
.replace(/'|’/gi, "'")
|
|
546
|
+
.replace(/[ \t]+/g, " ")
|
|
547
|
+
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
|
548
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
549
|
+
.trim();
|
|
550
|
+
}
|
|
551
|
+
|
|
259
552
|
/**
|
|
260
553
|
* Returns compound words that contain this word.
|
|
261
554
|
*/
|
|
@@ -275,17 +568,20 @@ export class TDK {
|
|
|
275
568
|
|
|
276
569
|
/**
|
|
277
570
|
* Returns the part of speech (isim, sıfat, zarf vb.).
|
|
571
|
+
* TDK's `ozelliklerListe` mixes grammatical categories (`tur: "3"`, e.g.
|
|
572
|
+
* sıfat/zarf/isim) with usage-register tags (`tur: "4"`, e.g. mecaz/argo)
|
|
573
|
+
* in the same list — only `tur === "3"` entries are actual parts of speech.
|
|
278
574
|
*/
|
|
279
575
|
public static async getPartOfSpeech(word: string): Promise<string[]> {
|
|
280
576
|
const results = await this.getWord(word);
|
|
281
577
|
const pos = new Set<string>();
|
|
282
|
-
|
|
578
|
+
|
|
283
579
|
for (const result of results) {
|
|
284
580
|
if (result.anlamlarListe) {
|
|
285
581
|
for (const anlam of result.anlamlarListe) {
|
|
286
582
|
if (anlam.ozelliklerListe) {
|
|
287
583
|
for (const ozellik of anlam.ozelliklerListe) {
|
|
288
|
-
pos.add(ozellik.tam_adi);
|
|
584
|
+
if (ozellik.tur === "3") pos.add(ozellik.tam_adi);
|
|
289
585
|
}
|
|
290
586
|
}
|
|
291
587
|
}
|
|
@@ -297,6 +593,94 @@ export class TDK {
|
|
|
297
593
|
return Array.from(pos);
|
|
298
594
|
}
|
|
299
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Compares two words side by side: meaning count, etymological origin,
|
|
598
|
+
* syllables and vowel-harmony compliance.
|
|
599
|
+
*/
|
|
600
|
+
public static async compareWords(a: string, b: string): Promise<WordComparison> {
|
|
601
|
+
const [meaningsA, meaningsB, originA, originB] = await Promise.all([
|
|
602
|
+
this.getMeanings(a),
|
|
603
|
+
this.getMeanings(b),
|
|
604
|
+
this.getOrigin(a),
|
|
605
|
+
this.getOrigin(b),
|
|
606
|
+
]);
|
|
607
|
+
return {
|
|
608
|
+
a: {
|
|
609
|
+
word: a,
|
|
610
|
+
meaningCount: meaningsA.length,
|
|
611
|
+
origin: originA,
|
|
612
|
+
syllables: this.syllabicate(a),
|
|
613
|
+
harmony: this.checkVowelHarmony(a),
|
|
614
|
+
},
|
|
615
|
+
b: {
|
|
616
|
+
word: b,
|
|
617
|
+
meaningCount: meaningsB.length,
|
|
618
|
+
origin: originB,
|
|
619
|
+
syllables: this.syllabicate(b),
|
|
620
|
+
harmony: this.checkVowelHarmony(b),
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private static readonly STOPWORDS = new Set([
|
|
626
|
+
"ve", "veya", "ile", "ama", "fakat", "ancak", "de", "da", "ki", "bu", "şu", "o",
|
|
627
|
+
"bir", "çok", "az", "gibi", "için", "mi", "mı", "mu", "mü", "ne", "her", "hiç",
|
|
628
|
+
"ben", "sen", "biz", "siz", "onlar", "değil", "bile", "diye",
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
private static firstMeaning(results: WordInfo[]): string | null {
|
|
632
|
+
for (const result of results) {
|
|
633
|
+
for (const anlam of result.anlamlarListe ?? []) {
|
|
634
|
+
if (anlam.anlam) return anlam.anlam;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Analyzes every distinct word in a text (Turkish stopwords filtered out),
|
|
642
|
+
* returning each word's first meaning and etymological origin if found.
|
|
643
|
+
* Looks each word up individually (throttled), so scales with text length.
|
|
644
|
+
*/
|
|
645
|
+
public static async analyzeText(text: string): Promise<WordAnalysis[]> {
|
|
646
|
+
const words = text
|
|
647
|
+
.toLocaleLowerCase("tr-TR")
|
|
648
|
+
.replace(/[^\p{L}\s]/gu, " ")
|
|
649
|
+
.split(/\s+/)
|
|
650
|
+
.filter((w) => w.length > 1 && !this.STOPWORDS.has(w));
|
|
651
|
+
const unique = [...new Set(words)];
|
|
652
|
+
|
|
653
|
+
const analyses: WordAnalysis[] = [];
|
|
654
|
+
for (const word of unique) {
|
|
655
|
+
const results = await this.getWord(word);
|
|
656
|
+
const found = results.length > 0;
|
|
657
|
+
analyses.push({
|
|
658
|
+
word,
|
|
659
|
+
found,
|
|
660
|
+
meaning: found ? this.firstMeaning(results) : null,
|
|
661
|
+
origin: found ? results[0].lisan || "Türkçe" : null,
|
|
662
|
+
});
|
|
663
|
+
await this.delay(200);
|
|
664
|
+
}
|
|
665
|
+
return analyses;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Classic edit-distance between two strings.
|
|
670
|
+
*/
|
|
671
|
+
private static levenshtein(a: string, b: string): number {
|
|
672
|
+
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
673
|
+
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
|
|
674
|
+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
|
|
675
|
+
for (let i = 1; i <= a.length; i++) {
|
|
676
|
+
for (let j = 1; j <= b.length; j++) {
|
|
677
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
678
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return dp[a.length][b.length];
|
|
682
|
+
}
|
|
683
|
+
|
|
300
684
|
/**
|
|
301
685
|
* Fetches multiple words concurrently with a small delay to avoid rate limiting.
|
|
302
686
|
*/
|
|
@@ -357,13 +741,17 @@ export class TDK {
|
|
|
357
741
|
|
|
358
742
|
/**
|
|
359
743
|
* Checks if a word follows Turkish Major Vowel Harmony (Büyük Ünlü Uyumu).
|
|
744
|
+
* Normalizes case via the Turkish locale first: a plain case-insensitive
|
|
745
|
+
* regex would fold ASCII "I" to "i", misreading the back vowel "I"
|
|
746
|
+
* (dotless) as the front vowel "i" (dotted).
|
|
360
747
|
*/
|
|
361
748
|
public static checkVowelHarmony(word: string): boolean {
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
749
|
+
const lower = word.toLocaleLowerCase("tr-TR");
|
|
750
|
+
const backVowels = /[aıou]/;
|
|
751
|
+
const frontVowels = /[eiöü]/;
|
|
752
|
+
const hasBack = backVowels.test(lower);
|
|
753
|
+
const hasFront = frontVowels.test(lower);
|
|
754
|
+
|
|
367
755
|
// If it has both front and back vowels, it breaks harmony.
|
|
368
756
|
return !(hasBack && hasFront);
|
|
369
757
|
}
|
package/src/types.ts
CHANGED
|
@@ -87,4 +87,40 @@ export interface SpellCheckResult {
|
|
|
87
87
|
suggestion?: string;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export interface WordOfTheDay {
|
|
91
|
+
word: string;
|
|
92
|
+
meanings: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DailyPick {
|
|
96
|
+
type: "kelime" | "atasoz";
|
|
97
|
+
madde: string;
|
|
98
|
+
anlam: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface TDKRule {
|
|
102
|
+
adi: string;
|
|
103
|
+
url: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface WordComparisonSide {
|
|
107
|
+
word: string;
|
|
108
|
+
meaningCount: number;
|
|
109
|
+
origin: string | null;
|
|
110
|
+
syllables: string[];
|
|
111
|
+
harmony: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface WordComparison {
|
|
115
|
+
a: WordComparisonSide;
|
|
116
|
+
b: WordComparisonSide;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WordAnalysis {
|
|
120
|
+
word: string;
|
|
121
|
+
found: boolean;
|
|
122
|
+
meaning: string | null;
|
|
123
|
+
origin: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
export type TDKResponse = WordInfo[] | { error: string };
|