tdk-api-wrapper 1.2.1 → 1.3.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/src/tdk.ts CHANGED
@@ -7,12 +7,15 @@ import type {
7
7
  WordComparison,
8
8
  WordAnalysis,
9
9
  TDKRule,
10
+ KubbealtiEntry,
11
+ WiktionaryEntry,
10
12
  } from "./types";
11
13
  import { TDKValidationError, TDKNetworkError } from "./errors";
12
14
  import * as fs from "node:fs";
13
15
  import * as path from "node:path";
14
16
  import * as os from "node:os";
15
17
  import * as https from "node:https";
18
+ import * as tls from "node:tls";
16
19
 
17
20
  /**
18
21
  * TDK (Türk Dil Kurumu) API Wrapper
@@ -20,6 +23,84 @@ import * as https from "node:https";
20
23
  export class TDK {
21
24
  private static readonly BASE_URL = "https://sozluk.gov.tr";
22
25
  private static readonly AUDIO_API_HOST = "api.sozluk.gov.tr";
26
+ private static readonly KUBBEALTI_HOST = "eski.lugatim.com";
27
+
28
+ /**
29
+ * `eski.lugatim.com` (Kubbealtı Lugatı's data API) sends only its leaf
30
+ * certificate during the TLS handshake, omitting the intermediates a
31
+ * correctly configured server would include — a server-side misconfiguration,
32
+ * not something we should paper over by disabling verification. These are
33
+ * the two certificates the server *should* be sending (fetched from the
34
+ * leaf's own Authority Information Access URLs), supplied here so Node can
35
+ * still build a full, properly verified chain up to a root it already
36
+ * trusts (ISRG Root X1). If Let's Encrypt rotates this intermediate, this
37
+ * stops working and every Kubbealtı call fails closed to `null` — same
38
+ * fail-closed contract as the rest of this file's fragile integrations.
39
+ */
40
+ private static readonly KUBBEALTI_EXTRA_CA = [
41
+ `-----BEGIN CERTIFICATE-----
42
+ MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
43
+ MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
44
+ Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
45
+ FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
46
+ DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
47
+ WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
48
+ CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
49
+ OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
50
+ 6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
51
+ N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
52
+ DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
53
+ MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
54
+ MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
55
+ BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
56
+ ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
57
+ SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
58
+ mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
59
+ 8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
60
+ MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
61
+ G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
62
+ hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
63
+ Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
64
+ TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
65
+ NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
66
+ 86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
67
+ LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
68
+ -----END CERTIFICATE-----`,
69
+ `-----BEGIN CERTIFICATE-----
70
+ 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
102
+ -----END CERTIFICATE-----`,
103
+ ];
23
104
 
24
105
  // Cache Mechanism
25
106
  private static isCacheEnabled = false;
