@zonuexe/techbook-mcp 0.2.3 → 0.3.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 +52 -1
- package/README.md +54 -22
- package/dist/adapters/http/fetch-client.d.ts.map +1 -1
- package/dist/adapters/http/fetch-client.js +18 -1
- package/dist/adapters/http/fetch-client.js.map +1 -1
- package/dist/adapters/openbd.d.ts.map +1 -1
- package/dist/adapters/openbd.js +18 -5
- package/dist/adapters/openbd.js.map +1 -1
- package/dist/adapters/publishers/base.d.ts +2 -1
- package/dist/adapters/publishers/base.d.ts.map +1 -1
- package/dist/adapters/publishers/base.js +8 -3
- package/dist/adapters/publishers/base.js.map +1 -1
- package/dist/adapters/publishers/cq-publishing.d.ts +3 -0
- package/dist/adapters/publishers/cq-publishing.d.ts.map +1 -0
- package/dist/adapters/publishers/cq-publishing.js +120 -0
- package/dist/adapters/publishers/cq-publishing.js.map +1 -0
- package/dist/adapters/publishers/google-books.d.ts +4 -0
- package/dist/adapters/publishers/google-books.d.ts.map +1 -0
- package/dist/adapters/publishers/google-books.js +76 -0
- package/dist/adapters/publishers/google-books.js.map +1 -0
- package/dist/adapters/publishers/isbn-publisher-codes.d.ts +21 -0
- package/dist/adapters/publishers/isbn-publisher-codes.d.ts.map +1 -0
- package/dist/adapters/publishers/isbn-publisher-codes.js +49 -0
- package/dist/adapters/publishers/isbn-publisher-codes.js.map +1 -0
- package/dist/adapters/publishers/juse-p.d.ts +3 -0
- package/dist/adapters/publishers/juse-p.d.ts.map +1 -0
- package/dist/adapters/publishers/juse-p.js +110 -0
- package/dist/adapters/publishers/juse-p.js.map +1 -0
- package/dist/adapters/publishers/leanpub.d.ts +3 -0
- package/dist/adapters/publishers/leanpub.d.ts.map +1 -0
- package/dist/adapters/publishers/leanpub.js +96 -0
- package/dist/adapters/publishers/leanpub.js.map +1 -0
- package/dist/adapters/publishers/oreilly-japan.d.ts.map +1 -1
- package/dist/adapters/publishers/oreilly-japan.js +8 -2
- package/dist/adapters/publishers/oreilly-japan.js.map +1 -1
- package/dist/adapters/publishers/peaks.d.ts.map +1 -1
- package/dist/adapters/publishers/peaks.js +3 -2
- package/dist/adapters/publishers/peaks.js.map +1 -1
- package/dist/adapters/publishers/personal-media.d.ts.map +1 -1
- package/dist/adapters/publishers/personal-media.js +3 -2
- package/dist/adapters/publishers/personal-media.js.map +1 -1
- package/dist/adapters/publishers/pragprog.d.ts +3 -0
- package/dist/adapters/publishers/pragprog.d.ts.map +1 -0
- package/dist/adapters/publishers/pragprog.js +120 -0
- package/dist/adapters/publishers/pragprog.js.map +1 -0
- package/dist/adapters/publishers/registry.d.ts.map +1 -1
- package/dist/adapters/publishers/registry.js +10 -0
- package/dist/adapters/publishers/registry.js.map +1 -1
- package/dist/adapters/publishers/techbookfest.d.ts.map +1 -1
- package/dist/adapters/publishers/techbookfest.js +2 -1
- package/dist/adapters/publishers/techbookfest.js.map +1 -1
- package/dist/application/concurrency.d.ts +16 -0
- package/dist/application/concurrency.d.ts.map +1 -0
- package/dist/application/concurrency.js +42 -0
- package/dist/application/concurrency.js.map +1 -0
- package/dist/application/get-book-by-isbn.d.ts +0 -8
- package/dist/application/get-book-by-isbn.d.ts.map +1 -1
- package/dist/application/get-book-by-isbn.js +64 -7
- package/dist/application/get-book-by-isbn.js.map +1 -1
- package/dist/application/get-book-detail.d.ts.map +1 -1
- package/dist/application/get-book-detail.js +3 -0
- package/dist/application/get-book-detail.js.map +1 -1
- package/dist/application/search-books.d.ts +16 -5
- package/dist/application/search-books.d.ts.map +1 -1
- package/dist/application/search-books.js +46 -9
- package/dist/application/search-books.js.map +1 -1
- package/dist/config/credentials.d.ts +8 -0
- package/dist/config/credentials.d.ts.map +1 -0
- package/dist/config/credentials.js +32 -0
- package/dist/config/credentials.js.map +1 -0
- package/dist/domain/authors.d.ts +7 -0
- package/dist/domain/authors.d.ts.map +1 -0
- package/dist/domain/authors.js +22 -0
- package/dist/domain/authors.js.map +1 -0
- package/dist/domain/book.d.ts +2 -0
- package/dist/domain/book.d.ts.map +1 -1
- package/dist/domain/isbn.d.ts +8 -0
- package/dist/domain/isbn.d.ts.map +1 -0
- package/dist/domain/isbn.js +16 -0
- package/dist/domain/isbn.js.map +1 -0
- package/dist/domain/publisher.d.ts +16 -0
- package/dist/domain/publisher.d.ts.map +1 -1
- package/dist/domain/text-match.d.ts +32 -0
- package/dist/domain/text-match.d.ts.map +1 -0
- package/dist/domain/text-match.js +84 -0
- package/dist/domain/text-match.js.map +1 -0
- package/dist/main.js +15 -1
- package/dist/main.js.map +1 -1
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +40 -4
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +9 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +43 -0
- package/dist/setup.js.map +1 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/docs/design-doc.md +127 -7
- package/package.json +14 -15
- package/.claude/settings.local.json +0 -38
- package/.codex/skills/techbook-mcp-release-prep/SKILL.md +0 -105
- package/.github/workflows/test.yml +0 -72
- package/.oxlintrc.json +0 -12
- package/AGENTS.md +0 -100
- package/deno.json +0 -3
- package/src/adapters/cache/memory-cache.ts +0 -31
- package/src/adapters/cache/null-cache.ts +0 -8
- package/src/adapters/calil.ts +0 -57
- package/src/adapters/html/cheerio-parser.ts +0 -50
- package/src/adapters/http/fetch-client.ts +0 -47
- package/src/adapters/http/mock-client.ts +0 -77
- package/src/adapters/openbd.ts +0 -142
- package/src/adapters/publishers/base.ts +0 -279
- package/src/adapters/publishers/book-tech.ts +0 -117
- package/src/adapters/publishers/born-digital.ts +0 -143
- package/src/adapters/publishers/coronasha.ts +0 -139
- package/src/adapters/publishers/gihyo.ts +0 -120
- package/src/adapters/publishers/impress.ts +0 -103
- package/src/adapters/publishers/lambdanote.ts +0 -146
- package/src/adapters/publishers/manatee.ts +0 -113
- package/src/adapters/publishers/maruzen-publishing.ts +0 -129
- package/src/adapters/publishers/optronics.ts +0 -113
- package/src/adapters/publishers/oreilly-japan.ts +0 -133
- package/src/adapters/publishers/peaks.ts +0 -98
- package/src/adapters/publishers/personal-media.ts +0 -168
- package/src/adapters/publishers/registry.ts +0 -38
- package/src/adapters/publishers/rutles.ts +0 -149
- package/src/adapters/publishers/saiensu.ts +0 -136
- package/src/adapters/publishers/seshop.ts +0 -121
- package/src/adapters/publishers/tatsu-zine.ts +0 -142
- package/src/adapters/publishers/techbookfest.ts +0 -179
- package/src/application/get-book-by-isbn.ts +0 -50
- package/src/application/get-book-detail.ts +0 -40
- package/src/application/search-books.ts +0 -64
- package/src/domain/book.ts +0 -35
- package/src/domain/publisher.ts +0 -18
- package/src/main.ts +0 -14
- package/src/mcp/server.ts +0 -113
- package/src/mcp/tools.ts +0 -71
- package/src/ports/cache.ts +0 -5
- package/src/ports/html-parser.ts +0 -15
- package/src/ports/http.ts +0 -17
- package/tests/fixtures/book-tech-detail.html +0 -51
- package/tests/fixtures/book-tech-search.html +0 -91
- package/tests/fixtures/born-digital-detail.html +0 -62
- package/tests/fixtures/born-digital-search.html +0 -51
- package/tests/fixtures/calil-book.html +0 -987
- package/tests/fixtures/coronasha-detail.html +0 -41
- package/tests/fixtures/coronasha-search.html +0 -61
- package/tests/fixtures/gihyo-detail.html +0 -42
- package/tests/fixtures/gihyo-search.json +0 -54
- package/tests/fixtures/impress-detail-epub.html +0 -746
- package/tests/fixtures/impress-detail-social.html +0 -689
- package/tests/fixtures/lambdanote-search.html +0 -66
- package/tests/fixtures/manatee-detail.html +0 -53
- package/tests/fixtures/manatee-search.html +0 -59
- package/tests/fixtures/maruzen-detail.html +0 -51
- package/tests/fixtures/maruzen-search.html +0 -60
- package/tests/fixtures/openbd-response.json +0 -110
- package/tests/fixtures/optronics-detail.html +0 -30
- package/tests/fixtures/optronics-search.html +0 -75
- package/tests/fixtures/oreilly-detail.html +0 -52
- package/tests/fixtures/oreilly-ebook-list.html +0 -53
- package/tests/fixtures/peaks-detail.html +0 -39
- package/tests/fixtures/peaks-top.html +0 -50
- package/tests/fixtures/personal-media-detail.html +0 -32
- package/tests/fixtures/personal-media-search.html +0 -39
- package/tests/fixtures/rutles-detail.html +0 -32
- package/tests/fixtures/rutles-search.html +0 -62
- package/tests/fixtures/saiensu-detail.html +0 -41
- package/tests/fixtures/saiensu-search.html +0 -65
- package/tests/fixtures/seshop-detail.html +0 -45
- package/tests/fixtures/seshop-search.html +0 -58
- package/tests/fixtures/tatsu-zine-detail-free.html +0 -24
- package/tests/fixtures/tatsu-zine-search.html +0 -40
- package/tests/fixtures/techbookfest-search.json +0 -73
- package/tests/unit/adapters/base.test.ts +0 -441
- package/tests/unit/adapters/calil.test.ts +0 -69
- package/tests/unit/adapters/openbd.test.ts +0 -185
- package/tests/unit/adapters/publishers/book-tech.test.ts +0 -186
- package/tests/unit/adapters/publishers/born-digital.test.ts +0 -194
- package/tests/unit/adapters/publishers/coronasha.test.ts +0 -207
- package/tests/unit/adapters/publishers/gihyo.test.ts +0 -137
- package/tests/unit/adapters/publishers/impress.test.ts +0 -129
- package/tests/unit/adapters/publishers/lambdanote.test.ts +0 -85
- package/tests/unit/adapters/publishers/manatee.test.ts +0 -165
- package/tests/unit/adapters/publishers/maruzen-publishing.test.ts +0 -179
- package/tests/unit/adapters/publishers/optronics.test.ts +0 -208
- package/tests/unit/adapters/publishers/oreilly-japan.test.ts +0 -194
- package/tests/unit/adapters/publishers/peaks.test.ts +0 -177
- package/tests/unit/adapters/publishers/personal-media.test.ts +0 -199
- package/tests/unit/adapters/publishers/rutles.test.ts +0 -173
- package/tests/unit/adapters/publishers/saiensu.test.ts +0 -169
- package/tests/unit/adapters/publishers/seshop.test.ts +0 -174
- package/tests/unit/adapters/publishers/tatsu-zine.test.ts +0 -172
- package/tests/unit/adapters/publishers/techbookfest.test.ts +0 -94
- package/tests/unit/adapters/registry.test.ts +0 -37
- package/tests/unit/application/get-book-by-isbn.test.ts +0 -176
- package/tests/unit/application/get-book-detail.test.ts +0 -102
- package/tests/unit/application/search-books.test.ts +0 -137
- package/tsconfig.json +0 -17
|
@@ -1,8 +0,0 @@
|
|
|
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
|
-
}
|
package/src/adapters/calil.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { PublisherDeps } from "../domain/publisher.js";
|
|
2
|
-
import type { BookRecord } from "../domain/book.js";
|
|
3
|
-
import { fetchText } from "./publishers/base.js";
|
|
4
|
-
|
|
5
|
-
export const CALIL_BASE_URL = "https://calil.jp";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* カーリルの書籍詳細ページ (/book/{isbn}) から書誌情報を取得する。
|
|
9
|
-
* openBD に存在しない書籍(廃業出版社など)のフォールバックとして使用する。
|
|
10
|
-
* @returns 書誌情報が見つかれば BookRecord、ページが存在しなければ null。
|
|
11
|
-
*/
|
|
12
|
-
export async function fetchCalilBook(
|
|
13
|
-
isbn: string,
|
|
14
|
-
deps: PublisherDeps,
|
|
15
|
-
): Promise<BookRecord | null> {
|
|
16
|
-
const url = `${CALIL_BASE_URL}/book/${isbn}`;
|
|
17
|
-
let html: string;
|
|
18
|
-
try {
|
|
19
|
-
html = await fetchText(url, deps);
|
|
20
|
-
} catch {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const doc = deps.parser.parse(html);
|
|
25
|
-
|
|
26
|
-
const title = doc.selectOne("h1.title[itemprop='name']")?.text().trim();
|
|
27
|
-
if (!title) return null;
|
|
28
|
-
|
|
29
|
-
// 著者: div.author 内の <a> テキストを収集する
|
|
30
|
-
// <div class="author" itemprop="author">
|
|
31
|
-
// <a href="/search?q=author:...">WebビジネスPHP研究部会</a><span>(著)</span>
|
|
32
|
-
// </div>
|
|
33
|
-
const authorLinks = doc.select("div[itemprop='author'] a");
|
|
34
|
-
const authors = authorLinks.map(el => el.text().trim()).filter(Boolean);
|
|
35
|
-
|
|
36
|
-
const publisher = doc.selectOne("span[itemprop='publisher']")?.text().trim() || undefined;
|
|
37
|
-
|
|
38
|
-
// "(2002-02-01)" → "2002-02-01"
|
|
39
|
-
const rawDate = doc.selectOne("span[itemprop='datePublished']")?.text().trim();
|
|
40
|
-
const publishedAt = rawDate ? rawDate.replace(/^\(|\)$/g, "").trim() || undefined : undefined;
|
|
41
|
-
|
|
42
|
-
// ISBN-13: <span itemprop="isbn">ISBN-13:</span> 9784901676038 の形式
|
|
43
|
-
const isbn13Match = html.match(/ISBN-13:[^<]*<\/span>[^<\d]*(97[89]\d{10})/);
|
|
44
|
-
const isbn13 = isbn13Match?.[1] ?? isbn;
|
|
45
|
-
|
|
46
|
-
const coverImageUrl = doc.selectOne("img[itemprop='image']")?.attr("src") || undefined;
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
title,
|
|
50
|
-
authors,
|
|
51
|
-
publisher: publisher ?? "",
|
|
52
|
-
isbn: isbn13,
|
|
53
|
-
publishedAt,
|
|
54
|
-
url,
|
|
55
|
-
coverImageUrl,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import * as cheerio from "cheerio";
|
|
2
|
-
import type { Element } from "domhandler";
|
|
3
|
-
import type { HtmlParser, HtmlDocument, HtmlElement } from "../../ports/html-parser.js";
|
|
4
|
-
|
|
5
|
-
class CheerioElement implements HtmlElement {
|
|
6
|
-
constructor(
|
|
7
|
-
private readonly $: cheerio.CheerioAPI,
|
|
8
|
-
private readonly el: Element,
|
|
9
|
-
) {}
|
|
10
|
-
|
|
11
|
-
text(): string {
|
|
12
|
-
return this.$(this.el).text().trim();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
html(): string | null {
|
|
16
|
-
return this.$(this.el).html();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
attr(name: string): string | undefined {
|
|
20
|
-
return this.$(this.el).attr(name);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
find(selector: string): HtmlElement[] {
|
|
24
|
-
return this.$(this.el)
|
|
25
|
-
.find(selector)
|
|
26
|
-
.toArray()
|
|
27
|
-
.map(el => new CheerioElement(this.$, el as Element));
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
class CheerioDocument implements HtmlDocument {
|
|
32
|
-
constructor(private readonly $: cheerio.CheerioAPI) {}
|
|
33
|
-
|
|
34
|
-
select(selector: string): HtmlElement[] {
|
|
35
|
-
return this.$(selector)
|
|
36
|
-
.toArray()
|
|
37
|
-
.map(el => new CheerioElement(this.$, el as Element));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
selectOne(selector: string): HtmlElement | null {
|
|
41
|
-
return this.select(selector)[0] ?? null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class CheerioHtmlParser implements HtmlParser {
|
|
46
|
-
parse(html: string): HtmlDocument {
|
|
47
|
-
const $ = cheerio.load(html);
|
|
48
|
-
return new CheerioDocument($);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
package/src/adapters/openbd.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import type { PublisherDeps } from "../domain/publisher.js";
|
|
2
|
-
import type { BookRecord } from "../domain/book.js";
|
|
3
|
-
import { fetchText } from "./publishers/base.js";
|
|
4
|
-
|
|
5
|
-
const OPENBD_API_URL = "https://api.openbd.jp/v1/get";
|
|
6
|
-
|
|
7
|
-
// --- 型定義 ---
|
|
8
|
-
|
|
9
|
-
interface OpenBDSummary {
|
|
10
|
-
isbn: string;
|
|
11
|
-
title: string;
|
|
12
|
-
publisher: string;
|
|
13
|
-
pubdate: string; // "YYYYMMDD"
|
|
14
|
-
cover: string; // "https://cover.openbd.jp/{isbn}.jpg"
|
|
15
|
-
author: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface OpenBDTextContent {
|
|
19
|
-
TextType: string; // "02": 短い説明, "03": 説明文, "04": 目次
|
|
20
|
-
ContentAudience: string;
|
|
21
|
-
Text: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface OpenBDPrice {
|
|
25
|
-
PriceType: string; // "03": 税込定価
|
|
26
|
-
PriceAmount: string;
|
|
27
|
-
CurrencyCode: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface OpenBDHanmoto {
|
|
31
|
-
isbn: string;
|
|
32
|
-
storelink?: string;
|
|
33
|
-
[key: string]: unknown;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface OpenBDEntry {
|
|
37
|
-
summary: OpenBDSummary;
|
|
38
|
-
hanmoto?: OpenBDHanmoto;
|
|
39
|
-
onix: {
|
|
40
|
-
CollateralDetail?: {
|
|
41
|
-
TextContent?: OpenBDTextContent[];
|
|
42
|
-
};
|
|
43
|
-
ProductSupply?: {
|
|
44
|
-
SupplyDetail?: {
|
|
45
|
-
Price?: OpenBDPrice[];
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// --- ユーティリティ ---
|
|
52
|
-
|
|
53
|
-
function parsePubDate(pubdate: string): string | undefined {
|
|
54
|
-
if (!pubdate || pubdate.length < 8) return undefined;
|
|
55
|
-
return `${pubdate.slice(0, 4)}-${pubdate.slice(4, 6)}-${pubdate.slice(6, 8)}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function findTextByType(entry: OpenBDEntry, ...types: string[]): string | undefined {
|
|
59
|
-
const texts = entry.onix.CollateralDetail?.TextContent;
|
|
60
|
-
if (!texts) return undefined;
|
|
61
|
-
for (const type of types) {
|
|
62
|
-
const found = texts.find(t => t.TextType === type);
|
|
63
|
-
if (found?.Text) return found.Text;
|
|
64
|
-
}
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function getTaxIncludedPrice(entry: OpenBDEntry): number | undefined {
|
|
69
|
-
const prices = entry.onix.ProductSupply?.SupplyDetail?.Price;
|
|
70
|
-
if (!prices) return undefined;
|
|
71
|
-
// PriceType "03" = 税込定価
|
|
72
|
-
const price = prices.find(p => p.PriceType === "03");
|
|
73
|
-
if (!price) return undefined;
|
|
74
|
-
const amount = parseInt(price.PriceAmount, 10);
|
|
75
|
-
return isNaN(amount) ? undefined : amount;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// --- 公開API ---
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* openBD API から複数ISBNの書誌情報を一括取得する。
|
|
82
|
-
* @returns ISBNをキーとするMapを返す。該当なし・取得失敗のISBNは含まれない。
|
|
83
|
-
*/
|
|
84
|
-
export async function fetchOpenBDBooks(
|
|
85
|
-
isbns: string[],
|
|
86
|
-
deps: PublisherDeps,
|
|
87
|
-
): Promise<Map<string, OpenBDEntry>> {
|
|
88
|
-
if (isbns.length === 0) return new Map();
|
|
89
|
-
|
|
90
|
-
const url = `${OPENBD_API_URL}?isbn=${isbns.join(",")}`;
|
|
91
|
-
const text = await fetchText(url, deps);
|
|
92
|
-
const data: (OpenBDEntry | null)[] = JSON.parse(text);
|
|
93
|
-
|
|
94
|
-
const result = new Map<string, OpenBDEntry>();
|
|
95
|
-
for (let i = 0; i < isbns.length; i++) {
|
|
96
|
-
const entry = data[i];
|
|
97
|
-
if (entry !== null && entry !== undefined) {
|
|
98
|
-
result.set(isbns[i], entry);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return result;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* openBD エントリを BookRecord に変換する。
|
|
106
|
-
* 出版社サイトから取得できない場合のフォールバック用。
|
|
107
|
-
* url には hanmoto.storelink を使用し、なければ openBD API URL を使用する。
|
|
108
|
-
*/
|
|
109
|
-
export function openBDEntryToBookRecord(entry: OpenBDEntry): BookRecord {
|
|
110
|
-
const { summary } = entry;
|
|
111
|
-
const storelink = entry.hanmoto?.storelink;
|
|
112
|
-
|
|
113
|
-
const authors = summary.author
|
|
114
|
-
? summary.author.split(/[\//、,,]/).map(a => a.trim()).filter(Boolean)
|
|
115
|
-
: [];
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
title: summary.title,
|
|
119
|
-
authors,
|
|
120
|
-
publisher: summary.publisher,
|
|
121
|
-
isbn: summary.isbn,
|
|
122
|
-
publishedAt: parsePubDate(summary.pubdate),
|
|
123
|
-
url: storelink ?? `https://api.openbd.jp/v1/get?isbn=${summary.isbn}`,
|
|
124
|
-
price: getTaxIncludedPrice(entry),
|
|
125
|
-
coverImageUrl: summary.cover || undefined,
|
|
126
|
-
description: findTextByType(entry, "03", "02"),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* openBD の書誌情報で BookRecord の欠損フィールドを補完する。
|
|
132
|
-
* 既存のフィールドは上書きしない。
|
|
133
|
-
*/
|
|
134
|
-
export function enrichWithOpenBD(book: BookRecord, entry: OpenBDEntry): BookRecord {
|
|
135
|
-
return {
|
|
136
|
-
...book,
|
|
137
|
-
publishedAt: book.publishedAt ?? parsePubDate(entry.summary.pubdate),
|
|
138
|
-
price: book.price ?? getTaxIncludedPrice(entry),
|
|
139
|
-
coverImageUrl: book.coverImageUrl ?? (entry.summary.cover || undefined),
|
|
140
|
-
description: book.description ?? findTextByType(entry, "03", "02"),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import iconv from "iconv-lite";
|
|
2
|
-
import type { PublisherDeps } from "../../domain/publisher.js";
|
|
3
|
-
import type { EbookStore, DrmType } from "../../domain/book.js";
|
|
4
|
-
import type { HtmlDocument } from "../../ports/html-parser.js";
|
|
5
|
-
|
|
6
|
-
const DEFAULT_HEADERS = {
|
|
7
|
-
"User-Agent": "techbook-mcp/0.1.0 (+https://github.com/zonuexe/techbook-mcp; bibliographic search bot)",
|
|
8
|
-
"Accept": "text/html,application/xhtml+xml,application/json",
|
|
9
|
-
"Accept-Language": "ja,en;q=0.9",
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export const CACHE_TTL_SECONDS = 3600; // 1時間
|
|
13
|
-
export const ROBOTS_CACHE_TTL_SECONDS = 6 * 3600; // 6時間
|
|
14
|
-
|
|
15
|
-
// --- robots.txt チェック ---
|
|
16
|
-
|
|
17
|
-
/** robots.txt の1ルール */
|
|
18
|
-
interface RobotsRule {
|
|
19
|
-
type: "allow" | "disallow";
|
|
20
|
-
path: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** robots.txt のユーザーエージェントセクション */
|
|
24
|
-
interface RobotsSection {
|
|
25
|
-
agents: string[];
|
|
26
|
-
rules: RobotsRule[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** robots.txt をパースしてセクション一覧を返す */
|
|
30
|
-
function parseRobotsTxt(content: string): RobotsSection[] {
|
|
31
|
-
const sections: RobotsSection[] = [];
|
|
32
|
-
let current: RobotsSection | null = null;
|
|
33
|
-
let inAgentBlock = true;
|
|
34
|
-
|
|
35
|
-
for (const rawLine of content.split(/\r?\n/)) {
|
|
36
|
-
const trimmedRaw = rawLine.trim();
|
|
37
|
-
// 空行(コメント行ではない)のみセクションをリセット
|
|
38
|
-
if (!trimmedRaw || trimmedRaw.startsWith("#")) {
|
|
39
|
-
if (!trimmedRaw) {
|
|
40
|
-
current = null;
|
|
41
|
-
inAgentBlock = true;
|
|
42
|
-
}
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const line = trimmedRaw.split("#")[0].trim();
|
|
47
|
-
if (!line) continue;
|
|
48
|
-
|
|
49
|
-
const colonIdx = line.indexOf(":");
|
|
50
|
-
if (colonIdx === -1) continue;
|
|
51
|
-
|
|
52
|
-
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
53
|
-
const value = line.slice(colonIdx + 1).trim();
|
|
54
|
-
|
|
55
|
-
if (key === "user-agent") {
|
|
56
|
-
if (inAgentBlock && current !== null) {
|
|
57
|
-
// 同じセクションに複数のUser-agent行
|
|
58
|
-
current.agents.push(value.toLowerCase());
|
|
59
|
-
} else {
|
|
60
|
-
// 新しいセクション開始
|
|
61
|
-
current = { agents: [value.toLowerCase()], rules: [] };
|
|
62
|
-
sections.push(current);
|
|
63
|
-
inAgentBlock = true;
|
|
64
|
-
}
|
|
65
|
-
} else if (current !== null && (key === "allow" || key === "disallow")) {
|
|
66
|
-
inAgentBlock = false;
|
|
67
|
-
current.rules.push({ type: key, path: value });
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return sections;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** 指定ユーザーエージェントに適用されるルールを返す(固有エージェント優先、なければ * にフォールバック) */
|
|
75
|
-
function getRulesForAgent(sections: RobotsSection[], agentToken: string): RobotsRule[] {
|
|
76
|
-
const lower = agentToken.toLowerCase();
|
|
77
|
-
|
|
78
|
-
for (const section of sections) {
|
|
79
|
-
if (section.agents.includes(lower)) return section.rules;
|
|
80
|
-
}
|
|
81
|
-
for (const section of sections) {
|
|
82
|
-
if (section.agents.includes("*")) return section.rules;
|
|
83
|
-
}
|
|
84
|
-
return [];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** パスがルール一覧で許可されているか判定する(最長プレフィックス一致) */
|
|
88
|
-
function isPathAllowed(path: string, rules: RobotsRule[]): boolean {
|
|
89
|
-
let bestMatch = { length: -1, allowed: true };
|
|
90
|
-
|
|
91
|
-
for (const rule of rules) {
|
|
92
|
-
if (!rule.path) continue; // 空の Disallow は「全許可」を意味するが不一致として扱う
|
|
93
|
-
|
|
94
|
-
if (path.startsWith(rule.path) && rule.path.length > bestMatch.length) {
|
|
95
|
-
bestMatch = { length: rule.path.length, allowed: rule.type === "allow" };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return bestMatch.allowed;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 指定URLのオリジンの robots.txt を取得してアクセス可否を返す。
|
|
104
|
-
* 取得結果は6時間キャッシュする。エラー時はアクセスを許可する(fail-open)。
|
|
105
|
-
*/
|
|
106
|
-
export async function checkRobotsTxt(url: string, deps: PublisherDeps): Promise<boolean> {
|
|
107
|
-
const parsed = new URL(url);
|
|
108
|
-
const origin = `${parsed.protocol}//${parsed.host}`;
|
|
109
|
-
const cacheKey = `robots:${origin}`;
|
|
110
|
-
|
|
111
|
-
let content: string;
|
|
112
|
-
const cached = await deps.cache.get(cacheKey);
|
|
113
|
-
|
|
114
|
-
if (cached !== null) {
|
|
115
|
-
content = cached;
|
|
116
|
-
} else {
|
|
117
|
-
try {
|
|
118
|
-
const response = await deps.http.get(`${origin}/robots.txt`, { headers: DEFAULT_HEADERS });
|
|
119
|
-
content = response.status === 200 ? await response.text() : "";
|
|
120
|
-
} catch {
|
|
121
|
-
// robots.txt 取得失敗時はアクセスを許可する
|
|
122
|
-
content = "";
|
|
123
|
-
}
|
|
124
|
-
await deps.cache.set(cacheKey, content, ROBOTS_CACHE_TTL_SECONDS);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (!content) return true;
|
|
128
|
-
|
|
129
|
-
const sections = parseRobotsTxt(content);
|
|
130
|
-
const rules = getRulesForAgent(sections, "techbook-mcp");
|
|
131
|
-
return isPathAllowed(parsed.pathname + parsed.search, rules);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function fetchText(
|
|
135
|
-
url: string,
|
|
136
|
-
deps: PublisherDeps,
|
|
137
|
-
extraHeaders?: Record<string, string>,
|
|
138
|
-
): Promise<string> {
|
|
139
|
-
const cached = await deps.cache.get(url);
|
|
140
|
-
if (cached !== null) return cached;
|
|
141
|
-
|
|
142
|
-
const headers = extraHeaders
|
|
143
|
-
? { ...DEFAULT_HEADERS, ...extraHeaders }
|
|
144
|
-
: DEFAULT_HEADERS;
|
|
145
|
-
|
|
146
|
-
const response = await deps.http.get(url, { headers });
|
|
147
|
-
if (response.status !== 200) {
|
|
148
|
-
throw new Error(`HTTP ${response.status}: ${url}`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const text = await response.text();
|
|
152
|
-
await deps.cache.set(url, text, CACHE_TTL_SECONDS);
|
|
153
|
-
return text;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** HTMLタグを除去する(gihyo APIのauthorフィールドのruby markup除去に使用) */
|
|
157
|
-
export function stripHtmlTags(html: string): string {
|
|
158
|
-
return html.replace(/<[^>]+>/g, "");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* キーワードを EUC-JP でパーセントエンコードする。
|
|
163
|
-
* born-digital・rutles など EUC-JP エンコードのみ受け付けるサイト向け。
|
|
164
|
-
*/
|
|
165
|
-
export function encodeEucJp(text: string): string {
|
|
166
|
-
const bytes = iconv.encode(text, "euc-jp");
|
|
167
|
-
return Array.from(bytes)
|
|
168
|
-
.map(b => "%" + b.toString(16).toUpperCase().padStart(2, "0"))
|
|
169
|
-
.join("");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* "2026年3月25日" → "2026-03-25"
|
|
174
|
-
* 1桁の月・日も対応する。
|
|
175
|
-
*/
|
|
176
|
-
export function parseJapaneseDateToISO(text: string): string | undefined {
|
|
177
|
-
const m = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
|
|
178
|
-
if (!m) return undefined;
|
|
179
|
-
return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 著者名末尾の役割語(著・訳・編・監修・監訳など)を除去して名前だけを返す。
|
|
184
|
-
* 例: "Dan Vanderkam 著" → "Dan Vanderkam"
|
|
185
|
-
*/
|
|
186
|
-
export function stripAuthorRole(name: string): string {
|
|
187
|
-
return name.replace(/[\u3000\s]*(著|訳|編|監修|監訳|著訳|著・訳|他)[\u3000\s]*$/, "").trim();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** "¥3,960" や "3,300円(税込)" などから整数値を取り出す */
|
|
191
|
-
export function parseJapanesePrice(text: string): number | undefined {
|
|
192
|
-
const match = text.match(/[\d,]+/);
|
|
193
|
-
if (!match) return undefined;
|
|
194
|
-
return parseInt(match[0].replace(/,/g, ""), 10);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** 相対URLを絶対URLに解決する */
|
|
198
|
-
export function resolveUrl(base: string, path: string): string {
|
|
199
|
-
return new URL(path, base).toString();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* HTMLテキストから Amazon ASIN を抽出する。
|
|
204
|
-
* amazon.co.jp/dp/{ASIN}, /gp/product/{ASIN}, /o/ASIN/{ASIN} 形式に対応。
|
|
205
|
-
*/
|
|
206
|
-
export function extractAsin(html: string): string | undefined {
|
|
207
|
-
const match = html.match(/amazon\.co\.jp\/(?:dp|gp\/product|o\/ASIN)\/([A-Z0-9]{10})/);
|
|
208
|
-
return match?.[1];
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// --- 電子書籍ストア分類 ---
|
|
212
|
-
|
|
213
|
-
interface StorePattern {
|
|
214
|
-
pattern: RegExp;
|
|
215
|
-
name: string;
|
|
216
|
-
drm: DrmType;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const EBOOK_STORE_PATTERNS: StorePattern[] = [
|
|
220
|
-
// DRM-free
|
|
221
|
-
{ pattern: /techbookfest\.org\/product\//, name: "技術書典", drm: "free" },
|
|
222
|
-
{ pattern: /oreilly\.co\.jp\/books\//, name: "オライリー・ジャパン", drm: "free" },
|
|
223
|
-
{ pattern: /shop\.rutles\.net\//, name: "ラトルズ", drm: "free" },
|
|
224
|
-
{ pattern: /peaks\.cc\/books\//, name: "PEAKS", drm: "free" },
|
|
225
|
-
{ pattern: /optronics-ebook\.com\/products\//, name: "オプトロニクス社", drm: "free" },
|
|
226
|
-
{ pattern: /gihyo\.jp\/dp\/ebook\//, name: "Gihyo Digital Publishing", drm: "social" },
|
|
227
|
-
{ pattern: /seshop\.com\/product\//, name: "SEshop", drm: "social" },
|
|
228
|
-
{ pattern: /book-tech\.com\/books\//, name: "BOOK TECH", drm: "social" },
|
|
229
|
-
{ pattern: /wgn-obs\.shop-pro\.jp\/\?pid=/, name: "ボーンデジタル", drm: "social" },
|
|
230
|
-
// ソーシャルDRM (購入時生成IDまたは購入者情報を透かし刻印、技術的制限なし)
|
|
231
|
-
{ pattern: /book\.mynavi\.jp\/manatee\//, name: "マナティ", drm: "social" },
|
|
232
|
-
{ pattern: /www\.lambdanote\.com\/products\//, name: "ラムダノート", drm: "social" },
|
|
233
|
-
{ pattern: /tatsu-zine\.com\/books\/(?!pub\/)/, name: "達人出版会", drm: "social" },
|
|
234
|
-
// ソーシャルDRM (購入者情報透かし入りPDF、技術的制限なし)
|
|
235
|
-
{ pattern: /book\.impress\.co\.jp\/books\//, name: "インプレスブックス", drm: "social" },
|
|
236
|
-
// DRM-attached
|
|
237
|
-
{ pattern: /saiensu\.co\.jp/, name: "サイエンス社", drm: "password_pdf" },
|
|
238
|
-
{ pattern: /amazon\.co\.jp/, name: "Kindle", drm: "drm" },
|
|
239
|
-
{ pattern: /kinokuniya\.co\.jp\/(?:kinoppystore|f\/dsg-08)/, name: "Kinoppy", drm: "drm" },
|
|
240
|
-
{ pattern: /coop-ebook\.jp\/mem\//, name: "VarsityWave eBooks", drm: "drm" },
|
|
241
|
-
{ pattern: /books\.rakuten\.co\.jp|rakuten\.kobo\.com|kobo\.com/, name: "楽天Kobo", drm: "drm" },
|
|
242
|
-
{ pattern: /booklive\.jp/, name: "BookLive", drm: "drm" },
|
|
243
|
-
{ pattern: /honto\.jp/, name: "honto", drm: "drm" },
|
|
244
|
-
{ pattern: /bookwalker\.jp/, name: "BOOK☆WALKER", drm: "drm" },
|
|
245
|
-
{ pattern: /ebookjapan\.yahoo\.co\.jp/, name: "eBookJapan", drm: "drm" },
|
|
246
|
-
{ pattern: /store\.line\.me/, name: "LINEマンガ", drm: "drm" },
|
|
247
|
-
];
|
|
248
|
-
|
|
249
|
-
/** URLから電子書籍ストア情報を返す。未知のストアは null。 */
|
|
250
|
-
export function classifyEbookStore(url: string): EbookStore | null {
|
|
251
|
-
for (const { pattern, name, drm } of EBOOK_STORE_PATTERNS) {
|
|
252
|
-
if (pattern.test(url)) {
|
|
253
|
-
return { name, url, drm };
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* HTMLドキュメント内の全リンクを走査して電子書籍ストアを抽出する。
|
|
261
|
-
* 同一ストアのURLが複数あれば最初の1件のみ返す。
|
|
262
|
-
*/
|
|
263
|
-
export function extractEbookStoresFromDoc(doc: HtmlDocument): EbookStore[] {
|
|
264
|
-
const stores: EbookStore[] = [];
|
|
265
|
-
const seenNames = new Set<string>();
|
|
266
|
-
|
|
267
|
-
for (const link of doc.select("a[href]")) {
|
|
268
|
-
const href = link.attr("href");
|
|
269
|
-
if (!href) continue;
|
|
270
|
-
|
|
271
|
-
const store = classifyEbookStore(href);
|
|
272
|
-
if (store && !seenNames.has(store.name)) {
|
|
273
|
-
seenNames.add(store.name);
|
|
274
|
-
stores.push(store);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return stores;
|
|
279
|
-
}
|