@zonuexe/techbook-mcp 0.2.3 → 0.3.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 (207) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +54 -22
  3. package/dist/adapters/http/fetch-client.d.ts.map +1 -1
  4. package/dist/adapters/http/fetch-client.js +18 -1
  5. package/dist/adapters/http/fetch-client.js.map +1 -1
  6. package/dist/adapters/openbd.d.ts.map +1 -1
  7. package/dist/adapters/openbd.js +18 -5
  8. package/dist/adapters/openbd.js.map +1 -1
  9. package/dist/adapters/publishers/base.d.ts +2 -1
  10. package/dist/adapters/publishers/base.d.ts.map +1 -1
  11. package/dist/adapters/publishers/base.js +8 -3
  12. package/dist/adapters/publishers/base.js.map +1 -1
  13. package/dist/adapters/publishers/cq-publishing.d.ts +3 -0
  14. package/dist/adapters/publishers/cq-publishing.d.ts.map +1 -0
  15. package/dist/adapters/publishers/cq-publishing.js +120 -0
  16. package/dist/adapters/publishers/cq-publishing.js.map +1 -0
  17. package/dist/adapters/publishers/google-books.d.ts +4 -0
  18. package/dist/adapters/publishers/google-books.d.ts.map +1 -0
  19. package/dist/adapters/publishers/google-books.js +76 -0
  20. package/dist/adapters/publishers/google-books.js.map +1 -0
  21. package/dist/adapters/publishers/isbn-publisher-codes.d.ts +21 -0
  22. package/dist/adapters/publishers/isbn-publisher-codes.d.ts.map +1 -0
  23. package/dist/adapters/publishers/isbn-publisher-codes.js +49 -0
  24. package/dist/adapters/publishers/isbn-publisher-codes.js.map +1 -0
  25. package/dist/adapters/publishers/juse-p.d.ts +3 -0
  26. package/dist/adapters/publishers/juse-p.d.ts.map +1 -0
  27. package/dist/adapters/publishers/juse-p.js +110 -0
  28. package/dist/adapters/publishers/juse-p.js.map +1 -0
  29. package/dist/adapters/publishers/leanpub.d.ts +3 -0
  30. package/dist/adapters/publishers/leanpub.d.ts.map +1 -0
  31. package/dist/adapters/publishers/leanpub.js +96 -0
  32. package/dist/adapters/publishers/leanpub.js.map +1 -0
  33. package/dist/adapters/publishers/oreilly-japan.d.ts.map +1 -1
  34. package/dist/adapters/publishers/oreilly-japan.js +8 -2
  35. package/dist/adapters/publishers/oreilly-japan.js.map +1 -1
  36. package/dist/adapters/publishers/peaks.d.ts.map +1 -1
  37. package/dist/adapters/publishers/peaks.js +3 -2
  38. package/dist/adapters/publishers/peaks.js.map +1 -1
  39. package/dist/adapters/publishers/personal-media.d.ts.map +1 -1
  40. package/dist/adapters/publishers/personal-media.js +3 -2
  41. package/dist/adapters/publishers/personal-media.js.map +1 -1
  42. package/dist/adapters/publishers/pragprog.d.ts +3 -0
  43. package/dist/adapters/publishers/pragprog.d.ts.map +1 -0
  44. package/dist/adapters/publishers/pragprog.js +120 -0
  45. package/dist/adapters/publishers/pragprog.js.map +1 -0
  46. package/dist/adapters/publishers/registry.d.ts.map +1 -1
  47. package/dist/adapters/publishers/registry.js +10 -0
  48. package/dist/adapters/publishers/registry.js.map +1 -1
  49. package/dist/adapters/publishers/techbookfest.d.ts.map +1 -1
  50. package/dist/adapters/publishers/techbookfest.js +2 -1
  51. package/dist/adapters/publishers/techbookfest.js.map +1 -1
  52. package/dist/application/concurrency.d.ts +16 -0
  53. package/dist/application/concurrency.d.ts.map +1 -0
  54. package/dist/application/concurrency.js +42 -0
  55. package/dist/application/concurrency.js.map +1 -0
  56. package/dist/application/get-book-by-isbn.d.ts +0 -8
  57. package/dist/application/get-book-by-isbn.d.ts.map +1 -1
  58. package/dist/application/get-book-by-isbn.js +64 -7
  59. package/dist/application/get-book-by-isbn.js.map +1 -1
  60. package/dist/application/get-book-detail.d.ts.map +1 -1
  61. package/dist/application/get-book-detail.js +3 -0
  62. package/dist/application/get-book-detail.js.map +1 -1
  63. package/dist/application/search-books.d.ts +16 -5
  64. package/dist/application/search-books.d.ts.map +1 -1
  65. package/dist/application/search-books.js +46 -9
  66. package/dist/application/search-books.js.map +1 -1
  67. package/dist/config/credentials.d.ts +8 -0
  68. package/dist/config/credentials.d.ts.map +1 -0
  69. package/dist/config/credentials.js +32 -0
  70. package/dist/config/credentials.js.map +1 -0
  71. package/dist/domain/authors.d.ts +7 -0
  72. package/dist/domain/authors.d.ts.map +1 -0
  73. package/dist/domain/authors.js +22 -0
  74. package/dist/domain/authors.js.map +1 -0
  75. package/dist/domain/book.d.ts +2 -0
  76. package/dist/domain/book.d.ts.map +1 -1
  77. package/dist/domain/isbn.d.ts +8 -0
  78. package/dist/domain/isbn.d.ts.map +1 -0
  79. package/dist/domain/isbn.js +16 -0
  80. package/dist/domain/isbn.js.map +1 -0
  81. package/dist/domain/publisher.d.ts +16 -0
  82. package/dist/domain/publisher.d.ts.map +1 -1
  83. package/dist/domain/text-match.d.ts +32 -0
  84. package/dist/domain/text-match.d.ts.map +1 -0
  85. package/dist/domain/text-match.js +84 -0
  86. package/dist/domain/text-match.js.map +1 -0
  87. package/dist/main.js +15 -1
  88. package/dist/main.js.map +1 -1
  89. package/dist/mcp/server.d.ts +6 -0
  90. package/dist/mcp/server.d.ts.map +1 -1
  91. package/dist/mcp/server.js +40 -4
  92. package/dist/mcp/server.js.map +1 -1
  93. package/dist/mcp/tools.d.ts.map +1 -1
  94. package/dist/mcp/tools.js +9 -1
  95. package/dist/mcp/tools.js.map +1 -1
  96. package/dist/setup.d.ts +2 -0
  97. package/dist/setup.d.ts.map +1 -0
  98. package/dist/setup.js +43 -0
  99. package/dist/setup.js.map +1 -0
  100. package/dist/version.d.ts +9 -0
  101. package/dist/version.d.ts.map +1 -0
  102. package/dist/version.js +9 -0
  103. package/dist/version.js.map +1 -0
  104. package/docs/design-doc.md +127 -7
  105. package/package.json +14 -15
  106. package/.claude/settings.local.json +0 -38
  107. package/.codex/skills/techbook-mcp-release-prep/SKILL.md +0 -105
  108. package/.github/workflows/test.yml +0 -72
  109. package/.oxlintrc.json +0 -12
  110. package/AGENTS.md +0 -100
  111. package/deno.json +0 -3
  112. package/src/adapters/cache/memory-cache.ts +0 -31
  113. package/src/adapters/cache/null-cache.ts +0 -8
  114. package/src/adapters/calil.ts +0 -57
  115. package/src/adapters/html/cheerio-parser.ts +0 -50
  116. package/src/adapters/http/fetch-client.ts +0 -47
  117. package/src/adapters/http/mock-client.ts +0 -77
  118. package/src/adapters/openbd.ts +0 -142
  119. package/src/adapters/publishers/base.ts +0 -279
  120. package/src/adapters/publishers/book-tech.ts +0 -117
  121. package/src/adapters/publishers/born-digital.ts +0 -143
  122. package/src/adapters/publishers/coronasha.ts +0 -139
  123. package/src/adapters/publishers/gihyo.ts +0 -120
  124. package/src/adapters/publishers/impress.ts +0 -103
  125. package/src/adapters/publishers/lambdanote.ts +0 -146
  126. package/src/adapters/publishers/manatee.ts +0 -113
  127. package/src/adapters/publishers/maruzen-publishing.ts +0 -129
  128. package/src/adapters/publishers/optronics.ts +0 -113
  129. package/src/adapters/publishers/oreilly-japan.ts +0 -133
  130. package/src/adapters/publishers/peaks.ts +0 -98
  131. package/src/adapters/publishers/personal-media.ts +0 -168
  132. package/src/adapters/publishers/registry.ts +0 -38
  133. package/src/adapters/publishers/rutles.ts +0 -149
  134. package/src/adapters/publishers/saiensu.ts +0 -136
  135. package/src/adapters/publishers/seshop.ts +0 -121
  136. package/src/adapters/publishers/tatsu-zine.ts +0 -142
  137. package/src/adapters/publishers/techbookfest.ts +0 -179
  138. package/src/application/get-book-by-isbn.ts +0 -50
  139. package/src/application/get-book-detail.ts +0 -40
  140. package/src/application/search-books.ts +0 -64
  141. package/src/domain/book.ts +0 -35
  142. package/src/domain/publisher.ts +0 -18
  143. package/src/main.ts +0 -14
  144. package/src/mcp/server.ts +0 -113
  145. package/src/mcp/tools.ts +0 -71
  146. package/src/ports/cache.ts +0 -5
  147. package/src/ports/html-parser.ts +0 -15
  148. package/src/ports/http.ts +0 -17
  149. package/tests/fixtures/book-tech-detail.html +0 -51
  150. package/tests/fixtures/book-tech-search.html +0 -91
  151. package/tests/fixtures/born-digital-detail.html +0 -62
  152. package/tests/fixtures/born-digital-search.html +0 -51
  153. package/tests/fixtures/calil-book.html +0 -987
  154. package/tests/fixtures/coronasha-detail.html +0 -41
  155. package/tests/fixtures/coronasha-search.html +0 -61
  156. package/tests/fixtures/gihyo-detail.html +0 -42
  157. package/tests/fixtures/gihyo-search.json +0 -54
  158. package/tests/fixtures/impress-detail-epub.html +0 -746
  159. package/tests/fixtures/impress-detail-social.html +0 -689
  160. package/tests/fixtures/lambdanote-search.html +0 -66
  161. package/tests/fixtures/manatee-detail.html +0 -53
  162. package/tests/fixtures/manatee-search.html +0 -59
  163. package/tests/fixtures/maruzen-detail.html +0 -51
  164. package/tests/fixtures/maruzen-search.html +0 -60
  165. package/tests/fixtures/openbd-response.json +0 -110
  166. package/tests/fixtures/optronics-detail.html +0 -30
  167. package/tests/fixtures/optronics-search.html +0 -75
  168. package/tests/fixtures/oreilly-detail.html +0 -52
  169. package/tests/fixtures/oreilly-ebook-list.html +0 -53
  170. package/tests/fixtures/peaks-detail.html +0 -39
  171. package/tests/fixtures/peaks-top.html +0 -50
  172. package/tests/fixtures/personal-media-detail.html +0 -32
  173. package/tests/fixtures/personal-media-search.html +0 -39
  174. package/tests/fixtures/rutles-detail.html +0 -32
  175. package/tests/fixtures/rutles-search.html +0 -62
  176. package/tests/fixtures/saiensu-detail.html +0 -41
  177. package/tests/fixtures/saiensu-search.html +0 -65
  178. package/tests/fixtures/seshop-detail.html +0 -45
  179. package/tests/fixtures/seshop-search.html +0 -58
  180. package/tests/fixtures/tatsu-zine-detail-free.html +0 -24
  181. package/tests/fixtures/tatsu-zine-search.html +0 -40
  182. package/tests/fixtures/techbookfest-search.json +0 -73
  183. package/tests/unit/adapters/base.test.ts +0 -441
  184. package/tests/unit/adapters/calil.test.ts +0 -69
  185. package/tests/unit/adapters/openbd.test.ts +0 -185
  186. package/tests/unit/adapters/publishers/book-tech.test.ts +0 -186
  187. package/tests/unit/adapters/publishers/born-digital.test.ts +0 -194
  188. package/tests/unit/adapters/publishers/coronasha.test.ts +0 -207
  189. package/tests/unit/adapters/publishers/gihyo.test.ts +0 -137
  190. package/tests/unit/adapters/publishers/impress.test.ts +0 -129
  191. package/tests/unit/adapters/publishers/lambdanote.test.ts +0 -85
  192. package/tests/unit/adapters/publishers/manatee.test.ts +0 -165
  193. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +0 -179
  194. package/tests/unit/adapters/publishers/optronics.test.ts +0 -208
  195. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +0 -194
  196. package/tests/unit/adapters/publishers/peaks.test.ts +0 -177
  197. package/tests/unit/adapters/publishers/personal-media.test.ts +0 -199
  198. package/tests/unit/adapters/publishers/rutles.test.ts +0 -173
  199. package/tests/unit/adapters/publishers/saiensu.test.ts +0 -169
  200. package/tests/unit/adapters/publishers/seshop.test.ts +0 -174
  201. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +0 -172
  202. package/tests/unit/adapters/publishers/techbookfest.test.ts +0 -94
  203. package/tests/unit/adapters/registry.test.ts +0 -37
  204. package/tests/unit/application/get-book-by-isbn.test.ts +0 -176
  205. package/tests/unit/application/get-book-detail.test.ts +0 -102
  206. package/tests/unit/application/search-books.test.ts +0 -137
  207. package/tsconfig.json +0 -17
