@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,121 +0,0 @@
1
- import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
- import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
- import { fetchText, 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
- };
@@ -1,142 +0,0 @@
1
- import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
- import type { BookRecord, SearchQuery, EbookStore } from "../../domain/book.js";
3
- import type { HtmlDocument } from "../../ports/html-parser.js";
4
- import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
5
-
6
- const BASE_URL = "https://tatsu-zine.com";
7
-
8
- /**
9
- * "Vlad Khononov(著), 島田 浩二(訳)" → ["Vlad Khononov", "島田 浩二"]
10
- * 末尾の役割記号 (著)(訳)(監修)(編著) などを除去する。
11
- */
12
- function parseAuthors(text: string): string[] {
13
- return text
14
- .split(/[,、]\s*/)
15
- .map(part => part.replace(/\s*[((][^))]*[))]\s*$/, "").trim())
16
- .filter(Boolean);
17
- }
18
-
19
- /**
20
- * ページネーションリンクから最終ページ番号を取得する。
21
- * <a class="btn-pagination" href="/books?page=11">最後へ</a>
22
- */
23
- function detectLastPage(doc: HtmlDocument): number {
24
- let max = 1;
25
- for (const a of doc.select("a.btn-pagination")) {
26
- const href = a.attr("href") ?? "";
27
- const m = href.match(/[?&]page=(\d+)/);
28
- if (m) max = Math.max(max, parseInt(m[1], 10));
29
- }
30
- return max;
31
- }
32
-
33
- /**
34
- * "3,300円 (3,000円+税)" → 3300
35
- * 最初の数値が税込価格。
36
- */
37
- function parsePrice(text: string): number | undefined {
38
- const match = text.match(/^([\d,]+)円/);
39
- if (match) return parseInt(match[1].replace(/,/g, ""), 10);
40
- return parseJapanesePrice(text);
41
- }
42
-
43
-
44
- export const tatsuZineAdapter: PublisherAdapter = {
45
- id: "tatsu-zine",
46
- name: "達人出版会",
47
- baseUrl: BASE_URL,
48
-
49
- async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
50
- // 検索APIがないため書籍一覧からローカルフィルタリングする
51
- // 著者のみの検索は非対応
52
- if (!query.title) return [];
53
-
54
- const titleKeyword = query.title.toLowerCase();
55
- const authorKeyword = query.author?.toLowerCase();
56
- const limit = query.limit ?? 10;
57
-
58
- // 書籍一覧ページ: <article class="book"> が各書籍アイテム、ページネーションあり
59
- const firstHtml = await fetchText(`${BASE_URL}/books/`, deps);
60
- const firstDoc = deps.parser.parse(firstHtml);
61
- const lastPage = detectLastPage(firstDoc);
62
-
63
- const results: BookRecord[] = [];
64
- const docs = [[firstHtml, firstDoc] as const];
65
-
66
- // ページ2以降を先行して取得しておく(キャッシュ経由)
67
- for (let page = 2; page <= lastPage; page++) {
68
- const html = await fetchText(`${BASE_URL}/books?page=${page}`, deps);
69
- docs.push([html, deps.parser.parse(html)]);
70
- }
71
-
72
- outer: for (const [, doc] of docs) {
73
- for (const article of doc.select("article.book")) {
74
- const titleEl = article.find("h3[itemprop='name'] a")[0];
75
- if (!titleEl) continue;
76
-
77
- const title = titleEl.text().trim();
78
- if (!title.toLowerCase().includes(titleKeyword)) continue;
79
-
80
- const authorText = article.find("p[itemprop='author']")[0]?.text().trim() ?? "";
81
- if (authorKeyword && !authorText.toLowerCase().includes(authorKeyword)) continue;
82
-
83
- const href = titleEl.attr("href");
84
- if (!href) continue;
85
- const bookUrl = resolveUrl(BASE_URL, href);
86
-
87
- const authors = authorText ? parseAuthors(authorText) : [];
88
-
89
- results.push({
90
- title,
91
- authors,
92
- publisher: "達人出版会",
93
- url: bookUrl,
94
- // 達人出版会は全書籍で購入者情報を各ページに印字 (ソーシャルDRM)
95
- ebookStores: [{ name: "達人出版会", url: bookUrl, drm: "social" }],
96
- });
97
-
98
- if (results.length >= limit) break outer;
99
- }
100
- }
101
-
102
- return results;
103
- },
104
-
105
- async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
106
- const html = await fetchText(url, deps);
107
- const doc = deps.parser.parse(html);
108
-
109
- const title = doc.selectOne("h1")?.text().trim() ?? "";
110
-
111
- // 実際の出版社: <a href="/books/pub/{slug}">出版社名</a>
112
- // 達人出版会が刊行している場合はこのリンクが存在しない場合もある
113
- const publisherEl = doc.selectOne("a[href*='/books/pub/']");
114
- const publisher = publisherEl?.text().trim() || "達人出版会";
115
-
116
- // カバー画像
117
- const imgEl = doc.selectOne("img[src*='/images/books/']");
118
- const imgSrc = imgEl?.attr("src");
119
- const coverImageUrl = imgSrc ? resolveUrl(BASE_URL, imgSrc) : undefined;
120
-
121
- // 著者: <p itemprop="author"> を優先使用
122
- const authorText = doc.selectOne("p[itemprop='author']")?.text().trim() ?? "";
123
- const authors = authorText ? parseAuthors(authorText) : [];
124
-
125
- // 価格: <span itemprop="price"> を優先使用
126
- const priceText = doc.selectOne("span[itemprop='price']")?.text().trim() ?? "";
127
- const price = priceText ? parsePrice(priceText) : undefined;
128
-
129
- // 達人出版会は全書籍で購入者情報を各ページに印字 (ソーシャルDRM)
130
- const ebookStores: EbookStore[] = [{ name: "達人出版会", url, drm: "social" }];
131
-
132
- return {
133
- title,
134
- authors,
135
- publisher,
136
- url,
137
- price,
138
- coverImageUrl,
139
- ebookStores,
140
- };
141
- },
142
- };
@@ -1,179 +0,0 @@
1
- import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
- import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
- import { fetchText, parseJapanesePrice, extractEbookStoresFromDoc } from "./base.js";
4
-
5
- const BASE_URL = "https://techbookfest.org";
6
- const GRAPHQL_URL = `${BASE_URL}/api/graphql`;
7
- const XSRF_CACHE_KEY = "techbookfest:xsrf-token";
8
- const XSRF_TTL_SECONDS = 3600;
9
-
10
- const DEFAULT_HEADERS = {
11
- "User-Agent": "techbook-mcp/0.1.0 (+https://github.com/zonuexe/techbook-mcp; bibliographic search bot)",
12
- "Accept": "application/json",
13
- };
14
-
15
- // node.product は ProductInfoSearchResult のインラインフラグメント経由でアクセスする
16
- const SEARCH_QUERY = `
17
- query MarketSearchQuery($query: String!, $first: Int!) {
18
- searchProducts(first: $first, query: $query, orderBy: CREATED_AT_DESC) {
19
- pageInfo { hasNextPage endCursor }
20
- edges {
21
- node {
22
- ... on ProductInfoSearchResult {
23
- product {
24
- id
25
- databaseID
26
- name
27
- description
28
- organization { name }
29
- coverImage { url }
30
- ebookVariant: productVariant(kind: MARKET_EBOOK) { price }
31
- firstPublishedAt
32
- status
33
- }
34
- }
35
- }
36
- }
37
- }
38
- }
39
- `.trim();
40
-
41
- interface TechbookfestProduct {
42
- id: string;
43
- databaseID: string;
44
- name: string;
45
- description: string | null;
46
- organization: { name: string } | null;
47
- coverImage: { url: string } | null;
48
- ebookVariant: { price: number } | null;
49
- firstPublishedAt: string | null;
50
- status: string;
51
- }
52
-
53
- interface GraphQLResponse {
54
- data?: {
55
- searchProducts?: {
56
- edges: Array<{ node: { product?: TechbookfestProduct } }>;
57
- };
58
- };
59
- }
60
-
61
- /**
62
- * トップページの Set-Cookie から XSRF-TOKEN を取得してキャッシュする。
63
- * 技術書典の GraphQL API は XSRF トークンを Cookie + X-XSRF-TOKEN ヘッダーの
64
- * ダブルサブミット方式で検証する。
65
- */
66
- async function fetchXsrfToken(deps: PublisherDeps): Promise<string> {
67
- const cached = await deps.cache.get(XSRF_CACHE_KEY);
68
- if (cached !== null) return cached;
69
-
70
- const response = await deps.http.get(BASE_URL, { headers: DEFAULT_HEADERS });
71
- const setCookie = response.header("set-cookie") ?? "";
72
-
73
- // Set-Cookie: XSRF-TOKEN=<urlencoded-value>; Path=/; Secure; SameSite=Lax
74
- const match = setCookie.match(/XSRF-TOKEN=([^;,\s]+)/);
75
- if (!match) throw new Error("techbookfest: XSRF-TOKEN not found in Set-Cookie");
76
-
77
- const token = decodeURIComponent(match[1]);
78
- await deps.cache.set(XSRF_CACHE_KEY, token, XSRF_TTL_SECONDS);
79
- return token;
80
- }
81
-
82
- function productToBookRecord(product: TechbookfestProduct): BookRecord {
83
- const url = `${BASE_URL}/product/${product.databaseID}`;
84
- const publishedAt = product.firstPublishedAt
85
- ? product.firstPublishedAt.slice(0, 10)
86
- : undefined;
87
-
88
- return {
89
- title: product.name,
90
- authors: product.organization ? [product.organization.name] : [],
91
- publisher: "技術書典",
92
- url,
93
- price: product.ebookVariant?.price,
94
- description: product.description ?? undefined,
95
- coverImageUrl: product.coverImage?.url,
96
- publishedAt,
97
- ebookStores: [{ name: "技術書典", url, drm: "free" }],
98
- };
99
- }
100
-
101
- export const techbookfestAdapter: PublisherAdapter = {
102
- id: "techbookfest",
103
- name: "技術書典オンラインマーケット",
104
- baseUrl: BASE_URL,
105
-
106
- async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
107
- const word = [query.title, query.author].filter(Boolean).join(" ");
108
- if (!word) return [];
109
-
110
- const limit = query.limit ?? 10;
111
- const xsrf = await fetchXsrfToken(deps);
112
-
113
- const body = JSON.stringify({
114
- operationName: "MarketSearchQuery",
115
- query: SEARCH_QUERY,
116
- variables: { query: word, first: limit },
117
- });
118
-
119
- const response = await deps.http.post(GRAPHQL_URL, body, {
120
- headers: {
121
- ...DEFAULT_HEADERS,
122
- "Content-Type": "application/json",
123
- "Cookie": `XSRF-TOKEN=${encodeURIComponent(xsrf)}`,
124
- "X-XSRF-TOKEN": xsrf,
125
- },
126
- });
127
-
128
- if (response.status !== 200) {
129
- throw new Error(`HTTP ${response.status}: ${GRAPHQL_URL}`);
130
- }
131
-
132
- const json = JSON.parse(await response.text()) as GraphQLResponse;
133
- const edges = json.data?.searchProducts?.edges ?? [];
134
-
135
- return edges
136
- .map(e => e.node.product)
137
- .filter((p): p is TechbookfestProduct => p != null)
138
- .slice(0, limit)
139
- .map(productToBookRecord);
140
- },
141
-
142
- async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
143
- const html = await fetchText(url, deps);
144
- const doc = deps.parser.parse(html);
145
-
146
- const title =
147
- doc.selectOne('meta[property="og:title"]')?.attr("content") ??
148
- doc.selectOne("h1")?.text() ??
149
- "";
150
-
151
- const description =
152
- doc.selectOne('meta[property="og:description"]')?.attr("content") ??
153
- doc.selectOne('meta[name="description"]')?.attr("content") ??
154
- undefined;
155
-
156
- const coverImageUrl =
157
- doc.selectOne('meta[property="og:image"]')?.attr("content") ??
158
- undefined;
159
-
160
- const priceText = doc.selectOne('[class*="price"]')?.text();
161
- const price = priceText ? parseJapanesePrice(priceText) : undefined;
162
-
163
- const ebookStores = extractEbookStoresFromDoc(doc);
164
- if (!ebookStores.some(s => s.name === "技術書典")) {
165
- ebookStores.unshift({ name: "技術書典", url, drm: "free" });
166
- }
167
-
168
- return {
169
- title,
170
- authors: [],
171
- publisher: "技術書典",
172
- url,
173
- price,
174
- description,
175
- coverImageUrl,
176
- ebookStores,
177
- };
178
- },
179
- };
@@ -1,50 +0,0 @@
1
- import type { BookRecord } from "../domain/book.js";
2
- import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
3
- import { checkRobotsTxt } from "../adapters/publishers/base.js";
4
- import { fetchOpenBDBooks, openBDEntryToBookRecord } from "../adapters/openbd.js";
5
- import { fetchCalilBook } from "../adapters/calil.js";
6
-
7
- /**
8
- * ISBNから書籍情報を取得する。
9
- *
10
- * 1. openBD で書誌情報と出版社ストアリンクを取得する
11
- * 2. ストアリンクが既知アダプターと一致する場合は出版社サイトから詳細取得を試みる
12
- * 3. 取得できない場合は openBD データをそのまま返す
13
- * 4. openBD にも存在しない場合はカーリルから書誌情報を取得する(廃業出版社など)
14
- */
15
- export async function getBookByIsbn(
16
- isbn: string,
17
- publishers: readonly PublisherAdapter[],
18
- deps: PublisherDeps,
19
- ): Promise<BookRecord> {
20
- const normalizedIsbn = isbn.replace(/-/g, "");
21
-
22
- const openBDMap = await fetchOpenBDBooks([normalizedIsbn], deps);
23
- const entry = openBDMap.get(normalizedIsbn);
24
-
25
- if (!entry) {
26
- // openBD にない場合はカーリルをフォールバックとして試みる(廃業出版社など)
27
- const calilBook = await fetchCalilBook(normalizedIsbn, deps);
28
- if (calilBook) return calilBook;
29
- throw new Error(`書誌情報が見つかりません: ${isbn}`);
30
- }
31
-
32
- // hanmoto.storelink が既知アダプターの baseUrl と前方一致する場合は
33
- // 出版社サイトから詳細取得を試みる
34
- const storelink = entry.hanmoto?.storelink;
35
- if (storelink) {
36
- const publisher = publishers.find(p => storelink.startsWith(p.baseUrl));
37
- if (publisher) {
38
- const allowed = await checkRobotsTxt(storelink, deps);
39
- if (allowed) {
40
- try {
41
- return await publisher.getDetail(storelink, deps);
42
- } catch {
43
- // 出版社サイトからの取得失敗は無視して openBD データで返す
44
- }
45
- }
46
- }
47
- }
48
-
49
- return openBDEntryToBookRecord(entry);
50
- }
@@ -1,40 +0,0 @@
1
- import type { BookRecord } from "../domain/book.js";
2
- import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
3
- import { checkRobotsTxt } from "../adapters/publishers/base.js";
4
- import { fetchOpenBDBooks, enrichWithOpenBD } from "../adapters/openbd.js";
5
-
6
- export async function getBookDetail(
7
- url: string,
8
- publishers: readonly PublisherAdapter[],
9
- deps: PublisherDeps,
10
- ): Promise<BookRecord> {
11
- const publisher = publishers.find(p => url.startsWith(p.baseUrl));
12
- if (!publisher) {
13
- throw new Error(
14
- `このURLに対応する出版社アダプターがありません: ${url}\n` +
15
- `対応URL: ${publishers.map(p => p.baseUrl).join(", ")}`,
16
- );
17
- }
18
-
19
- const allowed = await checkRobotsTxt(url, deps);
20
- if (!allowed) {
21
- throw new Error(`robots.txt によりアクセスが禁止されています: ${url}`);
22
- }
23
-
24
- const book = await publisher.getDetail(url, deps);
25
-
26
- // ISBNが特定できる場合はopenBDで欠損フィールドを補完
27
- if (book.isbn !== undefined) {
28
- try {
29
- const openBDMap = await fetchOpenBDBooks([book.isbn], deps);
30
- const entry = openBDMap.get(book.isbn);
31
- if (entry !== undefined) {
32
- return enrichWithOpenBD(book, entry);
33
- }
34
- } catch {
35
- // openBD の取得失敗は無視して出版社から取得できた情報を返す
36
- }
37
- }
38
-
39
- return book;
40
- }
@@ -1,64 +0,0 @@
1
- import type { BookRecord, SearchQuery } from "../domain/book.js";
2
- import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
3
- import { checkRobotsTxt } from "../adapters/publishers/base.js";
4
- import { fetchOpenBDBooks, enrichWithOpenBD } from "../adapters/openbd.js";
5
-
6
- export interface SearchBooksResult {
7
- books: BookRecord[];
8
- errors: Array<{ publisherId: string; message: string }>;
9
- }
10
-
11
- export async function searchBooks(
12
- query: SearchQuery,
13
- publishers: readonly PublisherAdapter[],
14
- deps: PublisherDeps,
15
- ): Promise<SearchBooksResult> {
16
- const targets = query.publisherId
17
- ? publishers.filter(p => p.id === query.publisherId)
18
- : publishers;
19
-
20
- const results = await Promise.allSettled(
21
- targets.map(async (p) => {
22
- const allowed = await checkRobotsTxt(p.baseUrl, deps);
23
- if (!allowed) throw new Error(`robots.txt によりアクセスが禁止されています: ${p.baseUrl}`);
24
- return p.search(query, deps);
25
- }),
26
- );
27
-
28
- const books: BookRecord[] = [];
29
- const errors: Array<{ publisherId: string; message: string }> = [];
30
-
31
- for (let i = 0; i < results.length; i++) {
32
- const result = results[i];
33
- const publisher = targets[i];
34
- if (result.status === "fulfilled") {
35
- books.push(...result.value);
36
- } else {
37
- const message = result.reason instanceof Error
38
- ? result.reason.message
39
- : String(result.reason);
40
- errors.push({ publisherId: publisher.id, message });
41
- }
42
- }
43
-
44
- // ISBNが特定できる書籍をopenBDで一括補完
45
- const isbns = books.map(b => b.isbn).filter((isbn): isbn is string => isbn !== undefined);
46
- if (isbns.length > 0) {
47
- try {
48
- const openBDMap = await fetchOpenBDBooks(isbns, deps);
49
- for (let i = 0; i < books.length; i++) {
50
- const isbn = books[i].isbn;
51
- if (isbn !== undefined) {
52
- const entry = openBDMap.get(isbn);
53
- if (entry !== undefined) {
54
- books[i] = enrichWithOpenBD(books[i], entry);
55
- }
56
- }
57
- }
58
- } catch {
59
- // openBD の取得失敗は無視して出版社から取得できた情報を返す
60
- }
61
- }
62
-
63
- return { books, errors };
64
- }
@@ -1,35 +0,0 @@
1
- /**
2
- * - `"free"` : 技術的DRMなし (DRM-free PDF/EPUB)
3
- * - `"social"` : ソーシャルDRM (購入者情報を透かし刻印、技術的制限なし)
4
- * - `"password_pdf"` : パスワード認証付きPDF (標準PDFビューアで閲覧可、パスワード必須)
5
- * - `"drm"` : 技術的DRM付き (専用ビューアー必須)
6
- */
7
- export type DrmType = "free" | "social" | "password_pdf" | "drm";
8
-
9
- export interface EbookStore {
10
- name: string;
11
- url: string;
12
- drm: DrmType;
13
- }
14
-
15
- export interface BookRecord {
16
- title: string;
17
- authors: string[];
18
- publisher: string;
19
- publishedAt?: string; // "YYYY-MM-DD"
20
- isbn?: string; // ISBN-13、ハイフンなし数字のみ
21
- asin?: string; // Amazon ASIN (Amazonリンクが存在する場合)
22
- url: string; // 出版社公式ページURL
23
- price?: number; // 税込価格(円)
24
- coverImageUrl?: string;
25
- description?: string;
26
- tags?: string[];
27
- ebookStores?: EbookStore[];
28
- }
29
-
30
- export interface SearchQuery {
31
- title?: string;
32
- author?: string;
33
- publisherId?: string; // 出版社IDでフィルタ (例: "gihyo", "lambdanote")
34
- limit?: number; // デフォルト: 10
35
- }
@@ -1,18 +0,0 @@
1
- import type { BookRecord, SearchQuery } from "./book.js";
2
- import type { HttpClient } from "../ports/http.js";
3
- import type { HtmlParser } from "../ports/html-parser.js";
4
- import type { CacheStore } from "../ports/cache.js";
5
-
6
- export interface PublisherDeps {
7
- http: HttpClient;
8
- parser: HtmlParser;
9
- cache: CacheStore;
10
- }
11
-
12
- export interface PublisherAdapter {
13
- readonly id: string;
14
- readonly name: string;
15
- readonly baseUrl: string;
16
- search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]>;
17
- getDetail(url: string, deps: PublisherDeps): Promise<BookRecord>;
18
- }
package/src/main.ts DELETED
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env node
2
- import { startServer } from "./mcp/server.js";
3
- import { DEFAULT_PUBLISHERS } from "./adapters/publishers/registry.js";
4
- import { FetchHttpClient } from "./adapters/http/fetch-client.js";
5
- import { CheerioHtmlParser } from "./adapters/html/cheerio-parser.js";
6
- import { MemoryCacheStore } from "./adapters/cache/memory-cache.js";
7
-
8
- const deps = {
9
- http: new FetchHttpClient(),
10
- parser: new CheerioHtmlParser(),
11
- cache: new MemoryCacheStore(),
12
- };
13
-
14
- await startServer(DEFAULT_PUBLISHERS, deps);