sbuilder-mcp 0.30.0 → 0.31.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 CHANGED
@@ -6,6 +6,23 @@ 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.31.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - sb_import and sb_import_site now turn a set of panels with a button row into a real `tab` element instead of flattening it, as long as every panel's label can be read off the page (via `aria-controls`, a shared `data-*` pairing, or position when the button row's clickable count matches the panel count); a panel set whose labels are filled in by script rather than present in the DOM is reported as `panel-set-without-labels` and left flattened rather than guessed at.
13
+
14
+ ## [0.30.1] - 2026-09-11
15
+
16
+ ### Changed
17
+ - sb_import and sb_import_site's default `max_images` and `max_nodes` rise from 24/300 to 60/900, so a real shop homepage's full catalogue is no longer cut off mid-band.
18
+ - A flex or grid container that wraps is now capped at 60 columns per row instead of 12, so a wide product shelf is captured as its own row instead of being coerced into a vertical stack.
19
+
20
+ ### Fixed
21
+ - sb_import and sb_import_site no longer read images from `src` alone: a lazy-loaded `data-src`/`data-original`/`data-lazy-src`/`srcset` (widest candidate) is now read as the real photo, so a lazy-loaded product gallery is no longer imported as a handful of pictures with the rest reported as missing.
22
+ - Content hidden inside a tab panel, carousel slide or filtered grid (`display:none` beside a visible sibling of the same kind) is now revealed and captured, so a homepage that lays its catalogue out in tabs no longer loses every tab but the one shown at load.
23
+ - `<button>` elements are no longer skipped as form controls: a button with words in it is now captured as a call-to-action or link the same way an `<a>` is, so "Add to cart"-style buttons are no longer dropped from the import.
24
+ - The section-candidate fallback now fires whenever the chosen sections cover under half of the page's non-chrome text, not only when they capture nothing at all, so a page whose only `<section>` elements are a breadcrumb no longer imports as that breadcrumb alone.
25
+
9
26
  ## [0.30.0] - 2026-09-10
10
27
 
11
28
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,23 @@ 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.31.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - sb_import và sb_import_site giờ biến một bộ panel có hàng nút thành một element `tab` thật thay vì làm phẳng nó, miễn là nhãn của mọi panel đọc được trên trang (qua `aria-controls`, một cặp giá trị `data-*` chung, hoặc theo vị trí khi số phần tử bấm được trong hàng nút khớp với số panel); một bộ panel có nhãn do script điền vào chứ không có sẵn trong DOM sẽ được báo là `panel-set-without-labels` và giữ nguyên dạng làm phẳng thay vì bị đoán mò.
13
+
14
+ ## [0.30.1] - 2026-09-11
15
+
16
+ ### Changed
17
+ - Giá trị mặc định `max_images` và `max_nodes` của sb_import và sb_import_site tăng từ 24/300 lên 60/900, nên toàn bộ catalogue trên trang chủ một shop thật không còn bị cắt cụt giữa chừng một band.
18
+ - Một container flex hoặc grid có wrap giờ được giới hạn 60 cột mỗi hàng thay vì 12, nên một kệ sản phẩm rộng được chụp lại đúng thành một hàng thay vì bị ép thành một cột dọc.
19
+
20
+ ### Fixed
21
+ - sb_import và sb_import_site không còn chỉ đọc ảnh từ `src`: một ảnh lazy-load qua `data-src`/`data-original`/`data-lazy-src`/`srcset` (chọn ứng viên rộng nhất) giờ được đọc như ảnh thật, nên một thư viện ảnh sản phẩm lazy-load không còn bị nhập vào với vài tấm còn lại bị báo là thiếu.
22
+ - Nội dung ẩn trong một tab panel, slide carousel hay lưới lọc (`display:none` cạnh một anh em cùng loại đang hiển thị) giờ được hiện ra và chụp lại, nên một trang chủ trình bày catalogue theo dạng tab không còn mất hết các tab trừ tab đang mở lúc tải.
23
+ - Phần tử `<button>` không còn bị bỏ qua như một control của form: một button có chữ bên trong giờ được chụp lại như một nút kêu gọi hành động hoặc liên kết, giống cách một `<a>` được xử lý, nên các nút kiểu "Thêm vào giỏ" không còn bị rớt khỏi bản nhập.
24
+ - Cơ chế dự phòng chọn section giờ kích hoạt bất cứ khi nào các section được chọn chỉ phủ dưới một nửa nội dung chữ không phải chrome của trang, chứ không chỉ khi chúng không chụp được gì cả, nên một trang mà các phần tử `<section>` duy nhất là một breadcrumb sẽ không còn bị nhập vào chỉ như breadcrumb đó.
25
+
9
26
  ## [0.30.0] - 2026-09-10
