@operstack/audit 0.33.3 → 0.34.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.
@@ -241,7 +241,7 @@ const bare = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
241
241
 
242
242
  /** Файлы и служебные разделы: это не страницы, по которым судят о сайте. */
243
243
  const FILE_EXT = /\.(?!html?$|php$|aspx?$)[a-z0-9]{2,5}$/i;
244
- const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|login|log-in|signin|sign-in|register|logout|wp-admin|wp-login\.php|wp-json|feed|rss|tag|tags|author|search|cdn-cgi)(\/|$)/i;
244
+ const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|login|customer_authentication|customer_identity|log-in|signin|sign-in|register|logout|wp-admin|wp-login\.php|wp-json|feed|rss|tag|tags|author|search|cdn-cgi)(\/|$)/i;
245
245
 
246
246
  /**
247
247
  * Свои ссылки страницы в порядке появления, без повторов.
@@ -249,6 +249,14 @@ const UTILITY = /\/(cart|basket|checkout|wishlist|compare|my-account|account|log
249
249
  * @param {string} baseUrl адрес страницы, от него считаются относительные ссылки
250
250
  * @param {Iterable<string>} [ownHosts] хосты сайта; по умолчанию хост baseUrl, с www и без
251
251
  */
252
+ /** Адреса из простого текста или markdown. Хвостовые знаки препинания, обратные кавычки и
253
+ * звёздочки к адресу не относятся: `https://x.com/a`. или (https://x.com/b), в конце строки. */
254
+ export function linksInText(text) {
255
+ return [...String(text || '').matchAll(/https?:\/\/[^\s)\]>"'`<]+/g)]
256
+ .map((m) => m[0].replace(/[.,;:!?*_'"`]+$/, ''))
257
+ .filter((u) => { try { return Boolean(new URL(u).host); } catch { return false; } });
258
+ }
259
+
252
260
  export function internalLinks(html, baseUrl, ownHosts = []) {
253
261
  let base;
254
262
  try { base = new URL(baseUrl); } catch { return []; }
@@ -531,13 +539,29 @@ async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
531
539
  const r = await get(u, { timeout: timeoutFn() });
532
540
  if (r.error === 'timeout') { timedOut = true; continue; }
533
541
  if (!r.ok || !/<(urlset|sitemapindex)/.test(r.text)) continue;
534
- let locs = [...r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
542
+ const locsOf = (text) => [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1].replace(/&amp;/g, '&'));
543
+ const pagesOf = (list) => list.filter((l) => { try { const p = new URL(l).pathname; return !FILE_EXT.test(p) && !UTILITY.test(p); } catch { return false; } });
544
+ let locs = locsOf(r.text);
535
545
  let lastmod = /<lastmod>/.test(r.text);
536
- if (/<sitemapindex/.test(r.text) && locs.length && timeoutFn() > 500) {
537
- const child = await get(locs[0], { timeout: timeoutFn() });
538
- if (child.ok) { locs = [...child.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]); lastmod = lastmod || /<lastmod>/.test(child.text); }
546
+ /*
547
+ * Из индекса берём первую вложенную карту, в которой есть страницы. До 0.33.4 брали просто
548
+ * первую, а у Shopify это sitemap_agentic_discovery.xml с одним agents.md: в выборку шёл файл
549
+ * не HTML, он считался «не ответившей страницей», и балл не считался вовсе (brainmd.com,
550
+ * 12.10.2026). Смотрим не больше трёх вложенных карт.
551
+ */
552
+ if (/<sitemapindex/.test(r.text) && locs.length) {
553
+ const children = locs;
554
+ for (const childUrl of children.slice(0, 3)) {
555
+ if (timeoutFn() <= 500) break;
556
+ const child = await get(childUrl, { timeout: timeoutFn() });
557
+ if (!child.ok) continue;
558
+ const childLocs = locsOf(child.text);
559
+ lastmod = lastmod || /<lastmod>/.test(child.text);
560
+ if (pagesOf(childLocs).length) { locs = childLocs; break; }
561
+ }
562
+ if (locs === children) locs = [];
539
563
  }
540
- return { found: true, url: u, count: locs.length, lastmod, pages: locs.filter((l) => !/\.(xml|pdf|jpe?g|png)$/i.test(l)) };
564
+ return { found: true, url: u, count: locs.length, lastmod, pages: pagesOf(locs) };
541
565
  }
542
566
  return { found: false, url: '', count: 0, lastmod: false, pages: [], timedOut };
543
567
  }
@@ -672,7 +696,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
672
696
  * сайт без карты мерился по одной главной. Запросов не добавляет, главная уже прочитана.
673
697
  * Добавлено 30.09.2026.
674
698
  */
675
- const fromSitemap = sitemap.pages.filter(isOwn);
699
+ const fromSitemap = sitemap.pages.filter(isOwn).filter((u) => { try { const p = new URL(u).pathname; return !FILE_EXT.test(p) && !UTILITY.test(p); } catch { return false; } });
676
700
  const allowedByRobots = robotsAllows(robotsRes.ok ? robotsRes.text : '');
677
701
  const fromHome = fromSitemap.length > samplePages ? [] : internalLinks(home.text, home.url, [host]).filter((l) => allowedByRobots(l));
678
702
  const poolSeen = new Set();
@@ -713,9 +737,15 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
713
737
  }
714
738
  const read = async (list) => Promise.all(list.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
715
739
  let rs = await read(picks);
740
+ /*
741
+ * Адрес, который ответил 200, но не HTML (markdown, PDF, JSON), это не «страница не ответила»,
742
+ * а не страница вовсе. Повторять его бессмысленно, и в счёт выборки он не идёт: иначе один
743
+ * agents.md в карте Shopify отменял балл всему сайту (12.10.2026).
744
+ */
745
+ const notPages = new Set(picks.filter((p, i) => rs[i].ok && !/html/i.test(rs[i].type)));
716
746
  // Одна повторная попытка по тем, что не ответили: страницы маленькие, а разница в балле
717
747
  // между «прочитали две» и «прочитали одну» доходила до двадцати пунктов.
718
- const failed = picks.filter((p, i) => !(rs[i].ok && /html/i.test(rs[i].type)));
748
+ const failed = picks.filter((p, i) => !notPages.has(p) && !(rs[i].ok && /html/i.test(rs[i].type)));
719
749
  if (failed.length) {
720
750
  // Повтору даётся своё время сверх общего бюджета, один раз и не больше трёх секунд.
721
751
  // Без этого на медленном сайте повтор упирался в тот же истёкший бюджет и проверка
@@ -732,13 +762,13 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
732
762
  // Дубли по адресу: повтор мог вернуть ту же страницу, что и первая попытка.
733
763
  const seenUrl = new Set();
734
764
  sampled = sampled.filter((p) => (seenUrl.has(p.url) ? false : seenUrl.add(p.url)));
735
- wantedPages = Math.min(picks.length, samplePages);
765
+ wantedPages = Math.min(picks.length - notPages.size, samplePages);
736
766
  }
737
767
  const pages = [homePage, ...sampled];
738
768
 
739
769
  // llms.txt
740
770
  const llmsIsText = llmsRes.ok && !/<html/i.test(llmsRes.text.slice(0, 400));
741
- const llmsLinks = llmsIsText ? [...llmsRes.text.matchAll(/https?:\/\/[^\s)\]>]+/g)].map((m) => m[0]) : [];
771
+ const llmsLinks = llmsIsText ? linksInText(llmsRes.text) : [];
742
772
  const llmsForeign = llmsLinks.filter((l) => { try { return !new URL(l).host.endsWith(host.replace(/^www\./, '')); } catch { return false; } });
743
773
 
744
774
  // Area 1: access for AI crawlers (25)