@@ -1,441 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import {
4
- parseJapanesePrice,
5
- stripHtmlTags,
6
- resolveUrl,
7
- extractAsin,
8
- classifyEbookStore,
9
- extractEbookStoresFromDoc,
10
- fetchText,
11
- encodeEucJp,
12
- parseJapaneseDateToISO,
13
- stripAuthorRole,
14
- checkRobotsTxt,
15
- ROBOTS_CACHE_TTL_SECONDS,
16
- } from "../../../src/adapters/publishers/base.js";
17
- import { MockHttpClient } from "../../../src/adapters/http/mock-client.js";
18
- import { CheerioHtmlParser } from "../../../src/adapters/html/cheerio-parser.js";
19
- import { NullCacheStore } from "../../../src/adapters/cache/null-cache.js";
20
- import { MemoryCacheStore } from "../../../src/adapters/cache/memory-cache.js";
21
-
22
- function makeDeps(http: MockHttpClient, cache = new NullCacheStore()) {
23
- return { http, parser: new CheerioHtmlParser(), cache };
24
- }
25
-
26
- // --- encodeEucJp ---
27
-
28
- describe("encodeEucJp()", () => {
29
- it("ASCII文字はそのままパーセントエンコードする", () => {
30
- assert.match(encodeEucJp("abc"), /^(%[0-9A-F]{2})+$/);
31
- });
32
-
33
- it("日本語をEUC-JPでエンコードする", () => {
34
- const result = encodeEucJp("TypeScript");
35
- // EUC-JPでエンコードされた結果は%XX形式
36
- assert.match(result, /^(%[0-9A-F]{2})+$/);
37
- });
38
-
39
- it("空文字列は空文字列を返す", () => {
40
- assert.strictEqual(encodeEucJp(""), "");
41
- });
42
- });
43
-
44
- // --- parseJapaneseDateToISO ---
45
-
46
- describe("parseJapaneseDateToISO()", () => {
47
- it("YYYY年M月D日 を YYYY-MM-DD に変換する", () => {
48
- assert.strictEqual(parseJapaneseDateToISO("2026年3月25日"), "2026-03-25");
49
- });
50
-
51
- it("1桁の月・日もゼロパディングする", () => {
52
- assert.strictEqual(parseJapaneseDateToISO("2024年1月5日"), "2024-01-05");
53
- });
54
-
55
- it("日付パターンがなければ undefined を返す", () => {
56
- assert.strictEqual(parseJapaneseDateToISO("発行:サイエンス社"), undefined);
57
- });
58
-
59
- it("テキスト中に埋め込まれていても抽出できる", () => {
60
- assert.strictEqual(parseJapaneseDateToISO("発行日:2026年3月25日"), "2026-03-25");
61
- });
62
- });
63
-
64
- // --- stripAuthorRole ---
65
-
66
- describe("stripAuthorRole()", () => {
67
- it("末尾の「著」を除去する", () => {
68
- assert.strictEqual(stripAuthorRole("Dan Vanderkam 著"), "Dan Vanderkam");
69
- });
70
-
71
- it("末尾の「訳」を除去する", () => {
72
- assert.strictEqual(stripAuthorRole("今村 謙士 訳"), "今村 謙士");
73
- });
74
-
75
- it("末尾の「監修」を除去する", () => {
76
- assert.strictEqual(stripAuthorRole("堀井俊佑 監修"), "堀井俊佑");
77
- });
78
-
79
- it("末尾の「著訳」を除去する", () => {
80
- assert.strictEqual(stripAuthorRole("島田浩二 著訳"), "島田浩二");
81
- });
82
-
83
- it("役割語がなければそのまま返す", () => {
84
- assert.strictEqual(stripAuthorRole("山田太郎"), "山田太郎");
85
- });
86
-
87
- it("前後の空白・全角スペースをトリムする", () => {
88
- assert.strictEqual(stripAuthorRole(" 著者名 "), "著者名");
89
- });
90
- });
91
-
92
- // --- parseJapanesePrice ---
93
-
94
- describe("parseJapanesePrice()", () => {
95
- it("カンマ区切りの円表記をパースする", () => {
96
- assert.strictEqual(parseJapanesePrice("3,960円(税込)"), 3960);
97
- });
98
-
99
- it("¥記号付きをパースする", () => {
100
- assert.strictEqual(parseJapanesePrice("¥3,960"), 3960);
101
- });
102
-
103
- it("カンマなし整数をパースする", () => {
104
- assert.strictEqual(parseJapanesePrice("1980円"), 1980);
105
- });
106
-
107
- it("数字がなければ undefined を返す", () => {
108
- assert.strictEqual(parseJapanesePrice("価格未定"), undefined);
109
- });
110
- });
111
-
112
- // --- stripHtmlTags ---
113
-
114
- describe("stripHtmlTags()", () => {
115
- it("HTMLタグを除去してテキストを返す", () => {
116
- assert.strictEqual(stripHtmlTags("<b>太字</b>テキスト"), "太字テキスト");
117
- });
118
-
119
- it("ruby タグを除去する(rt の中身は残る)", () => {
120
- // タグを除去するだけなので <rt> 内のテキストは残る
121
- assert.strictEqual(stripHtmlTags("<ruby>著者<rt>ちょしゃ</rt></ruby>名"), "著者ちょしゃ名");
122
- });
123
-
124
- it("タグがなければそのまま返す", () => {
125
- assert.strictEqual(stripHtmlTags("プレーンテキスト"), "プレーンテキスト");
126
- });
127
-
128
- it("空文字列はそのまま", () => {
129
- assert.strictEqual(stripHtmlTags(""), "");
130
- });
131
- });
132
-
133
- // --- resolveUrl ---
134
-
135
- describe("resolveUrl()", () => {
136
- it("相対パスを絶対URLに変換する", () => {
137
- assert.strictEqual(
138
- resolveUrl("https://example.com/books/", "../detail/1"),
139
- "https://example.com/detail/1",
140
- );
141
- });
142
-
143
- it("絶対URLはそのまま返す", () => {
144
- assert.strictEqual(
145
- resolveUrl("https://example.com/", "https://other.com/page"),
146
- "https://other.com/page",
147
- );
148
- });
149
-
150
- it("ルート相対パスを解決する", () => {
151
- assert.strictEqual(
152
- resolveUrl("https://example.com/books/123", "/about"),
153
- "https://example.com/about",
154
- );
155
- });
156
- });
157
-
158
- // --- extractAsin ---
159
-
160
- describe("extractAsin()", () => {
161
- it("/dp/ 形式から ASIN を抽出する", () => {
162
- assert.strictEqual(
163
- extractAsin("https://www.amazon.co.jp/dp/4873119464"),
164
- "4873119464",
165
- );
166
- });
167
-
168
- it("/gp/product/ 形式から ASIN を抽出する", () => {
169
- assert.strictEqual(
170
- extractAsin("https://www.amazon.co.jp/gp/product/4873119464"),
171
- "4873119464",
172
- );
173
- });
174
-
175
- it("Amazon URL でなければ undefined を返す", () => {
176
- assert.strictEqual(extractAsin("https://example.com/books/123"), undefined);
177
- });
178
-
179
- it("ASIN を含まなければ undefined を返す", () => {
180
- assert.strictEqual(extractAsin("https://www.amazon.co.jp/"), undefined);
181
- });
182
- });
183
-
184
- // --- classifyEbookStore ---
185
-
186
- describe("classifyEbookStore()", () => {
187
- it("技術書典URLは free を返す", () => {
188
- const store = classifyEbookStore("https://techbookfest.org/product/abc123");
189
- assert.partialDeepStrictEqual(store, { name: "技術書典", drm: "free" });
190
- });
191
-
192
- it("Kindle URLは drm を返す", () => {
193
- const store = classifyEbookStore("https://www.amazon.co.jp/dp/B0XXXXX");
194
- assert.partialDeepStrictEqual(store, { name: "Kindle", drm: "drm" });
195
- });
196
-
197
- it("サイエンス社は password_pdf を返す", () => {
198
- const store = classifyEbookStore("https://www.saiensu.co.jp/search/?isbn=978-4-7819-1234-5&y=2024#book");
199
- assert.partialDeepStrictEqual(store, { name: "サイエンス社", drm: "password_pdf" });
200
- });
201
-
202
- it("SEshop URLは social を返す", () => {
203
- const store = classifyEbookStore("https://www.seshop.com/product/detail/12345");
204
- assert.partialDeepStrictEqual(store, { name: "SEshop", drm: "social" });
205
- });
206
-
207
- it("未知のURLは null を返す", () => {
208
- assert.strictEqual(classifyEbookStore("https://unknown-store.example.com/book/1"), null);
209
- });
210
-
211
- it("URLを store.url に格納する", () => {
212
- const url = "https://techbookfest.org/product/abc123";
213
- assert.strictEqual(classifyEbookStore(url)?.url, url);
214
- });
215
- });
216
-
217
- // --- extractEbookStoresFromDoc ---
218
-
219
- describe("extractEbookStoresFromDoc()", () => {
220
- const parser = new CheerioHtmlParser();
221
-
222
- it("ページ内のストアリンクを抽出する", () => {
223
- const html = `<html><body>
224
- <a href="https://techbookfest.org/product/abc">技術書典</a>
225
- <a href="https://www.amazon.co.jp/dp/B0XXXXX">Kindle</a>
226
- </body></html>`;
227
- const doc = parser.parse(html);
228
- const stores = extractEbookStoresFromDoc(doc);
229
- assert.strictEqual(stores.length, 2);
230
- assert.partialDeepStrictEqual(stores[0], { name: "技術書典", drm: "free" });
231
- assert.partialDeepStrictEqual(stores[1], { name: "Kindle", drm: "drm" });
232
- });
233
-
234
- it("同一ストアのURLが複数あれば最初の1件のみ返す", () => {
235
- const html = `<html><body>
236
- <a href="https://www.amazon.co.jp/dp/B0AAAAA">Kindle A</a>
237
- <a href="https://www.amazon.co.jp/dp/B0BBBBB">Kindle B</a>
238
- </body></html>`;
239
- const doc = parser.parse(html);
240
- const stores = extractEbookStoresFromDoc(doc);
241
- assert.strictEqual(stores.length, 1);
242
- assert.strictEqual(stores[0].url, "https://www.amazon.co.jp/dp/B0AAAAA");
243
- });
244
-
245
- it("既知ストアへのリンクがなければ空配列を返す", () => {
246
- const html = `<html><body><a href="https://example.com/">不明</a></body></html>`;
247
- const doc = parser.parse(html);
248
- assert.deepStrictEqual(extractEbookStoresFromDoc(doc), []);
249
- });
250
-
251
- it("href を持たない a 要素は無視する", () => {
252
- const html = `<html><body><a>リンクなし</a></body></html>`;
253
- const doc = parser.parse(html);
254
- assert.deepStrictEqual(extractEbookStoresFromDoc(doc), []);
255
- });
256
- });
257
-
258
- // --- fetchText ---
259
-
260
- describe("fetchText()", () => {
261
- it("HTTPレスポンスのテキストを返す", async () => {
262
- const http = new MockHttpClient().addResponse(
263
- "https://example.com/page",
264
- { status: 200, body: "<html>hello</html>" },
265
- );
266
- const result = await fetchText("https://example.com/page", makeDeps(http));
267
- assert.strictEqual(result, "<html>hello</html>");
268
- });
269
-
270
- it("200以外はエラーをスローする", async () => {
271
- const http = new MockHttpClient().addResponse(
272
- "https://example.com/page",
273
- { status: 404, body: "Not Found" },
274
- );
275
- await assert.rejects(
276
- fetchText("https://example.com/page", makeDeps(http)),
277
- /HTTP 404/,
278
- );
279
- });
280
-
281
- it("キャッシュヒット時はHTTPを呼ばない", async () => {
282
- const cache = new MemoryCacheStore();
283
- await cache.set("https://example.com/page", "<html>cached</html>", 3600);
284
-
285
- const http = new MockHttpClient();
286
- const result = await fetchText("https://example.com/page", makeDeps(http, cache));
287
-
288
- assert.strictEqual(result, "<html>cached</html>");
289
- assert.strictEqual(http.calls.length, 0);
290
- });
291
-
292
- it("取得結果をキャッシュに保存する", async () => {
293
- const cache = new MemoryCacheStore();
294
- const http = new MockHttpClient().addResponse(
295
- "https://example.com/page",
296
- { status: 200, body: "<html>fresh</html>" },
297
- );
298
-
299
- await fetchText("https://example.com/page", makeDeps(http, cache));
300
- const cached = await cache.get("https://example.com/page");
301
- assert.strictEqual(cached, "<html>fresh</html>");
302
- });
303
-
304
- it("extraHeaders をリクエストに含める", async () => {
305
- // MockHttpClient は headers を直接検査できないが、HTTPが正常に呼ばれることを確認
306
- const http = new MockHttpClient().addResponse(
307
- "https://example.com/page",
308
- { status: 200, body: "ok" },
309
- );
310
- const result = await fetchText(
311
- "https://example.com/page",
312
- makeDeps(http),
313
- { Referer: "https://example.com/" },
314
- );
315
- assert.strictEqual(result, "ok");
316
- assert.strictEqual(http.calls.length, 1);
317
- });
318
- });
319
-
320
- // --- checkRobotsTxt ---
321
-
322
- describe("checkRobotsTxt()", () => {
323
- it("robots.txt が存在しない (404) 場合はアクセスを許可する", async () => {
324
- const http = new MockHttpClient().addResponse(
325
- "https://example.com/robots.txt",
326
- { status: 404, body: "Not Found" },
327
- );
328
- const result = await checkRobotsTxt("https://example.com/search?q=foo", makeDeps(http));
329
- assert.strictEqual(result, true);
330
- });
331
-
332
- it("HTTP エラー時はアクセスを許可する (fail-open)", async () => {
333
- // ハンドラー未登録 → MockHttpClient が例外をスロー
334
- const http = new MockHttpClient();
335
- const result = await checkRobotsTxt("https://example.com/search?q=foo", makeDeps(http));
336
- assert.strictEqual(result, true);
337
- });
338
-
339
- it("Disallow がない場合はアクセスを許可する", async () => {
340
- const http = new MockHttpClient().addResponse(
341
- "https://example.com/robots.txt",
342
- { status: 200, body: "User-agent: *\nDisallow:\n" },
343
- );
344
- const result = await checkRobotsTxt("https://example.com/search", makeDeps(http));
345
- assert.strictEqual(result, true);
346
- });
347
-
348
- it("Disallow: / はすべてのパスを禁止する", async () => {
349
- const http = new MockHttpClient().addResponse(
350
- "https://example.com/robots.txt",
351
- { status: 200, body: "User-agent: *\nDisallow: /\n" },
352
- );
353
- const result = await checkRobotsTxt("https://example.com/search?q=foo", makeDeps(http));
354
- assert.strictEqual(result, false);
355
- });
356
-
357
- it("特定パスの Disallow はそのパスだけを禁止する", async () => {
358
- const robotsTxt = "User-agent: *\nDisallow: /private/\n";
359
- const http = new MockHttpClient().addResponse(
360
- "https://example.com/robots.txt",
361
- { status: 200, body: robotsTxt },
362
- );
363
- const deps = makeDeps(http);
364
-
365
- assert.strictEqual(await checkRobotsTxt("https://example.com/private/data", deps), false);
366
- assert.strictEqual(await checkRobotsTxt("https://example.com/public/page", deps), true);
367
- });
368
-
369
- it("techbook-mcp 固有ルールがワイルドカードより優先される", async () => {
370
- const robotsTxt = [
371
- "User-agent: *",
372
- "Disallow: /",
373
- "",
374
- "User-agent: techbook-mcp",
375
- "Allow: /",
376
- ].join("\n");
377
- const http = new MockHttpClient().addResponse(
378
- "https://example.com/robots.txt",
379
- { status: 200, body: robotsTxt },
380
- );
381
- const result = await checkRobotsTxt("https://example.com/search", makeDeps(http));
382
- assert.strictEqual(result, true);
383
- });
384
-
385
- it("Allow が Disallow より長いプレフィックスで一致する場合は許可する", async () => {
386
- const robotsTxt = [
387
- "User-agent: *",
388
- "Disallow: /books/",
389
- "Allow: /books/detail/",
390
- ].join("\n");
391
- const http = new MockHttpClient().addResponse(
392
- "https://example.com/robots.txt",
393
- { status: 200, body: robotsTxt },
394
- );
395
- const deps = makeDeps(http);
396
-
397
- assert.strictEqual(await checkRobotsTxt("https://example.com/books/list", deps), false);
398
- assert.strictEqual(await checkRobotsTxt("https://example.com/books/detail/123", deps), true);
399
- });
400
-
401
- it("robots.txt の結果を ${ROBOTS_CACHE_TTL_SECONDS}秒キャッシュする", async () => {
402
- const http = new MockHttpClient().addResponse(
403
- "https://example.com/robots.txt",
404
- { status: 200, body: "User-agent: *\nDisallow: /\n" },
405
- );
406
- const cache = new MemoryCacheStore();
407
- const deps = makeDeps(http, cache);
408
-
409
- await checkRobotsTxt("https://example.com/page", deps);
410
-
411
- const cached = await cache.get("robots:https://example.com");
412
- assert.notEqual(cached, null);
413
- assert.strictEqual(http.calls.filter(u => u.includes("robots.txt")).length, 1);
414
- });
415
-
416
- it("キャッシュヒット時は HTTP を呼ばない", async () => {
417
- const cache = new MemoryCacheStore();
418
- await cache.set("robots:https://example.com", "", ROBOTS_CACHE_TTL_SECONDS);
419
-
420
- const http = new MockHttpClient();
421
- const result = await checkRobotsTxt("https://example.com/page", makeDeps(http, cache));
422
-
423
- assert.strictEqual(result, true);
424
- assert.strictEqual(http.calls.length, 0);
425
- });
426
-
427
- it("コメント行を無視する", async () => {
428
- const robotsTxt = [
429
- "# このサイトのクローラー設定",
430
- "User-agent: *",
431
- "# 検索ページを禁止",
432
- "Disallow: /search/",
433
- ].join("\n");
434
- const http = new MockHttpClient().addResponse(
435
- "https://example.com/robots.txt",
436
- { status: 200, body: robotsTxt },
437
- );
438
- const result = await checkRobotsTxt("https://example.com/search/query", makeDeps(http));
439
- assert.strictEqual(result, false);
440
- });
441
- });
@@ -1,69 +0,0 @@
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
- });
@@ -1,185 +0,0 @@
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
- });