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
15 changes: 15 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## 概要

<!-- 何のための変更か、一言〜数行で -->

## やったこと / やってないこと

<!-- 必要ならやったこと・やってないことに分けて書いてよい -->

-

## 動作確認

<!-- 確認した項目をチェックリストで書く -->

- [ ]
9 changes: 5 additions & 4 deletions app/islands/BookSearchForm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState } from "hono/jsx";
import type { BookDto, BookSearchResultDto, SearchBooksResponseDto } from "../types/dto";
import { mergeRankedSearchResults } from "../lib/book-search-ranking";
import { readApiResponse } from "../lib/api-client";
import type { BookDto, ScoredBookSearchResultDto, SearchBooksResponseDto } from "../types/dto";

export default function BookSearchForm() {

Check warning on line 6 in app/islands/BookSearchForm.tsx

View workflow job for this annotation

GitHub Actions / lint

unicorn(consistent-function-scoping)

Function `handleSelect` does not capture any variables from its parent scope
const [query, setQuery] = useState("");
const [results, setResults] = useState<BookSearchResultDto[]>([]);
const [results, setResults] = useState<ScoredBookSearchResultDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
Expand Down Expand Up @@ -49,7 +50,7 @@
const data = await readApiResponse<SearchBooksResponseDto>(res);

if (data.success) {
setResults((prev) => [...prev, ...data.data.results]);
setResults((currentResults) => mergeRankedSearchResults(currentResults, data.data.results));
setCurrentPage(nextPage);
setHasMore(nextPage < data.data.pageCount);
} else {
Expand All @@ -62,7 +63,7 @@
}
};

