@zonuexe/techbook-mcp 0.1.0

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 (174) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.github/workflows/test.yml +36 -0
  3. package/AGENTS.md +72 -0
  4. package/CLAUDE.md +2 -0
  5. package/LICENSE +661 -0
  6. package/README.md +154 -0
  7. package/dist/adapters/cache/memory-cache.d.ts +8 -0
  8. package/dist/adapters/cache/memory-cache.d.ts.map +1 -0
  9. package/dist/adapters/cache/memory-cache.js +23 -0
  10. package/dist/adapters/cache/memory-cache.js.map +1 -0
  11. package/dist/adapters/cache/null-cache.d.ts +8 -0
  12. package/dist/adapters/cache/null-cache.d.ts.map +1 -0
  13. package/dist/adapters/cache/null-cache.js +7 -0
  14. package/dist/adapters/cache/null-cache.js.map +1 -0
  15. package/dist/adapters/html/cheerio-parser.d.ts +5 -0
  16. package/dist/adapters/html/cheerio-parser.d.ts.map +1 -0
  17. package/dist/adapters/html/cheerio-parser.js +45 -0
  18. package/dist/adapters/html/cheerio-parser.js.map +1 -0
  19. package/dist/adapters/http/fetch-client.d.ts +6 -0
  20. package/dist/adapters/http/fetch-client.d.ts.map +1 -0
  21. package/dist/adapters/http/fetch-client.js +43 -0
  22. package/dist/adapters/http/fetch-client.js.map +1 -0
  23. package/dist/adapters/http/mock-client.d.ts +19 -0
  24. package/dist/adapters/http/mock-client.d.ts.map +1 -0
  25. package/dist/adapters/http/mock-client.js +59 -0
  26. package/dist/adapters/http/mock-client.js.map +1 -0
  27. package/dist/adapters/publishers/base.d.ts +24 -0
  28. package/dist/adapters/publishers/base.d.ts.map +1 -0
  29. package/dist/adapters/publishers/base.js +88 -0
  30. package/dist/adapters/publishers/base.js.map +1 -0
  31. package/dist/adapters/publishers/gihyo.d.ts +3 -0
  32. package/dist/adapters/publishers/gihyo.d.ts.map +1 -0
  33. package/dist/adapters/publishers/gihyo.js +75 -0
  34. package/dist/adapters/publishers/gihyo.js.map +1 -0
  35. package/dist/adapters/publishers/lambdanote.d.ts +3 -0
  36. package/dist/adapters/publishers/lambdanote.d.ts.map +1 -0
  37. package/dist/adapters/publishers/lambdanote.js +113 -0
  38. package/dist/adapters/publishers/lambdanote.js.map +1 -0
  39. package/dist/adapters/publishers/registry.d.ts +3 -0
  40. package/dist/adapters/publishers/registry.d.ts.map +1 -0
  41. package/dist/adapters/publishers/registry.js +11 -0
  42. package/dist/adapters/publishers/registry.js.map +1 -0
  43. package/dist/adapters/publishers/tatsu-zine.d.ts +3 -0
  44. package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -0
  45. package/dist/adapters/publishers/tatsu-zine.js +110 -0
  46. package/dist/adapters/publishers/tatsu-zine.js.map +1 -0
  47. package/dist/adapters/publishers/techbookfest.d.ts +3 -0
  48. package/dist/adapters/publishers/techbookfest.d.ts.map +1 -0
  49. package/dist/adapters/publishers/techbookfest.js +134 -0
  50. package/dist/adapters/publishers/techbookfest.js.map +1 -0
  51. package/dist/application/get-book-detail.d.ts +4 -0
  52. package/dist/application/get-book-detail.d.ts.map +1 -0
  53. package/dist/application/get-book-detail.js +9 -0
  54. package/dist/application/get-book-detail.js.map +1 -0
  55. package/dist/application/search-books.d.ts +11 -0
  56. package/dist/application/search-books.d.ts.map +1 -0
  57. package/dist/application/search-books.js +23 -0
  58. package/dist/application/search-books.js.map +1 -0
  59. package/dist/domain/book.d.ts +32 -0
  60. package/dist/domain/book.d.ts.map +1 -0
  61. package/dist/domain/book.js +2 -0
  62. package/dist/domain/book.js.map +1 -0
  63. package/dist/domain/publisher.d.ts +17 -0
  64. package/dist/domain/publisher.d.ts.map +1 -0
  65. package/dist/domain/publisher.js +2 -0
  66. package/dist/domain/publisher.js.map +1 -0
  67. package/dist/main.d.ts +2 -0
  68. package/dist/main.d.ts.map +1 -0
  69. package/dist/main.js +12 -0
  70. package/dist/main.js.map +1 -0
  71. package/dist/mcp/server.d.ts +5 -0
  72. package/dist/mcp/server.d.ts.map +1 -0
  73. package/dist/mcp/server.js +79 -0
  74. package/dist/mcp/server.js.map +1 -0
  75. package/dist/mcp/tools.d.ts +47 -0
  76. package/dist/mcp/tools.d.ts.map +1 -0
  77. package/dist/mcp/tools.js +53 -0
  78. package/dist/mcp/tools.js.map +1 -0
  79. package/dist/ports/cache.d.ts +6 -0
  80. package/dist/ports/cache.d.ts.map +1 -0
  81. package/dist/ports/cache.js +2 -0
  82. package/dist/ports/cache.js.map +1 -0
  83. package/dist/ports/html-parser.d.ts +14 -0
  84. package/dist/ports/html-parser.d.ts.map +1 -0
  85. package/dist/ports/html-parser.js +2 -0
  86. package/dist/ports/html-parser.js.map +1 -0
  87. package/dist/ports/http.d.ts +16 -0
  88. package/dist/ports/http.d.ts.map +1 -0
  89. package/dist/ports/http.js +2 -0
  90. package/dist/ports/http.js.map +1 -0
  91. package/docs/design-doc.md +365 -0
  92. package/flake.nix +50 -0
  93. package/package.json +29 -0
  94. package/src/adapters/cache/memory-cache.ts +31 -0
  95. package/src/adapters/cache/null-cache.ts +8 -0
  96. package/src/adapters/html/cheerio-parser.ts +49 -0
  97. package/src/adapters/http/fetch-client.ts +47 -0
  98. package/src/adapters/http/mock-client.ts +77 -0
  99. package/src/adapters/publishers/base.ts +129 -0
  100. package/src/adapters/publishers/book-tech.ts +117 -0
  101. package/src/adapters/publishers/born-digital.ts +158 -0
  102. package/src/adapters/publishers/coronasha.ts +139 -0
  103. package/src/adapters/publishers/gihyo.ts +120 -0
  104. package/src/adapters/publishers/lambdanote.ts +146 -0
  105. package/src/adapters/publishers/manatee.ts +112 -0
  106. package/src/adapters/publishers/maruzen-publishing.ts +141 -0
  107. package/src/adapters/publishers/optronics.ts +113 -0
  108. package/src/adapters/publishers/oreilly-japan.ts +138 -0
  109. package/src/adapters/publishers/peaks.ts +98 -0
  110. package/src/adapters/publishers/personal-media.ts +168 -0
  111. package/src/adapters/publishers/registry.ts +36 -0
  112. package/src/adapters/publishers/rutles.ts +161 -0
  113. package/src/adapters/publishers/saiensu.ts +149 -0
  114. package/src/adapters/publishers/seshop.ts +121 -0
  115. package/src/adapters/publishers/tatsu-zine.ts +129 -0
  116. package/src/adapters/publishers/techbookfest.ts +179 -0
  117. package/src/application/get-book-detail.ts +17 -0
  118. package/src/application/search-books.ts +39 -0
  119. package/src/domain/book.ts +35 -0
  120. package/src/domain/publisher.ts +18 -0
  121. package/src/main.ts +13 -0
  122. package/src/mcp/server.ts +103 -0
  123. package/src/mcp/tools.ts +54 -0
  124. package/src/ports/cache.ts +5 -0
  125. package/src/ports/html-parser.ts +15 -0
  126. package/src/ports/http.ts +17 -0
  127. package/tests/fixtures/book-tech-detail.html +51 -0
  128. package/tests/fixtures/book-tech-search.html +91 -0
  129. package/tests/fixtures/born-digital-detail.html +62 -0
  130. package/tests/fixtures/born-digital-search.html +51 -0
  131. package/tests/fixtures/coronasha-detail.html +41 -0
  132. package/tests/fixtures/coronasha-search.html +61 -0
  133. package/tests/fixtures/gihyo-detail.html +42 -0
  134. package/tests/fixtures/gihyo-search.json +54 -0
  135. package/tests/fixtures/lambdanote-search.html +66 -0
  136. package/tests/fixtures/manatee-detail.html +53 -0
  137. package/tests/fixtures/manatee-search.html +59 -0
  138. package/tests/fixtures/maruzen-detail.html +51 -0
  139. package/tests/fixtures/maruzen-search.html +60 -0
  140. package/tests/fixtures/optronics-detail.html +30 -0
  141. package/tests/fixtures/optronics-search.html +75 -0
  142. package/tests/fixtures/oreilly-detail.html +52 -0
  143. package/tests/fixtures/oreilly-ebook-list.html +53 -0
  144. package/tests/fixtures/peaks-detail.html +39 -0
  145. package/tests/fixtures/peaks-top.html +50 -0
  146. package/tests/fixtures/personal-media-detail.html +32 -0
  147. package/tests/fixtures/personal-media-search.html +39 -0
  148. package/tests/fixtures/rutles-detail.html +32 -0
  149. package/tests/fixtures/rutles-search.html +62 -0
  150. package/tests/fixtures/saiensu-detail.html +41 -0
  151. package/tests/fixtures/saiensu-search.html +65 -0
  152. package/tests/fixtures/seshop-detail.html +45 -0
  153. package/tests/fixtures/seshop-search.html +58 -0
  154. package/tests/fixtures/tatsu-zine-detail-free.html +22 -0
  155. package/tests/fixtures/tatsu-zine-search.html +24 -0
  156. package/tests/fixtures/techbookfest-search.json +73 -0
  157. package/tests/unit/adapters/publishers/book-tech.test.ts +183 -0
  158. package/tests/unit/adapters/publishers/born-digital.test.ts +191 -0
  159. package/tests/unit/adapters/publishers/coronasha.test.ts +201 -0
  160. package/tests/unit/adapters/publishers/gihyo.test.ts +135 -0
  161. package/tests/unit/adapters/publishers/lambdanote.test.ts +84 -0
  162. package/tests/unit/adapters/publishers/manatee.test.ts +163 -0
  163. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +177 -0
  164. package/tests/unit/adapters/publishers/optronics.test.ts +205 -0
  165. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +191 -0
  166. package/tests/unit/adapters/publishers/peaks.test.ts +174 -0
  167. package/tests/unit/adapters/publishers/personal-media.test.ts +196 -0
  168. package/tests/unit/adapters/publishers/rutles.test.ts +170 -0
  169. package/tests/unit/adapters/publishers/saiensu.test.ts +167 -0
  170. package/tests/unit/adapters/publishers/seshop.test.ts +171 -0
  171. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +130 -0
  172. package/tests/unit/adapters/publishers/techbookfest.test.ts +93 -0
  173. package/tsconfig.json +17 -0
  174. package/vitest.config.ts +8 -0
