@finbheara/names

中文文档
0.31.0发布于 2026/11/07MITTypeScriptES Module近 30 天下载 3357

A curated lexicon of personal, school and place names, the surname-particle vocabulary, a phrase classifier and a person-name normalizer

原始使用文档
文档目录18 节
  1. Entry points
  2. Normalize a name
  3. The levels
  4. A corrupted print
  5. Factories
  6. A document's name order
  7. Scoring a splitter against declared prints
  8. How two names are related
  9. A given name alone, against a known set
  10. The given-name tables, for another language
  11. Phonetic and blocking keys
  12. A name as database columns
  13. The lexicon
  14. names.txt
  15. Classify a phrase
  16. Measure a stream of names
  17. Contributing
  18. Licence

@finbheara/names

Person names as they are actually printed: parsed, normalized, compared, and told apart from school and place names.

  • A curated lexicon (names.txt): ~15,000 given names, surnames, places, schools and multi-word phrases, each flagged for what it is.
  • The surname-particle vocabulary: Mac, Ní, O, van, de and the rest, with one display spelling each.
  • A person-name normalizer: one printed cell in, a PersonName out, with a canonical display form, a filing key and a matching key.
  • A phrase classifier: is this string a person, a school or a place, and where does the person end and the school begin?
  • Measurement tools: run a stream of names through all of the above and see what it does not recognise.

It was built on results sheets from Irish dance competitions, where one dancer's name arrives as O'RERVTON, MOIRA, Moira ORervton and Moira O Rervton, and a school's name is fused onto the dancer's. The rules are general to Anglo, Irish and European names.

npm install @finbheara/names

ESM, Node ≥ 22.3 or Bun ≥ 1.4.3. MIT. No entry imports a node: module, so every entry bundles for a browser or worker as is; only Lexicon.load, which reads a file, needs Node or Bun at call time.

Entry points

Import only what you use; /lexicon, /particles, /normalize and /phonetic never load the classifier's bloom tables.

importwhat
@finbheara/names/normalizePersonName, its factories, compare, compose, lists
@finbheara/names/lexiconnames.txt parsed: flags, multi-word phrases, spellings, roles
@finbheara/names/particlesthe particle vocabulary
@finbheara/names/classifierperson / school / location classification and segmentation
@finbheara/names/phoneticDouble Metaphone: how a word sounds, as a key
@finbheara/names/measurethe census and the classifier evaluation
@finbheara/namesall of the above
@finbheara/names/names.txtthe raw lexicon

Normalize a name

import { isNoNamePrint, personName } from "@finbheara/names/normalize";

const n = personName("WYRKOW NÍ MHAOLVARNA");
n.nn2;           // "NiMhaolvarna<<Wyrkow"      canonical display form
n.nn3;           // "NIMHAOLVARNA<<WYRKOW"      filing key
n.nn4;           // "NIMHAOLVARNA<WYRKOW"       order-free matching key
n.surnameKey();  // "NIMHAOLVARNA"
n.givenName();   // "WYRKOW"
n.surname;       // { particles: ["NÍ"], base: "MHAOLVARNA", tradition: "i", ... }
n.isPerson();    // true

personName("ORERVTON, MOIRA", "surname-first").nn2;  // "O'Rervton<<Moira"
personName("Foireann A").isTeam();                  // true: a team, not a person
personName("Kelsey Pearraillina - BN2018").refusal;       // { rule: "refuse-digit", ... }
personName("N/A").printsNoName();                   // true: a placeholder, no name printed
personName("").isEmpty();                           // true: nothing printed at all
isNoNamePrint("Not known");                         // true, from the print alone (no parse)

Every input gets a PersonName; a cell that names no person is refused, not thrown: isPerson() is false, refusal says which rule, and every level from NN2 down reads PersonName.REFUSED ("!!!INVALID"). An empty cell is neither a person nor a refusal: it answers isEmpty(). printsNoName() is true for an empty cell, a cell with no letter (???) and a placeholder (N/A, TBA, Unknown, Not known), whichever rule refused it.

The levels

