tdk-api-wrapper 1.2.0 → 1.2.2

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 CHANGED
@@ -68,7 +68,7 @@ Aşağıdaki metotlar `TDK` sınıfı üzerinden statik olarak erişilebilir dur
68
68
  - **`TDK.syllabicate(word)`**: Kelimeyi Türkçe heceleme kurallarına göre doğru hecelerine ayırır (Örn: `['mu', 'vaf', 'fa', 'ki', 'yet']`). API isteği atmaz, çok hızlıdır.
69
69
  - **`TDK.checkVowelHarmony(word)`**: Kelimenin büyük ünlü uyumuna uyup uymadığını (boolean) kontrol eder.
70
70
  - **`TDK.getPartOfSpeech(word)`**: Kelimenin sözcük türünü (isim, sıfat, zarf vb.) döndürür.
71
- - **`TDK.checkSpelling(word)`**: Sıkça yapılan yanlışlar listesini ve TDK veritabanını kullanarak kelimenin doğru yazılıp yazılmadığını kontrol eder. Yanlışsa doğrusunu önerir; tam eşleşme yoksa, aynı listedeki kelimeler arasında edit-distance (Levenshtein) ile en yakınını önerir (not: tüm sözlükte değil, yalnızca bu küçük havuzda arama yapar).
71
+ - **`TDK.checkSpelling(word)`**: Kelimenin doğru yazılıp yazılmadığını kontrol eder. Önce TDK'nin "sık yapılan yanlışlar" listesinde tam eşleşme arar; bulamazsa TDK'nin ~81 bin kelimelik tam madde listesi üzerinde Damerau-Levenshtein edit-distance ile en yakın kelimeyi önerir (bitişik harf yer değiştirmelerini de tek düzeltme sayar; örn. `herkez` → `herkes`, `mektub` → `mektup`, `yanlız` → `yalnız`). Kelime sıklığı verisi olmadığı için nadiren aynı mesafedeki iki aday arasında beklenenden farklı biri seçilebilir.
72
72
  - **`TDK.getCompoundWords(word)`**: Aranan kelime ile oluşturulmuş birleşik kelimeleri (Örn: dolma kalem) listeler.
73
73
 
74
74
  ### 3. Edebi ve Kültürel Analiz
@@ -79,7 +79,7 @@ Aşağıdaki metotlar `TDK` sınıfı üzerinden statik olarak erişilebilir dur
79
79
  - **`TDK.groupByOrigin(words)`**: Bir kelime listesini etimolojik kökenlerine göre gruplar (bulunamayanlar `"Bilinmiyor"` altında toplanır).
