fix(character-dictionary): drop single-letter mob name disambiguators

- AniList mob names like 女子A/"Joshi A" romanize their trailing
  disambiguator letter into a single kana term (A → ア) that collides
  with common interjections (あ〜); filter these out unless kanji
- bump CHARACTER_DICTIONARY_FORMAT_VERSION to 20 to invalidate cached
  dictionaries built with the old term set
- add tests covering the dropped disambiguator and a kept single-kanji
  name part
This commit is contained in:
2026-08-04 17:42:29 -07:00
parent 8683961967
commit d878d8bf4f
3 changed files with 51 additions and 4 deletions
@@ -1,7 +1,7 @@
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export const ANILIST_REQUEST_DELAY_MS = 2000;
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 19;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 20;
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
export const HONORIFIC_SUFFIXES = [
@@ -36,3 +36,35 @@ test('buildNameTerms adds surname honorifics from Japanese localized aliases', (
assert.ok(terms.includes('馬渕さん'));
assert.ok(!terms.includes('송치'));
});
test('buildNameTerms drops the disambiguator letter of a mob character name', () => {
const terms = buildNameTerms(
characterRecord({
firstNameHint: '',
lastNameHint: '',
fullName: 'Joshi A',
nativeName: '女子A',
}),
);
// ア would match every あ〜 in the subtitles; the letter is a disambiguator
// (Girl A / Girl B), not a name.
assert.ok(!terms.includes('ア'));
assert.ok(!terms.includes('アさん'));
assert.ok(terms.includes('女子A'));
assert.ok(terms.includes('ジョシア'));
});
test('buildNameTerms keeps a single-kanji name part', () => {
const terms = buildNameTerms(
characterRecord({
firstNameHint: 'Sora',
lastNameHint: 'Yamada',
fullName: 'Sora Yamada',
nativeName: '山田 空',
}),
);
assert.ok(terms.includes('山田'));
assert.ok(terms.includes('空'));
});
@@ -42,6 +42,15 @@ export function expandRawNameVariants(rawName: string): string[] {
return [...variants];
}
// AniList disambiguates unnamed mob characters with a trailing letter (女子A /
// "Joshi A"), and a single letter romanizes into a single-kana alias (A → ア)
// that collides with interjections (あ〜 matching ア). A one-character form is
// only a real lookup target when it is kanji, so every other single-character
// form is dropped before it can become a term.
function isUsableNameTerm(name: string): boolean {
return [...name].length > 1 || containsKanji(name);
}
export function isJapaneseNameSplitCandidate(name: string): boolean {
const compact = name.replace(/[\s\u3000・・·•]/g, '');
return (
@@ -97,8 +106,11 @@ export function buildNameTerms(
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
target.add(split[0]!);
target.add(split[1]!);
for (const part of split) {
if (isUsableNameTerm(part)) {
target.add(part);
}
}
}
const splitByMiddleDot = name
@@ -107,7 +119,9 @@ export function buildNameTerms(
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
target.add(part);
if (isUsableNameTerm(part)) {
target.add(part);
}
}
}
@@ -136,6 +150,7 @@ export function buildNameTerms(
const withHonorifics = new Set<string>();
for (const entry of base) {
if (!isUsableNameTerm(entry)) continue;
withHonorifics.add(entry);
for (const suffix of HONORIFIC_SUFFIXES) {
withHonorifics.add(`${entry}${suffix.term}`);