leveliscaseaccentsapostrophesfield order
nn0exactly what the page saidkeptkeptkeptkept
nn1nn0 with whitespace, quotes, control bytes and a named corruption repairedkeptkeptnormalizedkept
nn2the canonical display form (Surname<<Given<Middle)derivedASCIIkeptkept
nn3the filing keyupperASCIIdroppedkept
nn4the matching key: are two prints the same name?upperASCIIdroppeddropped

Persist a level string, never the object: nn2 to display, nn3 as a key. Read a stored nn2 back with personNameFromIcao(nn2) (or factory.fromIcao). The full contract is in docs/NAME-NORMALIZATION.md.

A corrupted print

A print whose bytes went wrong on the way (UTF-8 read as Windows 1252, read twice, read as Mac OS Roman, a dropped byte, a stray control) says so. corruption names each run, and the levels read its repair, while nn0 keeps the raw print:

const n = personName("Méabh Varane", "given-first");
n.nn0;              // "Méabh Varane"
n.nn1;              // "Méabh Varane"
n.nn2;              // "Varane<<Meabh"
n.corruption?.kind; // "utf8-as-cp1252"
n.corruption?.spans; // [{ kind: "utf8-as-cp1252", at: 1, raw: "é", repair: "é", confidence: 0.98 }]

diagnoseNameCorruption(print, knownNameOf(lexicon)) is the same answer for a bare string. A dropped byte is read as a wildcard against the lexicon, and a lost letter (U+FFFD) is named and never filled. The kinds and the confidences are in docs/NAME-NORMALIZATION.md, A corrupted print.

Factories

personName is the default factory's shorthand: a caching factory over the committed names.txt. Make your own when you need other options; a factory's options are fixed when it is made, and no factory can change another's answers.

import { createCachingPersonNameFactory, createPersonNameFactory } from "@finbheara/names/normalize";

const names = createCachingPersonNameFactory({
  lexicon: myLexicon,             // a Lexicon or a (key) => spelling lookup; null for rules only
  traditions: null,               // no tradition lookup
  parenthetical: (aside) =>       // what "( … )" or "\" … \"" in a name cell means
    aside === "Ontario" ? { refuse: "refuse-location-alias" } : "strip",
  capacity: 100_000,              // memo size; 0 disables the memo
});
names.parse("Anna (Ontario) Mailtina").isPerson();  // false
names.parse("Lena (Xiaoyu) Zumock").nn2;           // "Zumock<<Lena"
names.stats();                                   // { hits, misses, evictions, size, capacity }

const uncached = createPersonNameFactory();      // parses every call

The default parenthetical handler strips every aside: Pundeallina "Katie" Foxe is Foxe<<Pundeallina.

A caching factory holds one entry per print and the order it was READ in: a print read with the order unknown and again with the order that resolves to is one parse and one object. Past capacity (default DEFAULT_PERSON_NAME_CAPACITY, 262,144) the least recently used entry is evicted; stats().evictions counts them.

The shared default factory's capacity can be set once per process, before anything parses through it. The capacity is the only option it takes, since it is the only one that changes no answer:

import { configureDefaultPersonNameFactory, personName } from "@finbheara/names/normalize";

configureDefaultPersonNameFactory({ capacity: 1_000_000 }); // ~1 KB an entry
personName("Moira O'Rervton");
configureDefaultPersonNameFactory({ capacity: 10 });        // throws: already made

It throws once the default factory exists, whether an earlier call made it or a first personName, personNameFromIcao or defaultPersonNameFactory() did.

A document's name order

deriveNameFormat reads the name cells of ONE document (a results sheet, a roster, a column) and says which order it prints them in, as probabilities over First Last, Last, First and Last First, with "unknown" where none clears the threshold or the document prints both orders. It also reports hasSchool and hasLocation (do most cells carry a school or a place after the name?) and each print's reading, so a caller can strip those parts before parsing.

import { createSegmenter } from "@finbheara/names/classifier";
import { Lexicon } from "@finbheara/names/lexicon";
import { deriveNameFormat, nameFormatOrder, readNameCell } from "@finbheara/names/normalize";

