@zonuexe/techbook-mcp 0.2.2 → 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 (151) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +39 -20
  3. package/dist/adapters/calil.d.ts +10 -0
  4. package/dist/adapters/calil.d.ts.map +1 -0
  5. package/dist/adapters/calil.js +45 -0
  6. package/dist/adapters/calil.js.map +1 -0
  7. package/dist/adapters/openbd.d.ts +57 -0
  8. package/dist/adapters/openbd.d.ts.map +1 -0
  9. package/dist/adapters/openbd.js +87 -0
  10. package/dist/adapters/openbd.js.map +1 -0
  11. package/dist/adapters/publishers/google-books.d.ts +4 -0
  12. package/dist/adapters/publishers/google-books.d.ts.map +1 -0
  13. package/dist/adapters/publishers/google-books.js +75 -0
  14. package/dist/adapters/publishers/google-books.js.map +1 -0
  15. package/dist/adapters/publishers/isbn-publisher-codes.d.ts +21 -0
  16. package/dist/adapters/publishers/isbn-publisher-codes.d.ts.map +1 -0
  17. package/dist/adapters/publishers/isbn-publisher-codes.js +49 -0
  18. package/dist/adapters/publishers/isbn-publisher-codes.js.map +1 -0
  19. package/dist/adapters/publishers/juse-p.d.ts +3 -0
  20. package/dist/adapters/publishers/juse-p.d.ts.map +1 -0
  21. package/dist/adapters/publishers/juse-p.js +110 -0
  22. package/dist/adapters/publishers/juse-p.js.map +1 -0
  23. package/dist/adapters/publishers/registry.d.ts.map +1 -1
  24. package/dist/adapters/publishers/registry.js +4 -0
  25. package/dist/adapters/publishers/registry.js.map +1 -1
  26. package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -1
  27. package/dist/adapters/publishers/tatsu-zine.js +6 -18
  28. package/dist/adapters/publishers/tatsu-zine.js.map +1 -1
  29. package/dist/application/get-book-by-isbn.d.ts +13 -0
  30. package/dist/application/get-book-by-isbn.d.ts.map +1 -0
  31. package/dist/application/get-book-by-isbn.js +61 -0
  32. package/dist/application/get-book-by-isbn.js.map +1 -0
  33. package/dist/application/get-book-detail.d.ts.map +1 -1
  34. package/dist/application/get-book-detail.js +16 -1
  35. package/dist/application/get-book-detail.js.map +1 -1
  36. package/dist/application/search-books.d.ts.map +1 -1
  37. package/dist/application/search-books.js +20 -0
  38. package/dist/application/search-books.js.map +1 -1
  39. package/dist/config/credentials.d.ts +8 -0
  40. package/dist/config/credentials.d.ts.map +1 -0
  41. package/dist/config/credentials.js +32 -0
  42. package/dist/config/credentials.js.map +1 -0
  43. package/dist/main.js +15 -1
  44. package/dist/main.js.map +1 -1
  45. package/dist/mcp/server.d.ts.map +1 -1
  46. package/dist/mcp/server.js +10 -0
  47. package/dist/mcp/server.js.map +1 -1
  48. package/dist/mcp/tools.d.ts +13 -0
  49. package/dist/mcp/tools.d.ts.map +1 -1
  50. package/dist/mcp/tools.js +16 -0
  51. package/dist/mcp/tools.js.map +1 -1
  52. package/dist/setup.d.ts +2 -0
  53. package/dist/setup.d.ts.map +1 -0
  54. package/dist/setup.js +43 -0
  55. package/dist/setup.js.map +1 -0
  56. package/flake.lock +61 -0
  57. package/package.json +1 -1
  58. package/.claude/settings.local.json +0 -36
  59. package/.codex/skills/techbook-mcp-release-prep/SKILL.md +0 -105
  60. package/.github/workflows/test.yml +0 -72
  61. package/.oxlintrc.json +0 -12
  62. package/AGENTS.md +0 -100
  63. package/deno.json +0 -3
  64. package/src/adapters/cache/memory-cache.ts +0 -31
  65. package/src/adapters/cache/null-cache.ts +0 -8
  66. package/src/adapters/html/cheerio-parser.ts +0 -50
  67. package/src/adapters/http/fetch-client.ts +0 -47
  68. package/src/adapters/http/mock-client.ts +0 -77
  69. package/src/adapters/publishers/base.ts +0 -279
  70. package/src/adapters/publishers/book-tech.ts +0 -117
  71. package/src/adapters/publishers/born-digital.ts +0 -143
  72. package/src/adapters/publishers/coronasha.ts +0 -139
  73. package/src/adapters/publishers/gihyo.ts +0 -120
  74. package/src/adapters/publishers/impress.ts +0 -103
  75. package/src/adapters/publishers/lambdanote.ts +0 -146
  76. package/src/adapters/publishers/manatee.ts +0 -113
  77. package/src/adapters/publishers/maruzen-publishing.ts +0 -129
  78. package/src/adapters/publishers/optronics.ts +0 -113
  79. package/src/adapters/publishers/oreilly-japan.ts +0 -133
  80. package/src/adapters/publishers/peaks.ts +0 -98
  81. package/src/adapters/publishers/personal-media.ts +0 -168
  82. package/src/adapters/publishers/registry.ts +0 -38
  83. package/src/adapters/publishers/rutles.ts +0 -149
  84. package/src/adapters/publishers/saiensu.ts +0 -136
  85. package/src/adapters/publishers/seshop.ts +0 -121
  86. package/src/adapters/publishers/tatsu-zine.ts +0 -154
  87. package/src/adapters/publishers/techbookfest.ts +0 -179
  88. package/src/application/get-book-detail.ts +0 -24
  89. package/src/application/search-books.ts +0 -44
  90. package/src/domain/book.ts +0 -35
  91. package/src/domain/publisher.ts +0 -18
  92. package/src/main.ts +0 -14
  93. package/src/mcp/server.ts +0 -103
  94. package/src/mcp/tools.ts +0 -54
  95. package/src/ports/cache.ts +0 -5
  96. package/src/ports/html-parser.ts +0 -15
  97. package/src/ports/http.ts +0 -17
  98. package/tests/fixtures/book-tech-detail.html +0 -51
  99. package/tests/fixtures/book-tech-search.html +0 -91
  100. package/tests/fixtures/born-digital-detail.html +0 -62
  101. package/tests/fixtures/born-digital-search.html +0 -51
  102. package/tests/fixtures/coronasha-detail.html +0 -41
  103. package/tests/fixtures/coronasha-search.html +0 -61
  104. package/tests/fixtures/gihyo-detail.html +0 -42
  105. package/tests/fixtures/gihyo-search.json +0 -54
  106. package/tests/fixtures/impress-detail-epub.html +0 -746
  107. package/tests/fixtures/impress-detail-social.html +0 -689
  108. package/tests/fixtures/lambdanote-search.html +0 -66
  109. package/tests/fixtures/manatee-detail.html +0 -53
  110. package/tests/fixtures/manatee-search.html +0 -59
  111. package/tests/fixtures/maruzen-detail.html +0 -51
  112. package/tests/fixtures/maruzen-search.html +0 -60
  113. package/tests/fixtures/optronics-detail.html +0 -30
  114. package/tests/fixtures/optronics-search.html +0 -75
  115. package/tests/fixtures/oreilly-detail.html +0 -52
  116. package/tests/fixtures/oreilly-ebook-list.html +0 -53
  117. package/tests/fixtures/peaks-detail.html +0 -39
  118. package/tests/fixtures/peaks-top.html +0 -50
  119. package/tests/fixtures/personal-media-detail.html +0 -32
  120. package/tests/fixtures/personal-media-search.html +0 -39
  121. package/tests/fixtures/rutles-detail.html +0 -32
  122. package/tests/fixtures/rutles-search.html +0 -62
  123. package/tests/fixtures/saiensu-detail.html +0 -41
  124. package/tests/fixtures/saiensu-search.html +0 -65
  125. package/tests/fixtures/seshop-detail.html +0 -45
  126. package/tests/fixtures/seshop-search.html +0 -58
  127. package/tests/fixtures/tatsu-zine-detail-free.html +0 -22
  128. package/tests/fixtures/tatsu-zine-search.html +0 -40
  129. package/tests/fixtures/techbookfest-search.json +0 -73
  130. package/tests/unit/adapters/base.test.ts +0 -441
  131. package/tests/unit/adapters/publishers/book-tech.test.ts +0 -186
  132. package/tests/unit/adapters/publishers/born-digital.test.ts +0 -194
  133. package/tests/unit/adapters/publishers/coronasha.test.ts +0 -207
  134. package/tests/unit/adapters/publishers/gihyo.test.ts +0 -137
  135. package/tests/unit/adapters/publishers/impress.test.ts +0 -129
  136. package/tests/unit/adapters/publishers/lambdanote.test.ts +0 -85
  137. package/tests/unit/adapters/publishers/manatee.test.ts +0 -165
  138. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +0 -179
  139. package/tests/unit/adapters/publishers/optronics.test.ts +0 -208
  140. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +0 -194
  141. package/tests/unit/adapters/publishers/peaks.test.ts +0 -177
  142. package/tests/unit/adapters/publishers/personal-media.test.ts +0 -199
  143. package/tests/unit/adapters/publishers/rutles.test.ts +0 -173
  144. package/tests/unit/adapters/publishers/saiensu.test.ts +0 -169
  145. package/tests/unit/adapters/publishers/seshop.test.ts +0 -174
  146. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +0 -172
  147. package/tests/unit/adapters/publishers/techbookfest.test.ts +0 -94
  148. package/tests/unit/adapters/registry.test.ts +0 -37
  149. package/tests/unit/application/get-book-detail.test.ts +0 -102
  150. package/tests/unit/application/search-books.test.ts +0 -137
  151. package/tsconfig.json +0 -17
