closewords
Version:
A library for finding the most similar word from a list of words, supporting Japanese (including kanji). / 最も似た単語を単語群から検索する日本語(漢字含む)対応のライブラリ
109 lines (108 loc) • 4.56 kB
JavaScript
import { t as isAlphabetOnly } from "./isAlphabetOnly-BQFCZl-E.mjs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import jaroWinkler from "jaro-winkler";
import * as levenshtein from "fast-levenshtein";
import { Worker } from "worker_threads";
import path$1 from "path";
//#region node_modules/tsdown/esm-shims.js
const getFilename = () => fileURLToPath(import.meta.url);
const getDirname = () => path.dirname(getFilename());
const __dirname = /* @__PURE__ */ getDirname();
const __filename = /* @__PURE__ */ getFilename();
//#endregion
//#region src/index.ts
let workerInstance = null;
let requestId = 0;
const pending = /* @__PURE__ */ new Map();
function getWorker() {
if (workerInstance) return workerInstance;
const isTs = __filename?.endsWith(".ts") ?? false;
const workerExt = isTs ? ".ts" : __filename?.endsWith(".mjs") ? ".mjs" : ".cjs";
const w = new Worker(path$1.resolve(__dirname, `./worker${workerExt}`), { execArgv: isTs ? ["--require", "tsx/cjs"] : [] });
w.on("message", (msg) => {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.error) p.reject(new Error(msg.error));
else p.resolve(msg.results);
});
w.on("error", (err) => {
for (const p of pending.values()) p.reject(err);
pending.clear();
workerInstance = null;
});
w.on("exit", () => {
for (const p of pending.values()) p.reject(/* @__PURE__ */ new Error("Worker exited unexpectedly"));
pending.clear();
workerInstance = null;
});
w.unref();
workerInstance = w;
return w;
}
function convertToRomaji(words, dicPath) {
const worker = getWorker();
const id = ++requestId;
return new Promise((resolve, reject) => {
pending.set(id, {
resolve,
reject
});
worker.postMessage({
id,
words,
dicPath
});
});
}
async function closeWords(word, candidates, raw = false) {
if (typeof word !== "string" && (typeof word !== "object" || !word.word)) throw new Error("word must be a string or an object with 'word'.");
if (typeof word === "object" && word.pronounce && !isAlphabetOnly(word.pronounce)) throw new Error("word.pronounce must be an alphabetic string.");
if (!Array.isArray(candidates) || !candidates.every((item) => typeof item === "string" || typeof item === "object" && item.word)) throw new Error("Candidates must be an array of strings or objects with 'word'.");
if (!candidates.filter((c) => typeof c === "object" && !!c.pronounce).every((item) => isAlphabetOnly(item.pronounce))) throw new Error("pronounces within candidates must be alphabetic strings.");
if (typeof raw !== "boolean") throw new Error("raw must be boolean.");
const dicPath = path$1.resolve(__dirname, "./dict");
const romajiWords = await convertToRomaji([word, ...candidates], dicPath);
const romajiWord = romajiWords[0];
const romajiCandidates = romajiWords.slice(1);
const searchWord = typeof word === "string" ? word : word.word;
const baseLength = searchWord.length;
const searchChars = Array.from(searchWord);
const scores = candidates.map((candidate, index) => {
const candidateWord = typeof candidate === "string" ? candidate : candidate.word;
if (searchWord === candidateWord) return {
word: candidateWord,
score: 1
};
const candidateLength = candidateWord.length;
const maxLen = Math.max(baseLength, candidateLength);
const romajiScore = jaroWinkler(romajiWord, romajiCandidates[index]);
const stringScore = 1 - levenshtein.get(searchWord, candidateWord) / maxLen;
const candidateChars = Array.from(candidateWord);
let prefixMatch = 0;
const minLen = Math.min(searchChars.length, candidateChars.length);
for (let i = 0; i < minLen; i++) if (searchChars[i] === candidateChars[i]) prefixMatch++;
else break;
const prefixRatio = prefixMatch / maxLen;
let charMatchCount = 0;
for (const ch of searchChars) if (candidateWord.includes(ch)) charMatchCount++;
const charRatio = charMatchCount / maxLen;
const exactCharBonus = charRatio * .4;
const lengthPenalty = Math.max(.7, 1 - Math.abs(baseLength - candidateLength) / baseLength);
const prefixBonus = prefixRatio > 0 ? prefixRatio * .05 : 0;
const combinedScore = (romajiScore * .7 + stringScore * .2 + charRatio * .1) * lengthPenalty + exactCharBonus + prefixBonus;
return {
word: candidateWord,
score: Math.min(combinedScore, 1)
};
});
scores.sort((a, b) => b.score - a.score);
if (!raw) {
const maxScore = scores[0]?.score;
return scores.filter((item) => item.score === maxScore).map((item) => item.word);
}
return scores;
}
//#endregion
export { closeWords };