@zonuexe/techbook-mcp 0.2.0 → 0.2.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.
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-04-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 達人出版会アダプターの検索が常に無関係な書籍を返す問題を修正(`?search=` パラメータがサーバーで無視されていたため、全書籍一覧からローカルフィルタリングする方式に変更)
|
|
15
|
+
|
|
10
16
|
## [0.2.0] - 2026-04-12
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -23,5 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
29
|
|
|
24
30
|
- テストを vitest から `node:test` + `node:assert` に移行(Node.js・Bun・Deno で共通実行可能に)
|
|
25
31
|
|
|
26
|
-
[Unreleased]: https://github.com/zonuexe/techbook-mcp/compare/v0.2.
|
|
32
|
+
[Unreleased]: https://github.com/zonuexe/techbook-mcp/compare/v0.2.1...HEAD
|
|
33
|
+
[0.2.1]: https://github.com/zonuexe/techbook-mcp/compare/v0.2.0...v0.2.1
|
|
27
34
|
[0.2.0]: https://github.com/zonuexe/techbook-mcp/releases/tag/v0.2.0
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
|
|
2
2
|
import type { BookRecord, SearchQuery, EbookStore } from "../../domain/book.js";
|
|
3
|
+
import type { HtmlDocument } from "../../ports/html-parser.js";
|
|
3
4
|
import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
|
|
4
5
|
|
|
5
6
|
const BASE_URL = "https://tatsu-zine.com";
|
|
@@ -15,6 +16,20 @@ function parseAuthors(text: string): string[] {
|
|
|
15
16
|
.filter(Boolean);
|
|
16
17
|
}
|
|
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
|
+
|
|
18
33
|
/**
|
|
19
34
|
* "3,300円 (3,000円+税)" → 3300
|
|
20
35
|
* 最初の数値が税込価格。
|
|
@@ -32,49 +47,59 @@ export const tatsuZineAdapter: PublisherAdapter = {
|
|
|
32
47
|
baseUrl: BASE_URL,
|
|
33
48
|
|
|
34
49
|
async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
// 検索APIがないため書籍一覧からローカルフィルタリングする
|
|
51
|
+
// 著者のみの検索は非対応
|
|
52
|
+
if (!query.title) return [];
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const doc = deps.parser.parse(html);
|
|
54
|
+
const titleKeyword = query.title.toLowerCase();
|
|
55
|
+
const authorKeyword = query.author?.toLowerCase();
|
|
56
|
+
const limit = query.limit ?? 10;
|
|
42
57
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
// タイトルリンクと著者段落を位置で対応付ける
|
|
49
|
-
const titleLinks = doc.select("h3 a[href]").filter(a => {
|
|
50
|
-
const href = a.attr("href") ?? "";
|
|
51
|
-
return href.startsWith("/books/") && !href.startsWith("/books/pub/");
|
|
52
|
-
});
|
|
53
|
-
const authorParagraphs = doc.select("h3 + p");
|
|
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);
|
|
54
62
|
|
|
55
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
|
+
}
|
|
56
71
|
|
|
57
|
-
for (
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
return results
|
|
102
|
+
return results;
|
|
78
103
|
},
|
|
79
104
|
|
|
80
105
|
async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
|
|
@@ -5,20 +5,36 @@
|
|
|
5
5
|
<title>書籍一覧 - 達人出版会</title>
|
|
6
6
|
</head>
|
|
7
7
|
<body>
|
|
8
|
-
<
|
|
9
|
-
<
|
|
8
|
+
<section class="booklist clear">
|
|
9
|
+
<article itemscope itemtype="http://schema.org/Book" class="book">
|
|
10
|
+
<a href="/books/go-programming"><img class="coversmall" itemprop="image" src="/images/books/123/cover_s.jpg" /></a>
|
|
11
|
+
<h3 itemprop="name"><a href="/books/go-programming">Goプログラミング実践入門</a></h3>
|
|
12
|
+
<p itemprop="author" class="author">Sau Sheong Chang(著), 武舎 広幸(訳)</p>
|
|
13
|
+
<p itemprop="publisher" class="publisher"><a href="/books/pub/impress">インプレス</a></p>
|
|
14
|
+
<p itemprop="offers" itemscope itemtype="http://schema.org/Offer" class="price">
|
|
15
|
+
<span itemprop="price">3,520円</span>
|
|
16
|
+
</p>
|
|
17
|
+
</article>
|
|
10
18
|
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
<p
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
</
|
|
20
|
-
<h3><a href="/books/go-concurrency">Go言語による並行処理</a></h3>
|
|
21
|
-
<p>Katherine Cox-Buday(著), 山口 能迪(訳)</p>
|
|
19
|
+
<article itemscope itemtype="http://schema.org/Book" class="book">
|
|
20
|
+
<a href="/books/go-concurrency"><img class="coversmall" itemprop="image" src="/images/books/456/cover_s.jpg" /></a>
|
|
21
|
+
<h3 itemprop="name"><a href="/books/go-concurrency">Go言語による並行処理</a></h3>
|
|
22
|
+
<p itemprop="author" class="author">Katherine Cox-Buday(著), 山口 能迪(訳)</p>
|
|
23
|
+
<p itemprop="publisher" class="publisher"><a href="/books/pub/oreilly-japan">オライリー・ジャパン</a></p>
|
|
24
|
+
<p itemprop="offers" itemscope itemtype="http://schema.org/Offer" class="price">
|
|
25
|
+
<span itemprop="price">3,740円</span>
|
|
26
|
+
</p>
|
|
27
|
+
</article>
|
|
22
28
|
|
|
29
|
+
<article itemscope itemtype="http://schema.org/Book" class="book">
|
|
30
|
+
<a href="/books/naruhounix"><img class="coversmall" itemprop="image" src="/images/books/789/cover_s.jpg" /></a>
|
|
31
|
+
<h3 itemprop="name"><a href="/books/naruhounix">なるほどUnixプロセス ― Rubyで学ぶUnixの基礎</a></h3>
|
|
32
|
+
<p itemprop="author" class="author">Jesse Storimer(著), 島田 浩二(訳), 角谷 信太郎(訳)</p>
|
|
33
|
+
<p itemprop="publisher" class="publisher"><a href="/books/pub/tatsu-zine">達人出版会</a></p>
|
|
34
|
+
<p itemprop="offers" itemscope itemtype="http://schema.org/Offer" class="price">
|
|
35
|
+
<span itemprop="price">2,200円</span>
|
|
36
|
+
</p>
|
|
37
|
+
</article>
|
|
38
|
+
</section>
|
|
23
39
|
</body>
|
|
24
40
|
</html>
|
|
@@ -63,7 +63,7 @@ describe("tatsuZineAdapter", () => {
|
|
|
63
63
|
assert.strictEqual(results.length, 1);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it("title
|
|
66
|
+
it("title が未指定の場合は [] を返しHTTPを呼ばない", async () => {
|
|
67
67
|
const http = new MockHttpClient();
|
|
68
68
|
const results = await tatsuZineAdapter.search({}, makeDeps(http));
|
|
69
69
|
|
|
@@ -71,7 +71,15 @@ describe("tatsuZineAdapter", () => {
|
|
|
71
71
|
assert.strictEqual(http.calls.length, 0);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("
|
|
74
|
+
it("author のみ指定の場合も [] を返しHTTPを呼ばない", async () => {
|
|
75
|
+
const http = new MockHttpClient();
|
|
76
|
+
const results = await tatsuZineAdapter.search({ author: "Jesse Storimer" }, makeDeps(http));
|
|
77
|
+
|
|
78
|
+
assert.deepStrictEqual(results, []);
|
|
79
|
+
assert.strictEqual(http.calls.length, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("書籍一覧ページ全体を取得してタイトルでフィルタする", async () => {
|
|
75
83
|
const body = await loadFixture("tatsu-zine-search.html");
|
|
76
84
|
const http = new MockHttpClient().addResponse(
|
|
77
85
|
"https://tatsu-zine.com/books/",
|
|
@@ -80,7 +88,40 @@ describe("tatsuZineAdapter", () => {
|
|
|
80
88
|
|
|
81
89
|
await tatsuZineAdapter.search({ title: "Go言語" }, makeDeps(http));
|
|
82
90
|
|
|
83
|
-
assert.
|
|
91
|
+
assert.strictEqual(http.calls[0], "https://tatsu-zine.com/books/");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ページネーションがある場合は全ページを取得してフィルタする", async () => {
|
|
95
|
+
const page1 = `<!DOCTYPE html><html><body>
|
|
96
|
+
<section class="pagination">
|
|
97
|
+
<nav class="pagination">
|
|
98
|
+
<a class="btn-pagination" href="/books?page=2">2</a>
|
|
99
|
+
<a class="btn-pagination" href="/books?page=2">最後へ</a>
|
|
100
|
+
</nav>
|
|
101
|
+
</section>
|
|
102
|
+
<article class="book">
|
|
103
|
+
<h3 itemprop="name"><a href="/books/page1-book">ページ1の本</a></h3>
|
|
104
|
+
<p itemprop="author" class="author">著者A(著)</p>
|
|
105
|
+
</article>
|
|
106
|
+
</body></html>`;
|
|
107
|
+
const page2 = `<!DOCTYPE html><html><body>
|
|
108
|
+
<article class="book">
|
|
109
|
+
<h3 itemprop="name"><a href="/books/naruhounix">なるほどUnixプロセス ― Rubyで学ぶUnixの基礎</a></h3>
|
|
110
|
+
<p itemprop="author" class="author">Jesse Storimer(著), 島田 浩二(訳), 角谷 信太郎(訳)</p>
|
|
111
|
+
</article>
|
|
112
|
+
</body></html>`;
|
|
113
|
+
const http = new MockHttpClient()
|
|
114
|
+
.addResponse("https://tatsu-zine.com/books/", { status: 200, body: page1 })
|
|
115
|
+
.addResponse("https://tatsu-zine.com/books?page=2", { status: 200, body: page2 });
|
|
116
|
+
|
|
117
|
+
const results = await tatsuZineAdapter.search({ title: "なるほどUnix" }, makeDeps(http));
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(results.length, 1);
|
|
120
|
+
assert.partialDeepStrictEqual(results[0], {
|
|
121
|
+
title: "なるほどUnixプロセス ― Rubyで学ぶUnixの基礎",
|
|
122
|
+
authors: ["Jesse Storimer", "島田 浩二", "角谷 信太郎"],
|
|
123
|
+
url: "https://tatsu-zine.com/books/naruhounix",
|
|
124
|
+
});
|
|
84
125
|
});
|
|
85
126
|
});
|
|
86
127
|
|