@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.
- package/.claude/settings.local.json +23 -0
- package/.github/workflows/test.yml +36 -0
- package/AGENTS.md +72 -0
- package/CLAUDE.md +2 -0
- package/LICENSE +661 -0
- package/README.md +154 -0
- package/dist/adapters/cache/memory-cache.d.ts +8 -0
- package/dist/adapters/cache/memory-cache.d.ts.map +1 -0
- package/dist/adapters/cache/memory-cache.js +23 -0
- package/dist/adapters/cache/memory-cache.js.map +1 -0
- package/dist/adapters/cache/null-cache.d.ts +8 -0
- package/dist/adapters/cache/null-cache.d.ts.map +1 -0
- package/dist/adapters/cache/null-cache.js +7 -0
- package/dist/adapters/cache/null-cache.js.map +1 -0
- package/dist/adapters/html/cheerio-parser.d.ts +5 -0
- package/dist/adapters/html/cheerio-parser.d.ts.map +1 -0
- package/dist/adapters/html/cheerio-parser.js +45 -0
- package/dist/adapters/html/cheerio-parser.js.map +1 -0
- package/dist/adapters/http/fetch-client.d.ts +6 -0
- package/dist/adapters/http/fetch-client.d.ts.map +1 -0
- package/dist/adapters/http/fetch-client.js +43 -0
- package/dist/adapters/http/fetch-client.js.map +1 -0
- package/dist/adapters/http/mock-client.d.ts +19 -0
- package/dist/adapters/http/mock-client.d.ts.map +1 -0
- package/dist/adapters/http/mock-client.js +59 -0
- package/dist/adapters/http/mock-client.js.map +1 -0
- package/dist/adapters/publishers/base.d.ts +24 -0
- package/dist/adapters/publishers/base.d.ts.map +1 -0
- package/dist/adapters/publishers/base.js +88 -0
- package/dist/adapters/publishers/base.js.map +1 -0
- package/dist/adapters/publishers/gihyo.d.ts +3 -0
- package/dist/adapters/publishers/gihyo.d.ts.map +1 -0
- package/dist/adapters/publishers/gihyo.js +75 -0
- package/dist/adapters/publishers/gihyo.js.map +1 -0
- package/dist/adapters/publishers/lambdanote.d.ts +3 -0
- package/dist/adapters/publishers/lambdanote.d.ts.map +1 -0
- package/dist/adapters/publishers/lambdanote.js +113 -0
- package/dist/adapters/publishers/lambdanote.js.map +1 -0
- package/dist/adapters/publishers/registry.d.ts +3 -0
- package/dist/adapters/publishers/registry.d.ts.map +1 -0
- package/dist/adapters/publishers/registry.js +11 -0
- package/dist/adapters/publishers/registry.js.map +1 -0
- package/dist/adapters/publishers/tatsu-zine.d.ts +3 -0
- package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -0
- package/dist/adapters/publishers/tatsu-zine.js +110 -0
- package/dist/adapters/publishers/tatsu-zine.js.map +1 -0
- package/dist/adapters/publishers/techbookfest.d.ts +3 -0
- package/dist/adapters/publishers/techbookfest.d.ts.map +1 -0
- package/dist/adapters/publishers/techbookfest.js +134 -0
- package/dist/adapters/publishers/techbookfest.js.map +1 -0
- package/dist/application/get-book-detail.d.ts +4 -0
- package/dist/application/get-book-detail.d.ts.map +1 -0
- package/dist/application/get-book-detail.js +9 -0
- package/dist/application/get-book-detail.js.map +1 -0
- package/dist/application/search-books.d.ts +11 -0
- package/dist/application/search-books.d.ts.map +1 -0
- package/dist/application/search-books.js +23 -0
- package/dist/application/search-books.js.map +1 -0
- package/dist/domain/book.d.ts +32 -0
- package/dist/domain/book.d.ts.map +1 -0
- package/dist/domain/book.js +2 -0
- package/dist/domain/book.js.map +1 -0
- package/dist/domain/publisher.d.ts +17 -0
- package/dist/domain/publisher.d.ts.map +1 -0
- package/dist/domain/publisher.js +2 -0
- package/dist/domain/publisher.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +12 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +79 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +47 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +53 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/ports/cache.d.ts +6 -0
- package/dist/ports/cache.d.ts.map +1 -0
- package/dist/ports/cache.js +2 -0
- package/dist/ports/cache.js.map +1 -0
- package/dist/ports/html-parser.d.ts +14 -0
- package/dist/ports/html-parser.d.ts.map +1 -0
- package/dist/ports/html-parser.js +2 -0
- package/dist/ports/html-parser.js.map +1 -0
- package/dist/ports/http.d.ts +16 -0
- package/dist/ports/http.d.ts.map +1 -0
- package/dist/ports/http.js +2 -0
- package/dist/ports/http.js.map +1 -0
- package/docs/design-doc.md +365 -0
- package/flake.nix +50 -0
- package/package.json +29 -0
- package/src/adapters/cache/memory-cache.ts +31 -0
- package/src/adapters/cache/null-cache.ts +8 -0
- package/src/adapters/html/cheerio-parser.ts +49 -0
- package/src/adapters/http/fetch-client.ts +47 -0
- package/src/adapters/http/mock-client.ts +77 -0
- package/src/adapters/publishers/base.ts +129 -0
- package/src/adapters/publishers/book-tech.ts +117 -0
- package/src/adapters/publishers/born-digital.ts +158 -0
- package/src/adapters/publishers/coronasha.ts +139 -0
- package/src/adapters/publishers/gihyo.ts +120 -0
- package/src/adapters/publishers/lambdanote.ts +146 -0
- package/src/adapters/publishers/manatee.ts +112 -0
- package/src/adapters/publishers/maruzen-publishing.ts +141 -0
- package/src/adapters/publishers/optronics.ts +113 -0
- package/src/adapters/publishers/oreilly-japan.ts +138 -0
- package/src/adapters/publishers/peaks.ts +98 -0
- package/src/adapters/publishers/personal-media.ts +168 -0
- package/src/adapters/publishers/registry.ts +36 -0
- package/src/adapters/publishers/rutles.ts +161 -0
- package/src/adapters/publishers/saiensu.ts +149 -0
- package/src/adapters/publishers/seshop.ts +121 -0
- package/src/adapters/publishers/tatsu-zine.ts +129 -0
- package/src/adapters/publishers/techbookfest.ts +179 -0
- package/src/application/get-book-detail.ts +17 -0
- package/src/application/search-books.ts +39 -0
- package/src/domain/book.ts +35 -0
- package/src/domain/publisher.ts +18 -0
- package/src/main.ts +13 -0
- package/src/mcp/server.ts +103 -0
- package/src/mcp/tools.ts +54 -0
- package/src/ports/cache.ts +5 -0
- package/src/ports/html-parser.ts +15 -0
- package/src/ports/http.ts +17 -0
- package/tests/fixtures/book-tech-detail.html +51 -0
- package/tests/fixtures/book-tech-search.html +91 -0
- package/tests/fixtures/born-digital-detail.html +62 -0
- package/tests/fixtures/born-digital-search.html +51 -0
- package/tests/fixtures/coronasha-detail.html +41 -0
- package/tests/fixtures/coronasha-search.html +61 -0
- package/tests/fixtures/gihyo-detail.html +42 -0
- package/tests/fixtures/gihyo-search.json +54 -0
- package/tests/fixtures/lambdanote-search.html +66 -0
- package/tests/fixtures/manatee-detail.html +53 -0
- package/tests/fixtures/manatee-search.html +59 -0
- package/tests/fixtures/maruzen-detail.html +51 -0
- package/tests/fixtures/maruzen-search.html +60 -0
- package/tests/fixtures/optronics-detail.html +30 -0
- package/tests/fixtures/optronics-search.html +75 -0
- package/tests/fixtures/oreilly-detail.html +52 -0
- package/tests/fixtures/oreilly-ebook-list.html +53 -0
- package/tests/fixtures/peaks-detail.html +39 -0
- package/tests/fixtures/peaks-top.html +50 -0
- package/tests/fixtures/personal-media-detail.html +32 -0
- package/tests/fixtures/personal-media-search.html +39 -0
- package/tests/fixtures/rutles-detail.html +32 -0
- package/tests/fixtures/rutles-search.html +62 -0
- package/tests/fixtures/saiensu-detail.html +41 -0
- package/tests/fixtures/saiensu-search.html +65 -0
- package/tests/fixtures/seshop-detail.html +45 -0
- package/tests/fixtures/seshop-search.html +58 -0
- package/tests/fixtures/tatsu-zine-detail-free.html +22 -0
- package/tests/fixtures/tatsu-zine-search.html +24 -0
- package/tests/fixtures/techbookfest-search.json +73 -0
- package/tests/unit/adapters/publishers/book-tech.test.ts +183 -0
- package/tests/unit/adapters/publishers/born-digital.test.ts +191 -0
- package/tests/unit/adapters/publishers/coronasha.test.ts +201 -0
- package/tests/unit/adapters/publishers/gihyo.test.ts +135 -0
- package/tests/unit/adapters/publishers/lambdanote.test.ts +84 -0
- package/tests/unit/adapters/publishers/manatee.test.ts +163 -0
- package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +177 -0
- package/tests/unit/adapters/publishers/optronics.test.ts +205 -0
- package/tests/unit/adapters/publishers/oreilly-japan.test.ts +191 -0
- package/tests/unit/adapters/publishers/peaks.test.ts +174 -0
- package/tests/unit/adapters/publishers/personal-media.test.ts +196 -0
- package/tests/unit/adapters/publishers/rutles.test.ts +170 -0
- package/tests/unit/adapters/publishers/saiensu.test.ts +167 -0
- package/tests/unit/adapters/publishers/seshop.test.ts +171 -0
- package/tests/unit/adapters/publishers/tatsu-zine.test.ts +130 -0
- package/tests/unit/adapters/publishers/techbookfest.test.ts +93 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BookRecord } from "../domain/book.js";
|
|
2
|
+
import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
|
|
3
|
+
|
|
4
|
+
export async function getBookDetail(
|
|
5
|
+
url: string,
|
|
6
|
+
publishers: readonly PublisherAdapter[],
|
|
7
|
+
deps: PublisherDeps,
|
|
8
|
+
): Promise<BookRecord> {
|
|
9
|
+
const publisher = publishers.find(p => url.startsWith(p.baseUrl));
|
|
10
|
+
if (!publisher) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`このURLに対応する出版社アダプターがありません: ${url}\n` +
|
|
13
|
+
`対応URL: ${publishers.map(p => p.baseUrl).join(", ")}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return publisher.getDetail(url, deps);
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { BookRecord, SearchQuery } from "../domain/book.js";
|
|
2
|
+
import type { PublisherAdapter, PublisherDeps } from "../domain/publisher.js";
|
|
3
|
+
|
|
4
|
+
export interface SearchBooksResult {
|
|
5
|
+
books: BookRecord[];
|
|
6
|
+
errors: Array<{ publisherId: string; message: string }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function searchBooks(
|
|
10
|
+
query: SearchQuery,
|
|
11
|
+
publishers: readonly PublisherAdapter[],
|
|
12
|
+
deps: PublisherDeps,
|
|
13
|
+
): Promise<SearchBooksResult> {
|
|
14
|
+
const targets = query.publisherId
|
|
15
|
+
? publishers.filter(p => p.id === query.publisherId)
|
|
16
|
+
: publishers;
|
|
17
|
+
|
|
18
|
+
const results = await Promise.allSettled(
|
|
19
|
+
targets.map(p => p.search(query, deps)),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const books: BookRecord[] = [];
|
|
23
|
+
const errors: Array<{ publisherId: string; message: string }> = [];
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < results.length; i++) {
|
|
26
|
+
const result = results[i];
|
|
27
|
+
const publisher = targets[i];
|
|
28
|
+
if (result.status === "fulfilled") {
|
|
29
|
+
books.push(...result.value);
|
|
30
|
+
} else {
|
|
31
|
+
const message = result.reason instanceof Error
|
|
32
|
+
? result.reason.message
|
|
33
|
+
: String(result.reason);
|
|
34
|
+
errors.push({ publisherId: publisher.id, message });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { books, errors };
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { startServer } from "./mcp/server.js";
|
|
2
|
+
import { DEFAULT_PUBLISHERS } from "./adapters/publishers/registry.js";
|
|
3
|
+
import { FetchHttpClient } from "./adapters/http/fetch-client.js";
|
|
4
|
+
import { CheerioHtmlParser } from "./adapters/html/cheerio-parser.js";
|
|
5
|
+
import { MemoryCacheStore } from "./adapters/cache/memory-cache.js";
|
|
6
|
+
|
|
7
|
+
const deps = {
|
|
8
|
+
http: new FetchHttpClient(),
|
|
9
|
+
parser: new CheerioHtmlParser(),
|
|
10
|
+
cache: new MemoryCacheStore(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
await startServer(DEFAULT_PUBLISHERS, deps);
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { TOOLS } from "./tools.js";
|
|
12
|
+
|
|
13
|
+
// --- 出力フォーマット ---
|
|
14
|
+
|
|
15
|
+
const DRM_LABELS: Record<DrmType, string> = {
|
|
16
|
+
free: "DRMフリー",
|
|
17
|
+
social: "DRMフリー (ソーシャル)",
|
|
18
|
+
password_pdf: "パスワード付きPDF",
|
|
19
|
+
drm: "DRM付き",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function formatEbookStore(store: EbookStore): Record<string, unknown> {
|
|
23
|
+
return { ...store, drmLabel: DRM_LABELS[store.drm] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatBook(book: BookRecord): Record<string, unknown> {
|
|
27
|
+
if (!book.ebookStores) return book as unknown as Record<string, unknown>;
|
|
28
|
+
return { ...book, ebookStores: book.ebookStores.map(formatEbookStore) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createServer(
|
|
32
|
+
publishers: readonly PublisherAdapter[],
|
|
33
|
+
deps: PublisherDeps,
|
|
34
|
+
): Server {
|
|
35
|
+
const server = new Server(
|
|
36
|
+
{
|
|
37
|
+
name: "@zonuexe/techbook-mcp",
|
|
38
|
+
version: "0.1.0",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
capabilities: { tools: {} },
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
46
|
+
tools: TOOLS,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
50
|
+
const { name, arguments: args = {} } = request.params;
|
|
51
|
+
|
|
52
|
+
switch (name) {
|
|
53
|
+
case "search_books": {
|
|
54
|
+
const query: SearchQuery = {
|
|
55
|
+
title: typeof args["title"] === "string" ? args["title"] : undefined,
|
|
56
|
+
author: typeof args["author"] === "string" ? args["author"] : undefined,
|
|
57
|
+
publisherId: typeof args["publisher"] === "string" ? args["publisher"] : undefined,
|
|
58
|
+
limit: typeof args["limit"] === "number" ? Math.min(args["limit"], 50) : 10,
|
|
59
|
+
};
|
|
60
|
+
const { books, errors } = await searchBooks(query, publishers, deps);
|
|
61
|
+
const output: Record<string, unknown> = { books: books.map(formatBook) };
|
|
62
|
+
if (errors.length > 0) output["errors"] = errors;
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case "get_book_detail": {
|
|
69
|
+
const url = args["url"];
|
|
70
|
+
if (typeof url !== "string") throw new Error("url は必須です");
|
|
71
|
+
const book = await getBookDetail(url, publishers, deps);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: JSON.stringify(formatBook(book), null, 2) }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case "list_publishers": {
|
|
78
|
+
const list = publishers.map(p => ({
|
|
79
|
+
id: p.id,
|
|
80
|
+
name: p.name,
|
|
81
|
+
baseUrl: p.baseUrl,
|
|
82
|
+
}));
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`未知のツール: ${name}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return server;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function startServer(
|
|
97
|
+
publishers: readonly PublisherAdapter[],
|
|
98
|
+
deps: PublisherDeps,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const server = createServer(publishers, deps);
|
|
101
|
+
const transport = new StdioServerTransport();
|
|
102
|
+
await server.connect(transport);
|
|
103
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const TOOLS = [
|
|
2
|
+
{
|
|
3
|
+
name: "search_books",
|
|
4
|
+
description:
|
|
5
|
+
"書名・著者名から日本語技術書を検索し、書誌情報の一覧を返します。" +
|
|
6
|
+
"複数の出版社を横断して検索します。",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
title: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "書名(部分一致)",
|
|
13
|
+
},
|
|
14
|
+
author: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "著者名(部分一致)",
|
|
17
|
+
},
|
|
18
|
+
publisher: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description:
|
|
21
|
+
"出版社IDで検索対象を絞り込みます。指定しない場合は全出版社を検索します。" +
|
|
22
|
+
"利用可能なIDは list_publishers で確認できます。",
|
|
23
|
+
},
|
|
24
|
+
limit: {
|
|
25
|
+
type: "number",
|
|
26
|
+
description: "1出版社あたりの最大取得件数(デフォルト: 10、最大: 50)",
|
|
27
|
+
default: 10,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "get_book_detail",
|
|
34
|
+
description: "書籍の公式ページURLから詳細な書誌情報を取得します。",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: "object",
|
|
37
|
+
required: ["url"],
|
|
38
|
+
properties: {
|
|
39
|
+
url: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "書籍の公式ページURL(出版社サイトのURL)",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "list_publishers",
|
|
48
|
+
description: "対応している出版社の一覧とIDを返します。",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
] as const;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface HtmlElement {
|
|
2
|
+
text(): string;
|
|
3
|
+
html(): string | null;
|
|
4
|
+
attr(name: string): string | undefined;
|
|
5
|
+
find(selector: string): HtmlElement[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HtmlDocument {
|
|
9
|
+
select(selector: string): HtmlElement[];
|
|
10
|
+
selectOne(selector: string): HtmlElement | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HtmlParser {
|
|
14
|
+
parse(html: string): HtmlDocument;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface RequestOptions {
|
|
2
|
+
headers?: Record<string, string>;
|
|
3
|
+
timeout?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface HttpResponse {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly url: string;
|
|
9
|
+
text(): Promise<string>;
|
|
10
|
+
/** ヘッダー値を取得する。複数値は `, ` 結合。存在しない場合は null。 */
|
|
11
|
+
header(name: string): string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HttpClient {
|
|
15
|
+
get(url: string, options?: RequestOptions): Promise<HttpResponse>;
|
|
16
|
+
post(url: string, body: string, options?: RequestOptions): Promise<HttpResponse>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head><meta charset="utf-8"><title>BOOK TECH | 次のステップへ!React実践開発 サクサク作って学ぶ UI/テスト/デプロイ</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<div class="contents-base">
|
|
6
|
+
<div class="contents-book">
|
|
7
|
+
<div class="contents-book-inner">
|
|
8
|
+
<div class="contents-book-item">
|
|
9
|
+
<div class="contents-book-item-detail-wrap">
|
|
10
|
+
<div class="contents-book-item-detail-thumb">
|
|
11
|
+
<img src="https://booktech-share.s3-ap-northeast-1.amazonaws.com/books/d80ffe3d.webp" class="thumb" alt="次のステップへ!React実践開発 サクサク作って学ぶ UI/テスト/デプロイ">
|
|
12
|
+
</div>
|
|
13
|
+
<div class="contents-book-item-detail-price_include_tax">
|
|
14
|
+
2,178円<span class="tax">(税込)</span>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="contents-book-about">
|
|
20
|
+
<div class="contents-book-about-title">
|
|
21
|
+
<h1>次のステップへ!React実践開発 サクサク作って学ぶ UI/テスト/デプロイ</h1>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="contents-book-about-publicationdate my-1" style="font-size: 0.85em;">
|
|
25
|
+
発売日:
|
|
26
|
+
2026/2/20
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="d-flex flex-wrap align-items-center gap-1">
|
|
30
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bpublisher_relations_publisher_id_in%5D%5B%5D=4">
|
|
31
|
+
<i class="fas fa-building me-1"></i>インプレス NextPublishing
|
|
32
|
+
</a>
|
|
33
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bauthor_relations_author_id_in%5D%5B%5D=2205">
|
|
34
|
+
<i class="fas fa-user me-1"></i>philosophy (著)
|
|
35
|
+
</a>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="contents-book-about-id" style="font-size: 0.85em;">
|
|
39
|
+
ISBN:
|
|
40
|
+
9784295604136
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="contents-book-about-description scroll">
|
|
44
|
+
TypeScriptによる型安全な開発、各種ホスティングサービスへの公開方法などを解説します。
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head><meta charset="utf-8"><title>BOOK TECH | 「TypeScript」の検索結果</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<div class="contents-base">
|
|
6
|
+
<div class="contents-index">
|
|
7
|
+
<div class="contents-index-wrap">
|
|
8
|
+
|
|
9
|
+
<div class="contents-index-item">
|
|
10
|
+
<div class="contents-index-item-lang">
|
|
11
|
+
<a class="contents-index-item-large-lang-wrap" href="/books?q%5Bbook_category_in%5D%5B%5D=%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF%E3%83%BBIT">コンピュータ・IT</a>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="contents-index-item-detail-wrap">
|
|
14
|
+
<div class="contents-index-item-detail">
|
|
15
|
+
<div class="contents-index-item-detail-thumb">
|
|
16
|
+
<a class="book-ribbon-link" href="/books/d80ffe3d-f3fe-458b-95ee-b4dd3327fab2">
|
|
17
|
+
<img src="https://booktech-share.s3-ap-northeast-1.amazonaws.com/books/d80ffe3d.webp" class="thumb" alt="次のステップへ!React実践開発 サクサク作って学ぶ UI/テスト/デプロイ">
|
|
18
|
+
</a>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="contents-index-item-detail">
|
|
22
|
+
<div class="contents-index-item-detail-title">
|
|
23
|
+
次のステップへ!React実践開発 サクサク作って学ぶ UI/テスト/デプロイ
|
|
24
|
+
</div>
|
|
25
|
+
<div class="my-1" style="font-size: 0.85em;">
|
|
26
|
+
発売日: 2026/2/20
|
|
27
|
+
</div>
|
|
28
|
+
<div class="d-none d-lg-block d-flex flex-wrap align-items-center gap-1">
|
|
29
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bpublisher_relations_publisher_id_in%5D%5B%5D=4">
|
|
30
|
+
<i class="fas fa-building me-1"></i>インプレス NextPublishing
|
|
31
|
+
</a>
|
|
32
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bauthor_relations_author_id_in%5D%5B%5D=2205">
|
|
33
|
+
<i class="fas fa-user me-1"></i>philosophy (著)
|
|
34
|
+
</a>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="contents-index-item-detail-description scroll">
|
|
37
|
+
TypeScriptによる型安全な開発を解説します。
|
|
38
|
+
</div>
|
|
39
|
+
<div class="contents-index-item-detail-price_include_tax">
|
|
40
|
+
<div>
|
|
41
|
+
2,178円<span class="tax">(税込)</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="contents-index-item">
|
|
49
|
+
<div class="contents-index-item-lang">
|
|
50
|
+
<a class="contents-index-item-large-lang-wrap" href="/books?q%5Bbook_category_in%5D%5B%5D=%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF%E3%83%BBIT">コンピュータ・IT</a>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="contents-index-item-detail-wrap">
|
|
53
|
+
<div class="contents-index-item-detail">
|
|
54
|
+
<div class="contents-index-item-detail-thumb">
|
|
55
|
+
<a class="book-ribbon-link" href="/books/a52f6467-5d0c-4d76-9351-19a0ab76eb96">
|
|
56
|
+
<img src="https://booktech-share.s3-ap-northeast-1.amazonaws.com/books/a52f6467.webp" class="thumb" alt="React環境構築の教科書">
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="contents-index-item-detail">
|
|
61
|
+
<div class="contents-index-item-detail-title">
|
|
62
|
+
React環境構築の教科書
|
|
63
|
+
</div>
|
|
64
|
+
<div class="my-1" style="font-size: 0.85em;">
|
|
65
|
+
発売日: 2020/8/21
|
|
66
|
+
</div>
|
|
67
|
+
<div class="d-none d-lg-block d-flex flex-wrap align-items-center gap-1">
|
|
68
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bpublisher_relations_publisher_id_in%5D%5B%5D=4">
|
|
69
|
+
<i class="fas fa-building me-1"></i>インプレス NextPublishing
|
|
70
|
+
</a>
|
|
71
|
+
<a class="badge bg-light text-dark border" href="/books?q%5Bauthor_relations_author_id_in%5D%5B%5D=107">
|
|
72
|
+
<i class="fas fa-user me-1"></i>井手 優太 (著)
|
|
73
|
+
</a>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="contents-index-item-detail-description scroll">
|
|
76
|
+
TypeScriptを使ったコンパイル環境を解説します。
|
|
77
|
+
</div>
|
|
78
|
+
<div class="contents-index-item-detail-price_include_tax">
|
|
79
|
+
<div>
|
|
80
|
+
3,300円<span class="tax">(税込)</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|