@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,161 @@
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 } from "./base.js";
5
+
6
+ const BASE_URL = "https://shop.rutles.net";
7
+
8
+ /**
9
+ * キーワードを EUC-JP でパーセントエンコードする。
10
+ * shop.rutles.net は 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
+ * "著者: 大槻有一郎:著 山田巧(DXライブラリ管理人):監修<br />"
21
+ * から著者名のリストを取得する。
22
+ * - 役割語(:著 / :訳 / :監修 など ":"以降)を除去
23
+ * - 所属(括弧内)を除去
24
+ * - 全角スペースで複数著者を分割
25
+ */
26
+ function parseAuthorsFromBodyText(bodyText: string): string[] {
27
+ const m = bodyText.match(/著者[::]\s*([^\n<]+)/);
28
+ if (!m) return [];
29
+ return m[1]
30
+ .split(/[\u3000 ]/) // 全角スペースで分割
31
+ .map(part =>
32
+ part
33
+ .replace(/\(.*?\)/g, "") // (所属) を除去
34
+ .replace(/:.*$/, "") // :役割語 を除去
35
+ .trim(),
36
+ )
37
+ .filter(Boolean);
38
+ }
39
+
40
+ /**
41
+ * "発売日:2014年10月25日" → "2014-10-25"
42
+ */
43
+ function parseDateFromBodyText(bodyText: string): string | undefined {
44
+ const m = bodyText.match(/発売日[::]\s*(\d{4})年(\d{1,2})月(\d{1,2})日/);
45
+ if (!m) return undefined;
46
+ return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
47
+ }
48
+
49
+ /**
50
+ * ページ埋め込みの Colorme JSON から商品情報を取得する。
51
+ * `var Colorme = {...};` 形式。
52
+ */
53
+ function extractColormeProduct(html: string): { isbn?: string; price?: number } {
54
+ const m = html.match(/var Colorme = (\{[^\n]+\});/);
55
+ if (!m) return {};
56
+ try {
57
+ const data = JSON.parse(m[1]) as {
58
+ product?: { model_number?: string; sales_price_including_tax?: number };
59
+ };
60
+ const product = data.product;
61
+ if (!product) return {};
62
+ const rawIsbn = product.model_number;
63
+ const isbn = rawIsbn ? rawIsbn.replace(/-/g, "") : undefined;
64
+ const price = product.sales_price_including_tax;
65
+ return { isbn, price };
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ export const rutlesAdapter: PublisherAdapter = {
72
+ id: "rutles",
73
+ name: "ラトルズ",
74
+ baseUrl: BASE_URL,
75
+
76
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
77
+ const word = [query.title, query.author].filter(Boolean).join(" ");
78
+ if (!word) return [];
79
+
80
+ const url = `${BASE_URL}/?mode=srh&keyword=${encodeEucJp(word)}`;
81
+ const html = await fetchText(url, deps);
82
+ const doc = deps.parser.parse(html);
83
+
84
+ const results: BookRecord[] = [];
85
+ const limit = query.limit ?? 10;
86
+
87
+ for (const item of doc.select("li.c-item-list__item")) {
88
+ const linkEl = item.find("div.c-item-list__ttl a")[0];
89
+ if (!linkEl) continue;
90
+
91
+ const title = linkEl.text().trim();
92
+ const href = linkEl.attr("href");
93
+ if (!title || !href) continue;
94
+
95
+ // 【電子版】 が含まれるもののみ対象
96
+ if (!title.includes("【電子版】")) continue;
97
+
98
+ // `?pid=...` 形式の相対URLを `https://shop.rutles.net/?pid=...` に変換
99
+ const bookUrl = new URL(href, BASE_URL + "/").toString();
100
+
101
+ const priceText = item.find("div.c-item-list__price")[0]?.text().trim();
102
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
103
+
104
+ const imgEl = item.find("div.c-item-list__img img")[0];
105
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
106
+
107
+ results.push({
108
+ title,
109
+ authors: [],
110
+ publisher: "ラトルズ",
111
+ url: bookUrl,
112
+ price,
113
+ coverImageUrl,
114
+ ebookStores: [{ name: "ラトルズ", url: bookUrl, drm: "free" }],
115
+ });
116
+
117
+ if (results.length >= limit) break;
118
+ }
119
+
120
+ return results;
121
+ },
122
+
123
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
124
+ const html = await fetchText(url, deps);
125
+ const doc = deps.parser.parse(html);
126
+
127
+ const getMeta = (prop: string): string | undefined =>
128
+ doc.selectOne(`meta[property="${prop}"]`)?.attr("content")?.trim() || undefined;
129
+
130
+ // タイトル: h2.p-product-info__ttl または og:title から " - ..." を除去
131
+ const h2Title = doc.selectOne("h2.p-product-info__ttl")?.text().trim();
132
+ const ogTitle = getMeta("og:title")?.replace(/\s*[--]\s*出版社ラトルズ公式ネットショップ.*$/, "").trim();
133
+ const title = h2Title || ogTitle || "";
134
+
135
+ // 説明文テキストから著者・発売日を取得
136
+ const bodyText = doc.selectOne("div.p-product-explain__body")?.text() ?? "";
137
+ const authors = parseAuthorsFromBodyText(bodyText);
138
+ const publishedAt = parseDateFromBodyText(bodyText);
139
+
140
+ // Colorme JSON から ISBN・価格を取得
141
+ const { isbn, price: colormePrice } = extractColormeProduct(html);
142
+
143
+ // og:price をフォールバックとして使用
144
+ const metaPriceStr = getMeta("product:price:amount");
145
+ const price = colormePrice ?? (metaPriceStr ? parseInt(metaPriceStr, 10) : undefined);
146
+
147
+ const coverImageUrl = getMeta("og:image") ?? undefined;
148
+
149
+ return {
150
+ title,
151
+ authors,
152
+ publisher: "ラトルズ",
153
+ url,
154
+ isbn,
155
+ price,
156
+ publishedAt,
157
+ coverImageUrl,
158
+ ebookStores: [{ name: "ラトルズ", url, drm: "free" }],
159
+ };
160
+ },
161
+ };
@@ -0,0 +1,149 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import { fetchText, parseJapanesePrice } from "./base.js";
4
+
5
+ const BASE_URL = "https://www.saiensu.co.jp";
6
+ const SEARCH_URL = `${BASE_URL}/search/`;
7
+
8
+ /**
9
+ * "堀井俊佑(早稲田大学准教授) 監修" → "堀井俊佑"
10
+ * 所属(括弧内)と役割語(著・編・監修など)を除去する。
11
+ */
12
+ function parseAuthorName(text: string): string {
13
+ return text
14
+ .replace(/\(.*?\)/g, "") // (所属) を除去
15
+ .replace(/[\u3000\s]*(著|訳|編|監修|監訳|他)[\u3000\s]*$/, "")
16
+ .trim();
17
+ }
18
+
19
+ /**
20
+ * "発行日:2026年3月25日" → "2026-03-25"
21
+ * 1桁の月・日も対応する。
22
+ */
23
+ function parseDate(text: string): string | undefined {
24
+ const m = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
25
+ if (!m) return undefined;
26
+ return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
27
+ }
28
+
29
+ /** "ISBN:978-4-7819-9049-1" → "9784781990491" */
30
+ function parseIsbn(text: string): string | undefined {
31
+ const m = text.match(/[\d-]{13,}/);
32
+ return m ? m[0].replace(/-/g, "") : undefined;
33
+ }
34
+
35
+ /** "発行:サイエンス社" → "サイエンス社" */
36
+ function parsePublisher(text: string): string {
37
+ return text.replace(/^発行[::]/, "").trim();
38
+ }
39
+
40
+ export const saiensuAdapter: PublisherAdapter = {
41
+ id: "saiensu",
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}?keyword=${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 article of doc.select("article.bookListItem")) {
57
+ // 電子書籍のみを対象とする
58
+ const mediaName = article.find(".bookListItemData_mediaName")[0]?.text().trim();
59
+ if (mediaName !== "電子") continue;
60
+
61
+ const titleEl = article.find("h4.bookListItemData_title p.minor-mt a.link_item")[0];
62
+ if (!titleEl) continue;
63
+
64
+ const title = titleEl.text().trim();
65
+ const href = titleEl.attr("href");
66
+ if (!title || !href) continue;
67
+
68
+ const subtitle = article.find("small.bookListItemData_subtitle")[0]?.text().trim();
69
+ const fullTitle = subtitle ? `${title} ―${subtitle}` : title;
70
+
71
+ const authorText = article.find(".bookListItemData_author a.link_item")
72
+ .map(el => parseAuthorName(el.text()))
73
+ .filter(Boolean);
74
+
75
+ const priceText = article.find(".bookListItemData_priceIncludedTaxNumber")[0]?.text().trim();
76
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
77
+
78
+ const dateText = article.find(".bookListItemData_publishDate")[0]?.text().trim();
79
+ const publishedAt = dateText ? parseDate(dateText) : undefined;
80
+
81
+ const publisherText = article.find(".bookListItemData_publisher")[0]?.text().trim();
82
+ const publisher = publisherText ? parsePublisher(publisherText) : "サイエンス社";
83
+
84
+ const isbnText = article.find(".bookListItemData_isbn2")[0]?.text().trim();
85
+ const isbn = isbnText ? parseIsbn(isbnText) : undefined;
86
+
87
+ const imgEl = article.find(".bookListItemCover img")[0];
88
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
89
+
90
+ const bookUrl = href.startsWith("http") ? href : `${BASE_URL}${href}`;
91
+
92
+ results.push({
93
+ title: fullTitle,
94
+ authors: authorText,
95
+ publisher,
96
+ url: bookUrl,
97
+ isbn,
98
+ price,
99
+ publishedAt,
100
+ coverImageUrl,
101
+ // パスワード認証付きPDF販売。標準PDFビューアで閲覧可能だが技術的制限あり
102
+ ebookStores: [{ name: "サイエンス社", url: bookUrl, drm: "password_pdf" }],
103
+ });
104
+
105
+ if (results.length >= limit) break;
106
+ }
107
+
108
+ return results;
109
+ },
110
+
111
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
112
+ const html = await fetchText(url, deps);
113
+ const doc = deps.parser.parse(html);
114
+
115
+ const titleMain = doc.selectOne("h2.mainDetail_bookTitle")?.text().trim() ?? "";
116
+ const subtitle = doc.selectOne(".mainDetail_subTitle")?.text().trim();
117
+ const title = subtitle ? `${titleMain} ―${subtitle}` : titleMain;
118
+
119
+ const authors = doc.select(".mainDetail_authorList a.link_item")
120
+ .map(el => parseAuthorName(el.text()))
121
+ .filter(Boolean);
122
+
123
+ const priceText = doc.selectOne(".price_num")?.text().trim();
124
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
125
+
126
+ const dateText = doc.selectOne(".bookDetail_publishDate")?.text().trim();
127
+ const publishedAt = dateText ? parseDate(dateText) : undefined;
128
+
129
+ const publisherText = doc.selectOne(".bookDetail_publisher")?.text().trim();
130
+ const publisher = publisherText ? parsePublisher(publisherText) : "サイエンス社";
131
+
132
+ const isbnText = doc.selectOne(".bookDetail_isbn")?.text().trim();
133
+ const isbn = isbnText ? parseIsbn(isbnText) : undefined;
134
+
135
+ const coverImageUrl = doc.selectOne("img.mainDetail_bookImage")?.attr("src") ?? undefined;
136
+
137
+ return {
138
+ title,
139
+ authors,
140
+ publisher,
141
+ url,
142
+ isbn,
143
+ price,
144
+ publishedAt,
145
+ coverImageUrl,
146
+ ebookStores: [{ name: "サイエンス社", url, drm: "password_pdf" }],
147
+ };
148
+ },
149
+ };
@@ -0,0 +1,121 @@
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://www.seshop.com";
6
+ const SEARCH_URL = `${BASE_URL}/search`;
7
+
8
+ /** 電子書籍カテゴリID */
9
+ const EBOOK_CATEGORY_ID = "327";
10
+
11
+ /**
12
+ * "2025.02.17発売" または "2025.02.17" → "2025-02-17"
13
+ */
14
+ function parseDate(text: string): string | undefined {
15
+ const m = text.match(/(\d{4})\.(\d{2})\.(\d{2})/);
16
+ if (!m) return undefined;
17
+ return `${m[1]}-${m[2]}-${m[3]}`;
18
+ }
19
+
20
+ export const seshopAdapter: PublisherAdapter = {
21
+ id: "seshop",
22
+ name: "SEshop (翔泳社)",
23
+ baseUrl: BASE_URL,
24
+
25
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
26
+ const word = [query.title, query.author].filter(Boolean).join(" ");
27
+ if (!word) return [];
28
+
29
+ const url = `${SEARCH_URL}?keyword=${encodeURIComponent(word)}&category_id=${EBOOK_CATEGORY_ID}&sort=newer`;
30
+ const html = await fetchText(url, deps);
31
+ const doc = deps.parser.parse(html);
32
+
33
+ const results: BookRecord[] = [];
34
+ const limit = query.limit ?? 10;
35
+
36
+ for (const item of doc.select("div.inner")) {
37
+ // 電子書籍のみ: data-category が "電子書籍" で始まるものに限定
38
+ const dataEl = item.find("div.product-data")[0];
39
+ const category = dataEl?.attr("data-category") ?? "";
40
+ if (!category.startsWith("電子書籍")) continue;
41
+
42
+ const linkEl = item.find("div.txt a")[0];
43
+ if (!linkEl) continue;
44
+
45
+ const title = linkEl.text().trim();
46
+ const href = linkEl.attr("href");
47
+ if (!title || !href) continue;
48
+
49
+ const bookUrl = href.startsWith("http") ? href : `${BASE_URL}${href}`;
50
+
51
+ const priceStr = dataEl?.attr("data-price");
52
+ const price = priceStr ? parseInt(priceStr, 10) : undefined;
53
+
54
+ const dateText = item.find("span.date")[0]?.text().trim();
55
+ const publishedAt = dateText ? parseDate(dateText) : undefined;
56
+
57
+ const imgEl = item.find("figure img")[0];
58
+ const imgSrc = imgEl?.attr("src");
59
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
60
+
61
+ results.push({
62
+ title,
63
+ authors: [],
64
+ publisher: "翔泳社",
65
+ url: bookUrl,
66
+ price,
67
+ publishedAt,
68
+ coverImageUrl,
69
+ ebookStores: [{ name: "SEshop", url: bookUrl, drm: "social" }],
70
+ });
71
+
72
+ if (results.length >= limit) break;
73
+ }
74
+
75
+ return results;
76
+ },
77
+
78
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
79
+ const html = await fetchText(url, deps);
80
+ const doc = deps.parser.parse(html);
81
+
82
+ const getMeta = (name: string): string | undefined =>
83
+ doc.selectOne(`meta[name="${name}"]`)?.attr("content")?.trim() || undefined;
84
+
85
+ const title =
86
+ doc.selectOne("h1")?.text().trim() ||
87
+ getMeta("cxenseparse:sho-product-name") ||
88
+ "";
89
+
90
+ // col-md-5 内の著者リンク(役割語は括弧内テキストノードで隣接しているがリンクテキストは名前のみ)
91
+ const authors = doc.select("a[href*='/product/author/']")
92
+ .map(el => el.text().trim())
93
+ .filter(Boolean);
94
+
95
+ const isbnRaw = getMeta("cxenseparse:sho-isbn");
96
+ const isbn = isbnRaw ? isbnRaw.replace(/\s+/g, "") : undefined;
97
+
98
+ const priceRaw = getMeta("cxenseparse:sho-price");
99
+ const price = priceRaw ? parseInt(priceRaw, 10) : undefined;
100
+
101
+ const dateRaw = getMeta("cxenseparse:sho-releasedate");
102
+ const publishedAt = dateRaw ? parseDate(dateRaw) : undefined;
103
+
104
+ // カバー画像: book-img または meta
105
+ const coverImgEl = doc.selectOne("img.book-img");
106
+ const coverSrc = coverImgEl?.attr("src") ?? getMeta("cxenseparse:recs:image");
107
+ const coverImageUrl = coverSrc ? resolveUrl(BASE_URL, coverSrc) : undefined;
108
+
109
+ return {
110
+ title,
111
+ authors,
112
+ publisher: "翔泳社",
113
+ url,
114
+ isbn,
115
+ price,
116
+ publishedAt,
117
+ coverImageUrl,
118
+ ebookStores: [{ name: "SEshop", url, drm: "social" }],
119
+ };
120
+ },
121
+ };
@@ -0,0 +1,129 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery, EbookStore } from "../../domain/book.js";
3
+ import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
4
+
5
+ const BASE_URL = "https://tatsu-zine.com";
6
+
7
+ /**
8
+ * "Vlad Khononov(著), 島田 浩二(訳)" → ["Vlad Khononov", "島田 浩二"]
9
+ * 末尾の役割記号 (著)(訳)(監修)(編著) などを除去する。
10
+ */
11
+ function parseAuthors(text: string): string[] {
12
+ return text
13
+ .split(/[,、]\s*/)
14
+ .map(part => part.replace(/\s*[((][^))]*[))]\s*$/, "").trim())
15
+ .filter(Boolean);
16
+ }
17
+
18
+ /**
19
+ * "3,300円 (3,000円+税)" → 3300
20
+ * 最初の数値が税込価格。
21
+ */
22
+ function parsePrice(text: string): number | undefined {
23
+ const match = text.match(/^([\d,]+)円/);
24
+ if (match) return parseInt(match[1].replace(/,/g, ""), 10);
25
+ return parseJapanesePrice(text);
26
+ }
27
+
28
+
29
+ export const tatsuZineAdapter: PublisherAdapter = {
30
+ id: "tatsu-zine",
31
+ name: "達人出版会",
32
+ baseUrl: BASE_URL,
33
+
34
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
35
+ const word = [query.title, query.author].filter(Boolean).join(" ");
36
+ if (!word) return [];
37
+
38
+ // 検索フォーム: <form method="get" action="/books/"><input name="search">
39
+ const url = `${BASE_URL}/books/?search=${encodeURIComponent(word)}`;
40
+ const html = await fetchText(url, deps);
41
+ const doc = deps.parser.parse(html);
42
+
43
+ // 書籍アイテムのHTML構造:
44
+ // <a href="/books/{slug}"><img src="/images/books/{id}/cover_s.jpg" alt="Title"></a>
45
+ // <h3><a href="/books/{slug}">Title</a></h3>
46
+ // <p>Author(著), ...</p>
47
+ //
48
+ // タイトルリンクと著者段落を位置で対応付ける
49
+ const titleLinks = doc.select("h3 a[href]").filter(a => {
50
+ const href = a.attr("href") ?? "";
51
+ return href.startsWith("/books/") && !href.startsWith("/books/pub/");
52
+ });
53
+ const authorParagraphs = doc.select("h3 + p");
54
+
55
+ const results: BookRecord[] = [];
56
+
57
+ for (let i = 0; i < titleLinks.length; i++) {
58
+ const titleLink = titleLinks[i];
59
+ const title = titleLink.text().trim();
60
+ const href = titleLink.attr("href");
61
+ if (!title || !href) continue;
62
+
63
+ const bookUrl = resolveUrl(BASE_URL, href);
64
+ const authorText = authorParagraphs[i]?.text().trim() ?? "";
65
+ const authors = authorText ? parseAuthors(authorText) : [];
66
+
67
+ results.push({
68
+ title,
69
+ authors,
70
+ publisher: "達人出版会",
71
+ url: bookUrl,
72
+ // 達人出版会は全書籍で購入者情報を各ページに印字 (ソーシャルDRM)
73
+ ebookStores: [{ name: "達人出版会", url: bookUrl, drm: "social" }],
74
+ });
75
+ }
76
+
77
+ return results.slice(0, query.limit ?? 10);
78
+ },
79
+
80
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
81
+ const html = await fetchText(url, deps);
82
+ const doc = deps.parser.parse(html);
83
+
84
+ const title = doc.selectOne("h1")?.text().trim() ?? "";
85
+
86
+ // 実際の出版社: <a href="/books/pub/{slug}">出版社名</a>
87
+ // 達人出版会が刊行している場合はこのリンクが存在しない場合もある
88
+ const publisherEl = doc.selectOne("a[href*='/books/pub/']");
89
+ const publisher = publisherEl?.text().trim() || "達人出版会";
90
+
91
+ // カバー画像
92
+ const imgEl = doc.selectOne("img[src*='/images/books/']");
93
+ const imgSrc = imgEl?.attr("src");
94
+ const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
95
+
96
+ // 著者・価格: dl > dd 構造を優先し、なければ p 要素を走査
97
+ let authors: string[] = [];
98
+ let price: number | undefined;
99
+
100
+ const candidates = [
101
+ ...doc.select("dd"),
102
+ ...doc.select("p"),
103
+ ];
104
+
105
+ for (const el of candidates) {
106
+ const text = el.text().trim();
107
+ if (!authors.length && /[((][著訳監編]/.test(text)) {
108
+ authors = parseAuthors(text);
109
+ }
110
+ if (price === undefined && /^\d/.test(text) && /円/.test(text)) {
111
+ price = parsePrice(text);
112
+ }
113
+ if (authors.length && price !== undefined) break;
114
+ }
115
+
116
+ // 達人出版会は全書籍で購入者情報を各ページに印字 (ソーシャルDRM)
117
+ const ebookStores: EbookStore[] = [{ name: "達人出版会", url, drm: "social" }];
118
+
119
+ return {
120
+ title,
121
+ authors,
122
+ publisher,
123
+ url,
124
+ price,
125
+ coverImageUrl,
126
+ ebookStores,
127
+ };
128
+ },
129
+ };