@zonuexe/techbook-mcp 0.2.3 → 0.2.4

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 (135) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +39 -20
  3. package/dist/adapters/publishers/google-books.d.ts +4 -0
  4. package/dist/adapters/publishers/google-books.d.ts.map +1 -0
  5. package/dist/adapters/publishers/google-books.js +75 -0
  6. package/dist/adapters/publishers/google-books.js.map +1 -0
  7. package/dist/adapters/publishers/isbn-publisher-codes.d.ts +21 -0
  8. package/dist/adapters/publishers/isbn-publisher-codes.d.ts.map +1 -0
  9. package/dist/adapters/publishers/isbn-publisher-codes.js +49 -0
  10. package/dist/adapters/publishers/isbn-publisher-codes.js.map +1 -0
  11. package/dist/adapters/publishers/juse-p.d.ts +3 -0
  12. package/dist/adapters/publishers/juse-p.d.ts.map +1 -0
  13. package/dist/adapters/publishers/juse-p.js +110 -0
  14. package/dist/adapters/publishers/juse-p.js.map +1 -0
  15. package/dist/adapters/publishers/registry.d.ts.map +1 -1
  16. package/dist/adapters/publishers/registry.js +4 -0
  17. package/dist/adapters/publishers/registry.js.map +1 -1
  18. package/dist/application/get-book-by-isbn.d.ts +3 -2
  19. package/dist/application/get-book-by-isbn.d.ts.map +1 -1
  20. package/dist/application/get-book-by-isbn.js +22 -3
  21. package/dist/application/get-book-by-isbn.js.map +1 -1
  22. package/dist/config/credentials.d.ts +8 -0
  23. package/dist/config/credentials.d.ts.map +1 -0
  24. package/dist/config/credentials.js +32 -0
  25. package/dist/config/credentials.js.map +1 -0
  26. package/dist/main.js +15 -1
  27. package/dist/main.js.map +1 -1
  28. package/dist/setup.d.ts +2 -0
  29. package/dist/setup.d.ts.map +1 -0
  30. package/dist/setup.js +43 -0
  31. package/dist/setup.js.map +1 -0
  32. package/flake.lock +61 -0
  33. package/package.json +1 -1
  34. package/.claude/settings.local.json +0 -38
  35. package/.codex/skills/techbook-mcp-release-prep/SKILL.md +0 -105
  36. package/.github/workflows/test.yml +0 -72
  37. package/.oxlintrc.json +0 -12
  38. package/AGENTS.md +0 -100
  39. package/deno.json +0 -3
  40. package/src/adapters/cache/memory-cache.ts +0 -31
  41. package/src/adapters/cache/null-cache.ts +0 -8
  42. package/src/adapters/calil.ts +0 -57
  43. package/src/adapters/html/cheerio-parser.ts +0 -50
  44. package/src/adapters/http/fetch-client.ts +0 -47
  45. package/src/adapters/http/mock-client.ts +0 -77
  46. package/src/adapters/openbd.ts +0 -142
  47. package/src/adapters/publishers/base.ts +0 -279
  48. package/src/adapters/publishers/book-tech.ts +0 -117
  49. package/src/adapters/publishers/born-digital.ts +0 -143
  50. package/src/adapters/publishers/coronasha.ts +0 -139
  51. package/src/adapters/publishers/gihyo.ts +0 -120
  52. package/src/adapters/publishers/impress.ts +0 -103
  53. package/src/adapters/publishers/lambdanote.ts +0 -146
  54. package/src/adapters/publishers/manatee.ts +0 -113
  55. package/src/adapters/publishers/maruzen-publishing.ts +0 -129
  56. package/src/adapters/publishers/optronics.ts +0 -113
  57. package/src/adapters/publishers/oreilly-japan.ts +0 -133
  58. package/src/adapters/publishers/peaks.ts +0 -98
  59. package/src/adapters/publishers/personal-media.ts +0 -168
  60. package/src/adapters/publishers/registry.ts +0 -38
  61. package/src/adapters/publishers/rutles.ts +0 -149
  62. package/src/adapters/publishers/saiensu.ts +0 -136
  63. package/src/adapters/publishers/seshop.ts +0 -121
  64. package/src/adapters/publishers/tatsu-zine.ts +0 -142
  65. package/src/adapters/publishers/techbookfest.ts +0 -179
  66. package/src/application/get-book-by-isbn.ts +0 -50
  67. package/src/application/get-book-detail.ts +0 -40
  68. package/src/application/search-books.ts +0 -64
  69. package/src/domain/book.ts +0 -35
  70. package/src/domain/publisher.ts +0 -18
  71. package/src/main.ts +0 -14
  72. package/src/mcp/server.ts +0 -113
  73. package/src/mcp/tools.ts +0 -71
  74. package/src/ports/cache.ts +0 -5
  75. package/src/ports/html-parser.ts +0 -15
  76. package/src/ports/http.ts +0 -17
  77. package/tests/fixtures/book-tech-detail.html +0 -51
  78. package/tests/fixtures/book-tech-search.html +0 -91
  79. package/tests/fixtures/born-digital-detail.html +0 -62
  80. package/tests/fixtures/born-digital-search.html +0 -51
  81. package/tests/fixtures/calil-book.html +0 -987
  82. package/tests/fixtures/coronasha-detail.html +0 -41
  83. package/tests/fixtures/coronasha-search.html +0 -61
  84. package/tests/fixtures/gihyo-detail.html +0 -42
  85. package/tests/fixtures/gihyo-search.json +0 -54
  86. package/tests/fixtures/impress-detail-epub.html +0 -746
  87. package/tests/fixtures/impress-detail-social.html +0 -689
  88. package/tests/fixtures/lambdanote-search.html +0 -66
  89. package/tests/fixtures/manatee-detail.html +0 -53
  90. package/tests/fixtures/manatee-search.html +0 -59
  91. package/tests/fixtures/maruzen-detail.html +0 -51
  92. package/tests/fixtures/maruzen-search.html +0 -60
  93. package/tests/fixtures/openbd-response.json +0 -110
  94. package/tests/fixtures/optronics-detail.html +0 -30
  95. package/tests/fixtures/optronics-search.html +0 -75
  96. package/tests/fixtures/oreilly-detail.html +0 -52
  97. package/tests/fixtures/oreilly-ebook-list.html +0 -53
  98. package/tests/fixtures/peaks-detail.html +0 -39
  99. package/tests/fixtures/peaks-top.html +0 -50
  100. package/tests/fixtures/personal-media-detail.html +0 -32
  101. package/tests/fixtures/personal-media-search.html +0 -39
  102. package/tests/fixtures/rutles-detail.html +0 -32
  103. package/tests/fixtures/rutles-search.html +0 -62
  104. package/tests/fixtures/saiensu-detail.html +0 -41
  105. package/tests/fixtures/saiensu-search.html +0 -65
  106. package/tests/fixtures/seshop-detail.html +0 -45
  107. package/tests/fixtures/seshop-search.html +0 -58
  108. package/tests/fixtures/tatsu-zine-detail-free.html +0 -24
  109. package/tests/fixtures/tatsu-zine-search.html +0 -40
  110. package/tests/fixtures/techbookfest-search.json +0 -73
  111. package/tests/unit/adapters/base.test.ts +0 -441
  112. package/tests/unit/adapters/calil.test.ts +0 -69
  113. package/tests/unit/adapters/openbd.test.ts +0 -185
  114. package/tests/unit/adapters/publishers/book-tech.test.ts +0 -186
  115. package/tests/unit/adapters/publishers/born-digital.test.ts +0 -194
  116. package/tests/unit/adapters/publishers/coronasha.test.ts +0 -207
  117. package/tests/unit/adapters/publishers/gihyo.test.ts +0 -137
  118. package/tests/unit/adapters/publishers/impress.test.ts +0 -129
  119. package/tests/unit/adapters/publishers/lambdanote.test.ts +0 -85
  120. package/tests/unit/adapters/publishers/manatee.test.ts +0 -165
  121. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +0 -179
  122. package/tests/unit/adapters/publishers/optronics.test.ts +0 -208
  123. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +0 -194
  124. package/tests/unit/adapters/publishers/peaks.test.ts +0 -177
  125. package/tests/unit/adapters/publishers/personal-media.test.ts +0 -199
  126. package/tests/unit/adapters/publishers/rutles.test.ts +0 -173
  127. package/tests/unit/adapters/publishers/saiensu.test.ts +0 -169
  128. package/tests/unit/adapters/publishers/seshop.test.ts +0 -174
  129. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +0 -172
  130. package/tests/unit/adapters/publishers/techbookfest.test.ts +0 -94
  131. package/tests/unit/adapters/registry.test.ts +0 -37
  132. package/tests/unit/application/get-book-by-isbn.test.ts +0 -176
  133. package/tests/unit/application/get-book-detail.test.ts +0 -102
  134. package/tests/unit/application/search-books.test.ts +0 -137
  135. package/tsconfig.json +0 -17