@@ -409,17 +490,35 @@ export class TDK {
409
490
  // headword (by edit distance) across TDK's full ~81k-word list (the same
410
491
  // data `getSuggestions()` uses). Restricted to single-token, lowercase
411
492
  // headwords so it doesn't suggest compounds/phrases or proper nouns.
493
+ // Candidates whose length differs too much are skipped before running
494
+ // the O(n*m) distance calculation, both for speed and because a huge
495
+ // length gap can't be within the distance threshold anyway. Ties (same
496
+ // distance) prefer a matching first letter, then a matching length —
497
+ // typos rarely change the first letter, and this avoids picking
498
+ // whatever happens to sort alphabetically first. There's no word
499
+ // frequency data available, so a genuine tie can still land on a
500
+ // technically-correct but less commonly intended word.
412
501
  if (this.autocompleteCache.length === 0) {
413
502
  this.autocompleteCache = await this.fetchAutocompleteData();
414
503
  }
415
504
  const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
416
- let best: { candidate: string; distance: number } | null = null;
505
+ let best: { candidate: string; distance: number; firstMismatch: number; lengthMismatch: number } | null = null;
417
506
  for (const candidate of this.autocompleteCache) {
418
507
  if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR")) continue;
419
- const distance = this.levenshtein(cleanWord, candidate);
420
- if (distance > 0 && (!best || distance < best.distance)) {
421
- best = { candidate, distance };
422
- if (distance === 1) break;
508
+ if (Math.abs(candidate.length - cleanWord.length) > 2) continue;
509
+
510
+ const distance = this.damerauLevenshtein(cleanWord, candidate);
511
+ if (distance === 0) continue;
512
+
513
+ const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
514
+ const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
515
+ const better =
516
+ !best ||
517
+ distance < best.distance ||
518
+ (distance === best.distance && firstMismatch < best.firstMismatch) ||
519
+ (distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch);
520
+ if (better) {
521
+ best = { candidate, distance, firstMismatch, lengthMismatch };
423
522
  }
424
523
  }
425
524
  if (best && best.distance <= 2) {
@@ -502,23 +601,32 @@ export class TDK {
502
601
  * case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
503
602
  * hands back a single randomly-rotated rule per request (out of a pool of
504
603
  * roughly twenty) rather than a fixed catalog, a single `getKurallar()`
505
- * draw would rarely match a given name — this re-draws (bounded, with a
506
- * short delay) until it finds a match or gives up. Every attempt bypasses
507
- * `dailyContentCache` without that, once `enableCache(true)` is on, all
508
- * 25 attempts would just re-read the same cached `/icerik` response and
509
- * could never find a rule outside whatever the first draw happened to be.
510
- * Returns `null` if no match turns up within the attempt budget or the
511
- * matched page can't be parsed.
604
+ * draw would rarely match a given name — this re-draws until it finds a
605
+ * match or gives up. Draws happen in concurrent batches (each `/icerik`
606
+ * request is independent and stateless) rather than one-at-a-time with a
607
+ * delay: same total sample size (25) and hit probability as a sequential
608
+ * loop, but bounded to a handful of round-trips instead of 25 of them, so
609
+ * a miss resolves in roughly one round-trip time instead of several
610
+ * seconds. Every draw bypasses `dailyContentCache` — without that, once
611
+ * `enableCache(true)` is on, every attempt would just re-read the same
612
+ * cached `/icerik` response and could never find a rule outside whatever
613
+ * the first draw happened to be. Returns `null` if no match turns up
614
+ * within the attempt budget or the matched page can't be parsed.
512
615
  */
513
616
  public static async getRule(name: string): Promise<string | null> {
514
617
  if (!name || name.trim() === "") return null;
515
618
  const target = name.trim().toLocaleLowerCase("tr-TR");
516
619
 
517
- for (let attempt = 0; attempt < 25; attempt++) {
518
- const rules = await this.getKurallar(true);
519
- const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
520
- if (match) return this.fetchRuleText(match.url);
521
- await this.delay(100);
620
+ const BATCH_SIZE = 5;
621
+ const ROUNDS = 5;
622
+ for (let round = 0; round < ROUNDS; round++) {
623
+ const batches = await Promise.all(
624
+ Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
625
+ );
626
+ for (const rules of batches) {
627
+ const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
628
+ if (match) return this.fetchRuleText(match.url);
629
+ }
522
630
  }
523
631
  return null;
524
632
  }
@@ -553,15 +661,173 @@ export class TDK {
553
661
  .replace(/<\/(p|div)>/gi, "\n\n")
554
662
  .replace(/<[^>]+>/g, "")
555
663
  .replace(/&nbsp;/gi, " ")
556
- .replace(/&amp;/gi, "&")
664
+ .replace(/&lt;/gi, "<")
665
+ .replace(/&gt;/gi, ">")
557
666
  .replace(/&quot;/gi, '"')
558
667
  .replace(/&#39;|&rsquo;/gi, "'")
668
+ .replace(/&amp;/gi, "&")
559
669
  .replace(/[ \t]+/g, " ")
560
670
  .replace(/[ \t]*\n[ \t]*/g, "\n")
561
671
  .replace(/\n{3,}/g, "\n\n")
562
672
  .trim();
563
673
  }
564
674
 
675
+ /**
676
+ * GETs a JSON path from Kubbealtı Lugatı's data API (`eski.lugatim.com`),
677
+ * supplying `KUBBEALTI_EXTRA_CA` to work around that host's incomplete
678
+ * certificate chain (see the constant's doc comment). Fails closed to
679
+ * `null` on any error — network, TLS, HTTP, or JSON parse.
680
+ */
681
+ private static fetchKubbealtiJson(path: string): Promise<any> {
682
+ return new Promise((resolve) => {
683
+ const req = https.request(
684
+ {
685
+ hostname: this.KUBBEALTI_HOST,
686
+ path,
687
+ method: "GET",
688
+ ca: [...tls.rootCertificates, ...this.KUBBEALTI_EXTRA_CA],
689
+ headers: {
690
+ "User-Agent":
691
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
692
+ },
693
+ },
694
+ (res) => {
695
+ if (res.statusCode !== 200) {
696
+ res.resume();
697
+ resolve(null);
698
+ return;
699
+ }
700
+ let body = "";
701
+ res.on("data", (chunk) => (body += chunk));
702
+ res.on("end", () => {
703
+ try {
704
+ resolve(JSON.parse(body));
705
+ } catch {
706
+ resolve(null);
707
+ }
708
+ });
709
+ }
710
+ );
711
+ req.on("error", () => resolve(null));
712
+ req.end();
713
+ });
714
+ }
715
+
716
+ /**
717
+ * Returns Kubbealtı Lugatı ("Misalli Büyük Türkçe Sözlük") entries for a
718
+ * word, scraped from the site's own data API — undocumented, and Kubbealtı
719
+ * Lugatı is a commercial dictionary product, unlike TDK's or Wiktionary's
720
+ * openly-published data, so use this in line with their terms. `anlam` is
721
+ * raw HTML (rich typography markup); use `getKubbealtiMeanings()` for
722
+ * plain text. Returns `null` on any fetch/parse failure, `[]` if the word
723
+ * isn't found.
724
+ */
725
+ public static async getKubbealti(word: string): Promise<KubbealtiEntry[] | null> {
726
+ if (!word || word.trim() === "") return null;
727
+ const data = await this.fetchKubbealtiJson(`/rest/s/${encodeURIComponent(word.trim())}/`);
728
+ if (!data || !Array.isArray(data.content)) return null;
729
+ return data.content.map((entry: any) => ({ kelime: entry.kelime, anlam: entry.anlam }));
730
+ }
731
+
732
+ /**
733
+ * Same as `getKubbealti()` but with each entry's `anlam` HTML stripped to
734
+ * plain text via `htmlToPlainText()`.
735
+ */
736
+ public static async getKubbealtiMeanings(word: string): Promise<string[] | null> {
737
+ const entries = await this.getKubbealti(word);
738
+ if (!entries) return null;
739
+ return entries.map((e) => this.htmlToPlainText(e.anlam));
740
+ }
741
+
742
+ /**
743
+ * Autocomplete suggestions from Kubbealtı Lugatı's own typeahead endpoint
744
+ * (separate from `getSuggestions()`, which uses TDK's data).
745
+ */
746
+ public static async getKubbealtiSuggestions(prefix: string): Promise<string[]> {
747
+ if (!prefix || prefix.trim() === "") return [];
748
+ const data = await this.fetchKubbealtiJson(`/rest/word-search/${encodeURIComponent(prefix.trim())}`);
749
+ if (!Array.isArray(data)) return [];
750
+ return data.map((item: any) => item.display).filter(Boolean);
751
+ }
752
+
753
+ /**
754
+ * Returns the etymology paragraph for a word from Nişanyan Sözlük, scraped
755
+ * from that page's server-rendered `<meta name="description">` tag (the
756
+ * page already puts the full etymology text there for SEO, so no need to
757
+ * parse the site's internal SvelteKit data format). Returns `null` if the
758
+ * word isn't found (the page falls back to a generic site tagline in that
759
+ * case) or the request fails.
760
+ */
761
+ public static async getNisanyan(word: string): Promise<string | null> {
762
+ if (!word || word.trim() === "") return null;
763
+ try {
764
+ const response = await fetch(
765
+ `https://www.nisanyansozluk.com/kelime/${encodeURIComponent(word.trim().toLocaleLowerCase("tr-TR"))}`,
766
+ { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } }
767
+ );
768
+ if (!response.ok) return null;
769
+ const html = await response.text();
770
+ const match = html.match(/<meta name="description" content="([^"]*)"/);
771
+ if (!match) return null;
772
+ const description = this.htmlToPlainText(match[1]);
773
+ if (description === "Çağdaş Türkçenin Etimolojisi") return null;
774
+ return description;
775
+ } catch {
776
+ return null;
777
+ }
778
+ }
779
+
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;
791
+ try {
792
+ const url = `https://tr.wiktionary.org/w/api.php?action=query&prop=extracts&titles=${encodeURIComponent(
793
+ word.trim()
794
+ )}&format=json&explaintext=1&formatversion=2`;
795
+ const response = await fetch(url, { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } });
796
+ if (!response.ok) return null;
797
+ const data = await response.json();
798
+ const page = data?.query?.pages?.[0];
799
+ if (!page || page.missing || !page.extract) return null;
800
+
801
+ const raw: string = page.extract;
802
+ const sections: Record<string, string> = {};
803
+ const parts = raw.split(/\n(={2,4})\s*(.+?)\s*\1\n/);
804
+ // parts[0] is text before the first heading (usually empty); after
805
+ // that, headings and their following text alternate in triples.
806
+ for (let i = 1; i < parts.length; i += 3) {
807
+ const title = parts[i + 1]?.trim();
808
+ const content = parts[i + 2]?.trim();
809
+ if (title) sections[title] = content ?? "";
810
+ }
811
+ return { raw, sections };
812
+ } catch {
813
+ return null;
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Convenience filter over `getWiktionary()`: returns just one section's
819
+ * text (e.g. `getWiktionarySection(word, "Köken")` for etymology), matched
820
+ * case-insensitively. Returns `null` if the word or the section isn't found.
821
+ */
822
+ public static async getWiktionarySection(word: string, sectionName: string): Promise<string | null> {
823
+ const entry = await this.getWiktionary(word);
824
+ if (!entry) return null;
825
+ const key = Object.keys(entry.sections).find(
826
+ (k) => k.toLocaleLowerCase("tr-TR") === sectionName.trim().toLocaleLowerCase("tr-TR")
827
+ );
828
+ return key ? entry.sections[key] : null;
829
+ }
830
+
565
831
  /**
566
832
  * Returns compound words that contain this word.
567
833
  */
@@ -654,6 +920,11 @@ export class TDK {
654
920
  * Analyzes every distinct word in a text (Turkish stopwords filtered out),
655
921
  * returning each word's first meaning and etymological origin if found.
656
922
  * Looks each word up individually (throttled), so scales with text length.
923
+ * TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
924
+ * it does no morphological analysis, and neither does this method: a
925
+ * suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
926
+ * suffix) will come back `found: false` even though the root is a real
927
+ * headword. This is an inherent limitation of the data source, not a bug.
657
928
  */
658
929
  public static async analyzeText(text: string): Promise<WordAnalysis[]> {
659
930
  const words = text
@@ -679,9 +950,13 @@ export class TDK {
679
950
  }
680
951
 
681
952
  /**
682
- * Classic edit-distance between two strings.
953
+ * Damerau-Levenshtein edit-distance (optimal string alignment variant):
954
+ * like classic Levenshtein but also counts an adjacent-character
955
+ * transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
956
+ * two substitutions — a very common class of typo that plain Levenshtein
957
+ * otherwise misses.
683
958
  */
684
- private static levenshtein(a: string, b: string): number {
959
+ private static damerauLevenshtein(a: string, b: string): number {
685
960
  const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
686
961
  for (let i = 0; i <= a.length; i++) dp[i][0] = i;
687
962
  for (let j = 0; j <= b.length; j++) dp[0][j] = j;
@@ -689,6 +964,9 @@ export class TDK {
689
964
  for (let j = 1; j <= b.length; j++) {
690
965
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
691
966
  dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
967
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
968
+ dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
969
+ }
692
970
  }
693
971
  }
694
972
  return dp[a.length][b.length];
package/src/types.ts CHANGED
@@ -123,4 +123,14 @@ export interface WordAnalysis {
123
123
  origin: string | null;
124
124
  }
125
125
 
126
+ export interface KubbealtiEntry {
127
+ kelime: string;
128
+ anlam: string;
129
+ }
130
+
131
+ export interface WiktionaryEntry {
132
+ raw: string;
133
+ sections: Record<string, string>;
134
+ }
135
+
126
136
  export type TDKResponse = WordInfo[] | { error: string };