feat: check-up fixes, zstd body storage, srcset toggle, crawl phases

This commit is contained in:
kemalai committed 2026-10-17 21:21:53 +03:00
1 parent ffded7b3a1
commit 3a4a97d40a
41 files changed
+1055 -122

No files matched your search

+6
View File
@@ -7843,6 +7843,12 @@ function registerIpc(): void {
/* never fatal */
}
sess.lastCrawlConfig = config;
// The crawl is about to rewrite the working copy, and nothing on the
// crawler's write path goes through `fireDataChanged`. Without this the
// project never read as unsaved: closing after a crawl showed no
// prompt, and the next launch wiped the scratch database with the
// crawl in it.
markDirty(sess);
// V2 Faz 1 — Spin up the Playwright browser pool when JS rendering
// is enabled. Pool start is lazy (Chromium launches on first
@@ -22,6 +22,9 @@ interface Node {
* never bogus), and Response Codes / Security use internal+external (they
* count external URLs, so an internal-only base would exceed 100%). */
percentBase?: number;
/** The check is switched off by a crawl setting, so there is no count to
* show — rendered as "Off" instead of a misleading 0. */
off?: boolean;
children?: Node[];
}
@@ -167,7 +170,19 @@ export function OverviewSidebar() {
// 150-node object construction on every progress-driven render.
const disabledIssues = useAppStore((s) => s.config.disabledIssues);
const disabledSet = useMemo(() => new Set<string>(disabledIssues ?? []), [disabledIssues]);
const tree = useMemo(() => buildTree(overview, disabledSet), [overview, disabledSet]);
// Under "Reject all" the crawler skips Set-Cookie analysis altogether, so
// the three cookie checks have nothing to count: a 0 there read as "no
// insecure cookies" on a site that sets them on every page.
const cookiesOff = useAppStore((s) => (s.config.cookiePolicy ?? 'reject-all') === 'reject-all');
const tree = useMemo(
() => buildTree(overview, disabledSet, cookiesOff),
[overview, disabledSet, cookiesOff],
);
const offLabel = t('sidebar.checkOff', { defaultValue: 'Off' });
const cookiesOffHint = t('sidebar.cookiesOffHint', {
defaultValue:
'Cookie checks are off because the crawl\'s cookie policy is "Reject all". Choose "Accept all" or "Block third-party" under Settings → Cookies to analyse Set-Cookie headers.',
});
const totalForPercent = overview?.summary.totalInternalUrls ?? 0;
const soft404Probe = overview?.site?.soft404Probe ?? null;
const botBlocked = overview?.site?.botBlocked ?? null;
@@ -247,6 +262,8 @@ export function OverviewSidebar() {
onClick={navigateToCategory}
total={totalForPercent}
lang={lang}
offLabel={offLabel}
offHint={cookiesOffHint}
/>
))}
</div>
@@ -264,6 +281,8 @@ const TreeNode = memo(function TreeNode({
onClick,
total,
lang,
offLabel,
offHint,
}: {
node: Node;
depth: number;
@@ -273,6 +292,8 @@ const TreeNode = memo(function TreeNode({
onClick: (c: UrlCategory) => void;
total: number;
lang: string;
offLabel: string;
offHint: string;
}) {
const isExpanded = expanded.has(node.key);
const isActive = node.category && node.category === activeCategory;
@@ -301,7 +322,12 @@ const TreeNode = memo(function TreeNode({
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-surface-200">{translateLabel(node.label, lang)}</span>
{node.count !== undefined && (
{node.off && (
<span className="w-[4.5rem] text-right font-mono italic text-surface-500" title={offHint}>
{offLabel}
</span>
)}
{!node.off && node.count !== undefined && (
<>
<span className="font-mono tabular-nums text-surface-300">
{node.count.toLocaleString()}
@@ -327,13 +353,19 @@ const TreeNode = memo(function TreeNode({
onClick={onClick}
total={total}
lang={lang}
offLabel={offLabel}
offHint={offHint}
/>
))}
</>
);
});
function buildTree(o: OverviewCounts | null, disabled: ReadonlySet<string>): Node[] {
function buildTree(
o: OverviewCounts | null,
disabled: ReadonlySet<string>,
cookiesOff: boolean,
): Node[] {
if (!o) return [];
// Response Codes and Security deliberately count external URLs too (that's
// how a broken outbound link surfaces), so their numerators exceed the
@@ -516,6 +548,7 @@ function buildTree(o: OverviewCounts | null, disabled: ReadonlySet<string>): Nod
count: issueCount(o.issues, i),
category: i.category,
...(i.percentBase === 'images' ? { percentBase: o.summary.totalImages } : {}),
...(cookiesOff && i.category.startsWith('issues:cookie-') ? { off: true } : {}),
})),
})).filter((g) => g.children.length > 0),
},
@@ -124,6 +124,7 @@ interface FormState {
// spider → crawl matrix
scope: CrawlScope;
storeImages: boolean;
crawlImageVariants: boolean;
crawlMedia: boolean;
storeMedia: boolean;
storeCss: boolean;
@@ -575,6 +576,7 @@ function configToForm(c: CrawlConfig): FormState {
discoverSitemaps: c.discoverSitemaps,
scope: c.scope,
storeImages: c.storeImages ?? true,
crawlImageVariants: c.crawlImageVariants ?? false,
crawlMedia: c.crawlMedia ?? false,
storeMedia: c.storeMedia ?? false,
storeCss: c.storeCss ?? true,
@@ -899,6 +901,7 @@ export function SettingsDialog({ open, onClose }: Props) {
discoverSitemaps: form.discoverSitemaps,
scope: form.scope,
storeImages: form.storeImages,
crawlImageVariants: form.crawlImageVariants,
crawlMedia: form.crawlMedia,
storeMedia: form.storeMedia,
storeCss: form.storeCss,
@@ -2099,11 +2102,22 @@ function SpiderCrawlPanel({ form, update }: PanelProps) {
>
<CrawlRow
label="Images"
info="Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests."
info="Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests."
example="Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check."
crawl={{ checked: form.checkImages, onChange: (v) => update('checkImages', v) }}
store={{ checked: form.storeImages, onChange: (v) => update('storeImages', v) }}
/>
<CrawlRow
label="srcset Variants"
info="Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."
example="Leave off unless you audit responsive image sets for broken or oversized sizes."
crawl={{
checked: form.crawlImageVariants,
disabled: !form.checkImages,
onChange: (v) => update('crawlImageVariants', v),
}}
store={null}
/>
<CrawlRow
label="Media"
info="<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for."
@@ -103,9 +103,45 @@ function lagClass(lagMs: number): string {
return 'text-red-300';
}
/**
* Post-crawl operation names (the crawler's `setOp` labels) → status-bar
* step. `stats.phase.<key>` carries the translation; the English here is
* the fallback.
*/
const PHASES: Record<string, { key: string; en: string }> = {
'post-crawl:drain-pending-stubs': { key: 'externalLinks', en: 'Checking external links' },
'crawl:sitemap-orphans': { key: 'sitemapOrphans', en: 'Sitemap orphans' },
'crawl:login': { key: 'login', en: 'Logging in' },
'post-crawl:optimize': { key: 'optimize', en: 'Optimising database' },
'post-crawl:recompute-inlinks': { key: 'inlinks', en: 'Counting inlinks' },
'post-crawl:link-score': { key: 'linkScore', en: 'Link scores' },
'post-crawl:recompute-redirect-chains': { key: 'redirectChains', en: 'Redirect chains' },
'post-crawl:recompute-hreflang': { key: 'hreflang', en: 'Hreflang analysis' },
'post-crawl:recompute-hreflang-inconsistent': { key: 'hreflang', en: 'Hreflang analysis' },
'post-crawl:cluster-duplicates': { key: 'duplicates', en: 'Clustering duplicates' },
'post-crawl:boilerplate-coverage': { key: 'boilerplate', en: 'Detecting boilerplate' },
'post-crawl:pagination-sequence': { key: 'pagination', en: 'Pagination sequences' },
'post-crawl:image-probes': { key: 'imageProbes', en: 'Measuring images' },
'post-crawl:tls-probes': { key: 'tlsProbes', en: 'TLS certificates' },
'post-crawl:manifest-probes': { key: 'manifestProbes', en: 'Web manifests' },
'post-crawl:social-image-probes': { key: 'socialImages', en: 'Social images' },
'post-crawl:mobile-parity': { key: 'mobileParity', en: 'Mobile parity' },
'post-crawl:pdf-probes': { key: 'pdfProbes', en: 'PDF files' },
'post-crawl:soft-404-probe': { key: 'soft404', en: 'Soft 404 probe' },
'post-crawl:performance-budget': { key: 'performanceBudget', en: 'Performance budget' },
'post-crawl:materialise-issues': { key: 'issues', en: 'Computing issues' },
'post-crawl:wal-checkpoint': { key: 'checkpoint', en: 'Flushing database' },
};
export function StatsBar() {
const { t } = useTranslation();
const progress = useAppStore((s) => s.progress);
const phase = progress?.phase ? PHASES[progress.phase] : undefined;
const phaseLabel = phase ? t(`stats.phase.${phase.key}`, { defaultValue: phase.en }) : null;
const phaseText =
phaseLabel && progress?.phaseTotal
? `${phaseLabel} ${(progress.phaseDone ?? 0).toLocaleString()} / ${progress.phaseTotal.toLocaleString()}`
: phaseLabel;
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
const perf = usePerfMeter();
@@ -260,7 +296,25 @@ export function StatsBar() {
})}
</button>
)}
{progress?.running ? (
{progress?.finishing ? (
// The fetch loop is over and the post-crawl passes are running;
// `running` is still true here, so this branch comes first —
// otherwise a 20-minute image probe reads as "Running" with
// Pending 0 and nothing to explain it.
<span
className="inline-flex items-center gap-1.5"
title={t('stats.finishingTitle', {
defaultValue:
'Pages are fetched; the crawl is now running its analysis passes. Each step is listed here as it runs.',
})}
>
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
<span className="text-amber-400">
{t('stats.finishing', { defaultValue: 'Finishing…' })}
{phaseText ? ` · ${phaseText}` : ''}
</span>
</span>
) : progress?.running ? (
progress.paused ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
@@ -272,13 +326,6 @@ export function StatsBar() {
<span className="text-emerald-400">{t('stats.running')}</span>
</span>
)
) : progress?.finishing ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
<span className="text-amber-400">
{t('stats.finishing', { defaultValue: 'Finishing…' })}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-surface-600" />
@@ -546,8 +546,12 @@ export const AZ_INFO_TIPS: Record<string, string> = {
"Crawl-delay direktivinə qlobal sürət həddi kimi əməl et (hər N saniyədə bir sorğu). Crawl-delay RFC 9309-un hissəsi deyil — Google onu nəzərə almır və Screaming Frog tətbiq etmir — və dərc olunan dəyərlər çox vaxt köhnəlmişdir: 'Crawl-delay: 30' 500 URL-lik taramanı saatlara çevirir. Standart olaraq nəzərə alınmır; tapıldıqda direktiv yenə jurnalda bildirilir.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Normal auditlər üçün söndürülü (standart). Əməliyyat siyasəti tələb etdikdə aktiv — taramanın hər URL üçün Crawl-delay saniyə çəkəcəyini gözləyin.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Tara daxili <img> hədəflərini (srcset / <picture> mənbələri daxil) yükləyir ki, hər biri Daxili sekmesində status, məzmun tipi və ölçü ilə görünsün — hər biri Maks URL-ə sayılır. Saxla <img> bəyanlarını Şəkillər sekmesində saxlayır, bu Tara söndürülü olsa belə işləyir: sıfır əlavə sorğu qarşılığında alt mətni ilə tam şəkil inventarı alırsınız.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Tara daxili <img> hədəflərini yükləyir ki, hər biri Daxili sekmesində status, məzmun tipi və ölçü ilə görünsün — hər biri Maks URL-ə sayılır. Saxla <img> bəyanlarını Şəkillər sekmesində saxlayır, bu Tara söndürülü olsa belə işləyir: sıfır əlavə sorğu qarşılığında alt mətni ilə tam şəkil inventarı alırsınız.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"<img>-in srcset / <picture> daxilində sadaladığı əlavə ölçüləri də yükləyir — WordPress və əksər CDN-lərin yaratdığı 300×169, 768×432… nüsxələr. Söndürüldükdə yalnız şəklin öz src-i sorğulanır və ölçülür; variantlar yenə Şəkillər sekmesində sıralanır. Bir bloqda bunu açmaq şəkil sorğularını 2.300-dən 41.600-ə qaldırdı.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Responsiv şəkil dəstlərini qırıq və ya həddən böyük ölçülər üçün yoxlamırsınızsa, söndürülü saxlayın.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'Saxla aktiv, Tara söndürülü ucuz alt mətn auditidir. Tam şəkil sağlamlığı yoxlaması üçün hər ikisi aktiv.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -549,8 +549,12 @@ export const ES_INFO_TIPS: Record<string, string> = {
"Respetar una directiva Crawl-delay como límite de tasa global (una petición cada N segundos). Crawl-delay no forma parte del RFC 9309 — Google lo ignora y Screaming Frog no lo implementa — y los valores publicados suelen estar obsoletos: 'Crawl-delay: 30' convierte un rastreo de 500 URL en horas. Ignorado por defecto; la directiva se sigue informando en el registro cuando se encuentra.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Desactivado (por defecto) para auditorías normales. Activado cuando una política operativa lo exija — espera que el rastreo tarde Crawl-delay segundos por URL.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Rastrear descarga los destinos <img> internos (incl. srcset / fuentes de <picture>) para que cada uno aparezca en la pestaña Interno con estado, tipo de contenido y tamaño — todos cuentan para Máx. URL. Almacenar conserva las declaraciones <img> en la pestaña Imágenes, que funciona incluso con Rastrear desactivado: obtienes el inventario completo de imágenes con texto alt a coste de cero peticiones extra.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Rastrear descarga los destinos <img> internos para que cada uno aparezca en la pestaña Interno con estado, tipo de contenido y tamaño — todos cuentan para Máx. URL. Almacenar conserva las declaraciones <img> en la pestaña Imágenes, que funciona incluso con Rastrear desactivado: obtienes el inventario completo de imágenes con texto alt a coste de cero peticiones extra.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"También descarga los tamaños adicionales que un <img> declara en srcset / <picture> — las copias 300×169, 768×432… que generan WordPress y la mayoría de CDN. Desactivado: solo se solicita y mide el src propio de la imagen; las variantes siguen listadas en la pestaña Imágenes. En un blog, activarlo pasó de 2.300 a 41.600 peticiones de imágenes.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Déjalo desactivado salvo que audites conjuntos de imágenes responsive en busca de tamaños rotos o sobredimensionados.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'Almacenar activado, Rastrear desactivado es la auditoría barata de texto alt. Ambos activados para una revisión completa de la salud de las imágenes.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -549,8 +549,12 @@ export const FR_INFO_TIPS: Record<string, string> = {
"Respecter une directive Crawl-delay comme limite de débit globale (une requête toutes les N secondes). Crawl-delay ne fait pas partie de la RFC 9309 — Google l'ignore et Screaming Frog ne l'implémente pas — et les valeurs publiées sont souvent obsolètes : 'Crawl-delay: 30' transforme une exploration de 500 URL en heures. Ignoré par défaut ; la directive est tout de même signalée dans le journal lorsqu'elle est trouvée.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
"Désactivé (par défaut) pour les audits classiques. Activé lorsqu'une politique d'exploitation l'exige — attendez-vous à ce que l'exploration prenne Crawl-delay secondes par URL.",
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"Explorer récupère les cibles <img> internes (y compris srcset / sources <picture>) pour que chacune apparaisse dans l'onglet Interne avec statut, type de contenu et taille — chacune compte dans Max URL. Stocker conserve les déclarations <img> dans l'onglet Images, qui fonctionne même avec Explorer désactivé : vous obtenez l'inventaire complet des images avec leur texte alt pour zéro requête supplémentaire.",
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"Explorer récupère les cibles <img> internes pour que chacune apparaisse dans l'onglet Interne avec statut, type de contenu et taille — chacune compte dans Max URL. Stocker conserve les déclarations <img> dans l'onglet Images, qui fonctionne même avec Explorer désactivé : vous obtenez l'inventaire complet des images avec leur texte alt pour zéro requête supplémentaire.",
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"Récupère aussi les tailles supplémentaires qu'un <img> déclare dans srcset / <picture> — les copies 300×169, 768×432… générées par WordPress et la plupart des CDN. Désactivé : seul le src de l'image est demandé et mesuré ; les variantes restent listées dans l'onglet Images. Sur un blog, l'activer a fait passer les requêtes d'images de 2 300 à 41 600.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Laissez désactivé sauf pour auditer des jeux d'images responsive à la recherche de tailles cassées ou trop lourdes.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
"Stocker activé, Explorer désactivé est l'audit de texte alt bon marché. Les deux activés pour un bilan complet de santé des images.",
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -544,8 +544,12 @@ export const HI_INFO_TIPS: Record<string, string> = {
"Crawl-delay निर्देश को वैश्विक दर सीमा (हर N सेकंड में एक अनुरोध) के रूप में मानें। Crawl-delay RFC 9309 का हिस्सा नहीं है — Google इसे अनदेखा करता है और Screaming Frog इसे लागू नहीं करता — और प्रकाशित मान अक्सर पुराने होते हैं: 'Crawl-delay: 30' 500-URL क्रॉल को घंटों में बदल देता है। डिफ़ॉल्ट रूप से अनदेखा; मिलने पर निर्देश फिर भी लॉग में दर्ज होता है।",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'सामान्य ऑडिट के लिए बंद (डिफ़ॉल्ट)। जब कोई ऑप्स नीति अपेक्षित करे तब चालू — प्रति URL Crawl-delay सेकंड लगने की उम्मीद रखें।',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"क्रॉल आंतरिक <img> लक्ष्य (srcset / <picture> स्रोत सहित) फ़ेच करता है ताकि हर एक स्टेटस, कंटेंट टाइप और आकार के साथ 'आंतरिक' टैब में दिखे — हर एक अधिकतम URL में गिना जाता है। संग्रह <img> घोषणाओं को छवियाँ टैब में रखता है, जो क्रॉल बंद होने पर भी काम करता है: शून्य अतिरिक्त अनुरोध पर alt टेक्स्ट सहित पूरी छवि सूची मिलती है।",
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"क्रॉल आंतरिक <img> लक्ष्य फ़ेच करता है ताकि हर एक स्टेटस, कंटेंट टाइप और आकार के साथ 'आंतरिक' टैब में दिखे — हर एक अधिकतम URL में गिना जाता है। संग्रह <img> घोषणाओं को छवियाँ टैब में रखता है, जो क्रॉल बंद होने पर भी काम करता है: शून्य अतिरिक्त अनुरोध पर alt टेक्स्ट सहित पूरी छवि सूची मिलती है।",
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"<img> द्वारा srcset / <picture> में सूचीबद्ध अतिरिक्त आकार भी फ़ेच करता है — WordPress और अधिकांश CDN द्वारा बनाई गई 300×169, 768×432… प्रतियाँ। बंद होने पर केवल छवि का अपना src अनुरोधित और मापा जाता है; वैरिएंट फिर भी छवियाँ टैब में सूचीबद्ध रहते हैं। एक ब्लॉग पर इसे चालू करने से छवि अनुरोध 2,300 से 41,600 हो गए।",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"टूटे या बहुत बड़े आकारों के लिए रिस्पॉन्सिव छवि सेट का ऑडिट न कर रहे हों तो इसे बंद रखें।",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'संग्रह चालू, क्रॉल बंद सस्ता alt-टेक्स्ट ऑडिट है। पूर्ण छवि स्वास्थ्य जाँच के लिए दोनों चालू।',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -549,8 +549,12 @@ export const IT_INFO_TIPS: Record<string, string> = {
"Rispetta una direttiva Crawl-delay come limite di frequenza globale (una richiesta ogni N secondi). Crawl-delay non fa parte dell'RFC 9309 — Google lo ignora e Screaming Frog non lo implementa — e i valori pubblicati sono spesso obsoleti: 'Crawl-delay: 30' trasforma una scansione da 500 URL in ore. Ignorato per impostazione predefinita; la direttiva viene comunque riportata nel log quando trovata.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Disattivato (predefinito) per audit normali. Attivato quando una policy operativa lo richiede — aspettati che la scansione impieghi Crawl-delay secondi per URL.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"Scansiona recupera le destinazioni <img> interne (incl. srcset / sorgenti <picture>) così ognuna compare nella scheda Interni con stato, tipo di contenuto e dimensione — ognuna conta ai fini di Max URL. Memorizza conserva le dichiarazioni <img> nella scheda Immagini, che funziona anche con Scansiona disattivato: ottieni l'inventario completo delle immagini con testo alt al costo di zero richieste extra.",
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
"Scansiona recupera le destinazioni <img> interne così ognuna compare nella scheda Interni con stato, tipo di contenuto e dimensione — ognuna conta ai fini di Max URL. Memorizza conserva le dichiarazioni <img> nella scheda Immagini, che funziona anche con Scansiona disattivato: ottieni l'inventario completo delle immagini con testo alt al costo di zero richieste extra.",
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"Recupera anche le dimensioni aggiuntive che un <img> elenca in srcset / <picture> — le copie 300×169, 768×432… generate da WordPress e dalla maggior parte dei CDN. Disattivato: viene richiesto e misurato solo il src dell'immagine; le varianti restano elencate nella scheda Immagini. Su un blog, attivarlo ha portato le richieste di immagini da 2.300 a 41.600.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Lascialo disattivato a meno che tu non stia verificando set di immagini responsive per dimensioni rotte o eccessive.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
"Memorizza attivato, Scansiona disattivato è l'audit economico del testo alt. Entrambi attivati per un controllo completo della salute delle immagini.",
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -539,8 +539,12 @@ export const KO_INFO_TIPS: Record<string, string> = {
"Crawl-delay 지시문을 전역 속도 제한(N초마다 요청 하나)으로 준수합니다. Crawl-delay는 RFC 9309에 포함되지 않으며 — Google은 무시하고 Screaming Frog는 구현하지 않습니다 — 게시된 값은 오래된 경우가 많습니다: 'Crawl-delay: 30'은 500개 URL 크롤링을 몇 시간짜리로 만듭니다. 기본적으로 무시되며, 발견되면 로그에는 여전히 보고됩니다.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'일반 감사에는 끔(기본값). 운영 정책이 요구할 때 켬 — URL당 Crawl-delay 초가 걸릴 것을 감안하세요.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'크롤링은 내부 <img> 대상(srcset / <picture> 소스 포함)을 가져와 각각 상태, 콘텐츠 유형, 크기와 함께 내부 탭에 표시합니다 — 모두 최대 URL 수에 포함됩니다. 저장은 <img> 선언을 이미지 탭에 유지하며 크롤링이 꺼져 있어도 동작합니다: 추가 요청 없이 alt 텍스트가 포함된 전체 이미지 목록을 얻습니다.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'크롤링은 내부 <img> 대상을 가져와 각각 상태, 콘텐츠 유형, 크기와 함께 내부 탭에 표시합니다 — 모두 최대 URL 수에 포함됩니다. 저장은 <img> 선언을 이미지 탭에 유지하며 크롤링이 꺼져 있어도 동작합니다: 추가 요청 없이 alt 텍스트가 포함된 전체 이미지 목록을 얻습니다.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"<img>가 srcset / <picture>에 나열한 추가 크기도 가져옵니다 — WordPress와 대부분의 CDN이 생성하는 300×169, 768×432… 사본입니다. 꺼져 있으면 이미지 자체의 src만 요청·측정되며, 변형은 여전히 이미지 탭에 표시됩니다. 한 블로그에서는 이 옵션을 켜자 이미지 요청이 2,300건에서 41,600건으로 늘었습니다.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"깨지거나 너무 큰 크기를 찾기 위해 반응형 이미지 세트를 감사하는 경우가 아니면 꺼 두세요.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'저장 켬, 크롤링 끔은 저렴한 alt 텍스트 감사입니다. 완전한 이미지 상태 점검에는 둘 다 켜세요.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -547,8 +547,12 @@ export const PT_BR_INFO_TIPS: Record<string, string> = {
"Respeitar uma diretiva Crawl-delay como limite global de taxa (uma requisição a cada N segundos). Crawl-delay não faz parte da RFC 9309 — o Google a ignora e o Screaming Frog não a implementa — e os valores publicados costumam estar obsoletos: 'Crawl-delay: 30' transforma um rastreamento de 500 URLs em horas. Ignorada por padrão; a diretiva ainda é informada no log quando encontrada.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Desligado (padrão) para auditorias normais. Ligado quando uma política operacional exigir — espere que o rastreamento leve Crawl-delay segundos por URL.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Rastrear busca os destinos <img> internos (incl. srcset / fontes de <picture>) para que cada um apareça na aba Interno com status, tipo de conteúdo e tamanho — todos contam para Máx. URLs. Armazenar mantém as declarações <img> na aba Imagens, que funciona mesmo com Rastrear desligado: você obtém o inventário completo de imagens com texto alt ao custo de zero requisições extras.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Rastrear busca os destinos <img> internos para que cada um apareça na aba Interno com status, tipo de conteúdo e tamanho — todos contam para Máx. URLs. Armazenar mantém as declarações <img> na aba Imagens, que funciona mesmo com Rastrear desligado: você obtém o inventário completo de imagens com texto alt ao custo de zero requisições extras.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"Também busca os tamanhos extras que um <img> lista em srcset / <picture> — as cópias 300×169, 768×432… que o WordPress e a maioria dos CDNs geram. Desligado: só o src da própria imagem é solicitado e medido; as variantes continuam listadas na aba Imagens. Em um blog, ligar isso elevou as requisições de imagens de 2.300 para 41.600.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Deixe desligado a menos que esteja auditando conjuntos de imagens responsivas em busca de tamanhos quebrados ou grandes demais.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'Armazenar ligado, Rastrear desligado é a auditoria barata de texto alt. Ambos ligados para uma verificação completa da saúde das imagens.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -548,8 +548,12 @@ export const RU_INFO_TIPS: Record<string, string> = {
"Соблюдать директиву Crawl-delay как глобальное ограничение частоты (один запрос каждые N секунд). Crawl-delay не входит в RFC 9309 — Google её игнорирует, а Screaming Frog не реализует — и опубликованные значения часто устарели: 'Crawl-delay: 30' превращает сканирование 500 URL в часы. По умолчанию игнорируется; найденная директива всё равно отмечается в логе.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Выкл. (по умолчанию) для обычных аудитов. Вкл., когда этого требует политика эксплуатации — ожидайте Crawl-delay секунд на каждый URL.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'«Сканировать» загружает внутренние цели <img> (включая srcset / источники <picture>), чтобы каждая появилась во вкладке «Внутренние» со статусом, типом контента и размером — каждая засчитывается в «Макс. URL». «Сохранять» держит объявления <img> во вкладке «Изображения», которая работает и при выключенном «Сканировать»: вы получаете полный инвентарь изображений с alt-текстом ценой нуля дополнительных запросов.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'«Сканировать» загружает внутренние цели <img>, чтобы каждая появилась во вкладке «Внутренние» со статусом, типом контента и размером — каждая засчитывается в «Макс. URL». «Сохранять» держит объявления <img> во вкладке «Изображения», которая работает и при выключенном «Сканировать»: вы получаете полный инвентарь изображений с alt-текстом ценой нуля дополнительных запросов.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"Также загружает дополнительные размеры, которые <img> перечисляет в srcset / <picture>, — копии 300×169, 768×432…, создаваемые WordPress и большинством CDN. Выключено: запрашивается и измеряется только собственный src изображения; варианты по-прежнему перечислены во вкладке «Изображения». На одном блоге включение подняло число запросов изображений с 2 300 до 41 600.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Оставьте выключенным, если не проверяете адаптивные наборы изображений на битые или слишком большие размеры.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'«Сохранять» вкл., «Сканировать» выкл. — дешёвый аудит alt-текста. Оба вкл. — полная проверка здоровья изображений.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -548,8 +548,12 @@ export const TR_INFO_TIPS: Record<string, string> = {
"Crawl-delay direktifini genel bir hız sınırı olarak uygular (N saniyede bir istek). Crawl-delay RFC 9309'un parçası değildir — Google yok sayar, Screaming Frog uygulamaz — ve yayımlanan değerler çoğu zaman eskimiştir: 'Crawl-delay: 30' 500 URL'lik bir taramayı saatlere çıkarır. Varsayılan olarak yok sayılır; direktif bulunduğunda yine de log'a yazılır.",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'Normal denetimler için kapalı (varsayılan). Bir operasyon politikası gerektiriyorsa açın — taramanın URL başına Crawl-delay saniye süreceğini hesaba katın.',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Tara, dahili <img> hedeflerini (srcset / <picture> kaynakları dahil) çeker; her biri Internal sekmesinde durum, içerik tipi ve boyutuyla görünür — ve her biri Maks URL kotasından düşer. Sakla, <img> bildirimlerini Images sekmesinde tutar ve Tara kapalıyken de çalışır: sıfır ek istek karşılığında alt metinleriyle birlikte tam görsel envanterini alırsınız.',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'Tara, dahili <img> hedeflerini çeker; her biri Internal sekmesinde durum, içerik tipi ve boyutuyla görünür — ve her biri Maks URL kotasından düşer. Sakla, <img> bildirimlerini Images sekmesinde tutar ve Tara kapalıyken de çalışır: sıfır ek istek karşılığında alt metinleriyle birlikte tam görsel envanterini alırsınız.',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"Bir <img>'in srcset / <picture> içinde listelediği ek boyutları da çeker — WordPress'in ve çoğu CDN'in ürettiği 300×169, 768×432… kopyalar. Kapalıyken yalnızca görselin kendi src'si istenir ve ölçülür; varyantlar yine Images sekmesinde listelenir. Bu açıkken bir blogda görsel isteği 2.300'den 41.600'e çıktı.",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"Duyarlı görsel setlerini kırık ya da fazla büyük boyutlar için denetlemiyorsanız kapalı bırakın.",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'Sakla açık, Tara kapalı — ucuz alt metin denetimi. Tam bir görsel sağlık kontrolü için ikisi de açık.',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -535,8 +535,12 @@ export const ZH_CN_INFO_TIPS: Record<string, string> = {
"将 Crawl-delay 指令作为全局速率限制(每 N 秒一个请求)遵守。Crawl-delay 不属于 RFC 9309——Google 忽略它,Screaming Frog 也未实现——且发布的值往往过时:'Crawl-delay: 30' 会让 500 个 URL 的抓取耗时数小时。默认忽略;发现该指令时仍会在日志中报告。",
['Off (default) for normal audits. On when an ops policy requires it — expect the crawl to take Crawl-delay seconds per URL.']:
'常规审计关闭(默认)。运维策略要求时开启——预期每个 URL 耗时 Crawl-delay 秒。',
['Crawl fetches internal <img> targets (incl. srcset / <picture> sources) so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'“抓取”获取内部 <img> 目标(含 srcset / <picture> 源),使每个都带状态、内容类型和大小出现在“内部”标签页中——每个都计入最大 URL 数。“存储”将 <img> 声明保留在“图片”标签页中,即使“抓取”关闭也有效:零额外请求即可获得带 alt 文本的完整图片清单。',
['Crawl fetches internal <img> targets so each appears in the Internal tab with status, content type, and size — every one counts toward Max URLs. Store keeps the <img> declarations in the Images tab, which works even with Crawl off: you get the full image inventory with alt text for the cost of zero extra requests.']:
'“抓取”获取内部 <img> 目标,使每个都带状态、内容类型和大小出现在“内部”标签页中——每个都计入最大 URL 数。“存储”将 <img> 声明保留在“图片”标签页中,即使“抓取”关闭也有效:零额外请求即可获得带 alt 文本的完整图片清单。',
["Also fetch the extra sizes an <img> lists in srcset / <picture> — the 300×169, 768×432… copies WordPress and most CDNs generate. Off: only the image's own src is requested and measured; the variants are still listed in the Images tab. One blog went from 2,300 to 41,600 image requests with this on."]:
"同时获取 <img> 在 srcset / <picture> 中列出的其他尺寸——WordPress 和多数 CDN 生成的 300×169、768×432… 副本。关闭时只请求并测量图片自身的 src;变体仍会列在“图片”标签页中。某博客开启后图片请求从 2,300 增至 41,600。",
["Leave off unless you audit responsive image sets for broken or oversized sizes."]:
"除非要审核响应式图片集中损坏或过大的尺寸,否则保持关闭。",
['Store on, Crawl off is the cheap alt-text audit. Both on for a full image health check.']:
'“存储”开、“抓取”关是低成本的 alt 文本审计。两者都开则进行完整的图片健康检查。',
['<video> / <audio> and the <source> children they own. Off by default — media files are large and rarely what an SEO crawl is looking for.']:
@@ -687,6 +687,7 @@ export const AZ_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Media',
'srcset Variants': 'srcset variantları',
'Internal Hyperlinks': 'Daxili hiperkeçidlər',
'External Links': 'Xarici keçidlər',
'Pagination (Rel Next/Prev)': 'Səhifələmə (rel next/prev)',
@@ -687,6 +687,7 @@ export const ES_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Medios',
'srcset Variants': 'Variantes srcset',
'Internal Hyperlinks': 'Hiperenlaces internos',
'External Links': 'Enlaces externos',
'Pagination (Rel Next/Prev)': 'Paginación (rel next/prev)',
@@ -685,6 +685,7 @@ export const FR_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Médias',
'srcset Variants': 'Variantes srcset',
'Internal Hyperlinks': 'Hyperliens internes',
'External Links': 'Liens externes',
'Pagination (Rel Next/Prev)': 'Pagination (rel next/prev)',
@@ -680,6 +680,7 @@ export const HI_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'मीडिया',
'srcset Variants': 'srcset वैरिएंट',
'Internal Hyperlinks': 'आंतरिक हाइपरलिंक',
'External Links': 'बाहरी लिंक',
'Pagination (Rel Next/Prev)': 'पेजिनेशन (rel next/prev)',
@@ -683,6 +683,7 @@ export const IT_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Media',
'srcset Variants': 'Varianti srcset',
'Internal Hyperlinks': 'Collegamenti interni',
'External Links': 'Link esterni',
'Pagination (Rel Next/Prev)': 'Paginazione (rel next/prev)',
@@ -675,6 +675,7 @@ export const KO_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: '미디어',
'srcset Variants': 'srcset 변형',
'Internal Hyperlinks': '내부 하이퍼링크',
'External Links': '외부 링크',
'Pagination (Rel Next/Prev)': '페이지네이션(rel next/prev)',
@@ -685,6 +685,7 @@ export const PT_BR_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Mídia',
'srcset Variants': 'Variantes srcset',
'Internal Hyperlinks': 'Hiperlinks internos',
'External Links': 'Links externos',
'Pagination (Rel Next/Prev)': 'Paginação (rel next/prev)',
@@ -682,6 +682,7 @@ export const RU_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: 'Медиа',
'srcset Variants': 'Варианты srcset',
'Internal Hyperlinks': 'Внутренние гиперссылки',
'External Links': 'Внешние ссылки',
'Pagination (Rel Next/Prev)': 'Пагинация (rel next/prev)',
@@ -670,6 +670,7 @@ export const TR_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels (the ones not already registered
// above as table columns / sidebar nodes).
Media: 'Medya',
'srcset Variants': 'srcset Varyantları',
'Internal Hyperlinks': 'Dahili Bağlantılar',
'External Links': 'Harici Bağlantılar',
'Pagination (Rel Next/Prev)': 'Sayfalama (Rel Next/Prev)',
@@ -674,6 +674,7 @@ export const ZH_CN_LABELS: Record<string, string> = {
// Spider → Crawl matrix row labels
Media: '媒体',
'srcset Variants': 'srcset 变体',
'Internal Hyperlinks': '内部超链接',
'External Links': '外部链接',
'Pagination (Rel Next/Prev)': '分页(rel next/prev)',
@@ -6,7 +6,9 @@
"siteSoft404": "Mövcud olmayan səhifələr {{status}} qaytarır (soft 404)",
"siteSoft404Hint": "Mövcud olmayan səhifəyə edilən sorğuya 404 əvəzinə {{status}} cavabı verildi, buna görə də bu saytdakı qırıq keçidlər axtarış sistemlərinə işləyən səhifələr kimi görünür.",
"siteBotBlocked": "Bot qorunması taramanın bir hissəsini blokladı",
"siteBotBlockedHint": "Saytın bot qorunması bu səhifələrin əvəzinə yoxlama səhifəsi ilə cavab verdi. Onlar taranmadı və qırıq keçid və sitemap yoxlamalarından kənarda saxlanılır. Tarama sürətini azaldın və ya tarayıcının user agent-ini icazə siyahısına əlavə edin, sonra onları yenidən tarayın."
"siteBotBlockedHint": "Saytın bot qorunması bu səhifələrin əvəzinə yoxlama səhifəsi ilə cavab verdi. Onlar taranmadı və qırıq keçid və sitemap yoxlamalarından kənarda saxlanılır. Tarama sürətini azaldın və ya tarayıcının user agent-ini icazə siyahısına əlavə edin, sonra onları yenidən tarayın.",
"checkOff": "Bağlı",
"cookiesOffHint": "Taramanın kuki siyasəti \"Hamısını rədd et\" olduğu üçün kuki yoxlamaları bağlıdır. Set-Cookie başlıqlarını təhlil etmək üçün Parametrlər → Kukilər bölməsində \"Hamısını qəbul et\" və ya \"Üçüncü tərəfləri blokla\" seçin."
},
"integrations": {
"intro": "Xarici xidmətləri öz giriş məlumatlarınızla qoşun. API açarları və sirlər əməliyyat sisteminizin təhlükəsiz anbarı ilə şifrələnərək saxlanılır və heç vaxt bu maşından kənara çıxmır.",
@@ -130,6 +132,30 @@
"closeTitle": "Bu agent sessiyasını bağla (onun taramasını dayandırır və resursları boşaldır)"
},
"stats": {
"finishingTitle": "Səhifələr yükləndi; tarama indi analiz keçidlərini icra edir. Hər addım icra olunarkən burada göstərilir.",
"phase": {
"externalLinks": "Xarici keçidlər yoxlanılır",
"sitemapOrphans": "Sitemap yetimləri",
"login": "Daxil olunur",
"optimize": "Verilənlər bazası optimallaşdırılır",
"inlinks": "Daxil olan keçidlər sayılır",
"linkScore": "Keçid balları",
"redirectChains": "Yönləndirmə zəncirləri",
"hreflang": "Hreflang analizi",
"duplicates": "Dublikatlar qruplaşdırılır",
"boilerplate": "Şablon məzmun aşkarlanır",
"pagination": "Səhifələmə ardıcıllıqları",
"imageProbes": "Şəkillər ölçülür",
"tlsProbes": "TLS sertifikatları",
"manifestProbes": "Veb manifestlər",
"socialImages": "Sosial şəkillər",
"mobileParity": "Mobil bərabərlik",
"pdfProbes": "PDF faylları",
"soft404": "Soft 404 yoxlaması",
"performanceBudget": "Performans büdcəsi",
"issues": "Problemlər hesablanır",
"checkpoint": "Verilənlər bazası yazılır"
},
"discovered": "Tapıldı",
"crawled": "Tarandı",
"pending": "Gözləyir",
@@ -6,7 +6,9 @@
"siteSoft404": "Missing pages return {{status}} (soft 404)",
"siteSoft404Hint": "A request for a page that does not exist was answered {{status}} instead of 404, so broken links on this site look like working pages to search engines.",
"siteBotBlocked": "Bot protection blocked part of the crawl",
"siteBotBlockedHint": "The site's bot protection answered with a challenge instead of these pages. They were not crawled and are left out of the broken-link and sitemap checks. Lower the crawl speed or allowlist the crawler's user agent, then re-crawl them."
"siteBotBlockedHint": "The site's bot protection answered with a challenge instead of these pages. They were not crawled and are left out of the broken-link and sitemap checks. Lower the crawl speed or allowlist the crawler's user agent, then re-crawl them.",
"checkOff": "Off",
"cookiesOffHint": "Cookie checks are off because the crawl's cookie policy is \"Reject all\". Choose \"Accept all\" or \"Block third-party\" under Settings → Cookies to analyse Set-Cookie headers."
},
"integrations": {
"intro": "Connect external services with your own credentials. API keys and secrets are encrypted at rest using your operating system's secure storage and never leave this machine.",
@@ -130,6 +132,30 @@
"closeTitle": "Close this agent session (stops its crawl and frees resources)"
},
"stats": {
"finishingTitle": "Pages are fetched; the crawl is now running its analysis passes. Each step is listed here as it runs.",
"phase": {
"externalLinks": "Checking external links",
"sitemapOrphans": "Sitemap orphans",
"login": "Logging in",
"optimize": "Optimising database",
"inlinks": "Counting inlinks",
"linkScore": "Link scores",
"redirectChains": "Redirect chains",
"hreflang": "Hreflang analysis",
"duplicates": "Clustering duplicates",
"boilerplate": "Detecting boilerplate",
"pagination": "Pagination sequences",
"imageProbes": "Measuring images",
"tlsProbes": "TLS certificates",
"manifestProbes": "Web manifests",
"socialImages": "Social images",
"mobileParity": "Mobile parity",
"pdfProbes": "PDF files",
"soft404": "Soft 404 probe",
"performanceBudget": "Performance budget",
"issues": "Computing issues",
"checkpoint": "Flushing database"
},
"discovered": "Discovered",
"crawled": "Crawled",
"pending": "Pending",
@@ -6,7 +6,9 @@
"siteSoft404": "Las páginas inexistentes devuelven {{status}} (soft 404)",
"siteSoft404Hint": "Una solicitud a una página que no existe recibió {{status}} en lugar de 404, así que los enlaces rotos de este sitio parecen páginas válidas para los buscadores.",
"siteBotBlocked": "La protección antibots bloqueó parte del rastreo",
"siteBotBlockedHint": "La protección antibots del sitio respondió con un desafío en lugar de estas páginas. No se rastrearon y quedan fuera de las comprobaciones de enlaces rotos y de sitemap. Reduce la velocidad de rastreo o añade el user agent del rastreador a la lista de permitidos y vuelve a rastrearlas."
"siteBotBlockedHint": "La protección antibots del sitio respondió con un desafío en lugar de estas páginas. No se rastrearon y quedan fuera de las comprobaciones de enlaces rotos y de sitemap. Reduce la velocidad de rastreo o añade el user agent del rastreador a la lista de permitidos y vuelve a rastrearlas.",
"checkOff": "Desactivado",
"cookiesOffHint": "Las comprobaciones de cookies están desactivadas porque la política de cookies del rastreo es \"Rechazar todo\". Elija \"Aceptar todo\" o \"Bloquear de terceros\" en Ajustes → Cookies para analizar las cabeceras Set-Cookie."
},
"integrations": {
"intro": "Conecta servicios externos con tus propias credenciales. Las claves de API y los secretos se cifran en reposo mediante el almacenamiento seguro de tu sistema operativo y nunca salen de esta máquina.",
@@ -132,6 +134,30 @@
"closeTitle": "Cerrar esta sesión de agente (detiene su rastreo y libera recursos)"
},
"stats": {
"finishingTitle": "Las páginas ya se descargaron; el rastreo ejecuta ahora sus pasadas de análisis. Cada paso aparece aquí mientras se ejecuta.",
"phase": {
"externalLinks": "Comprobando enlaces externos",
"sitemapOrphans": "Huérfanas del sitemap",
"login": "Iniciando sesión",
"optimize": "Optimizando la base de datos",
"inlinks": "Contando enlaces entrantes",
"linkScore": "Puntuaciones de enlaces",
"redirectChains": "Cadenas de redirección",
"hreflang": "Análisis de hreflang",
"duplicates": "Agrupando duplicados",
"boilerplate": "Detectando contenido repetido",
"pagination": "Secuencias de paginación",
"imageProbes": "Midiendo imágenes",
"tlsProbes": "Certificados TLS",
"manifestProbes": "Manifiestos web",
"socialImages": "Imágenes sociales",
"mobileParity": "Paridad móvil",
"pdfProbes": "Archivos PDF",
"soft404": "Sondeo de soft 404",
"performanceBudget": "Presupuesto de rendimiento",
"issues": "Calculando problemas",
"checkpoint": "Volcando la base de datos"
},
"discovered": "Descubiertas",
"crawled": "Rastreadas",
"pending": "Pendientes",
@@ -6,7 +6,9 @@
"siteSoft404": "Les pages inexistantes renvoient {{status}} (soft 404)",
"siteSoft404Hint": "Une requête vers une page qui n'existe pas a reçu {{status}} au lieu de 404 : les liens cassés de ce site ressemblent donc à des pages valides pour les moteurs de recherche.",
"siteBotBlocked": "La protection anti-bots a bloqué une partie de l'exploration",
"siteBotBlockedHint": "La protection anti-bots du site a répondu par une vérification au lieu de ces pages. Elles n'ont pas été explorées et sont exclues des contrôles de liens cassés et de sitemap. Réduisez la vitesse d'exploration ou autorisez le user agent du robot, puis explorez-les à nouveau."
"siteBotBlockedHint": "La protection anti-bots du site a répondu par une vérification au lieu de ces pages. Elles n'ont pas été explorées et sont exclues des contrôles de liens cassés et de sitemap. Réduisez la vitesse d'exploration ou autorisez le user agent du robot, puis explorez-les à nouveau.",
"checkOff": "Désactivé",
"cookiesOffHint": "Les contrôles des cookies sont désactivés car la politique de cookies de l'exploration est « Tout refuser ». Choisissez « Tout accepter » ou « Bloquer les tiers » dans Paramètres → Cookies pour analyser les en-têtes Set-Cookie."
},
"integrations": {
"intro": "Connectez des services externes avec vos propres identifiants. Les clés d'API et les secrets sont chiffrés au repos via le stockage sécurisé de votre système d'exploitation et ne quittent jamais cette machine.",
@@ -132,6 +134,30 @@
"closeTitle": "Fermer cette session d'agent (arrête son exploration et libère les ressources)"
},
"stats": {
"finishingTitle": "Les pages sont récupérées ; l'exploration exécute maintenant ses passes d'analyse. Chaque étape s'affiche ici pendant son exécution.",
"phase": {
"externalLinks": "Vérification des liens externes",
"sitemapOrphans": "Orphelines du sitemap",
"login": "Connexion",
"optimize": "Optimisation de la base de données",
"inlinks": "Comptage des liens entrants",
"linkScore": "Scores de liens",
"redirectChains": "Chaînes de redirection",
"hreflang": "Analyse hreflang",
"duplicates": "Regroupement des doublons",
"boilerplate": "Détection du contenu récurrent",
"pagination": "Séquences de pagination",
"imageProbes": "Mesure des images",
"tlsProbes": "Certificats TLS",
"manifestProbes": "Manifestes web",
"socialImages": "Images sociales",
"mobileParity": "Parité mobile",
"pdfProbes": "Fichiers PDF",
"soft404": "Sonde soft 404",
"performanceBudget": "Budget de performance",
"issues": "Calcul des problèmes",
"checkpoint": "Écriture de la base de données"
},
"discovered": "Découvertes",
"crawled": "Explorées",
"pending": "En attente",
@@ -6,7 +6,9 @@
"siteSoft404": "मौजूद न होने वाले पेज {{status}} लौटाते हैं (soft 404)",
"siteSoft404Hint": "मौजूद न होने वाले पेज के अनुरोध का उत्तर 404 के बजाय {{status}} मिला, इसलिए इस साइट के टूटे लिंक सर्च इंजनों को काम करते पेज जैसे दिखते हैं।",
"siteBotBlocked": "बॉट सुरक्षा ने क्रॉल का एक हिस्सा रोक दिया",
"siteBotBlockedHint": "साइट की बॉट सुरक्षा ने इन पेजों की जगह एक चुनौती पेज से उत्तर दिया। ये पेज क्रॉल नहीं हुए और टूटे लिंक व साइटमैप जाँचों से बाहर रखे गए हैं। क्रॉल की गति कम करें या क्रॉलर के यूज़र एजेंट को अनुमति सूची में जोड़ें, फिर इन्हें दोबारा क्रॉल करें।"
"siteBotBlockedHint": "साइट की बॉट सुरक्षा ने इन पेजों की जगह एक चुनौती पेज से उत्तर दिया। ये पेज क्रॉल नहीं हुए और टूटे लिंक व साइटमैप जाँचों से बाहर रखे गए हैं। क्रॉल की गति कम करें या क्रॉलर के यूज़र एजेंट को अनुमति सूची में जोड़ें, फिर इन्हें दोबारा क्रॉल करें।",
"checkOff": "बंद",
"cookiesOffHint": "क्रॉल की कुकी नीति \"सभी अस्वीकारें\" होने के कारण कुकी जाँचें बंद हैं। Set-Cookie हेडर का विश्लेषण करने के लिए सेटिंग्स → कुकीज़ में \"सभी स्वीकारें\" या \"तृतीय-पक्ष ब्लॉक करें\" चुनें।"
},
"integrations": {
"intro": "अपनी ही क्रेडेंशियल से बाहरी सेवाएँ जोड़ें। API कुंजियाँ और सीक्रेट आपके ऑपरेटिंग सिस्टम के सुरक्षित स्टोरेज से एन्क्रिप्ट होकर रखे जाते हैं और कभी इस मशीन से बाहर नहीं जाते।",
@@ -130,6 +132,30 @@
"closeTitle": "यह एजेंट सत्र बंद करें (इसका क्रॉल रोकता है और संसाधन मुक्त करता है)"
},
"stats": {
"finishingTitle": "पेज फ़ेच हो चुके हैं; क्रॉल अब अपने विश्लेषण चरण चला रहा है। हर चरण चलते समय यहाँ दिखता है।",
"phase": {
"externalLinks": "बाहरी लिंक जाँचे जा रहे हैं",
"sitemapOrphans": "साइटमैप अनाथ पेज",
"login": "लॉग इन हो रहा है",
"optimize": "डेटाबेस ऑप्टिमाइज़ हो रहा है",
"inlinks": "इनलिंक गिने जा रहे हैं",
"linkScore": "लिंक स्कोर",
"redirectChains": "रीडायरेक्ट चेन",
"hreflang": "Hreflang विश्लेषण",
"duplicates": "डुप्लिकेट क्लस्टर हो रहे हैं",
"boilerplate": "बॉयलरप्लेट पहचानी जा रही है",
"pagination": "पेजिनेशन अनुक्रम",
"imageProbes": "छवियाँ मापी जा रही हैं",
"tlsProbes": "TLS प्रमाणपत्र",
"manifestProbes": "वेब मैनिफ़ेस्ट",
"socialImages": "सोशल छवियाँ",
"mobileParity": "मोबाइल समानता",
"pdfProbes": "PDF फ़ाइलें",
"soft404": "Soft 404 जाँच",
"performanceBudget": "परफ़ॉर्मेंस बजट",
"issues": "समस्याएँ गिनी जा रही हैं",
"checkpoint": "डेटाबेस लिखा जा रहा है"
},
"discovered": "मिले",
"crawled": "क्रॉल किए",
"pending": "प्रतीक्षारत",
@@ -6,7 +6,9 @@
"siteSoft404": "Le pagine inesistenti restituiscono {{status}} (soft 404)",
"siteSoft404Hint": "Una richiesta a una pagina che non esiste ha ricevuto {{status}} invece di 404, quindi i link interrotti di questo sito sembrano pagine funzionanti ai motori di ricerca.",
"siteBotBlocked": "La protezione anti-bot ha bloccato parte della scansione",
"siteBotBlockedHint": "La protezione anti-bot del sito ha risposto con una verifica al posto di queste pagine. Non sono state scansionate e sono escluse dai controlli su link interrotti e sitemap. Riduci la velocità di scansione o autorizza lo user agent del crawler, poi scansionale di nuovo."
"siteBotBlockedHint": "La protezione anti-bot del sito ha risposto con una verifica al posto di queste pagine. Non sono state scansionate e sono escluse dai controlli su link interrotti e sitemap. Riduci la velocità di scansione o autorizza lo user agent del crawler, poi scansionale di nuovo.",
"checkOff": "Disattivato",
"cookiesOffHint": "I controlli sui cookie sono disattivati perché la politica dei cookie della scansione è \"Rifiuta tutto\". Scegli \"Accetta tutto\" o \"Blocca terze parti\" in Impostazioni → Cookie per analizzare le intestazioni Set-Cookie."
},
"integrations": {
"intro": "Collega servizi esterni con le tue credenziali. Le chiavi API e i segreti sono cifrati a riposo tramite l'archivio sicuro del tuo sistema operativo e non lasciano mai questa macchina.",
@@ -132,6 +134,30 @@
"closeTitle": "Chiudi questa sessione di agente (interrompe la sua scansione e libera risorse)"
},
"stats": {
"finishingTitle": "Le pagine sono state scaricate; la scansione sta eseguendo le passate di analisi. Ogni passo compare qui mentre è in corso.",
"phase": {
"externalLinks": "Controllo dei link esterni",
"sitemapOrphans": "Orfane della sitemap",
"login": "Accesso in corso",
"optimize": "Ottimizzazione del database",
"inlinks": "Conteggio dei link in entrata",
"linkScore": "Punteggi dei link",
"redirectChains": "Catene di redirect",
"hreflang": "Analisi hreflang",
"duplicates": "Raggruppamento dei duplicati",
"boilerplate": "Rilevamento del contenuto ripetuto",
"pagination": "Sequenze di paginazione",
"imageProbes": "Misurazione delle immagini",
"tlsProbes": "Certificati TLS",
"manifestProbes": "Manifest web",
"socialImages": "Immagini social",
"mobileParity": "Parità mobile",
"pdfProbes": "File PDF",
"soft404": "Sonda soft 404",
"performanceBudget": "Budget di performance",
"issues": "Calcolo dei problemi",
"checkpoint": "Scrittura del database"
},
"discovered": "Individuati",
"crawled": "Scansionati",
"pending": "In attesa",
@@ -6,7 +6,9 @@
"siteSoft404": "없는 페이지가 {{status}} 반환 (soft 404)",
"siteSoft404Hint": "존재하지 않는 페이지를 요청했을 때 404 대신 {{status}} 응답을 받았습니다. 따라서 이 사이트의 깨진 링크가 검색 엔진에는 정상 페이지처럼 보입니다.",
"siteBotBlocked": "봇 보호가 크롤의 일부를 차단했습니다",
"siteBotBlockedHint": "사이트의 봇 보호가 이 페이지 대신 확인 페이지로 응답했습니다. 이 페이지는 크롤되지 않았으며 깨진 링크 및 사이트맵 검사에서 제외됩니다. 크롤 속도를 낮추거나 크롤러의 User-Agent를 허용 목록에 추가한 뒤 다시 크롤하세요."
"siteBotBlockedHint": "사이트의 봇 보호가 이 페이지 대신 확인 페이지로 응답했습니다. 이 페이지는 크롤되지 않았으며 깨진 링크 및 사이트맵 검사에서 제외됩니다. 크롤 속도를 낮추거나 크롤러의 User-Agent를 허용 목록에 추가한 뒤 다시 크롤하세요.",
"checkOff": "꺼짐",
"cookiesOffHint": "크롤링의 쿠키 정책이 \"모두 거부\"이므로 쿠키 검사가 꺼져 있습니다. Set-Cookie 헤더를 분석하려면 설정 → 쿠키에서 \"모두 허용\" 또는 \"서드파티 차단\"을 선택하세요."
},
"integrations": {
"intro": "자신의 자격 증명으로 외부 서비스를 연결하십시오. API 키와 비밀 값은 운영체제의 보안 저장소를 통해 암호화되어 저장되며 이 컴퓨터를 벗어나지 않습니다.",
@@ -128,6 +130,30 @@
"closeTitle": "이 에이전트 세션을 닫습니다(해당 크롤을 중지하고 자원을 해제합니다)"
},
"stats": {
"finishingTitle": "페이지 가져오기가 끝났고 크롤링이 분석 단계를 실행 중입니다. 각 단계는 실행되는 동안 여기에 표시됩니다.",
"phase": {
"externalLinks": "외부 링크 확인 중",
"sitemapOrphans": "사이트맵 고아 페이지",
"login": "로그인 중",
"optimize": "데이터베이스 최적화 중",
"inlinks": "인링크 집계 중",
"linkScore": "링크 점수",
"redirectChains": "리디렉션 체인",
"hreflang": "Hreflang 분석",
"duplicates": "중복 클러스터링 중",
"boilerplate": "보일러플레이트 감지 중",
"pagination": "페이지네이션 순서",
"imageProbes": "이미지 측정 중",
"tlsProbes": "TLS 인증서",
"manifestProbes": "웹 매니페스트",
"socialImages": "소셜 이미지",
"mobileParity": "모바일 동등성",
"pdfProbes": "PDF 파일",
"soft404": "Soft 404 탐지",
"performanceBudget": "성능 예산",
"issues": "문제 계산 중",
"checkpoint": "데이터베이스 기록 중"
},
"discovered": "발견됨",
"crawled": "크롤됨",
"pending": "대기 중",
@@ -6,7 +6,9 @@
"siteSoft404": "Páginas inexistentes retornam {{status}} (soft 404)",
"siteSoft404Hint": "Uma solicitação para uma página que não existe recebeu {{status}} em vez de 404, então os links quebrados deste site parecem páginas válidas para os buscadores.",
"siteBotBlocked": "A proteção contra bots bloqueou parte do rastreamento",
"siteBotBlockedHint": "A proteção contra bots do site respondeu com um desafio no lugar destas páginas. Elas não foram rastreadas e ficam fora das verificações de links quebrados e de sitemap. Reduza a velocidade de rastreamento ou libere o user agent do rastreador e rastreie-as novamente."
"siteBotBlockedHint": "A proteção contra bots do site respondeu com um desafio no lugar destas páginas. Elas não foram rastreadas e ficam fora das verificações de links quebrados e de sitemap. Reduza a velocidade de rastreamento ou libere o user agent do rastreador e rastreie-as novamente.",
"checkOff": "Desativado",
"cookiesOffHint": "As verificações de cookies estão desativadas porque a política de cookies do rastreamento é \"Rejeitar tudo\". Escolha \"Aceitar tudo\" ou \"Bloquear terceiros\" em Configurações → Cookies para analisar os cabeçalhos Set-Cookie."
},
"integrations": {
"intro": "Conecte serviços externos com suas próprias credenciais. As chaves de API e os segredos são criptografados em repouso pelo armazenamento seguro do seu sistema operacional e nunca saem desta máquina.",
@@ -132,6 +134,30 @@
"closeTitle": "Fechar esta sessão de agente (interrompe o rastreamento dela e libera recursos)"
},
"stats": {
"finishingTitle": "As páginas foram buscadas; o rastreamento agora executa suas passagens de análise. Cada etapa aparece aqui enquanto roda.",
"phase": {
"externalLinks": "Verificando links externos",
"sitemapOrphans": "Órfãs do sitemap",
"login": "Fazendo login",
"optimize": "Otimizando o banco de dados",
"inlinks": "Contando links de entrada",
"linkScore": "Pontuações de links",
"redirectChains": "Cadeias de redirecionamento",
"hreflang": "Análise de hreflang",
"duplicates": "Agrupando duplicados",
"boilerplate": "Detectando conteúdo repetido",
"pagination": "Sequências de paginação",
"imageProbes": "Medindo imagens",
"tlsProbes": "Certificados TLS",
"manifestProbes": "Manifestos web",
"socialImages": "Imagens sociais",
"mobileParity": "Paridade móvel",
"pdfProbes": "Arquivos PDF",
"soft404": "Sondagem de soft 404",
"performanceBudget": "Orçamento de desempenho",
"issues": "Calculando problemas",
"checkpoint": "Gravando o banco de dados"
},
"discovered": "Descobertas",
"crawled": "Rastreadas",
"pending": "Pendentes",
@@ -6,7 +6,9 @@
"siteSoft404": "Несуществующие страницы возвращают {{status}} (soft 404)",
"siteSoft404Hint": "Запрос к несуществующей странице получил ответ {{status}} вместо 404, поэтому битые ссылки на этом сайте выглядят для поисковых систем как рабочие страницы.",
"siteBotBlocked": "Защита от ботов заблокировала часть обхода",
"siteBotBlockedHint": "Защита сайта от ботов ответила проверкой вместо этих страниц. Они не обойдены и исключены из проверок битых ссылок и sitemap. Снизьте скорость обхода или добавьте User-Agent краулера в список разрешённых, затем обойдите их снова."
"siteBotBlockedHint": "Защита сайта от ботов ответила проверкой вместо этих страниц. Они не обойдены и исключены из проверок битых ссылок и sitemap. Снизьте скорость обхода или добавьте User-Agent краулера в список разрешённых, затем обойдите их снова.",
"checkOff": "Выкл.",
"cookiesOffHint": "Проверки cookie отключены, потому что политика cookie при сканировании — «Отклонять все». Выберите «Принимать все» или «Блокировать сторонние» в Настройки → Cookie, чтобы анализировать заголовки Set-Cookie."
},
"integrations": {
"intro": "Подключайте внешние сервисы со своими собственными учётными данными. API-ключи и секреты шифруются на диске средствами защищённого хранилища вашей операционной системы и никогда не покидают эту машину.",
@@ -134,6 +136,30 @@
"closeTitle": "Закрыть эту сессию агента (останавливает её обход и освобождает ресурсы)"
},
"stats": {
"finishingTitle": "Страницы загружены; сканирование выполняет проходы анализа. Каждый шаг показывается здесь по мере выполнения.",
"phase": {
"externalLinks": "Проверка внешних ссылок",
"sitemapOrphans": "Сироты sitemap",
"login": "Вход в систему",
"optimize": "Оптимизация базы данных",
"inlinks": "Подсчёт входящих ссылок",
"linkScore": "Оценки ссылок",
"redirectChains": "Цепочки редиректов",
"hreflang": "Анализ hreflang",
"duplicates": "Кластеризация дубликатов",
"boilerplate": "Поиск шаблонного контента",
"pagination": "Последовательности пагинации",
"imageProbes": "Измерение изображений",
"tlsProbes": "Сертификаты TLS",
"manifestProbes": "Веб-манифесты",
"socialImages": "Социальные изображения",
"mobileParity": "Мобильный паритет",
"pdfProbes": "Файлы PDF",
"soft404": "Проверка soft 404",
"performanceBudget": "Бюджет производительности",
"issues": "Расчёт проблем",
"checkpoint": "Запись базы данных"
},
"discovered": "Найдено",
"crawled": "Обойдено",
"pending": "В очереди",
@@ -6,7 +6,9 @@
"siteSoft404": "Olmayan sayfalar {{status}} döndürüyor (soft 404)",
"siteSoft404Hint": "Var olmayan bir sayfa için yapılan istek 404 yerine {{status}} ile yanıtlandı; bu yüzden sitedeki kırık bağlantılar arama motorlarına çalışan sayfalar gibi görünüyor.",
"siteBotBlocked": "Bot koruması taramanın bir kısmını engelledi",
"siteBotBlockedHint": "Sitenin bot koruması bu sayfalar yerine bir doğrulama sayfasıyla yanıt verdi. Bu sayfalar taranmadı; kırık bağlantı ve sitemap kontrollerinin dışında tutuldu. Tarama hızını düşürün ya da tarayıcının user agent’ını izin listesine ekleyin, sonra yeniden tarayın."
"siteBotBlockedHint": "Sitenin bot koruması bu sayfalar yerine bir doğrulama sayfasıyla yanıt verdi. Bu sayfalar taranmadı; kırık bağlantı ve sitemap kontrollerinin dışında tutuldu. Tarama hızını düşürün ya da tarayıcının user agent’ını izin listesine ekleyin, sonra yeniden tarayın.",
"checkOff": "Kapalı",
"cookiesOffHint": "Taramanın çerez politikası \"Tümünü reddet\" olduğu için çerez kontrolleri kapalı. Set-Cookie başlıklarını incelemek için Ayarlar → Çerezler altında \"Tümünü kabul et\" veya \"Üçüncü tarafları engelle\" seçin."
},
"integrations": {
"intro": "Harici servisleri kendi kimlik bilgilerinle bağla. API anahtarları ve secret'lar işletim sisteminin güvenli depolaması ile şifreli saklanır ve bu makineden asla çıkmaz.",
@@ -130,6 +132,30 @@
"closeTitle": "Bu ajan oturumunu kapat (crawl'ını durdurur ve kaynakları serbest bırakır)"
},
"stats": {
"finishingTitle": "Sayfalar çekildi; tarama şimdi analiz geçişlerini çalıştırıyor. Her adım çalışırken burada listelenir.",
"phase": {
"externalLinks": "Dış bağlantılar kontrol ediliyor",
"sitemapOrphans": "Sitemap öksüzleri",
"login": "Oturum açılıyor",
"optimize": "Veritabanı optimize ediliyor",
"inlinks": "Gelen bağlantılar sayılıyor",
"linkScore": "Bağlantı puanları",
"redirectChains": "Yönlendirme zincirleri",
"hreflang": "Hreflang analizi",
"duplicates": "Kopyalar kümeleniyor",
"boilerplate": "Şablon içerik tespit ediliyor",
"pagination": "Sayfalama dizileri",
"imageProbes": "Görseller ölçülüyor",
"tlsProbes": "TLS sertifikaları",
"manifestProbes": "Web manifestleri",
"socialImages": "Sosyal görseller",
"mobileParity": "Mobil eşitlik",
"pdfProbes": "PDF dosyaları",
"soft404": "Soft 404 sondası",
"performanceBudget": "Performans bütçesi",
"issues": "Sorunlar hesaplanıyor",
"checkpoint": "Veritabanı yazılıyor"
},
"discovered": "Keşfedilen",
"crawled": "Taranan",
"pending": "Bekleyen",
@@ -6,7 +6,9 @@
"siteSoft404": "不存在的页面返回 {{status}}(soft 404)",
"siteSoft404Hint": "请求一个不存在的页面时得到的是 {{status}} 而不是 404,因此该站点上的失效链接在搜索引擎看来就像正常页面。",
"siteBotBlocked": "机器人防护拦截了部分抓取",
"siteBotBlockedHint": "站点的机器人防护以验证页面代替了这些页面的响应。它们未被抓取,也不计入失效链接和站点地图检查。请降低抓取速度或将抓取器的 User-Agent 加入白名单,然后重新抓取。"
"siteBotBlockedHint": "站点的机器人防护以验证页面代替了这些页面的响应。它们未被抓取,也不计入失效链接和站点地图检查。请降低抓取速度或将抓取器的 User-Agent 加入白名单,然后重新抓取。",
"checkOff": "已关闭",
"cookiesOffHint": "由于抓取的 Cookie 策略为“全部拒绝”,Cookie 检查已关闭。请在 设置 → Cookie 中选择“全部接受”或“拦截第三方”以分析 Set-Cookie 标头。"
},
"integrations": {
"intro": "使用你自己的凭据连接外部服务。API 密钥和机密会通过操作系统的安全存储在本地加密保存,绝不会离开这台机器。",
@@ -128,6 +130,30 @@
"closeTitle": "关闭此代理会话(停止其抓取并释放资源)"
},
"stats": {
"finishingTitle": "页面已抓取完成;抓取正在运行分析步骤。每个步骤运行时都会显示在这里。",
"phase": {
"externalLinks": "正在检查外部链接",
"sitemapOrphans": "站点地图孤立页",
"login": "正在登录",
"optimize": "正在优化数据库",
"inlinks": "正在统计入链",
"linkScore": "链接评分",
"redirectChains": "重定向链",
"hreflang": "Hreflang 分析",
"duplicates": "正在聚类重复页",
"boilerplate": "正在检测模板内容",
"pagination": "分页序列",
"imageProbes": "正在测量图片",
"tlsProbes": "TLS 证书",
"manifestProbes": "Web 清单",
"socialImages": "社交图片",
"mobileParity": "移动端一致性",
"pdfProbes": "PDF 文件",
"soft404": "Soft 404 探测",
"performanceBudget": "性能预算",
"issues": "正在计算问题",
"checkpoint": "正在写入数据库"
},
"discovered": "已发现",
"crawled": "已抓取",
"pending": "待处理",
+48 -8
View File
@@ -142,6 +142,7 @@ const EXT_TO_KIND: Record<string, ContentKind> = {
woff2: 'font',
ttf: 'font',
otf: 'font',
eot: 'font',
};
// Statuses a HEAD request often reports that differ from a real GET: many
@@ -255,13 +256,18 @@ export function detectBotProtection(
status: number,
headers: { get(name: string): string | null },
): string | null {
// AWS WAF's JavaScript challenge answers 202 with `x-amzn-waf-action:
// challenge` and an interstitial body — a success code for a page that
// was never served — so it is checked before the 4xx gate.
const aws = headers.get('x-amzn-waf-action');
if (aws && (status >= 400 || status === 202)) {
return `AWS WAF ${aws.trim().toLowerCase()} (HTTP ${status})`;
}
if (status < 400) return null;
const cf = headers.get('cf-mitigated');
if (cf) return `Cloudflare ${cf.trim().toLowerCase()} (HTTP ${status})`;
const vercel = headers.get('x-vercel-mitigated');
if (vercel) return `Vercel ${vercel.trim().toLowerCase()} (HTTP ${status})`;
const aws = headers.get('x-amzn-waf-action');
if (aws) return `AWS WAF ${aws.trim().toLowerCase()} (HTTP ${status})`;
if (headers.get('x-sucuri-block')) return `Sucuri block (HTTP ${status})`;
if (status === 403 && headers.get('x-datadome')) return `DataDome block (HTTP ${status})`;
if (status === 429) return `Rate limited (HTTP ${status})`;
@@ -794,7 +800,15 @@ export class Crawler extends EventEmitter {
super();
this.respiderSeeds = opts.respiderSeeds ?? null;
this.resumeOnly = opts.resumeOnly === true;
this.setOp = opts.setOp ?? ((): void => undefined);
// The watchdog's operation label doubles as the progress phase shown
// in the status bar; a new op resets the per-phase counters.
const hostSetOp = opts.setOp ?? ((): void => undefined);
this.setOp = (op: string): void => {
this.phase = op;
this.phaseDone = undefined;
this.phaseTotal = undefined;
hostSetOp(op);
};
this.parsePage =
opts.parseHtml ??
((html, pageUrl, parseOpts) => Promise.resolve(parseHtml(html, pageUrl, parseOpts)));
@@ -1773,6 +1787,11 @@ export class Crawler extends EventEmitter {
private async probeTick(state: ProbeProgress): Promise<void> {
state.started++;
this.phaseDone = state.started;
this.phaseTotal = state.total;
// Throttled like every other progress emit, so the status bar's
// "Measuring images 3,470 / 20,000" ticks without flooding IPC.
this.emitProgress();
const now = Date.now();
if (now - state.lastEmitAt >= Crawler.PROBE_PROGRESS_INTERVAL_MS) {
state.lastEmitAt = now;
@@ -1803,7 +1822,7 @@ export class Crawler extends EventEmitter {
if (this.stopped) return;
let unprobed: { id: number; src: string }[] = [];
try {
unprobed = this.db.unprobedImages(20_000);
unprobed = this.db.unprobedImages(20_000, this.config.crawlImageVariants);
} catch (err) {
this.emit(
'info',
@@ -3085,7 +3104,7 @@ export class Crawler extends EventEmitter {
/* ignore */
}
const rank = (r: Awaited<ReturnType<typeof undiciFetch>>): number =>
r.status < 400 ? 0 : detectBotProtection(r.status, r.headers) ? 1 : 2;
detectBotProtection(r.status, r.headers) ? 1 : r.status < 400 ? 0 : 2;
let got = await attempt('GET', cfgUa);
if (
got.res &&
@@ -3608,7 +3627,11 @@ export class Crawler extends EventEmitter {
url: item.url,
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
urlTrap: this.trapKind(item.url),
contentKind: 'html',
// Never fetched, so the extension is all there is to go on. A
// flat 'html' put blocked scripts, styles and images in the HTML
// / Non-Indexable counts (40 of 58 robots-blocked URLs on one
// site were assets).
contentKind: detectContentKind(item.url, null),
statusCode: null,
statusText: null,
indexability: 'non-indexable:robots-blocked',
@@ -3689,6 +3712,9 @@ export class Crawler extends EventEmitter {
private enqueueResources(parsed: ParsedPage, depth: number): void {
if (this.config.checkImages) {
for (const img of [...parsed.images, ...parsed.socialImages]) {
// srcset / <picture> sizes are stored either way (Images tab) but
// fetched only on request — see `crawlImageVariants`.
if (img.variant && !this.config.crawlImageVariants) continue;
if (img.isInternal) this.enqueue({ url: img.src, depth, resourceRow: 'image' });
// External images: status/size are filled by the post-crawl image
// probe (images table), which now covers external images too — so
@@ -4095,7 +4121,9 @@ export class Crawler extends EventEmitter {
url: item.url,
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
urlTrap: this.trapKind(item.url),
contentKind: 'html',
// The challenge page is HTML whatever the URL was; classify by
// the URL, as the robots-blocked path does.
contentKind: detectContentKind(item.url, null),
statusCode: null,
statusText: botBlock,
indexability: 'unknown:bot-blocked',
@@ -4905,6 +4933,9 @@ export class Crawler extends EventEmitter {
this.config.storeIframes ||
this.config.crawlInvalidLinks,
storeImages: this.config.storeImages,
// Lets a script / stylesheet / font row list the pages that embed
// it under Inlinks.
resources: parsed.resources,
fromDepth: item.depth,
});
// V2 Faz 1 — Persist render artefacts inline so they finish before
@@ -5147,7 +5178,9 @@ export class Crawler extends EventEmitter {
url: item.url,
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
urlTrap: this.trapKind(item.url),
contentKind: 'html',
// Nothing came back, so the kind comes from the URL: a failed
// `.webp` stays an image instead of appearing among HTML pages.
contentKind: detectContentKind(item.url, null),
statusCode: null,
statusText: detail,
indexability: 'non-indexable:client-error',
@@ -5725,6 +5758,10 @@ export class Crawler extends EventEmitter {
private lastProgressEmitTs = 0;
/** See `CrawlProgress.finishing`. */
private finishing = false;
/** Current operation label (see `setOp`) and its item counters. */
private phase = 'idle';
private phaseDone: number | undefined;
private phaseTotal: number | undefined;
private progressTrailingTimer: ReturnType<typeof setTimeout> | null = null;
/** Minimum gap between two progress events. 200 ms = 5 Hz, which is
* dense enough that the user reads the URL/s + Crawled counters as
@@ -5795,6 +5832,9 @@ export class Crawler extends EventEmitter {
running: this.running,
paused: this.paused,
finishing: this.finishing,
phase: this.phase,
phaseDone: this.phaseDone,
phaseTotal: this.phaseTotal,
startUrl: this.config.startUrl,
};
this.emit('progress', progress);
+104 -23
View File
@@ -500,6 +500,36 @@ const JS_SCRIPT_TYPES = new Set([
/** A Cloudflare-obfuscated `mailto:` (see the link loop in `parseHtml`). */
const CF_EMAIL_PROTECTION_RE = /^https?:\/\/[^/]+\/cdn-cgi\/l\/email-protection(?:[?#]|$)/i;
/**
* Lazy-load plugins (LiteSpeed, WP Rocket, a3, Smush, Next.js blur
* placeholders…) ship a `data:` placeholder — or no `src` at all — and the
* real address in one of these. Read in this order, first non-empty wins.
*/
const LAZY_SRC_ATTRS = ['data-src', 'data-lazy-src', 'data-original', 'data-lazy', 'data-url'] as const;
const LAZY_SRCSET_ATTRS = ['data-srcset', 'data-lazy-srcset'] as const;
/**
* Endpoints that only ever serve a tracking pixel. Such an `<img>` is a
* beacon, not a content image: it has no alt to be missing and is on every
* page of a site with the tag installed, so counting it put Missing Alt on
* all 7,491 pages of one crawl (Meta Pixel's `<noscript>` fallback).
*/
const TRACKING_PIXEL_RE =
/^https?:\/\/(?:www\.)?(?:facebook\.com\/tr[/?]|px\.ads\.linkedin\.com\/|dc\.ads\.linkedin\.com\/|bat\.bing\.com\/action\/|(?:www\.)?google-analytics\.com\/(?:collect|__utm\.gif)|(?:www\.)?googleadservices\.com\/pagead\/conversion\/|googleads\.g\.doubleclick\.net\/pagead\/viewthroughconversion\/|ct\.pinterest\.com\/|analytics\.twitter\.com\/|t\.co\/i\/adsct|tr\.snapchat\.com\/|alb\.reddit\.com\/|mc\.yandex\.ru\/watch\/|(?:ib|secure)\.adnxs\.com\/|insight\.adsrvr\.org\/track\/|(?:sslwidget|widget|dynamic)\.criteo\.com\/|trc\.taboola\.com\/|q\.quora\.com\/_\/ad\/|tags\.tiqcdn\.com\/|cdn\.evgnet\.com\/|px\.owneriq\.net\/|p\.adsymptotic\.com\/|secure\.gaug\.es\/)/i;
/** Attributes that make an `<img>` a hidden 1×1 beacon rather than a picture. */
function isTrackingPixel(
src: string,
attr: (name: string) => string | undefined,
insideNoscript: boolean,
): boolean {
if (TRACKING_PIXEL_RE.test(src)) return true;
const w = (attr('width') ?? '').trim();
const h = (attr('height') ?? '').trim();
if ((w === '1' || w === '0') && (h === '1' || h === '0')) return true;
return insideNoscript && /display\s*:\s*none/i.test(attr('style') ?? '');
}
/** An `onclick` body that navigates somewhere rather than acting in place. */
const JS_NAVIGATION_RE =
/(?:^|[^\w$.])(?:window\.|document\.|top\.|self\.)?location(?:\.href)?\s*=(?!=)|location\.(?:assign|replace)\s*\(|window\.open\s*\(/;
@@ -797,10 +827,15 @@ export function parseHtml(
// every page of a site with titled SVG icons (found live: 77 pages).
const titleEls = $('title').filter((_, el) => $(el).closest('svg').length === 0);
const titleCount = titleEls.length;
const title = decodeEntities(titleEls.first().text().trim()) || null;
// Whitespace inside the title, description and heading is collapsed the
// way a browser (and a search result) renders it: a line break or a
// double space in the markup is not a character the reader sees, so it
// must not count toward the length limits or make "H1 = Title" miss.
const collapseWs = (s: string): string => s.replace(/\s+/g, ' ').trim();
const title = decodeEntities(collapseWs(titleEls.first().text())) || null;
const metaDescription =
decodeEntities(($('meta[name="description"]').attr('content') ?? '').trim()) || null;
const h1 = decodeEntities($('h1').first().text().trim()) || null;
decodeEntities(collapseWs($('meta[name="description"]').attr('content') ?? '')) || null;
const h1 = decodeEntities(collapseWs($('h1').first().text())) || null;
const h1Count = $('h1').length;
const h2Count = $('h2').length;
const h3Count = $('h3').length;
@@ -1623,12 +1658,21 @@ export function parseHtml(
// below: the link graph keeps one record per target, so an icon-only,
// generic or `target=_blank` link to a page the nav already links to
// never reached the `links` table and the page went unflagged.
// Empty anchor: no usable text inside the <a> AND no alt on a nested
// image. Common accessibility/SEO failure on icon-only or image-only
// links. Only count internal links — externals aren't our problem.
if (isInternal && !anchor && !altText) emptyAnchorCount++;
if (anchor && anchor.length > 100) anchorTooLongCount++;
if (anchor && GENERIC_ANCHORS.has(anchor.trim().toLowerCase())) genericAnchorCount++;
// Empty anchor: no usable text inside the <a>, no alt on a nested image
// and no accessible name from `aria-label` / `aria-labelledby` / `title`
// (WCAG 2.4.4 — an icon link that carries one is named; flagging it put
// every page of a site with an aria-labelled cart icon in the list).
// Common accessibility/SEO failure on icon-only or image-only links.
// Only count internal links — externals aren't our problem.
const hasAriaName =
($el.attr('aria-label') ?? '').trim() !== '' ||
($el.attr('aria-labelledby') ?? '').trim() !== '' ||
($el.attr('title') ?? '').trim() !== '';
if (isInternal && !anchor && !altText && !hasAriaName) emptyAnchorCount++;
const anchorTooLong = !!anchor && [...anchor].length > 100;
const anchorGeneric = !!anchor && GENERIC_ANCHORS.has(anchor.trim().toLowerCase());
if (anchorTooLong) anchorTooLongCount++;
if (anchorGeneric) genericAnchorCount++;
if (
target?.toLowerCase() === '_blank' &&
!rel?.includes('noopener') &&
@@ -1653,6 +1697,17 @@ export function parseHtml(
// text than an icon-only nav link, so take it when we had none.
if (!existing.anchor && anchor) existing.anchor = anchor;
}
// The page is flagged above for a too-long / generic anchor even when
// the same target is also linked with a fine one (a product card's
// "Read more" next to its title link). Keep the offending text on the
// one record this target gets, otherwise Outlinks shows nothing that
// explains the flag and the user cannot find the link to fix.
if ((anchorTooLong || anchorGeneric) && anchor) {
const ex = existing.anchor;
const exFlagged =
!!ex && ([...ex].length > 100 || GENERIC_ANCHORS.has(ex.trim().toLowerCase()));
if (!exFlagged) existing.anchor = anchor;
}
return;
}
@@ -1873,16 +1928,35 @@ export function parseHtml(
let imageSlotCount = 0;
let imagesMissingAlt = 0;
$('img[src]').each((_, el) => {
$('img').each((_, el) => {
const $el = $(el);
const rawSrc = $el.attr('src');
if (!rawSrc) return;
// The address the browser ends up loading: `src` unless it is an inline
// `data:` placeholder (or absent), then the lazy-load attribute that
// holds the real one. Pages whose every image was lazy-loaded this way
// used to show 0 images (found live: ~2,070 pages of one site), with
// the Images sub-tab empty and no alt / size check reaching them.
let rawSrc = ($el.attr('src') ?? '').trim();
let viaLazyAttr = false;
if (!rawSrc || rawSrc.startsWith('data:')) {
rawSrc = '';
for (const name of LAZY_SRC_ATTRS) {
const v = ($el.attr(name) ?? '').trim();
if (v && !v.startsWith('data:')) {
rawSrc = v;
viaLazyAttr = true;
break;
}
}
}
// Skip inline data URIs — they're not "web resources" in the crawler
// sense and would bloat the images table fast on any CMS.
if (rawSrc.startsWith('data:')) return;
if (!rawSrc) return;
const normalized = normalizeUrl(rawSrc, baseUrl, opts.urlRewrites);
if (!normalized) return;
if (!/^https?:/.test(normalized)) return;
if (isTrackingPixel(normalized, (n) => $el.attr(n), $el.closest('noscript').length > 0)) {
return;
}
// `null` = alt missing entirely (accessibility issue); `''` = decorative.
const alt = imgAlt($el);
@@ -1896,18 +1970,25 @@ export function parseHtml(
const isInternal = isSameHost(pageUrl, normalized, opts);
// Lazy-loading adoption — `loading="lazy"` is the native browser
// attribute and the only signal that's reliable without rendering JS.
if (($el.attr('loading') ?? '').trim().toLowerCase() === 'lazy') {
// attribute; an image whose real address sits in a lazy-load data
// attribute is deferred by script, which is lazy loading as well.
if (($el.attr('loading') ?? '').trim().toLowerCase() === 'lazy' || viaLazyAttr) {
imagesLazy++;
}
// Responsive imagery — an `<img>` is "responsive" if it carries
// `srcset` itself OR is wrapped in `<picture>` (the parent supplies
// the variants via `<source>` siblings). We only count the primary
// `<img>` here so a single page contributes 1 per slot, not 1 per
// variant URL — the variant URLs themselves go into `imageMap` so
// the broken-image / large-image probes still see them.
const srcsetAttr = ($el.attr('srcset') ?? '').trim();
// `srcset` itself (or its lazy-load twin) OR is wrapped in `<picture>`
// (the parent supplies the variants via `<source>` siblings). We only
// count the primary `<img>` here so a single page contributes 1 per
// slot, not 1 per variant URL — the variant URLs themselves go into
// `imageMap` so the broken-image / large-image probes still see them.
let srcsetAttr = ($el.attr('srcset') ?? '').trim();
if (!srcsetAttr) {
for (const name of LAZY_SRCSET_ATTRS) {
srcsetAttr = ($el.attr(name) ?? '').trim();
if (srcsetAttr) break;
}
}
const inPicture = $el.parent('picture').length > 0;
if (srcsetAttr || inPicture) imagesResponsive++;
@@ -1934,8 +2015,8 @@ export function parseHtml(
// instead of the inner `<img src>`. Includes format-fallback cases
// (avif → webp → jpg) where the primary `<img>` references only the
// last fallback.
$('picture > source[srcset]').each((_, el) => {
const srcsetAttr = ($(el).attr('srcset') ?? '').trim();
$('picture > source[srcset], picture > source[data-srcset]').each((_, el) => {
const srcsetAttr = ($(el).attr('srcset') ?? $(el).attr('data-srcset') ?? '').trim();
if (!srcsetAttr) return;
// A `<source>` is a format/breakpoint variant of the picture's own
// `<img>`, which carries the alt for every variant — the source
+7 -1
View File
@@ -339,7 +339,13 @@ export function formatFetchError(err: unknown): string {
if (/ENOTFOUND|EAI_AGAIN|ENODATA|ESERVFAIL|EREFUSED|ENOTIMP|ENONAME/.test(chain)) {
return `${chain} (DNS lookup failed across all 3 layers (OS, public DNS, DoH). Most likely the host genuinely doesn't exist, or your machine has no internet at all. Check the spelling and your connection.)`;
}
if (/UNABLE_TO_GET_ISSUER_CERT_LOCALLY|SELF_SIGNED_CERT_IN_CHAIN|CERT_HAS_EXPIRED|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/.test(chain)) {
// An expired certificate is the site's problem, not this machine's: a
// browser shows the same warning. Pointing the user at a proxy or
// antivirus here sent them looking in the wrong place.
if (/CERT_HAS_EXPIRED/.test(chain)) {
return `${chain} (the site's TLS certificate has expired — browsers show a security warning for it too; nothing to change on this machine, the site owner must renew it)`;
}
if (/UNABLE_TO_GET_ISSUER_CERT_LOCALLY|SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/.test(chain)) {
return `${chain} (TLS certificate rejected — likely corporate proxy or antivirus HTTPS inspection; set NODE_EXTRA_CA_CERTS to your CA bundle)`;
}
if (/UND_ERR_HEADERS_TIMEOUT/.test(chain)) {
+37
View File
@@ -2437,6 +2437,43 @@ const MIGRATIONS: Migration[] = [
}
},
},
{
version: 99,
name: 'add_resource_usages',
// Which page references which script / stylesheet / font / media URL.
// Mirrors images + image_usages: one row per distinct resource URL, one
// usage row per (page, resource). Gives a resource row its Inlinks —
// the pages that embed it — which the hyperlink-only `links` table
// never held, so the tab stayed empty for every JS / CSS / font URL.
up: `
CREATE TABLE IF NOT EXISTS resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS resource_usages (
from_url_id INTEGER NOT NULL REFERENCES urls(id) ON DELETE CASCADE,
resource_id INTEGER NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
PRIMARY KEY (from_url_id, resource_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_usages_resource ON resource_usages(resource_id);
`,
},
{
version: 100,
name: 'add_body_dictionary',
// The zstd dictionary stored page bodies are compressed with (see
// `BodyCodec` in project-db.ts). Written once from the first pages of
// the first crawl and never replaced — rows reference its bytes, so
// `reset()` deliberately leaves it alone.
up: `
CREATE TABLE IF NOT EXISTS body_dictionary (
id INTEGER PRIMARY KEY CHECK (id = 1),
dict BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
},
];
export function runMigrations(db: DatabaseSync): void {
+360 -47
View File
@@ -1,4 +1,5 @@
import { DatabaseSync, type StatementSync } from 'node:sqlite';
import * as zlib from 'node:zlib';
import type {
MobileParityDiff,
AdvancedFilter,
@@ -8,6 +9,7 @@ import type {
CrawlUrlRow,
DiscoveredImage,
DiscoveredLink,
DiscoveredResource,
DuplicateClusterRow,
FilterClause,
FilterField,
@@ -932,6 +934,178 @@ function truncateUtf8(input: string, cap: number): string {
return buf.subarray(0, end).toString('utf8');
}
/**
* Page bodies (`url_sources.body` / `rendered_body`) are stored compressed.
* The working database of one 7,500-page crawl was 4.6 GB with 4.3 GB of
* it raw HTML — about 60 GB at the 100k-URL target.
*
* Codec: zstd level 3 with a per-project *dictionary*. Pages of one site
* share most of their markup (header, nav, footer, product cards), and a
* per-row compressor cannot see that from inside a single page: plain
* gzip / zstd got 4–6× on two real crawls. Seeding zstd with the first
* ~1 MB of HTML the crawl stores lets every later page reference that
* shared markup, which measured 30× on a template-heavy shop and 8.7× on a
* blog, at ~2.5 ms per page. The dictionary is written once, into
* `body_dictionary`, and never changes: every reader (the main process,
* the reader workers, the CLI) decodes with the same bytes, and a row
* compressed before the dictionary existed is a plain frame that decodes
* with or without it.
*
* The per-row approach matters for the saved `.seoproject` too: the
* archive brotli-compresses the SQLite file as a whole, and already
* compressed rows do not shrink again. With gzip rows a 45 MB project
* became 577 MB on disk; the dictionary recovers most of that.
*
* Rows written before compression landed are plain TEXT and rows from
* the gzip interim are gzip BLOBs; `unpack` reads all three by signature,
* so no migration touches existing data. `body_length` keeps the
* uncompressed size. On a Node.js without zstd (before 22.15) bodies fall
* back to gzip, and the CLI reports a clear error if it meets a zstd row.
*/
const ZSTD_LEVEL = 3;
/** Dictionary size cap — matches zstd's level-3 window, so nothing past it would be reachable anyway. */
const BODY_DICT_CAP = 1_048_576;
/** Stop seeding after this many pages even when the cap is not reached (small-page sites). */
const BODY_DICT_SEED_PAGES = 16;
type ZstdOpts = zlib.ZstdOptions & { dictionary?: Buffer };
/**
* zstd entry points, or null when this Node.js predates them. `dictionaries`
* is probed at load: a runtime that accepts the option but ignores it would
* write plain frames (harmless) — and must not be *told* it used one.
*/
const zstd = ((): {
compress: (buf: Buffer, opts?: ZstdOpts) => Buffer;
decompress: (buf: Uint8Array, opts?: ZstdOpts) => Buffer;
dictionaries: boolean;
} | null => {
const z = zlib as Partial<typeof zlib>;
if (typeof z.zstdCompressSync !== 'function' || typeof z.zstdDecompressSync !== 'function') {
return null;
}
const compress = z.zstdCompressSync;
const decompress = z.zstdDecompressSync;
// Incompressible pseudo-random bytes: with the dictionary honoured the
// frame is a few bytes of back-reference, without it ~4 KB.
const probe = Buffer.alloc(4096);
let x = 0x2545f491;
for (let i = 0; i < probe.length; i++) {
x = (Math.imul(x, 1103515245) + 12345) >>> 0;
probe[i] = x >>> 24;
}
let dictionaries = false;
try {
const withDict = compress(probe, { dictionary: probe });
dictionaries =
withDict.length < probe.length / 4 &&
decompress(withDict, { dictionary: probe }).equals(probe);
} catch {
dictionaries = false;
}
return { compress, decompress, dictionaries };
})();
function isZstdFrame(b: Uint8Array): boolean {
return b.length >= 4 && b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd;
}
/** Compresses and decompresses stored page bodies; one per connection. */
class BodyCodec {
/** undefined = not looked up yet; null = the project has no dictionary (yet). */
private dict: Buffer | null | undefined = undefined;
private seed: Buffer[] = [];
private seedBytes = 0;
constructor(private readonly db: DatabaseSync) {}
pack(body: string): Uint8Array {
const buf = Buffer.from(body, 'utf8');
if (!zstd) return zlib.gzipSync(buf, { level: 4 });
const opts: ZstdOpts = { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } };
const dict = this.dictForWrite(buf);
if (dict) opts.dictionary = dict;
return zstd.compress(buf, opts);
}
/** Inverse of `pack`; also accepts gzip rows of the interim build and the plain TEXT of older projects. */
unpack(stored: unknown): string | null {
if (stored === null || stored === undefined) return null;
if (typeof stored === 'string') return stored;
if (!(stored instanceof Uint8Array)) return String(stored);
if (stored.length === 0) return '';
if (stored[0] === 0x1f && stored[1] === 0x8b) return zlib.gunzipSync(stored).toString('utf8');
if (isZstdFrame(stored)) {
if (!zstd) {
throw new Error(
'This project stores page HTML with zstd, which needs Node.js 22.15 or newer to read.',
);
}
// A null cache is re-checked: the writer may have created the
// dictionary after this connection first looked.
const dict = this.dict ?? this.load();
try {
return zstd.decompress(stored, dict ? { dictionary: dict } : undefined).toString('utf8');
} catch (err) {
throw new Error(
`Could not decompress a stored page body: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
return Buffer.from(stored.buffer, stored.byteOffset, stored.byteLength).toString('utf8');
}
private load(): Buffer | null {
try {
const row = this.db.prepare('SELECT dict FROM body_dictionary WHERE id = 1').get() as
| { dict: Uint8Array }
| undefined;
this.dict = row ? Buffer.from(row.dict) : null;
} catch {
// Read-only connection to a project that predates the table.
this.dict = null;
}
return this.dict;
}
/**
* The project's dictionary, creating it from the first pages stored when
* there is none yet. Two writers can race here (main process + db-writer
* worker); `INSERT OR IGNORE` plus a reload means both end up using the
* row that won.
*/
private dictForWrite(buf: Buffer): Buffer | null {
if (!zstd?.dictionaries) return null;
const current = this.dict === undefined ? this.load() : this.dict;
if (current) return current;
const room = BODY_DICT_CAP - this.seedBytes;
if (room > 0) {
const part = buf.subarray(0, room);
this.seed.push(Buffer.from(part));
this.seedBytes += part.length;
}
if (this.seedBytes < BODY_DICT_CAP && this.seed.length < BODY_DICT_SEED_PAGES) return null;
const dict = Buffer.concat(this.seed);
this.seed = [];
try {
this.db.prepare('INSERT OR IGNORE INTO body_dictionary (id, dict) VALUES (1, ?)').run(dict);
} catch {
return null;
}
return this.load();
}
}
/**
* Length in characters (code points), not UTF-16 units: `'🚀'.length` is 2,
* and the title / meta description limits are about what a person reads.
* One crawl had 12 titles wrongly "Over 60" and 163 descriptions wrongly
* "Over 160" because every emoji counted twice.
*/
function charLength(text: string): number {
return Array.from(text).length;
}
/**
* SQL predicate (against the `images` table) matching images whose
* non-empty alt text is shared by ≥2 distinct images. The `images` table
@@ -947,11 +1121,15 @@ const DUPLICATE_ALT_PREDICATE = `is_variant = 0 AND alt IS NOT NULL AND alt != '
GROUP BY alt HAVING COUNT(*) > 1
)`;
// Per-usage duplicate-alt: a usage whose alt text is shared by >1 usage.
// Operates on `image_usages.alt` to match the per-usage Images view.
// Per-usage duplicate-alt: a usage whose alt text is also carried by a
// DIFFERENT image somewhere on the site. Operates on `image_usages.alt` to
// match the per-usage Images view. `COUNT(DISTINCT image_id)`, not
// `COUNT(*)`: the same logo on 1,263 pages is one image with one alt, not
// 1,263 duplicates — counting usages listed every page's header image and
// made the sidebar (2 images) and the grid (1,263 rows) disagree.
const USAGE_DUPLICATE_ALT_PREDICATE = `iu.is_variant = 0 AND iu.alt IS NOT NULL AND iu.alt != '' AND iu.alt IN (
SELECT alt FROM image_usages WHERE is_variant = 0 AND alt IS NOT NULL AND alt != ''
GROUP BY alt HAVING COUNT(*) > 1
GROUP BY alt HAVING COUNT(DISTINCT image_id) > 1
)`;
// Missing / empty alt are computed from PER-USAGE truth (`image_usages.alt`):
@@ -1088,6 +1266,7 @@ export class ProjectDb {
private disabledIssuesRaw: string | null = null;
private disabledIssuesSet: ReadonlySet<UrlCategory> | null = null;
private readonly db: DatabaseSync;
private readonly bodies: BodyCodec;
/** Read-only connections can't write `sqlite_stat1`; see `optimize()`. */
private readonly readOnly: boolean;
private readonly stmtUpsertUrl: StatementSync;
@@ -1126,6 +1305,7 @@ export class ProjectDb {
this.db = readOnly
? new DatabaseSync(filePath, { readOnly: true })
: new DatabaseSync(filePath);
this.bodies = new BodyCodec(this.db);
if (!readOnly) {
this.db.exec('PRAGMA journal_mode = WAL');
this.db.exec('PRAGMA synchronous = NORMAL');
@@ -1239,6 +1419,8 @@ export class ProjectDb {
this.db.exec(
`DELETE FROM image_usages;
DELETE FROM images;
DELETE FROM resource_usages;
DELETE FROM resources;
DELETE FROM links;
DELETE FROM headers;
DELETE FROM url_sources;
@@ -1369,6 +1551,9 @@ export class ProjectDb {
this.db
.prepare(`DELETE FROM image_usages WHERE from_url_id IN (${placeholders})`)
.run(...slice);
this.db
.prepare(`DELETE FROM resource_usages WHERE from_url_id IN (${placeholders})`)
.run(...slice);
this.db
.prepare(`DELETE FROM urls_issues WHERE url_id IN (${placeholders})`)
.run(...slice);
@@ -1696,6 +1881,12 @@ export class ProjectDb {
storeLinks?: boolean;
/** Same contract for `image_usages` (Spider → Crawl → Store → Images). */
storeImages?: boolean;
/**
* Scripts / stylesheets / fonts / media the page references. Recorded
* in `resource_usages` under the `storeLinks` gate so a resource row's
* Inlinks tab can list the pages that embed it.
*/
resources?: DiscoveredResource[];
fromDepth: number;
}): { urlId: number } {
return this.runInTransaction(() => {
@@ -1731,6 +1922,10 @@ export class ProjectDb {
if (payload.links.length > 0) {
this.insertLinks(urlId, payload.links, payload.fromDepth);
}
if (payload.resources) {
this.db.prepare('DELETE FROM resource_usages WHERE from_url_id = ?').run(urlId);
if (payload.resources.length > 0) this.insertResources(urlId, payload.resources);
}
}
if (payload.storeImages ?? true) {
// `images.occurrences` is a running tally of how many pages use each
@@ -2177,11 +2372,12 @@ export class ProjectDb {
indexability: input.indexability ?? 'indexable',
indexability_reason: input.indexabilityReason ?? null,
title: input.title ?? null,
title_length: input.title?.length ?? null,
title_length: input.title != null ? charLength(input.title) : null,
meta_description: input.metaDescription ?? null,
meta_description_length: input.metaDescription?.length ?? null,
meta_description_length:
input.metaDescription != null ? charLength(input.metaDescription) : null,
h1: input.h1 ?? null,
h1_length: input.h1?.length ?? null,
h1_length: input.h1 != null ? charLength(input.h1) : null,
h1_count: input.h1Count ?? 0,
h2_count: input.h2Count ?? 0,
h3_count: input.h3Count ?? 0,
@@ -3448,8 +3644,10 @@ export class ProjectDb {
// versus the ~2 GB the pre-batching `.all(sampleSize)` reached.
const BATCH = 64;
const BODY_CHAR_CAP = 1_048_576;
// Bodies are compressed BLOBs (see `BodyCodec`), so the character cap is
// applied after decompression rather than with SQL SUBSTR.
const pickBatch = this.db.prepare(
`SELECT u.id AS id, SUBSTR(us.body, 1, ${BODY_CHAR_CAP}) AS body
`SELECT u.id AS id, us.body AS body
FROM urls u
JOIN url_sources us ON us.url_id = u.id
WHERE u.id > ?
@@ -3465,11 +3663,12 @@ export class ProjectDb {
while (remaining > 0) {
const batch = pickBatch.all(lastId, Math.min(BATCH, remaining)) as {
id: number;
body: string;
body: string | Uint8Array;
}[];
if (batch.length === 0) break;
for (const row of batch) {
const shingles = shingleHashes(stripHtml(row.body));
const body = (this.bodies.unpack(row.body) ?? '').slice(0, BODY_CHAR_CAP);
const shingles = shingleHashes(stripHtml(body));
pageShingles.set(row.id, shingles);
for (const h of shingles) {
shingleCounts.set(h, (shingleCounts.get(h) ?? 0) + 1);
@@ -4397,6 +4596,31 @@ export class ProjectDb {
}
}
/**
* Record which scripts / stylesheets / fonts / media a page references.
* One `resources` row per distinct URL, one `resource_usages` row per
* (page, resource) — the shape `images` / `image_usages` use. Runs inside
* the caller's `writeFetchedUrl` transaction.
*/
insertResources(fromUrlId: number, resources: DiscoveredResource[]): void {
if (resources.length === 0) return;
const upsertResource = this.db.prepare(
`INSERT INTO resources (url, kind) VALUES (?, ?)
ON CONFLICT(url) DO UPDATE SET kind = excluded.kind
RETURNING id`,
);
const insertUsage = this.db.prepare(
`INSERT OR IGNORE INTO resource_usages (from_url_id, resource_id) VALUES (?, ?)`,
);
const seen = new Set<string>();
for (const r of resources) {
if (seen.has(r.url)) continue;
seen.add(r.url);
const row = upsertResource.get(r.url, r.kind) as { id: number };
insertUsage.run(fromUrlId, row.id);
}
}
/**
* Flag already-crawled URLs as part of a crawl trap the crawler only
* recognised after fetching them (a calendar's pages look ordinary one
@@ -4776,9 +5000,9 @@ export class ProjectDb {
LEFT JOIN url_sources s ON s.url_id = u.id
WHERE u.url = ?`,
)
.get(url) as { lang: string | null; body: string | null } | undefined;
.get(url) as { lang: string | null; body: string | Uint8Array | null } | undefined;
if (!row) return null;
return { body: row.body ?? null, lang: row.lang ?? null };
return { body: this.bodies.unpack(row.body), lang: row.lang ?? null };
}
/**
@@ -5939,7 +6163,7 @@ export class ProjectDb {
title: string | null;
meta_description: string | null;
h1: string | null;
body: string | null;
body: string | Uint8Array | null;
}
| undefined;
if (!row) return null;
@@ -5948,7 +6172,7 @@ export class ProjectDb {
title: row.title,
metaDescription: row.meta_description,
h1: row.h1,
body: row.body,
body: this.bodies.unpack(row.body),
};
}
@@ -6444,6 +6668,11 @@ export class ProjectDb {
// Probe-only rows (share-card images, `occurrences = 0`) aren't on any page.
const totalImages = this.scalarCount('SELECT COUNT(*) AS c FROM images WHERE occurrences > 0');
const botBlocked = countCategory('status:bot-blocked');
// The banner is about the site being crawled. An external page behind
// a bot wall (52 fandom.com links on one crawl) is that site's business
// and must not read as "your crawl is being blocked"; the Response
// Codes row above keeps externals, like its siblings.
const botBlockedInternal = countWhere("is_external = 0 AND indexability = 'unknown:bot-blocked'");
return {
summary: {
@@ -6495,18 +6724,22 @@ export class ProjectDb {
issues: this.silenceDisabledIssues(this.getIssuesCounts()),
site: {
soft404Probe: this.readSoft404Probe(),
botBlocked: botBlocked > 0 ? { count: botBlocked, sample: this.botBlockedSample() } : null,
botBlocked:
botBlockedInternal > 0
? { count: botBlockedInternal, sample: this.botBlockedSample() }
: null,
},
};
}
/** What answered for one bot-blocked URL — e.g. "Cloudflare challenge (HTTP 403)". */
/** What answered for one bot-blocked internal URL — e.g. "Cloudflare challenge (HTTP 403)". */
private botBlockedSample(): string | null {
if (this.overviewCollect) return null;
const row = this.db
.prepare(
`SELECT status_text AS s FROM urls
WHERE indexability = 'unknown:bot-blocked' AND status_text IS NOT NULL LIMIT 1`,
WHERE is_external = 0 AND indexability = 'unknown:bot-blocked'
AND status_text IS NOT NULL LIMIT 1`,
)
.get() as { s: string } | undefined;
return row?.s ?? null;
@@ -6736,12 +6969,15 @@ export class ProjectDb {
THEN (SELECT COUNT(*) FROM image_usages WHERE alt IS NULL AND is_variant = 0)
ELSE (SELECT COUNT(*) FROM images WHERE alt IS NULL AND occurrences > 0) END) AS c`,
),
// Distinct images sharing a non-empty alt with ≥1 other image —
// non-descriptive / templated alt reuse. Counted image-level (the
// `images` table is deduped by src) so it lines up with the Images
// tab drill-down and with Missing/Empty Alt.
// Usages whose non-empty alt is shared with a different image —
// non-descriptive / templated alt reuse. Per-usage, with the very
// predicate the Images tab filters by, so the sidebar count equals
// the rows the click lists (as Missing / Empty Alt already do).
// Legacy projects without usage rows fall back to distinct images.
imageDuplicateAlt: this.scalarCount(
`SELECT COUNT(*) AS c FROM images WHERE ${DUPLICATE_ALT_PREDICATE}`,
`SELECT (CASE WHEN EXISTS(SELECT 1 FROM image_usages)
THEN (SELECT COUNT(*) FROM image_usages iu WHERE ${USAGE_DUPLICATE_ALT_PREDICATE})
ELSE (SELECT COUNT(*) FROM images WHERE ${DUPLICATE_ALT_PREDICATE}) END) AS c`,
),
metaRefreshUsed: countWhere(
`${html} AND meta_refresh IS NOT NULL AND meta_refresh != ''`,
@@ -7236,9 +7472,45 @@ export class ProjectDb {
}
getInlinks(url: string, limit: number): { rows: InlinkRow[]; total: number } {
const total = (
// Hyperlinks come from `links`. An image's or a script / stylesheet /
// font's inlinks are the pages that embed it, which live in
// `image_usages` and `resource_usages` — the tab was empty for every
// such row while this read `links` alone.
const hyperTotal = (
this.db.prepare('SELECT COUNT(*) AS c FROM links WHERE to_url = ?').get(url) as { c: number }
).c;
const imageTotal = (
this.db
.prepare(
`SELECT COUNT(*) AS c FROM image_usages iu
JOIN images i ON i.id = iu.image_id WHERE i.src = ?`,
)
.get(url) as { c: number }
).c;
const resourceTotal = (
this.db
.prepare(
`SELECT COUNT(*) AS c FROM resource_usages ru
JOIN resources r ON r.id = ru.resource_id WHERE r.url = ?`,
)
.get(url) as { c: number }
).c;
const total = hyperTotal + imageTotal + resourceTotal;
type Row = {
from_url: string;
from_status_code: number | null;
to_status_code: number | null;
to_size: number | null;
type: string | null;
anchor: string | null;
alt_text: string | null;
rel: string | null;
target: string | null;
path_type: string | null;
link_path: string | null;
link_position: string | null;
link_origin: string | null;
};
const rows = this.db
.prepare(
`SELECT
@@ -7254,21 +7526,51 @@ export class ProjectDb {
WHERE l.to_url = ?
LIMIT ?`,
)
.all(url, limit) as unknown as {
from_url: string;
from_status_code: number | null;
to_status_code: number | null;
to_size: number | null;
type: string | null;
anchor: string | null;
alt_text: string | null;
rel: string | null;
target: string | null;
path_type: string | null;
link_path: string | null;
link_position: string | null;
link_origin: string | null;
}[];
.all(url, limit) as unknown as Row[];
if (rows.length < limit && imageTotal > 0) {
rows.push(
...(this.db
.prepare(
`SELECT
f.url AS from_url,
f.status_code AS from_status_code,
t.status_code AS to_status_code,
t.content_length AS to_size,
'image' AS type, NULL AS anchor, iu.alt AS alt_text, NULL AS rel, NULL AS target,
NULL AS path_type, NULL AS link_path, NULL AS link_position, 'html' AS link_origin
FROM image_usages iu
JOIN images i ON i.id = iu.image_id
JOIN urls f ON f.id = iu.from_url_id
LEFT JOIN urls t ON t.url = i.src
WHERE i.src = ?
LIMIT ?`,
)
.all(url, limit - rows.length) as unknown as Row[]),
);
}
if (rows.length < limit && resourceTotal > 0) {
rows.push(
...(this.db
.prepare(
`SELECT
f.url AS from_url,
f.status_code AS from_status_code,
t.status_code AS to_status_code,
t.content_length AS to_size,
CASE r.kind WHEN 'css' THEN 'stylesheet' WHEN 'js' THEN 'script'
WHEN 'image' THEN 'image' ELSE 'other' END AS type,
NULL AS anchor, NULL AS alt_text, NULL AS rel, NULL AS target,
NULL AS path_type, NULL AS link_path, NULL AS link_position, 'html' AS link_origin
FROM resource_usages ru
JOIN resources r ON r.id = ru.resource_id
JOIN urls f ON f.id = ru.from_url_id
LEFT JOIN urls t ON t.url = r.url
WHERE r.url = ?
LIMIT ?`,
)
.all(url, limit - rows.length) as unknown as Row[]),
);
}
return {
total,
rows: rows.map((r) => ({
@@ -7450,7 +7752,7 @@ export class ProjectDb {
truncated = excluded.truncated,
captured_at = CURRENT_TIMESTAMP`,
)
.run(urlId, stored, fullLength, truncated);
.run(urlId, this.bodies.pack(stored), fullLength, truncated);
}
/**
@@ -7481,11 +7783,11 @@ export class ProjectDb {
)
.get(urlId) as
| {
body: string;
body: string | Uint8Array;
body_length: number;
truncated: number;
captured_at: string;
rendered_body: string | null;
rendered_body: string | Uint8Array | null;
rendered_body_length: number | null;
render_ms: number | null;
screenshot_fullpage_path: string | null;
@@ -7495,11 +7797,11 @@ export class ProjectDb {
| undefined;
if (!row) return null;
return {
body: row.body,
body: this.bodies.unpack(row.body) ?? '',
bodyLength: row.body_length,
truncated: row.truncated === 1,
capturedAt: row.captured_at,
renderedBody: row.rendered_body,
renderedBody: this.bodies.unpack(row.rendered_body),
renderedBodyLength: row.rendered_body_length,
renderMs: row.render_ms,
screenshotFullpagePath: row.screenshot_fullpage_path,
@@ -7703,7 +8005,7 @@ export class ProjectDb {
render_ms = excluded.render_ms,
captured_at = CURRENT_TIMESTAMP`,
)
.run(urlId, stored, fullLength, renderMs);
.run(urlId, this.bodies.pack(stored), fullLength, renderMs);
}
*iterateAllUrls(): IterableIterator<CrawlUrlRow> {
@@ -9151,11 +9453,16 @@ export class ProjectDb {
);
let lastId = 0;
for (;;) {
const rows = stmt.all(lastId) as unknown as { id: number; url: string; body: string }[];
const rows = stmt.all(lastId) as unknown as {
id: number;
url: string;
body: string | Uint8Array;
}[];
if (rows.length === 0) return;
for (const r of rows) {
lastId = r.id;
if (r.body) yield { url: r.url, body: r.body };
const body = this.bodies.unpack(r.body);
if (body) yield { url: r.url, body };
}
if (rows.length < 200) return;
}
@@ -9352,16 +9659,22 @@ export class ProjectDb {
* issue fires on dead external/CDN images too; the "Large Image" issue
* stays internal-only via its own `is_internal = 1` join filter.
*/
unprobedImages(limit = 20_000): { id: number; src: string }[] {
/**
* Images still waiting for the post-crawl size probe. srcset / `<picture>`
* sizes are left out unless `includeVariants` — they are stored for the
* Images tab but, like the crawl itself, only requested on demand.
*/
unprobedImages(limit = 20_000, includeVariants = false): { id: number; src: string }[] {
return this.db
.prepare(
`SELECT id, src FROM images
WHERE probe_status IS NULL
AND src LIKE 'http%'
AND (? OR is_variant = 0)
ORDER BY id
LIMIT ?`,
)
.all(limit) as { id: number; src: string }[];
.all(includeVariants ? 1 : 0, limit) as { id: number; src: string }[];
}
/**
+20
View File
@@ -1321,6 +1321,15 @@ export interface CrawlConfig {
* tab). Independent of `checkImages`, which decides whether the image
* bytes are actually requested. */
storeImages: boolean;
/**
* Also fetch the extra sizes an `<img>` lists in `srcset` / `<picture>`
* (the 300×169, 768×432… copies WordPress and most CDNs generate). Off
* by default: only the slot's own `src` is requested and the variants
* are still stored in the Images tab. Reading lazy-loaded `data-srcset`
* took one blog from 2,300 to 41,600 image requests, which quadrupled
* the crawl for sizes that rarely differ in health from the original.
*/
crawlImageVariants: boolean;
/** Fetch internal `<video>` / `<audio>` / their `<source>` targets so
* they land in the Internal tab with status + size. Off by default —
* media files are large and rarely the point of an SEO crawl. */
@@ -2248,6 +2257,16 @@ export interface CrawlProgress {
* silent Stop button read as a frozen app.
*/
finishing?: boolean;
/**
* What the crawler is doing right now, as the operation name the
* freeze watchdog also sees (`post-crawl:image-probes`, `crawl:login`,
* `idle`…). The status bar turns it into a readable step so a crawl
* that shows Pending 0 for twenty minutes says *why*.
*/
phase?: string;
/** Items done / planned in the current phase, when it counts (probes). */
phaseDone?: number;
phaseTotal?: number;
startUrl: string;
}
@@ -2562,6 +2581,7 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
// Spider → Crawl matrix. Each value reproduces the behaviour that was
// hardcoded before the matrix existed, so the defaults are a no-op.
storeImages: true,
crawlImageVariants: false,
crawlMedia: false,
storeMedia: false,
storeCss: true,