@@ -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);
package/src/mcp/server.ts DELETED
@@ -1,113 +0,0 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import {
4
- CallToolRequestSchema,
5
- ListToolsRequestSchema,
6
- } from "@modelcontextprotocol/sdk/types.js";
7
- import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
8
- import type { BookRecord, EbookStore, DrmType, SearchQuery } from "../domain/book.js";
9
- import { searchBooks } from "../application/search-books.js";
10
- import { getBookDetail } from "../application/get-book-detail.js";
11
- import { getBookByIsbn } from "../application/get-book-by-isbn.js";
12
- import { TOOLS } from "./tools.js";
13
-
14
- // --- 出力フォーマット ---
15
-
16
- const DRM_LABELS: Record<DrmType, string> = {
17
- free: "DRMフリー",
18
- social: "DRMフリー (ソーシャル)",
19
- password_pdf: "パスワード付きPDF",
20
- drm: "DRM付き",
21
- };
22
-
23
- function formatEbookStore(store: EbookStore): Record<string, unknown> {
24
- return { ...store, drmLabel: DRM_LABELS[store.drm] };
25
- }
26
-
27
- function formatBook(book: BookRecord): Record<string, unknown> {
28
- if (!book.ebookStores) return book as unknown as Record<string, unknown>;
29
- return { ...book, ebookStores: book.ebookStores.map(formatEbookStore) };
30
- }
31
-
32
- export function createServer(
33
- publishers: readonly PublisherAdapter[],
34
- deps: PublisherDeps,
35
- ): Server {
36
- const server = new Server(
37
- {
38
- name: "@zonuexe/techbook-mcp",
39
- version: "0.1.0",
40
- },
41
- {
42
- capabilities: { tools: {} },
43
- },
44
- );
45
-
46
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
47
- tools: TOOLS,
48
- }));
49
-
50
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
51
- const { name, arguments: args = {} } = request.params;
52
-
53
- switch (name) {
54
- case "search_books": {
55
- const query: SearchQuery = {
56
- title: typeof args["title"] === "string" ? args["title"] : undefined,
57
- author: typeof args["author"] === "string" ? args["author"] : undefined,
58
- publisherId: typeof args["publisher"] === "string" ? args["publisher"] : undefined,
59
- limit: typeof args["limit"] === "number" ? Math.min(args["limit"], 50) : 10,
60
- };
61
- const { books, errors } = await searchBooks(query, publishers, deps);
62
- const output: Record<string, unknown> = { books: books.map(formatBook) };
63
- if (errors.length > 0) output["errors"] = errors;
64
- return {
65
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
66
- };
67
- }
68
-
69
- case "get_book_detail": {
70
- const url = args["url"];
71
- if (typeof url !== "string") throw new Error("url は必須です");
72
- const book = await getBookDetail(url, publishers, deps);
73
- return {
74
- content: [{ type: "text", text: JSON.stringify(formatBook(book), null, 2) }],
75
- };
76
- }
77
-
78
- case "list_publishers": {
79
- const list = publishers.map(p => ({
80
- id: p.id,
81
- name: p.name,
82
- baseUrl: p.baseUrl,
83
- }));
84
- return {
85
- content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
86
- };
87
- }
88
-
89
- case "get_book_by_isbn": {
90
- const isbn = args["isbn"];
91
- if (typeof isbn !== "string") throw new Error("isbn は必須です");
92
- const book = await getBookByIsbn(isbn, publishers, deps);
93
- return {
94
- content: [{ type: "text", text: JSON.stringify(formatBook(book), null, 2) }],
95
- };
96
- }
97
-
98
- default:
99
- throw new Error(`未知のツール: ${name}`);
100
- }
101
- });
102
-
103
- return server;
104
- }
105
-
106
- export async function startServer(
107
- publishers: readonly PublisherAdapter[],
108
- deps: PublisherDeps,
109
- ): Promise<void> {
110
- const server = createServer(publishers, deps);
111
- const transport = new StdioServerTransport();
112
- await server.connect(transport);
113
- }