10
27
 
11
28
  ### Added
@@ -167,6 +167,29 @@ function one(c, t) {
167
167
  children: body,
168
168
  };
169
169
  }
170
+ case 'tab': {
171
+ const items = (c.children ?? [])
172
+ .map((item) => one(item, t))
173
+ .filter((n) => n !== null);
174
+ // Two is the floor for a tab: one panel with a button over it is a
175
+ // heading the visitor cannot dismiss.
176
+ if (items.length < 2)
177
+ return null;
178
+ return { type: 'tab', children: items };
179
+ }
180
+ case 'tab-item': {
181
+ const label = c.text?.trim();
182
+ const body = (c.children ?? [])
183
+ .map((k) => one(k, t))
184
+ .filter((n) => n !== null);
185
+ // NO LABEL, NO PANEL — and this is stricter than the accordion beside it
186
+ // on purpose. `accordion-content` seeds its own summary, so an unlabelled
187
+ // one still opens; `tab` builds its BUTTON ROW from these labels, so an
188
+ // unlabelled panel is one the visitor has no way to reach.
189
+ if (!label || !body.length)
190
+ return null;
191
+ return { type: 'tab-content', specials: { label }, children: body };
192
+ }
170
193
  case 'divider': {
171
194
  return { type: 'divider', style: { width: '100%' } };
172
195
  }
@@ -464,7 +464,13 @@ export function registerImportTools(server, ctx, session) {
464
464
  }
465
465
  }
466
466
  const tokens = tokenDoc ? tokensFromPage(tokenDoc.doc) : {};
467
- const shots = await captureMany(plan.pages.map((p) => p.url), { maxImages: max_images ?? 24, maxNodes: max_nodes ?? 300 });
467
+ const shots = await captureMany(plan.pages.map((p) => p.url),
468
+ // 900, not 300. The old ceiling was chosen against marketing pages;
469
+ // a real shop's homepage is a different quantity — ttgshop.vn measured
470
+ // 9,829px of catalogue across a dozen collection bands, and 300 cut it
471
+ // off in the middle of the third. A cap is here to stop a runaway page,
472
+ // not to decide how much of an ordinary one survives.
473
+ { maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900 });
468
474
  const byUrl = new Map(shots.map((s) => [s.url, s]));
469
475
  // ONE UPLOAD PER IMAGE FOR THE WHOLE SITE, not per page. A logo, a payment
470
476
  // strip and a footer badge appear on every page of a real site, and
@@ -74,7 +74,16 @@ function capturePage(limits) {
74
74
  // nothing, so a contact page says it had one. The CONTROLS stay ignored —
75
75
  // a stray input outside a form is chrome, and the fields of a form that IS
76
76
  // reported are counted there rather than walked into.
77
- 'NAV', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON',
77
+ //
78
+ // BUTTON IS NOT HERE EITHER, AND USED TO BE — filed with the form controls,
79
+ // on the reasoning that fits `input` and does not fit it. A `<button>` with
80
+ // words in it is a CALL TO ACTION, which on a shop is the most important
81
+ // interactive thing on the page: MEASURED on ttgshop.vn, 27 of them dropped
82
+ // in one capture, every "Mua ngay" and "Thêm vào giỏ" among them. A button
83
+ // that really is a form control is unreachable anyway — the FORM branch
84
+ // returns without walking its children — so the only ones this reaches are
85
+ // the ones a reader would press.
86
+ 'NAV', 'INPUT', 'SELECT', 'TEXTAREA',
78
87
  ]);
79
88
  const forms = [];
80
89
  /** The provider and id behind an embed URL, or null if this platform has no element for it. */
