Skip to content

Commit 719ee5a

Browse files
committed
fix: fuzzy search the millionth time
1 parent e4e3b3e commit 719ee5a

File tree

3 files changed

+105
-79
lines changed

3 files changed

+105
-79
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@castdrian/kdapi",
3-
"version": "0.3.4",
3+
"version": "0.3.5",
44
"type": "module",
55
"description": "kpop idol and group profiles dataset generator",
66
"main": "dist/index.js",

src/index.ts

Lines changed: 86 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,19 @@ const dataset: DataSet = {
1313

1414
const groupSearchOptions: IFuseOptions<Group> = {
1515
keys: [
16-
{ name: "groupInfo.names.stage", weight: 2.5 },
17-
{ name: "groupInfo.names.korean", weight: 2 },
18-
{ name: "groupInfo.names.japanese", weight: 1.5 },
19-
{ name: "groupInfo.names.chinese", weight: 1.5 },
16+
{ name: "groupInfo.names.stage", weight: 10 },
17+
{ name: "groupInfo.names.korean", weight: 3 },
18+
{ name: "groupInfo.names.japanese", weight: 2 },
19+
{ name: "groupInfo.names.chinese", weight: 2 },
2020
{ name: "groupInfo.fandomName", weight: 0.5 },
2121
{ name: "company.current", weight: 0.3 },
22-
{ name: "memberHistory.currentMembers.name", weight: 0.3 },
22+
{ name: "memberHistory.currentMembers.name", weight: 0.2 },
2323
],
2424
includeScore: true,
25-
threshold: 0.4,
25+
threshold: 0.3,
2626
ignoreLocation: true,
2727
minMatchCharLength: 2,
28+
shouldSort: true,
2829
};
2930

3031
const idolSearchOptions: IFuseOptions<Idol> = {
@@ -35,10 +36,7 @@ const idolSearchOptions: IFuseOptions<Idol> = {
3536
{ name: "names.korean", weight: 2 },
3637
{ name: "names.japanese", weight: 1.5 },
3738
{ name: "names.chinese", weight: 1.5 },
38-
{
39-
name: "groups.name",
40-
weight: 1,
41-
},
39+
{ name: "groups.name", weight: 1 },
4240
],
4341
includeScore: true,
4442
threshold: 0.3,
@@ -66,7 +64,11 @@ export function search(
6664
} = {},
6765
) {
6866
const { type = "all", limit = 10, threshold } = options;
69-
const results: { item: Idol | Group; type: "idol" | "group" }[] = [];
67+
let results: {
68+
item: Idol | Group;
69+
type: "idol" | "group";
70+
score?: number;
71+
}[] = [];
7072

7173
// Split query into words for better matching
7274
const words = query.toLowerCase().trim().split(/\s+/);
@@ -93,70 +95,94 @@ export function search(
9395
: idolSearcher;
9496

9597
if (hasMultipleWords) {
98+
// Split search into individual words
9699
const [firstWord, ...restWords] = words;
97-
if (!firstWord) return [];
98-
const restWordsStr = restWords.join(" ");
99-
100-
// Search for idols matching the first word
101-
const potentialIdols = idolSearcherInstance.search(firstWord);
102-
103-
// Add matches where idol belongs to the specified group
104-
for (const idolResult of potentialIdols) {
105-
const idol = idolResult.item;
106-
if (idol.groups?.some((g) => g.name.toLowerCase() === restWordsStr)) {
107-
results.push({
108-
item: idol,
109-
type: "idol",
110-
});
111-
}
112-
}
113100

114-
// Try reverse order (group first, then idol name)
115-
const reversePotentialIdols = idolSearcherInstance.search(restWordsStr);
116-
for (const idolResult of reversePotentialIdols) {
117-
const idol = idolResult.item;
118-
if (idol.groups?.some((g) => g.name.toLowerCase() === firstWord)) {
119-
results.push({
120-
item: idol,
121-
type: "idol",
122-
});
123-
}
124-
}
101+
// Search for the first word
102+
const firstWordResults = firstWord ? search(firstWord, { limit: 50 }) : [];
103+
104+
// Filter results that match all words
105+
results = firstWordResults.filter((result) => {
106+
const textToSearch =
107+
result.type === "group"
108+
? [
109+
(result.item as Group).groupInfo.names.stage,
110+
...((result.item as Group).memberHistory?.currentMembers?.map(
111+
(m) => m.name,
112+
) || []),
113+
]
114+
: [
115+
(result.item as Idol).names.stage,
116+
(result.item as Idol).names.full,
117+
...((result.item as Idol).groups?.map((g) => g.name) || []),
118+
];
119+
120+
return restWords.every((word) =>
121+
textToSearch.some((text) => text?.toLowerCase().includes(word)),
122+
);
123+
});
124+
} else {
125+
const normalizedQuery = query.toLowerCase().trim();
126+
127+
// First check for exact group matches if we're not specifically searching for idols
128+
if (type !== "idol") {
129+
const exactGroupMatches = [
130+
...dataset.girlGroups,
131+
...dataset.boyGroups,
132+
...dataset.coedGroups,
133+
]
134+
.filter(
135+
(group) =>
136+
group.groupInfo.names.stage?.toLowerCase() === normalizedQuery,
137+
)
138+
.map((group) => ({
139+
item: group,
140+
type: "group" as const,
141+
score: 0, // Give exact matches the highest priority
142+
}));
143+
144+
if (exactGroupMatches.length > 0) {
145+
results.push(...exactGroupMatches);
125146

126-
// If no exact matches found, fall back to fuzzy search
127-
if (results.length === 0) {
128-
for (const idolResult of potentialIdols) {
129-
const idol = idolResult.item;
130-
if (
131-
idol.groups?.some((g) => g.name.toLowerCase().includes(restWordsStr))
132-
) {
133-
results.push({
134-
item: idol,
135-
type: "idol",
136-
});
147+
// If we only want groups, return here
148+
if (type === "group") {
149+
return exactGroupMatches.slice(0, limit);
137150
}
138151
}
139152
}
140-
} else {
153+
154+
// If no exact matches or we want all results, proceed with fuzzy search
155+
if (type === "all" || type === "group") {
156+
const groupResults = groupSearcherInstance.search(query);
157+
results.push(
158+
...groupResults
159+
.filter(
160+
(result) =>
161+
// Exclude exact matches we already added
162+
result.item.groupInfo.names.stage?.toLowerCase() !==
163+
normalizedQuery,
164+
)
165+
.map((result) => ({
166+
item: result.item,
167+
type: "group" as const,
168+
score: result.score || 1,
169+
})),
170+
);
171+
}
172+
141173
if (type === "all" || type === "idol") {
142174
const idolResults = idolSearcherInstance.search(query);
143175
results.push(
144176
...idolResults.map((result) => ({
145177
item: result.item,
146178
type: "idol" as const,
179+
score: result.score || 1,
147180
})),
148181
);
149182
}
150183

151-
if (type === "all" || type === "group") {
152-
const groupResults = groupSearcherInstance.search(query);
153-
results.push(
154-
...groupResults.map((result) => ({
155-
item: result.item,
156-
type: "group" as const,
157-
})),
158-
);
159-
}
184+
// Sort results by score (lower is better)
185+
results = results.sort((a, b) => (a.score || 1) - (b.score || 1));
160186
}
161187

162188
// Remove duplicates
@@ -168,21 +194,4 @@ export function search(
168194
return uniqueResults.slice(0, limit);
169195
}
170196

171-
export type { Idol, Group };
172-
export function getItemById(
173-
id: string,
174-
): { item: Idol | Group; type: "idol" | "group" } | null {
175-
const idol = [...dataset.femaleIdols, ...dataset.maleIdols].find(
176-
(i) => i.id === id,
177-
);
178-
if (idol) return { item: idol, type: "idol" };
179-
180-
const group = [
181-
...dataset.girlGroups,
182-
...dataset.boyGroups,
183-
...dataset.coedGroups,
184-
].find((g) => g.id === id);
185-
if (group) return { item: group, type: "group" };
186-
187-
return null;
188-
}
197+
export type { Idol, Group, DataSet, GroupsData, IdolsData };

test/search.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe("Fuzzy Search", () => {
2020
results.some(
2121
(r) =>
2222
r.type === "idol" &&
23-
(r.item as Idol).groups?.some((g) =>
23+
(r.item as Idol).groups?.some((g: { name: string; }) =>
2424
g.name.toLowerCase().includes("stayc"),
2525
),
2626
),
@@ -33,6 +33,23 @@ describe("Fuzzy Search", () => {
3333
expect(results.some((r) => r.type === "group")).toBe(true);
3434
});
3535

36+
test("should return both group and members when searching for group name", () => {
37+
const results = search("stayc");
38+
const hasGroup = results.some((r) => r.type === "group");
39+
const hasMembers = results.some((r) => r.type === "idol");
40+
expect(hasGroup).toBe(true);
41+
expect(hasMembers).toBe(true);
42+
});
43+
44+
test("should prioritize exact group name matches", () => {
45+
const results = search("le sserafim");
46+
expect(results.length).toBeGreaterThan(0);
47+
expect(results[0]?.type).toBe("group");
48+
expect(
49+
(results[0]?.item as Group).groupInfo?.names?.stage?.toLowerCase(),
50+
).toContain("le sserafim");
51+
});
52+
3653
test("should handle Korean characters", () => {
3754
const results = search("스테이씨");
3855
expect(results.length).toBeGreaterThan(0);

0 commit comments

Comments
 (0)