// invented names, in a lexicon of their own
const lexicon = Lexicon.fromText("Zelvine:F\nOramie:F\nTalwyn:F\nQuorrish:L\nVantrell:L\nOspindle:L\nZandrel:G");
const segmenter = createSegmenter(lexicon);   // optional: reads the school part; same lexicon
const doc = deriveNameFormat(
  ["Quorrish, Zelvine", "Vantrell, Oramie - Zandrel", "Ospindle, Talwyn", "Quorrish, Oramie", "Vantrell, Talwyn"],
  { lexicon, segmenter },
);
doc.verdict;                    // "Last, First"
nameFormatOrder(doc.verdict);   // "surname-first": pass it as the document's declared order
doc.readings[1].name;           // "Vantrell, Oramie"
doc.readings[1].location;       // "Zandrel"

deriveNameFormat(["QUORRISH ZELVINE", "VANTRELL ORAMIE"], { lexicon }).verdict;  // "unknown": too few
readNameCell("QUORRISH ZELVINE", { lexicon }).order;                           // "surname-first:two-token"

The verdict is a declaration the caller makes about a document; grouping rows into documents is the caller's.

A declared order beats the comma; the comma decides only when nothing is declared:

personName("Fennick, Orla", "given-first").nn2;  // "Orla<<Fennick": the declaration decides
personName("Fennick, Orla", "unknown").nn2;      // "Fennick<<Orla": the comma decides

On a given-first page a comma is far more often punctuation inside a given-first name than an inversion. A caller that knows a page mixes Surname, Given cells into a given-first document passes unknown for that page, so the comma decides each cell. deriveNameFormat reports such a page as unknown (mixed) when it holds many of them (3 among 6 given-first cells), and nameFormatOrder maps that to unknown; a single stray cell (1 among 6) still derives First Last. bun run names order <file> prints the same for a file of name cells, one per line. The specification is docs/NAME-NORMALIZATION.md.

Scoring a splitter against declared prints

A Last, First print names its own surname span. buildAnchorIndex collects those spans from prints whose document declared its order, anchoredSurname finds the span for a print of the same name in the other order, and primedVerdict says whether a splitter put the boundary there. The splitter is an argument, so a deliberately wrong control (edgeOnlySplit, compoundLessSplit) shows the bar can fail. unprimedTally scores an order guess with the declaration withheld, and clearsUnprimedFloor compares its balanced rate to UNPRIMED_FLOOR.

import {
  anchoredSurname, buildAnchorIndex, edgeOnlySplit, primedVerdict, splitPersonName,
} from "@finbheara/names/normalize";

const prints = [
  { name: "van der Quell, Pealling", declared: "Last, First" as const },
  { name: "Pealling van der Quell", declared: "First Last" as const },
];
const anchor = anchoredSurname(prints[1], buildAnchorIndex(prints));  // "vanderquell"
primedVerdict(prints[1], anchor, splitPersonName).verdict;             // "consistent"
primedVerdict(prints[1], anchor, edgeOnlySplit).verdict;               // "inconsistent"
import {
  createGivenFamilyList, createNameIndex, createNameKinship, givenFamilyTable, nameKinship,
  personName,
} from "@finbheara/names/normalize";

nameKinship(personName("Kate Smith"), personName("Katherine Smith"));
// { relation: "candidate", via: ["given-family"], reason: "middles-equal", gateSafe: true }
nameKinship(personName("Mary Larson"), personName("Mary Conley-Larson")).via;  // ["surname-part"]
nameKinship(personName("Mary Larson"), personName("Mary Carlson")).reason;     // "surname-differs"
nameKinship(personName("Kate M Smith"), personName("Katherine Conley-Smith")).via;
// ["surname-part", "given-family", "middle-absent"]
nameKinship(personName("Connor Smith"), personName("Conor Smith")).via;       // ["given-variant"]
nameKinship(personName("Evie Smith"), personName("Genevieve Smith"));
// { relation: "candidate", via: ["given-family-not-gate"], reason: "middles-equal", gateSafe: false }
nameKinship(personName("Beth Smith"), personName("Bethany Smith"));   // the extended family list
// { relation: "candidate", via: ["given-maybe"], reason: "middles-equal", gateSafe: false }
nameKinship(personName("Grace Zumock"), personName("Ailbhe Grace Zumock"));  // a middle name used
// { relation: "candidate", via: ["given-middle"], reason: "middles-equal", gateSafe: false }

// four opt-in readings, each off by default: a suffix one side lacks, a run of initials,
// a hyphenated first name with its first half spelt another way, an Irish surname form
const wide = createNameKinship({
  suffixAbsent: true, initialRuns: true, joinedVariants: true, surnameForms: true,
});
wide(personName("Lena Zumock"), personName("Lena Zumock II")).via;      // ["suffix-absent"]
wide(personName("JR Zumock"), personName("Zumock, Joseph R.")).via;     // ["given-initials"]
wide(personName("Ann Marie Zumock"), personName("Zumock, Anne-Marie")).via;
// ["given-joined", "given-family"]
wide(personName("Lena Nolan"), personName("Ó Nualláin, Lena")).via;     // ["surname-form"]

// the given-name tolerance is a list of providers, asked in order
const tableOnly = createNameKinship({ given: [givenFamilyTable] });
tableOnly(personName("Beth Smith"), personName("Bethany Smith")).reason;  // "given-differs"
const common = createNameKinship({
  given: [givenFamilyTable, createGivenFamilyList({ maxTier: 1 })],  // common forms only
});
common(personName("Maggie Smith"), personName("Marjorie Smith")).reason;  // "given-differs"

const index = createNameIndex<number>("nn3");   // a NameMap with a tolerant lookup
index.set(personName("Mary Conley-Larson"), 1).set(personName("Mary Carlson"), 2);
index.get(personName("Mary Larson"));            // undefined: get stays exact
index.related(personName("Mary Larson"));        // [{ name: Conley-Larson<<Mary, value: 1, kinship: … }]

To block a set of RECORDS (two records may print one name), hold them in a blocker and take the pairs; each related pair comes once, without comparing every pair:

import { createNameBlocker, personName } from "@finbheara/names/normalize";

const blocker = createNameBlocker<string>();
blocker
  .add("row-1", personName("Mary Larson"))
  .add("row-2", personName("Mary Conley-Larson"))
  .add("row-3", personName("Mary Larson"))
  .add("row-4", personName("Mary Carlson"));
for (const { a, b, kinship } of blocker.pairs()) console.log(a.id, b.id, kinship.relation);
// row-1 row-3 same
// row-1 row-2 candidate
// row-2 row-3 candidate

candidate is never a merge: it says two prints MAY be one person. via lists every tolerance the pair used, and gateSafe is false when any of them is one a gate must not admit on its own.

A ? (or U+FFFD) in a print is a letter the capture lost. The levels keep what the print kept, and nameKinship reads each ? as exactly one letter where the prints disagree without it:

import { lostLetterForm, nameKinship, personName } from "@finbheara/names/normalize";

nameKinship(personName("Zoë Bérard"), personName("Zo? B?rard"));
// { relation: "candidate", via: ["lost-letter"], reason: "middles-equal", gateSafe: false }
personName("Orlaith N? Varraigh").nn2;              // "NiVarraigh<<Orlaith": `N?` can only be Ní
lostLetterForm(personName("Zo? B?rard")).surname;   // "B?RARD": NN3's field, the `?` kept

A name read the Spanish way (given names, a paternal and a maternal surname) is opt-in. Where the default relation says different, spanishNames: true reads each print every way it may be cut, keeps a compound joined by a particle or y whole, and relates a full form to a shorter one:

import { createNameIndex, createNameKinship, personName } from "@finbheara/names/normalize";

const spanish = createNameKinship({ spanishNames: true });
spanish(personName("Ana Inés Varrondo Quelmar"), personName("Inés Varrondo"));
// { relation: "candidate", via: ["surname-maternal", "given-middle", "spanish"],
//   reason: "middle-absent", gateSafe: false }
spanish(personName("Sofía Ostrevo Landar"), personName("SOFIA OSTREVO")).via;
// ["surname-maternal", "spanish"]
spanish(personName("Ailbhe Grace Zumock"), personName("Ailbhe Grace")).relation;
// "different": `Grace` is a given name, never the first surname
spanish(personName("Renata Ruelas y Terzán"), personName("Renata Ruelas")).relation;
// "different": `Ruelas y Terzán` is one surname