const handleSelect = async (book: BookSearchResultDto) => {
const handleSelect = async (book: ScoredBookSearchResultDto) => {
try {
const res = await fetch("/api/books", {
method: "POST",
Expand Down
91 changes: 91 additions & 0 deletions app/lib/book-search-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { mergeRankedSearchResults } from "./book-search-ranking";

interface TestSearchResult {
isbn: string;
title: string;
techScore: number;
publishedDate: string;
}

describe("mergeRankedSearchResults", () => {
it("should sort results from multiple pages by technical score", () => {
const currentResults: TestSearchResult[] = [
{ isbn: "1", title: "Medium", techScore: 20, publishedDate: "2025年1月1日" },
{ isbn: "2", title: "Low", techScore: -40, publishedDate: "2026年1月1日" },
];
const additionalResults: TestSearchResult[] = [
{ isbn: "3", title: "High", techScore: 50, publishedDate: "2020年1月1日" },
];

const merged = mergeRankedSearchResults(currentResults, additionalResults);

expect(merged.map((result) => result.title)).toEqual(["High", "Medium", "Low"]);
});

it("should prefer newer publications when technical scores are tied", () => {
const currentResults: TestSearchResult[] = [
{ isbn: "1", title: "Older", techScore: 15, publishedDate: "2020年1月1日" },
];
const additionalResults: TestSearchResult[] = [
{ isbn: "2", title: "Unknown", techScore: 15, publishedDate: "unknown" },
{ isbn: "3", title: "Newer", techScore: 15, publishedDate: "2025年1月1日" },
];

const merged = mergeRankedSearchResults(currentResults, additionalResults);

expect(merged.map((result) => result.title)).toEqual(["Newer", "Older", "Unknown"]);
});

it("should not mutate either input array", () => {
const currentResults: TestSearchResult[] = [
{ isbn: "1", title: "Current", techScore: 0, publishedDate: "2025年1月1日" },
];
const additionalResults: TestSearchResult[] = [
{ isbn: "2", title: "Additional", techScore: 30, publishedDate: "2024年1月1日" },
];
const originalCurrentResults = structuredClone(currentResults);
const originalAdditionalResults = structuredClone(additionalResults);

mergeRankedSearchResults(currentResults, additionalResults);

expect(currentResults).toEqual(originalCurrentResults);
expect(additionalResults).toEqual(originalAdditionalResults);
});

it("should remove duplicate ISBNs across pages", () => {
const currentResults: TestSearchResult[] = [
{
isbn: "978-4-1234-5678-9",
title: "Current",
techScore: 10,
publishedDate: "2024年1月1日",
},
];
const additionalResults: TestSearchResult[] = [
{
isbn: "9784123456789",
title: "Duplicate",
techScore: 20,
publishedDate: "2025年1月1日",
},
];

const merged = mergeRankedSearchResults(currentResults, additionalResults);

expect(merged.map((result) => result.title)).toEqual(["Current"]);
});

it("should keep results without an ISBN", () => {
const currentResults: TestSearchResult[] = [
{ isbn: "", title: "Current", techScore: 10, publishedDate: "2024年1月1日" },
];
const additionalResults: TestSearchResult[] = [
{ isbn: "", title: "Additional", techScore: 20, publishedDate: "2025年1月1日" },
];

const merged = mergeRankedSearchResults(currentResults, additionalResults);

expect(merged.map((result) => result.title)).toEqual(["Additional", "Current"]);
});
});
56 changes: 56 additions & 0 deletions app/lib/book-search-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export interface RankedSearchResult {
isbn: string;
techScore: number;
publishedDate: string;
}

export function mergeRankedSearchResults<T extends RankedSearchResult>(
currentResults: T[],
additionalResults: T[],
): T[] {
const seenIsbns = new Set<string>();

return [...currentResults, ...additionalResults]
.filter((result) => {
const isbn = result.isbn.replace(/[-\s]/g, "");
if (!isbn) return true;
if (seenIsbns.has(isbn)) return false;

seenIsbns.add(isbn);
return true;
})
.toSorted(compareRankedSearchResults);
}

export function compareRankedSearchResults(
before: RankedSearchResult,
after: RankedSearchResult,
): number {
if (before.techScore !== after.techScore) {
return after.techScore - before.techScore;
}

return comparePublishedDateDesc(before.publishedDate, after.publishedDate);
}

export function parsePublishedDate(publishedDate: string): Date | null {
if (!publishedDate) return null;

const match = publishedDate.match(/(\d{4})年(?:\s*(\d{1,2})月)?(?:\s*(\d{1,2})日)?/);
if (!match) return null;

const [, year, month, day] = match;

return new Date(Number(year), Number(month ?? 1) - 1, Number(day ?? 1));
}

function comparePublishedDateDesc(before: string, after: string): number {
const beforeDate = parsePublishedDate(before);
const afterDate = parsePublishedDate(after);

if (!beforeDate && !afterDate) return 0;
if (!beforeDate) return 1;
if (!afterDate) return -1;

return afterDate.getTime() - beforeDate.getTime();
}
1 change: 1 addition & 0 deletions app/server/api/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const rakutenSearchSchema = type({
query: "1 <= string <= 100",
"limit?": "1 <= (number % 1) <= 10",
"page?": "1 <= (number % 1) <= 1000", // 追加
"debug?": "'1' | 'true' | '0' | 'false'",
});

/**
Expand Down
82 changes: 74 additions & 8 deletions app/server/api/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("Search API Integration", () => {
expect(body.error.code).toBe("VALIDATION_ERROR");
});

it("should return search results sorted by published date", async () => {
it("should return search results sorted by technical score and published date", async () => {
const user = await createTestUser(env.DB);
const sessionId = await createTestSession(env.KV, user.id);

Expand All @@ -50,25 +50,25 @@ describe("Search API Integration", () => {
{
Item: {
isbn: "9780000000001",
title: "Older Book",
title: "Older React Book",
author: "Author A",
publisherName: "Publisher A",
publisherName: "技術評論社",
salesDate: "2023年12月1日",
size: "200p",
itemCaption: "Older book description",
itemCaption: "React book description",
largeImageUrl: "https://example.com/older.png",
affiliateUrl: "https://example.com/older",
},
},
{
Item: {
isbn: "9780000000002",
title: "Newer Book",
title: "Newer Novel",
author: "Author B",
publisherName: "Publisher B",
salesDate: "2024年1月2日",
size: "320p",
itemCaption: "Newer book description",
itemCaption: "Newer novel description",
largeImageUrl: "https://example.com/newer.png",
affiliateUrl: "https://example.com/newer",
},
Expand Down Expand Up @@ -101,12 +101,78 @@ describe("Search API Integration", () => {
const body = (await res.json()) as SuccessResponseDto<SearchBooksResponseDto>;
expect(body.success).toBe(true);
expect(body.data.results).toHaveLength(2);
expect(body.data.results[0].title).toBe("Newer Book");
expect(body.data.results[0].publishedDate).toBe("2024年1月2日");
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].scoreReasons).toBeUndefined();
expect(body.data.hits).toBe(2);
expect(body.data.pageCount).toBe(1);
});

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);

const mockResponse = {
Items: [
{
Item: {
isbn: "9780000000003",
title: "Docker入門",
author: "Author C",
publisherName: "翔泳社",
salesDate: "2024年1月1日",
size: "240p",
itemCaption: "Linuxとコンテナの基礎",
largeImageUrl: "",
affiliateUrl: "",
},
},
],
pageCount: 1,
hits: 1,
};

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=docker&debug=${debug}`,
{
headers: { Cookie: `session_id=${sessionId}` },
},
env,
);

expect(res.status).toBe(200);
const body = (await res.json()) as {
success: boolean;
data: {
results: Array<{
techScore: number;
scoreReasons?: Array<{ type: string; label: string; score: number }>;
}>;
};
};

expect(body.success).toBe(true);
expect(body.data.results[0].techScore).toBeGreaterThan(0);
expect(body.data.results[0].scoreReasons).toEqual(
expect.arrayContaining([
{ type: "tech_publisher", label: "翔泳社", score: 30 },
{ type: "title_keyword", label: "Docker", score: 15 },
]),
);
});

it("should return 400 for invalid isbn", async () => {
const user = await createTestUser(env.DB);
const sessionId = await createTestSession(env.KV, user.id);
Expand Down
7 changes: 5 additions & 2 deletions app/server/api/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { errorResponse, successResponse } from "../lib/response";
import { isValidISBN } from "../lib/validation";
import * as rakutenService from "../services/rakuten";
import { searchRateLimiter } from "../lib/rate-limit";
import { rankTechBooks } from "../domain/tech-book-search";

const app = new Hono<HonoContext>();

Expand All @@ -23,7 +24,7 @@ app.use("*", searchRateLimiter);
*/
app.get("/books", validator("query", rakutenSearchSchema), async (c) => {
// バリデーション済みのデータを取得
const { query, limit, page } = getValidated<RakutenSearchInput>(c, "query");
const { query, limit, page, debug } = getValidated<RakutenSearchInput>(c, "query");

// queryがundefinedの場合はエラーを返す
if (!query) {
Expand All @@ -39,7 +40,9 @@ app.get("/books", validator("query", rakutenSearchSchema), async (c) => {
page ?? 1,
);

const sortedResults = rakutenService.sortByPublishedDateDesc(results);
const sortedResults = rankTechBooks(results, query, {
includeReasons: debug === "1" || debug === "true",
});

return successResponse(c, {
results: sortedResults,
Expand Down
1 change: 1 addition & 0 deletions app/server/domain/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * as bookDomain from "./book";
export * as techBookSearchDomain from "./tech-book-search";
Loading
Loading