Skip to content

Commit 30f2d32

Browse files
cursoragentOjoxux
andcommitted
非技術書を検索結果から除外する
並べ替えだけでは小説などが残るため、技術シグナル必須のハードフィルタを追加し、 楽天検索もコンピュータ・情報処理ジャンルに絞り込む。 Co-authored-by: Jou Okuyama <Ojoxux@users.noreply.github.com>
1 parent b5b46b1 commit 30f2d32

5 files changed

Lines changed: 159 additions & 20 deletions

File tree

app/server/api/search.test.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,77 @@ describe("Search API Integration", () => {
100100
expect(res.status).toBe(200);
101101
const body = (await res.json()) as SuccessResponseDto<SearchBooksResponseDto>;
102102
expect(body.success).toBe(true);
103-
expect(body.data.results).toHaveLength(2);
103+
expect(body.data.results).toHaveLength(1);
104104
expect(body.data.results[0].title).toBe("Older React Book");
105105
expect(body.data.results[0].publishedDate).toBe("2023年12月1日");
106-
expect(body.data.results[0].techScore).toBeGreaterThan(body.data.results[1].techScore);
106+
expect(body.data.results[0].techScore).toBeGreaterThan(0);
107107
expect(body.data.results[0].scoreReasons).toBeUndefined();
108108
expect(body.data.hits).toBe(2);
109109
expect(body.data.pageCount).toBe(1);
110110
});
111111