const index = createNameIndex<number>("nn3", { spanishNames: true });
index.set(personName("Ana Inés Varrondo Quelmar"), 1);
index.related(personName("Inés Varrondo")).map((m) => m.value);  // [1]

A name read by OCR from a scanned page may carry glyphs the recogniser confused: a lower-case l for a capital I, rn for m. ocrConfusion(a, b) compares two WORDS and answers the runs it read as their confusables, [] where the words already agree as NN3 reads them, or null. It renders nothing and makes no key: the table (OCR_CONFUSABLES, frozen data) is read glyph for glyph, case included, and the package sets no threshold, so a caller counts the runs or refuses a class itself. createNameKinship({ ocrConfusables: true }) reads it field by field where the relation would say different, via ocr-glyph (never gate-safe), and every answer that used it carries the runs as confusions; createNameIndex and createNameBlocker take the same option, so each match carries the same array:

import {
  OCR_CONFUSABLES, createNameIndex, createNameKinship, ocrConfusion, personName,
} from "@finbheara/names/normalize";

ocrConfusion("Quamby", "Quarnby");  // [{ class: "rn-m", a: "m", b: "rn", at: 3 }]
ocrConfusion("lvarra", "Ivarra");   // [{ class: "I-l", a: "l", b: "I", at: 0 }]
ocrConfusion("LVARRA", "IVARRA");   // null: a capital L is no capital I's confusable
ocrConfusion("Quarnby", "QUARNBY"); // []: equal as NN3 reads them

const ocr = createNameKinship({ ocrConfusables: true });
ocr(personName("Kate Quamby"), personName("Kate Quarnby"));
// { relation: "candidate", via: ["ocr-glyph"], reason: "middles-equal", gateSafe: false,
//   confusions: [{ class: "rn-m", a: "m", b: "rn", at: 3, field: "surname", index: 0 }] }
const noRn = createNameKinship({
  ocrConfusables: OCR_CONFUSABLES.filter((r) => r.class !== "rn-m"),
});
noRn(personName("Kate Quamby"), personName("Kate Quarnby")).relation;  // "different"

const index = createNameIndex<number>("nn3", { ocrConfusables: true });
index.set(personName("Kate Quarnby"), 1);
index.related(personName("Kate Quamby")).map((m) => m.kinship.confusions);  // [[{ class: "rn-m", … }]]

A given name alone, against a known set

import { createNameRoster, givenOnlyName, personName } from "@finbheara/names/normalize";

givenOnlyName("Lena").nn2;     // "<<Lena": the caller declares the print given-only
personName("Lena").isPerson(); // false: undeclared, one word is still refused
givenOnlyName("Lena Z.").nn2;  // "Z<<Lena": a trailing single letter is the surname initial

const roster = createNameRoster(
  ["Lena Zumock", "Mairéad Rervton", "Lena Brambleton", "Éilis Zumock"].map((p) => personName(p)),
);
roster.match(givenOnlyName("Mairead")); // { kind: "unique", name: Rervton<<Mairead }
roster.match(givenOnlyName("eilis"));   // { kind: "unique", name: Zumock<<Eilis }
roster.match(givenOnlyName("Lena"));    // { kind: "ambiguous", names: [Brambleton<<Lena, Zumock<<Lena] }
roster.match(givenOnlyName("Lena Z"));  // { kind: "unique", name: Zumock<<Lena }
roster.match(givenOnlyName("Orla"));    // { kind: "none", reason: "no-candidate" }

Only unique is an answer: the roster never picks between candidates. A print is matched by nameKinship, where a missing surname (surname-absent) or an initial (surname-initial) cannot rule a pair out and is never gate-safe. The default relation uses the curated given-name table, so Kate finds Katherine; pass { kinship: createNameKinship({ given: [] }) } to match given names exactly. Answers do not depend on the order the set was given in.

The given-name tables, for another language

The tables behind givenFamilyTable and givenFamilyList print as JSON, for a consumer that is not JavaScript:

npx @finbheara/names tables given-name > given-name-tables.json   # or bunx

