feat(library): book author-hint resolve + ambiguous candidate logging
This commit is contained in:
parent
548e4c8ce0
commit
49d7779bc1
8 changed files with 161 additions and 7 deletions
|
|
@ -114,6 +114,26 @@ async function fetchDocs(query: string, deps: SpecDeps): Promise<any[]> {
|
|||
return res?.docs ?? [];
|
||||
}
|
||||
|
||||
/** Skeleton/legacy `author` (singular, stock) or canonical `authors` (bracketed list) frontmatter
|
||||
* field -> first author name, used as a query hint to disambiguate common book titles against
|
||||
* Open Library's `search.json`. Match rule is unaffected -- still checked against the OL doc's
|
||||
* `title` field only. */
|
||||
function authorHint(fm: Record<string, string>): string {
|
||||
const stock = stripQuotes(fm['author']);
|
||||
const canonical = (fm['authors'] ?? '').trim().replace(/^\[|\]$/g, '');
|
||||
const raw = stock || canonical;
|
||||
if (!raw) return '';
|
||||
return stripQuotes(raw.split(',')[0].trim());
|
||||
}
|
||||
|
||||
/** Top-candidate identifying info for ambiguous-resolve logging. */
|
||||
function candidateSummary(d: any): string {
|
||||
const olid = String(d.key ?? '').replace(/^\/works\//, '');
|
||||
const year = d.first_publish_year != null ? String(d.first_publish_year) : '';
|
||||
const author = Array.isArray(d.author_name) ? d.author_name.filter(Boolean).join(', ') : '';
|
||||
return `olid=${olid} «${d.title ?? ''}»${year ? ` (${year})` : ''}${author ? ` ${author}` : ''}`;
|
||||
}
|
||||
|
||||
export const bookSpec: MediaTypeSpec = {
|
||||
typeName: 'book',
|
||||
itemType: 'book_item',
|
||||
|
|
@ -135,19 +155,24 @@ export const bookSpec: MediaTypeSpec = {
|
|||
},
|
||||
|
||||
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
|
||||
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
||||
if (!query) return null;
|
||||
const title = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
||||
if (!title) return null;
|
||||
const hint = authorHint(ctx.frontmatter);
|
||||
const searchQuery = hint ? `${title} ${hint}` : title;
|
||||
try {
|
||||
const docs = await fetchDocs(query, deps);
|
||||
const docs = await fetchDocs(searchQuery, deps);
|
||||
if (docs.length === 0) return null;
|
||||
const q = query.toLowerCase();
|
||||
const q = title.toLowerCase();
|
||||
const exacts = docs.filter(d => String(d.title ?? '').toLowerCase() === q);
|
||||
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null;
|
||||
if (!pick) return null;
|
||||
if (!pick) {
|
||||
deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`);
|
||||
return null;
|
||||
}
|
||||
const olid = String(pick.key ?? '').replace(/^\/works\//, '');
|
||||
return olid ? { olid } : null;
|
||||
} catch (e) {
|
||||
deps.log(`book resolve failed for "${query}": ${String(e)}`);
|
||||
deps.log(`book resolve failed for "${searchQuery}": ${String(e)}`);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): P
|
|||
return Array.isArray(res?.results) ? res.results : [];
|
||||
}
|
||||
|
||||
/** Top-candidate identifying info for ambiguous-resolve logging. */
|
||||
function candidateSummary(r: any): string {
|
||||
const year = r.start_year != null ? String(r.start_year) : '';
|
||||
const publisher = r.publisher?.name ?? '';
|
||||
return `id=${r.id ?? ''} «${r.name ?? ''}»${year ? ` (${year})` : ''}${publisher ? ` ${publisher}` : ''}`;
|
||||
}
|
||||
|
||||
export const comicSpec: MediaTypeSpec = {
|
||||
typeName: 'comic',
|
||||
itemType: 'comic_item',
|
||||
|
|
@ -180,7 +187,11 @@ export const comicSpec: MediaTypeSpec = {
|
|||
const q = query.toLowerCase();
|
||||
const exacts = results.filter(r => String(r.name ?? '').toLowerCase() === q);
|
||||
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null;
|
||||
return pick && pick.id != null ? { comicvine_id: String(pick.id) } : null;
|
||||
if (!pick) {
|
||||
deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`);
|
||||
return null;
|
||||
}
|
||||
return pick.id != null ? { comicvine_id: String(pick.id) } : null;
|
||||
} catch (e) {
|
||||
deps.log(`comic resolve failed for "${query}": ${String(e)}`);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -168,6 +168,11 @@ function pickUniqueExact<T>(items: T[], query: string, titleOf: (t: T) => string
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Top-candidate identifying info for ambiguous-resolve logging. */
|
||||
function candidateSummary(label: string, id: unknown, name: string, year?: string): string {
|
||||
return `${label}=${id ?? ''} «${name}»${year ? ` (${year})` : ''}`;
|
||||
}
|
||||
|
||||
async function fetchSteamSearch(query: string, deps: SpecDeps): Promise<any[]> {
|
||||
const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' });
|
||||
const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {});
|
||||
|
|
@ -213,6 +218,9 @@ export const gameSpec: MediaTypeSpec = {
|
|||
const items = await fetchSteamSearch(query, deps);
|
||||
const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? ''));
|
||||
if (pick) return { steam_appid: String(pick.id) };
|
||||
if (items.length > 0) {
|
||||
deps.log(`ambiguous "${query}": candidates: ${items.slice(0, 3).map((it: any) => candidateSummary('appid', it.id, it.name ?? '')).join('; ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
deps.log(`game steam storesearch failed for "${query}": ${String(e)}`);
|
||||
}
|
||||
|
|
@ -226,6 +234,10 @@ export const gameSpec: MediaTypeSpec = {
|
|||
const results = await fetchRawgSearch(query, key, deps);
|
||||
const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? ''));
|
||||
if (pick) return { rawg_id: String(pick.id) };
|
||||
if (results.length > 0) {
|
||||
const year = (it: any) => (typeof it.released === 'string' ? it.released.slice(0, 4) : '');
|
||||
deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map((it: any) => candidateSummary('rawg_id', it.id, it.name ?? '', year(it))).join('; ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
deps.log(`game rawg search failed for "${query}": ${String(e)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ function resultTitles(r: any): string[] {
|
|||
return titles.filter(Boolean).map((t: string) => String(t).toLowerCase());
|
||||
}
|
||||
|
||||
/** Top-candidate identifying info for ambiguous-resolve logging. */
|
||||
function candidateSummary(r: any): string {
|
||||
const year = r.published?.from ? String(r.published.from).slice(0, 4) : '';
|
||||
return `mal_id=${r.mal_id ?? ''} «${r.title ?? ''}»${year ? ` (${year})` : ''}`;
|
||||
}
|
||||
|
||||
async function resolveMalId(query: string, deps: SpecDeps): Promise<string | null> {
|
||||
const qs = new URLSearchParams({ q: query, limit: '10' });
|
||||
const res = await deps.http(`${JIKAN_BASE}/manga?${qs.toString()}`, {});
|
||||
|
|
@ -189,6 +195,9 @@ async function resolveMalId(query: string, deps: SpecDeps): Promise<string | nul
|
|||
const q = query.toLowerCase();
|
||||
const exacts = results.filter(r => resultTitles(r).includes(q));
|
||||
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null;
|
||||
if (!pick) {
|
||||
deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`);
|
||||
}
|
||||
return pick ? String(pick.mal_id) : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -325,6 +325,19 @@ describe('bookSpec.resolve', () => {
|
|||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test('ambiguous -> logs top candidates with olid + title', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
docs: [
|
||||
{ key: '/works/OL1W', title: 'Foo' },
|
||||
{ key: '/works/OL2W', title: 'Bar' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('olid=OL1W') && m.includes('Foo') && m.includes('olid=OL2W') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
test('no results -> null', async () => {
|
||||
const deps = makeDeps({ http: async () => ({ docs: [] }) });
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
|
|
@ -341,3 +354,47 @@ describe('bookSpec.resolve', () => {
|
|||
expect(deps.logCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`)', () => {
|
||||
test('stock `author` field present -> query is "title author", exact-title match still resolves', async () => {
|
||||
let capturedUrl = '';
|
||||
const deps = makeDeps({
|
||||
http: async (url: string) => {
|
||||
capturedUrl = url;
|
||||
return olFixture;
|
||||
},
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984', author: 'George Orwell' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984 George Orwell');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
});
|
||||
|
||||
test('canonical `authors` bracketed list -> first author used in query', async () => {
|
||||
let capturedUrl = '';
|
||||
const deps = makeDeps({
|
||||
http: async (url: string) => {
|
||||
capturedUrl = url;
|
||||
return olFixture;
|
||||
},
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984', authors: '[George Orwell, Someone Else]' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984 George Orwell');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
});
|
||||
|
||||
test('no author anywhere in frontmatter -> query is title only (unchanged behavior)', async () => {
|
||||
let capturedUrl = '';
|
||||
const deps = makeDeps({
|
||||
http: async (url: string) => {
|
||||
capturedUrl = url;
|
||||
return olFixture;
|
||||
},
|
||||
});
|
||||
const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps);
|
||||
const q = new URL(capturedUrl).searchParams.get('q');
|
||||
expect(q).toBe('1984');
|
||||
expect(result).toEqual({ olid: 'OL1168083W' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -290,6 +290,20 @@ describe('comicSpec.resolve', () => {
|
|||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test('ambiguous -> logs top candidates with id + name', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
results: [
|
||||
{ id: 1, name: 'Batman' },
|
||||
{ id: 2, name: 'Batman Beyond' },
|
||||
],
|
||||
}),
|
||||
getKey: () => 'cvkey',
|
||||
});
|
||||
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('id=1') && m.includes('Batman') && m.includes('id=2') && m.includes('Batman Beyond'))).toBe(true);
|
||||
});
|
||||
|
||||
test('no results -> null', async () => {
|
||||
const deps = makeDeps({ http: async () => ({ results: [] }), getKey: () => 'cvkey' });
|
||||
|
|
|
|||
|
|
@ -329,6 +329,19 @@ describe('gameSpec.resolve', () => {
|
|||
expect(result).toBeNull();
|
||||
expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true);
|
||||
});
|
||||
test('steam storesearch ambiguous -> logs top candidates with appid + name', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
items: [
|
||||
{ id: 1, name: 'Foo' },
|
||||
{ id: 2, name: 'Bar' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('appid=1') && m.includes('Foo') && m.includes('appid=2') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
|
||||
test('steam storesearch throws -> falls through to RAWG', async () => {
|
||||
const deps = makeDeps({
|
||||
|
|
|
|||
|
|
@ -587,6 +587,19 @@ describe('mangaSpec.resolve', () => {
|
|||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test('ambiguous -> logs top candidates with mal_id + title', async () => {
|
||||
const deps = makeDeps({
|
||||
http: async () => ({
|
||||
data: [
|
||||
{ mal_id: 1, title: 'Foo' },
|
||||
{ mal_id: 2, title: 'Bar' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
expect(result).toBeNull();
|
||||
expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('mal_id=1') && m.includes('Foo') && m.includes('mal_id=2') && m.includes('Bar'))).toBe(true);
|
||||
});
|
||||
test('no results -> null', async () => {
|
||||
const deps = makeDeps({ http: async () => ({ data: [] }) });
|
||||
const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue