@zonuexe/techbook-mcp 0.1.0 → 0.2.1

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.
Files changed (124) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.codex/skills/techbook-mcp-release-prep/SKILL.md +105 -0
  3. package/.github/workflows/test.yml +36 -0
  4. package/.oxlintrc.json +12 -0
  5. package/AGENTS.md +29 -1
  6. package/CHANGELOG.md +34 -0
  7. package/deno.json +3 -0
  8. package/dist/adapters/html/cheerio-parser.d.ts.map +1 -1
  9. package/dist/adapters/html/cheerio-parser.js.map +1 -1
  10. package/dist/adapters/publishers/base.d.ts +22 -1
  11. package/dist/adapters/publishers/base.d.ts.map +1 -1
  12. package/dist/adapters/publishers/base.js +142 -2
  13. package/dist/adapters/publishers/base.js.map +1 -1
  14. package/dist/adapters/publishers/book-tech.d.ts +3 -0
  15. package/dist/adapters/publishers/book-tech.d.ts.map +1 -0
  16. package/dist/adapters/publishers/book-tech.js +95 -0
  17. package/dist/adapters/publishers/book-tech.js.map +1 -0
  18. package/dist/adapters/publishers/born-digital.d.ts +3 -0
  19. package/dist/adapters/publishers/born-digital.d.ts.map +1 -0
  20. package/dist/adapters/publishers/born-digital.js +122 -0
  21. package/dist/adapters/publishers/born-digital.js.map +1 -0
  22. package/dist/adapters/publishers/coronasha.d.ts +3 -0
  23. package/dist/adapters/publishers/coronasha.d.ts.map +1 -0
  24. package/dist/adapters/publishers/coronasha.js +119 -0
  25. package/dist/adapters/publishers/coronasha.js.map +1 -0
  26. package/dist/adapters/publishers/impress.d.ts +3 -0
  27. package/dist/adapters/publishers/impress.d.ts.map +1 -0
  28. package/dist/adapters/publishers/impress.js +92 -0
  29. package/dist/adapters/publishers/impress.js.map +1 -0
  30. package/dist/adapters/publishers/manatee.d.ts +3 -0
  31. package/dist/adapters/publishers/manatee.d.ts.map +1 -0
  32. package/dist/adapters/publishers/manatee.js +93 -0
  33. package/dist/adapters/publishers/manatee.js.map +1 -0
  34. package/dist/adapters/publishers/maruzen-publishing.d.ts +3 -0
  35. package/dist/adapters/publishers/maruzen-publishing.d.ts.map +1 -0
  36. package/dist/adapters/publishers/maruzen-publishing.js +108 -0
  37. package/dist/adapters/publishers/maruzen-publishing.js.map +1 -0
  38. package/dist/adapters/publishers/optronics.d.ts +3 -0
  39. package/dist/adapters/publishers/optronics.d.ts.map +1 -0
  40. package/dist/adapters/publishers/optronics.js +92 -0
  41. package/dist/adapters/publishers/optronics.js.map +1 -0
  42. package/dist/adapters/publishers/oreilly-japan.d.ts +3 -0
  43. package/dist/adapters/publishers/oreilly-japan.d.ts.map +1 -0
  44. package/dist/adapters/publishers/oreilly-japan.js +112 -0
  45. package/dist/adapters/publishers/oreilly-japan.js.map +1 -0
  46. package/dist/adapters/publishers/peaks.d.ts +3 -0
  47. package/dist/adapters/publishers/peaks.d.ts.map +1 -0
  48. package/dist/adapters/publishers/peaks.js +80 -0
  49. package/dist/adapters/publishers/peaks.js.map +1 -0
  50. package/dist/adapters/publishers/personal-media.d.ts +3 -0
  51. package/dist/adapters/publishers/personal-media.d.ts.map +1 -0
  52. package/dist/adapters/publishers/personal-media.js +144 -0
  53. package/dist/adapters/publishers/personal-media.js.map +1 -0
  54. package/dist/adapters/publishers/registry.d.ts.map +1 -1
  55. package/dist/adapters/publishers/registry.js +26 -0
  56. package/dist/adapters/publishers/registry.js.map +1 -1
  57. package/dist/adapters/publishers/rutles.d.ts +3 -0
  58. package/dist/adapters/publishers/rutles.d.ts.map +1 -0
  59. package/dist/adapters/publishers/rutles.js +128 -0
  60. package/dist/adapters/publishers/rutles.js.map +1 -0
  61. package/dist/adapters/publishers/saiensu.d.ts +3 -0
  62. package/dist/adapters/publishers/saiensu.d.ts.map +1 -0
  63. package/dist/adapters/publishers/saiensu.js +109 -0
  64. package/dist/adapters/publishers/saiensu.js.map +1 -0
  65. package/dist/adapters/publishers/seshop.d.ts +3 -0
  66. package/dist/adapters/publishers/seshop.d.ts.map +1 -0
  67. package/dist/adapters/publishers/seshop.js +98 -0
  68. package/dist/adapters/publishers/seshop.js.map +1 -0
  69. package/dist/application/get-book-detail.d.ts.map +1 -1
  70. package/dist/application/get-book-detail.js +5 -0
  71. package/dist/application/get-book-detail.js.map +1 -1
  72. package/dist/application/search-books.d.ts.map +1 -1
  73. package/dist/application/search-books.js +7 -1
  74. package/dist/application/search-books.js.map +1 -1
  75. package/dist/domain/book.d.ts +5 -4
  76. package/dist/domain/book.d.ts.map +1 -1
  77. package/dist/main.d.ts +1 -0
  78. package/dist/main.js +1 -0
  79. package/dist/main.js.map +1 -1
  80. package/dist/mcp/server.d.ts.map +1 -1
  81. package/dist/mcp/server.js +1 -0
  82. package/dist/mcp/server.js.map +1 -1
  83. package/flake.nix +1 -1
  84. package/package.json +7 -5
  85. package/src/adapters/html/cheerio-parser.ts +4 -3
  86. package/src/adapters/publishers/base.ts +150 -0
  87. package/src/adapters/publishers/born-digital.ts +2 -17
  88. package/src/adapters/publishers/impress.ts +103 -0
  89. package/src/adapters/publishers/manatee.ts +2 -1
  90. package/src/adapters/publishers/maruzen-publishing.ts +4 -16
  91. package/src/adapters/publishers/oreilly-japan.ts +5 -10
  92. package/src/adapters/publishers/registry.ts +2 -0
  93. package/src/adapters/publishers/rutles.ts +1 -13
  94. package/src/adapters/publishers/saiensu.ts +5 -18
  95. package/src/adapters/publishers/seshop.ts +1 -1
  96. package/src/adapters/publishers/tatsu-zine.ts +61 -36
  97. package/src/application/get-book-detail.ts +7 -0
  98. package/src/application/search-books.ts +6 -1
  99. package/src/main.ts +1 -0
  100. package/tests/fixtures/impress-detail-epub.html +746 -0
  101. package/tests/fixtures/impress-detail-social.html +689 -0
  102. package/tests/fixtures/tatsu-zine-search.html +29 -13
  103. package/tests/unit/adapters/base.test.ts +441 -0
  104. package/tests/unit/adapters/publishers/book-tech.test.ts +18 -15
  105. package/tests/unit/adapters/publishers/born-digital.test.ts +18 -15
  106. package/tests/unit/adapters/publishers/coronasha.test.ts +26 -20
  107. package/tests/unit/adapters/publishers/gihyo.test.ts +21 -19
  108. package/tests/unit/adapters/publishers/impress.test.ts +129 -0
  109. package/tests/unit/adapters/publishers/lambdanote.test.ts +12 -11
  110. package/tests/unit/adapters/publishers/manatee.test.ts +14 -12
  111. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +19 -17
  112. package/tests/unit/adapters/publishers/optronics.test.ts +19 -16
  113. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +19 -16
  114. package/tests/unit/adapters/publishers/peaks.test.ts +17 -14
  115. package/tests/unit/adapters/publishers/personal-media.test.ts +18 -15
  116. package/tests/unit/adapters/publishers/rutles.test.ts +15 -12
  117. package/tests/unit/adapters/publishers/saiensu.test.ts +14 -12
  118. package/tests/unit/adapters/publishers/seshop.test.ts +16 -13
  119. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +56 -14
  120. package/tests/unit/adapters/publishers/techbookfest.test.ts +12 -11
  121. package/tests/unit/adapters/registry.test.ts +37 -0
  122. package/tests/unit/application/get-book-detail.test.ts +102 -0
  123. package/tests/unit/application/search-books.test.ts +137 -0
  124. package/vitest.config.ts +0 -8
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { saiensuAdapter } from "../../../../src/adapters/publishers/saiensu.js";
@@ -28,8 +29,8 @@ describe("saiensuAdapter", () => {
28
29
  const results = await saiensuAdapter.search({ title: "統計" }, makeDeps(http));
29
30
 
30
31
  // フィクスチャには電子1件・紙1件あり、電子のみ返す
31
- expect(results).toHaveLength(1);
32
- expect(results[0]).toMatchObject({
32
+ assert.strictEqual(results.length, 1);
33
+ assert.partialDeepStrictEqual(results[0], {
33
34
  title: "統計リテラシーI【電子版】 ―記述統計から推測統計へ",
34
35
  authors: ["堀井俊佑"],
35
36
  publisher: "サイエンス社",
@@ -49,7 +50,8 @@ describe("saiensuAdapter", () => {
49
50
 
50
51
  const results = await saiensuAdapter.search({ title: "統計" }, makeDeps(http));
51
52
 
52
- expect(results[0].coverImageUrl).toBe(
53
+ assert.strictEqual(
54
+ results[0].coverImageUrl,
53
55
  "https://www.saiensu.co.jp/bookThumbs/2026-978-4-7819-9049-1.jpg",
54
56
  );
55
57
  });
@@ -63,7 +65,7 @@ describe("saiensuAdapter", () => {
63
65
 
64
66
  const results = await saiensuAdapter.search({ title: "統計" }, makeDeps(http));
65
67
 
66
- expect(results[0].ebookStores).toEqual([
68
+ assert.deepStrictEqual(results[0].ebookStores, [
67
69
  {
68
70
  name: "サイエンス社",
69
71
  url: "https://www.saiensu.co.jp/search/?isbn=978-4-7819-9049-1&y=2026",
@@ -81,7 +83,7 @@ describe("saiensuAdapter", () => {
81
83
 
82
84
  const results = await saiensuAdapter.search({ title: "統計" }, makeDeps(http));
83
85
 
84
- expect(results[0].authors).toEqual(["堀井俊佑"]);
86
+ assert.deepStrictEqual(results[0].authors, ["堀井俊佑"]);
85
87
  });
86
88
 
87
89
  it("title も author も空の場合は [] を返しHTTPを呼ばない", async () => {
@@ -89,8 +91,8 @@ describe("saiensuAdapter", () => {
89
91
 
90
92
  const results = await saiensuAdapter.search({}, makeDeps(http));
91
93
 
92
- expect(results).toEqual([]);
93
- expect(http.calls).toHaveLength(0);
94
+ assert.deepStrictEqual(results, []);
95
+ assert.strictEqual(http.calls.length, 0);
94
96
  });
95
97
 
96
98
  it("検索リクエストに keyword が含まれる", async () => {
@@ -102,7 +104,7 @@ describe("saiensuAdapter", () => {
102
104
 
103
105
  await saiensuAdapter.search({ title: "意味論" }, makeDeps(http));
104
106
 
105
- expect(http.calls[0]).toContain("keyword=%E6%84%8F%E5%91%B3%E8%AB%96");
107
+ assert.ok(http.calls[0].includes("keyword=%E6%84%8F%E5%91%B3%E8%AB%96"));
106
108
  });
107
109
  });
108
110
 
@@ -119,7 +121,7 @@ describe("saiensuAdapter", () => {
119
121
  makeDeps(http),
120
122
  );
121
123
 
122
- expect(book).toMatchObject({
124
+ assert.partialDeepStrictEqual(book, {
123
125
  title: "統計リテラシーI【電子版】 ―記述統計から推測統計へ",
124
126
  publisher: "サイエンス社",
125
127
  isbn: "9784781990491",
@@ -140,7 +142,7 @@ describe("saiensuAdapter", () => {
140
142
  makeDeps(http),
141
143
  );
142
144
 
143
- expect(book.authors).toEqual(["堀井俊佑"]);
145
+ assert.deepStrictEqual(book.authors, ["堀井俊佑"]);
144
146
  });
145
147
 
146
148
  it("ebookStores にサイエンス社(DRM付き)が含まれる", async () => {
@@ -155,7 +157,7 @@ describe("saiensuAdapter", () => {
155
157
  makeDeps(http),
156
158
  );
157
159
 
158
- expect(book.ebookStores).toEqual([
160
+ assert.deepStrictEqual(book.ebookStores, [
159
161
  {
160
162
  name: "サイエンス社",
161
163
  url: "https://www.saiensu.co.jp/search/?isbn=978-4-7819-9049-1&y=2026",
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { seshopAdapter } from "../../../../src/adapters/publishers/seshop.js";
@@ -28,8 +29,8 @@ describe("seshopAdapter", () => {
28
29
  const results = await seshopAdapter.search({ title: "TypeScript" }, makeDeps(http));
29
30
 
30
31
  // フィクスチャには電子2件・紙1件あり、電子のみ返す
31
- expect(results).toHaveLength(2);
32
- expect(results[0]).toMatchObject({
32
+ assert.strictEqual(results.length, 2);
33
+ assert.partialDeepStrictEqual(results[0], {
33
34
  title: "TypeScript入門【PDF版】",
34
35
  publisher: "翔泳社",
35
36
  url: "https://www.seshop.com/product/detail/26500",
@@ -47,7 +48,8 @@ describe("seshopAdapter", () => {
47
48
 
48
49
  const results = await seshopAdapter.search({ title: "TypeScript" }, makeDeps(http));
49
50
 
50
- expect(results[0].coverImageUrl).toBe(
51
+ assert.strictEqual(
52
+ results[0].coverImageUrl,
51
53
  "https://www.seshop.com/static/images/product/26500/L.png",
52
54
  );
53
55
  });
@@ -61,7 +63,7 @@ describe("seshopAdapter", () => {
61
63
 
62
64
  const results = await seshopAdapter.search({ title: "TypeScript" }, makeDeps(http));
63
65
 
64
- expect(results[0].ebookStores).toEqual([
66
+ assert.deepStrictEqual(results[0].ebookStores, [
65
67
  {
66
68
  name: "SEshop",
67
69
  url: "https://www.seshop.com/product/detail/26500",
@@ -75,8 +77,8 @@ describe("seshopAdapter", () => {
75
77
 
76
78
  const results = await seshopAdapter.search({}, makeDeps(http));
77
79
 
78
- expect(results).toEqual([]);
79
- expect(http.calls).toHaveLength(0);
80
+ assert.deepStrictEqual(results, []);
81
+ assert.strictEqual(http.calls.length, 0);
80
82
  });
81
83
 
82
84
  it("検索リクエストに keyword と category_id=327 が含まれる", async () => {
@@ -88,8 +90,8 @@ describe("seshopAdapter", () => {
88
90
 
89
91
  await seshopAdapter.search({ title: "TypeScript" }, makeDeps(http));
90
92
 
91
- expect(http.calls[0]).toContain("keyword=TypeScript");
92
- expect(http.calls[0]).toContain("category_id=327");
93
+ assert.ok(http.calls[0].includes("keyword=TypeScript"));
94
+ assert.ok(http.calls[0].includes("category_id=327"));
93
95
  });
94
96
  });
95
97
 
@@ -106,7 +108,7 @@ describe("seshopAdapter", () => {
106
108
  makeDeps(http),
107
109
  );
108
110
 
109
- expect(book).toMatchObject({
111
+ assert.partialDeepStrictEqual(book, {
110
112
  title: "TypeScript入門【PDF版】",
111
113
  publisher: "翔泳社",
112
114
  isbn: "9784798190014",
@@ -127,7 +129,7 @@ describe("seshopAdapter", () => {
127
129
  makeDeps(http),
128
130
  );
129
131
 
130
- expect(book.authors).toEqual(["山田 太郎", "鈴木 花子"]);
132
+ assert.deepStrictEqual(book.authors, ["山田 太郎", "鈴木 花子"]);
131
133
  });
132
134
 
133
135
  it("coverImageUrl が設定される", async () => {
@@ -142,7 +144,8 @@ describe("seshopAdapter", () => {
142
144
  makeDeps(http),
143
145
  );
144
146
 
145
- expect(book.coverImageUrl).toBe(
147
+ assert.strictEqual(
148
+ book.coverImageUrl,
146
149
  "https://www.seshop.com/static/images/product/26500/L.png",
147
150
  );
148
151
  });
@@ -159,7 +162,7 @@ describe("seshopAdapter", () => {
159
162
  makeDeps(http),
160
163
  );
161
164
 
162
- expect(book.ebookStores).toEqual([
165
+ assert.deepStrictEqual(book.ebookStores, [
163
166
  {
164
167
  name: "SEshop",
165
168
  url: "https://www.seshop.com/product/detail/26500",
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { tatsuZineAdapter } from "../../../../src/adapters/publishers/tatsu-zine.js";
@@ -27,13 +28,13 @@ describe("tatsuZineAdapter", () => {
27
28
 
28
29
  const results = await tatsuZineAdapter.search({ title: "Go" }, makeDeps(http));
29
30
 
30
- expect(results).toHaveLength(2);
31
- expect(results[0]).toMatchObject({
31
+ assert.strictEqual(results.length, 2);
32
+ assert.partialDeepStrictEqual(results[0], {
32
33
  title: "Goプログラミング実践入門",
33
34
  authors: ["Sau Sheong Chang", "武舎 広幸"],
34
35
  publisher: "達人出版会",
35
36
  });
36
- expect(results[0].url).toBe("https://tatsu-zine.com/books/go-programming");
37
+ assert.strictEqual(results[0].url, "https://tatsu-zine.com/books/go-programming");
37
38
  });
38
39
 
39
40
  it("ebookStores に達人出版会(ソーシャルDRM)が含まれる", async () => {
@@ -45,7 +46,7 @@ describe("tatsuZineAdapter", () => {
45
46
 
46
47
  const results = await tatsuZineAdapter.search({ title: "Go" }, makeDeps(http));
47
48
 
48
- expect(results[0].ebookStores).toEqual([
49
+ assert.deepStrictEqual(results[0].ebookStores, [
49
50
  { name: "達人出版会", url: "https://tatsu-zine.com/books/go-programming", drm: "social" },
50
51
  ]);
51
52
  });
@@ -59,18 +60,26 @@ describe("tatsuZineAdapter", () => {
59
60
 
60
61
  const results = await tatsuZineAdapter.search({ title: "Go", limit: 1 }, makeDeps(http));
61
62
 
62
- expect(results).toHaveLength(1);
63
+ assert.strictEqual(results.length, 1);
63
64
  });
64
65
 
65
- it("title author も空の場合は [] を返しHTTPを呼ばない", async () => {
66
+ it("title が未指定の場合は [] を返しHTTPを呼ばない", async () => {
66
67
  const http = new MockHttpClient();
67
68
  const results = await tatsuZineAdapter.search({}, makeDeps(http));
68
69
 
69
- expect(results).toEqual([]);
70
- expect(http.calls).toHaveLength(0);
70
+ assert.deepStrictEqual(results, []);
71
+ assert.strictEqual(http.calls.length, 0);
71
72
  });
72
73
 
73
- it("検索リクエストに search パラメータが含まれる", async () => {
74
+ it("author のみ指定の場合も [] を返しHTTPを呼ばない", async () => {
75
+ const http = new MockHttpClient();
76
+ const results = await tatsuZineAdapter.search({ author: "Jesse Storimer" }, makeDeps(http));
77
+
78
+ assert.deepStrictEqual(results, []);
79
+ assert.strictEqual(http.calls.length, 0);
80
+ });
81
+
82
+ it("書籍一覧ページ全体を取得してタイトルでフィルタする", async () => {
74
83
  const body = await loadFixture("tatsu-zine-search.html");
75
84
  const http = new MockHttpClient().addResponse(
76
85
  "https://tatsu-zine.com/books/",
@@ -79,7 +88,40 @@ describe("tatsuZineAdapter", () => {
79
88
 
80
89
  await tatsuZineAdapter.search({ title: "Go言語" }, makeDeps(http));
81
90
 
82
- expect(http.calls[0]).toContain("search=Go%E8%A8%80%E8%AA%9E");
91
+ assert.strictEqual(http.calls[0], "https://tatsu-zine.com/books/");
92
+ });
93
+
94
+ it("ページネーションがある場合は全ページを取得してフィルタする", async () => {
95
+ const page1 = `<!DOCTYPE html><html><body>
96
+ <section class="pagination">
97
+ <nav class="pagination">
98
+ <a class="btn-pagination" href="/books?page=2">2</a>
99
+ <a class="btn-pagination" href="/books?page=2">最後へ</a>
100
+ </nav>
101
+ </section>
102
+ <article class="book">
103
+ <h3 itemprop="name"><a href="/books/page1-book">ページ1の本</a></h3>
104
+ <p itemprop="author" class="author">著者A(著)</p>
105
+ </article>
106
+ </body></html>`;
107
+ const page2 = `<!DOCTYPE html><html><body>
108
+ <article class="book">
109
+ <h3 itemprop="name"><a href="/books/naruhounix">なるほどUnixプロセス ― Rubyで学ぶUnixの基礎</a></h3>
110
+ <p itemprop="author" class="author">Jesse Storimer(著), 島田 浩二(訳), 角谷 信太郎(訳)</p>
111
+ </article>
112
+ </body></html>`;
113
+ const http = new MockHttpClient()
114
+ .addResponse("https://tatsu-zine.com/books/", { status: 200, body: page1 })
115
+ .addResponse("https://tatsu-zine.com/books?page=2", { status: 200, body: page2 });
116
+
117
+ const results = await tatsuZineAdapter.search({ title: "なるほどUnix" }, makeDeps(http));
118
+
119
+ assert.strictEqual(results.length, 1);
120
+ assert.partialDeepStrictEqual(results[0], {
121
+ title: "なるほどUnixプロセス ― Rubyで学ぶUnixの基礎",
122
+ authors: ["Jesse Storimer", "島田 浩二", "角谷 信太郎"],
123
+ url: "https://tatsu-zine.com/books/naruhounix",
124
+ });
83
125
  });
84
126
  });
85
127
 
@@ -96,14 +138,14 @@ describe("tatsuZineAdapter", () => {
96
138
  makeDeps(http),
97
139
  );
98
140
 
99
- expect(book).toMatchObject({
141
+ assert.partialDeepStrictEqual(book, {
100
142
  title: "Goプログラミング実践入門",
101
143
  authors: ["Sau Sheong Chang", "武舎 広幸"],
102
144
  publisher: "インプレス",
103
145
  price: 3520,
104
146
  });
105
147
  // 達人出版会は「ソーシャルDRM」と明記がなくても全書籍で購入者情報を印字
106
- expect(book.ebookStores).toEqual([
148
+ assert.deepStrictEqual(book.ebookStores, [
107
149
  { name: "達人出版会", url: "https://tatsu-zine.com/books/go-programming", drm: "social" },
108
150
  ]);
109
151
  });
@@ -124,7 +166,7 @@ describe("tatsuZineAdapter", () => {
124
166
  makeDeps(http),
125
167
  );
126
168
 
127
- expect(book.publisher).toBe("達人出版会");
169
+ assert.strictEqual(book.publisher, "達人出版会");
128
170
  });
129
171
  });
130
172
  });
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { techbookfestAdapter } from "../../../../src/adapters/publishers/techbookfest.js";
@@ -33,8 +34,8 @@ describe("techbookfestAdapter", () => {
33
34
 
34
35
  const results = await techbookfestAdapter.search({ title: "TypeScript", limit: 10 }, makeDeps(http));
35
36
 
36
- expect(results).toHaveLength(3);
37
- expect(results[0]).toMatchObject({
37
+ assert.strictEqual(results.length, 3);
38
+ assert.partialDeepStrictEqual(results[0], {
38
39
  title: "TypeScriptで学ぶデザインパターン",
39
40
  authors: ["サークル名A"],
40
41
  publisher: "技術書典",
@@ -42,7 +43,7 @@ describe("techbookfestAdapter", () => {
42
43
  price: 1000,
43
44
  publishedAt: "2024-01-15",
44
45
  });
45
- expect(results[0].coverImageUrl).toBe("https://techbookfest.org/api/image/01HXXXX1.png");
46
+ assert.strictEqual(results[0].coverImageUrl, "https://techbookfest.org/api/image/01HXXXX1.png");
46
47
  });
47
48
 
48
49
  it("ebookStores に技術書典(DRMフリー)が含まれる", async () => {
@@ -50,7 +51,7 @@ describe("techbookfestAdapter", () => {
50
51
 
51
52
  const results = await techbookfestAdapter.search({ title: "TypeScript" }, makeDeps(http));
52
53
 
53
- expect(results[0].ebookStores).toEqual([
54
+ assert.deepStrictEqual(results[0].ebookStores, [
54
55
  { name: "技術書典", url: "https://techbookfest.org/product/01HXXXX1", drm: "free" },
55
56
  ]);
56
57
  });
@@ -61,15 +62,15 @@ describe("techbookfestAdapter", () => {
61
62
  const results = await techbookfestAdapter.search({ title: "TypeScript" }, makeDeps(http));
62
63
 
63
64
  const book = results.find(b => b.url === "https://techbookfest.org/product/01HXXXX2");
64
- expect(book?.coverImageUrl).toBeUndefined();
65
+ assert.strictEqual(book?.coverImageUrl, undefined);
65
66
  });
66
67
 
67
68
  it("title も author も空の場合は [] を返しHTTPを呼ばない", async () => {
68
69
  const http = new MockHttpClient();
69
70
  const results = await techbookfestAdapter.search({}, makeDeps(http));
70
71
 
71
- expect(results).toEqual([]);
72
- expect(http.calls).toHaveLength(0);
72
+ assert.deepStrictEqual(results, []);
73
+ assert.strictEqual(http.calls.length, 0);
73
74
  });
74
75
 
75
76
  it("トップページ GET で XSRF-TOKEN を取得してから GraphQL に POST する", async () => {
@@ -77,8 +78,8 @@ describe("techbookfestAdapter", () => {
77
78
 
78
79
  await techbookfestAdapter.search({ title: "TypeScript" }, makeDeps(http));
79
80
 
80
- expect(http.calls[0]).toBe("https://techbookfest.org");
81
- expect(http.calls[1]).toBe("https://techbookfest.org/api/graphql");
81
+ assert.strictEqual(http.calls[0], "https://techbookfest.org");
82
+ assert.strictEqual(http.calls[1], "https://techbookfest.org/api/graphql");
82
83
  });
83
84
 
84
85
  it("price が 0 の書籍も返す", async () => {
@@ -87,7 +88,7 @@ describe("techbookfestAdapter", () => {
87
88
  const results = await techbookfestAdapter.search({ title: "TypeScript" }, makeDeps(http));
88
89
 
89
90
  const freeBook = results.find(b => b.url === "https://techbookfest.org/product/01HXXXX3");
90
- expect(freeBook?.price).toBe(0);
91
+ assert.strictEqual(freeBook?.price, 0);
91
92
  });
92
93
  });
93
94
  });
@@ -0,0 +1,37 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { DEFAULT_PUBLISHERS } from "../../../src/adapters/publishers/registry.js";
4
+
5
+ describe("DEFAULT_PUBLISHERS", () => {
6
+ it("アダプターが1件以上登録されている", () => {
7
+ assert.ok(DEFAULT_PUBLISHERS.length > 0);
8
+ });
9
+
10
+ it("id がすべて一意である", () => {
11
+ const ids = DEFAULT_PUBLISHERS.map(p => p.id);
12
+ const uniqueIds = new Set(ids);
13
+ assert.strictEqual(uniqueIds.size, ids.length);
14
+ });
15
+
16
+ it("baseUrl がすべて一意である", () => {
17
+ const urls = DEFAULT_PUBLISHERS.map(p => p.baseUrl);
18
+ const uniqueUrls = new Set(urls);
19
+ assert.strictEqual(uniqueUrls.size, urls.length);
20
+ });
21
+
22
+ it("各アダプターが必須フィールドを持つ", () => {
23
+ for (const p of DEFAULT_PUBLISHERS) {
24
+ assert.ok(p.id, `${p.id}: id が空`);
25
+ assert.ok(p.name, `${p.id}: name が空`);
26
+ assert.ok(p.baseUrl, `${p.id}: baseUrl が空`);
27
+ assert.strictEqual(typeof p.search, "function", `${p.id}: search が関数でない`);
28
+ assert.strictEqual(typeof p.getDetail, "function", `${p.id}: getDetail が関数でない`);
29
+ }
30
+ });
31
+
32
+ it("baseUrl がすべて https:// で始まる", () => {
33
+ for (const p of DEFAULT_PUBLISHERS) {
34
+ assert.match(p.baseUrl, /^https:\/\//, `${p.id}: baseUrl が https でない`);
35
+ }
36
+ });
37
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getBookDetail } from "../../../src/application/get-book-detail.js";
4
+ import type { PublisherAdapter, PublisherDeps } from "../../../src/domain/publisher.js";
5
+ import type { BookRecord } from "../../../src/domain/book.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
+ /** ランタイム非依存の最小モック関数 */
11
+ function mockFn<T>(impl: (...args: unknown[]) => T = () => undefined as T) {
12
+ const _calls: { arguments: unknown[] }[] = [];
13
+ const fn = Object.assign(
14
+ (...args: unknown[]) => {
15
+ _calls.push({ arguments: args });
16
+ return impl(...args);
17
+ },
18
+ { mock: { calls: _calls, callCount: () => _calls.length } },
19
+ );
20
+ return fn;
21
+ }
22
+
23
+ function makeDeps(): PublisherDeps {
24
+ return {
25
+ http: new MockHttpClient(),
26
+ parser: new CheerioHtmlParser(),
27
+ cache: new NullCacheStore(),
28
+ };
29
+ }
30
+
31
+ function makeBook(overrides: Partial<BookRecord> = {}): BookRecord {
32
+ return {
33
+ title: "テスト本",
34
+ authors: ["著者名"],
35
+ publisher: "テスト社",
36
+ url: "https://example.com/book/1",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function makeAdapter(baseUrl: string, book: BookRecord): PublisherAdapter {
42
+ return {
43
+ id: "test",
44
+ name: "テスト社",
45
+ baseUrl,
46
+ search: mockFn(),
47
+ getDetail: mockFn(async () => book),
48
+ };
49
+ }
50
+
51
+ describe("getBookDetail()", () => {
52
+ it("URLに対応するアダプターの getDetail() を呼んで結果を返す", async () => {
53
+ const book = makeBook({ title: "詳細情報テスト" });
54
+ const adapter = makeAdapter("https://example.com", book);
55
+ const url = "https://example.com/book/42";
56
+
57
+ const result = await getBookDetail(url, [adapter], makeDeps());
58
+
59
+ assert.deepStrictEqual(result, book);
60
+ assert.strictEqual(
61
+ (adapter.getDetail as ReturnType<typeof mockFn>).mock.calls[0].arguments[0],
62
+ url,
63
+ );
64
+ });
65
+
66
+ it("baseUrl が前方一致するアダプターを選択する", async () => {
67
+ const bookA = makeBook({ title: "A社の本" });
68
+ const bookB = makeBook({ title: "B社の本" });
69
+ const adapterA = makeAdapter("https://a.example.com", bookA);
70
+ const adapterB = makeAdapter("https://b.example.com", bookB);
71
+
72
+ const result = await getBookDetail("https://b.example.com/book/1", [adapterA, adapterB], makeDeps());
73
+
74
+ assert.strictEqual(result.title, "B社の本");
75
+ assert.strictEqual((adapterA.getDetail as ReturnType<typeof mockFn>).mock.callCount(), 0);
76
+ });
77
+
78
+ it("対応するアダプターがなければエラーをスローする", async () => {
79
+ const adapter = makeAdapter("https://other.example.com", makeBook());
80
+
81
+ await assert.rejects(
82
+ getBookDetail("https://unknown.example.com/book/1", [adapter], makeDeps()),
83
+ /このURLに対応する出版社アダプターがありません/,
84
+ );
85
+ });
86
+
87
+ it("エラーメッセージに対応URLリストを含む", async () => {
88
+ const adapter = makeAdapter("https://example.com", makeBook());
89
+
90
+ await assert.rejects(
91
+ getBookDetail("https://unknown.example.com/book/1", [adapter], makeDeps()),
92
+ /https:\/\/example\.com/,
93
+ );
94
+ });
95
+
96
+ it("アダプターが空のときエラーをスローする", async () => {
97
+ await assert.rejects(
98
+ getBookDetail("https://example.com/book/1", [], makeDeps()),
99
+ /このURLに対応する出版社アダプターがありません/,
100
+ );
101
+ });
102
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { searchBooks } from "../../../src/application/search-books.js";
4
+ import type { PublisherAdapter, PublisherDeps } from "../../../src/domain/publisher.js";
5
+ import type { BookRecord } from "../../../src/domain/book.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
+ /** ランタイム非依存の最小モック関数 */
11
+ function mockFn<T>(impl: (...args: unknown[]) => T = () => undefined as T) {
12
+ const _calls: { arguments: unknown[] }[] = [];
13
+ const fn = Object.assign(
14
+ (...args: unknown[]) => {
15
+ _calls.push({ arguments: args });
16
+ return impl(...args);
17
+ },
18
+ { mock: { calls: _calls, callCount: () => _calls.length } },
19
+ );
20
+ return fn;
21
+ }
22
+
23
+ function makeDeps(): PublisherDeps {
24
+ return {
25
+ http: new MockHttpClient(),
26
+ parser: new CheerioHtmlParser(),
27
+ cache: new NullCacheStore(),
28
+ };
29
+ }
30
+
31
+ function makeBook(overrides: Partial<BookRecord> = {}): BookRecord {
32
+ return {
33
+ title: "テスト本",
34
+ authors: ["著者名"],
35
+ publisher: "テスト社",
36
+ url: "https://example.com/book/1",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function makeAdapter(id: string, books: BookRecord[]): PublisherAdapter {
42
+ return {
43
+ id,
44
+ name: `${id} 出版社`,
45
+ baseUrl: `https://${id}.example.com`,
46
+ search: mockFn(async () => books),
47
+ getDetail: mockFn(),
48
+ };
49
+ }
50
+
51
+ describe("searchBooks()", () => {
52
+ it("全アダプターの結果を結合して返す", async () => {
53
+ const book1 = makeBook({ title: "本A", url: "https://a.example.com/1" });
54
+ const book2 = makeBook({ title: "本B", url: "https://b.example.com/1" });
55
+ const publishers = [makeAdapter("a", [book1]), makeAdapter("b", [book2])];
56
+
57
+ const { books, errors } = await searchBooks({ title: "テスト" }, publishers, makeDeps());
58
+
59
+ assert.strictEqual(books.length, 2);
60
+ assert.strictEqual(books[0].title, "本A");
61
+ assert.strictEqual(books[1].title, "本B");
62
+ assert.strictEqual(errors.length, 0);
63
+ });
64
+
65
+ it("publisherId が指定された場合は該当アダプターのみ呼ぶ", async () => {
66
+ const book = makeBook({ title: "本A" });
67
+ const adapterA = makeAdapter("a", [book]);
68
+ const adapterB = makeAdapter("b", []);
69
+ const publishers = [adapterA, adapterB];
70
+
71
+ const { books } = await searchBooks({ title: "テスト", publisherId: "a" }, publishers, makeDeps());
72
+
73
+ assert.strictEqual(books.length, 1);
74
+ assert.strictEqual((adapterA.search as ReturnType<typeof mockFn>).mock.callCount(), 1);
75
+ assert.strictEqual((adapterB.search as ReturnType<typeof mockFn>).mock.callCount(), 0);
76
+ });
77
+
78
+ it("1つのアダプターが失敗しても他の結果は返す", async () => {
79
+ const book = makeBook({ title: "成功" });
80
+ const failingAdapter: PublisherAdapter = {
81
+ id: "fail",
82
+ name: "失敗社",
83
+ baseUrl: "https://fail.example.com",
84
+ search: mockFn(() => Promise.reject(new Error("network error"))),
85
+ getDetail: mockFn(),
86
+ };
87
+ const publishers = [failingAdapter, makeAdapter("ok", [book])];
88
+
89
+ const { books, errors } = await searchBooks({ title: "テスト" }, publishers, makeDeps());
90
+
91
+ assert.strictEqual(books.length, 1);
92
+ assert.strictEqual(books[0].title, "成功");
93
+ assert.strictEqual(errors.length, 1);
94
+ assert.deepStrictEqual(errors[0], { publisherId: "fail", message: "network error" });
95
+ });
96
+
97
+ it("全アダプターが失敗した場合は books が空で errors に全件入る", async () => {
98
+ const publishers = [
99
+ { id: "a", name: "A社", baseUrl: "https://a.example.com", search: mockFn(() => Promise.reject(new Error("err A"))), getDetail: mockFn() },
100
+ { id: "b", name: "B社", baseUrl: "https://b.example.com", search: mockFn(() => Promise.reject(new Error("err B"))), getDetail: mockFn() },
101
+ ];
102
+
103
+ const { books, errors } = await searchBooks({ title: "テスト" }, publishers, makeDeps());
104
+
105
+ assert.strictEqual(books.length, 0);
106
+ assert.strictEqual(errors.length, 2);
107
+ assert.deepStrictEqual(errors.map(e => e.publisherId), ["a", "b"]);
108
+ });
109
+
110
+ it("Error 以外の例外も文字列化して errors に入れる", async () => {
111
+ const publishers = [
112
+ { id: "x", name: "X社", baseUrl: "https://x.example.com", search: mockFn(() => Promise.reject("string error")), getDetail: mockFn() },
113
+ ];
114
+
115
+ const { errors } = await searchBooks({ title: "テスト" }, publishers, makeDeps());
116
+
117
+ assert.strictEqual(errors[0].message, "string error");
118
+ });
119
+
120
+ it("アダプターが0件のとき空配列を返す", async () => {
121
+ const { books, errors } = await searchBooks({ title: "テスト" }, [], makeDeps());
122
+ assert.deepStrictEqual(books, []);
123
+ assert.deepStrictEqual(errors, []);
124
+ });
125
+
126
+ it("クエリをそのまま各アダプターの search() に渡す", async () => {
127
+ const adapter = makeAdapter("a", []);
128
+ const query = { title: "TypeScript", author: "山田", limit: 5 };
129
+
130
+ await searchBooks(query, [adapter], makeDeps());
131
+
132
+ assert.deepStrictEqual(
133
+ (adapter.search as ReturnType<typeof mockFn>).mock.calls[0].arguments[0],
134
+ query,
135
+ );
136
+ });
137
+ });