@@ -1,149 +0,0 @@
1
- import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
- import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
- import { fetchText, parseJapanesePrice, encodeEucJp } from "./base.js";
4
-
5
- const BASE_URL = "https://shop.rutles.net";
6
-
7
- /**
8
- * "著者: 大槻有一郎:著 山田巧(DXライブラリ管理人):監修<br />"
9
- * から著者名のリストを取得する。
10
- * - 役割語(:著 / :訳 / :監修 など ":"以降)を除去
11
- * - 所属(括弧内)を除去
12
- * - 全角スペースで複数著者を分割
13
- */
14
- function parseAuthorsFromBodyText(bodyText: string): string[] {
15
- const m = bodyText.match(/著者[::]\s*([^\n<]+)/);
16
- if (!m) return [];
17
- return m[1]
18
- .split(/[\u3000 ]/) // 全角スペースで分割
19
- .map(part =>
20
- part
21
- .replace(/\(.*?\)/g, "") // (所属) を除去
22
- .replace(/:.*$/, "") // :役割語 を除去
23
- .trim(),
24
- )
25
- .filter(Boolean);
26
- }
27
-
28
- /**
29
- * "発売日:2014年10月25日" → "2014-10-25"
30
- */
31
- function parseDateFromBodyText(bodyText: string): string | undefined {
32
- const m = bodyText.match(/発売日[::]\s*(\d{4})年(\d{1,2})月(\d{1,2})日/);
33
- if (!m) return undefined;
34
- return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
35
- }
36
-
37
- /**
38
- * ページ埋め込みの Colorme JSON から商品情報を取得する。
39
- * `var Colorme = {...};` 形式。
40
- */
41
- function extractColormeProduct(html: string): { isbn?: string; price?: number } {
42
- const m = html.match(/var Colorme = (\{[^\n]+\});/);
43
- if (!m) return {};
44
- try {
45
- const data = JSON.parse(m[1]) as {
46
- product?: { model_number?: string; sales_price_including_tax?: number };
47
- };
48
- const product = data.product;
49
- if (!product) return {};
50
- const rawIsbn = product.model_number;
51
- const isbn = rawIsbn ? rawIsbn.replace(/-/g, "") : undefined;
52
- const price = product.sales_price_including_tax;
53
- return { isbn, price };
54
- } catch {
55
- return {};
56
- }
57
- }
58
-
59
- export const rutlesAdapter: PublisherAdapter = {
60
- id: "rutles",
61
- name: "ラトルズ",
62
- baseUrl: BASE_URL,
63
-
64
- async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
65
- const word = [query.title, query.author].filter(Boolean).join(" ");
66
- if (!word) return [];
67
-
68
- const url = `${BASE_URL}/?mode=srh&keyword=${encodeEucJp(word)}`;
69
- const html = await fetchText(url, deps);
70
- const doc = deps.parser.parse(html);
71
-
72
- const results: BookRecord[] = [];
73
- const limit = query.limit ?? 10;
74
-
75
- for (const item of doc.select("li.c-item-list__item")) {
76
- const linkEl = item.find("div.c-item-list__ttl a")[0];
77
- if (!linkEl) continue;
78
-
79
- const title = linkEl.text().trim();
80
- const href = linkEl.attr("href");
81
- if (!title || !href) continue;
82
-
83
- // 【電子版】 が含まれるもののみ対象
84
- if (!title.includes("【電子版】")) continue;
85
-
86
- // `?pid=...` 形式の相対URLを `https://shop.rutles.net/?pid=...` に変換
87
- const bookUrl = new URL(href, BASE_URL + "/").toString();
88
-
89
- const priceText = item.find("div.c-item-list__price")[0]?.text().trim();
90
- const price = priceText ? parseJapanesePrice(priceText) : undefined;
91
-
92
- const imgEl = item.find("div.c-item-list__img img")[0];
93
- const coverImageUrl = imgEl?.attr("src") ?? undefined;
94
-
95
- results.push({
96
- title,
97
- authors: [],
98
- publisher: "ラトルズ",
99
- url: bookUrl,
100
- price,
101
- coverImageUrl,
102
- ebookStores: [{ name: "ラトルズ", url: bookUrl, drm: "free" }],
103
- });
104
-
105
- if (results.length >= limit) break;
106
- }
107
-
108
- return results;
109
- },
110
-
111
- async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
112
- const html = await fetchText(url, deps);
113
- const doc = deps.parser.parse(html);
114
-
115
- const getMeta = (prop: string): string | undefined =>
116
- doc.selectOne(`meta[property="${prop}"]`)?.attr("content")?.trim() || undefined;
117
-
118
- // タイトル: h2.p-product-info__ttl または og:title から " - ..." を除去
119
- const h2Title = doc.selectOne("h2.p-product-info__ttl")?.text().trim();
120
- const ogTitle = getMeta("og:title")?.replace(/\s*[--]\s*出版社ラトルズ公式ネットショップ.*$/, "").trim();
121
- const title = h2Title || ogTitle || "";
122
-
123
- // 説明文テキストから著者・発売日を取得
124
- const bodyText = doc.selectOne("div.p-product-explain__body")?.text() ?? "";
125
- const authors = parseAuthorsFromBodyText(bodyText);
126
- const publishedAt = parseDateFromBodyText(bodyText);
127
-
128
- // Colorme JSON から ISBN・価格を取得
129
- const { isbn, price: colormePrice } = extractColormeProduct(html);
130
-
131
- // og:price をフォールバックとして使用
132
- const metaPriceStr = getMeta("product:price:amount");
133
- const price = colormePrice ?? (metaPriceStr ? parseInt(metaPriceStr, 10) : undefined);
134
-
135
- const coverImageUrl = getMeta("og:image") ?? undefined;
136
-
137
- return {
138
- title,
139
- authors,
140
- publisher: "ラトルズ",
141
- url,
142
- isbn,
143
- price,
144
- publishedAt,
145
- coverImageUrl,
146
- ebookStores: [{ name: "ラトルズ", url, drm: "free" }],
147
- };
148
- },
149
- };
@@ -1,136 +0,0 @@
1
- import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
- import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
- import { fetchText, parseJapanesePrice, parseJapaneseDateToISO, stripAuthorRole } from "./base.js";
4
-
5
- const BASE_URL = "https://www.saiensu.co.jp";
6
- const SEARCH_URL = `${BASE_URL}/search/`;
7
-
8
- /**
9
- * "堀井俊佑(早稲田大学准教授) 監修" → "堀井俊佑"
10
- * 所属(括弧内)と役割語を除去する。
11
- */
12
- function parseAuthorName(text: string): string {
13
- return stripAuthorRole(text.replace(/\(.*?\)/g, ""));
14
- }
15
-
16
- /** "ISBN:978-4-7819-9049-1" → "9784781990491" */
17
- function parseIsbn(text: string): string | undefined {
18
- const m = text.match(/[\d-]{13,}/);
19
- return m ? m[0].replace(/-/g, "") : undefined;
20
- }
21
-
22
- /** "発行:サイエンス社" → "サイエンス社" */
23
- function parsePublisher(text: string): string {
24
- return text.replace(/^発行[::]/, "").trim();
25
- }
26
-
27
- export const saiensuAdapter: PublisherAdapter = {
28
- id: "saiensu",
29
- name: "サイエンス社",
30
- baseUrl: BASE_URL,
31
-
32
- async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
33
- const word = [query.title, query.author].filter(Boolean).join(" ");
34
- if (!word) return [];
35
-
36
- const url = `${SEARCH_URL}?keyword=${encodeURIComponent(word)}`;
37
- const html = await fetchText(url, deps);
38
- const doc = deps.parser.parse(html);
39
-
40
- const results: BookRecord[] = [];
41
- const limit = query.limit ?? 10;
42
-
43
- for (const article of doc.select("article.bookListItem")) {
44
- // 電子書籍のみを対象とする
45
- const mediaName = article.find(".bookListItemData_mediaName")[0]?.text().trim();
46
- if (mediaName !== "電子") continue;
47
-
48
- const titleEl = article.find("h4.bookListItemData_title p.minor-mt a.link_item")[0];
49
- if (!titleEl) continue;
50
-
51
- const title = titleEl.text().trim();
52
- const href = titleEl.attr("href");
53
- if (!title || !href) continue;
54
-
55
- const subtitle = article.find("small.bookListItemData_subtitle")[0]?.text().trim();
56
- const fullTitle = subtitle ? `${title} ―${subtitle}` : title;
57
-
58
- const authorText = article.find(".bookListItemData_author a.link_item")
59
- .map(el => parseAuthorName(el.text()))
60
- .filter(Boolean);
61
-
62
- const priceText = article.find(".bookListItemData_priceIncludedTaxNumber")[0]?.text().trim();
63
- const price = priceText ? parseJapanesePrice(priceText) : undefined;
64
-
65
- const dateText = article.find(".bookListItemData_publishDate")[0]?.text().trim();
66
- const publishedAt = dateText ? parseJapaneseDateToISO(dateText) : undefined;
67
-
68
- const publisherText = article.find(".bookListItemData_publisher")[0]?.text().trim();
69
- const publisher = publisherText ? parsePublisher(publisherText) : "サイエンス社";
70
-
71
- const isbnText = article.find(".bookListItemData_isbn2")[0]?.text().trim();
72
- const isbn = isbnText ? parseIsbn(isbnText) : undefined;
73
-
74
- const imgEl = article.find(".bookListItemCover img")[0];
75
- const coverImageUrl = imgEl?.attr("src") ?? undefined;
76
-
77
- const bookUrl = href.startsWith("http") ? href : `${BASE_URL}${href}`;
78
-
79
- results.push({
80
- title: fullTitle,
81
- authors: authorText,
82
- publisher,
83
- url: bookUrl,
84
- isbn,
85
- price,
86
- publishedAt,
87
- coverImageUrl,
88
- // パスワード認証付きPDF販売。標準PDFビューアで閲覧可能だが技術的制限あり
89
- ebookStores: [{ name: "サイエンス社", url: bookUrl, drm: "password_pdf" }],
90
- });
91
-
92
- if (results.length >= limit) break;
93
- }
94
-
95
- return results;
96
- },
97
-
98
- async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
99
- const html = await fetchText(url, deps);
100
- const doc = deps.parser.parse(html);
101
-
102
- const titleMain = doc.selectOne("h2.mainDetail_bookTitle")?.text().trim() ?? "";
103
- const subtitle = doc.selectOne(".mainDetail_subTitle")?.text().trim();
104
- const title = subtitle ? `${titleMain} ―${subtitle}` : titleMain;
105
-
106
- const authors = doc.select(".mainDetail_authorList a.link_item")
107
- .map(el => parseAuthorName(el.text()))
108
- .filter(Boolean);
109
-
110
- const priceText = doc.selectOne(".price_num")?.text().trim();
111
- const price = priceText ? parseJapanesePrice(priceText) : undefined;
112
-
113
- const dateText = doc.selectOne(".bookDetail_publishDate")?.text().trim();
114
- const publishedAt = dateText ? parseJapaneseDateToISO(dateText) : undefined;
115
-
116
- const publisherText = doc.selectOne(".bookDetail_publisher")?.text().trim();
117
- const publisher = publisherText ? parsePublisher(publisherText) : "サイエンス社";
118
-
119
- const isbnText = doc.selectOne(".bookDetail_isbn")?.text().trim();
120
- const isbn = isbnText ? parseIsbn(isbnText) : undefined;
121
-
122
- const coverImageUrl = doc.selectOne("img.mainDetail_bookImage")?.attr("src") ?? undefined;
123
-
124
- return {
125
- title,
126
- authors,
127
- publisher,
128
- url,
129
- isbn,
130
- price,
131
- publishedAt,
132
- coverImageUrl,
133
- ebookStores: [{ name: "サイエンス社", url, drm: "password_pdf" }],
134
- };
135
- },
136
- };
@@ -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,154 +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
- // 著者・価格: dl > dd 構造を優先し、なければ p 要素を走査
122
- let authors: string[] = [];
123
- let price: number | undefined;
124
-
125
- const candidates = [
126
- ...doc.select("dd"),
127
- ...doc.select("p"),
128
- ];
129
-
130
- for (const el of candidates) {
131
- const text = el.text().trim();
132
- if (!authors.length && /[((][著訳監編]/.test(text)) {
133
- authors = parseAuthors(text);
134
- }
135
- if (price === undefined && /^\d/.test(text) && /円/.test(text)) {
136
- price = parsePrice(text);
137
- }
138
- if (authors.length && price !== undefined) break;
139
- }
140
-
141
- // 達人出版会は全書籍で購入者情報を各ページに印字 (ソーシャルDRM)
142
- const ebookStores: EbookStore[] = [{ name: "達人出版会", url, drm: "social" }];
143
-
144
- return {
145
- title,
146
- authors,
147
- publisher,
148
- url,
149
- price,
150
- coverImageUrl,
151
- ebookStores,
152
- };
153
- },
154
- };