@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.
- package/CHANGELOG.md +28 -1
- package/README.md +39 -20
- package/dist/adapters/calil.d.ts +10 -0
- package/dist/adapters/calil.d.ts.map +1 -0
- package/dist/adapters/calil.js +45 -0
- package/dist/adapters/calil.js.map +1 -0
- package/dist/adapters/openbd.d.ts +57 -0
- package/dist/adapters/openbd.d.ts.map +1 -0
- package/dist/adapters/openbd.js +87 -0
- package/dist/adapters/openbd.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 +75 -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/registry.d.ts.map +1 -1
- package/dist/adapters/publishers/registry.js +4 -0
- package/dist/adapters/publishers/registry.js.map +1 -1
- package/dist/adapters/publishers/tatsu-zine.d.ts.map +1 -1
- package/dist/adapters/publishers/tatsu-zine.js +6 -18
- package/dist/adapters/publishers/tatsu-zine.js.map +1 -1
- package/dist/application/get-book-by-isbn.d.ts +13 -0
- package/dist/application/get-book-by-isbn.d.ts.map +1 -0
- package/dist/application/get-book-by-isbn.js +61 -0
- package/dist/application/get-book-by-isbn.js.map +1 -0
- package/dist/application/get-book-detail.d.ts.map +1 -1
- package/dist/application/get-book-detail.js +16 -1
- package/dist/application/get-book-detail.js.map +1 -1
- package/dist/application/search-books.d.ts.map +1 -1
- package/dist/application/search-books.js +20 -0
- 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/main.js +15 -1
- package/dist/main.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +10 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +16 -0
- 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/flake.lock +61 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -36
- 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/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/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 -154
- package/src/adapters/publishers/techbookfest.ts +0 -179
- package/src/application/get-book-detail.ts +0 -24
- package/src/application/search-books.ts +0 -44
- 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 -103
- package/src/mcp/tools.ts +0 -54
- 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/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/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 -22
- 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/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-detail.test.ts +0 -102
- package/tests/unit/application/search-books.test.ts +0 -137
- package/tsconfig.json +0 -17
|
@@ -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
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
|
|
2
|
-
import type { BookRecord, SearchQuery } from "../../domain/book.js";
|
|
3
|
-
import { fetchText, parseJapanesePrice, resolveUrl, encodeEucJp, parseJapaneseDateToISO } from "./base.js";
|
|
4
|
-
|
|
5
|
-
const BASE_URL = "https://wgn-obs.shop-pro.jp";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* 商品説明テキストから著者・出版社・発売日を取得する。
|
|
9
|
-
* 以下の2形式に対応:
|
|
10
|
-
* - "著者\tヘイドン・ピカリング<br />" (タブ区切り)
|
|
11
|
-
* - "著者:太田 良典、中村 直樹<br />" (全角コロン区切り)
|
|
12
|
-
*/
|
|
13
|
-
function parseDescription(text: string): {
|
|
14
|
-
authors: string[];
|
|
15
|
-
publisher: string;
|
|
16
|
-
publishedAt: string | undefined;
|
|
17
|
-
} {
|
|
18
|
-
const authors: string[] = [];
|
|
19
|
-
let publisher = "ボーンデジタル";
|
|
20
|
-
let publishedAt: string | undefined;
|
|
21
|
-
|
|
22
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
23
|
-
const line = rawLine.trim();
|
|
24
|
-
const m = line.match(/^(.+?)[\t:]\s*(.+)$/);
|
|
25
|
-
if (!m) continue;
|
|
26
|
-
const key = m[1].trim();
|
|
27
|
-
const value = m[2].trim();
|
|
28
|
-
|
|
29
|
-
if (key === "著者" || key === "著") {
|
|
30
|
-
authors.push(...value.split(/[、,,]/).map(s => s.trim()).filter(Boolean));
|
|
31
|
-
} else if (key === "翻訳" || key === "翻訳者" || key === "訳者" || key === "訳") {
|
|
32
|
-
authors.push(...value.split(/[、,,]/).map(s => s.trim()).filter(Boolean));
|
|
33
|
-
} else if (key.startsWith("発行") || key === "発売") {
|
|
34
|
-
publisher = value.replace(/^株式会社\s*/, "").replace(/\s*株式会社$/, "").trim();
|
|
35
|
-
} else if (key === "発売日") {
|
|
36
|
-
publishedAt = parseJapaneseDateToISO(value);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return { authors, publisher, publishedAt };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* ページ埋め込みの Colorme JSON から商品情報を取得する。
|
|
45
|
-
* `var Colorme = {...};` 形式。
|
|
46
|
-
*/
|
|
47
|
-
function extractColormeProduct(html: string): { price?: number } {
|
|
48
|
-
const m = html.match(/var Colorme = (\{[^\n]+\});/);
|
|
49
|
-
if (!m) return {};
|
|
50
|
-
try {
|
|
51
|
-
const data = JSON.parse(m[1]) as {
|
|
52
|
-
product?: { sales_price_including_tax?: number };
|
|
53
|
-
};
|
|
54
|
-
const price = data.product?.sales_price_including_tax;
|
|
55
|
-
return { price };
|
|
56
|
-
} catch {
|
|
57
|
-
return {};
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export const bornDigitalAdapter: PublisherAdapter = {
|
|
62
|
-
id: "born-digital",
|
|
63
|
-
name: "ボーンデジタル",
|
|
64
|
-
baseUrl: BASE_URL,
|
|
65
|
-
|
|
66
|
-
async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
|
|
67
|
-
const word = [query.title, query.author].filter(Boolean).join(" ");
|
|
68
|
-
if (!word) return [];
|
|
69
|
-
|
|
70
|
-
const url = `${BASE_URL}/?mode=srh&keyword=${encodeEucJp(word)}`;
|
|
71
|
-
const html = await fetchText(url, deps);
|
|
72
|
-
const doc = deps.parser.parse(html);
|
|
73
|
-
|
|
74
|
-
const results: BookRecord[] = [];
|
|
75
|
-
const limit = query.limit ?? 10;
|
|
76
|
-
|
|
77
|
-
for (const item of doc.select("li.c-product-list__item")) {
|
|
78
|
-
const titleEl = item.find("a.c-product-list__name")[0];
|
|
79
|
-
const title = titleEl?.text().trim();
|
|
80
|
-
if (!title) continue;
|
|
81
|
-
|
|
82
|
-
// 電子書籍のみ: タイトルが【電子書籍版】または【PDFダウンロード版】で始まる
|
|
83
|
-
if (!title.startsWith("【")) continue;
|
|
84
|
-
|
|
85
|
-
const href = item.find("a.c-product-list__image-wrap")[0]?.attr("href")
|
|
86
|
-
?? titleEl?.attr("href");
|
|
87
|
-
if (!href) continue;
|
|
88
|
-
const bookUrl = resolveUrl(BASE_URL + "/", href);
|
|
89
|
-
|
|
90
|
-
const priceText = item.find(".c-product-list__price")[0]?.text();
|
|
91
|
-
const price = priceText ? parseJapanesePrice(priceText) : undefined;
|
|
92
|
-
|
|
93
|
-
const imgEl = item.find("a.c-product-list__image-wrap img.c-image-box__image")[0];
|
|
94
|
-
const coverImageUrl = imgEl?.attr("src") ?? undefined;
|
|
95
|
-
|
|
96
|
-
results.push({
|
|
97
|
-
title,
|
|
98
|
-
authors: [],
|
|
99
|
-
publisher: "ボーンデジタル",
|
|
100
|
-
url: bookUrl,
|
|
101
|
-
price,
|
|
102
|
-
coverImageUrl,
|
|
103
|
-
ebookStores: [{ name: "ボーンデジタル", url: bookUrl, drm: "social" }],
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
if (results.length >= limit) break;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return results;
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
|
|
113
|
-
const html = await fetchText(url, deps);
|
|
114
|
-
const doc = deps.parser.parse(html);
|
|
115
|
-
|
|
116
|
-
// Colorme JSON から価格を取得
|
|
117
|
-
const { price: colormePrice } = extractColormeProduct(html);
|
|
118
|
-
|
|
119
|
-
const title = doc.selectOne(".p-cart-form__name")?.text().trim()
|
|
120
|
-
?? doc.selectOne(".p-product-body__name")?.text().trim()
|
|
121
|
-
?? "";
|
|
122
|
-
|
|
123
|
-
const priceText = doc.selectOne(".c-product-info__price")?.text();
|
|
124
|
-
const price = colormePrice ?? (priceText ? parseJapanesePrice(priceText) : undefined);
|
|
125
|
-
|
|
126
|
-
const descText = doc.selectOne(".p-product-body__description")?.text() ?? "";
|
|
127
|
-
const { authors, publisher, publishedAt } = parseDescription(descText);
|
|
128
|
-
|
|
129
|
-
const imgEl = doc.selectOne(".p-large-image img");
|
|
130
|
-
const coverImageUrl = imgEl?.attr("src") ?? undefined;
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
title,
|
|
134
|
-
authors,
|
|
135
|
-
publisher,
|
|
136
|
-
url,
|
|
137
|
-
price,
|
|
138
|
-
publishedAt,
|
|
139
|
-
coverImageUrl,
|
|
140
|
-
ebookStores: [{ name: "ボーンデジタル", url, drm: "social" }],
|
|
141
|
-
};
|
|
142
|
-
},
|
|
143
|
-
};
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import type { PublisherAdapter, PublisherDeps } from "../../domain/publisher.js";
|
|
2
|
-
import type { BookRecord, SearchQuery } from "../../domain/book.js";
|
|
3
|
-
import type { HtmlElement } from "../../ports/html-parser.js";
|
|
4
|
-
import { fetchText, parseJapanesePrice, resolveUrl, extractEbookStoresFromDoc } from "./base.js";
|
|
5
|
-
|
|
6
|
-
const BASE_URL = "https://www.coronasha.co.jp";
|
|
7
|
-
const SEARCH_URL = `${BASE_URL}/np/result.html`;
|
|
8
|
-
|
|
9
|
-
/** "2023/05/01" → "2023-05-01" */
|
|
10
|
-
function parseDate(text: string): string | undefined {
|
|
11
|
-
const m = text.match(/(\d{4})\/(\d{2})\/(\d{2})/);
|
|
12
|
-
if (!m) return undefined;
|
|
13
|
-
return `${m[1]}-${m[2]}-${m[3]}`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* `.tunogaki` と `.book-title` からタイトルを組み立てる。
|
|
18
|
-
* "1から始める" + "Juliaプログラミング" → "1から始める Juliaプログラミング"
|
|
19
|
-
*/
|
|
20
|
-
function buildTitle(container: HtmlElement): string {
|
|
21
|
-
const tunogaki = container.find(".tunogaki")[0]?.text().trim();
|
|
22
|
-
const bookTitle = container.find(".book-title")[0]?.text().trim() ?? "";
|
|
23
|
-
return tunogaki ? `${tunogaki} ${bookTitle}` : bookTitle;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* `dl` 要素の配列から `dt` をキー、`dd` をバリューとするマップを返す。
|
|
28
|
-
* 価格・ISBN・発行年月日などの取得に使う。
|
|
29
|
-
*/
|
|
30
|
-
function parseDlMap(dls: HtmlElement[]): Map<string, string> {
|
|
31
|
-
const map = new Map<string, string>();
|
|
32
|
-
for (const dl of dls) {
|
|
33
|
-
const key = dl.find("dt")[0]?.text().trim();
|
|
34
|
-
const val = dl.find("dd")[0]?.text().trim();
|
|
35
|
-
if (key && val) map.set(key, val);
|
|
36
|
-
}
|
|
37
|
-
return map;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const coronashaAdapter: PublisherAdapter = {
|
|
41
|
-
id: "coronasha",
|
|
42
|
-
name: "コロナ社",
|
|
43
|
-
baseUrl: BASE_URL,
|
|
44
|
-
|
|
45
|
-
async search(query: SearchQuery, deps: PublisherDeps): Promise<BookRecord[]> {
|
|
46
|
-
const word = [query.title, query.author].filter(Boolean).join(" ");
|
|
47
|
-
if (!word) return [];
|
|
48
|
-
|
|
49
|
-
const url = `${SEARCH_URL}?q=${encodeURIComponent(word)}`;
|
|
50
|
-
const html = await fetchText(url, deps);
|
|
51
|
-
const doc = deps.parser.parse(html);
|
|
52
|
-
|
|
53
|
-
const results: BookRecord[] = [];
|
|
54
|
-
const limit = query.limit ?? 10;
|
|
55
|
-
|
|
56
|
-
for (const item of doc.select("article.item")) {
|
|
57
|
-
// 電子版があるものだけ対象
|
|
58
|
-
const hasEbook = item.find("ul.status-list li")
|
|
59
|
-
.some(el => el.text().trim() === "電子版あり");
|
|
60
|
-
if (!hasEbook) continue;
|
|
61
|
-
|
|
62
|
-
const linkEl = item.find("dl.col1 dt a")[0];
|
|
63
|
-
const href = linkEl?.attr("href");
|
|
64
|
-
if (!href) continue;
|
|
65
|
-
const bookUrl = resolveUrl(BASE_URL, href);
|
|
66
|
-
|
|
67
|
-
const title = buildTitle(item);
|
|
68
|
-
if (!title) continue;
|
|
69
|
-
|
|
70
|
-
const authors = item.find("ul.authors li a")
|
|
71
|
-
.map(el => el.text().trim())
|
|
72
|
-
.filter(Boolean);
|
|
73
|
-
|
|
74
|
-
const info = parseDlMap(item.find(".book-info dl"));
|
|
75
|
-
const price = parseJapanesePrice(info.get("定価") ?? "");
|
|
76
|
-
const publishedAt = parseDate(info.get("発行年月日") ?? "");
|
|
77
|
-
const isbn = info.get("ISBN")?.replace(/-/g, "") || undefined;
|
|
78
|
-
|
|
79
|
-
const imgEl = item.find("img.cover")[0];
|
|
80
|
-
const coverSrc = imgEl?.attr("src");
|
|
81
|
-
const coverImageUrl = coverSrc ? resolveUrl(BASE_URL, coverSrc) : undefined;
|
|
82
|
-
|
|
83
|
-
results.push({
|
|
84
|
-
title,
|
|
85
|
-
authors,
|
|
86
|
-
publisher: "コロナ社",
|
|
87
|
-
url: bookUrl,
|
|
88
|
-
isbn,
|
|
89
|
-
price,
|
|
90
|
-
publishedAt,
|
|
91
|
-
coverImageUrl,
|
|
92
|
-
ebookStores: [],
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (results.length >= limit) break;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return results;
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
async getDetail(url: string, deps: PublisherDeps): Promise<BookRecord> {
|
|
102
|
-
const html = await fetchText(url, deps);
|
|
103
|
-
const doc = deps.parser.parse(html);
|
|
104
|
-
|
|
105
|
-
const h2 = doc.selectOne("h2.title");
|
|
106
|
-
const title = h2 ? buildTitle(h2) : "";
|
|
107
|
-
|
|
108
|
-
const authors = doc.select("ul.authors li a")
|
|
109
|
-
.map(el => el.text().trim())
|
|
110
|
-
.filter(Boolean);
|
|
111
|
-
|
|
112
|
-
const info = parseDlMap(doc.select(".book-info dl"));
|
|
113
|
-
const publishedAt = parseDate(info.get("発行年月日") ?? "");
|
|
114
|
-
const isbn = info.get("ISBN")?.replace(/-/g, "") || undefined;
|
|
115
|
-
|
|
116
|
-
// 価格はサイドバーの .price に表示される(book-info dl には含まれない)
|
|
117
|
-
const priceText = doc.selectOne(".price")?.text();
|
|
118
|
-
const price = priceText ? parseJapanesePrice(priceText) : undefined;
|
|
119
|
-
|
|
120
|
-
const imgEl = doc.selectOne("img.cover");
|
|
121
|
-
const coverSrc = imgEl?.attr("src");
|
|
122
|
-
const coverImageUrl = coverSrc ? resolveUrl(BASE_URL, coverSrc) : undefined;
|
|
123
|
-
|
|
124
|
-
// 電子版購入ポップアップのリンクから電子書籍ストアを自動検出
|
|
125
|
-
const ebookStores = extractEbookStoresFromDoc(doc);
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
title,
|
|
129
|
-
authors,
|
|
130
|
-
publisher: "コロナ社",
|
|
131
|
-
url,
|
|
132
|
-
isbn,
|
|
133
|
-
price,
|
|
134
|
-
publishedAt,
|
|
135
|
-
coverImageUrl,
|
|
136
|
-
ebookStores,
|
|
137
|
-
};
|
|
138
|
-
},
|
|
139
|
-
};
|