--output json and --schema 1 are the defaults. The PRINTED form is the contract: a schema keeps its shape for as long as the package offers it, and a new shape is a new schema, so a command written today keeps working. The files the tables are stored in are not part of it. Schema 1 is

{
  "$comment": [ /* how to read it */ ],
  "table": "given-name", "schema": 1,
  "variants":  { "CONNOR": ["CONOR"], … },        // canonical -> its other spellings
  "families":  { "CATHERINE": ["KATE", …], … },   // the curated gate tier: root -> forms
  "notAtGate": ["MEGAN", …],                      // forms a gate must not admit as forms
  "extended":  { "BETHANY": { "BETH": 1 }, … }    // root -> form -> tier (1 common .. 3 rare)
}

Read it as the library does, and as $comment says: read each name as its canonical spelling first; two names relate only when they share a root, a form meets EVERY root it sits under, and nothing is transitive (EVIE meets EVE and EVA, which do not meet each other). A shared root is given-family unless one name reaches it only as a notAtGate form; a name that is the root itself always passes. An extended pair's tier is its rarer member's, the root counting as 1. To avoid comparing every pair, index each name under every root it has and compare within a root, never on one root per name.

Phonetic and blocking keys

import { doubleMetaphone } from "@finbheara/names/phonetic";
import { initialsBlock, metaphoneBlocks, personName, surnameBlock } from "@finbheara/names/normalize";

doubleMetaphone("SMITH");      // ["SM0", "XMT"]  primary, secondary
doubleMetaphone("KAVANAGH");   // ["KFNK", "KFNK"]

const n = personName("Moira O'Rervton");
surnameBlock(n.surnameKey());                     // "ORERVTON"  A-Z letters only
metaphoneBlocks(n.surnameKey());                  // ["ARRF"]
initialsBlock(n.givenFields()[0] ?? "", n.surnameKey());  // "MO"

doubleMetaphone is a general phonetic key: a printed name and a transcribed spoken word are asked the same question. The three block keys bucket names that are worth comparing and decide nothing; each answers null (or []) where there is nothing to bucket on, so a self-join never pools the blanks.

A name as database columns

import { NAME_COLUMNS, nameColumns, personName } from "@finbheara/names/normalize";

NAME_COLUMNS;  // ["nn0", …, "nn4", "surname_key", "given_key", "blk_surname", "blk_initials",
               //  "blk_metaphone", "blk_metaphone_alt"]
nameColumns(personName("Moira O'Rervton"));
// { nn0: "Moira O'Rervton", …, nn3: "ORERVTON<<MOIRA", surname_key: "ORERVTON",
//   given_key: "MOIRA", blk_surname: "ORERVTON", blk_initials: "MO", blk_metaphone: "ARRF", … }

One encoder for every key a store indexes, with no database dependency. A level column is the slot createNameMap files a name under, null for a print that names no person, so a map lookup at a level is one equality on one column.

The lexicon

import { Lexicon } from "@finbheara/names/lexicon";

const lex = Lexicon.default();              // the committed names.txt, parsed on first use
lex.getNameFlags("Murphy");                 // "LT": a surname, and a tradename
lex.getNameFlags("New York");               // "BG": a multi-word place
lex.matchBigramContinuations("New");        // [["south","wales"], ["york","city"], ..., ["york"], ...]
lex.displaySpelling("MACDONALD");           // "MacDonald"

Lexicon.load("./my-names.txt");             // your own file, same format (Node and Bun)
Lexicon.fromText("Aoife:F\nKowalski:L6\n");

names.txt

One entry per line, Name:FLAGS. # starts a comment. A line with no flags is a female given name. Flags are case-sensitive and combine (Kelly:AF, New York:BG).

flagmeaning
F M Nfemale / male / gender-neutral given name
Lsurname
Aambiguous: seen as both a given name and a surname
Gplace
Ttradename: a school or a teacher
Cconcept word (River)
Bmulti-word entry (bigram or trigram)
Sa known typo, kept so it is recognised
Xexcluded from the display renderer's round-trip check
Da given name a diminutive table holds (Kate:FD); a mark, like S and X

