@zonuexe/techbook-mcp 0.2.1 → 0.2.3
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/.claude/settings.local.json +4 -1
- package/CHANGELOG.md +21 -1
- package/dist/adapters/calil.d.ts +10 -0
- package/dist/adapters/calil.d.ts.map +1 -0
- package/dist/adapters/calil.js +45 -0
- package/dist/adapters/calil.js.map +1 -0
- package/dist/adapters/openbd.d.ts +57 -0
- package/dist/adapters/openbd.d.ts.map +1 -0
- package/dist/adapters/openbd.js +87 -0
- package/dist/adapters/openbd.js.map +1 -0
- package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -1
- package/dist/adapters/publishers/tatsu-zine.js +64 -53
- package/dist/adapters/publishers/tatsu-zine.js.map +1 -1
- package/dist/application/get-book-by-isbn.d.ts +12 -0
- package/dist/application/get-book-by-isbn.d.ts.map +1 -0
- package/dist/application/get-book-by-isbn.js +42 -0
- package/dist/application/get-book-by-isbn.js.map +1 -0
- package/dist/application/get-book-detail.d.ts.map +1 -1
- package/dist/application/get-book-detail.js +16 -1
- package/dist/application/get-book-detail.js.map +1 -1
- package/dist/application/search-books.d.ts.map +1 -1
- package/dist/application/search-books.js +20 -0
- package/dist/application/search-books.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +10 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +16 -0
- package/dist/mcp/tools.js.map +1 -1
- package/package.json +3 -2
- package/src/adapters/calil.ts +57 -0
- package/src/adapters/openbd.ts +142 -0
- package/src/adapters/publishers/tatsu-zine.ts +7 -19
- package/src/application/get-book-by-isbn.ts +50 -0
- package/src/application/get-book-detail.ts +17 -1
- package/src/application/search-books.ts +20 -0
- package/src/mcp/server.ts +10 -0
- package/src/mcp/tools.ts +17 -0
- package/tests/fixtures/calil-book.html +987 -0
- package/tests/fixtures/openbd-response.json +110 -0
- package/tests/fixtures/tatsu-zine-detail-free.html +14 -12
- package/tests/unit/adapters/calil.test.ts +69 -0
- package/tests/unit/adapters/openbd.test.ts +185 -0
- package/tests/unit/application/get-book-by-isbn.test.ts +176 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"onix": {
|
|
4
|
+
"RecordReference": "9784908686207",
|
|
5
|
+
"NotificationType": "03",
|
|
6
|
+
"ProductIdentifier": {
|
|
7
|
+
"ProductIDType": "15",
|
|
8
|
+
"IDValue": "9784908686207"
|
|
9
|
+
},
|
|
10
|
+
"DescriptiveDetail": {
|
|
11
|
+
"ProductComposition": "00",
|
|
12
|
+
"ProductForm": "BA",
|
|
13
|
+
"TitleDetail": {
|
|
14
|
+
"TitleType": "01",
|
|
15
|
+
"TitleElement": {
|
|
16
|
+
"TitleElementLevel": "01",
|
|
17
|
+
"TitleText": {
|
|
18
|
+
"collationkey": "カタシステムノシクミ",
|
|
19
|
+
"content": "型システムのしくみ TypeScriptで実装しながら学ぶ型とプログラミング言語"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"Contributor": [
|
|
24
|
+
{
|
|
25
|
+
"SequenceNumber": "1",
|
|
26
|
+
"ContributorRole": ["A01"],
|
|
27
|
+
"PersonName": {
|
|
28
|
+
"collationkey": "エンドウユウスケ",
|
|
29
|
+
"content": "遠藤侑介"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"CollateralDetail": {
|
|
35
|
+
"TextContent": [
|
|
36
|
+
{
|
|
37
|
+
"TextType": "02",
|
|
38
|
+
"ContentAudience": "00",
|
|
39
|
+
"Text": "現代のすべてのプログラミング言語の基礎理論である「型」を、プログラマー向けに解き明かした初の概説書!"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"TextType": "03",
|
|
43
|
+
"ContentAudience": "00",
|
|
44
|
+
"Text": "TypeScriptのサブセットを実装しながら、型推論・型検査・多相型・ジェネリクスのしくみを一から学べる実践的な解説書。"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"TextType": "04",
|
|
48
|
+
"ContentAudience": "00",
|
|
49
|
+
"Text": "第1章 型とは何か\n第2章 単純型付きラムダ計算\n第3章 型推論"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"SupportingResource": [
|
|
53
|
+
{
|
|
54
|
+
"ResourceContentType": "01",
|
|
55
|
+
"ContentAudience": "01",
|
|
56
|
+
"ResourceMode": "03",
|
|
57
|
+
"ResourceVersion": [
|
|
58
|
+
{
|
|
59
|
+
"ResourceForm": "02",
|
|
60
|
+
"ResourceLink": "https://cover.openbd.jp/9784908686207.jpg"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"PublishingDetail": {
|
|
67
|
+
"Imprint": {
|
|
68
|
+
"ImprintName": "ラムダノート"
|
|
69
|
+
},
|
|
70
|
+
"Publisher": {
|
|
71
|
+
"PublishingRole": "01",
|
|
72
|
+
"PublisherName": "ラムダノート"
|
|
73
|
+
},
|
|
74
|
+
"PublishingDate": [
|
|
75
|
+
{
|
|
76
|
+
"PublishingDateRole": "01",
|
|
77
|
+
"Date": "20250418"
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"ProductSupply": {
|
|
82
|
+
"SupplyDetail": {
|
|
83
|
+
"ProductAvailability": "99",
|
|
84
|
+
"Price": [
|
|
85
|
+
{
|
|
86
|
+
"PriceType": "03",
|
|
87
|
+
"PriceAmount": "3300",
|
|
88
|
+
"CurrencyCode": "JPY"
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"hanmoto": {
|
|
95
|
+
"isbn": "9784908686207",
|
|
96
|
+
"hatsubai": "ラムダノート",
|
|
97
|
+
"storelink": "https://www.lambdanote.com/collections/type-systems",
|
|
98
|
+
"datemodified": "2025-10-22 12:11:38",
|
|
99
|
+
"datecreated": "2025-04-08 17:23:18"
|
|
100
|
+
},
|
|
101
|
+
"summary": {
|
|
102
|
+
"isbn": "9784908686207",
|
|
103
|
+
"title": "型システムのしくみ TypeScriptで実装しながら学ぶ型とプログラミング言語",
|
|
104
|
+
"publisher": "ラムダノート",
|
|
105
|
+
"pubdate": "20250418",
|
|
106
|
+
"cover": "https://cover.openbd.jp/9784908686207.jpg",
|
|
107
|
+
"author": "遠藤侑介"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
@@ -6,17 +6,19 @@
|
|
|
6
6
|
</head>
|
|
7
7
|
<body>
|
|
8
8
|
<h1>Goプログラミング実践入門</h1>
|
|
9
|
-
<img src="/images/books/123/cover.jpg" alt="Goプログラミング実践入門">
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
</
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
<img class="coversmall" src="/images/books/123/cover.jpg" alt="Goプログラミング実践入門">
|
|
10
|
+
<p itemprop="author" class="author">Sau Sheong Chang(著), 武舎 広幸(訳)</p>
|
|
11
|
+
<p class="publisher"><a href="/books/pub/impress">インプレス</a></p>
|
|
12
|
+
<p itemprop="offers" itemscope itemtype="http://schema.org/Offer" class="price">
|
|
13
|
+
<span itemprop="price">
|
|
14
|
+
3,520円
|
|
15
|
+
(3,200円+税)
|
|
16
|
+
</span>
|
|
17
|
+
</p>
|
|
18
|
+
<h3>著者について</h3>
|
|
19
|
+
<h4>Sau Sheong Chang</h4>
|
|
20
|
+
<p>著書に『Go Web Programming』(共著、Manning Publications)がある。</p>
|
|
21
|
+
<h4>武舎 広幸</h4>
|
|
22
|
+
<p>翻訳者。著書・訳書多数。(監訳、オライリー・ジャパン)など。</p>
|
|
21
23
|
</body>
|
|
22
24
|
</html>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fetchCalilBook, CALIL_BASE_URL } from "../../../src/adapters/calil.js";
|
|
6
|
+
import { MockHttpClient } from "../../../src/adapters/http/mock-client.js";
|
|
7
|
+
import { CheerioHtmlParser } from "../../../src/adapters/html/cheerio-parser.js";
|
|
8
|
+
import { NullCacheStore } from "../../../src/adapters/cache/null-cache.js";
|
|
9
|
+
|
|
10
|
+
const FIXTURES_DIR = join(import.meta.dirname, "../../fixtures");
|
|
11
|
+
|
|
12
|
+
function makeDeps(http: MockHttpClient) {
|
|
13
|
+
return { http, parser: new CheerioHtmlParser(), cache: new NullCacheStore() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("fetchCalilBook", () => {
|
|
17
|
+
it("詳細ページから書誌情報を取得する", async () => {
|
|
18
|
+
const body = await readFile(join(FIXTURES_DIR, "calil-book.html"), "utf-8");
|
|
19
|
+
const http = new MockHttpClient().addResponse(
|
|
20
|
+
`${CALIL_BASE_URL}/book/4901676032`,
|
|
21
|
+
{ status: 200, body },
|
|
22
|
+
);
|
|
23
|
+
const deps = makeDeps(http);
|
|
24
|
+
|
|
25
|
+
const book = await fetchCalilBook("4901676032", deps);
|
|
26
|
+
|
|
27
|
+
assert.ok(book !== null);
|
|
28
|
+
assert.strictEqual(book.title, "PHPを使おう: PHPで広がるWebビジネス展開");
|
|
29
|
+
assert.deepStrictEqual(book.authors, ["WebビジネスPHP研究部会"]);
|
|
30
|
+
assert.strictEqual(book.publisher, "九天社");
|
|
31
|
+
assert.strictEqual(book.publishedAt, "2002-02-01");
|
|
32
|
+
assert.strictEqual(book.isbn, "9784901676038");
|
|
33
|
+
assert.strictEqual(book.url, `${CALIL_BASE_URL}/book/4901676032`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("書影URLがAmazon CDNのURLで取得される", async () => {
|
|
37
|
+
const body = await readFile(join(FIXTURES_DIR, "calil-book.html"), "utf-8");
|
|
38
|
+
const http = new MockHttpClient().addResponse(
|
|
39
|
+
`${CALIL_BASE_URL}/book/`,
|
|
40
|
+
{ status: 200, body },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const book = await fetchCalilBook("4901676032", makeDeps(http));
|
|
44
|
+
|
|
45
|
+
assert.ok(book?.coverImageUrl?.includes("amazon") || book?.coverImageUrl?.includes("media-amazon"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("404相当のページ(タイトルなし)は null を返す", async () => {
|
|
49
|
+
const http = new MockHttpClient().addResponse(
|
|
50
|
+
`${CALIL_BASE_URL}/book/`,
|
|
51
|
+
{ status: 200, body: "<html><body></body></html>" },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const book = await fetchCalilBook("0000000000000", makeDeps(http));
|
|
55
|
+
|
|
56
|
+
assert.strictEqual(book, null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("HTTPエラーは null を返す(エラーをスローしない)", async () => {
|
|
60
|
+
const http = new MockHttpClient().addResponse(
|
|
61
|
+
`${CALIL_BASE_URL}/book/`,
|
|
62
|
+
{ status: 404, body: "Not Found" },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const book = await fetchCalilBook("0000000000000", makeDeps(http));
|
|
66
|
+
|
|
67
|
+
assert.strictEqual(book, null);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fetchOpenBDBooks, enrichWithOpenBD } from "../../../src/adapters/openbd.js";
|
|
6
|
+
import type { OpenBDEntry } from "../../../src/adapters/openbd.js";
|
|
7
|
+
import { MockHttpClient } from "../../../src/adapters/http/mock-client.js";
|
|
8
|
+
import { NullCacheStore } from "../../../src/adapters/cache/null-cache.js";
|
|
9
|
+
import type { HtmlParser } from "../../../src/ports/html-parser.js";
|
|
10
|
+
import type { BookRecord } from "../../../src/domain/book.js";
|
|
11
|
+
|
|
12
|
+
const FIXTURES_DIR = join(import.meta.dirname, "../../fixtures");
|
|
13
|
+
|
|
14
|
+
const noopParser: HtmlParser = {
|
|
15
|
+
parse(_html) {
|
|
16
|
+
throw new Error("openBD adapter must not call HtmlParser");
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function makeDeps(http: MockHttpClient) {
|
|
21
|
+
return { http, parser: noopParser, cache: new NullCacheStore() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("fetchOpenBDBooks", () => {
|
|
25
|
+
it("ISBNに対応するエントリをMapで返す", async () => {
|
|
26
|
+
const body = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
27
|
+
const http = new MockHttpClient().addResponse(
|
|
28
|
+
"https://api.openbd.jp/v1/get",
|
|
29
|
+
{ status: 200, body },
|
|
30
|
+
);
|
|
31
|
+
const deps = makeDeps(http);
|
|
32
|
+
|
|
33
|
+
const result = await fetchOpenBDBooks(["9784908686207"], deps);
|
|
34
|
+
|
|
35
|
+
assert.strictEqual(result.size, 1);
|
|
36
|
+
const entry = result.get("9784908686207");
|
|
37
|
+
assert.ok(entry !== undefined);
|
|
38
|
+
assert.strictEqual(entry.summary.isbn, "9784908686207");
|
|
39
|
+
assert.strictEqual(entry.summary.publisher, "ラムダノート");
|
|
40
|
+
assert.strictEqual(entry.summary.pubdate, "20250418");
|
|
41
|
+
assert.strictEqual(entry.summary.cover, "https://cover.openbd.jp/9784908686207.jpg");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("APIレスポンスのnullエントリはMapに含まれない", async () => {
|
|
45
|
+
const body = JSON.stringify([null]);
|
|
46
|
+
const http = new MockHttpClient().addResponse(
|
|
47
|
+
"https://api.openbd.jp/v1/get",
|
|
48
|
+
{ status: 200, body },
|
|
49
|
+
);
|
|
50
|
+
const deps = makeDeps(http);
|
|
51
|
+
|
|
52
|
+
const result = await fetchOpenBDBooks(["9780000000000"], deps);
|
|
53
|
+
|
|
54
|
+
assert.strictEqual(result.size, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("複数ISBNを一括取得してカンマ区切りURLを呼ぶ", async () => {
|
|
58
|
+
const body = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
59
|
+
// 2件リクエスト、1件はnull
|
|
60
|
+
const twoEntries = JSON.parse(body) as OpenBDEntry[];
|
|
61
|
+
const batchBody = JSON.stringify([twoEntries[0], null]);
|
|
62
|
+
const http = new MockHttpClient().addResponse(
|
|
63
|
+
"https://api.openbd.jp/v1/get",
|
|
64
|
+
{ status: 200, body: batchBody },
|
|
65
|
+
);
|
|
66
|
+
const deps = makeDeps(http);
|
|
67
|
+
|
|
68
|
+
const result = await fetchOpenBDBooks(["9784908686207", "9780000000000"], deps);
|
|
69
|
+
|
|
70
|
+
assert.strictEqual(result.size, 1);
|
|
71
|
+
assert.ok(result.has("9784908686207"));
|
|
72
|
+
assert.ok(!result.has("9780000000000"));
|
|
73
|
+
assert.ok(deps.http.calls[0].includes("9784908686207,9780000000000"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ISBNリストが空の場合はHTTPを呼ばず空Mapを返す", async () => {
|
|
77
|
+
const http = new MockHttpClient();
|
|
78
|
+
const deps = makeDeps(http);
|
|
79
|
+
|
|
80
|
+
const result = await fetchOpenBDBooks([], deps);
|
|
81
|
+
|
|
82
|
+
assert.strictEqual(result.size, 0);
|
|
83
|
+
assert.strictEqual(http.calls.length, 0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("enrichWithOpenBD", () => {
|
|
88
|
+
let entry: OpenBDEntry;
|
|
89
|
+
|
|
90
|
+
// フィクスチャを同期的にセットアップするためにbeforeを使わず直接使用
|
|
91
|
+
async function loadEntry(): Promise<OpenBDEntry> {
|
|
92
|
+
const body = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
93
|
+
return (JSON.parse(body) as OpenBDEntry[])[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("欠損フィールドをopenBDデータで補完する", async () => {
|
|
97
|
+
entry = await loadEntry();
|
|
98
|
+
const book: BookRecord = {
|
|
99
|
+
title: "型システムのしくみ",
|
|
100
|
+
authors: ["遠藤侑介"],
|
|
101
|
+
publisher: "ラムダノート",
|
|
102
|
+
url: "https://www.lambdanote.com/products/type-systems",
|
|
103
|
+
isbn: "9784908686207",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const enriched = enrichWithOpenBD(book, entry);
|
|
107
|
+
|
|
108
|
+
assert.strictEqual(enriched.publishedAt, "2025-04-18");
|
|
109
|
+
assert.strictEqual(enriched.price, 3300);
|
|
110
|
+
assert.strictEqual(enriched.coverImageUrl, "https://cover.openbd.jp/9784908686207.jpg");
|
|
111
|
+
assert.strictEqual(
|
|
112
|
+
enriched.description,
|
|
113
|
+
"TypeScriptのサブセットを実装しながら、型推論・型検査・多相型・ジェネリクスのしくみを一から学べる実践的な解説書。",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("既存フィールドは上書きしない", async () => {
|
|
118
|
+
entry = await loadEntry();
|
|
119
|
+
const book: BookRecord = {
|
|
120
|
+
title: "型システムのしくみ",
|
|
121
|
+
authors: ["遠藤侑介"],
|
|
122
|
+
publisher: "ラムダノート",
|
|
123
|
+
url: "https://www.lambdanote.com/products/type-systems",
|
|
124
|
+
isbn: "9784908686207",
|
|
125
|
+
publishedAt: "2025-01-01",
|
|
126
|
+
price: 9999,
|
|
127
|
+
coverImageUrl: "https://example.com/cover.jpg",
|
|
128
|
+
description: "既存の説明文",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const enriched = enrichWithOpenBD(book, entry);
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(enriched.publishedAt, "2025-01-01");
|
|
134
|
+
assert.strictEqual(enriched.price, 9999);
|
|
135
|
+
assert.strictEqual(enriched.coverImageUrl, "https://example.com/cover.jpg");
|
|
136
|
+
assert.strictEqual(enriched.description, "既存の説明文");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("TextType 03がなければ 02 にフォールバックする", async () => {
|
|
140
|
+
entry = await loadEntry();
|
|
141
|
+
// TextType "03" を除去
|
|
142
|
+
const entryWithout03: OpenBDEntry = {
|
|
143
|
+
...entry,
|
|
144
|
+
onix: {
|
|
145
|
+
...entry.onix,
|
|
146
|
+
CollateralDetail: {
|
|
147
|
+
TextContent: entry.onix.CollateralDetail?.TextContent?.filter(
|
|
148
|
+
t => t.TextType !== "03",
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const book: BookRecord = {
|
|
154
|
+
title: "型システムのしくみ",
|
|
155
|
+
authors: [],
|
|
156
|
+
publisher: "ラムダノート",
|
|
157
|
+
url: "https://www.lambdanote.com/products/type-systems",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const enriched = enrichWithOpenBD(book, entryWithout03);
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(
|
|
163
|
+
enriched.description,
|
|
164
|
+
"現代のすべてのプログラミング言語の基礎理論である「型」を、プログラマー向けに解き明かした初の概説書!",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("pubdate が不正な場合は publishedAt を設定しない", async () => {
|
|
169
|
+
entry = await loadEntry();
|
|
170
|
+
const badEntry: OpenBDEntry = {
|
|
171
|
+
...entry,
|
|
172
|
+
summary: { ...entry.summary, pubdate: "" },
|
|
173
|
+
};
|
|
174
|
+
const book: BookRecord = {
|
|
175
|
+
title: "テスト",
|
|
176
|
+
authors: [],
|
|
177
|
+
publisher: "テスト",
|
|
178
|
+
url: "https://example.com",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const enriched = enrichWithOpenBD(book, badEntry);
|
|
182
|
+
|
|
183
|
+
assert.strictEqual(enriched.publishedAt, undefined);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getBookByIsbn } from "../../../src/application/get-book-by-isbn.js";
|
|
6
|
+
import type { PublisherAdapter, PublisherDeps } from "../../../src/domain/publisher.js";
|
|
7
|
+
import type { BookRecord } from "../../../src/domain/book.js";
|
|
8
|
+
import { MockHttpClient } from "../../../src/adapters/http/mock-client.js";
|
|
9
|
+
import { CheerioHtmlParser } from "../../../src/adapters/html/cheerio-parser.js";
|
|
10
|
+
import { NullCacheStore } from "../../../src/adapters/cache/null-cache.js";
|
|
11
|
+
|
|
12
|
+
const FIXTURES_DIR = join(import.meta.dirname, "../../fixtures");
|
|
13
|
+
|
|
14
|
+
/** ランタイム非依存の最小モック関数 */
|
|
15
|
+
function mockFn<T>(impl: (...args: unknown[]) => T = () => undefined as T) {
|
|
16
|
+
const _calls: { arguments: unknown[] }[] = [];
|
|
17
|
+
const fn = Object.assign(
|
|
18
|
+
(...args: unknown[]) => {
|
|
19
|
+
_calls.push({ arguments: args });
|
|
20
|
+
return impl(...args);
|
|
21
|
+
},
|
|
22
|
+
{ mock: { calls: _calls, callCount: () => _calls.length } },
|
|
23
|
+
);
|
|
24
|
+
return fn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// openBD が返す storelink と一致する baseUrl を持つアダプター
|
|
28
|
+
const LAMBDANOTE_BASE_URL = "https://www.lambdanote.com";
|
|
29
|
+
const LAMBDANOTE_STORELINK = "https://www.lambdanote.com/collections/type-systems";
|
|
30
|
+
|
|
31
|
+
function makeDetailBook(): BookRecord {
|
|
32
|
+
return {
|
|
33
|
+
title: "型システムのしくみ(出版社サイト取得)",
|
|
34
|
+
authors: ["遠藤侑介"],
|
|
35
|
+
publisher: "ラムダノート",
|
|
36
|
+
url: LAMBDANOTE_STORELINK,
|
|
37
|
+
isbn: "9784908686207",
|
|
38
|
+
price: 3300,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeAdapter(baseUrl: string, book: BookRecord): PublisherAdapter {
|
|
43
|
+
return {
|
|
44
|
+
id: "lambdanote",
|
|
45
|
+
name: "ラムダノート",
|
|
46
|
+
baseUrl,
|
|
47
|
+
search: mockFn(),
|
|
48
|
+
getDetail: mockFn(async () => book),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function makeOpenBDDeps(http: MockHttpClient): Promise<PublisherDeps> {
|
|
53
|
+
return { http, parser: new CheerioHtmlParser(), cache: new NullCacheStore() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function makeHttpWithOpenBD(): Promise<MockHttpClient> {
|
|
57
|
+
const openBDBody = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
58
|
+
// hanmoto.storelink を追加してフィクスチャを加工
|
|
59
|
+
const data = JSON.parse(openBDBody);
|
|
60
|
+
data[0].hanmoto = { isbn: "9784908686207", storelink: LAMBDANOTE_STORELINK };
|
|
61
|
+
return new MockHttpClient().addResponse(
|
|
62
|
+
"https://api.openbd.jp/v1/get",
|
|
63
|
+
{ status: 200, body: JSON.stringify(data) },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("getBookByIsbn()", () => {
|
|
68
|
+
it("storelink が既知アダプターと一致する場合は getDetail() を呼ぶ", async () => {
|
|
69
|
+
const http = await makeHttpWithOpenBD();
|
|
70
|
+
const deps = await makeOpenBDDeps(http);
|
|
71
|
+
const detailBook = makeDetailBook();
|
|
72
|
+
const adapter = makeAdapter(LAMBDANOTE_BASE_URL, detailBook);
|
|
73
|
+
|
|
74
|
+
const result = await getBookByIsbn("9784908686207", [adapter], deps);
|
|
75
|
+
|
|
76
|
+
assert.strictEqual(result.title, "型システムのしくみ(出版社サイト取得)");
|
|
77
|
+
assert.strictEqual(
|
|
78
|
+
(adapter.getDetail as ReturnType<typeof mockFn>).mock.calls[0].arguments[0],
|
|
79
|
+
LAMBDANOTE_STORELINK,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("getDetail() が失敗した場合は openBD データで返す", async () => {
|
|
84
|
+
const http = await makeHttpWithOpenBD();
|
|
85
|
+
const deps = await makeOpenBDDeps(http);
|
|
86
|
+
const adapter: PublisherAdapter = {
|
|
87
|
+
id: "lambdanote",
|
|
88
|
+
name: "ラムダノート",
|
|
89
|
+
baseUrl: LAMBDANOTE_BASE_URL,
|
|
90
|
+
search: mockFn(),
|
|
91
|
+
getDetail: mockFn(async () => { throw new Error("詳細取得失敗"); }),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = await getBookByIsbn("9784908686207", [adapter], deps);
|
|
95
|
+
|
|
96
|
+
// フォールバックとして openBD のデータが返る
|
|
97
|
+
assert.strictEqual(result.isbn, "9784908686207");
|
|
98
|
+
assert.strictEqual(result.publisher, "ラムダノート");
|
|
99
|
+
assert.strictEqual(result.url, LAMBDANOTE_STORELINK);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("storelink が既知アダプターと一致しない場合は openBD データで返す", async () => {
|
|
103
|
+
const openBDBody = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
104
|
+
const data = JSON.parse(openBDBody);
|
|
105
|
+
data[0].hanmoto = { isbn: "9784908686207", storelink: "https://unknown-store.example.com/books/1" };
|
|
106
|
+
const http = new MockHttpClient().addResponse(
|
|
107
|
+
"https://api.openbd.jp/v1/get",
|
|
108
|
+
{ status: 200, body: JSON.stringify(data) },
|
|
109
|
+
);
|
|
110
|
+
const deps = await makeOpenBDDeps(http);
|
|
111
|
+
const adapter = makeAdapter(LAMBDANOTE_BASE_URL, makeDetailBook());
|
|
112
|
+
|
|
113
|
+
const result = await getBookByIsbn("9784908686207", [adapter], deps);
|
|
114
|
+
|
|
115
|
+
assert.strictEqual(result.isbn, "9784908686207");
|
|
116
|
+
assert.strictEqual((adapter.getDetail as ReturnType<typeof mockFn>).mock.callCount(), 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("storelink がない場合は openBD データで返す", async () => {
|
|
120
|
+
const openBDBody = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
121
|
+
// storelink を持たない hanmoto に差し替え
|
|
122
|
+
const data = JSON.parse(openBDBody);
|
|
123
|
+
data[0].hanmoto = { isbn: "9784908686207" };
|
|
124
|
+
const http = new MockHttpClient().addResponse(
|
|
125
|
+
"https://api.openbd.jp/v1/get",
|
|
126
|
+
{ status: 200, body: JSON.stringify(data) },
|
|
127
|
+
);
|
|
128
|
+
const deps = await makeOpenBDDeps(http);
|
|
129
|
+
const adapter = makeAdapter(LAMBDANOTE_BASE_URL, makeDetailBook());
|
|
130
|
+
|
|
131
|
+
const result = await getBookByIsbn("9784908686207", [adapter], deps);
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(result.isbn, "9784908686207");
|
|
134
|
+
assert.strictEqual((adapter.getDetail as ReturnType<typeof mockFn>).mock.callCount(), 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("openBD に存在しない ISBN はエラーをスローする", async () => {
|
|
138
|
+
const http = new MockHttpClient().addResponse(
|
|
139
|
+
"https://api.openbd.jp/v1/get",
|
|
140
|
+
{ status: 200, body: JSON.stringify([null]) },
|
|
141
|
+
);
|
|
142
|
+
const deps = await makeOpenBDDeps(http);
|
|
143
|
+
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
getBookByIsbn("9780000000000", [], deps),
|
|
146
|
+
/書誌情報が見つかりません/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ISBNのハイフンを除去して正規化する", async () => {
|
|
151
|
+
const http = await makeHttpWithOpenBD();
|
|
152
|
+
const deps = await makeOpenBDDeps(http);
|
|
153
|
+
|
|
154
|
+
// ハイフンありで渡しても openBD は正規化されたISBNで照合される
|
|
155
|
+
await getBookByIsbn("978-4-908686-20-7", [makeAdapter(LAMBDANOTE_BASE_URL, makeDetailBook())], deps);
|
|
156
|
+
|
|
157
|
+
assert.ok(deps.http.calls[0].includes("9784908686207"));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("openBD データから著者が分割される", async () => {
|
|
161
|
+
const openBDBody = await readFile(join(FIXTURES_DIR, "openbd-response.json"), "utf-8");
|
|
162
|
+
const data = JSON.parse(openBDBody);
|
|
163
|
+
// 複数著者をスラッシュ区切りで設定
|
|
164
|
+
data[0].summary.author = "著者A/著者B";
|
|
165
|
+
data[0].hanmoto = undefined;
|
|
166
|
+
const http = new MockHttpClient().addResponse(
|
|
167
|
+
"https://api.openbd.jp/v1/get",
|
|
168
|
+
{ status: 200, body: JSON.stringify(data) },
|
|
169
|
+
);
|
|
170
|
+
const deps = await makeOpenBDDeps(http);
|
|
171
|
+
|
|
172
|
+
const result = await getBookByIsbn("9784908686207", [], deps);
|
|
173
|
+
|
|
174
|
+
assert.deepStrictEqual(result.authors, ["著者A", "著者B"]);
|
|
175
|
+
});
|
|
176
|
+
});
|