80
80
  - **`TDK.getSynonyms(word)`** / **`TDK.getAntonyms(word)`**: Kelimenin eş/zıt anlamlılarını döner (undocumented `gts-yeni` endpoint'i üzerinden; sonuç bulunamazsa `[]`).
81
81
  - **`TDK.compareWords(a, b)`**: İki kelimeyi anlam sayısı, köken, hece bölünüşü ve büyük ünlü uyumu açısından yan yana karşılaştırır.
82
- - **`TDK.analyzeText(text)`**: Bir metindeki (Türkçe bağlaçlar/edatlar hariç) her benzersiz kelimeyi tek tek arayıp ilk anlamını ve kökenini döner.
82
+ - **`TDK.analyzeText(text)`**: Bir metindeki (Türkçe bağlaçlar/edatlar hariç) her benzersiz kelimeyi tek tek arayıp ilk anlamını ve kökenini döner. Not: TDK yalnızca yalın (sözlük) biçimleri indeksliyor, morfolojik analiz yapmıyor — bu yüzden "evde", "dildir" gibi ek almış kelimeler kökleri (`ev`, `dil`) sözlükte olsa bile `found: false` döner; bu veri kaynağının doğal bir sınırlılığıdır.
83
83
 
84
84
  ### 4. Yardımcı Metotlar
85
85
  - **`TDK.getSuggestions(prefix)`**: TDK'nin ~81 bin kelimelik tam madde listesi üzerinden önek bazlı otomatik tamamlama önerileri döner (ilk çağrıda listeyi indirip önbelleğe alır, sonraki çağrılar anlıktır).
@@ -88,7 +88,7 @@ Aşağıdaki metotlar `TDK` sınıfı üzerinden statik olarak erişilebilir dur
88
88
  - **`TDK.getWordOfTheDay()`**: `getDailyContent()`'in üzerine ince bir katman; günün kelimesini ve tüm anlamlarını `{ word, meanings }` şeklinde döner.
89
89
  - **`TDK.getRandomWord()`**: Günün içeriğindeki kelime ve atasözü havuzundan rastgele bir tanesini `{ type: "kelime" | "atasoz", madde, anlam }` şeklinde seçer (not: tüm sözlük değil, sadece o günkü içerik havuzundan seçim yapar).
90
90
  - **`TDK.getKurallar()`**: TDK'nin `/icerik` akışının o an döndürdüğü yazım kuralı sayfa(lar)ını `{ adi, url }` şeklinde listeler. Not: bu sabit bir katalog değildir — `/icerik` her istekte, yaklaşık yirmi kurallık bir havuzdan rastgele tek bir kural döndürür.
91
- - **`TDK.getRule(name)`**: Adı verilen (küçük/büyük harf duyarsız, alt dize eşleşmesi) yazım kuralının tam metnini `tdk.gov.tr`'den çekip düz metne çevirir. `getKurallar()`'ın rastgeleliği yüzünden istenen kuralı bulana kadar sınırlı sayıda (en fazla 25, aralarda kısa bekleme ile) yeniden dener; bulamazsa veya sayfa ayrıştırılamazsa `null` döner.
91
+ - **`TDK.getRule(name)`**: Adı verilen (küçük/büyük harf duyarsız, alt dize eşleşmesi) yazım kuralının tam metnini `tdk.gov.tr`'den çekip düz metne çevirir. `getKurallar()`'ın rastgeleliği yüzünden istenen kuralı bulana kadar eşzamanlı gruplar hâlinde (toplam en fazla 25 deneme, ~5 round-trip'e sığdırılmış) yeniden dener; bulamazsa veya sayfa ayrıştırılamazsa `null` döner.
92
92
 
93
93
  ## Hata Yönetimi
94
94
 
@@ -390,29 +390,42 @@ var TDK = class {
390
390
  if (mixMatch) {
391
391
  return { isCorrect: false, word, suggestion: mixMatch.dogru };
392
392
  }
393
- const candidates = [
394
- ...daily.syyd.map((s) => s.dogrukelime),
395
- ...daily.karistirma.flatMap((s) => [s.yanlis, s.dogru]),
396
- ...daily.kelime.map((k) => k.madde)
397
- ];
398
- let best = null;
399
- for (const candidate of candidates) {
400
- const distance = this.levenshtein(word.toLocaleLowerCase("tr-TR"), candidate.toLocaleLowerCase("tr-TR"));
401
- if (distance > 0 && (!best || distance < best.distance)) {
402
- best = { candidate, distance };
403
- }
404
- }
405
- if (best && best.distance <= 2) {
406
- return { isCorrect: false, word, suggestion: best.candidate };
393
+ }
394
+ if (this.autocompleteCache.length === 0) {
395
+ this.autocompleteCache = await this.fetchAutocompleteData();
396
+ }
397
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
398
+ let best = null;
399
+ for (const candidate of this.autocompleteCache) {
400
+ if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR"))
401
+ continue;
402
+ if (Math.abs(candidate.length - cleanWord.length) > 2)
403
+ continue;
404
+ const distance = this.damerauLevenshtein(cleanWord, candidate);
405
+ if (distance === 0)
406
+ continue;
407
+ const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
408
+ const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
409
+ const better = !best || distance < best.distance || distance === best.distance && firstMismatch < best.firstMismatch || distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch;
410
+ if (better) {
411
+ best = { candidate, distance, firstMismatch, lengthMismatch };
407
412
  }
408
413
  }
414
+ if (best && best.distance <= 2) {
415
+ return { isCorrect: false, word, suggestion: best.candidate };
416
+ }
409
417
  return { isCorrect: false, word };
410
418
  }
411
419
  /**
412
420
  * Fetches daily content (word of the day, proverbs, rules, etc).
421
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
422
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
423
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
424
+ * caching is enabled the loop would just re-read the same cached response
425
+ * 25 times and could never find a rule outside that first random draw.
413
426
  */
414
- static async getDailyContent() {
415
- if (this.isCacheEnabled && this.dailyContentCache)
427
+ static async getDailyContent(bypassCache = false) {
428
+ if (!bypassCache && this.isCacheEnabled && this.dailyContentCache)
416
429
  return this.dailyContentCache;
417
430
  try {
418
431
  const response = await fetch(`${this.BASE_URL}/icerik`, {
@@ -420,7 +433,7 @@ var TDK = class {
420
433
  });
421
434
  if (response.ok) {
422
435
  const data = await response.json();
423
- if (this.isCacheEnabled)
436
+ if (!bypassCache && this.isCacheEnabled)
424
437
  this.dailyContentCache = data;
425
438
  return data;
426
439
  }
@@ -461,10 +474,12 @@ var TDK = class {
461
474
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
462
475
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
463
476
  * appears to hand back a single randomly-rotated rule per request, so two
464
- * calls a second apart can return entirely different rules.
477
+ * calls a second apart can return entirely different rules. `bypassCache`
478
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
479
+ * draw even when `enableCache(true)` is on.
465
480
  */
466
- static async getKurallar() {
467
- const daily = await this.getDailyContent();
481
+ static async getKurallar(bypassCache = false) {
482
+ const daily = await this.getDailyContent(bypassCache);
468
483
  return daily?.kural ?? [];
469
484
  }
470
485
  /**
@@ -472,21 +487,33 @@ var TDK = class {
472
487
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
473
488
  * hands back a single randomly-rotated rule per request (out of a pool of
474
489
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
475
- * draw would rarely match a given name — this re-draws (bounded, with a
476
- * short delay) until it finds a match or gives up. Returns `null` if no
477
- * match turns up within the attempt budget or the matched page can't be
478
- * parsed.
490
+ * draw would rarely match a given name — this re-draws until it finds a
491
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
492
+ * request is independent and stateless) rather than one-at-a-time with a
493
+ * delay: same total sample size (25) and hit probability as a sequential
494
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
495
+ * a miss resolves in roughly one round-trip time instead of several
496
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
497
+ * `enableCache(true)` is on, every attempt would just re-read the same
498
+ * cached `/icerik` response and could never find a rule outside whatever
499
+ * the first draw happened to be. Returns `null` if no match turns up
500
+ * within the attempt budget or the matched page can't be parsed.
479
501
  */
480
502
  static async getRule(name) {
481
503
  if (!name || name.trim() === "")
482
504
  return null;
483
505
  const target = name.trim().toLocaleLowerCase("tr-TR");
484
- for (let attempt = 0; attempt < 25; attempt++) {
485
- const rules = await this.getKurallar();
486
- const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
487
- if (match)
488
- return this.fetchRuleText(match.url);
489
- await this.delay(100);
506
+ const BATCH_SIZE = 5;
507
+ const ROUNDS = 5;
508
+ for (let round = 0; round < ROUNDS; round++) {
509
+ const batches = await Promise.all(
510
+ Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
511
+ );
512
+ for (const rules of batches) {
513
+ const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
514
+ if (match)
515
+ return this.fetchRuleText(match.url);
516
+ }
490
517
  }
491
518
  return null;
492
519
  }
@@ -634,6 +661,11 @@ var TDK = class {
634
661
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
635
662
  * returning each word's first meaning and etymological origin if found.
636
663
  * Looks each word up individually (throttled), so scales with text length.
664
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
665
+ * it does no morphological analysis, and neither does this method: a
666
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
667
+ * suffix) will come back `found: false` even though the root is a real
668
+ * headword. This is an inherent limitation of the data source, not a bug.
637
669
  */
638
670
  static async analyzeText(text) {
639
671
  const words = text.toLocaleLowerCase("tr-TR").replace(/[^\p{L}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1 && !this.STOPWORDS.has(w));
@@ -653,9 +685,13 @@ var TDK = class {
653
685
  return analyses;
654
686
  }
655
687
  /**
656
- * Classic edit-distance between two strings.
688
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
689
+ * like classic Levenshtein but also counts an adjacent-character
690
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
691
+ * two substitutions — a very common class of typo that plain Levenshtein
692
+ * otherwise misses.
657
693
  */
658
- static levenshtein(a, b) {
694
+ static damerauLevenshtein(a, b) {
659
695
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
660
696
  for (let i = 0; i <= a.length; i++)
661
697
  dp[i][0] = i;
@@ -665,6 +701,9 @@ var TDK = class {
665
701
  for (let j = 1; j <= b.length; j++) {
666
702
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
667
703
  dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
704
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
705
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
706
+ }
668
707
  }
669
708
  }
670
709
  return dp[a.length][b.length];
package/dist/cli.js CHANGED
@@ -415,29 +415,42 @@ var TDK = class {
415
415
  if (mixMatch) {
416
416
  return { isCorrect: false, word: word2, suggestion: mixMatch.dogru };
417
417
  }
418
- const candidates = [
419
- ...daily.syyd.map((s) => s.dogrukelime),
420
- ...daily.karistirma.flatMap((s) => [s.yanlis, s.dogru]),
421
- ...daily.kelime.map((k) => k.madde)
422
- ];
423
- let best = null;
424
- for (const candidate of candidates) {
425
- const distance = this.levenshtein(word2.toLocaleLowerCase("tr-TR"), candidate.toLocaleLowerCase("tr-TR"));
426
- if (distance > 0 && (!best || distance < best.distance)) {
427
- best = { candidate, distance };
428
- }
429
- }
430
- if (best && best.distance <= 2) {
431
- return { isCorrect: false, word: word2, suggestion: best.candidate };
418
+ }
419
+ if (this.autocompleteCache.length === 0) {
420
+ this.autocompleteCache = await this.fetchAutocompleteData();
421
+ }
422
+ const cleanWord = word2.trim().toLocaleLowerCase("tr-TR");
423
+ let best = null;
424
+ for (const candidate of this.autocompleteCache) {
425
+ if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR"))
426
+ continue;
427
+ if (Math.abs(candidate.length - cleanWord.length) > 2)
428
+ continue;
429
+ const distance = this.damerauLevenshtein(cleanWord, candidate);
430
+ if (distance === 0)
431
+ continue;
432
+ const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
433
+ const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
434
+ const better = !best || distance < best.distance || distance === best.distance && firstMismatch < best.firstMismatch || distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch;
435
+ if (better) {
436
+ best = { candidate, distance, firstMismatch, lengthMismatch };
432
437
  }
433
438
  }
439
+ if (best && best.distance <= 2) {
440
+ return { isCorrect: false, word: word2, suggestion: best.candidate };
441
+ }
434
442
  return { isCorrect: false, word: word2 };
435
443
  }
436
444
  /**
437
445
  * Fetches daily content (word of the day, proverbs, rules, etc).
446
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
447
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
448
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
449
+ * caching is enabled the loop would just re-read the same cached response
450
+ * 25 times and could never find a rule outside that first random draw.
438
451
  */
439
- static async getDailyContent() {
440
- if (this.isCacheEnabled && this.dailyContentCache)
452
+ static async getDailyContent(bypassCache = false) {
453
+ if (!bypassCache && this.isCacheEnabled && this.dailyContentCache)
441
454
  return this.dailyContentCache;
442
455
  try {
443
456
  const response = await fetch(`${this.BASE_URL}/icerik`, {
@@ -445,7 +458,7 @@ var TDK = class {
445
458
  });
446
459
  if (response.ok) {
447
460
  const data = await response.json();
448
- if (this.isCacheEnabled)
461
+ if (!bypassCache && this.isCacheEnabled)
449
462
  this.dailyContentCache = data;
450
463
  return data;
451
464
  }
@@ -486,10 +499,12 @@ var TDK = class {
486
499
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
487
500
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
488
501
  * appears to hand back a single randomly-rotated rule per request, so two
489
- * calls a second apart can return entirely different rules.
502
+ * calls a second apart can return entirely different rules. `bypassCache`
503
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
504
+ * draw even when `enableCache(true)` is on.
490
505
  */
491
- static async getKurallar() {
492
- const daily = await this.getDailyContent();
506
+ static async getKurallar(bypassCache = false) {
507
+ const daily = await this.getDailyContent(bypassCache);
493
508
  return daily?.kural ?? [];
494
509
  }
495
510
  /**
@@ -497,21 +512,33 @@ var TDK = class {
497
512
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
498
513
  * hands back a single randomly-rotated rule per request (out of a pool of
499
514
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
500
- * draw would rarely match a given name — this re-draws (bounded, with a
501
- * short delay) until it finds a match or gives up. Returns `null` if no
502
- * match turns up within the attempt budget or the matched page can't be
503
- * parsed.
515
+ * draw would rarely match a given name — this re-draws until it finds a
516
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
517
+ * request is independent and stateless) rather than one-at-a-time with a
518
+ * delay: same total sample size (25) and hit probability as a sequential
519
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
520
+ * a miss resolves in roughly one round-trip time instead of several
521
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
522
+ * `enableCache(true)` is on, every attempt would just re-read the same
523
+ * cached `/icerik` response and could never find a rule outside whatever
524
+ * the first draw happened to be. Returns `null` if no match turns up
525
+ * within the attempt budget or the matched page can't be parsed.
504
526
  */
505
527
  static async getRule(name) {
506
528
  if (!name || name.trim() === "")
507
529
  return null;
508
530
  const target = name.trim().toLocaleLowerCase("tr-TR");
509
- for (let attempt = 0; attempt < 25; attempt++) {
510
- const rules = await this.getKurallar();
511
- const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
512
- if (match)
513
- return this.fetchRuleText(match.url);
514
- await this.delay(100);
531
+ const BATCH_SIZE = 5;
532
+ const ROUNDS = 5;
533
+ for (let round = 0; round < ROUNDS; round++) {
534
+ const batches = await Promise.all(
535
+ Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
536
+ );
537
+ for (const rules of batches) {
538
+ const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
539
+ if (match)
540
+ return this.fetchRuleText(match.url);
541
+ }
515
542
  }
516
543
  return null;
517
544
  }
@@ -659,6 +686,11 @@ var TDK = class {
659
686
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
660
687
  * returning each word's first meaning and etymological origin if found.
661
688
  * Looks each word up individually (throttled), so scales with text length.
689
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
690
+ * it does no morphological analysis, and neither does this method: a
691
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
692
+ * suffix) will come back `found: false` even though the root is a real
693
+ * headword. This is an inherent limitation of the data source, not a bug.
662
694
  */
663
695
  static async analyzeText(text) {
664
696
  const words = text.toLocaleLowerCase("tr-TR").replace(/[^\p{L}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1 && !this.STOPWORDS.has(w));
@@ -678,9 +710,13 @@ var TDK = class {
678
710
  return analyses;
679
711
  }
680
712
  /**
681
- * Classic edit-distance between two strings.
713
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
714
+ * like classic Levenshtein but also counts an adjacent-character
715
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
716
+ * two substitutions — a very common class of typo that plain Levenshtein
717
+ * otherwise misses.
682
718
  */
683
- static levenshtein(a, b) {
719
+ static damerauLevenshtein(a, b) {
684
720
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
685
721
  for (let i = 0; i <= a.length; i++)
686
722
  dp[i][0] = i;
@@ -690,6 +726,9 @@ var TDK = class {
690
726
  for (let j = 1; j <= b.length; j++) {
691
727
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
692
728
  dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
729
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
730
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
731
+ }
693
732
  }
694
733
  }
695
734
  return dp[a.length][b.length];
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  TDK
4
- } from "./chunk-2TA5PMVZ.mjs";
4
+ } from "./chunk-SNY3KUCF.mjs";
5
5
 
6
6
  // src/cli.ts
7
7
  var rawArgs = process.argv.slice(2);
package/dist/index.d.mts CHANGED
@@ -244,8 +244,13 @@ declare class TDK {
244
244
  static checkSpelling(word: string): Promise<SpellCheckResult>;
245
245
  /**
246
246
  * Fetches daily content (word of the day, proverbs, rules, etc).
247
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
248
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
249
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
250
+ * caching is enabled the loop would just re-read the same cached response
251
+ * 25 times and could never find a rule outside that first random draw.
247
252
  */
248
- static getDailyContent(): Promise<DailyContent | null>;
253
+ static getDailyContent(bypassCache?: boolean): Promise<DailyContent | null>;
249
254
  /**
250
255
  * Returns today's word of the day along with all of its listed meanings.
251
256
  */
@@ -260,18 +265,27 @@ declare class TDK {
260
265
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
261
266
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
262
267
  * appears to hand back a single randomly-rotated rule per request, so two
263
- * calls a second apart can return entirely different rules.
268
+ * calls a second apart can return entirely different rules. `bypassCache`
269
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
270
+ * draw even when `enableCache(true)` is on.
264
271
  */
265
- static getKurallar(): Promise<TDKRule[]>;
272
+ static getKurallar(bypassCache?: boolean): Promise<TDKRule[]>;
266
273
  /**
267
274
  * Fetches the full plain-text content of a named spelling rule (matched
268
275
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
269
276
  * hands back a single randomly-rotated rule per request (out of a pool of
270
277
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
271
- * draw would rarely match a given name — this re-draws (bounded, with a
272
- * short delay) until it finds a match or gives up. Returns `null` if no
273
- * match turns up within the attempt budget or the matched page can't be
274
- * parsed.
278
+ * draw would rarely match a given name — this re-draws until it finds a
279
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
280
+ * request is independent and stateless) rather than one-at-a-time with a
281
+ * delay: same total sample size (25) and hit probability as a sequential
282
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
283
+ * a miss resolves in roughly one round-trip time instead of several
284
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
285
+ * `enableCache(true)` is on, every attempt would just re-read the same
286
+ * cached `/icerik` response and could never find a rule outside whatever
287
+ * the first draw happened to be. Returns `null` if no match turns up
288
+ * within the attempt budget or the matched page can't be parsed.
275
289
  */
276
290
  static getRule(name: string): Promise<string | null>;
277
291
  /**
@@ -304,12 +318,21 @@ declare class TDK {
304
318
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
305
319
  * returning each word's first meaning and etymological origin if found.
306
320
  * Looks each word up individually (throttled), so scales with text length.
321
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
322
+ * it does no morphological analysis, and neither does this method: a
323
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
324
+ * suffix) will come back `found: false` even though the root is a real
325
+ * headword. This is an inherent limitation of the data source, not a bug.
307
326
  */
308
327
  static analyzeText(text: string): Promise<WordAnalysis[]>;
309
328
  /**
310
- * Classic edit-distance between two strings.
329
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
330
+ * like classic Levenshtein but also counts an adjacent-character
331
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
332
+ * two substitutions — a very common class of typo that plain Levenshtein
333
+ * otherwise misses.
311
334
  */
312
- private static levenshtein;
335
+ private static damerauLevenshtein;
313
336
  /**
314
337
  * Fetches multiple words concurrently with a small delay to avoid rate limiting.
315
338
  */
package/dist/index.d.ts CHANGED
@@ -244,8 +244,13 @@ declare class TDK {
244
244
  static checkSpelling(word: string): Promise<SpellCheckResult>;
245
245
  /**
246
246
  * Fetches daily content (word of the day, proverbs, rules, etc).
247
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
248
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
249
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
250
+ * caching is enabled the loop would just re-read the same cached response
251
+ * 25 times and could never find a rule outside that first random draw.
247
252
  */
248
- static getDailyContent(): Promise<DailyContent | null>;
253
+ static getDailyContent(bypassCache?: boolean): Promise<DailyContent | null>;
249
254
  /**
250
255
  * Returns today's word of the day along with all of its listed meanings.
251
256
  */
@@ -260,18 +265,27 @@ declare class TDK {
260
265
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
261
266
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
262
267
  * appears to hand back a single randomly-rotated rule per request, so two
263
- * calls a second apart can return entirely different rules.
268
+ * calls a second apart can return entirely different rules. `bypassCache`
269
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
270
+ * draw even when `enableCache(true)` is on.
264
271
  */
265
- static getKurallar(): Promise<TDKRule[]>;
272
+ static getKurallar(bypassCache?: boolean): Promise<TDKRule[]>;
266
273
  /**
267
274
  * Fetches the full plain-text content of a named spelling rule (matched
268
275
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
269
276
  * hands back a single randomly-rotated rule per request (out of a pool of
270
277
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
271
- * draw would rarely match a given name — this re-draws (bounded, with a
272
- * short delay) until it finds a match or gives up. Returns `null` if no
273
- * match turns up within the attempt budget or the matched page can't be
274
- * parsed.
278
+ * draw would rarely match a given name — this re-draws until it finds a
279
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
280
+ * request is independent and stateless) rather than one-at-a-time with a
281
+ * delay: same total sample size (25) and hit probability as a sequential
282
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
283
+ * a miss resolves in roughly one round-trip time instead of several
284
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
285
+ * `enableCache(true)` is on, every attempt would just re-read the same
286
+ * cached `/icerik` response and could never find a rule outside whatever
287
+ * the first draw happened to be. Returns `null` if no match turns up
288
+ * within the attempt budget or the matched page can't be parsed.
275
289
  */
276
290
  static getRule(name: string): Promise<string | null>;
277
291
  /**
@@ -304,12 +318,21 @@ declare class TDK {
304
318
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
305
319
  * returning each word's first meaning and etymological origin if found.
306
320
  * Looks each word up individually (throttled), so scales with text length.
321
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
322
+ * it does no morphological analysis, and neither does this method: a
323
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
324
+ * suffix) will come back `found: false` even though the root is a real
325
+ * headword. This is an inherent limitation of the data source, not a bug.
307
326
  */
308
327
  static analyzeText(text: string): Promise<WordAnalysis[]>;
309
328
  /**
310
- * Classic edit-distance between two strings.
329
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
330
+ * like classic Levenshtein but also counts an adjacent-character
331
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
332
+ * two substitutions — a very common class of typo that plain Levenshtein
333
+ * otherwise misses.
311
334
  */
312
- private static levenshtein;
335
+ private static damerauLevenshtein;
313
336
  /**
314
337
  * Fetches multiple words concurrently with a small delay to avoid rate limiting.
315
338
  */
package/dist/index.js CHANGED
@@ -429,29 +429,42 @@ var TDK = class {
429
429
  if (mixMatch) {
430
430
  return { isCorrect: false, word, suggestion: mixMatch.dogru };
431
431
  }
432
- const candidates = [
433
- ...daily.syyd.map((s) => s.dogrukelime),
434
- ...daily.karistirma.flatMap((s) => [s.yanlis, s.dogru]),
435
- ...daily.kelime.map((k) => k.madde)
436
- ];
437
- let best = null;
438
- for (const candidate of candidates) {
439
- const distance = this.levenshtein(word.toLocaleLowerCase("tr-TR"), candidate.toLocaleLowerCase("tr-TR"));
440
- if (distance > 0 && (!best || distance < best.distance)) {
441
- best = { candidate, distance };
442
- }
443
- }
444
- if (best && best.distance <= 2) {
445
- return { isCorrect: false, word, suggestion: best.candidate };
432
+ }
433
+ if (this.autocompleteCache.length === 0) {
434
+ this.autocompleteCache = await this.fetchAutocompleteData();
435
+ }
436
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
437
+ let best = null;
438
+ for (const candidate of this.autocompleteCache) {
439
+ if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR"))
440
+ continue;
441
+ if (Math.abs(candidate.length - cleanWord.length) > 2)
442
+ continue;
443
+ const distance = this.damerauLevenshtein(cleanWord, candidate);
444
+ if (distance === 0)
445
+ continue;
446
+ const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
447
+ const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
448
+ const better = !best || distance < best.distance || distance === best.distance && firstMismatch < best.firstMismatch || distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch;
449
+ if (better) {
450
+ best = { candidate, distance, firstMismatch, lengthMismatch };
446
451
  }
447
452
  }
453
+ if (best && best.distance <= 2) {
454
+ return { isCorrect: false, word, suggestion: best.candidate };
455
+ }
448
456
  return { isCorrect: false, word };
449
457
  }
450
458
  /**
451
459
  * Fetches daily content (word of the day, proverbs, rules, etc).
460
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
461
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
462
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
463
+ * caching is enabled the loop would just re-read the same cached response
464
+ * 25 times and could never find a rule outside that first random draw.
452
465
  */
453
- static async getDailyContent() {
454
- if (this.isCacheEnabled && this.dailyContentCache)
466
+ static async getDailyContent(bypassCache = false) {
467
+ if (!bypassCache && this.isCacheEnabled && this.dailyContentCache)
455
468
  return this.dailyContentCache;
456
469
  try {
457
470
  const response = await fetch(`${this.BASE_URL}/icerik`, {
@@ -459,7 +472,7 @@ var TDK = class {
459
472
  });
460
473
  if (response.ok) {
461
474
  const data = await response.json();
462
- if (this.isCacheEnabled)
475
+ if (!bypassCache && this.isCacheEnabled)
463
476
  this.dailyContentCache = data;
464
477
  return data;
465
478
  }
@@ -500,10 +513,12 @@ var TDK = class {
500
513
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
501
514
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
502
515
  * appears to hand back a single randomly-rotated rule per request, so two
503
- * calls a second apart can return entirely different rules.
516
+ * calls a second apart can return entirely different rules. `bypassCache`
517
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
518
+ * draw even when `enableCache(true)` is on.
504
519
  */
505
- static async getKurallar() {
506
- const daily = await this.getDailyContent();
520
+ static async getKurallar(bypassCache = false) {
521
+ const daily = await this.getDailyContent(bypassCache);
507
522
  return daily?.kural ?? [];
508
523
  }
509
524
  /**
@@ -511,21 +526,33 @@ var TDK = class {
511
526
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
512
527
  * hands back a single randomly-rotated rule per request (out of a pool of
513
528
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
514
- * draw would rarely match a given name — this re-draws (bounded, with a
515
- * short delay) until it finds a match or gives up. Returns `null` if no
516
- * match turns up within the attempt budget or the matched page can't be
517
- * parsed.
529
+ * draw would rarely match a given name — this re-draws until it finds a
530
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
531
+ * request is independent and stateless) rather than one-at-a-time with a
532
+ * delay: same total sample size (25) and hit probability as a sequential
533
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
534
+ * a miss resolves in roughly one round-trip time instead of several
535
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
536
+ * `enableCache(true)` is on, every attempt would just re-read the same
537
+ * cached `/icerik` response and could never find a rule outside whatever
538
+ * the first draw happened to be. Returns `null` if no match turns up
539
+ * within the attempt budget or the matched page can't be parsed.
518
540
  */
519
541
  static async getRule(name) {
520
542
  if (!name || name.trim() === "")
521
543
  return null;
522
544
  const target = name.trim().toLocaleLowerCase("tr-TR");
523
- for (let attempt = 0; attempt < 25; attempt++) {
524
- const rules = await this.getKurallar();
525
- const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
526
- if (match)
527
- return this.fetchRuleText(match.url);
528
- await this.delay(100);
545
+ const BATCH_SIZE = 5;
546
+ const ROUNDS = 5;
547
+ for (let round = 0; round < ROUNDS; round++) {
548
+ const batches = await Promise.all(
549
+ Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
550
+ );
551
+ for (const rules of batches) {
552
+ const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
553
+ if (match)
554
+ return this.fetchRuleText(match.url);
555
+ }
529
556
  }
530
557
  return null;
531
558
  }
@@ -673,6 +700,11 @@ var TDK = class {
673
700
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
674
701
  * returning each word's first meaning and etymological origin if found.
675
702
  * Looks each word up individually (throttled), so scales with text length.
703
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
704
+ * it does no morphological analysis, and neither does this method: a
705
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
706
+ * suffix) will come back `found: false` even though the root is a real
707
+ * headword. This is an inherent limitation of the data source, not a bug.
676
708
  */
677
709
  static async analyzeText(text) {
678
710
  const words = text.toLocaleLowerCase("tr-TR").replace(/[^\p{L}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1 && !this.STOPWORDS.has(w));
@@ -692,9 +724,13 @@ var TDK = class {
692
724
  return analyses;
693
725
  }
694
726
  /**
695
- * Classic edit-distance between two strings.
727
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
728
+ * like classic Levenshtein but also counts an adjacent-character
729
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
730
+ * two substitutions — a very common class of typo that plain Levenshtein
731
+ * otherwise misses.
696
732
  */
697
- static levenshtein(a, b) {
733
+ static damerauLevenshtein(a, b) {
698
734
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
699
735
  for (let i = 0; i <= a.length; i++)
700
736
  dp[i][0] = i;
@@ -704,6 +740,9 @@ var TDK = class {
704
740
  for (let j = 1; j <= b.length; j++) {
705
741
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
706
742
  dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
743
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
744
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
745
+ }
707
746
  }
708
747
  }
709
748
  return dp[a.length][b.length];
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  TDKError,
4
4
  TDKNetworkError,
5
5
  TDKValidationError
6
- } from "./chunk-2TA5PMVZ.mjs";
6
+ } from "./chunk-SNY3KUCF.mjs";
7
7
  export {
8
8
  TDK,
9
9
  TDKError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdk-api-wrapper",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",
package/src/tdk.ts CHANGED
@@ -389,8 +389,10 @@ export class TDK {
389
389
  if (results.length > 0) {
390
390
  return { isCorrect: true, word };
391
391
  }
392
-
393
- // 2. If not, check "sıkça yapılan yanlışlar" from DailyContent
392
+
393
+ // 2. If not, check "sıkça yapılan yanlışlar" from DailyContent — an exact
394
+ // match here is TDK explicitly saying "X is often confused with Y", so
395
+ // it's authoritative when it hits (but only 2-3 rotating entries per call).
394
396
  const daily = await this.getDailyContent();
395
397
  if (daily) {
396
398
  const syydMatch = daily.syyd.find(s => s.yanliskelime.toLocaleLowerCase("tr-TR") === word.toLocaleLowerCase("tr-TR"));
@@ -401,43 +403,67 @@ export class TDK {
401
403
  if (mixMatch) {
402
404
  return { isCorrect: false, word, suggestion: mixMatch.dogru };
403
405
  }
406
+ }
404
407
 
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 };
408
+ // 3. No exact match in TDK's curated lists: fall back to the closest
409
+ // headword (by edit distance) across TDK's full ~81k-word list (the same
410
+ // data `getSuggestions()` uses). Restricted to single-token, lowercase
411
+ // headwords so it doesn't suggest compounds/phrases or proper nouns.
412
+ // Candidates whose length differs too much are skipped before running
413
+ // the O(n*m) distance calculation, both for speed and because a huge
414
+ // length gap can't be within the distance threshold anyway. Ties (same
415
+ // distance) prefer a matching first letter, then a matching length —
416
+ // typos rarely change the first letter, and this avoids picking
417
+ // whatever happens to sort alphabetically first. There's no word
418
+ // frequency data available, so a genuine tie can still land on a
419
+ // technically-correct but less commonly intended word.
420
+ if (this.autocompleteCache.length === 0) {
421
+ this.autocompleteCache = await this.fetchAutocompleteData();
422
+ }
423
+ const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
424
+ let best: { candidate: string; distance: number; firstMismatch: number; lengthMismatch: number } | null = null;
425
+ for (const candidate of this.autocompleteCache) {
426
+ if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR")) continue;
427
+ if (Math.abs(candidate.length - cleanWord.length) > 2) continue;
428
+
429
+ const distance = this.damerauLevenshtein(cleanWord, candidate);
430
+ if (distance === 0) continue;
431
+
432
+ const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
433
+ const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
434
+ const better =
435
+ !best ||
436
+ distance < best.distance ||
437
+ (distance === best.distance && firstMismatch < best.firstMismatch) ||
438
+ (distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch);
439
+ if (better) {
440
+ best = { candidate, distance, firstMismatch, lengthMismatch };
423
441
  }
424
442
  }
443
+ if (best && best.distance <= 2) {
444
+ return { isCorrect: false, word, suggestion: best.candidate };
445
+ }
425
446
  return { isCorrect: false, word };
426
447
  }
427
448
 
428
449
  /**
429
450
  * Fetches daily content (word of the day, proverbs, rules, etc).
451
+ * `bypassCache` skips both reading and writing `dailyContentCache` even
452
+ * when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
453
+ * needs a fresh random `/icerik` draw on every attempt; without it, once
454
+ * caching is enabled the loop would just re-read the same cached response
455
+ * 25 times and could never find a rule outside that first random draw.
430
456
  */
431
- public static async getDailyContent(): Promise<DailyContent | null> {
432
- if (this.isCacheEnabled && this.dailyContentCache) return this.dailyContentCache;
433
-
457
+ public static async getDailyContent(bypassCache = false): Promise<DailyContent | null> {
458
+ if (!bypassCache && this.isCacheEnabled && this.dailyContentCache) return this.dailyContentCache;
459
+
434
460
  try {
435
461
  const response = await fetch(`${this.BASE_URL}/icerik`, {
436
462
  headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
437
463
  });
438
464
  if (response.ok) {
439
465
  const data = await response.json() as DailyContent;
440
- if (this.isCacheEnabled) this.dailyContentCache = data;
466
+ if (!bypassCache && this.isCacheEnabled) this.dailyContentCache = data;
441
467
  return data;
442
468
  }
443
469
  } catch {
@@ -480,10 +506,12 @@ export class TDK {
480
506
  * `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
481
507
  * Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
482
508
  * appears to hand back a single randomly-rotated rule per request, so two
483
- * calls a second apart can return entirely different rules.
509
+ * calls a second apart can return entirely different rules. `bypassCache`
510
+ * (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
511
+ * draw even when `enableCache(true)` is on.
484
512
  */
485
- public static async getKurallar(): Promise<TDKRule[]> {
486
- const daily = await this.getDailyContent();
513
+ public static async getKurallar(bypassCache = false): Promise<TDKRule[]> {
514
+ const daily = await this.getDailyContent(bypassCache);
487
515
  return daily?.kural ?? [];
488
516
  }
489
517
 
@@ -492,20 +520,32 @@ export class TDK {
492
520
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
493
521
  * hands back a single randomly-rotated rule per request (out of a pool of
494
522
  * 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.
523
+ * draw would rarely match a given name — this re-draws until it finds a
524
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
525
+ * request is independent and stateless) rather than one-at-a-time with a
526
+ * delay: same total sample size (25) and hit probability as a sequential
527
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
528
+ * a miss resolves in roughly one round-trip time instead of several
529
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
530
+ * `enableCache(true)` is on, every attempt would just re-read the same
531
+ * cached `/icerik` response and could never find a rule outside whatever
532
+ * the first draw happened to be. Returns `null` if no match turns up
533
+ * within the attempt budget or the matched page can't be parsed.
499
534
  */
500
535
  public static async getRule(name: string): Promise<string | null> {
501
536
  if (!name || name.trim() === "") return null;
502
537
  const target = name.trim().toLocaleLowerCase("tr-TR");
503
538
 
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);
539
+ const BATCH_SIZE = 5;
540
+ const ROUNDS = 5;
541
+ for (let round = 0; round < ROUNDS; round++) {
542
+ const batches = await Promise.all(
543
+ Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
544
+ );
545
+ for (const rules of batches) {
546
+ const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
547
+ if (match) return this.fetchRuleText(match.url);
548
+ }
509
549
  }
510
550
  return null;
511
551
  }
@@ -641,6 +681,11 @@ export class TDK {
641
681
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
642
682
  * returning each word's first meaning and etymological origin if found.
643
683
  * Looks each word up individually (throttled), so scales with text length.
684
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
685
+ * it does no morphological analysis, and neither does this method: a
686
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
687
+ * suffix) will come back `found: false` even though the root is a real
688
+ * headword. This is an inherent limitation of the data source, not a bug.
644
689
  */
645
690
  public static async analyzeText(text: string): Promise<WordAnalysis[]> {
646
691
  const words = text
@@ -666,9 +711,13 @@ export class TDK {
666
711
  }
667
712
 
668
713
  /**
669
- * Classic edit-distance between two strings.
714
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
715
+ * like classic Levenshtein but also counts an adjacent-character
716
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
717
+ * two substitutions — a very common class of typo that plain Levenshtein
718
+ * otherwise misses.
670
719
  */
671
- private static levenshtein(a: string, b: string): number {
720
+ private static damerauLevenshtein(a: string, b: string): number {
672
721
  const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
673
722
  for (let i = 0; i <= a.length; i++) dp[i][0] = i;
674
723
  for (let j = 0; j <= b.length; j++) dp[0][j] = j;
@@ -676,6 +725,9 @@ export class TDK {
676
725
  for (let j = 1; j <= b.length; j++) {
677
726
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
678
727
  dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
728
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
729
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
730
+ }
679
731
  }
680
732
  }
681
733
  return dp[a.length][b.length];