A digit after L or A is a tradition footnote: 1 Armenian, 2 Germanic, 3 Iberian, 4 Irish/Gaelic, 5 Nordic, 6 Slavic (Kowalski:L6). An index is permanent once issued; a new tradition takes the next free index.

Classify a phrase

import { phraseClassifier } from "@finbheara/names/classifier";

const pc = phraseClassifier();               // over the committed lexicon
pc.classifyNameNormalized("Quorrish Academy of Irish Dance");
// { scores: { person: 0.00, school: 1.00, location: 0.00 }, confidence: 1.00, category: "school" }
pc.segment("Mary Kate O Trurane Quorrish Academy");
// { type: "PS", person: ["Mary","Kate","O","Trurane"], school: ["Quorrish","Academy"], boundary: 4, ... }

A logistic-regression classifier and a segmenter over trained weights and bloom filters of public name and place lists (data/bloom/MANIFEST.json). It is built for multi-word phrases; for a single token, ask the lexicon.

A sheet that declares its name order (given-first or surname-first) tells the classifier so, and the classifier then reads its cells with weights fitted only on documents declaring that order. On a surname-first sheet, a comma in the name column separates surname from given name:

pc.getNameCategory(cell, "surname-first"); // the order the document declares
pc.getNameCategory(cell);                  // no order: the same as "unknown"

Pass the order the document declares, never a guess: an order read off the cell itself (inferNameOrder) is not a declaration, so it is not an input to the classifier. With no order, or unknown, the answers are exactly what they are without the argument. classifyName, classifyNameNormalized, getNameCategory, classifyBoundary and the is… tests (after threshold) take it. The garbage refusal and the place rules answer first under every order.

The model always picks one of person, school or place. Text that is none of them (page footers, headings) can be declared up front; the whole phrase is matched ignoring case, spacing and punctuation, scores zero everywhere and segments to nothing:

const pc = phraseClassifier(undefined, { garbageVocab: ["Results are provisional (c)"] });
pc.classifyNameNormalized("RESULTS ARE PROVISIONAL (C)");
// { scores: { person: 0, school: 0, location: 0 }, confidence: 0, category: "none" }

Measure a stream of names

npx names-census < names.txt-or.jsonl           # one name per line, or JSON lines
npx names-census --field=name --order-field=order --classify --limit=50 < rows.jsonl
import { createNameCensus } from "@finbheara/names/measure";

const census = createNameCensus();
for (const row of rows) census.add(row.name, row.order);
const report = census.report();
report.unknownTokens;  // words no lexicon entry knows, most printed first, with examples
report.unknownPairs;   // adjacent unknown words: candidate multi-word entries
report.refused;        // refusals by rule

The unknown words are the candidate names.txt additions. That is the fix for almost every misread.

Contributing

See CONTRIBUTING.md: how to add a name, the gates, the classifier methodology and versioning.

Licence

MIT © Finbheara Systems. The bloom tables are built from third-party lists; their sources are in data/bloom/MANIFEST.json.

版本与发布记录

@finbheara/names 共发布 34 个版本,首次发布于 2026/10/01;过去 12 个月发布了 34 个版本。

版本发布日期距今
0.31.0latest2026/11/077 天前
0.30.02026/10/2322 天前
0.29.02026/10/2322 天前
0.28.02026/10/2125 天前
0.27.12026/10/2025 天前
0.26.02026/10/1926 天前

关于 @finbheara/names 的常见问题

@finbheara/names 是什么?

A curated lexicon of personal, school and place names, the surname-particle vocabulary, a phrase classifier and a person-name normalizer

如何安装 @finbheara/names?

在项目目录运行 npm install @finbheara/names,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 names、names-census,可以用 npx names 直接运行。

@finbheara/names 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@finbheara/names 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@finbheara/names 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.3。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@finbheara/names 可以商用吗?

@finbheara/names 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@finbheara/names 的下载量有多少?

根据 npm 官方下载统计,@finbheara/names 在 2026/10/15 – 2026/11/13 这 30 天内被下载 3,357 次,日均约 112 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@finbheara/names 还在维护吗?

最新版本 0.31.0 发布于 2026/11/07(7 天前);至今共发布 34 个版本,其中过去 12 个月发布了 34 个;首次发布于 2026/10/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。