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/README.md +14 -3
- package/dist/{chunk-6BTOGV2M.mjs → chunk-SLNXKZKR.mjs} +284 -21
- package/dist/cli.js +329 -23
- package/dist/cli.mjs +46 -3
- package/dist/index.d.mts +97 -10
- package/dist/index.d.ts +97 -10
- package/dist/index.js +284 -21
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/cli.ts +41 -1
- package/src/tdk.ts +298 -20
- package/src/types.ts +10 -0
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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(/ /gi, " ")
|
|
556
|
-
.replace(/&
|
|
664
|
+
.replace(/</gi, "<")
|
|
665
|
+
.replace(/>/gi, ">")
|
|
557
666
|
.replace(/"/gi, '"')
|
|
558
667
|
.replace(/'|’/gi, "'")
|
|
668
|
+
.replace(/&/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
|
-
*
|
|
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
|
|
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 };
|