@detiq/crawler 1.1.15 → 1.1.16

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/dist/crawler.js CHANGED
@@ -951,6 +951,13 @@ async function crawlProject(opts, onProgress) {
951
951
  logger.info({ url: finalUrl }, "[crawler] pre-auth page has 0 elements \u2014 deferring to authenticated BFS");
952
952
  continue;
953
953
  }
954
+ const AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
955
+ const allAnchorOnly = preAuthElements.every((el) => el.role === "a");
956
+ const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every((el) => AUTH_WALL_LABEL_RE.test(el.label)));
957
+ if (looksLikeAuthWall) {
958
+ logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map((e) => e.label) }, "[crawler] pre-auth auth wall \u2014 deferring to authenticated BFS");
959
+ continue;
960
+ }
954
961
  const pathParts = finalPath.split("/").filter(Boolean);
955
962
  const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
956
963
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
@@ -1644,7 +1651,23 @@ async function crawlProject(opts, onProgress) {
1644
1651
  const s = window.getComputedStyle(el);
1645
1652
  return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
1646
1653
  }
1647
- document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a) => {
1654
+ const NAV_SEL = [
1655
+ "nav a",
1656
+ '[role="navigation"] a',
1657
+ "header a",
1658
+ "footer nav a",
1659
+ '[aria-label*="navigation" i] a',
1660
+ '[aria-label*="sidebar" i] a',
1661
+ "aside a",
1662
+ '[role="complementary"] a',
1663
+ '[class*="sidebar" i] a',
1664
+ '[class*="side-nav" i] a',
1665
+ '[class*="sidenav" i] a',
1666
+ '[class*="nav-menu" i] a',
1667
+ '[class*="navmenu" i] a',
1668
+ "[data-nav] a"
1669
+ ].join(", ");
1670
+ document.querySelectorAll(NAV_SEL).forEach((a) => {
1648
1671
  if (!isV(a)) return;
1649
1672
  const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1650
1673
  const href = a.href ?? "";
@@ -1654,6 +1677,7 @@ async function crawlProject(opts, onProgress) {
1654
1677
  while (cur) {
1655
1678
  const t = cur.tagName?.toLowerCase();
1656
1679
  const r = cur.getAttribute?.("role")?.toLowerCase();
1680
+ const cl = (cur.className ?? "").toLowerCase();
1657
1681
  if (t === "header" || r === "banner") {
1658
1682
  landmark = "Header";
1659
1683
  break;
@@ -1662,6 +1686,10 @@ async function crawlProject(opts, onProgress) {
1662
1686
  landmark = "Footer";
1663
1687
  break;
1664
1688
  }
1689
+ if (t === "aside" || r === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
1690
+ landmark = "Sidebar";
1691
+ break;
1692
+ }
1665
1693
  if (t === "nav" || r === "navigation") {
1666
1694
  landmark = cur.getAttribute("aria-label")?.trim() || "Site Navigation";
1667
1695
  break;
@@ -1687,6 +1715,44 @@ async function crawlProject(opts, onProgress) {
1687
1715
  fc++;
1688
1716
  }
1689
1717
  });
1718
+ {
1719
+ const navHrefsSeen = /* @__PURE__ */ new Set();
1720
+ for (const it of items) {
1721
+ try {
1722
+ const h = JSON.parse(it.notes ?? "{}").href;
1723
+ if (h) navHrefsSeen.add(h);
1724
+ } catch {
1725
+ }
1726
+ }
1727
+ const gpGroups = /* @__PURE__ */ new Map();
1728
+ document.querySelectorAll("a[href]").forEach((a) => {
1729
+ if (!isV(a)) return;
1730
+ const href = a.href ?? "";
1731
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
1732
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1733
+ if (!label || label.length > 40) return;
1734
+ const parent = a.parentElement;
1735
+ const gp = parent?.parentElement;
1736
+ if (!parent || !gp) return;
1737
+ if (parent.querySelectorAll("a").length !== 1) return;
1738
+ const gpTag = gp.tagName.toLowerCase();
1739
+ if (["body", "html", "form"].includes(gpTag)) return;
1740
+ const arr = gpGroups.get(gp) ?? [];
1741
+ arr.push({ label, href });
1742
+ gpGroups.set(gp, arr);
1743
+ });
1744
+ for (const [, links] of gpGroups.entries()) {
1745
+ if (links.length >= 4) {
1746
+ for (const { label, href } of links) {
1747
+ const k = `cluster|${label.toLowerCase()}|${href}`;
1748
+ if (!seen.has(k)) {
1749
+ seen.add(k);
1750
+ items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1690
1756
  return items;
1691
1757
  }).catch(() => []);
1692
1758
  const allElements = [
@@ -2329,7 +2395,7 @@ async function crawlProject(opts, onProgress) {
2329
2395
  const lastSeg = allSegs[allSegs.length - 1];
2330
2396
  pathName = lastSeg.slice(0, 8).toUpperCase();
2331
2397
  }
2332
- const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view"];
2398
+ const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view", "play", "mode", "id", "page"];
2333
2399
  const paramSuffix = SIG_PARAMS.map((p) => {
2334
2400
  const v = u.searchParams.get(p);
2335
2401
  return v ? ` \xB7 ${v.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}` : "";
@@ -2349,7 +2415,8 @@ async function crawlProject(opts, onProgress) {
2349
2415
  }
2350
2416
  const candidate = parts[0].trim();
2351
2417
  const isBrand = candidate.split(/\s+/).length === 1 && (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
2352
- if (!isBrand && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2418
+ const isSiteGenericTitle = homeFingerprintTitle !== "" && candidate === homeFingerprintTitle;
2419
+ if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2353
2420
  return candidate;
2354
2421
  }
2355
2422
  }
@@ -2843,95 +2910,121 @@ ${snippet}`,
2843
2910
  try {
2844
2911
  const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, "DESKTOP", gitInfo.branch);
2845
2912
  if (baseline && baseline.storagePath !== shotPath) {
2846
- const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
2847
- const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
2848
- screenshotDiffPercent(baseline.storagePath, shotPath, baseline.ignoredRegions),
2849
- gridLayoutDiff(baseline.storagePath, shotPath, baseline.ignoredRegions),
2850
- AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
2851
- ]);
2852
- let verdict = pHashVerdict(pHashDistance, changeLevel, [
2853
- ...layoutChanges,
2854
- ...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
2855
- ]);
2856
- if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
2857
- try {
2858
- const regions = baseline.ignoredRegions ?? [];
2859
- const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
2860
- const { applyMask } = await import("./phash.js");
2861
- return Promise.all([applyMask(baseline.storagePath, regions), applyMask(shotPath, regions)]);
2862
- })() : await Promise.all([
2863
- fs.readFile(baseline.storagePath),
2864
- fs.readFile(shotPath)
2865
- ]);
2866
- const aiResult = await aiVisualCompare({
2867
- baselineImageBase64: baselineBuffer.toString("base64"),
2868
- newImageBase64: newBuffer.toString("base64"),
2869
- screenName: screen.name,
2870
- screenPurpose: "Application screen captured during automated crawl",
2871
- tenantId: opts.tenantId,
2872
- projectId: opts.projectId
2873
- });
2874
- verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
2875
- } catch (aiErr) {
2876
- logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
2913
+ let baselinePath = baseline.storagePath;
2914
+ let tempBaselinePath = null;
2915
+ const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
2916
+ if (!localExists) {
2917
+ if (baseline.cloudUrl) {
2918
+ try {
2919
+ const resp = await fetch(baseline.cloudUrl);
2920
+ if (resp.ok) {
2921
+ const buf = Buffer.from(await resp.arrayBuffer());
2922
+ tempBaselinePath = `${shotPath}.baseline-tmp.png`;
2923
+ await fs.writeFile(tempBaselinePath, buf);
2924
+ baselinePath = tempBaselinePath;
2925
+ logger.info({ screenId: screen.id }, "[crawler] VisualDiff baseline downloaded from cloud storage");
2926
+ } else {
2927
+ logger.warn({ screenId: screen.id, status: resp.status }, "[crawler] VisualDiff skipped \u2014 baseline cloud fetch returned non-200");
2928
+ return;
2929
+ }
2930
+ } catch (dlErr) {
2931
+ logger.warn({ screenId: screen.id, err: String(dlErr) }, "[crawler] VisualDiff skipped \u2014 baseline cloud download failed");
2932
+ return;
2933
+ }
2934
+ } else {
2935
+ logger.warn({ screenId: screen.id }, "[crawler] VisualDiff skipped \u2014 baseline not on disk and no cloudUrl available");
2936
+ return;
2877
2937
  }
2878
2938
  }
2879
- let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2880
- if (changeType !== "NONE") {
2881
- try {
2882
- const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
2883
- const threshold = project?.visualDiffThreshold ?? null;
2884
- if (threshold !== null && pHashDistance < threshold) {
2885
- changeType = "NONE";
2939
+ try {
2940
+ const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
2941
+ const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
2942
+ screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
2943
+ gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
2944
+ AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
2945
+ ]);
2946
+ let verdict = pHashVerdict(pHashDistance, changeLevel, [
2947
+ ...layoutChanges,
2948
+ ...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
2949
+ ]);
2950
+ if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
2951
+ try {
2952
+ const regions = baseline.ignoredRegions ?? [];
2953
+ const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
2954
+ const { applyMask } = await import("./phash.js");
2955
+ return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
2956
+ })() : await Promise.all([
2957
+ fs.readFile(baselinePath),
2958
+ fs.readFile(shotPath)
2959
+ ]);
2960
+ const aiResult = await aiVisualCompare({
2961
+ baselineImageBase64: baselineBuffer.toString("base64"),
2962
+ newImageBase64: newBuffer.toString("base64"),
2963
+ screenName: screen.name,
2964
+ screenPurpose: "Application screen captured during automated crawl",
2965
+ tenantId: opts.tenantId,
2966
+ projectId: opts.projectId
2967
+ });
2968
+ verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
2969
+ } catch (aiErr) {
2970
+ logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
2886
2971
  }
2887
- } catch {
2888
2972
  }
2889
- }
2890
- if (changeType !== "NONE") {
2891
- let newScreenshotCloudUrl;
2892
- let diffImageCloudUrl;
2893
- try {
2894
- const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
2895
- const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2896
- if (storageCfg) {
2897
- const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2898
- newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2899
- try {
2900
- const { generatePixelmatchDiff } = await import("./phash.js");
2901
- const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
2902
- const diffTmpPath = `${shotPath}.diff.png`;
2903
- await fs.writeFile(diffTmpPath, diffBuffer);
2904
- const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
2905
- diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
2906
- fs.unlink(diffTmpPath).catch(() => {
2907
- });
2908
- } catch (diffErr) {
2909
- logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
2973
+ let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2974
+ if (changeType !== "NONE") {
2975
+ try {
2976
+ const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
2977
+ const threshold = project?.visualDiffThreshold ?? null;
2978
+ if (threshold !== null && pHashDistance < threshold) {
2979
+ changeType = "NONE";
2910
2980
  }
2981
+ } catch {
2911
2982
  }
2912
- } catch {
2913
2983
  }
2914
- await AppBrain.saveVisualDiff(opts.tenantId, {
2915
- baselineId: baseline.id,
2916
- newScreenshotPath: shotPath,
2917
- newScreenshotCloudUrl,
2918
- diffImageCloudUrl,
2919
- diffScore: pHashDistance,
2920
- changeType,
2921
- changes: allChanges,
2922
- recommendation,
2923
- bugReportDraft,
2924
- aiSummary
2984
+ if (changeType !== "NONE") {
2985
+ let newScreenshotCloudUrl;
2986
+ let diffImageCloudUrl;
2987
+ try {
2988
+ const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
2989
+ const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2990
+ if (storageCfg) {
2991
+ const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2992
+ newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2993
+ try {
2994
+ const { generatePixelmatchDiff } = await import("./phash.js");
2995
+ const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
2996
+ const diffTmpPath = `${shotPath}.diff.png`;
2997
+ await fs.writeFile(diffTmpPath, diffBuffer);
2998
+ const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
2999
+ diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
3000
+ fs.unlink(diffTmpPath).catch(() => {
3001
+ });
3002
+ } catch (diffErr) {
3003
+ logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
3004
+ }
3005
+ }
3006
+ } catch {
3007
+ }
3008
+ await AppBrain.saveVisualDiff(opts.tenantId, {
3009
+ baselineId: baseline.id,
3010
+ newScreenshotPath: shotPath,
3011
+ newScreenshotCloudUrl,
3012
+ diffImageCloudUrl,
3013
+ diffScore: pHashDistance,
3014
+ changeType,
3015
+ changes: allChanges,
3016
+ recommendation,
3017
+ bugReportDraft,
3018
+ aiSummary
3019
+ });
3020
+ }
3021
+ } finally {
3022
+ if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {
2925
3023
  });
2926
3024
  }
2927
3025
  }
2928
3026
  } catch (err) {
2929
- const missing = String(err?.message ?? err).includes("Input file is missing");
2930
- if (missing) {
2931
- logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, "[crawler] VisualDiff skipped \u2014 baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.");
2932
- } else {
2933
- console.warn("[crawler] VisualDiff pipeline failed:", err);
2934
- }
3027
+ console.warn("[crawler] VisualDiff pipeline failed:", err);
2935
3028
  }
2936
3029
  })();
2937
3030
  }
@@ -4355,7 +4448,26 @@ async function contentFallbackElements(page) {
4355
4448
  const s = window.getComputedStyle(el);
4356
4449
  return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
4357
4450
  };
4358
- document.querySelectorAll('nav a, [role="navigation"] a, header a, header nav a, footer nav a, [aria-label*="navigation" i] a, [aria-label*="menu" i] a').forEach((a) => {
4451
+ const navSel = [
4452
+ "nav a",
4453
+ '[role="navigation"] a',
4454
+ "header a",
4455
+ "header nav a",
4456
+ "footer nav a",
4457
+ '[aria-label*="navigation" i] a',
4458
+ '[aria-label*="menu" i] a',
4459
+ '[aria-label*="sidebar" i] a',
4460
+ "aside a",
4461
+ '[role="complementary"] a',
4462
+ '[class*="sidebar" i] a',
4463
+ '[class*="side-nav" i] a',
4464
+ '[class*="sidenav" i] a',
4465
+ '[class*="nav-menu" i] a',
4466
+ '[class*="navmenu" i] a',
4467
+ "[data-nav] a"
4468
+ ].join(", ");
4469
+ const navLinksFoundCount = { n: 0 };
4470
+ document.querySelectorAll(navSel).forEach((a) => {
4359
4471
  if (!isNavVisible(a)) return;
4360
4472
  const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
4361
4473
  const href = a.href;
@@ -4365,6 +4477,7 @@ async function contentFallbackElements(page) {
4365
4477
  while (el) {
4366
4478
  const tag = el.tagName?.toLowerCase();
4367
4479
  const role = el.getAttribute?.("role")?.toLowerCase();
4480
+ const cl = (el.className ?? "").toLowerCase();
4368
4481
  if (tag === "header" || role === "banner") {
4369
4482
  landmark = "Header";
4370
4483
  break;
@@ -4373,6 +4486,10 @@ async function contentFallbackElements(page) {
4373
4486
  landmark = "Footer";
4374
4487
  break;
4375
4488
  }
4489
+ if (tag === "aside" || role === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
4490
+ landmark = "Sidebar";
4491
+ break;
4492
+ }
4376
4493
  if (tag === "nav" || role === "navigation") {
4377
4494
  const directLabel = el.getAttribute("aria-label")?.trim() ?? "";
4378
4495
  const labelId = el.getAttribute("aria-labelledby");
@@ -4383,7 +4500,44 @@ async function contentFallbackElements(page) {
4383
4500
  el = el.parentElement;
4384
4501
  }
4385
4502
  addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
4503
+ navLinksFoundCount.n++;
4386
4504
  });
4505
+ {
4506
+ const navHrefsAlready = /* @__PURE__ */ new Set();
4507
+ for (const item of out) {
4508
+ if (item.role === "nav-link") {
4509
+ try {
4510
+ const h = JSON.parse(item.notes ?? "{}").href;
4511
+ if (h) navHrefsAlready.add(h);
4512
+ } catch {
4513
+ }
4514
+ }
4515
+ }
4516
+ const gpGroups2 = /* @__PURE__ */ new Map();
4517
+ document.querySelectorAll("a[href]").forEach((a) => {
4518
+ if (!isNavVisible(a)) return;
4519
+ const href = a.href ?? "";
4520
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
4521
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
4522
+ if (!label || label.length > 40) return;
4523
+ const parent = a.parentElement;
4524
+ const gp = parent?.parentElement;
4525
+ if (!parent || !gp) return;
4526
+ if (parent.querySelectorAll("a").length !== 1) return;
4527
+ const gpTag = gp.tagName.toLowerCase();
4528
+ if (["body", "html", "form"].includes(gpTag)) return;
4529
+ const arr = gpGroups2.get(gp) ?? [];
4530
+ arr.push({ label, href });
4531
+ gpGroups2.set(gp, arr);
4532
+ });
4533
+ for (const [, links] of gpGroups2.entries()) {
4534
+ if (links.length >= 4) {
4535
+ for (const { label, href } of links) {
4536
+ addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
4537
+ }
4538
+ }
4539
+ }
4540
+ }
4387
4541
  document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a) => {
4388
4542
  const label = a.textContent?.replace(/\s+/g, " ").trim().slice(0, 60);
4389
4543
  const href = a.href;