Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions app/server/api/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,77 @@ describe("Search API Integration", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as SuccessResponseDto<SearchBooksResponseDto>;
expect(body.success).toBe(true);
expect(body.data.results).toHaveLength(2);
expect(body.data.results).toHaveLength(1);
expect(body.data.results[0].title).toBe("Older React Book");
expect(body.data.results[0].publishedDate).toBe("2023年12月1日");
expect(body.data.results[0].techScore).toBeGreaterThan(body.data.results[1].techScore);
expect(body.data.results[0].techScore).toBeGreaterThan(0);
expect(body.data.results[0].scoreReasons).toBeUndefined();
expect(body.data.hits).toBe(2);
expect(body.data.pageCount).toBe(1);
});

it("should exclude non-technical books from search results", async () => {
const user = await createTestUser(env.DB);
const sessionId = await createTestSession(env.KV, user.id);

const mockResponse = {
Items: [
{
Item: {
isbn: "9780000000010",
title: "最近の小説",
author: "Author Novel",
publisherName: "一般出版社",
salesDate: "2026年1月1日",
size: "280p",
itemCaption: "話題のフィクション",
largeImageUrl: "https://example.com/novel.png",
affiliateUrl: "https://example.com/novel",
},
},
{
Item: {
isbn: "9780000000011",
title: "料理レシピ大全",
author: "Author Cook",
publisherName: "料理社",
salesDate: "2025年6月1日",
size: "180p",
itemCaption: "家庭の味",
largeImageUrl: "https://example.com/cook.png",
affiliateUrl: "https://example.com/cook",
},
},
],
pageCount: 1,
hits: 2,
};

vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify(mockResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);

const res = await api.request(
"/search/books?query=react",
{
headers: { Cookie: `session_id=${sessionId}` },
},
env,
);

expect(res.status).toBe(200);
const body = (await res.json()) as SuccessResponseDto<SearchBooksResponseDto>;
expect(body.success).toBe(true);
expect(body.data.results).toEqual([]);
});