@@ -0,0 +1,158 @@
1
+ import iconv from "iconv-lite";
2
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
3
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
4
+ import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
5
+
6
+ const BASE_URL = "https://wgn-obs.shop-pro.jp";
7
+
8
+ /**
9
+ * キーワードを EUC-JP でパーセントエンコードする。
10
+ * wgn-obs.shop-pro.jp は EUC-JP エンコードされたクエリのみ受け付けるため。
11
+ */
12
+ function encodeEucJp(text: string): string {
13
+ const bytes = iconv.encode(text, "euc-jp");
14
+ return Array.from(bytes)
15
+ .map(b => "%" + b.toString(16).toUpperCase().padStart(2, "0"))
16
+ .join("");
17
+ }
18
+
19
+ /**
20
+ * 商品説明テキストから著者・出版社・発売日を取得する。
21
+ * 以下の2形式に対応:
22
+ * - "著者\tヘイドン・ピカリング<br />" (タブ区切り)
23
+ * - "著者:太田 良典、中村 直樹<br />" (全角コロン区切り)
24
+ */
25
+ function parseDescription(text: string): {
26
+ authors: string[];
27
+ publisher: string;
28
+ publishedAt: string | undefined;
29
+ } {
30
+ const authors: string[] = [];
31
+ let publisher = "ボーンデジタル";
32
+ let publishedAt: string | undefined;
33
+
34
+ for (const rawLine of text.split(/\r?\n/)) {
35
+ const line = rawLine.trim();
36
+ const m = line.match(/^(.+?)[\t:]\s*(.+)$/);
37
+ if (!m) continue;
38
+ const key = m[1].trim();
39
+ const value = m[2].trim();
40
+
41
+ if (key === "著者" || key === "著") {
42
+ authors.push(...value.split(/[、,,]/).map(s => s.trim()).filter(Boolean));
43
+ } else if (key === "翻訳" || key === "翻訳者" || key === "訳者" || key === "訳") {
44
+ authors.push(...value.split(/[、,,]/).map(s => s.trim()).filter(Boolean));
45
+ } else if (key.startsWith("発行") || key === "発売") {
46
+ publisher = value.replace(/^株式会社\s*/, "").replace(/\s*株式会社$/, "").trim();
47
+ } else if (key === "発売日") {
48
+ const dm = value.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
49
+ if (dm) {
50
+ publishedAt = `${dm[1]}-${dm[2].padStart(2, "0")}-${dm[3].padStart(2, "0")}`;
51
+ }
52
+ }
53
+ }
54
+
55
+ return { authors, publisher, publishedAt };
56
+ }
57
+
58
+ /**
59
+ * ページ埋め込みの Colorme JSON から商品情報を取得する。
60
+ * `var Colorme = {...};` 形式。
61
+ */
62
+ function extractColormeProduct(html: string): { price?: number } {
63
+ const m = html.match(/var Colorme = (\{[^\n]+\});/);
64
+ if (!m) return {};
65
+ try {
66
+ const data = JSON.parse(m[1]) as {
67
+ product?: { sales_price_including_tax?: number };
68
+ };
69
+ const price = data.product?.sales_price_including_tax;
70
+ return { price };
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ export const bornDigitalAdapter: PublisherAdapter = {
77
+ id: "born-digital",
78
+ name: "ボーンデジタル",
79
+ baseUrl: BASE_URL,
80
+
81
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
82
+ const word = [query.title, query.author].filter(Boolean).join(" ");
83
+ if (!word) return [];
84
+
85
+ const url = `${BASE_URL}/?mode=srh&keyword=${encodeEucJp(word)}`;
86
+ const html = await fetchText(url, deps);
87
+ const doc = deps.parser.parse(html);
88
+
89
+ const results: BookRecord[] = [];
90
+ const limit = query.limit ?? 10;
91
+
92
+ for (const item of doc.select("li.c-product-list__item")) {
93
+ const titleEl = item.find("a.c-product-list__name")[0];
94
+ const title = titleEl?.text().trim();
95
+ if (!title) continue;
96
+
97
+ // 電子書籍のみ: タイトルが【電子書籍版】または【PDFダウンロード版】で始まる
98
+ if (!title.startsWith("【")) continue;
99
+
100
+ const href = item.find("a.c-product-list__image-wrap")[0]?.attr("href")
101
+ ?? titleEl?.attr("href");
102
+ if (!href) continue;
103
+ const bookUrl = resolveUrl(BASE_URL + "/", href);
104
+
105
+ const priceText = item.find(".c-product-list__price")[0]?.text();
106
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
107
+
108
+ const imgEl = item.find("a.c-product-list__image-wrap img.c-image-box__image")[0];
109
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
110
+
111
+ results.push({
112
+ title,
113
+ authors: [],
114
+ publisher: "ボーンデジタル",
115
+ url: bookUrl,
116
+ price,
117
+ coverImageUrl,
118
+ ebookStores: [{ name: "ボーンデジタル", url: bookUrl, drm: "social" }],
119
+ });
120
+
121
+ if (results.length >= limit) break;
122
+ }
123
+
124
+ return results;
125
+ },
126
+
127
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
128
+ const html = await fetchText(url, deps);
129
+ const doc = deps.parser.parse(html);
130
+
131
+ // Colorme JSON から価格を取得
132
+ const { price: colormePrice } = extractColormeProduct(html);
133
+
134
+ const title = doc.selectOne(".p-cart-form__name")?.text().trim()
135
+ ?? doc.selectOne(".p-product-body__name")?.text().trim()
136
+ ?? "";
137
+
138
+ const priceText = doc.selectOne(".c-product-info__price")?.text();
139
+ const price = colormePrice ?? (priceText ? parseJapanesePrice(priceText) : undefined);
140
+
141
+ const descText = doc.selectOne(".p-product-body__description")?.text() ?? "";
142
+ const { authors, publisher, publishedAt } = parseDescription(descText);
143
+
144
+ const imgEl = doc.selectOne(".p-large-image img");
145
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
146
+
147
+ return {
148
+ title,
149
+ authors,
150
+ publisher,
151
+ url,
152
+ price,
153
+ publishedAt,
154
+ coverImageUrl,
155
+ ebookStores: [{ name: "ボーンデジタル", url, drm: "social" }],
156
+ };
157
+ },
158
+ };
@@ -0,0 +1,139 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import type { HtmlElement } from "../../ports/html-parser.js";
4
+ import { fetchText, parseJapanesePrice, resolveUrl, extractEbookStoresFromDoc } from "./base.js";
5
+
6
+ const BASE_URL = "https://www.coronasha.co.jp";
7
+ const SEARCH_URL = `${BASE_URL}/np/result.html`;
8
+
9
+ /** "2023/05/01" → "2023-05-01" */
10
+ function parseDate(text: string): string | undefined {
11
+ const m = text.match(/(\d{4})\/(\d{2})\/(\d{2})/);
12
+ if (!m) return undefined;
13
+ return `${m[1]}-${m[2]}-${m[3]}`;
14
+ }
15
+
16
+ /**
17
+ * `.tunogaki` と `.book-title` からタイトルを組み立てる。
18
+ * "1から始める" + "Juliaプログラミング" → "1から始める Juliaプログラミング"
19
+ */
20
+ function buildTitle(container: HtmlElement): string {
21
+ const tunogaki = container.find(".tunogaki")[0]?.text().trim();
22
+ const bookTitle = container.find(".book-title")[0]?.text().trim() ?? "";
23
+ return tunogaki ? `${tunogaki} ${bookTitle}` : bookTitle;
24
+ }
25
+
26
+ /**
27
+ * `dl` 要素の配列から `dt` をキー、`dd` をバリューとするマップを返す。
28
+ * 価格・ISBN・発行年月日などの取得に使う。
29
+ */
30
+ function parseDlMap(dls: HtmlElement[]): Map<string, string> {
31
+ const map = new Map<string, string>();
32
+ for (const dl of dls) {
33
+ const key = dl.find("dt")[0]?.text().trim();
34
+ const val = dl.find("dd")[0]?.text().trim();
35
+ if (key && val) map.set(key, val);
36
+ }
37
+ return map;
38
+ }
39
+
40
+ export const coronashaAdapter: PublisherAdapter = {
41
+ id: "coronasha",
42
+ name: "コロナ社",
43
+ baseUrl: BASE_URL,
44
+
45
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
46
+ const word = [query.title, query.author].filter(Boolean).join(" ");
47
+ if (!word) return [];
48
+
49
+ const url = `${SEARCH_URL}?q=${encodeURIComponent(word)}`;
50
+ const html = await fetchText(url, deps);
51
+ const doc = deps.parser.parse(html);
52
+
53
+ const results: BookRecord[] = [];
54
+ const limit = query.limit ?? 10;
55
+
56
+ for (const item of doc.select("article.item")) {
57
+ // 電子版があるものだけ対象
58
+ const hasEbook = item.find("ul.status-list li")
59
+ .some(el => el.text().trim() === "電子版あり");
60
+ if (!hasEbook) continue;
61
+
62
+ const linkEl = item.find("dl.col1 dt a")[0];
63
+ const href = linkEl?.attr("href");
64
+ if (!href) continue;
65
+ const bookUrl = resolveUrl(BASE_URL, href);
66
+
67
+ const title = buildTitle(item);
68
+ if (!title) continue;
69
+
70
+ const authors = item.find("ul.authors li a")
71
+ .map(el => el.text().trim())
72
+ .filter(Boolean);
73
+
74
+ const info = parseDlMap(item.find(".book-info dl"));
75
+ const price = parseJapanesePrice(info.get("定価") ?? "");
76
+ const publishedAt = parseDate(info.get("発行年月日") ?? "");
77
+ const isbn = info.get("ISBN")?.replace(/-/g, "") || undefined;
78
+
79
+ const imgEl = item.find("img.cover")[0];
80
+ const coverSrc = imgEl?.attr("src");
81
+ const coverImageUrl = coverSrc ? resolveUrl(BASE_URL, coverSrc) : undefined;
82
+
83
+ results.push({
84
+ title,
85
+ authors,
86
+ publisher: "コロナ社",
87
+ url: bookUrl,
88
+ isbn,
89
+ price,
90
+ publishedAt,
91
+ coverImageUrl,
92
+ ebookStores: [],
93
+ });
94
+
95
+ if (results.length >= limit) break;
96
+ }
97
+
98
+ return results;
99
+ },
100
+
101
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
102
+ const html = await fetchText(url, deps);
103
+ const doc = deps.parser.parse(html);
104
+
105
+ const h2 = doc.selectOne("h2.title");
106
+ const title = h2 ? buildTitle(h2) : "";
107
+
108
+ const authors = doc.select("ul.authors li a")
109
+ .map(el => el.text().trim())
110
+ .filter(Boolean);
111
+
112
+ const info = parseDlMap(doc.select(".book-info dl"));
113
+ const publishedAt = parseDate(info.get("発行年月日") ?? "");
114
+ const isbn = info.get("ISBN")?.replace(/-/g, "") || undefined;
115
+
116
+ // 価格はサイドバーの .price に表示される(book-info dl には含まれない)
117
+ const priceText = doc.selectOne(".price")?.text();
118
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
119
+
120
+ const imgEl = doc.selectOne("img.cover");
121
+ const coverSrc = imgEl?.attr("src");
122
+ const coverImageUrl = coverSrc ? resolveUrl(BASE_URL, coverSrc) : undefined;
123
+
124
+ // 電子版購入ポップアップのリンクから電子書籍ストアを自動検出
125
+ const ebookStores = extractEbookStoresFromDoc(doc);
126
+
127
+ return {
128
+ title,
129
+ authors,
130
+ publisher: "コロナ社",
131
+ url,
132
+ isbn,
133
+ price,
134
+ publishedAt,
135
+ coverImageUrl,
136
+ ebookStores,
137
+ };
138
+ },
139
+ };
@@ -0,0 +1,120 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import { fetchText, stripHtmlTags, resolveUrl, extractAsin, extractEbookStoresFromDoc } from "./base.js";
4
+
5
+ const BASE_URL = "https://gihyo.jp";
6
+
7
+ // --- API レスポンス型 ---
8
+
9
+ interface GihyoSearchResponse {
10
+ lname: string;
11
+ total: number;
12
+ list: Record<string, GihyoBookEntry>;
13
+ next: boolean;
14
+ query: string;
15
+ }
16
+
17
+ interface GihyoBookEntry {
18
+ series: string;
19
+ title: string;
20
+ subtitle: string;
21
+ /** キー: 役割 ("著", "監修" など)、値: { 著者名: markup } */
22
+ author: Record<string, Record<string, string>>;
23
+ /** [定価, 割引価格] */
24
+ price: [number, number];
25
+ stock: number;
26
+ /** ["YYYY.M.D", ""] */
27
+ release: [string, string];
28
+ /** 相対URL: "/book/YYYY/978-4-..." */
29
+ url: string;
30
+ upcoming: boolean;
31
+ support: boolean;
32
+ /** [thumb_url, width, height, full_url] */
33
+ cover: [string, number, number, string];
34
+ }
35
+
36
+ // --- 変換ヘルパー ---
37
+
38
+ function parseAuthors(authorField: Record<string, Record<string, string>>): string[] {
39
+ return Object.values(authorField).flatMap(roleEntries =>
40
+ Object.keys(roleEntries).map(name => stripHtmlTags(name).trim()),
41
+ );
42
+ }
43
+
44
+ function parseReleaseDate(release: [string, string]): string | undefined {
45
+ const raw = release[0];
46
+ if (!raw) return undefined;
47
+ // "2025.9.29" → "2025-09-29"
48
+ const parts = raw.split(".");
49
+ if (parts.length !== 3) return raw;
50
+ const [y, m, d] = parts;
51
+ return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
52
+ }
53
+
54
+ function entryToBookRecord(isbn: string, entry: GihyoBookEntry): BookRecord {
55
+ const title = entry.subtitle
56
+ ? `${entry.title} ${entry.subtitle}`
57
+ : entry.title;
58
+
59
+ return {
60
+ title,
61
+ authors: parseAuthors(entry.author),
62
+ publisher: "技術評論社",
63
+ publishedAt: parseReleaseDate(entry.release),
64
+ isbn: isbn.replace(/-/g, ""),
65
+ url: resolveUrl(BASE_URL, entry.url),
66
+ price: entry.price[0] > 0 ? entry.price[0] : undefined,
67
+ coverImageUrl: entry.cover[3] ? resolveUrl(BASE_URL, entry.cover[3]) : undefined,
68
+ };
69
+ }
70
+
71
+ // --- アダプター実装 ---
72
+
73
+ export const gihyoAdapter: PublisherAdapter = {
74
+ id: "gihyo",
75
+ name: "技術評論社",
76
+ baseUrl: BASE_URL,
77
+
78
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
79
+ const word = [query.title, query.author].filter(Boolean).join(" ");
80
+ if (!word) return [];
81
+
82
+ const limit = query.limit ?? 10;
83
+ const url = `${BASE_URL}/api_gh/site/search?search=${encodeURIComponent(word)}&limit=${limit}`;
84
+ const text = await fetchText(url, deps);
85
+ const data: GihyoSearchResponse = JSON.parse(text);
86
+
87
+ return Object.entries(data.list).map(([isbn, entry]) => entryToBookRecord(isbn, entry));
88
+ },
89
+
90
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
91
+ // URL例: https://gihyo.jp/book/2022/978-4-297-12815-2
92
+ const isbnMatch = url.match(/\/(978-[\d-]+)\s*$/);
93
+ if (!isbnMatch) throw new Error(`URLからISBNを取得できません: ${url}`);
94
+
95
+ const isbn = isbnMatch[1];
96
+ const apiUrl = `${BASE_URL}/api_gh/site/search?search=${encodeURIComponent(isbn)}&limit=1`;
97
+
98
+ // JSONメタデータとebook store情報(HTML)を並列取得
99
+ const [apiText, htmlText] = await Promise.all([
100
+ fetchText(apiUrl, deps),
101
+ fetchText(url, deps),
102
+ ]);
103
+
104
+ const data: GihyoSearchResponse = JSON.parse(apiText);
105
+ const entry = data.list[isbn];
106
+ if (!entry) throw new Error(`書籍が見つかりません: ${isbn}`);
107
+
108
+ const base = entryToBookRecord(isbn, entry);
109
+
110
+ const doc = deps.parser.parse(htmlText);
111
+ const ebookStores = extractEbookStoresFromDoc(doc);
112
+ const asin = extractAsin(htmlText);
113
+
114
+ return {
115
+ ...base,
116
+ asin,
117
+ ebookStores: ebookStores.length > 0 ? ebookStores : undefined,
118
+ };
119
+ },
120
+ };
@@ -0,0 +1,146 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import { fetchText, parseJapanesePrice, resolveUrl, extractAsin, extractEbookStoresFromDoc } from "./base.js";
4
+ import type { EbookStore } from "../../domain/book.js";
5
+
6
+ const BASE_URL = "https://www.lambdanote.com";
7
+
8
+ function extractIsbn(text: string): string | undefined {
9
+ const match = text.match(/97[89]-[\d-]{10,}/);
10
+ return match ? match[0].replace(/-/g, "") : undefined;
11
+ }
12
+
13
+ /**
14
+ * Shopify ページに埋め込まれた product JSON を取得する。
15
+ * <script type="application/json" id="ProductJson-..."> または
16
+ * data-product-json 属性を探す。
17
+ */
18
+ function parseShopifyProductJson(html: string): Record<string, unknown> | null {
19
+ const match = html.match(
20
+ /<script[^>]+type=["']application\/json["'][^>]*id=["']ProductJson[^"']*["'][^>]*>([\s\S]*?)<\/script>/i,
21
+ );
22
+ if (match?.[1]) {
23
+ try {
24
+ return JSON.parse(match[1]) as Record<string, unknown>;
25
+ } catch {
26
+ // fall through
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export const lambdanoteAdapter: PublisherAdapter = {
33
+ id: "lambdanote",
34
+ name: "ラムダノート",
35
+ baseUrl: BASE_URL,
36
+
37
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
38
+ const word = [query.title, query.author].filter(Boolean).join(" ");
39
+ if (!word) return [];
40
+
41
+ const url = `${BASE_URL}/search?q=${encodeURIComponent(word)}&type=product`;
42
+ const html = await fetchText(url, deps);
43
+ const doc = deps.parser.parse(html);
44
+
45
+ const results: BookRecord[] = [];
46
+
47
+ // Shopify Dawn テーマ系の検索結果セレクター
48
+ const items = doc.select(
49
+ ".search-results__list-item, li.search-result-product",
50
+ );
51
+
52
+ for (const item of items) {
53
+ const titleEl =
54
+ item.find(".card__heading a")[0] ??
55
+ item.find("h2 a")[0] ??
56
+ item.find("h3 a")[0] ??
57
+ item.find("a.full-unstyled-link")[0];
58
+
59
+ if (!titleEl) continue;
60
+
61
+ const title = titleEl.text();
62
+ const href = titleEl.attr("href");
63
+ if (!title || !href) continue;
64
+
65
+ const bookUrl = resolveUrl(BASE_URL, href);
66
+
67
+ const priceEl =
68
+ item.find(".price__regular .price-item")[0] ??
69
+ item.find(".price-item")[0] ??
70
+ item.find(".price")[0];
71
+ const price = priceEl ? parseJapanesePrice(priceEl.text()) : undefined;
72
+
73
+ const imgEl = item.find("img")[0];
74
+ const imgSrc = imgEl?.attr("src") ?? imgEl?.attr("data-src");
75
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
76
+
77
+ results.push({
78
+ title,
79
+ authors: [],
80
+ publisher: "ラムダノート",
81
+ url: bookUrl,
82
+ price,
83
+ coverImageUrl,
84
+ });
85
+ }
86
+
87
+ return results.slice(0, query.limit ?? 10);
88
+ },
89
+
90
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
91
+ const html = await fetchText(url, deps);
92
+ const doc = deps.parser.parse(html);
93
+
94
+ const title =
95
+ doc.selectOne("h1.product__title")?.text() ??
96
+ doc.selectOne("h1")?.text() ??
97
+ "";
98
+
99
+ const priceEl =
100
+ doc.selectOne(".price__regular .price-item") ??
101
+ doc.selectOne(".price-item--regular") ??
102
+ doc.selectOne("[class*='price']");
103
+ const price = priceEl ? parseJapanesePrice(priceEl.text()) : undefined;
104
+
105
+ const descEl =
106
+ doc.selectOne(".product__description") ??
107
+ doc.selectOne("[class*='description']");
108
+ const description = descEl?.text() || undefined;
109
+
110
+ const isbn = extractIsbn(html);
111
+ const asin = extractAsin(html);
112
+
113
+ // vendor フィールドに著者が入っている場合がある
114
+ const productJson = parseShopifyProductJson(html);
115
+ const vendor = typeof productJson?.["vendor"] === "string" ? productJson["vendor"] : null;
116
+ const authors: string[] = vendor
117
+ ? vendor.split(/[,、//]/).map((a: string) => a.trim()).filter(Boolean)
118
+ : [];
119
+
120
+ const imgEl =
121
+ doc.selectOne(".product__media img") ??
122
+ doc.selectOne(".product-featured-image") ??
123
+ doc.selectOne("[class*='product'] img");
124
+ const imgSrc = imgEl?.attr("src") ?? imgEl?.attr("data-src");
125
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
126
+
127
+ // ラムダノートは購入時生成の一意IDをPDF欄外に印字 (ソーシャルDRM)
128
+ const ebookStores: EbookStore[] = [
129
+ { name: "ラムダノート", url, drm: "social" },
130
+ ...extractEbookStoresFromDoc(doc).filter(s => s.name !== "ラムダノート"),
131
+ ];
132
+
133
+ return {
134
+ title,
135
+ authors,
136
+ publisher: "ラムダノート",
137
+ url,
138
+ price,
139
+ description,
140
+ isbn,
141
+ asin,
142
+ coverImageUrl,
143
+ ebookStores,
144
+ };
145
+ },
146
+ };
@@ -0,0 +1,112 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
4
+
5
+ const BASE_URL = "https://book.mynavi.jp/manatee";
6
+ const BOOKS_URL = `${BASE_URL}/books/`;
7
+
8
+ /**
9
+ * `.attribute li` 内の著者リンクを配列にする。
10
+ * 各 <a> のテキストが著者名(役割は括弧内テキストで付記されているが名前は <a> 内)。
11
+ */
12
+ function parseAuthors(doc: ReturnType<import("../../ports/html-parser.js").HtmlParser["parse"]>): string[] {
13
+ return doc
14
+ .select(".attribute li a")
15
+ .map(el => el.text().trim())
16
+ .filter(Boolean);
17
+ }
18
+
19
+ export const manateeAdapter: PublisherAdapter = {
20
+ id: "manatee",
21
+ name: "マナティ",
22
+ baseUrl: BASE_URL,
23
+
24
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
25
+ const word = [query.title, query.author].filter(Boolean).join(" ");
26
+ if (!word) return [];
27
+
28
+ const url = `${BOOKS_URL}?topics_keyword=${encodeURIComponent(word)}`;
29
+ const html = await fetchText(url, deps);
30
+ const doc = deps.parser.parse(html);
31
+
32
+ const results: BookRecord[] = [];
33
+ const limit = query.limit ?? 10;
34
+
35
+ // 書籍アイテムは <!-- item --> コメントで区切られた <li> 内
36
+ for (const li of doc.select("ul.category_list li")) {
37
+ const titleEl = li.find("dl.detail dt a")[0];
38
+ if (!titleEl) continue;
39
+
40
+ const title = titleEl.text().trim();
41
+ if (!title) continue;
42
+
43
+ const href = titleEl.attr("href");
44
+ if (!href) continue;
45
+ const bookUrl = href.startsWith("http") ? href : resolveUrl(BASE_URL, href);
46
+
47
+ const priceText = li.find(".detail__price")[0]?.text().trim();
48
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
49
+
50
+ const imgEl = li.find(".image img")[0];
51
+ const imgSrc = imgEl?.attr("src");
52
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
53
+
54
+ results.push({
55
+ title,
56
+ authors: [],
57
+ publisher: "マナティ",
58
+ url: bookUrl,
59
+ price,
60
+ coverImageUrl,
61
+ ebookStores: [{ name: "マナティ", url: bookUrl, drm: "social" }],
62
+ });
63
+
64
+ if (results.length >= limit) break;
65
+ }
66
+
67
+ return results;
68
+ },
69
+
70
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
71
+ const html = await fetchText(url, deps);
72
+ const doc = deps.parser.parse(html);
73
+
74
+ const title = doc.selectOne("h1.title")?.text().trim() ?? "";
75
+
76
+ // `.intro` の最初の <p> が出版社名
77
+ const publisher = doc.selectOne(".intro p")?.text().trim() ?? "マナティ";
78
+
79
+ const authors = parseAuthors(doc);
80
+
81
+ // 購入形態テーブルの最初の行から価格取得
82
+ const priceText = doc.selectOne("#item_selectlist table .price")?.text().trim();
83
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
84
+
85
+ // 表紙画像の alt 属性が "ISBN.jpg" 形式(例: "9784839984274.jpg")
86
+ const imgEl = doc.selectOne("div.item_meta_image img");
87
+ const imgSrc = imgEl?.attr("src");
88
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
89
+ const imgAlt = imgEl?.attr("alt") ?? "";
90
+ const isbn = imgAlt.replace(/\.jpg$/i, "").replace(/-/g, "") || undefined;
91
+
92
+ // 発売日: "発売日:2024-03-22"
93
+ const dateText = doc.selectOne("p.date")?.text().trim();
94
+ const publishedAt = dateText?.match(/(\d{4}-\d{2}-\d{2})/)?.[1] ?? undefined;
95
+
96
+ const descEl = doc.selectOne(".item_desc p:not(.date):not(.pages)");
97
+ const description = descEl?.text().trim() || undefined;
98
+
99
+ return {
100
+ title,
101
+ authors,
102
+ publisher,
103
+ url,
104
+ isbn,
105
+ price,
106
+ publishedAt,
107
+ description,
108
+ coverImageUrl,
109
+ ebookStores: [{ name: "マナティ", url, drm: "social" }],
110
+ };
111
+ },
112
+ };