@kecan0406/aninames 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kecan0406
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # aninames
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A multilingual alias index for anime titles and characters. Type a title or a character name the way you know it — 後藤ひとり, gotou hitori, Hitori Gotoh, 고토 히토리, ぼざろ, 봇치 더 록, 孤独摇滚 — and get the work or the character.
4
+
5
+ aninames ships data files, not a service: a scheduled build merges open data sources into one SQLite file per license layer, and a small search library joins the layers on your machine. The same build also cuts the search keys into small static files, so a client can search without downloading the databases (see [Static index](#static-index)).
6
+
7
+ ## Layers
8
+
9
+ Sources under different share-alike licenses cannot be merged into one database, so every built file holds one layer. Load the layers whose terms suit you; `core` is always required.
10
+
11
+ | Layer | File | License | Sources |
12
+ |---|---|---|---|
13
+ | `core` | `core.sqlite` | ODbL 1.0 (contents DbCL) | Wikidata (CC0), anime-offline-database (ODbL), VNDB (ODbL) |
14
+ | `kowiki` | `kowiki.sqlite` | CC BY-SA 4.0 | Korean Wikipedia article titles and redirects |
15
+ | `unlicensed` | `unlicensed.sqlite` | none stated by the sources | AniDB titles (Anime-Lists mirror), Bangumi Archive, Anissia, Danbooru |
16
+
17
+ The `unlicensed` layer carries data whose sources state no license. It is published as a separate file so you can leave it out; redistributing it is at your own risk.
18
+
19
+ Korean names generated from kana readings (national-standard and fan spellings) are marked `generated` and rank below sourced names.
20
+
21
+ ## Status
22
+
23
+ v0, built and searched locally.
24
+
25
+ Golden queries (`tests/golden.ts`) and the 139 character tags of ttheme's palettes (`tests/ttheme.tsv`), `mise run eval`:
26
+
27
+ | Layers | Titles | Characters | Typos (not counted) | ttheme tags resolved |
28
+ |---|---|---|---|---|
29
+ | core, kowiki, unlicensed | 15/15 | 13/13 | 9/9 | 139/139 |
30
+ | core, kowiki | 15/15 | 13/13 | 9/9 | 46/139 |
31
+ | core | 15/15 | 13/13 | 9/9 | 46/139 |
32
+
33
+ Without `unlicensed`, Danbooru tags resolve only where Wikidata records one (P13236).
34
+
35
+ `mise run probe` searches a frozen sample of works and characters (`tests/probe.json`, 150 per popularity band) by forms of their own names and reports the share found first and the mean reciprocal rank, keeping names several entities share apart. With all layers, names no other entity carries are found first 99–100% of the time in Japanese, kana, romaji and Korean; romaji with one letter dropped 77–95%; Korean initials (ㄱㅌ ㅎㅌㄹ) 64–100%; Korean typed on an English keyboard (rhxh glxhfl) 72–100%. A given name alone, or a name several characters share, is genuinely ambiguous and ranks by popularity. It also searches Korean Wikipedia titles and redirects without the `kowiki` layer: spellings only Korean Wikipedia has are found first 20% (works) and 42% (characters) of the time, which is what that layer adds.
36
+
37
+ `mise run silver` hides the external ids that link characters across sources and measures how well name matching alone rejoins them: 89.0% recall, 98.8% precision on 5,040 such pairs.
38
+
39
+ Works and characters flagged adult (VNDB titles with no release under 18, Bangumi `nsfw`, anime-offline-database `hentai`; a character when all its works are) are returned with `adult: true`; `search(query, { adult: false })` leaves them out.
40
+
41
+ Korean names: 19,065 characters carry a Korean name from a source (Wikidata, Korean Wikipedia cast lists, Danbooru, Bangumi), 7,057 of them from Korean Wikipedia alone. The rest get generated spellings (fan style preferred, national standard and loanword forms ranked lower).
42
+
43
+ Built sizes: `core.sqlite` 610 MB, `kowiki.sqlite` 25 MB, `unlicensed.sqlite` 830 MB, plus `ttheme-<layer>.json` slices mapping Danbooru-tagged characters to their names for consumers without SQLite: each character's Danbooru tags (its main tag, the one with the most posts, first), that post count, its popularity and its names by language, with `other` for names no source labels. `mise run pack` writes `dist/release`: the databases zstd-compressed, the slices gzipped, `ttheme.json` (each slice's file, SHA-256 and size) and `NOTICE.md` (each layer's sources and terms).
44
+
45
+ ## Releases
46
+
47
+ `.github/workflows/release.yml` fetches every source, builds, runs `eval`, packs and chunks every Sunday (and on demand), then publishes the files as a `data-<date>` release, keeping the four latest, and deploys `dist/site` to GitHub Pages as the static index. The latest release is always at `https://github.com/kecan0406/aninames/releases/latest/download/<file>` — ttheme reads `ttheme.json` there once a day and downloads the slices whose hash changed.
48
+
49
+ ## Static index
50
+
51
+ `https://names.ttheme.dev/` serves each layer's search keys as sorted, gzip'd JSON files, so a search fetches only the few files its query needs instead of the databases (610, 25 and 830 MB). `mise run chunk` writes it to `dist/site`:
52
+
53
+ | Path | Holds |
54
+ |---|---|
55
+ | `manifest.json` | the format number, the build time and, for every layer and table, each file's lower bound (prefix-compressed against the one before) and name |
56
+ | `<layer>/<table>/<hash>.json.gz` | about 32 KB of JSON rows sorted by key (16 KB for `entity`), named by the first 48 bits of the SHA-256 of the file |
57
+ | `NOTICE.md`, `index.html` | each layer's sources and terms |
58
+
59
+ The key tables are `flat` (folded names), `latin` (romaji keys), `sound` (sound keys across kana, Hangul and romaji), `part` (name tokens) and `initial` (Korean initials); their rows are `[key, entity number, name weight, flags, popularity, standing, bonus]`. `entity` rows, keyed by entity number (which follows the entity ids' order), carry the id, names, Danbooru tags, works and family of one entity in that layer. Layers stay in separate files; the only values a file carries from other layers are derived numbers and flags (popularity, family standing, adult, which layers know the entity).
60
+
61
+ On the 10-04 build: 19,167 files, 149 MB gzip'd, a manifest of 236 KB gzip'd. `mise run compare` searches the probe sample (4,370 queries) both ways: the files find the entity first 87% of the time, SQLite 86% (FTS substring search is not in the files; typo search reads the rows around the query's position instead). A cold query needs a median of 2 rounds (p90 3), 31 files (p90 78) and 287 KB (p90 584 KB).
62
+
63
+ The client is `@kecan0406/aninames` (`src/client/`), plain TypeScript for Node 22 and Bun that depends only on `es-hangul`:
64
+
65
+ ```ts
66
+ import { cachedSource, httpSource, Names } from '@kecan0406/aninames'
67
+
68
+ const source = cachedSource(httpSource('https://names.ttheme.dev'), `${cacheDir}/aninames`)
69
+ const names = new Names(source, { layers: ['core', 'kowiki'] })
70
+ const [first] = await names.search('봇치', { limit: 10, signal })
71
+ const works = await names.lookup(first?.works ?? [])
72
+ ```
73
+
74
+ `search` returns `Found` cards (`id`, `kind`, `label`, `names`, `danbooru` tags with the most posts first, `works`, `score`, `adult`); `search(query, { kind, limit, adult: false })` narrows it, and `lookup(ids)` returns the cards of given ids in one round. `cachedSource` keeps the files it fetched forever (their names are hashes of their contents), revalidates the manifest with its ETag or Last-Modified once a day (`maxAge`), answers from the cache when offline, refetches the manifest when the site has moved on to a new build, and deletes files a new manifest no longer names. `fileSource(dir)` reads a local `dist/site`.
75
+
76
+ ## Development
77
+
78
+ ```sh
79
+ mise run fetch # download raw data into .cache/raw
80
+ mise run stage # parse it into .cache/stage/*.jsonl
81
+ mise run build # write dist/core.sqlite, dist/kowiki.sqlite, dist/unlicensed.sqlite
82
+ mise run search 고토 히토리
83
+ mise run eval # golden queries and the ttheme cast
84
+ mise run probe # search popular to long-tail entities by forms of their names
85
+ mise run silver # measure character merging against hidden cross-source ids
86
+ mise run chunk # write the static index to dist/site
87
+ mise run compare # search the probe sample with SQLite and with the client over dist/site
88
+ mise run lib # compile the client into lib/ for the npm package
89
+ ```
90
+
91
+ Code is MIT-licensed (see `LICENSE`); the data files carry the licenses above.
@@ -0,0 +1,26 @@
1
+ import type { Layer } from '../model.ts';
2
+ import { type EntityRow, type KeyRow, type KeyTable, type Manifest } from './format.ts';
3
+ export type Rows = KeyRow[] | EntityRow[];
4
+ export type Loaded = (path: string) => Rows | undefined;
5
+ export interface Range {
6
+ low: string;
7
+ high: string;
8
+ open?: boolean;
9
+ limit?: number;
10
+ spans?: number;
11
+ keep?: (row: KeyRow) => boolean;
12
+ }
13
+ export declare class View {
14
+ readonly wanted: Set<string>;
15
+ readonly manifest: Manifest;
16
+ readonly layers: Layer[];
17
+ labels: boolean;
18
+ private readonly loaded;
19
+ constructor(manifest: Manifest, layers: Layer[], loaded: Loaded);
20
+ private rows;
21
+ keys(table: KeyTable, range: Range): KeyRow[];
22
+ private keysIn;
23
+ around(table: KeyTable, key: string, radius: number): KeyRow[];
24
+ entity(eid: number, mask?: number): EntityRow[];
25
+ byId(id: string): EntityRow[];
26
+ }
@@ -0,0 +1,121 @@
1
+ import { chunkPath, present } from "./format.js";
2
+ import { compare } from "./order.js";
3
+ function last(count, below) {
4
+ let lo = 0;
5
+ let hi = count;
6
+ while (lo < hi) {
7
+ const mid = (lo + hi) >> 1;
8
+ if (below(mid))
9
+ lo = mid + 1;
10
+ else
11
+ hi = mid;
12
+ }
13
+ return lo - 1;
14
+ }
15
+ function firstRow(rows, low, open) {
16
+ return (last(rows.length, (at) => {
17
+ const c = compare(rows[at][0], low);
18
+ return c < 0 || (open && c === 0);
19
+ }) + 1);
20
+ }
21
+ export class View {
22
+ wanted = new Set();
23
+ manifest;
24
+ layers;
25
+ labels = false;
26
+ loaded;
27
+ constructor(manifest, layers, loaded) {
28
+ this.manifest = manifest;
29
+ this.layers = layers;
30
+ this.loaded = loaded;
31
+ }
32
+ rows(layer, table, hash) {
33
+ const path = chunkPath(layer, table, hash);
34
+ const rows = this.loaded(path);
35
+ if (!rows)
36
+ this.wanted.add(path);
37
+ return rows;
38
+ }
39
+ keys(table, range) {
40
+ return this.layers.flatMap((layer) => this.keysIn(layer, table, range));
41
+ }
42
+ keysIn(layer, table, range) {
43
+ const spans = this.manifest.layers.get(layer)?.keys[table];
44
+ if (!spans)
45
+ return [];
46
+ const { low, high, open = false, limit = Number.POSITIVE_INFINITY, spans: most = Number.POSITIVE_INFINITY } = range;
47
+ const start = Math.max(0, last(spans.bounds.length, (at) => {
48
+ const c = compare(spans.bounds[at], low);
49
+ return c < 0 || (open && c === 0);
50
+ }));
51
+ const out = [];
52
+ for (let at = start; at < spans.files.length && at < start + most && out.length < limit; at++) {
53
+ const c = compare(spans.bounds[at], high);
54
+ if (c > 0 || (open && c === 0))
55
+ break;
56
+ const rows = this.rows(layer, table, spans.files[at]);
57
+ if (!rows)
58
+ continue;
59
+ for (let i = firstRow(rows, low, open); i < rows.length && out.length < limit; i++) {
60
+ const row = rows[i];
61
+ const d = compare(row[0], high);
62
+ if (d > 0 || (open && d === 0))
63
+ break;
64
+ if (!range.keep || range.keep(row))
65
+ out.push(row);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ around(table, key, radius) {
71
+ const out = [];
72
+ for (const layer of this.layers) {
73
+ const spans = this.manifest.layers.get(layer)?.keys[table];
74
+ if (!spans || spans.files.length === 0)
75
+ continue;
76
+ const at = Math.max(0, last(spans.bounds.length, (i) => compare(spans.bounds[i], key) <= 0));
77
+ for (let i = Math.max(0, at - radius); i <= Math.min(spans.files.length - 1, at + radius); i++) {
78
+ const rows = this.rows(layer, table, spans.files[i]);
79
+ if (rows)
80
+ out.push(...rows);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ entity(eid, mask = 0) {
86
+ const out = [];
87
+ for (const layer of this.layers) {
88
+ if (mask && !(mask & present(layer)))
89
+ continue;
90
+ const spans = this.manifest.layers.get(layer)?.entity;
91
+ if (!spans)
92
+ continue;
93
+ const at = last(spans.eids.length, (i) => spans.eids[i] <= eid);
94
+ if (at < 0)
95
+ continue;
96
+ const rows = this.rows(layer, 'entity', spans.files[at]);
97
+ const i = rows ? last(rows.length, (r) => rows[r].eid < eid) + 1 : -1;
98
+ const row = rows?.[i];
99
+ if (row?.eid === eid)
100
+ out.push(row);
101
+ }
102
+ return out;
103
+ }
104
+ byId(id) {
105
+ const out = [];
106
+ for (const layer of this.layers) {
107
+ const spans = this.manifest.layers.get(layer)?.entity;
108
+ if (!spans)
109
+ continue;
110
+ const at = last(spans.bounds.length, (i) => compare(spans.bounds[i], id) <= 0);
111
+ if (at < 0)
112
+ continue;
113
+ const rows = this.rows(layer, 'entity', spans.files[at]);
114
+ const i = rows ? last(rows.length, (r) => compare(rows[r].id, id) < 0) + 1 : -1;
115
+ const row = rows?.[i];
116
+ if (row?.id === id)
117
+ out.push(row);
118
+ }
119
+ return out;
120
+ }
121
+ }
@@ -0,0 +1,20 @@
1
+ import type { Kind } from '../model.ts';
2
+ import type { View } from './chunks.ts';
3
+ export interface Hit {
4
+ eid: number;
5
+ kind: Kind;
6
+ score: number;
7
+ matched: string;
8
+ how: string;
9
+ rank: number;
10
+ standing: number;
11
+ extra: number;
12
+ adult: boolean;
13
+ mask: number;
14
+ }
15
+ export interface Options {
16
+ kind?: Kind;
17
+ limit?: number;
18
+ adult?: boolean;
19
+ }
20
+ export declare function search(view: View, query: string, options?: Options): Hit[] | undefined;
@@ -0,0 +1,244 @@
1
+ import { swapLayout, TOP, typing } from "../match.js";
2
+ import { editDistance, flat, latinKey, partKey, qualifiers, soundKeys } from "../text.js";
3
+ import { ADULT, CHARACTER, MAIN } from "./format.js";
4
+ const SEEDS = 8;
5
+ const FAMILY = 16;
6
+ const PEOPLE = 10;
7
+ const SPANS = 2;
8
+ const RADIUS = 1;
9
+ function hitOf(row, score, how) {
10
+ const flags = row[3];
11
+ return {
12
+ eid: row[1],
13
+ kind: flags & CHARACTER ? 'character' : 'work',
14
+ score,
15
+ matched: row[0],
16
+ how,
17
+ rank: row[4],
18
+ standing: row[5],
19
+ extra: row[6],
20
+ adult: (flags & ADULT) !== 0,
21
+ mask: flags & ~(CHARACTER | MAIN | ADULT),
22
+ };
23
+ }
24
+ function keeper(kind) {
25
+ if (!kind)
26
+ return undefined;
27
+ const character = kind === 'character';
28
+ return (row) => ((row[3] & CHARACTER) !== 0) === character;
29
+ }
30
+ function candidates(view, query, kind) {
31
+ const hits = new Map();
32
+ const keep = keeper(kind);
33
+ const consider = (row, base, how) => {
34
+ if (keep && !keep(row))
35
+ return;
36
+ const score = base * (row[2] / 1000);
37
+ const current = hits.get(row[1]);
38
+ if (!current || current.score < score)
39
+ hits.set(row[1], hitOf(row, score, how));
40
+ };
41
+ const exact = (table, key) => view.keys(table, { low: key, high: key, ...(keep ? { keep } : {}) });
42
+ const prefix = (table, low, high) => view.keys(table, { low, high, open: true, limit: 3000, spans: SPANS, ...(keep ? { keep } : {}) });
43
+ const q = flat(query);
44
+ if (!q)
45
+ return hits;
46
+ for (const row of exact('flat', q))
47
+ consider(row, 100, 'exact');
48
+ const latin = latinKey(query);
49
+ if (latin && /[a-z]/.test(latin))
50
+ for (const row of exact('latin', latin))
51
+ consider(row, 95, 'latin');
52
+ if (/^[ㄱ-ㅎ\s]+$/.test(query.trim())) {
53
+ const key = query.replace(/\s+/g, '');
54
+ for (const row of exact('initial', key))
55
+ consider(row, 86, 'initials');
56
+ for (const row of prefix('initial', key, `${key}${TOP}`))
57
+ consider(row, 60 + 20 * ([...key].length / [...row[0]].length), 'initials prefix');
58
+ }
59
+ if (!/\s/.test(query.trim())) {
60
+ for (const row of exact('part', partKey(query)))
61
+ consider(row, row[3] & CHARACTER && row[3] & MAIN ? 92 : 84, 'part');
62
+ }
63
+ for (const key of /[ㄱ-ㅎㅏ-ㅣᄀ-ᇿ]/.test(query) ? [] : soundKeys(qualifiers(query).base)) {
64
+ for (const row of exact('sound', key))
65
+ consider(row, 82, 'sound');
66
+ }
67
+ const length = [...q].length;
68
+ for (const [low, high] of typing(q)) {
69
+ for (const row of prefix('flat', low, high))
70
+ consider(row, 60 + 25 * (length / [...row[0]].length), low === q ? 'prefix' : 'typing');
71
+ }
72
+ return hits;
73
+ }
74
+ function fuzzy(view, query, kind) {
75
+ const hits = new Map();
76
+ const q = flat(query);
77
+ const chars = [...q];
78
+ if (chars.length < 4)
79
+ return hits;
80
+ const limit = chars.length >= 8 ? 2 : 1;
81
+ const keep = keeper(kind);
82
+ for (const row of view.around('flat', q, RADIUS)) {
83
+ if (keep && !keep(row))
84
+ continue;
85
+ const name = [...row[0]];
86
+ let score = 0;
87
+ const whole = editDistance(q, row[0], limit);
88
+ if (whole <= limit)
89
+ score = 92 - 10 * whole;
90
+ else if (name.length > chars.length) {
91
+ const lead = editDistance(q, name.slice(0, chars.length).join(''), limit);
92
+ if (lead <= limit)
93
+ score = 72 - 10 * lead;
94
+ }
95
+ if (score === 0)
96
+ continue;
97
+ score *= row[2] / 1000;
98
+ const current = hits.get(row[1]);
99
+ if (!current || current.score < score)
100
+ hits.set(row[1], hitOf(row, score, 'typo'));
101
+ }
102
+ return hits;
103
+ }
104
+ function reach(view, hit) {
105
+ return [...new Set(view.entity(hit.eid, hit.mask).flatMap((row) => row.reach))];
106
+ }
107
+ function named(rows, text) {
108
+ return rows.some((row) => row.names.some((name) => name.flat.includes(text)));
109
+ }
110
+ function family(view, hits, query) {
111
+ const wanted = flat(query);
112
+ const seeds = [...hits.values()]
113
+ .filter((hit) => hit.kind === 'work' && hit.score >= 50)
114
+ .sort((a, b) => b.score - a.score)
115
+ .slice(0, SEEDS);
116
+ for (const seed of seeds) {
117
+ const score = seed.score * 0.97;
118
+ const matched = flat(seed.matched);
119
+ for (const other of reach(view, seed).slice(0, FAMILY)) {
120
+ const current = hits.get(other);
121
+ if (current && current.score >= score)
122
+ continue;
123
+ const rows = view.entity(other);
124
+ const row = rows[0];
125
+ if (!row || (!named(rows, wanted) && !named(rows, matched)))
126
+ continue;
127
+ hits.set(other, {
128
+ eid: other,
129
+ kind: 'work',
130
+ score,
131
+ matched: seed.matched,
132
+ how: `family(${seed.how})`,
133
+ rank: row.rank,
134
+ standing: row.standing,
135
+ extra: row.extra,
136
+ adult: row.adult,
137
+ mask: 0,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ function scoped(view, query) {
143
+ const hits = new Map();
144
+ const tokens = query.normalize('NFKC').split(/\s+/).filter(Boolean);
145
+ for (let cut = 1; cut < tokens.length; cut++) {
146
+ for (const [who, where] of [
147
+ [tokens.slice(0, cut), tokens.slice(cut)],
148
+ [tokens.slice(cut), tokens.slice(0, cut)],
149
+ ]) {
150
+ const before = view.wanted.size;
151
+ const people = [...candidates(view, who.join(' '), 'character').values()]
152
+ .filter((h) => h.score >= 80)
153
+ .sort((a, b) => b.score - a.score)
154
+ .slice(0, PEOPLE);
155
+ if (people.length === 0)
156
+ continue;
157
+ const series = flat(where.join(' '));
158
+ const matched = [...candidates(view, where.join(' '), 'work').values()]
159
+ .filter((h) => h.score >= 50)
160
+ .sort((a, b) => b.score - a.score);
161
+ if (view.wanted.size > before)
162
+ continue;
163
+ const works = new Set(matched.map((h) => h.eid));
164
+ for (const hit of matched.slice(0, 20))
165
+ for (const other of reach(view, hit))
166
+ works.add(other);
167
+ for (const person of people) {
168
+ const rows = view.entity(person.eid, person.mask);
169
+ const linked = rows.some((row) => row.works.some(([eid]) => works.has(eid)));
170
+ const qualified = rows.some((row) => row.names.some((name) => {
171
+ if (!name.qual)
172
+ return false;
173
+ const q = flat(name.qual);
174
+ return q.startsWith(series) || series.startsWith(q);
175
+ }));
176
+ if (!linked && !qualified)
177
+ continue;
178
+ const score = 90 + person.score / 10;
179
+ const current = hits.get(person.eid);
180
+ if (!current || current.score < score)
181
+ hits.set(person.eid, { ...person, score, how: linked ? 'scoped-work' : 'scoped-qual' });
182
+ }
183
+ }
184
+ }
185
+ return hits;
186
+ }
187
+ export function search(view, query, options = {}) {
188
+ const { kind } = options;
189
+ const hits = candidates(view, query, kind);
190
+ const swapped = swapLayout(query);
191
+ if (swapped)
192
+ candidates(view, swapped, kind);
193
+ fuzzy(view, query, kind);
194
+ if (!kind || kind === 'character') {
195
+ const tokens = query.normalize('NFKC').split(/\s+/).filter(Boolean);
196
+ for (let cut = 1; cut < tokens.length; cut++) {
197
+ for (const part of [tokens.slice(0, cut), tokens.slice(cut)]) {
198
+ candidates(view, part.join(' '), 'character');
199
+ candidates(view, part.join(' '), 'work');
200
+ }
201
+ }
202
+ }
203
+ if (view.wanted.size > 0)
204
+ return undefined;
205
+ if (!kind || kind === 'character') {
206
+ for (const [eid, hit] of scoped(view, query)) {
207
+ const current = hits.get(eid);
208
+ if (!current || current.score < hit.score)
209
+ hits.set(eid, hit);
210
+ }
211
+ }
212
+ if (!kind || kind === 'work')
213
+ family(view, hits, query);
214
+ if (view.wanted.size > 0)
215
+ return undefined;
216
+ const merge = (more, factor, how) => {
217
+ for (const [eid, hit] of more) {
218
+ const score = hit.score * factor;
219
+ const current = hits.get(eid);
220
+ if (!current || current.score < score)
221
+ hits.set(eid, { ...hit, score, how: how ? `${how}(${hit.how})` : hit.how });
222
+ }
223
+ };
224
+ let best = Math.max(0, ...[...hits.values()].map((h) => h.score));
225
+ if (best < 90 && swapped) {
226
+ merge(candidates(view, swapped, kind), 0.95, 'layout');
227
+ best = Math.max(0, ...[...hits.values()].map((h) => h.score));
228
+ }
229
+ if (best < 95)
230
+ merge(fuzzy(view, query, kind), 1);
231
+ if (view.wanted.size > 0)
232
+ return undefined;
233
+ const prior = (hit) => (hit.kind === 'work' ? hit.extra : 0);
234
+ const first = [...hits.values()]
235
+ .map((hit) => ({ ...hit, score: hit.score + (15 * hit.rank) / 1000 + prior(hit) }))
236
+ .sort((a, b) => b.score - a.score)
237
+ .filter((hit) => options.adult !== false || !hit.adult)
238
+ .slice(0, 60);
239
+ const weight = /\s/.test(query.trim()) ? 15 : 25;
240
+ return first
241
+ .map((hit) => ({ ...hit, score: (hits.get(hit.eid)?.score ?? 0) + (weight * hit.standing) / 1000 + hit.extra }))
242
+ .sort((a, b) => b.score - a.score)
243
+ .slice(0, options.limit ?? 10);
244
+ }
@@ -0,0 +1,74 @@
1
+ import { type Kind, type Layer, type NameKind } from '../model.ts';
2
+ export declare const FORMAT = 1;
3
+ export declare const KEY_TABLES: readonly ["flat", "latin", "sound", "part", "initial"];
4
+ export type KeyTable = (typeof KEY_TABLES)[number];
5
+ export type Table = KeyTable | 'entity';
6
+ export declare const NAME_KINDS: readonly NameKind[];
7
+ export declare const TRUSTED: readonly string[];
8
+ export declare const CHARACTER = 1;
9
+ export declare const MAIN = 2;
10
+ export declare const ADULT = 4;
11
+ export declare function present(layer: Layer): number;
12
+ export declare const HASH_LENGTH = 12;
13
+ export declare function chunkPath(layer: Layer, table: Table, hash: string): string;
14
+ export declare function parsePath(path: string): {
15
+ layer: Layer;
16
+ table: Table;
17
+ hash: string;
18
+ } | undefined;
19
+ export declare function shared(a: string, b: string): number;
20
+ export interface Packed {
21
+ share: number[];
22
+ rest: string[];
23
+ files: string;
24
+ eids?: number[];
25
+ }
26
+ export declare function pack(keys: string[], hashes: string[], eids?: number[]): Packed;
27
+ export interface Spans {
28
+ bounds: string[];
29
+ files: string[];
30
+ }
31
+ export interface EntitySpans extends Spans {
32
+ eids: number[];
33
+ }
34
+ export interface LayerSpans {
35
+ keys: Record<KeyTable, Spans>;
36
+ entity: EntitySpans;
37
+ }
38
+ export interface Manifest {
39
+ built: string;
40
+ layers: Map<Layer, LayerSpans>;
41
+ }
42
+ export declare function readManifest(text: string): Manifest;
43
+ export declare function manifestPaths(manifest: Manifest): Set<string>;
44
+ export type KeyRow = readonly [
45
+ key: string,
46
+ eid: number,
47
+ weight: number,
48
+ flags: number,
49
+ rank: number,
50
+ standing: number,
51
+ extra: number
52
+ ];
53
+ export interface Name {
54
+ text: string;
55
+ lang: string | null;
56
+ kind: NameKind;
57
+ trust: number;
58
+ qual: string | null;
59
+ flat: string;
60
+ }
61
+ export interface EntityRow {
62
+ eid: number;
63
+ id: string;
64
+ kind: Kind;
65
+ names: Name[];
66
+ danbooru: [tag: string, posts: number | null][];
67
+ works: [eid: number, id: string][];
68
+ reach: number[];
69
+ rank: number;
70
+ standing: number;
71
+ extra: number;
72
+ adult: boolean;
73
+ }
74
+ export declare function readRows(table: Table, value: unknown): KeyRow[] | EntityRow[] | undefined;