@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.
Files changed (174) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.github/workflows/test.yml +36 -0
  3. package/AGENTS.md +72 -0
  4. package/CLAUDE.md +2 -0
  5. package/LICENSE +661 -0
  6. package/README.md +154 -0
  7. package/dist/adapters/cache/memory-cache.d.ts +8 -0
  8. package/dist/adapters/cache/memory-cache.d.ts.map +1 -0
  9. package/dist/adapters/cache/memory-cache.js +23 -0
  10. package/dist/adapters/cache/memory-cache.js.map +1 -0
  11. package/dist/adapters/cache/null-cache.d.ts +8 -0
  12. package/dist/adapters/cache/null-cache.d.ts.map +1 -0
  13. package/dist/adapters/cache/null-cache.js +7 -0
  14. package/dist/adapters/cache/null-cache.js.map +1 -0
  15. package/dist/adapters/html/cheerio-parser.d.ts +5 -0
  16. package/dist/adapters/html/cheerio-parser.d.ts.map +1 -0
  17. package/dist/adapters/html/cheerio-parser.js +45 -0
  18. package/dist/adapters/html/cheerio-parser.js.map +1 -0
  19. package/dist/adapters/http/fetch-client.d.ts +6 -0
  20. package/dist/adapters/http/fetch-client.d.ts.map +1 -0
  21. package/dist/adapters/http/fetch-client.js +43 -0
  22. package/dist/adapters/http/fetch-client.js.map +1 -0
  23. package/dist/adapters/http/mock-client.d.ts +19 -0
  24. package/dist/adapters/http/mock-client.d.ts.map +1 -0
  25. package/dist/adapters/http/mock-client.js +59 -0
  26. package/dist/adapters/http/mock-client.js.map +1 -0
  27. package/dist/adapters/publishers/base.d.ts +24 -0
  28. package/dist/adapters/publishers/base.d.ts.map +1 -0
  29. package/dist/adapters/publishers/base.js +88 -0
  30. package/dist/adapters/publishers/base.js.map +1 -0
  31. package/dist/adapters/publishers/gihyo.d.ts +3 -0
  32. package/dist/adapters/publishers/gihyo.d.ts.map +1 -0
  33. package/dist/adapters/publishers/gihyo.js +75 -0
  34. package/dist/adapters/publishers/gihyo.js.map +1 -0
  35. package/dist/adapters/publishers/lambdanote.d.ts +3 -0
  36. package/dist/adapters/publishers/lambdanote.d.ts.map +1 -0
  37. package/dist/adapters/publishers/lambdanote.js +113 -0
  38. package/dist/adapters/publishers/lambdanote.js.map +1 -0
  39. package/dist/adapters/publishers/registry.d.ts +3 -0
  40. package/dist/adapters/publishers/registry.d.ts.map +1 -0
  41. package/dist/adapters/publishers/registry.js +11 -0
  42. package/dist/adapters/publishers/registry.js.map +1 -0
  43. package/dist/adapters/publishers/tatsu-zine.d.ts +3 -0
  44. package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -0
  45. package/dist/adapters/publishers/tatsu-zine.js +110 -0
  46. package/dist/adapters/publishers/tatsu-zine.js.map +1 -0
  47. package/dist/adapters/publishers/techbookfest.d.ts +3 -0
  48. package/dist/adapters/publishers/techbookfest.d.ts.map +1 -0
  49. package/dist/adapters/publishers/techbookfest.js +134 -0
  50. package/dist/adapters/publishers/techbookfest.js.map +1 -0
  51. package/dist/application/get-book-detail.d.ts +4 -0
  52. package/dist/application/get-book-detail.d.ts.map +1 -0
  53. package/dist/application/get-book-detail.js +9 -0
  54. package/dist/application/get-book-detail.js.map +1 -0
  55. package/dist/application/search-books.d.ts +11 -0
  56. package/dist/application/search-books.d.ts.map +1 -0
  57. package/dist/application/search-books.js +23 -0
  58. package/dist/application/search-books.js.map +1 -0
  59. package/dist/domain/book.d.ts +32 -0
  60. package/dist/domain/book.d.ts.map +1 -0
  61. package/dist/domain/book.js +2 -0
  62. package/dist/domain/book.js.map +1 -0
  63. package/dist/domain/publisher.d.ts +17 -0
  64. package/dist/domain/publisher.d.ts.map +1 -0
  65. package/dist/domain/publisher.js +2 -0
  66. package/dist/domain/publisher.js.map +1 -0
  67. package/dist/main.d.ts +2 -0
  68. package/dist/main.d.ts.map +1 -0
  69. package/dist/main.js +12 -0
  70. package/dist/main.js.map +1 -0
  71. package/dist/mcp/server.d.ts +5 -0
  72. package/dist/mcp/server.d.ts.map +1 -0
  73. package/dist/mcp/server.js +79 -0
  74. package/dist/mcp/server.js.map +1 -0
  75. package/dist/mcp/tools.d.ts +47 -0
  76. package/dist/mcp/tools.d.ts.map +1 -0
  77. package/dist/mcp/tools.js +53 -0
  78. package/dist/mcp/tools.js.map +1 -0
  79. package/dist/ports/cache.d.ts +6 -0
  80. package/dist/ports/cache.d.ts.map +1 -0
  81. package/dist/ports/cache.js +2 -0
  82. package/dist/ports/cache.js.map +1 -0
  83. package/dist/ports/html-parser.d.ts +14 -0
  84. package/dist/ports/html-parser.d.ts.map +1 -0
  85. package/dist/ports/html-parser.js +2 -0
  86. package/dist/ports/html-parser.js.map +1 -0
  87. package/dist/ports/http.d.ts +16 -0
  88. package/dist/ports/http.d.ts.map +1 -0
  89. package/dist/ports/http.js +2 -0
  90. package/dist/ports/http.js.map +1 -0
  91. package/docs/design-doc.md +365 -0
  92. package/flake.nix +50 -0
  93. package/package.json +29 -0
  94. package/src/adapters/cache/memory-cache.ts +31 -0
  95. package/src/adapters/cache/null-cache.ts +8 -0
  96. package/src/adapters/html/cheerio-parser.ts +49 -0
  97. package/src/adapters/http/fetch-client.ts +47 -0
  98. package/src/adapters/http/mock-client.ts +77 -0
  99. package/src/adapters/publishers/base.ts +129 -0
  100. package/src/adapters/publishers/book-tech.ts +117 -0
  101. package/src/adapters/publishers/born-digital.ts +158 -0
  102. package/src/adapters/publishers/coronasha.ts +139 -0
  103. package/src/adapters/publishers/gihyo.ts +120 -0
  104. package/src/adapters/publishers/lambdanote.ts +146 -0
  105. package/src/adapters/publishers/manatee.ts +112 -0
  106. package/src/adapters/publishers/maruzen-publishing.ts +141 -0
  107. package/src/adapters/publishers/optronics.ts +113 -0
  108. package/src/adapters/publishers/oreilly-japan.ts +138 -0
  109. package/src/adapters/publishers/peaks.ts +98 -0
  110. package/src/adapters/publishers/personal-media.ts +168 -0
  111. package/src/adapters/publishers/registry.ts +36 -0
  112. package/src/adapters/publishers/rutles.ts +161 -0
  113. package/src/adapters/publishers/saiensu.ts +149 -0
  114. package/src/adapters/publishers/seshop.ts +121 -0
  115. package/src/adapters/publishers/tatsu-zine.ts +129 -0
  116. package/src/adapters/publishers/techbookfest.ts +179 -0
  117. package/src/application/get-book-detail.ts +17 -0
  118. package/src/application/search-books.ts +39 -0
  119. package/src/domain/book.ts +35 -0
  120. package/src/domain/publisher.ts +18 -0
  121. package/src/main.ts +13 -0
  122. package/src/mcp/server.ts +103 -0
  123. package/src/mcp/tools.ts +54 -0
  124. package/src/ports/cache.ts +5 -0
  125. package/src/ports/html-parser.ts +15 -0
  126. package/src/ports/http.ts +17 -0
  127. package/tests/fixtures/book-tech-detail.html +51 -0
  128. package/tests/fixtures/book-tech-search.html +91 -0
  129. package/tests/fixtures/born-digital-detail.html +62 -0
  130. package/tests/fixtures/born-digital-search.html +51 -0
  131. package/tests/fixtures/coronasha-detail.html +41 -0
  132. package/tests/fixtures/coronasha-search.html +61 -0
  133. package/tests/fixtures/gihyo-detail.html +42 -0
  134. package/tests/fixtures/gihyo-search.json +54 -0
  135. package/tests/fixtures/lambdanote-search.html +66 -0
  136. package/tests/fixtures/manatee-detail.html +53 -0
  137. package/tests/fixtures/manatee-search.html +59 -0
  138. package/tests/fixtures/maruzen-detail.html +51 -0
  139. package/tests/fixtures/maruzen-search.html +60 -0
  140. package/tests/fixtures/optronics-detail.html +30 -0
  141. package/tests/fixtures/optronics-search.html +75 -0
  142. package/tests/fixtures/oreilly-detail.html +52 -0
  143. package/tests/fixtures/oreilly-ebook-list.html +53 -0
  144. package/tests/fixtures/peaks-detail.html +39 -0
  145. package/tests/fixtures/peaks-top.html +50 -0
  146. package/tests/fixtures/personal-media-detail.html +32 -0
  147. package/tests/fixtures/personal-media-search.html +39 -0
  148. package/tests/fixtures/rutles-detail.html +32 -0
  149. package/tests/fixtures/rutles-search.html +62 -0
  150. package/tests/fixtures/saiensu-detail.html +41 -0
  151. package/tests/fixtures/saiensu-search.html +65 -0
  152. package/tests/fixtures/seshop-detail.html +45 -0
  153. package/tests/fixtures/seshop-search.html +58 -0
  154. package/tests/fixtures/tatsu-zine-detail-free.html +22 -0
  155. package/tests/fixtures/tatsu-zine-search.html +24 -0
  156. package/tests/fixtures/techbookfest-search.json +73 -0
  157. package/tests/unit/adapters/publishers/book-tech.test.ts +183 -0
  158. package/tests/unit/adapters/publishers/born-digital.test.ts +191 -0
  159. package/tests/unit/adapters/publishers/coronasha.test.ts +201 -0
  160. package/tests/unit/adapters/publishers/gihyo.test.ts +135 -0
  161. package/tests/unit/adapters/publishers/lambdanote.test.ts +84 -0
  162. package/tests/unit/adapters/publishers/manatee.test.ts +163 -0
  163. package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +177 -0
  164. package/tests/unit/adapters/publishers/optronics.test.ts +205 -0
  165. package/tests/unit/adapters/publishers/oreilly-japan.test.ts +191 -0
  166. package/tests/unit/adapters/publishers/peaks.test.ts +174 -0
  167. package/tests/unit/adapters/publishers/personal-media.test.ts +196 -0
  168. package/tests/unit/adapters/publishers/rutles.test.ts +170 -0
  169. package/tests/unit/adapters/publishers/saiensu.test.ts +167 -0
  170. package/tests/unit/adapters/publishers/seshop.test.ts +171 -0
  171. package/tests/unit/adapters/publishers/tatsu-zine.test.ts +130 -0
  172. package/tests/unit/adapters/publishers/techbookfest.test.ts +93 -0
  173. package/tsconfig.json +17 -0
  174. package/vitest.config.ts +8 -0