112+
it("should exclude non-technical books from search results", async () => {
113+
const user = await createTestUser(env.DB);
114+
const sessionId = await createTestSession(env.KV, user.id);
115+
116+
const mockResponse = {
117+
Items: [
118+
{
119+
Item: {
120+
isbn: "9780000000010",
121+
title: "最近の小説",
122+
author: "Author Novel",
123+
publisherName: "一般出版社",
124+
salesDate: "2026年1月1日",
125+
size: "280p",
126+
itemCaption: "話題のフィクション",
127+
largeImageUrl: "https://example.com/novel.png",
128+
affiliateUrl: "https://example.com/novel",
129+
},
130+
},
131+
{
132+
Item: {
133+
isbn: "9780000000011",
134+
title: "料理レシピ大全",
135+
author: "Author Cook",
136+
publisherName: "料理社",
137+
salesDate: "2025年6月1日",
138+
size: "180p",
139+
itemCaption: "家庭の味",
140+
largeImageUrl: "https://example.com/cook.png",
141+
affiliateUrl: "https://example.com/cook",
142+
},
143+
},
144+
],
145+
pageCount: 1,
146+
hits: 2,
147+
};
148+
149+
vi.stubGlobal(
150+
"fetch",
151+
vi.fn(
152+
async () =>
153+
new Response(JSON.stringify(mockResponse), {
154+
status: 200,
155+
headers: { "Content-Type": "application/json" },
156+
}),
157+
),
158+
);
159+
160+
const res = await api.request(
161+
"/search/books?query=react",
162+
{
163+
headers: { Cookie: `session_id=${sessionId}` },
164+
},
165+
env,
166+
);
167+
168+
expect(res.status).toBe(200);
169+
const body = (await res.json()) as SuccessResponseDto<SearchBooksResponseDto>;
170+
expect(body.success).toBe(true);
171+
expect(body.data.results).toEqual([]);
172+
});
173+
112174
it.each(["1", "true"])("should include score reasons when debug=%s", async (debug) => {
113175
const user = await createTestUser(env.DB);
114176
const sessionId = await createTestSession(env.KV, user.id);

app/server/domain/tech-book-search.test.ts

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import type { BookSearchResultDto as BookSearchResult } from "../../types/dto";
3-
import { calculateTechScore, rankTechBooks } from "./tech-book-search";
3+
import {
4+
calculateTechScore,
5+
isLikelyTechBook,
6+
rankTechBooks,
7+
type ScoreReason,
8+
} from "./tech-book-search";
49

510
const TEST_NOW = new Date(2026, 5, 29);
611

@@ -388,8 +393,41 @@ describe("calculateTechScore", () => {
388393
);
389394
});
390395

396+
describe("isLikelyTechBook", () => {
397+
it("should keep a book with technical evidence and a positive score", () => {
398+
expect(isLikelyTechBook(15, [{ type: "title_keyword", label: "Docker", score: 15 }])).toBe(
399+
true,
400+
);
401+
});
402+
403+
it("should reject a book that only has publication recency", () => {
404+
expect(
405+
isLikelyTechBook(12, [{ type: "recent_publication", label: "1年以内", score: 12 }]),
406+
).toBe(false);
407+
});
408+
409+
it("should reject a book with no score reasons", () => {
410+
expect(isLikelyTechBook(0, [])).toBe(false);
411+
});
412+
413+
it("should reject a book whose net technical score is not positive", () => {
414+
const reasons: ScoreReason[] = [
415+
{ type: "title_keyword", label: "Docker", score: 15 },
416+
{ type: "negative_title_keyword", label: "レシピ", score: -40 },
417+
];
418+
419+
expect(isLikelyTechBook(-25, reasons)).toBe(false);
420+
});
421+
422+
it("should reject a book with only negative keywords", () => {
423+
expect(
424+
isLikelyTechBook(-40, [{ type: "negative_title_keyword", label: "小説", score: -40 }]),
425+
).toBe(false);
426+
});
427+
});
428+
391429
describe("rankTechBooks", () => {
392-
it("should rank a technical book before a newer non-technical book", () => {
430+
it("should exclude a newer non-technical book from ranked results", () => {
393431
const books = [
394432
createBook({
395433
title: "新しい小説",
@@ -405,11 +443,11 @@ describe("rankTechBooks", () => {
405443

406444
const ranked = rankTechBooks(books, "react", { now: TEST_NOW });
407445

408-
expect(ranked[0].title).toBe("React設計パターン");
409-
expect(ranked[0].techScore).toBeGreaterThan(ranked[1].techScore);
446+
expect(ranked.map((book) => book.title)).toEqual(["React設計パターン"]);
447+
expect(ranked[0].techScore).toBeGreaterThan(0);
410448
});
411449

412-
it("should sort books by technical score in descending order", () => {
450+
it("should sort remaining technical books by technical score in descending order", () => {
413451
const books = [
414452
createBook({ title: "Neutral Book" }),
415453
createBook({ title: "Docker入門" }),
@@ -418,34 +456,46 @@ describe("rankTechBooks", () => {
418456

419457
const ranked = rankTechBooks(books, "query", { now: TEST_NOW });
420458

421-
expect(ranked.map((book) => book.title)).toEqual([
422-
"TypeScript入門",
423-
"Docker入門",
424-
"Neutral Book",
425-
]);
459+
expect(ranked.map((book) => book.title)).toEqual(["TypeScript入門", "Docker入門"]);
460+
});
461+
462+
it("should exclude books without technical evidence even if they are recent", () => {
463+
const books = [
464+
createBook({
465+
title: "話題のエッセイ",
466+
publisher: "一般出版社",
467+
publishedDate: "2026年6月1日",
468+
}),
469+
createBook({ title: "漫画入門", publishedDate: "2026年5月1日" }),
470+
createBook({ title: "Python実践入門", publishedDate: "2018年1月1日" }),
471+
];
472+
473+
const ranked = rankTechBooks(books, "python", { now: TEST_NOW });
474+
475+
expect(ranked.map((book) => book.title)).toEqual(["Python実践入門"]);
426476
});
427477

428478
it("should prefer the newer publication when technical scores are tied", () => {
429479
const books = [
430-
createBook({ title: "Older", publishedDate: "2019年1月1日" }),
431-
createBook({ title: "Newer", publishedDate: "2020年1月1日" }),
480+
createBook({ title: "Older Docker", publishedDate: "2019年1月1日" }),
481+
createBook({ title: "Newer Docker", publishedDate: "2020年1月1日" }),
432482
];
433483

434484
const ranked = rankTechBooks(books, "query", { now: TEST_NOW });
435485

436-
expect(ranked.map((book) => book.title)).toEqual(["Newer", "Older"]);
486+
expect(ranked.map((book) => book.title)).toEqual(["Newer Docker", "Older Docker"]);
437487
expect(ranked[0].techScore).toBe(ranked[1].techScore);
438488
});
439489

440490
it("should place an unknown publication date after a valid date when scores are tied", () => {
441491
const books = [
442-
createBook({ title: "Unknown", publishedDate: "unknown" }),
443-
createBook({ title: "Known", publishedDate: "2020年1月1日" }),
492+
createBook({ title: "Unknown Docker", publishedDate: "unknown" }),
493+
createBook({ title: "Known Docker", publishedDate: "2020年1月1日" }),
444494
];
445495

446496
const ranked = rankTechBooks(books, "query", { now: TEST_NOW });
447497

448-
expect(ranked.map((book) => book.title)).toEqual(["Known", "Unknown"]);
498+
expect(ranked.map((book) => book.title)).toEqual(["Known Docker", "Unknown Docker"]);
449499
});
450500

451501
it("should omit score reasons by default", () => {

app/server/domain/tech-book-search.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ interface KeywordMatch {
4141

4242
type ScoreRule = (book: BookSearchResult, context: ScoreContext) => ScoreReason[];
4343

44+
const TECH_EVIDENCE_TYPES = new Set<ScoreReasonType>([
45+
"isbn_exact_match",
46+
"tech_publisher",
47+
"title_keyword",
48+
"description_keyword",
49+
"author_keyword",
50+
]);
51+
4452
const SCORE = {
4553
isbnExactMatch: 100,
4654
techPublisher: 30,
@@ -206,8 +214,12 @@ export function rankTechBooks(
206214
options: { includeReasons?: boolean; now?: Date } = {},
207215
): ScoredBookSearchResult[] {
208216
return books
209-
.map((book) => {
217+
.flatMap((book) => {
210218
const result = calculateTechScore(book, query, { now: options.now });
219+
if (!isLikelyTechBook(result.techScore, result.scoreReasons)) {
220+
return [];
221+
}
222+
211223
const scoredBook: ScoredBookSearchResult = {
212224
...book,
213225
techScore: result.techScore,
@@ -217,11 +229,21 @@ export function rankTechBooks(
217229
scoredBook.scoreReasons = result.scoreReasons;
218230
}
219231

220-
return scoredBook;
232+
return [scoredBook];
221233
})
222234
.toSorted(compareRankedSearchResults);
223235
}
224236

237+
/**
238+
* 技術書らしい候補かどうか。
239+
* 出版日の新しさだけでは残さず、出版社・キーワード・ISBN などの技術シグナルが必要。
240+
*/
241+
export function isLikelyTechBook(techScore: number, scoreReasons: ScoreReason[]): boolean {
242+
if (techScore <= 0) return false;
243+
244+
return scoreReasons.some((reason) => TECH_EVIDENCE_TYPES.has(reason.type));
245+
}
246+
225247
export function calculateTechScore(
226248
book: BookSearchResult,
227249
query: string,

app/server/services/rakuten.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ describe("rakuten service", () => {
5959
expect(requestedOrigin).toBe("https://gidoku.com");
6060
expect(requestedReferer).toBe("https://gidoku.com/");
6161
expect(url.searchParams.get("hits")).toBe("10");
62+
expect(url.searchParams.get("booksGenreId")).toBe("001005");
63+
expect(url.searchParams.get("title")).toBe("query");
6264
expect(result.results[0].authors).toEqual(["Author A", "Author B"]);
6365
expect(result.results[0].pageCount).toBe(320);
6466
expect(result.hits).toBe(1);

app/server/services/rakuten.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ interface RakutenApiErrorDetails {
3434
}
3535

3636
const RAKUTEN_API_BASE = "https://openapi.rakuten.co.jp/services/api/BooksBook/Search/20170404";
37+
/** 楽天ブックス「コンピュータ・情報処理」ジャンル */
38+
export const RAKUTEN_COMPUTER_BOOKS_GENRE_ID = "001005";
3739
const MAX_ATTEMPTS = 3;
3840
const MAX_RETRY_DELAY_MS = 5_000;
3941
const RETRYABLE_STATUSES = new Set([429, 503]);
@@ -52,6 +54,7 @@ export async function searchBooks(
5254
const url = new URL(RAKUTEN_API_BASE);
5355
url.searchParams.set("applicationId", applicationId);
5456
url.searchParams.set("title", query);
57+
url.searchParams.set("booksGenreId", RAKUTEN_COMPUTER_BOOKS_GENRE_ID);
5558
url.searchParams.set("hits", String(Math.min(limit, 10)));
5659
url.searchParams.set("page", String(page));
5760
url.searchParams.set("format", "json");

0 commit comments

Comments
 (0)