@@ -136,6 +145,104 @@ function capturePage(limits) {
136
145
  // a throwaway tab that is closed straight after.
137
146
  for (const d of Array.from(document.querySelectorAll('details')))
138
147
  d.setAttribute('open', '');
148
+ // A TAB PANEL THAT IS NOT THE OPEN ONE MEASURES AS ZERO, and on a shop that
149
+ // is most of the page.
150
+ //
151
+ // Same principle as the `<details>` pass above and the same sentence decides
152
+ // it: the source's collapsed state is not content. MEASURED on ttgshop.vn —
153
+ // a homepage that lays its catalogue out in tabs (PC GAMING, WORKSTATION,
154
+ // AMD, MINI, …) — 73% of the page's own text was in panels carrying
155
+ // `display:none`, so the import kept one tab's products and silently dropped
156
+ // every other tab: product names, prices, discounts, stock. Nothing reported
157
+ // it, because a hidden element is indistinguishable from an absent one once
158
+ // it has been skipped.
159
+ //
160
+ // REVEALED BY THE SHAPE OF A PANEL SET, never by unhiding what is hidden. A
161
+ // `display:none` element with no visible SIBLING is a modal, a drop-down, an
162
+ // off-canvas menu or a mobile-only copy of a desktop bar — showing those is
163
+ // how an import grows a navigation drawer in the middle of a page. What marks
164
+ // a panel is that it sits beside a peer of the same kind that IS shown: one
165
+ // tab open, the rest waiting. So the reveal needs a visible sibling, real
166
+ // content of its own, and a parent that is not page chrome.
167
+ //
168
+ // The display value is COPIED from that visible sibling rather than forced to
169
+ // `block`: a flex row of cards revealed as a block would stack, and the walk
170
+ // reads `display` to decide what is a row.
171
+ const revealPanels = () => {
172
+ const parents = new Set();
173
+ for (const el of Array.from(document.querySelectorAll('[style*="display"], .hidden, [hidden], [aria-hidden]'))) {
174
+ const p = el.parentElement;
175
+ if (p)
176
+ parents.add(p);
177
+ }
178
+ // Any container can hold a panel set; the attribute scan above only finds
179
+ // the common spellings, so the sweep below is over every parent of more
180
+ // than one element, bounded by the document itself.
181
+ for (const el of Array.from(document.querySelectorAll('*'))) {
182
+ if (el.children.length > 1)
183
+ parents.add(el);
184
+ }
185
+ for (const parent of parents) {
186
+ if (inPageChrome(parent))
187
+ continue;
188
+ const kids = Array.from(parent.children);
189
+ if (kids.length < 2)
190
+ continue;
191
+ // A VISIBLE PEER IS NOT ENOUGH, and the first version of this stopped
192
+ // there — which reveals every hidden thing on the page, because anything
193
+ // in a content flow has visible siblings. A pinned test caught it: a
194
+ // `<p style="display:none">` beside a visible paragraph came back as
195
+ // content, and that `<p>` is hidden because its author hid it.
196
+ //
197
+ // A PANEL IS A CONTAINER THAT MATCHES ITS PEER. Same tag as a sibling
198
+ // that IS shown, and children of its own — which is what a tab body, a
199
+ // carousel track or a filtered grid always is, and what a hidden
200
+ // paragraph, a stray span and an empty slot never are.
201
+ const shownByTag = {};
202
+ const hidden = [];
203
+ for (const k of kids) {
204
+ const cs = getComputedStyle(k);
205
+ const r = k.getBoundingClientRect();
206
+ if (cs.display === 'none')
207
+ hidden.push(k);
208
+ else if (!shownByTag[k.tagName] && r.width > 0 && r.height > 0) {
209
+ shownByTag[k.tagName] = cs.display;
210
+ }
211
+ }
212
+ if (!hidden.length)
213
+ continue;
214
+ // STAMPED WHILE WE STILL KNOW. Once revealed, a panel is indistinguishable
215
+ // from an ordinary visible child, so the split is recorded here — and
216
+ // ONLY the ones that were actually hidden. A first version stamped every
217
+ // child that shared a tag with a visible peer, which swept in the BUTTON
218
+ // ROW itself: it is a `<div>` with children like the panels are, so the
219
+ // tab came out with its own controls as one of its panes.
220
+ let stamped = 0;
221
+ for (const k of hidden) {
222
+ if (!shownByTag[k.tagName] || k.children.length === 0)
223
+ continue;
224
+ k.setAttribute('data-sb-panel', '');
225
+ stamped += 1;
226
+ }
227
+ if (stamped >= 1)
228
+ parent.setAttribute('data-sb-panelset', '');
229
+ for (const k of hidden) {
230
+ // Real content only — an empty slot or a script-filled placeholder is
231
+ // not a panel worth showing, and revealing it costs a skip either way.
232
+ // Low floor on purpose: a panel whose whole content is a price —
233
+ // "1.490.000 VNĐ", thirteen characters — is exactly the panel a shop
234
+ // hides, and a threshold tuned for prose skipped it.
235
+ const shownDisplay = shownByTag[k.tagName];
236
+ if (!shownDisplay || k.children.length === 0)
237
+ continue;
238
+ const holds = clean(k.textContent).length > 2 || k.querySelectorAll('img').length > 0;
239
+ if (!holds)
240
+ continue;
241
+ const was = k.getAttribute('style') ?? '';
242
+ k.setAttribute('style', `${was};display:${shownDisplay} !important`);
243
+ }
244
+ }
245
+ };
139
246
  /**
140
247
  * What the page CALLS this icon, in its own words. A candidate, never a verdict.
141
248
  *
@@ -145,6 +252,64 @@ function capturePage(limits) {
145
252
  * decides whether this platform has one by that name — nothing in the page can
146
253
  * answer that.
147
254
  */
255
+ /**
256
+ * THE REAL FILE, wherever the page put it — and on a shop that is almost never
257
+ * `src`.
258
+ *
259
+ * MEASURED on ttgshop.vn: 100 images, and 84 of them carry `data-src` with no
260
+ * `src` at all. Those 84 are the PRODUCT PHOTOS. Reading `src` alone imported
261
+ * a shop with a sixth of its pictures and reported the rest as
262
+ * `image-without-src`, which reads like the page's fault rather than ours.
263
+ *
264
+ * Lazy loading is not an edge case, it is how the web ships images: every
265
+ * mainstream loader (lazysizes, lozad, and most CMS themes) parks the URL in a
266
+ * data attribute and fills `src` only when the image nears the viewport — and
267
+ * a full-page screenshot does not scroll, which is the same reason `sb_look`
268
+ * walks the page before it fires.
269
+ *
270
+ * `srcset` is read for its LARGEST candidate rather than its first: the
271
+ * platform re-encodes what it is given, so handing it the 350w thumbnail when
272
+ * the page also offers 1400w throws away detail nothing can recover. A
273
+ * `data:` URI is refused wherever it appears — it is the 1x1 placeholder the
274
+ * loader is displaying until the real one arrives, which is precisely the
275
+ * placeholder this import must not ship.
276
+ */
277
+ const LAZY_SRC = ['data-src', 'data-original', 'data-lazy-src', 'data-lazy', 'data-echo', 'data-url'];
278
+ const widest = (srcset) => {
279
+ let best = '';
280
+ let bestW = -1;
281
+ for (const part of srcset.split(',')) {
282
+ const bits = part.trim().split(/\s+/);
283
+ if (!bits[0])
284
+ continue;
285
+ const w = /^([0-9]+)w$/.exec(bits[1] ?? '');
286
+ const n = w ? Number(w[1]) : 0;
287
+ if (n >= bestW) {
288
+ bestW = n;
289
+ best = bits[0];
290
+ }
291
+ }
292
+ return best;
293
+ };
294
+ const realSrc = (el) => {
295
+ const direct = el.getAttribute('src');
296
+ if (direct && !direct.startsWith('data:'))
297
+ return direct;
298
+ for (const a of LAZY_SRC) {
299
+ const v = el.getAttribute(a);
300
+ if (v && !v.startsWith('data:'))
301
+ return v;
302
+ }
303
+ for (const a of ['srcset', 'data-srcset']) {
304
+ const v = el.getAttribute(a);
305
+ if (v) {
306
+ const w = widest(v);
307
+ if (w && !w.startsWith('data:'))
308
+ return w;
309
+ }
310
+ }
311
+ return '';
312
+ };
148
313
  const iconName = (el) => {
149
314
  const use = Array.from(el.querySelectorAll('use'))[0];
150
315
  const ref = use ? use.getAttribute('href') ?? use.getAttribute('xlink:href') : null;
@@ -308,6 +473,116 @@ function capturePage(limits) {
308
473
  skip('hidden');
309
474
  return [];
310
475
  }
476
+ // A TAB SET, WHEN — AND ONLY WHEN — ITS LABELS CAN BE READ.
477
+ //
478
+ // The platform's `tab` synthesizes its whole button row from each
479
+ // `tab-content` child's `specials.label` (render/nodes/tab/html.go), so a
480
+ // tab with no labels is a stack of panels wearing a control nobody can
481
+ // use. That makes "are there labels" the right question AND the right
482
+ // discriminator: MEASURED on ttgshop.vn, the panel-set stamp also lands on
483
+ // a `grid-cols-5` product grid that happens to hide one card, and a label
484
+ // row is exactly what that grid does not have.
485
+ //
486
+ // Three pairings, strongest first, because a wrong label is worse than
487
+ // none — it puts one collection's name over another's products:
488
+ // 1. ARIA. `[role=tab][aria-controls=<panel id>]` says it outright.
489
+ // 2. A SHARED data-* VALUE. `<button data-id="7">` beside
490
+ // `<div data-id="7">` is how most hand-rolled tab scripts wire
491
+ // themselves, and it survives reordering.
492
+ // 3. POSITION, and only from a row that holds exactly as many
493
+ // clickable items as there are panels — anything else is a guess
494
+ // dressed as a match.
495
+ //
496
+ // Every panel must come out with a label or this is not a tab. ttgshop.vn
497
+ // itself does NOT become one: its tab buttons are empty in the DOM and
498
+ // filled by script, and its section titles are images. Deriving a label
499
+ // from the `data-url` slug was available and refused — "hot-sale" is not
500
+ // what the page says, and a Vietnamese slug comes back stripped of its
501
+ // diacritics, which is the invented-copy defect this repo reports on
502
+ // everybody else's seeds.
503
+ const tabSetOf = (host) => {
504
+ const kids = Array.from(host.children);
505
+ const revealed = kids.filter((c) => c.getAttribute('data-sb-panel') !== null);
506
+ if (revealed.length === 0)
507
+ return null;
508
+ // THE BUTTON ROW IS A SIBLING OF THE PANELS, and it has to be taken out
509
+ // of the set before anything is counted — it is a container of the same
510
+ // shape, and leaving it in makes the panel count wrong and the pairing
511
+ // meaningless. It is the child that holds the clickable words.
512
+ // INNERMOST ONLY. `<li><button>Một</button></li>` is the ordinary way a
513
+ // tab bar is written, and counting both gives four clickables for two
514
+ // panels — which fails the count test that position pairing depends on,
515
+ // so a perfectly labelled tab bar read as unlabelled.
516
+ const clicksIn = (c) => {
517
+ const all = Array.from(c.querySelectorAll('button, a, li, [role="tab"]')).filter((b) => clean(b.textContent).length > 0);
518
+ return all.filter((b) => !all.some((o) => o !== b && b.contains(o)));
519
+ };
520
+ let bar = null;
521
+ for (const c of kids) {
522
+ if (c.getAttribute('data-sb-panel') !== null)
523
+ continue;
524
+ if (clicksIn(c).length >= 2) {
525
+ bar = c;
526
+ break;
527
+ }
528
+ }
529
+ const tagOf = revealed[0].tagName;
530
+ const panels = kids.filter((c) => c !== bar && c.tagName === tagOf && c.children.length > 0);
531
+ if (panels.length < 2)
532
+ return null;
533
+ const clickable = bar ? clicksIn(bar) : [];
534
+ const labels = [];
535
+ for (let i = 0; i < panels.length; i += 1) {
536
+ const panel = panels[i];
537
+ const id = panel.getAttribute('id');
538
+ let label = '';
539
+ if (id) {
540
+ for (const b of Array.from(host.querySelectorAll(`[aria-controls="${id}"]`))) {
541
+ const t = clean(b.textContent);
542
+ if (t) {
543
+ label = t;
544
+ break;
545
+ }
546
+ }
547
+ }
548
+ if (!label) {
549
+ for (const attr of Array.from(panel.attributes ?? [])) {
550
+ if (attr.name.indexOf('data-') !== 0 || attr.name === 'data-sb-panel' || !attr.value)
551
+ continue;
552
+ for (const b of clickable) {
553
+ if (b.getAttribute(attr.name) === attr.value) {
554
+ label = clean(b.textContent);
555
+ break;
556
+ }
557
+ }
558
+ if (label)
559
+ break;
560
+ }
561
+ }
562
+ if (!label && clickable.length === panels.length)
563
+ label = clean(clickable[i].textContent);
564
+ if (!label)
565
+ return null;
566
+ labels.push(label);
567
+ }
568
+ const children = [];
569
+ for (let i = 0; i < panels.length; i += 1) {
570
+ const body = walk(panels[i]);
571
+ if (body.length === 0)
572
+ continue;
573
+ children.push({ kind: 'tab-item', text: labels[i], children: body });
574
+ }
575
+ if (children.length < 2)
576
+ return null;
577
+ taken.nodes++;
578
+ return { kind: 'tab', children };
579
+ };
580
+ if (el.getAttribute('data-sb-panelset') !== null) {
581
+ const asTabs = tabSetOf(el);
582
+ if (asTabs)
583
+ return [asTabs];
584
+ skip('panel-set-without-labels');
585
+ }
311
586
  if (tag === 'DETAILS') {
312
587
  const kids2 = Array.from(el.children);
313
588
  const summary = kids2.filter((c) => c.tagName === 'SUMMARY')[0];
@@ -385,8 +660,8 @@ function capturePage(limits) {
385
660
  return [{ kind: 'heading', level: Number(tag.slice(1)), text }];
386
661
  }
387
662
  if (tag === 'IMG') {
388
- const src = el.getAttribute('src');
389
- if (!src || src.startsWith('data:')) {
663
+ const src = realSrc(el);
664
+ if (!src) {
390
665
  skip('image-without-src');
391
666
  return [];
392
667
  }
@@ -398,6 +673,31 @@ function capturePage(limits) {
398
673
  taken.nodes++;
399
674
  return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
400
675
  }
676
+ if (tag === 'BUTTON') {
677
+ // Read exactly as an `<a>` is, minus the href: painted is a call to
678
+ // action, unpainted is a plain control, and the variant is what stops
679
+ // every one of them arriving as a pink pill — the defect the anchor
680
+ // branch below records from a documentation sidebar.
681
+ const text = clean(el.textContent);
682
+ // A BUTTON WITH MARKUP INSIDE IT IS A CARD, not a label — the same
683
+ // reading the anchor branch below already applies, and taking the text
684
+ // whole instead COST coverage rather than gaining it: a product tile
685
+ // wrapped in a `<button>` came back as one run-on string with the name,
686
+ // the price and the discount glued together, replacing three nodes that
687
+ // had been captured separately. Measured: 89.7% down to 82.7% on
688
+ // ttgshop.vn, recovered by descending first.
689
+ if (el.children.length > 0) {
690
+ const inner = walkChildren(el);
691
+ if (inner.length > 0)
692
+ return inner;
693
+ }
694
+ if (!text) {
695
+ skip('button-without-words');
696
+ return [];
697
+ }
698
+ taken.nodes++;
699
+ return [{ kind: 'button', variant: looksLikeButton(el) ? 'cta' : 'link', text }];
700
+ }
401
701
  if (tag === 'A') {
402
702
  const text = clean(el.textContent);
403
703
  // A LINK THAT IS NOT A BUTTON IS STILL A LINK. It used to contribute
@@ -526,7 +826,20 @@ function capturePage(limits) {
526
826
  // wrapping it — so the honest translation is the stack it already reads
527
827
  // as. Twelve is above any real row seen here and far below a content
528
828
  // grid.
529
- const ROW_MAX = 12;
829
+ // THE CAP IS ABOUT SLIVERS, so it belongs on the containers that make
830
+ // them. A NOWRAP row divides one width by its column count — that is
831
+ // how 279 columns became 279 slivers — but a container that WRAPS never
832
+ // does: it lays out as many as fit and starts a new line, which is the
833
+ // same thing a twenty-card product shelf wants and exactly what the
834
+ // source was already showing.
835
+ //
836
+ // Twelve on a wrapping shelf was a rule coercing the layout rather than
837
+ // reading it: a shop's collection band came back as a vertical stack of
838
+ // cards because it had more than a dozen. The bound that remains is the
839
+ // whole-import node budget, which is the honest place for "this page is
840
+ // enormous".
841
+ const wraps = grid || cs.flexWrap === 'wrap';
842
+ const ROW_MAX = wraps ? 60 : 12;
530
843
  if (lays && row && kids.length >= 2 && kids.length <= ROW_MAX) {
531
844
  taken.nodes++;
532
845
  return [{
@@ -535,7 +848,7 @@ function capturePage(limits) {
535
848
  // A GRID ALWAYS WRAPS — that is what a grid IS — and `flexWrap` reads
536
849
  // `nowrap` on one because the property does not apply. Carrying that
537
850
  // literally gave the columns nowhere to go at any width.
538
- wrap: grid || cs.flexWrap === 'wrap',
851
+ wrap: wraps,
539
852
  children: kids,
540
853
  }];
541
854
  }
@@ -561,6 +874,12 @@ function capturePage(limits) {
561
874
  };
562
875
  return walk(root);
563
876
  };
877
+ // AFTER `inPageChrome` exists, and that ordering is load-bearing: the reveal
878
+ // asks whether a container is page chrome, and a `const` arrow read before
879
+ // its own definition throws inside `evaluate`, which kills the whole capture.
880
+ // The same class as the closure trap this file already carries — a name that
881
+ // exists but is not yet initialised.
882
+ revealPanels();
564
883
  // SECTION CANDIDATES, widest first: a page that marks its bands up
565
884
  // semantically is read that way, and one that does not falls back to the
566
885
  // top-level children of its main content, which is what a hand-written page
@@ -614,15 +933,119 @@ function capturePage(limits) {
614
933
  return acc;
615
934
  };
616
935
  let sections = build(candidates);
617
- // THE FALLBACK HAS TO FIRE ON AN EMPTY RESULT, not only on an empty candidate
618
- // LIST. A page can offer `<section>` elements that hold nothing this platform
619
- // renders — a wrapper around a canvas, a slot filled by script later — and the
620
- // old order took "we found candidates" as "we found content", so the whole
621
- // page came back empty. Measured: tailwindcss.com kept 0 of 6,004 characters
622
- // while reporting one skipped empty section.
623
- if (sections.length === 0) {
936
+ // THE FALLBACK HAS TO FIRE ON A DERISORY RESULT, not only on an empty one —
937
+ // and this entry has now been widened TWICE, each time by a real page.
938
+ //
939
+ // First it fired only on an empty candidate LIST, so a page offering
940
+ // `<section>`s that hold nothing this platform renders came back empty
941
+ // (tailwindcss.com: 0 of 6,004 characters, one skipped empty section). That
942
+ // was fixed by firing on an empty RESULT.
943
+ //
944
+ // An empty result is still the wrong test, because it treats "we captured
945
+ // something" as "we captured the page". MEASURED on ttgshop.vn, a shop with
946
+ // 2,856 divs, 602 paragraphs, 110 headings and 100 images: the whole page
947
+ // lives in `div.homepage`, and the document's only two `<section>` elements
948
+ // are a BREADCRUMB and one more. The selector privileges `<section>`
949
+ // absolutely, so the breadcrumb WAS the result — one section, one text node,
950
+ // 34 of 4,993 characters — and because that is not empty, nothing fell back.
951
+ // A shop imported as its own breadcrumb, with no error at any step.
952
+ //
953
+ // So the question is coverage, against the denominator this file already
954
+ // records as the honest one: page chrome is skipped ON PURPOSE and must not
955
+ // count against the result, or every correct import of a nav-heavy site would
956
+ // look like a failure. Below half of the non-chrome text, the candidate set
957
+ // was simply the wrong reading of the page, and the body walk is tried and
958
+ // kept only if it does better — so a page where the sections really are the
959
+ // content pays one comparison and keeps its own answer.
960
+ const textOf = (nodes) => {
961
+ let n = 0;
962
+ const walk = (c) => {
963
+ if (typeof c.text === 'string')
964
+ n += c.text.length;
965
+ for (const k of c.children ?? [])
966
+ walk(k);
967
+ };
968
+ for (const c of nodes)
969
+ walk(c);
970
+ return n;
971
+ };
972
+ let chromeChars = 0;
973
+ for (const el of Array.from(document.querySelectorAll('header, nav, footer'))) {
974
+ if (inPageChrome(el))
975
+ chromeChars += (el.innerText ?? '').length;
976
+ }
977
+ const contentChars = Math.max(0, (document.body.innerText ?? '').length - chromeChars);
978
+ // NO SIZE FLOOR ON THE CHECK. An earlier version only asked the question on
979
+ // pages with more than 400 characters, which is the guard you write when you
980
+ // fear a fallback firing too often — but the fallback cannot do harm here: it
981
+ // BUILDS the alternative and keeps it only if it captured more, so the worst
982
+ // case is one wasted walk on a page that was already right. A small page is
983
+ // also exactly where one stray band is the whole import.
984
+ if (sections.length === 0 || (contentChars > 0 && textOf(sections) < contentChars * 0.5)) {
985
+ // THE CONTENT ROOT, not `body` — otherwise the whole page comes back as ONE
986
+ // band and every arrangement the source had is gone. Measured on
987
+ // ttgshop.vn: `body` yields `div.homepage`, one candidate holding 9,829px of
988
+ // shop, so the import produced a single section with 467 text nodes in it.
989
+ //
990
+ // A real page wraps its content two or three deep before the bands start
991
+ // (`body > div.homepage > div.container > div.section-collection…`), so the
992
+ // root is found by DESCENDING while one child still holds nearly all the
993
+ // text. That child is a wrapper by definition — it has siblings that are
994
+ // scripts, chrome and empty slots — and its children are the bands.
995
+ // Bounded, and it stops the moment the text spreads out, which is exactly
996
+ // when the bands have been reached.
997
+ const textLen = (el) => (el.innerText ?? el.textContent ?? '').length;
998
+ let root = document.querySelectorAll('main')[0] ?? document.body;
999
+ for (let depth = 0; depth < 6; depth += 1) {
1000
+ const kids = Array.from(root.children).filter((k) => !inPageChrome(k));
1001
+ if (kids.length === 0)
1002
+ break;
1003
+ const total = textLen(root);
1004
+ let biggest = kids[0];
1005
+ for (const k of kids)
1006
+ if (textLen(k) > textLen(biggest))
1007
+ biggest = k;
1008
+ if (total < 200 || textLen(biggest) < total * 0.7)
1009
+ break;
1010
+ root = biggest;
1011
+ }
1012
+ // BOTH READINGS, AND THE ONE THAT COVERS MORE WINS. Descending finds the
1013
+ // band split on a page that wraps its content; it LOSES material on a page
1014
+ // whose content is spread across two wrappers, because everything outside
1015
+ // the biggest one is left behind — measured here as 89.7% falling to 82.7%.
1016
+ // Neither rule is right for every page and the comparison costs one walk,
1017
+ // so the page decides rather than the heuristic.
1018
+ // A SPECULATIVE BUILD MUST LEAVE NO TRACE, and the first version of this
1019
+ // left three. `build` is not pure — it pushes every `<form>` it meets onto
1020
+ // the shared list, counts every skip, and spends the image and node budget
1021
+ // — so trying a second reading REPORTED a one-form page as having three and
1022
+ // trebled every skip count. Caught by two tests that were already there,
1023
+ // which is the argument for asserting side effects and not only results.
1024
+ const snap = () => ({
1025
+ forms: forms.length,
1026
+ skipped: { ...skipped },
1027
+ taken: { ...taken },
1028
+ });
1029
+ const restore = (was) => {
1030
+ forms.length = was.forms;
1031
+ for (const k of Object.keys(skipped))
1032
+ delete skipped[k];
1033
+ Object.assign(skipped, was.skipped);
1034
+ taken.images = was.taken.images;
1035
+ taken.nodes = was.taken.nodes;
1036
+ };
1037
+ const before = snap();
1038
+ let bestState = snap();
624
1039
  const main = document.querySelectorAll('main')[0] ?? document.body;
625
- sections = build(Array.from(main.children));
1040
+ for (const from of [root, main]) {
1041
+ restore(before);
1042
+ const wider = build(Array.from(from.children));
1043
+ if (textOf(wider) > textOf(sections)) {
1044
+ sections = wider;
1045
+ bestState = snap();
1046
+ }
1047
+ }
1048
+ restore(bestState);
626
1049
  }
627
1050
  const link = Array.from(document.querySelectorAll('link[rel="canonical"]'))[0];
628
1051
  const canonical = link ? (link.getAttribute('href') ?? '') : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.30.0",
3
+ "version": "0.31.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",