package/flake.nix ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ description = "MCP server for Japanese technical book bibliographic search";
3
+
4
+ inputs = {
5
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
+ flake-utils.url = "github:numtide/flake-utils";
7
+ };
8
+
9
+ outputs = { self, nixpkgs, flake-utils }:
10
+ flake-utils.lib.eachDefaultSystem (system:
11
+ let
12
+ pkgs = nixpkgs.legacyPackages.${system};
13
+ in
14
+ {
15
+ packages.default = pkgs.buildNpmPackage {
16
+ pname = "techbook-mcp";
17
+ version = "0.1.0";
18
+ src = ./.;
19
+ # After running `npm install` to generate package-lock.json,
20
+ # run `nix build 2>&1 | grep "got:"` and paste the hash here.
21
+ npmDepsHash = pkgs.lib.fakeHash;
22
+ buildPhase = ''
23
+ npm run build
24
+ '';
25
+ installPhase = ''
26
+ mkdir -p $out/lib
27
+ cp -r dist $out/lib/dist
28
+ cp package.json $out/lib/
29
+ makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/techbook-mcp \
30
+ --add-flags "$out/lib/dist/main.js"
31
+ '';
32
+ nativeBuildInputs = [ pkgs.makeWrapper ];
33
+ };
34
+
35
+ devShells.default = pkgs.mkShell {
36
+ buildInputs = [
37
+ pkgs.nodejs_22
38
+ pkgs.bun
39
+ pkgs.deno
40
+ ];
41
+ shellHook = ''
42
+ echo "techbook-mcp dev shell"
43
+ echo " Node.js $(node --version)"
44
+ echo " Bun $(bun --version)"
45
+ echo " Deno $(deno --version | head -1)"
46
+ '';
47
+ };
48
+ }
49
+ );
50
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@zonuexe/techbook-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for searching Japanese technical book bibliographic information",
5
+ "license": "AGPL-3.0-or-later",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/main.js"
9
+ },
10
+ "bin": {
11
+ "techbook-mcp": "./dist/main.js"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.11.0",
20
+ "cheerio": "^1.0.0",
21
+ "iconv-lite": "^0.6.0",
22
+ "zod": "^3.24.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.8.0",
27
+ "vitest": "^3.0.0"
28
+ }
29
+ }
@@ -0,0 +1,31 @@
1
+ import type { CacheStore } from "../../ports/cache.js";
2
+
3
+ interface CacheEntry {
4
+ value: string;
5
+ expiresAt?: number;
6
+ }
7
+
8
+ export class MemoryCacheStore implements CacheStore {
9
+ private readonly store = new Map<string, CacheEntry>();
10
+
11
+ async get(key: string): Promise<string | null> {
12
+ const entry = this.store.get(key);
13
+ if (!entry) return null;
14
+ if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
15
+ this.store.delete(key);
16
+ return null;
17
+ }
18
+ return entry.value;
19
+ }
20
+
21
+ async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
22
+ this.store.set(key, {
23
+ value,
24
+ expiresAt: ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : undefined,
25
+ });
26
+ }
27
+
28
+ async delete(key: string): Promise<void> {
29
+ this.store.delete(key);
30
+ }
31
+ }
@@ -0,0 +1,8 @@
1
+ import type { CacheStore } from "../../ports/cache.js";
2
+
3
+ /** テスト・デバッグ用。キャッシュを一切行わない。 */
4
+ export class NullCacheStore implements CacheStore {
5
+ async get(_key: string): Promise<null> { return null; }
6
+ async set(_key: string, _value: string, _ttlSeconds?: number): Promise<void> {}
7
+ async delete(_key: string): Promise<void> {}
8
+ }
@@ -0,0 +1,49 @@
1
+ import * as cheerio from "cheerio";
2
+ import type { HtmlParser, HtmlDocument, HtmlElement } from "../../ports/html-parser.js";
3
+
4
+ class CheerioElement implements HtmlElement {
5
+ constructor(
6
+ private readonly $: cheerio.CheerioAPI,
7
+ private readonly el: cheerio.Element,
8
+ ) {}
9
+
10
+ text(): string {
11
+ return this.$(this.el).text().trim();
12
+ }
13
+
14
+ html(): string | null {
15
+ return this.$(this.el).html();
16
+ }
17
+
18
+ attr(name: string): string | undefined {
19
+ return this.$(this.el).attr(name);
20
+ }
21
+
22
+ find(selector: string): HtmlElement[] {
23
+ return this.$(this.el)
24
+ .find(selector)
25
+ .toArray()
26
+ .map(el => new CheerioElement(this.$, el));
27
+ }
28
+ }
29
+
30
+ class CheerioDocument implements HtmlDocument {
31
+ constructor(private readonly $: cheerio.CheerioAPI) {}
32
+
33
+ select(selector: string): HtmlElement[] {
34
+ return this.$(selector)
35
+ .toArray()
36
+ .map(el => new CheerioElement(this.$, el));
37
+ }
38
+
39
+ selectOne(selector: string): HtmlElement | null {
40
+ return this.select(selector)[0] ?? null;
41
+ }
42
+ }
43
+
44
+ export class CheerioHtmlParser implements HtmlParser {
45
+ parse(html: string): HtmlDocument {
46
+ const $ = cheerio.load(html);
47
+ return new CheerioDocument($);
48
+ }
49
+ }
@@ -0,0 +1,47 @@
1
+ import type { HttpClient, RequestOptions, HttpResponse } from "../../ports/http.js";
2
+
3
+ class FetchHttpResponse implements HttpResponse {
4
+ constructor(private readonly response: Response) {}
5
+
6
+ get status(): number {
7
+ return this.response.status;
8
+ }
9
+
10
+ get url(): string {
11
+ return this.response.url;
12
+ }
13
+
14
+ text(): Promise<string> {
15
+ return this.response.text();
16
+ }
17
+
18
+ header(name: string): string | null {
19
+ return this.response.headers.get(name);
20
+ }
21
+ }
22
+
23
+ export class FetchHttpClient implements HttpClient {
24
+ async get(url: string, options?: RequestOptions): Promise<HttpResponse> {
25
+ const init: RequestInit = {
26
+ headers: options?.headers,
27
+ };
28
+ if (options?.timeout !== undefined) {
29
+ init.signal = AbortSignal.timeout(options.timeout);
30
+ }
31
+ const response = await fetch(url, init);
32
+ return new FetchHttpResponse(response);
33
+ }
34
+
35
+ async post(url: string, body: string, options?: RequestOptions): Promise<HttpResponse> {
36
+ const init: RequestInit = {
37
+ method: "POST",
38
+ body,
39
+ headers: { "Content-Type": "application/json", ...options?.headers },
40
+ };
41
+ if (options?.timeout !== undefined) {
42
+ init.signal = AbortSignal.timeout(options.timeout);
43
+ }
44
+ const response = await fetch(url, init);
45
+ return new FetchHttpResponse(response);
46
+ }
47
+ }
@@ -0,0 +1,77 @@
1
+ import type { HttpClient, RequestOptions, HttpResponse } from "../../ports/http.js";
2
+
3
+ export interface MockResponseData {
4
+ status: number;
5
+ body: string;
6
+ headers?: Record<string, string>;
7
+ }
8
+
9
+ class MockHttpResponse implements HttpResponse {
10
+ constructor(
11
+ private readonly data: MockResponseData,
12
+ private readonly requestUrl: string,
13
+ ) {}
14
+
15
+ get status(): number { return this.data.status; }
16
+ get url(): string { return this.requestUrl; }
17
+ async text(): Promise<string> { return this.data.body; }
18
+ header(name: string): string | null {
19
+ return this.data.headers?.[name.toLowerCase()] ?? null;
20
+ }
21
+ }
22
+
23
+ export class MockHttpClient implements HttpClient {
24
+ private readonly handlers = new Map<string, MockResponseData>();
25
+ private readonly postHandlers = new Map<string, MockResponseData>();
26
+ private readonly _calls: string[] = [];
27
+
28
+ /** GET: URL の前方一致でレスポンスを登録する */
29
+ addResponse(urlPrefix: string, data: MockResponseData): this {
30
+ this.handlers.set(urlPrefix, data);
31
+ return this;
32
+ }
33
+
34
+ /** POST: URL の前方一致でレスポンスを登録する */
35
+ addPostResponse(urlPrefix: string, data: MockResponseData): this {
36
+ this.postHandlers.set(urlPrefix, data);
37
+ return this;
38
+ }
39
+
40
+ get calls(): readonly string[] {
41
+ return this._calls;
42
+ }
43
+
44
+ async get(url: string, _options?: RequestOptions): Promise<HttpResponse> {
45
+ this._calls.push(url);
46
+
47
+ // 完全一致を優先
48
+ if (this.handlers.has(url)) {
49
+ return new MockHttpResponse(this.handlers.get(url)!, url);
50
+ }
51
+
52
+ // 前方一致
53
+ for (const [prefix, data] of this.handlers) {
54
+ if (url.startsWith(prefix)) {
55
+ return new MockHttpResponse(data, url);
56
+ }
57
+ }
58
+
59
+ throw new Error(`MockHttpClient: no handler for GET: ${url}`);
60
+ }
61
+
62
+ async post(url: string, _body: string, _options?: RequestOptions): Promise<HttpResponse> {
63
+ this._calls.push(url);
64
+
65
+ if (this.postHandlers.has(url)) {
66
+ return new MockHttpResponse(this.postHandlers.get(url)!, url);
67
+ }
68
+
69
+ for (const [prefix, data] of this.postHandlers) {
70
+ if (url.startsWith(prefix)) {
71
+ return new MockHttpResponse(data, url);
72
+ }
73
+ }
74
+
75
+ throw new Error(`MockHttpClient: no handler for POST: ${url}`);
76
+ }
77
+ }
@@ -0,0 +1,129 @@
1
+ import type { PublisherDeps } from "../../domain/publisher.js";
2
+ import type { EbookStore, DrmType } from "../../domain/book.js";
3
+ import type { HtmlDocument } from "../../ports/html-parser.js";
4
+
5
+ const DEFAULT_HEADERS = {
6
+ "User-Agent": "techbook-mcp/0.1.0 (+https://github.com/zonuexe/techbook-mcp; bibliographic search bot)",
7
+ "Accept": "text/html,application/xhtml+xml,application/json",
8
+ "Accept-Language": "ja,en;q=0.9",
9
+ };
10
+
11
+ export const CACHE_TTL_SECONDS = 3600; // 1時間
12
+
13
+ export async function fetchText(
14
+ url: string,
15
+ deps: PublisherDeps,
16
+ extraHeaders?: Record<string, string>,
17
+ ): Promise<string> {
18
+ const cached = await deps.cache.get(url);
19
+ if (cached !== null) return cached;
20
+
21
+ const headers = extraHeaders
22
+ ? { ...DEFAULT_HEADERS, ...extraHeaders }
23
+ : DEFAULT_HEADERS;
24
+
25
+ const response = await deps.http.get(url, { headers });
26
+ if (response.status !== 200) {
27
+ throw new Error(`HTTP ${response.status}: ${url}`);
28
+ }
29
+
30
+ const text = await response.text();
31
+ await deps.cache.set(url, text, CACHE_TTL_SECONDS);
32
+ return text;
33
+ }
34
+
35
+ /** HTMLタグを除去する(gihyo APIのauthorフィールドのruby markup除去に使用) */
36
+ export function stripHtmlTags(html: string): string {
37
+ return html.replace(/<[^>]+>/g, "");
38
+ }
39
+
40
+ /** "¥3,960" や "3,300円(税込)" などから整数値を取り出す */
41
+ export function parseJapanesePrice(text: string): number | undefined {
42
+ const match = text.match(/[\d,]+/);
43
+ if (!match) return undefined;
44
+ return parseInt(match[0].replace(/,/g, ""), 10);
45
+ }
46
+
47
+ /** 相対URLを絶対URLに解決する */
48
+ export function resolveUrl(base: string, path: string): string {
49
+ return new URL(path, base).toString();
50
+ }
51
+
52
+ /**
53
+ * HTMLテキストから Amazon ASIN を抽出する。
54
+ * amazon.co.jp/dp/{ASIN}, /gp/product/{ASIN}, /o/ASIN/{ASIN} 形式に対応。
55
+ */
56
+ export function extractAsin(html: string): string | undefined {
57
+ const match = html.match(/amazon\.co\.jp\/(?:dp|gp\/product|o\/ASIN)\/([A-Z0-9]{10})/);
58
+ return match?.[1];
59
+ }
60
+
61
+ // --- 電子書籍ストア分類 ---
62
+
63
+ interface StorePattern {
64
+ pattern: RegExp;
65
+ name: string;
66
+ drm: DrmType;
67
+ }
68
+
69
+ const EBOOK_STORE_PATTERNS: StorePattern[] = [
70
+ // DRM-free
71
+ { pattern: /techbookfest\.org\/product\//, name: "技術書典", drm: "free" },
72
+ { pattern: /oreilly\.co\.jp\/books\//, name: "オライリー・ジャパン", drm: "free" },
73
+ { pattern: /shop\.rutles\.net\//, name: "ラトルズ", drm: "free" },
74
+ { pattern: /peaks\.cc\/books\//, name: "PEAKS", drm: "free" },
75
+ { pattern: /optronics-ebook\.com\/products\//, name: "オプトロニクス社", drm: "free" },
76
+ { pattern: /gihyo\.jp\/dp\/ebook\//, name: "Gihyo Digital Publishing", drm: "social" },
77
+ { pattern: /seshop\.com\/product\//, name: "SEshop", drm: "social" },
78
+ { pattern: /book-tech\.com\/books\//, name: "BOOK TECH", drm: "social" },
79
+ { pattern: /wgn-obs\.shop-pro\.jp\/\?pid=/, name: "ボーンデジタル", drm: "social" },
80
+ // ソーシャルDRM (購入時生成IDまたは購入者情報を透かし刻印、技術的制限なし)
81
+ { pattern: /book\.mynavi\.jp\/manatee\//, name: "マナティ", drm: "social" },
82
+ { pattern: /www\.lambdanote\.com\/products\//, name: "ラムダノート", drm: "social" },
83
+ { pattern: /tatsu-zine\.com\/books\/(?!pub\/)/, name: "達人出版会", drm: "social" },
84
+ // ソーシャルDRM (購入者情報透かし入りPDF、技術的制限なし)
85
+ { pattern: /book\.impress\.co\.jp\/books\//, name: "インプレスブックス", drm: "social" },
86
+ // DRM-attached
87
+ { pattern: /saiensu\.co\.jp/, name: "サイエンス社", drm: "password_pdf" },
88
+ { pattern: /amazon\.co\.jp/, name: "Kindle", drm: "drm" },
89
+ { pattern: /kinokuniya\.co\.jp\/(?:kinoppystore|f\/dsg-08)/, name: "Kinoppy", drm: "drm" },
90
+ { pattern: /coop-ebook\.jp\/mem\//, name: "VarsityWave eBooks", drm: "drm" },
91
+ { pattern: /books\.rakuten\.co\.jp|rakuten\.kobo\.com|kobo\.com/, name: "楽天Kobo", drm: "drm" },
92
+ { pattern: /booklive\.jp/, name: "BookLive", drm: "drm" },
93
+ { pattern: /honto\.jp/, name: "honto", drm: "drm" },
94
+ { pattern: /bookwalker\.jp/, name: "BOOK☆WALKER", drm: "drm" },
95
+ { pattern: /ebookjapan\.yahoo\.co\.jp/, name: "eBookJapan", drm: "drm" },
96
+ { pattern: /store\.line\.me/, name: "LINEマンガ", drm: "drm" },
97
+ ];
98
+
99
+ /** URLから電子書籍ストア情報を返す。未知のストアは null。 */
100
+ export function classifyEbookStore(url: string): EbookStore | null {
101
+ for (const { pattern, name, drm } of EBOOK_STORE_PATTERNS) {
102
+ if (pattern.test(url)) {
103
+ return { name, url, drm };
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * HTMLドキュメント内の全リンクを走査して電子書籍ストアを抽出する。
111
+ * 同一ストアのURLが複数あれば最初の1件のみ返す。
112
+ */
113
+ export function extractEbookStoresFromDoc(doc: HtmlDocument): EbookStore[] {
114
+ const stores: EbookStore[] = [];
115
+ const seenNames = new Set<string>();
116
+
117
+ for (const link of doc.select("a[href]")) {
118
+ const href = link.attr("href");
119
+ if (!href) continue;
120
+
121
+ const store = classifyEbookStore(href);
122
+ if (store && !seenNames.has(store.name)) {
123
+ seenNames.add(store.name);
124
+ stores.push(store);
125
+ }
126
+ }
127
+
128
+ return stores;
129
+ }
@@ -0,0 +1,117 @@
1
+ import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
2
+ import type { BookRecord, SearchQuery } from "../../domain/book.js";
3
+ import { fetchText, parseJapanesePrice, resolveUrl } from "./base.js";
4
+
5
+ const BASE_URL = "https://book-tech.com";
6
+ const SEARCH_URL = `${BASE_URL}/books`;
7
+ // クエリパラメータキー(URLエンコード済み)
8
+ const SEARCH_PARAM = "q%5Btitle_or_overview_or_identification_number_1_or_product_code_cont%5D";
9
+
10
+ /** "2026/2/20" → "2026-02-20" */
11
+ function parseDate(text: string): string | undefined {
12
+ const m = text.match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
13
+ if (!m) return undefined;
14
+ return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
15
+ }
16
+
17
+ /** "(著)" などの役割語を末尾から除去する */
18
+ function stripRole(name: string): string {
19
+ return name.replace(/\s*[((][^))]*[))]\s*$/, "").trim();
20
+ }
21
+
22
+ export const bookTechAdapter: PublisherAdapter = {
23
+ id: "book-tech",
24
+ name: "BOOK TECH",
25
+ baseUrl: BASE_URL,
26
+
27
+ async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
28
+ const word = [query.title, query.author].filter(Boolean).join(" ");
29
+ if (!word) return [];
30
+
31
+ const url = `${SEARCH_URL}?${SEARCH_PARAM}=${encodeURIComponent(word)}`;
32
+ const html = await fetchText(url, deps);
33
+ const doc = deps.parser.parse(html);
34
+
35
+ const results: BookRecord[] = [];
36
+ const limit = query.limit ?? 10;
37
+
38
+ for (const item of doc.select("div.contents-index-item")) {
39
+ const linkEl = item.find("a.book-ribbon-link")[0];
40
+ const href = linkEl?.attr("href");
41
+ if (!href) continue;
42
+ const bookUrl = resolveUrl(BASE_URL, href);
43
+
44
+ const title = item.find(".contents-index-item-detail-title")[0]?.text().trim();
45
+ if (!title) continue;
46
+
47
+ const publisherEl = item.find("a[href*='publisher_relations']")[0];
48
+ const publisher = publisherEl?.text().trim() ?? "";
49
+
50
+ const authors = item.find("a[href*='author_relations']")
51
+ .map(el => stripRole(el.text().trim()))
52
+ .filter(Boolean);
53
+
54
+ const priceText = item.find(".contents-index-item-detail-price_include_tax")[0]?.text();
55
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
56
+
57
+ const dateText = item.find(".my-1")[0]?.text();
58
+ const publishedAt = dateText ? parseDate(dateText) : undefined;
59
+
60
+ const imgEl = item.find("img.thumb")[0];
61
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
62
+
63
+ results.push({
64
+ title,
65
+ authors,
66
+ publisher,
67
+ url: bookUrl,
68
+ price,
69
+ publishedAt,
70
+ coverImageUrl,
71
+ ebookStores: [{ name: "BOOK TECH", url: bookUrl, drm: "social" }],
72
+ });
73
+
74
+ if (results.length >= limit) break;
75
+ }
76
+
77
+ return results;
78
+ },
79
+
80
+ async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
81
+ const html = await fetchText(url, deps);
82
+ const doc = deps.parser.parse(html);
83
+
84
+ const title = doc.selectOne(".contents-book-about-title h1")?.text().trim() ?? "";
85
+
86
+ const publisherEl = doc.selectOne("a[href*='publisher_relations']");
87
+ const publisher = publisherEl?.text().trim() ?? "";
88
+
89
+ const authors = doc.select("a[href*='author_relations']")
90
+ .map(el => stripRole(el.text().trim()))
91
+ .filter(Boolean);
92
+
93
+ const priceText = doc.selectOne(".contents-book-item-detail-price_include_tax")?.text();
94
+ const price = priceText ? parseJapanesePrice(priceText) : undefined;
95
+
96
+ const dateText = doc.selectOne(".contents-book-about-publicationdate")?.text();
97
+ const publishedAt = dateText ? parseDate(dateText) : undefined;
98
+
99
+ const isbnText = doc.selectOne(".contents-book-about-id")?.text();
100
+ const isbn = isbnText?.match(/\d{13}/)?.[0];
101
+
102
+ const imgEl = doc.selectOne("img.thumb");
103
+ const coverImageUrl = imgEl?.attr("src") ?? undefined;
104
+
105
+ return {
106
+ title,
107
+ authors,
108
+ publisher,
109
+ url,
110
+ isbn,
111
+ price,
112
+ publishedAt,
113
+ coverImageUrl,
114
+ ebookStores: [{ name: "BOOK TECH", url, drm: "social" }],
115
+ };
116
+ },
117
+ };