sbuilder-mcp 0.36.0 → 0.37.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/CHANGELOG.md +12 -0
- package/CHANGELOG.vi.md +8 -0
- package/dist/tools/api.js +31 -1
- package/dist/tools/importpage.js +18 -3
- package/dist/vision/capture.js +73 -6
- package/dist/vision/shoot.js +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ All notable changes to this project are documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.37.0] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import and sb_import_site now take a `nav_timeout_ms` argument (5,000-120,000ms, default 30,000) to raise how long a capture waits for a slow origin to answer at all.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- sb_import_site's timeout failure now reports the number of seconds the page actually waited and suggests raising `nav_timeout_ms`, instead of always naming the unmodified default even after a caller had already raised it.
|
|
16
|
+
|
|
17
|
+
## [0.36.1] - 2026-09-11
|
|
18
|
+
|
|
19
|
+
- fix(import,api): a capture waited on iframes, and a recovery list was unreadable
|
|
20
|
+
|
|
9
21
|
## [0.36.0] - 2026-09-11
|
|
10
22
|
|
|
11
23
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,14 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
|
|
|
6
6
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.37.0] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_import và sb_import_site giờ nhận thêm tham số `nav_timeout_ms` (5.000-120.000ms, mặc định 30.000) để tăng thời gian chờ trang trả lời khi nguồn phản hồi chậm.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Lỗi timeout của sb_import_site giờ báo đúng số giây trang thực sự đã chờ và gợi ý tăng `nav_timeout_ms`, thay vì luôn nêu giá trị mặc định chưa đổi ngay cả khi caller đã tự tăng nó.
|
|
16
|
+
|
|
9
17
|
## [0.36.0] - 2026-09-11
|
|
10
18
|
|
|
11
19
|
### Added
|
package/dist/tools/api.js
CHANGED
|
@@ -45,6 +45,33 @@ function pickFields(item, fields) {
|
|
|
45
45
|
* it would have been and how to narrow the call. A non-list answer is never
|
|
46
46
|
* cut: there is no honest place to stop inside one object.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* LIST OPERATIONS WHOSE EVERY ROW CARRIES A WHOLE PAGE DOCUMENT.
|
|
50
|
+
*
|
|
51
|
+
* The page recovery surface answers with the documents themselves — which is
|
|
52
|
+
* right, since a restore has to have something to restore from — and useless to
|
|
53
|
+
* read. MEASURED against a live server: one version of a TWO-NODE page is 1,690
|
|
54
|
+
* bytes, so a realistic 120-node page runs about 70 KB per version and a listing
|
|
55
|
+
* of twenty is **1.4 MB in one answer**. An agent choosing which version to
|
|
56
|
+
* restore would be handed a truncated blob and no reliable way to pick.
|
|
57
|
+
*
|
|
58
|
+
* `sb_publish` already had this exact problem and the same answer: a published
|
|
59
|
+
* row carries `document`, `html` and `css` for every page the cascade touched,
|
|
60
|
+
* so it PROJECTS the rows. This is that, applied where the caller cannot know to
|
|
61
|
+
* ask — and it is a DEFAULT rather than a rule: an explicit `pick` still wins,
|
|
62
|
+
* so the document is one argument away for a caller that wants to read one.
|
|
63
|
+
*/
|
|
64
|
+
const LIST_PROJECTIONS = {
|
|
65
|
+
'get:/api/sites/{siteId}/pages/{pageId}/versions': [
|
|
66
|
+
'id',
|
|
67
|
+
'versionNo',
|
|
68
|
+
'label',
|
|
69
|
+
'createdBy',
|
|
70
|
+
'createdAt',
|
|
71
|
+
'isLive',
|
|
72
|
+
],
|
|
73
|
+
'get:/api/sites/{siteId}/pages/{pageId}/history': ['id', 'createdBy', 'createdAt'],
|
|
74
|
+
};
|
|
48
75
|
export function shapeResponse(raw, opts) {
|
|
49
76
|
const asked = opts.pick !== undefined || opts.max_items !== undefined;
|
|
50
77
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -271,7 +298,10 @@ export async function callOperation(ctx, args) {
|
|
|
271
298
|
if (raw === null || raw === undefined) {
|
|
272
299
|
return { ok: true, method: op.method, path, note: 'The platform answered with no content.' };
|
|
273
300
|
}
|
|
274
|
-
|
|
301
|
+
// The caller's own `pick` outranks the default: asking for `document` is how
|
|
302
|
+
// you read a version rather than merely choose one.
|
|
303
|
+
const projection = args.pick ?? LIST_PROJECTIONS[op.id];
|
|
304
|
+
return shapeResponse(raw, { pick: projection, max_items: args.max_items });
|
|
275
305
|
}
|
|
276
306
|
export function registerApiTools(server, ctx) {
|
|
277
307
|
server.registerTool('sb_api_find', {
|
package/dist/tools/importpage.js
CHANGED
|
@@ -193,6 +193,13 @@ export function registerImportTools(server, ctx, session) {
|
|
|
193
193
|
.max(100)
|
|
194
194
|
.optional()
|
|
195
195
|
.describe('Default 24 — every image is an upload'),
|
|
196
|
+
nav_timeout_ms: z
|
|
197
|
+
.number()
|
|
198
|
+
.int()
|
|
199
|
+
.min(5_000)
|
|
200
|
+
.max(120_000)
|
|
201
|
+
.optional()
|
|
202
|
+
.describe('How long to wait for a page to answer at all. Default 30000; raise it for a slow origin'),
|
|
196
203
|
max_nodes: z
|
|
197
204
|
.number()
|
|
198
205
|
.int()
|
|
@@ -207,7 +214,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
207
214
|
dry_run: z.boolean().optional(),
|
|
208
215
|
},
|
|
209
216
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
210
|
-
}, async ({ url, site_id: given, max_sections, max_images, max_nodes, upload_images, dry_run }) => {
|
|
217
|
+
}, async ({ url, site_id: given, max_sections, max_images, max_nodes, nav_timeout_ms, upload_images, dry_run }) => {
|
|
211
218
|
const siteId = siteFor(ctx, given);
|
|
212
219
|
// THE TARGET PAGE MUST BE OPEN, and not only because that is where the
|
|
213
220
|
// nodes go: its own heading, button and section are where the tokens come
|
|
@@ -217,6 +224,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
217
224
|
maxSections: max_sections,
|
|
218
225
|
maxImages: max_images,
|
|
219
226
|
maxNodes: max_nodes,
|
|
227
|
+
navTimeoutMs: nav_timeout_ms,
|
|
220
228
|
});
|
|
221
229
|
const tokens = tokensFromPage(doc.doc);
|
|
222
230
|
const images = imageSources(shot.sections);
|
|
@@ -332,13 +340,20 @@ export function registerImportTools(server, ctx, session) {
|
|
|
332
340
|
exclude: z.array(z.string()).optional(),
|
|
333
341
|
max_images: z.number().int().min(0).max(200).optional().describe('Default 24, whole import'),
|
|
334
342
|
max_nodes: z.number().int().min(1).max(1000).optional().describe('Per page, default 300'),
|
|
343
|
+
nav_timeout_ms: z
|
|
344
|
+
.number()
|
|
345
|
+
.int()
|
|
346
|
+
.min(5_000)
|
|
347
|
+
.max(120_000)
|
|
348
|
+
.optional()
|
|
349
|
+
.describe('How long to wait for a page to answer at all. Default 30000; raise it for a slow origin'),
|
|
335
350
|
upload_images: z.boolean().optional(),
|
|
336
351
|
homepage: z.boolean().optional().describe("Entry into this site's home page, default true"),
|
|
337
352
|
nav: z.boolean().optional().describe('Shared header linking the new pages, default true'),
|
|
338
353
|
dry_run: z.boolean().optional(),
|
|
339
354
|
},
|
|
340
355
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
341
|
-
}, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, upload_images, homepage, nav, dry_run, }) => {
|
|
356
|
+
}, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, nav_timeout_ms, upload_images, homepage, nav, dry_run, }) => {
|
|
342
357
|
const siteId = siteFor(ctx, given);
|
|
343
358
|
const entry = normalizeUrl(url);
|
|
344
359
|
if (!entry) {
|
|
@@ -470,7 +485,7 @@ export function registerImportTools(server, ctx, session) {
|
|
|
470
485
|
// 9,829px of catalogue across a dozen collection bands, and 300 cut it
|
|
471
486
|
// off in the middle of the third. A cap is here to stop a runaway page,
|
|
472
487
|
// not to decide how much of an ordinary one survives.
|
|
473
|
-
{ maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900 });
|
|
488
|
+
{ maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900, navTimeoutMs: nav_timeout_ms });
|
|
474
489
|
const byUrl = new Map(shots.map((s) => [s.url, s]));
|
|
475
490
|
// ONE UPLOAD PER IMAGE FOR THE WHOLE SITE, not per page. A logo, a payment
|
|
476
491
|
// strip and a footer badge appear on every page of a real site, and
|
package/dist/vision/capture.js
CHANGED
|
@@ -1075,6 +1075,19 @@ function capturePage(limits) {
|
|
|
1075
1075
|
}
|
|
1076
1076
|
restore(bestState);
|
|
1077
1077
|
}
|
|
1078
|
+
// WHAT FRACTION OF THE PAGE A READER SEES SURVIVED, reported rather than
|
|
1079
|
+
// assumed. Both halves are already computed above for the fallback decision,
|
|
1080
|
+
// so this costs nothing and closes the one failure a capture cannot express:
|
|
1081
|
+
// SETTLING IS NOT FAILING. MEASURED on ttgshop.vn while the origin was taking
|
|
1082
|
+
// 45s to answer — 6 text nodes and 114 characters of a page holding 2,396,
|
|
1083
|
+
// 4.8%, `skipped` empty, no error anywhere. A thin import that says so is one
|
|
1084
|
+
// a caller retries; a silent one ships.
|
|
1085
|
+
//
|
|
1086
|
+
// Against the honest denominator this file already argues for: page chrome is
|
|
1087
|
+
// skipped ON PURPOSE, so counting it would make every correct import of a
|
|
1088
|
+
// nav-heavy site look broken.
|
|
1089
|
+
const kept = textOf(sections);
|
|
1090
|
+
const coverage = contentChars > 0 ? Math.round((kept / contentChars) * 100) : 100;
|
|
1078
1091
|
const link = Array.from(document.querySelectorAll('link[rel="canonical"]'))[0];
|
|
1079
1092
|
const canonical = link ? (link.getAttribute('href') ?? '') : '';
|
|
1080
1093
|
return {
|
|
@@ -1084,6 +1097,7 @@ function capturePage(limits) {
|
|
|
1084
1097
|
...(forms.length ? { forms } : {}),
|
|
1085
1098
|
sections,
|
|
1086
1099
|
skipped,
|
|
1100
|
+
coverage,
|
|
1087
1101
|
};
|
|
1088
1102
|
}
|
|
1089
1103
|
/**
|
|
@@ -1123,18 +1137,49 @@ async function withBrowser(fn) {
|
|
|
1123
1137
|
* `shoot.ts` gives: a page with a poller never goes idle, and waiting for that
|
|
1124
1138
|
* spends the whole budget on a timeout that cannot resolve.
|
|
1125
1139
|
*/
|
|
1126
|
-
|
|
1140
|
+
/**
|
|
1141
|
+
* How long to wait for a page to answer AT ALL, and why it is a knob.
|
|
1142
|
+
*
|
|
1143
|
+
* Thirty seconds is right for the web and wrong for one site on one afternoon.
|
|
1144
|
+
* MEASURED on ttgshop.vn, the same shop this importer was built against: it
|
|
1145
|
+
* answered in 4.6s all morning and then took 45 SECONDS for 151 KB, with the
|
|
1146
|
+
* next request not answering inside 45s at all. Nothing about the page changed
|
|
1147
|
+
* — the origin was simply having a bad day, which is the ordinary condition of
|
|
1148
|
+
* the sites a merchant actually asks to import.
|
|
1149
|
+
*
|
|
1150
|
+
* The default stays 30s, because a caller who says nothing wants a bound rather
|
|
1151
|
+
* than a hang. What was wrong was that the number could not be raised at all:
|
|
1152
|
+
* an import of your OWN slow site had no recourse, and the failure arrives as a
|
|
1153
|
+
* bare Playwright timeout that names the browser rather than the remedy.
|
|
1154
|
+
*/
|
|
1155
|
+
const NAV_TIMEOUT_MS = 30_000;
|
|
1156
|
+
async function readPage(browser, url, width, work, navMs = NAV_TIMEOUT_MS, settleMs) {
|
|
1127
1157
|
let page;
|
|
1128
1158
|
try {
|
|
1129
1159
|
page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
1130
|
-
|
|
1160
|
+
// `domcontentloaded`, NOT `load`, and `settleDom` does the rest.
|
|
1161
|
+
//
|
|
1162
|
+
// `load` waits for every SUBRESOURCE — including third-party iframes, which
|
|
1163
|
+
// an import has no use for: this walk reads the iframe's `src` ATTRIBUTE and
|
|
1164
|
+
// never needs the frame to render. So a page carrying an ad frame, a chat
|
|
1165
|
+
// widget or a slow video embed stalled the whole capture for up to thirty
|
|
1166
|
+
// seconds and then THREW, losing an import whose DOM had been ready the
|
|
1167
|
+
// entire time. Caught by this repo's own test, whose fixture embeds real
|
|
1168
|
+
// YouTube, Vimeo and Google Maps frames: it started failing at exactly 30s
|
|
1169
|
+
// with nothing about the page having changed.
|
|
1170
|
+
//
|
|
1171
|
+
// The same lesson `sb_look` already paid for with `networkidle`, one wait
|
|
1172
|
+
// earlier: the right question is "has the DOM stopped changing", and
|
|
1173
|
+
// `settleDom` answers it directly and bounded. A page that genuinely needs
|
|
1174
|
+
// its images is the SHOOT path's problem, and that one still waits.
|
|
1175
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navMs });
|
|
1131
1176
|
// THE SAME SETTLE `sb_look` USES, not a flat sleep. A fixed 600ms is wrong
|
|
1132
1177
|
// at both ends: example.com is finished long before it, and a page that
|
|
1133
1178
|
// builds itself with scripts is not finished after it — which is exactly the
|
|
1134
1179
|
// page an import is most likely to be pointed at. `settleDom` asks the
|
|
1135
1180
|
// question actually being asked (has the page stopped changing) and answers
|
|
1136
1181
|
// when it becomes true, bounded so a page that never settles is still read.
|
|
1137
|
-
await settleDom(page);
|
|
1182
|
+
await settleDom(page, settleMs);
|
|
1138
1183
|
return await work(page);
|
|
1139
1184
|
}
|
|
1140
1185
|
finally {
|
|
@@ -1149,10 +1194,32 @@ function limitsFrom(opts) {
|
|
|
1149
1194
|
maxNodes: opts.maxNodes ?? 400,
|
|
1150
1195
|
};
|
|
1151
1196
|
}
|
|
1197
|
+
/**
|
|
1198
|
+
* A read that failed, said in the caller's terms.
|
|
1199
|
+
*
|
|
1200
|
+
* Playwright's own timeout names the browser and the wait — "page.goto: Timeout
|
|
1201
|
+
* 30000ms exceeded" — which is true and tells a merchant nothing they can act
|
|
1202
|
+
* on. The remedy is a number they can raise, so the message carries it.
|
|
1203
|
+
*/
|
|
1204
|
+
function readFailure(e) {
|
|
1205
|
+
const msg = e.message ?? String(e);
|
|
1206
|
+
// THE NUMBER COMES OUT OF THE ERROR, never out of the default. The first
|
|
1207
|
+
// version of this printed NAV_TIMEOUT_MS, so a caller who had already raised
|
|
1208
|
+
// the budget to 90s was told the page "did not answer within 30s" — a
|
|
1209
|
+
// confidently wrong number, and one that sends them to change a setting they
|
|
1210
|
+
// had just changed.
|
|
1211
|
+
const hit = /Timeout (\d+)ms exceeded/.exec(msg);
|
|
1212
|
+
if (hit) {
|
|
1213
|
+
const secs = Math.round(Number(hit[1]) / 1000);
|
|
1214
|
+
return (`the page did not answer within ${secs}s. A slow origin is ordinary — raise ` +
|
|
1215
|
+
'nav_timeout_ms and try again, or import fewer pages at once.');
|
|
1216
|
+
}
|
|
1217
|
+
return msg.slice(0, 160);
|
|
1218
|
+
}
|
|
1152
1219
|
/** Open a URL and capture it. */
|
|
1153
1220
|
export async function capture(url, opts = {}) {
|
|
1154
1221
|
const limits = limitsFrom(opts);
|
|
1155
|
-
return withBrowser((browser) => readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits)));
|
|
1222
|
+
return withBrowser((browser) => readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits), opts.navTimeoutMs, opts.settleMs));
|
|
1156
1223
|
}
|
|
1157
1224
|
/**
|
|
1158
1225
|
* Capture several pages through one browser.
|
|
@@ -1169,11 +1236,11 @@ export async function captureMany(urls, opts = {}) {
|
|
|
1169
1236
|
const out = [];
|
|
1170
1237
|
for (const url of urls) {
|
|
1171
1238
|
try {
|
|
1172
|
-
const result = (await readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits)));
|
|
1239
|
+
const result = (await readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits), opts.navTimeoutMs, opts.settleMs));
|
|
1173
1240
|
out.push({ url, ok: true, result });
|
|
1174
1241
|
}
|
|
1175
1242
|
catch (e) {
|
|
1176
|
-
out.push({ url, ok: false, why: e
|
|
1243
|
+
out.push({ url, ok: false, why: readFailure(e) });
|
|
1177
1244
|
}
|
|
1178
1245
|
}
|
|
1179
1246
|
return out;
|
package/dist/vision/shoot.js
CHANGED
|
@@ -172,7 +172,7 @@ export async function shoot(url, opts = {}) {
|
|
|
172
172
|
* polling widget from holding the shot forever. A page that never settles is
|
|
173
173
|
* photographed anyway — a late picture beats none.
|
|
174
174
|
*/
|
|
175
|
-
export async function settleDom(page) {
|
|
175
|
+
export async function settleDom(page, capMs = 2_000) {
|
|
176
176
|
await page
|
|
177
177
|
.evaluate(({ quiet, cap }) => new Promise((resolve) => {
|
|
178
178
|
const start = Date.now();
|
|
@@ -194,7 +194,14 @@ export async function settleDom(page) {
|
|
|
194
194
|
resolve();
|
|
195
195
|
}
|
|
196
196
|
}, 50);
|
|
197
|
-
}),
|
|
197
|
+
}),
|
|
198
|
+
// The CAP is a knob because 2,000 ms is right for a healthy origin and
|
|
199
|
+
// wrong for a struggling one. MEASURED on ttgshop.vn while it was taking
|
|
200
|
+
// 45s to answer: the capture returned 6 text nodes and 114 characters of
|
|
201
|
+
// a page that holds 2,396 — 4.8% — and reported NO error, because
|
|
202
|
+
// settling is not failing. A silent thin import is worse than a refused
|
|
203
|
+
// one, and the shoot path keeps the default it was tuned for.
|
|
204
|
+
{ quiet: 250, cap: capMs })
|
|
198
205
|
.catch(() => { });
|
|
199
206
|
}
|
|
200
207
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|