it.each(["1", "true"])("should include score reasons when debug=%s", async (debug) => {
const user = await createTestUser(env.DB);
const sessionId = await createTestSession(env.KV, user.id);
Expand Down
82 changes: 66 additions & 16 deletions app/server/domain/tech-book-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import type { BookSearchResultDto as BookSearchResult } from "../../types/dto";
import { calculateTechScore, rankTechBooks } from "./tech-book-search";
import {
calculateTechScore,
isLikelyTechBook,
rankTechBooks,
type ScoreReason,
} from "./tech-book-search";

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

Expand Down Expand Up @@ -388,8 +393,41 @@ describe("calculateTechScore", () => {
);
});

describe("isLikelyTechBook", () => {
it("should keep a book with technical evidence and a positive score", () => {
expect(isLikelyTechBook(15, [{ type: "title_keyword", label: "Docker", score: 15 }])).toBe(
true,
);
});

it("should reject a book that only has publication recency", () => {
expect(
isLikelyTechBook(12, [{ type: "recent_publication", label: "1年以内", score: 12 }]),
).toBe(false);
});

it("should reject a book with no score reasons", () => {
expect(isLikelyTechBook(0, [])).toBe(false);
});

it("should reject a book whose net technical score is not positive", () => {
const reasons: ScoreReason[] = [
{ type: "title_keyword", label: "Docker", score: 15 },
{ type: "negative_title_keyword", label: "レシピ", score: -40 },
];

expect(isLikelyTechBook(-25, reasons)).toBe(false);
});

it("should reject a book with only negative keywords", () => {
expect(
isLikelyTechBook(-40, [{ type: "negative_title_keyword", label: "小説", score: -40 }]),
).toBe(false);
});
});

describe("rankTechBooks", () => {
it("should rank a technical book before a newer non-technical book", () => {
it("should exclude a newer non-technical book from ranked results", () => {
const books = [
createBook({
title: "新しい小説",
Expand All @@ -405,11 +443,11 @@ describe("rankTechBooks", () => {

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

expect(ranked[0].title).toBe("React設計パターン");
expect(ranked[0].techScore).toBeGreaterThan(ranked[1].techScore);
expect(ranked.map((book) => book.title)).toEqual(["React設計パターン"]);
expect(ranked[0].techScore).toBeGreaterThan(0);
});

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

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

expect(ranked.map((book) => book.title)).toEqual([
"TypeScript入門",
"Docker入門",
"Neutral Book",
]);
expect(ranked.map((book) => book.title)).toEqual(["TypeScript入門", "Docker入門"]);
});

it("should exclude books without technical evidence even if they are recent", () => {
const books = [
createBook({
title: "話題のエッセイ",
publisher: "一般出版社",
publishedDate: "2026年6月1日",
}),
createBook({ title: "漫画入門", publishedDate: "2026年5月1日" }),
createBook({ title: "Python実践入門", publishedDate: "2018年1月1日" }),
];

const ranked = rankTechBooks(books, "python", { now: TEST_NOW });

expect(ranked.map((book) => book.title)).toEqual(["Python実践入門"]);
});

it("should prefer the newer publication when technical scores are tied", () => {
const books = [
createBook({ title: "Older", publishedDate: "2019年1月1日" }),
createBook({ title: "Newer", publishedDate: "2020年1月1日" }),
createBook({ title: "Older Docker", publishedDate: "2019年1月1日" }),
createBook({ title: "Newer Docker", publishedDate: "2020年1月1日" }),
];

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

expect(ranked.map((book) => book.title)).toEqual(["Newer", "Older"]);
expect(ranked.map((book) => book.title)).toEqual(["Newer Docker", "Older Docker"]);
expect(ranked[0].techScore).toBe(ranked[1].techScore);
});

it("should place an unknown publication date after a valid date when scores are tied", () => {
const books = [
createBook({ title: "Unknown", publishedDate: "unknown" }),
createBook({ title: "Known", publishedDate: "2020年1月1日" }),
createBook({ title: "Unknown Docker", publishedDate: "unknown" }),
createBook({ title: "Known Docker", publishedDate: "2020年1月1日" }),
];

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

expect(ranked.map((book) => book.title)).toEqual(["Known", "Unknown"]);
expect(ranked.map((book) => book.title)).toEqual(["Known Docker", "Unknown Docker"]);
});

it("should omit score reasons by default", () => {
Expand Down
26 changes: 24 additions & 2 deletions app/server/domain/tech-book-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ interface KeywordMatch {

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

const TECH_EVIDENCE_TYPES = new Set<ScoreReasonType>([
"isbn_exact_match",
"tech_publisher",
"title_keyword",
"description_keyword",
"author_keyword",
]);

const SCORE = {
isbnExactMatch: 100,
techPublisher: 30,
Expand Down Expand Up @@ -206,8 +214,12 @@ export function rankTechBooks(
options: { includeReasons?: boolean; now?: Date } = {},
): ScoredBookSearchResult[] {
return books
.map((book) => {
.flatMap((book) => {
const result = calculateTechScore(book, query, { now: options.now });
if (!isLikelyTechBook(result.techScore, result.scoreReasons)) {
return [];
}

const scoredBook: ScoredBookSearchResult = {
...book,
techScore: result.techScore,
Expand All @@ -217,11 +229,21 @@ export function rankTechBooks(
scoredBook.scoreReasons = result.scoreReasons;
}

return scoredBook;
return [scoredBook];
})
.toSorted(compareRankedSearchResults);
}

/**
* 技術書らしい候補かどうか。
* 出版日の新しさだけでは残さず、出版社・キーワード・ISBN などの技術シグナルが必要。
*/
export function isLikelyTechBook(techScore: number, scoreReasons: ScoreReason[]): boolean {
if (techScore <= 0) return false;

return scoreReasons.some((reason) => TECH_EVIDENCE_TYPES.has(reason.type));
}

export function calculateTechScore(
book: BookSearchResult,
query: string,
Expand Down
2 changes: 2 additions & 0 deletions app/server/services/rakuten.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ describe("rakuten service", () => {
expect(requestedOrigin).toBe("https://gidoku.com");
expect(requestedReferer).toBe("https://gidoku.com/");
expect(url.searchParams.get("hits")).toBe("10");
expect(url.searchParams.get("booksGenreId")).toBe("001005");
expect(url.searchParams.get("title")).toBe("query");
expect(result.results[0].authors).toEqual(["Author A", "Author B"]);
expect(result.results[0].pageCount).toBe(320);
expect(result.hits).toBe(1);
Expand Down
3 changes: 3 additions & 0 deletions app/server/services/rakuten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface RakutenApiErrorDetails {
}

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