feat: sitemap crawl mode + mobile device toggle + duplicate-alt check

This commit is contained in:
kemalai committed 2026-07-20 17:46:09 +03:00
1 parent 65e434fed4
commit 663e977f4b
28 files changed
+782 -121

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
+29 -6
View File
@@ -170,6 +170,9 @@ import {
import {
BrowserPool,
PlaywrightBrowserMissingError,
MOBILE_USER_AGENT,
MOBILE_VIEWPORT,
MOBILE_DEVICE_SCALE_FACTOR,
Crawler,
renderUrl,
auditMobileUsability,
@@ -4735,17 +4738,25 @@ function registerIpc(): void {
if (config.jsRender.blockResources.media) blocked.add('media');
if (config.jsRender.blockResources.stylesheet) blocked.add('stylesheet');
if (config.jsRender.blockResources.script) blocked.add('script');
// Mobile device mode overrides the JS-render context so responsive
// layouts render as the real mobile view (UA + mobile viewport + touch
// + DPR), matching the mobile User-Agent the HTTP paths already use.
const mobile = config.deviceMode === 'mobile';
const pool = new BrowserPool({
maxPages:
config.jsRender.maxPages > 0
? config.jsRender.maxPages
: Math.max(1, Math.min(8, config.maxConcurrency)),
headless: config.jsRender.headless,
viewport: {
width: config.jsRender.viewportWidth,
height: config.jsRender.viewportHeight,
},
userAgent: config.userAgent || undefined,
viewport: mobile
? { width: MOBILE_VIEWPORT.width, height: MOBILE_VIEWPORT.height }
: {
width: config.jsRender.viewportWidth,
height: config.jsRender.viewportHeight,
},
isMobile: mobile || undefined,
deviceScaleFactor: mobile ? MOBILE_DEVICE_SCALE_FACTOR : undefined,
userAgent: (mobile ? MOBILE_USER_AGENT : config.userAgent) || undefined,
acceptLanguage: config.acceptLanguage || undefined,
channel: config.jsRender.browserChannel || undefined,
blockResourceTypes: blocked.size > 0 ? blocked : undefined,
@@ -5333,10 +5344,18 @@ function registerIpc(): void {
},
'export-images': async (input) => {
const i = input as { filePath?: string; missingAltOnly?: boolean; search?: string };
const i = input as {
filePath?: string;
missingAltOnly?: boolean;
emptyAltOnly?: boolean;
duplicateAltOnly?: boolean;
search?: string;
};
if (!i.filePath) throw new Error('filePath is required.');
const { rowsWritten } = await exportImagesToCsv(getDb(), i.filePath, {
missingAltOnly: i.missingAltOnly,
emptyAltOnly: i.emptyAltOnly,
duplicateAltOnly: i.duplicateAltOnly,
search: i.search,
});
return { filePath: i.filePath, rowsWritten };
@@ -6447,6 +6466,8 @@ function registerIpc(): void {
offset: input.offset,
search: input.search,
missingAltOnly: input.missingAltOnly,
emptyAltOnly: input.emptyAltOnly,
duplicateAltOnly: input.duplicateAltOnly,
internalOnly: input.internalOnly,
},
];
@@ -6817,6 +6838,8 @@ function registerIpc(): void {
}
const { rowsWritten } = await exportImagesToCsv(getDb(), filePath, {
missingAltOnly: input.missingAltOnly,
emptyAltOnly: input.emptyAltOnly,
duplicateAltOnly: input.duplicateAltOnly,
search: input.search,
});
return { filePath, rowsWritten };
@@ -11,6 +11,10 @@ interface Node {
label: string;
category?: UrlCategory;
count?: number;
/** Denominator for this node's "% of Total". Defaults to the tree-wide
* total (internal URLs); image-level nodes override it with the total
* image count so an image ÷ page ratio never shows a bogus %. */
percentBase?: number;
children?: Node[];
}
@@ -231,7 +235,10 @@ const TreeNode = memo(function TreeNode({
{node.count.toLocaleString()}
</span>
<span className="w-14 text-right font-mono tabular-nums text-surface-500">
{total > 0 ? ((node.count / total) * 100).toFixed(2) + '%' : '—'}
{(() => {
const base = node.percentBase ?? total;
return base > 0 ? ((node.count / base) * 100).toFixed(2) + '%' : '—';
})()}
</span>
</>
)}
@@ -1378,12 +1385,21 @@ function buildTree(o: OverviewCounts | null): Node[] {
label: 'Missing Alt',
count: o.issues.imageMissingAlt,
category: 'issues:image-missing-alt',
percentBase: o.summary.totalImages,
},
{
key: 'issues-images-empty-alt',
label: 'Empty Alt',
count: o.issues.imageEmptyAlt,
category: 'issues:image-empty-alt',
percentBase: o.summary.totalImages,
},
{
key: 'issues-images-duplicate-alt',
label: 'Duplicate Alt Text',
count: o.issues.imageDuplicateAlt,
category: 'issues:image-duplicate-alt',
percentBase: o.summary.totalImages,
},
{
key: 'issues-images-no-lazy-loading',
@@ -74,6 +74,7 @@ interface FormState {
// mode
mode: CrawlMode;
urlListText: string;
seedSitemapText: string;
// crawler
maxDepth: string;
maxUrls: string;
@@ -403,6 +404,7 @@ function configToForm(c: CrawlConfig): FormState {
return {
mode: c.mode,
urlListText: (c.urlList ?? []).join('\n'),
seedSitemapText: (c.seedSitemapUrls ?? []).join('\n'),
maxDepth: String(c.maxDepth),
maxUrls: String(c.maxUrls),
maxConcurrency: String(c.maxConcurrency),
@@ -617,6 +619,7 @@ export function SettingsDialog({ open, onClose }: Props) {
setConfig({
mode: form.mode,
urlList: parseLines(form.urlListText),
seedSitemapUrls: parseLines(form.seedSitemapText),
maxDepth: Math.max(0, num(form.maxDepth, config.maxDepth)),
maxUrls: Math.max(1, num(form.maxUrls, config.maxUrls)),
maxConcurrency: Math.max(1, Math.min(200, num(form.maxConcurrency, config.maxConcurrency))),
@@ -1214,13 +1217,13 @@ function ModePanel({ form, update }: PanelProps) {
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
{t('settingsPanels.mode.intro', { defaultValue: 'Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set.' })}
{t('settingsPanels.mode.intro', { defaultValue: 'Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set; Sitemap crawls the URLs listed in a sitemap.' })}
</p>
<label className="mb-2 flex flex-col gap-1">
<FieldLabel
label={t('settingsPanels.mode.crawlMode', { defaultValue: 'Crawl Mode' })}
info="Spider follows links from the start URL across the chosen scope. List fetches a fixed set of URLs once with no link-following."
example="Spider for full site audits; List for re-checking a known set of pages."
info="Spider follows links from the start URL across the chosen scope. List fetches a fixed set of URLs once with no link-following. Sitemap fetches a sitemap URL and crawls every page it lists (no link-following)."
example="Spider for full site audits; List for re-checking a known set of pages; Sitemap to audit exactly what's published in sitemap.xml."
/>
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
@@ -1233,6 +1236,9 @@ function ModePanel({ form, update }: PanelProps) {
<option value="list">
{t('settingsPanels.mode.listOption', { defaultValue: 'List — fetch a fixed URL list, no link follow' })}
</option>
<option value="sitemap">
{t('settingsPanels.mode.sitemapOption', { defaultValue: 'Sitemap — crawl the URLs listed in a sitemap' })}
</option>
</select>
</label>
{form.mode === 'list' && (
@@ -1246,6 +1252,25 @@ function ModePanel({ form, update }: PanelProps) {
example={'https://example.com/about\nhttps://example.com/pricing\n# old urls\nhttps://example.com/legacy'}
/>
)}
{form.mode === 'sitemap' && (
<p className="rounded border border-blue-700/40 bg-blue-900/15 px-3 py-2 text-[11px] text-blue-200">
{t('settingsPanels.mode.sitemapHint', {
defaultValue:
'Enter the sitemap (or sitemap-index) URL in the top bar, then press Start. Every page listed is crawled once and also recorded for the orphan / sitemap reports.',
})}
</p>
)}
{form.mode === 'spider' && (
<Area
label={t('settingsPanels.mode.seedSitemap', { defaultValue: 'Seed from sitemap URL(s) — optional' })}
value={form.seedSitemapText}
onChange={(v) => update('seedSitemapText', v)}
rows={4}
placeholder={'https://example.com/sitemap.xml'}
info="One sitemap URL per line. On top of following links from the start URL, the crawler fetches these sitemaps and queues every page they list as an extra seed — faster/more complete discovery, and reliable orphan detection even when the sitemap lives at a non-standard path. Leave empty to disable."
example={'https://example.com/sitemap_index.xml\nhttps://example.com/news-sitemap.xml'}
/>
)}
</>
);
}
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Play, Square, Pause, Eraser, ChevronDown, Settings, History, Plus } from 'lucide-react';
import { Play, Square, Pause, Eraser, ChevronDown, Settings, History, Plus, ListChecks, Monitor, Smartphone } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { CrawlScope } from '@freecrawl/shared-types';
import type { CrawlScope, CrawlMode } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { clearCrawlWithConfirm } from '../utils/clearCrawl.js';
@@ -14,6 +14,14 @@ export function TopBar() {
{ value: 'all-subdomains', label: t('topbar.scope.allSubdomains'), hint: t('topbar.scope.allSubdomainsHint') },
{ value: 'exact-url', label: t('topbar.scope.exactUrl'), hint: t('topbar.scope.exactUrlHint') },
];
// Crawl mode sits to the LEFT of the URL bar: it determines what the URL
// field means (start URL / sitemap URL) or, for List, replaces it with a
// URL-list editor. Mirrors Screaming Frog's top-left Mode menu.
const modeOptions: { value: CrawlMode; label: string; hint: string }[] = [
{ value: 'spider', label: t('topbar.mode.spider', { defaultValue: 'Spider' }), hint: t('topbar.mode.spiderHint', { defaultValue: 'Start URL + follow links' }) },
{ value: 'list', label: t('topbar.mode.list', { defaultValue: 'List' }), hint: t('topbar.mode.listHint', { defaultValue: 'Crawl a fixed URL list, no link follow' }) },
{ value: 'sitemap', label: t('topbar.mode.sitemap', { defaultValue: 'Sitemap' }), hint: t('topbar.mode.sitemapHint', { defaultValue: 'Crawl the URLs listed in a sitemap' }) },
];
const config = useAppStore((s) => s.config);
const setConfig = useAppStore((s) => s.setConfig);
// Scalar subscriptions instead of the full `progress` object — TopBar
@@ -40,6 +48,17 @@ export function TopBar() {
const addRecentUrl = useAppStore((s) => s.addRecentUrl);
const [scopeOpen, setScopeOpen] = useState(false);
const [recentOpen, setRecentOpen] = useState(false);
const [modeOpen, setModeOpen] = useState(false);
const [listOpen, setListOpen] = useState(false);
// Raw textarea buffer for the List editor. Kept separate from
// `config.urlList` so a mid-typing newline isn't collapsed on every
// keystroke — we parse into the config but render the raw text.
const [listText, setListText] = useState('');
function openListEditor() {
setListText(config.urlList.join('\n'));
setListOpen(true);
}
// Clear is enabled whenever there's anything to wipe. Four signals
// because each one alone is incomplete (fresh run vs. post-`done`
@@ -51,14 +70,32 @@ export function TopBar() {
overviewInternalTotal > 0 ||
overviewExternalTotal > 0;
const activeScope = scopeOptions.find((o) => o.value === config.scope)!;
const activeMode = modeOptions.find((o) => o.value === config.mode)!;
const urlPlaceholder =
config.mode === 'sitemap'
? t('topbar.sitemapPlaceholder', { defaultValue: 'https://example.com/sitemap.xml' })
: t('topbar.urlPlaceholder');
async function start() {
const trimmed = config.startUrl.trim();
if (!trimmed) {
setError(t('topbar.errorEmptyUrl'));
return;
// List mode has no start URL — it runs off `config.urlList`.
if (config.mode === 'list') {
if (config.urlList.length === 0) {
setError(t('topbar.errorEmptyList', { defaultValue: 'Add at least one URL to the list first.' }));
setListOpen(true);
return;
}
} else {
const trimmed = config.startUrl.trim();
if (!trimmed) {
setError(
config.mode === 'sitemap'
? t('topbar.errorEmptySitemap', { defaultValue: 'Enter a sitemap URL first.' })
: t('topbar.errorEmptyUrl'),
);
return;
}
addRecentUrl(trimmed);
}
addRecentUrl(trimmed);
setRecentOpen(false);
reset();
// Flip the UI to "Running" immediately so the user gets feedback
@@ -118,46 +155,40 @@ export function TopBar() {
FreeCrawl
</div>
<div className="mx-2 h-5 w-px bg-surface-800" />
<div className="relative flex-1">
<input
className="input w-full"
placeholder={t('topbar.urlPlaceholder')}
value={config.startUrl}
onChange={(e) => setConfig({ startUrl: e.target.value })}
onFocus={() => {
if (recentUrls.length > 0) setRecentOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !running) void start();
if (e.key === 'Escape') setRecentOpen(false);
}}
{/* Crawl mode — determines what the URL bar means (Spider/Sitemap) or
swaps it for a URL-list editor (List). */}
<div className="relative">
<button
className="btn btn-ghost whitespace-nowrap border border-surface-700 px-2 py-1.5"
onClick={() => setModeOpen((v) => !v)}
disabled={running}
spellCheck={false}
/>
{recentOpen && recentUrls.length > 0 && (
title={activeMode.hint}
>
{activeMode.label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{modeOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setRecentOpen(false)}
/>
<div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded border border-surface-700 bg-surface-900 shadow-xl">
<div className="flex items-center gap-1.5 border-b border-surface-800 px-3 py-1.5 text-[10px] uppercase tracking-wider text-surface-500">
<History className="h-3 w-3" />
{t('topbar.recentUrls')}
</div>
{recentUrls.map((url) => (
<div className="fixed inset-0 z-10" onClick={() => setModeOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 w-64 rounded border border-surface-700 bg-surface-900 shadow-xl">
{modeOptions.map((opt) => (
<button
key={url}
className="flex w-full items-center px-3 py-1.5 text-left text-[12px] text-surface-200 hover:bg-surface-800"
onMouseDown={(e) => {
// mousedown so input blur doesn't race with click
e.preventDefault();
setConfig({ startUrl: url });
setRecentOpen(false);
key={opt.value}
className={clsx(
'flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-[11px] hover:bg-surface-800',
config.mode === opt.value && 'bg-surface-800',
)}
onClick={() => {
setConfig({ mode: opt.value });
setModeOpen(false);
// Jump straight to the list editor so the user sees where
// the URLs go the moment they pick List.
if (opt.value === 'list') openListEditor();
}}
title={url}
>
<span className="truncate">{url}</span>
<span className="font-medium text-surface-100">{opt.label}</span>
<span className="text-surface-500">{opt.hint}</span>
</button>
))}
</div>
@@ -165,11 +196,160 @@ export function TopBar() {
)}
</div>
{config.mode === 'list' ? (
<div className="relative flex-1">
<button
className="btn btn-ghost w-full justify-start border border-surface-700 px-3 py-1.5 text-left text-surface-300"
onClick={() => (listOpen ? setListOpen(false) : openListEditor())}
disabled={running}
>
<ListChecks className="h-3.5 w-3.5 shrink-0" />
{config.urlList.length > 0
? t('topbar.editList', { defaultValue: 'Edit URL list ({{n}})', n: config.urlList.length })
: t('topbar.addList', { defaultValue: 'Add URL list…' })}
</button>
{listOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setListOpen(false)} />
<div className="absolute left-0 right-0 top-full z-20 mt-1 rounded border border-surface-700 bg-surface-900 p-2 shadow-xl">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-surface-500">
{t('topbar.listTitle', { defaultValue: 'URL list — one per line' })}
</span>
<button
className="text-[11px] text-blue-400 hover:text-blue-300"
onClick={() => setListOpen(false)}
>
{t('topbar.listDone', { defaultValue: 'Done' })}
</button>
</div>
<textarea
className="input h-40 w-full resize-y font-mono text-[11px]"
placeholder={'https://example.com/\nhttps://example.com/about'}
value={listText}
onChange={(e) => {
const raw = e.target.value;
setListText(raw);
setConfig({
urlList: raw
.split('\n')
.map((s) => s.trim())
.filter((l) => l.length > 0 && !l.startsWith('#')),
});
}}
spellCheck={false}
autoFocus
/>
<div className="mt-1 text-[10px] text-surface-500">
{t('topbar.listCount', { defaultValue: '{{n}} URL(s)', n: config.urlList.length })}
</div>
</div>
</>
)}
</div>
) : (
<div className="relative flex-1">
<input
className="input w-full"
placeholder={urlPlaceholder}
value={config.startUrl}
onChange={(e) => setConfig({ startUrl: e.target.value })}
onFocus={() => {
if (recentUrls.length > 0) setRecentOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !running) void start();
if (e.key === 'Escape') setRecentOpen(false);
}}
disabled={running}
spellCheck={false}
/>
{recentOpen && recentUrls.length > 0 && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setRecentOpen(false)}
/>
<div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded border border-surface-700 bg-surface-900 shadow-xl">
<div className="flex items-center gap-1.5 border-b border-surface-800 px-3 py-1.5 text-[10px] uppercase tracking-wider text-surface-500">
<History className="h-3 w-3" />
{t('topbar.recentUrls')}
</div>
{recentUrls.map((url) => (
<button
key={url}
className="flex w-full items-center px-3 py-1.5 text-left text-[12px] text-surface-200 hover:bg-surface-800"
onMouseDown={(e) => {
// mousedown so input blur doesn't race with click
e.preventDefault();
setConfig({ startUrl: url });
setRecentOpen(false);
}}
title={url}
>
<span className="truncate">{url}</span>
</button>
))}
</div>
</>
)}
</div>
)}
{/* Device toggle — Desktop (default) vs Mobile. Mobile swaps the
crawl's User-Agent to a smartphone UA so servers that serve a
different mobile HTML return their mobile version. */}
<div className="flex items-center overflow-hidden rounded border border-surface-700">
<button
type="button"
className={clsx(
'flex items-center px-2 py-1.5',
config.deviceMode !== 'mobile'
? 'bg-surface-700 text-surface-50'
: 'text-surface-400 hover:bg-surface-800 hover:text-surface-200',
)}
onClick={() => setConfig({ deviceMode: 'desktop' })}
disabled={running}
title={t('topbar.device.desktop', { defaultValue: 'Desktop crawl' })}
aria-label={t('topbar.device.desktop', { defaultValue: 'Desktop crawl' })}
aria-pressed={config.deviceMode !== 'mobile'}
>
<Monitor className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={clsx(
'flex items-center border-l border-surface-700 px-2 py-1.5',
config.deviceMode === 'mobile'
? 'bg-surface-700 text-surface-50'
: 'text-surface-400 hover:bg-surface-800 hover:text-surface-200',
)}
onClick={() => setConfig({ deviceMode: 'mobile' })}
disabled={running}
title={t('topbar.device.mobile', {
defaultValue: 'Mobile crawl — crawl the site’s mobile version (mobile User-Agent)',
})}
aria-label={t('topbar.device.mobile', {
defaultValue: 'Mobile crawl — crawl the site’s mobile version (mobile User-Agent)',
})}
aria-pressed={config.deviceMode === 'mobile'}
>
<Smartphone className="h-3.5 w-3.5" />
</button>
</div>
<div className="relative">
<button
className="btn btn-ghost border border-surface-700 px-2 py-1.5"
onClick={() => setScopeOpen((v) => !v)}
disabled={running}
disabled={running || config.mode !== 'spider'}
title={
config.mode !== 'spider'
? t('topbar.scopeSpiderOnly', {
defaultValue: 'Scope applies to Spider mode only',
})
: undefined
}
>
{activeScope.label}
<ChevronDown className="h-3.5 w-3.5" />
@@ -275,6 +275,7 @@ const TR_LABELS: Record<string, string> = {
// Images
'Missing Alt': 'Alt Eksik',
'Empty Alt': 'Boş Alt',
'Duplicate Alt Text': 'Yinelenen Alt Metni',
'Broken Image Src': 'Bozuk Görsel Src',
'Large Image (>100KB)': 'Büyük Görsel (>100KB)',
'Slow-Loading Image (>200KB, no lazy)': 'Yavaş Yüklenen Görsel (>200KB, lazy yok)',
@@ -70,7 +70,28 @@
},
"errorEmptyUrl": "Please enter a starting URL.",
"errorAddUrl": "URL not accepted (invalid format, already crawled at full depth, or queue full).",
"addUrlPrompt": "Add URL to queue:"
"addUrlPrompt": "Add URL to queue:",
"sitemapPlaceholder": "https://example.com/sitemap.xml",
"errorEmptyList": "Add at least one URL to the list first.",
"errorEmptySitemap": "Enter a sitemap URL first.",
"editList": "Edit URL list ({{n}})",
"addList": "Add URL list…",
"listTitle": "URL list — one per line",
"listDone": "Done",
"listCount": "{{n}} URL(s)",
"scopeSpiderOnly": "Scope applies to Spider mode only",
"mode": {
"spider": "Spider",
"spiderHint": "Start URL + follow links",
"list": "List",
"listHint": "Crawl a fixed URL list, no link follow",
"sitemap": "Sitemap",
"sitemapHint": "Crawl the URLs listed in a sitemap"
},
"device": {
"desktop": "Desktop crawl",
"mobile": "Mobile crawl — crawl the site’s mobile version (mobile User-Agent)"
}
},
"stats": {
"discovered": "Discovered",
@@ -382,11 +403,14 @@
"outro": "After clicking Apply or Import, review each panel to verify the values, then press Save at the bottom to persist."
},
"mode": {
"intro": "Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set.",
"intro": "Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set; Sitemap crawls the URLs listed in a sitemap.",
"crawlMode": "Crawl Mode",
"spiderOption": "Spider — start URL + follow links",
"listOption": "List — fetch a fixed URL list, no link follow",
"urlList": "URL List (one URL per line)"
"sitemapOption": "Sitemap — crawl the URLs listed in a sitemap",
"urlList": "URL List (one URL per line)",
"sitemapHint": "Enter the sitemap (or sitemap-index) URL in the top bar, then press Start. Every page listed is crawled once and also recorded for the orphan / sitemap reports.",
"seedSitemap": "Seed from sitemap URL(s) — optional"
},
"crawler": {
"intro": "Traversal limits and crawl-scope toggles. Screaming Frog equivalents shown in parentheses.",
@@ -969,6 +993,12 @@
"imagesTab": {
"searchPlaceholder": "Search image URLs / alt…",
"missingAltOnly": "Missing Alt only",
"emptyAltOnly": "Empty Alt only",
"duplicateAltOnly": "Duplicate Alt only",
"emptyMissingAlt": "No images without alt text.",
"emptyEmptyAlt": "No images with an empty alt=\"\".",
"emptyDuplicateAlt": "No images share the same alt text.",
"emptyDefault": "Crawl a site to discover images.",
"imagesUnit": "images",
"loaded": "{{n}} loaded",
"row": "Row",
@@ -70,7 +70,28 @@
},
"errorEmptyUrl": "Lütfen bir başlangıç URL'i gir.",
"errorAddUrl": "URL kabul edilmedi (geçersiz format, zaten tam derinlikte tarandı ya da kuyruk dolu).",
"addUrlPrompt": "Kuyruğa eklenecek URL:"
"addUrlPrompt": "Kuyruğa eklenecek URL:",
"sitemapPlaceholder": "https://ornek.com/sitemap.xml",
"errorEmptyList": "Önce listeye en az bir URL ekle.",
"errorEmptySitemap": "Önce bir sitemap URL'si gir.",
"editList": "URL listesini düzenle ({{n}})",
"addList": "URL listesi ekle…",
"listTitle": "URL listesi — her satıra bir tane",
"listDone": "Tamam",
"listCount": "{{n}} URL",
"scopeSpiderOnly": "Kapsam yalnızca Spider modunda geçerli",
"mode": {
"spider": "Spider",
"spiderHint": "Başlangıç URL + linkleri takip et",
"list": "List",
"listHint": "Sabit URL listesi tara, link takip etme",
"sitemap": "Sitemap",
"sitemapHint": "Sitemap'te listelenen URL'leri tara"
},
"device": {
"desktop": "Masaüstü tarama",
"mobile": "Mobil tarama — sitenin mobil sürümünü tara (mobil User-Agent)"
}
},
"stats": {
"discovered": "Keşfedilen",
@@ -382,11 +403,14 @@
"outro": "Uygula veya İçe Aktar'a tıkladıktan sonra her paneli değerleri doğrulamak için gözden geçir, sonra altta Kaydet'e bas."
},
"mode": {
"intro": "Crawler'ın URL'leri nasıl keşfedeceğini seç. Spider başlangıç URL'inden linkleri takip eder; List sabit bir setten alır.",
"intro": "Crawler'ın URL'leri nasıl keşfedeceğini seç. Spider başlangıç URL'inden linkleri takip eder; List sabit bir setten alır; Sitemap bir sitemap'te listelenen URL'leri tarar.",
"crawlMode": "Crawl Modu",
"spiderOption": "Spider — başlangıç URL + linkleri takip et",
"listOption": "List — sabit URL listesi getir, link takip etme",
"urlList": "URL Listesi (her satıra bir URL)"
"sitemapOption": "Sitemap — sitemap'te listelenen URL'leri tara",
"urlList": "URL Listesi (her satıra bir URL)",
"sitemapHint": "Üst çubuğa sitemap (ya da sitemap-index) URL'sini gir, sonra Başlat'a bas. Listelenen her sayfa bir kez taranır ve ayrıca orphan / sitemap raporları için kaydedilir.",
"seedSitemap": "Sitemap URL('ler)inden besle — opsiyonel"
},
"crawler": {
"intro": "Gezinme limitleri ve crawl-kapsam toggle'ları. Screaming Frog karşılıkları parantez içinde gösterildi.",
@@ -969,6 +993,12 @@
"imagesTab": {
"searchPlaceholder": "Görsel URL / alt ara…",
"missingAltOnly": "Yalnızca Alt eksik",
"emptyAltOnly": "Yalnızca boş Alt",
"duplicateAltOnly": "Yalnızca yinelenen Alt",
"emptyMissingAlt": "Alt metni olmayan görsel yok.",
"emptyEmptyAlt": "Boş alt=\"\" içeren görsel yok.",
"emptyDuplicateAlt": "Aynı alt metnini paylaşan görsel yok.",
"emptyDefault": "Görselleri keşfetmek için bir site tara.",
"imagesUnit": "görsel",
"loaded": "{{n}} yüklendi",
"row": "Satır",
+17 -4
View File
@@ -381,14 +381,23 @@ function loadRecentUrls(): string[] {
* the Settings dialog merges over the defaults so new fields added in
* later versions still surface (the merge order is `defaults <- saved`).
*
* `startUrl` is intentionally NOT restored — every launch starts with an
* empty URL bar; recent URLs are surfaced via a dropdown instead.
* `startUrl` and `mode` are intentionally NOT restored — every launch
* starts with an empty URL bar in Spider mode (the common default), so a
* one-off List/Sitemap crawl last session doesn't silently carry over.
* Recent URLs are surfaced via a dropdown instead. The saved `urlList` /
* `seedSitemapUrls` are still restored — only the *active mode* resets.
*/
function loadInitialConfig(): CrawlConfig {
if (typeof window === 'undefined' || !window.freecrawl) return DEFAULT_CRAWL_CONFIG;
const saved = window.freecrawl.prefsGet('crawl-config');
if (!saved || typeof saved !== 'object') return { ...DEFAULT_CRAWL_CONFIG, startUrl: '' };
return { ...DEFAULT_CRAWL_CONFIG, ...(saved as Partial<CrawlConfig>), startUrl: '' };
return {
...DEFAULT_CRAWL_CONFIG,
...(saved as Partial<CrawlConfig>),
startUrl: '',
mode: 'spider',
deviceMode: 'desktop',
};
}
/**
@@ -542,7 +551,11 @@ function tabForCategory(cat: UrlCategory): TabKey | null {
if (cat === 'tab:redirects') return 'redirects';
// Issue groupings whose home tab is obvious.
if (cat === 'issues:image-missing-alt' || cat === 'issues:image-empty-alt') {
if (
cat === 'issues:image-missing-alt' ||
cat === 'issues:image-empty-alt' ||
cat === 'issues:image-duplicate-alt'
) {
return 'images';
}
if (
@@ -33,9 +33,12 @@ export function ImagesTab() {
const [exporting, setExporting] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// Missing-alt filter is driven by the Overview sidebar's Issues section —
// clicking "Images Missing Alt" switches category, which we watch here.
// Alt-issue filters are driven by the Overview sidebar's Issues section —
// clicking a node switches category, which we watch here. Each maps to a
// distinct DB filter so the count shown equals the rows listed.
const missingAltOnly = activeCategory === 'issues:image-missing-alt';
const emptyAltOnly = activeCategory === 'issues:image-empty-alt';
const duplicateAltOnly = activeCategory === 'issues:image-duplicate-alt';
useEffect(() => {
let cancelled = false;
@@ -45,6 +48,8 @@ export function ImagesTab() {
offset: 0,
search: search || undefined,
missingAltOnly,
emptyAltOnly,
duplicateAltOnly,
});
if (cancelled) return;
setRows(res.rows);
@@ -57,7 +62,7 @@ export function ImagesTab() {
cancelled = true;
clearInterval(id);
};
}, [search, missingAltOnly, dataVersion, progress?.running]);
}, [search, missingAltOnly, emptyAltOnly, duplicateAltOnly, dataVersion, progress?.running]);
const sorted = useMemo(() => {
const copy = [...rows];
@@ -92,6 +97,8 @@ export function ImagesTab() {
try {
await window.freecrawl.exportImages({
missingAltOnly,
emptyAltOnly,
duplicateAltOnly,
search: search || undefined,
});
} finally {
@@ -172,6 +179,16 @@ export function ImagesTab() {
{t('imagesTab.missingAltOnly', { defaultValue: 'Missing Alt only' })}
</span>
)}
{emptyAltOnly && (
<span className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300">
{t('imagesTab.emptyAltOnly', { defaultValue: 'Empty Alt only' })}
</span>
)}
{duplicateAltOnly && (
<span className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300">
{t('imagesTab.duplicateAltOnly', { defaultValue: 'Duplicate Alt only' })}
</span>
)}
<div className="ml-auto text-[11px] text-surface-500">
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('imagesTab.imagesUnit', { defaultValue: 'images' })}
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: sorted.length.toLocaleString() })})</span>
@@ -360,8 +377,12 @@ export function ImagesTab() {
<div className="mb-1 text-sm font-semibold text-surface-300">{t('imagesTab.noImages', { defaultValue: 'No images' })}</div>
<div className="text-xs text-surface-500">
{missingAltOnly
? 'No images without alt text.'
: 'Crawl a site to discover images.'}
? t('imagesTab.emptyMissingAlt', { defaultValue: 'No images without alt text.' })
: emptyAltOnly
? t('imagesTab.emptyEmptyAlt', { defaultValue: 'No images with an empty alt="".' })
: duplicateAltOnly
? t('imagesTab.emptyDuplicateAlt', { defaultValue: 'No images share the same alt text.' })
: t('imagesTab.emptyDefault', { defaultValue: 'Crawl a site to discover images.' })}
</div>
</div>
</div>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, RefreshCw } from 'lucide-react';
import { InfoTip } from '../components/InfoTip.js';
import type {
GoogleAuthState,
GscFetchMeta,
@@ -426,7 +427,15 @@ export function SearchConsoleTab() {
>
<div className="flex-1 px-2">{t('gscTab.colUrl', { defaultValue: 'URL' })}</div>
<div className="w-[54px] shrink-0 text-center">{t('gscTab.colStatus', { defaultValue: 'Status' })}</div>
<div className="w-[64px] shrink-0 text-center">{t('gscTab.colVerdict', { defaultValue: 'Index' })}</div>
<div className="flex w-[64px] shrink-0 items-center justify-center gap-1">
{t('gscTab.colVerdict', { defaultValue: 'Index' })}
<InfoTip
info={
"Google's index status, pulled from the URL Inspection API — not the Fetch button. Click \"Inspect (top 100)\" to fill this column; Fetch only pulls clicks / impressions / position."
}
example={'PASS = indexed · FAIL = not indexed · PART/NEU = discovered but not yet indexed'}
/>
</div>
<div className="w-[72px] shrink-0 text-right">{t('gscTab.colClicks', { defaultValue: 'Clicks' })}</div>
<div className="w-[92px] shrink-0 text-right">{t('gscTab.colImpr', { defaultValue: 'Impressions' })}</div>
<div className="w-[64px] shrink-0 text-right">{t('gscTab.colCtr', { defaultValue: 'CTR' })}</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"bin": {
+15 -3
View File
@@ -783,7 +783,12 @@ export function buildTools(): Tool[] {
type: 'string',
description: 'Substring match against src or alt.',
},
missingAltOnly: { type: 'boolean' },
missingAltOnly: { type: 'boolean', description: 'Only images with no alt attribute.' },
emptyAltOnly: { type: 'boolean', description: 'Only images with an explicit empty alt="".' },
duplicateAltOnly: {
type: 'boolean',
description: 'Only images whose non-empty alt text is shared by ≥2 distinct images.',
},
internalOnly: { type: 'boolean' },
limit: { type: 'integer', minimum: 1, maximum: 5000 },
offset: { type: 'integer', minimum: 0 },
@@ -795,6 +800,8 @@ export function buildTools(): Tool[] {
offset: clamp(args.offset, 0, 10_000_000, 0),
search: typeof args.search === 'string' ? args.search : undefined,
missingAltOnly: args.missingAltOnly === true,
emptyAltOnly: args.emptyAltOnly === true,
duplicateAltOnly: args.duplicateAltOnly === true,
internalOnly: args.internalOnly === true,
}),
},
@@ -1436,12 +1443,17 @@ export function buildTools(): Tool[] {
{
requiresDb: false,
name: 'export_images',
description: 'Export every image the crawler saw to a CSV with src/alt/dimensions/byte_size/is_internal. Honours the Images tab\'s "Missing Alt only" toggle and search box.',
description: 'Export every image the crawler saw to a CSV with src/alt/dimensions/byte_size/is_internal. Honours the Images tab\'s alt filters (missing / empty / duplicate) and search box.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string' },
missingAltOnly: { type: 'boolean' },
missingAltOnly: { type: 'boolean', description: 'Only images with no alt attribute.' },
emptyAltOnly: { type: 'boolean', description: 'Only images with an explicit empty alt="".' },
duplicateAltOnly: {
type: 'boolean',
description: 'Only images whose non-empty alt is shared by ≥2 distinct images.',
},
search: { type: 'string', description: 'Substring filter on src or alt.' },
},
required: ['filePath'],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "./dist/index.js",
+10
View File
@@ -35,6 +35,11 @@ export interface BrowserPoolOptions {
headless: boolean;
/** Viewport dimensions for every page in the pool. */
viewport: { width: number; height: number };
/** Emulate a mobile device (touch, mobile meta viewport, DPR). Chromium
* only — set when the crawl's device mode is 'mobile'. */
isMobile?: boolean;
/** Device pixel ratio (e.g. 3 for iPhone-class). Defaults to 1. */
deviceScaleFactor?: number;
/** UA string applied at context level. */
userAgent?: string;
/** Accept-Language applied at context level. */
@@ -140,6 +145,11 @@ export class BrowserPool {
this.context = await this.browser.newContext({
viewport: this.opts.viewport,
userAgent: this.opts.userAgent,
// Mobile emulation — makes responsive CSS/JS render the mobile layout,
// not just a narrow desktop window. Chromium-only; harmless when false.
isMobile: this.opts.isMobile,
hasTouch: this.opts.isMobile,
deviceScaleFactor: this.opts.deviceScaleFactor,
locale: this.opts.acceptLanguage,
extraHTTPHeaders: this.opts.acceptLanguage
? { 'Accept-Language': this.opts.acceptLanguage }
+202 -35
View File
@@ -37,10 +37,11 @@ import {
type DigestChallenge,
formatFetchError,
initHttpClient,
MOBILE_USER_AGENT,
parseDigestChallenge,
} from './http-client.js';
import { setActiveDnsHook } from './dns-resolver.js';
import { discoverSitemapUrls, fetchSitemaps } from './sitemap.js';
import { discoverSitemapUrls, fetchSitemaps, type SitemapEntry } from './sitemap.js';
import { runJsonExtractionRules } from './extraction.js';
import { SessionCookieJar } from './cookie-jar.js';
import { runBrowserLogin } from './browser-login.js';
@@ -552,6 +553,18 @@ export class Crawler extends EventEmitter {
this.startedAt = Date.now();
this.stopped = false;
this.running = true;
// Mobile device mode: swap the base User-Agent to a smartphone UA before
// ANY request goes out. Every request path (page fetch, robots.txt,
// sitemap, start-URL probe, form login) reads `this.config.userAgent`, so
// this single overwrite makes the whole crawl see the site's mobile
// version on servers that vary HTML by UA. Per-host UA overrides still
// win. The JS-render browser context is switched to a mobile viewport
// separately in the desktop host (it builds the BrowserPool).
if (this.config.deviceMode === 'mobile') {
this.config = { ...this.config, userAgent: MOBILE_USER_AGENT };
}
this.setOp(`crawl:start:${this.config.startUrl}`);
// Fire an immediate progress event so the UI can flip to "Running"
@@ -580,6 +593,11 @@ export class Crawler extends EventEmitter {
return;
}
if (this.config.mode === 'sitemap') {
await this.startSitemapMode();
return;
}
const startProbeT0 = Date.now();
const start = await resolveStartUrl(
this.config.startUrl,
@@ -660,9 +678,14 @@ export class Crawler extends EventEmitter {
}
})
: Promise.resolve();
const sitemapPromise = this.config.discoverSitemaps
? this.discoverAndIngestSitemaps(origin)
: Promise.resolve();
// Runs when auto-discovery is on OR the user supplied explicit sitemap
// seed URLs — both are handled inside `discoverAndIngestSitemaps`, which
// writes `sitemap_urls` exactly once (merged) to avoid the replace-all
// race, and additionally enqueues the seed entries as crawl seeds.
const sitemapPromise =
this.config.discoverSitemaps || this.config.seedSitemapUrls.length > 0
? this.discoverAndIngestSitemaps(origin)
: Promise.resolve();
this.progressTimer = setInterval(() => this.emitProgress(), 500);
@@ -1445,23 +1468,116 @@ export class Crawler extends EventEmitter {
* queue and the disabled scope so links never get re-enqueued.
*/
private async startListMode(): Promise<void> {
const urls = this.dedupeNormalized(this.config.urlList);
if (urls.length === 0) {
this.emit('error', 'List mode: urlList is empty (or no entries normalised to valid URLs).');
this.finishEmptyFixedCrawl();
return;
}
// Fingerprint: list signature is "list:<count>:<first-url>". Two crawls
// with the same first URL + same count look identical — good enough
// heuristic; users who really want a fresh start can use Clear.
const fingerprint = `list:${urls.length}:${urls[0] ?? ''}`;
await this.runFixedUrlCrawl(urls, fingerprint);
}
/**
* Sitemap-mode entry point — treat `startUrl` as a sitemap (or
* sitemap-index) URL, fetch + parse it, then crawl every `<loc>` it
* lists exactly once (no link follow — same engine as List mode). The
* parsed entries are also persisted to `sitemap_urls` so the post-crawl
* orphan / sitemap-issue reports have data to compare against. Fetch
* failures surface as an 'error' event, never thrown.
*/
private async startSitemapMode(): Promise<void> {
const sitemapUrl = this.config.startUrl.trim();
if (!sitemapUrl) {
this.emit('error', 'Sitemap mode: no sitemap URL provided.');
this.finishEmptyFixedCrawl();
return;
}
this.applyProcessPriority();
const controller = new AbortController();
this.sitemapAbort = controller;
// Sitemap fetch gets a longer budget than passive discovery — it is
// the crawl's only URL source here, so a slow server shouldn't abort
// the whole run prematurely.
const t = setTimeout(
() => controller.abort(),
Math.max(30_000, this.config.requestTimeoutMs * 2),
);
let entries: SitemapEntry[] = [];
try {
const sitemapMaxUrls = Math.max(50_000, this.config.maxUrls);
const result = await fetchSitemaps([sitemapUrl], {
userAgent: this.config.userAgent,
signal: controller.signal,
timeoutMs: this.config.requestTimeoutMs,
maxUrls: sitemapMaxUrls,
maxDepth: 3,
});
entries = result.entries;
this.emit(
'info',
`Sitemap mode: parsed ${result.sitemapsParsed.length}/${result.sitemapsTried.length} sitemap(s), ${entries.length} URL(s)${result.truncated ? ` (truncated at ${sitemapMaxUrls.toLocaleString()})` : ''}`,
);
} catch (err) {
if (!this.stopped) {
this.emit(
'error',
`Sitemap mode: could not fetch ${sitemapUrl} — ${formatFetchError(err)}`,
);
}
this.finishEmptyFixedCrawl();
return;
} finally {
clearTimeout(t);
this.sitemapAbort = null;
}
if (this.stopped) return;
const urls = this.dedupeNormalized(entries.map((e) => e.url));
if (urls.length === 0) {
this.emit('error', `Sitemap mode: ${sitemapUrl} yielded no crawlable URLs.`);
this.finishEmptyFixedCrawl();
return;
}
const fingerprint = `sitemap:${sitemapUrl}:${urls.length}`;
// Persist entries for orphan / sitemap reports — but only *after* the
// reset inside runFixedUrlCrawl, else the DELETE-all in setSitemapUrls
// would be wiped by the table reset. The callback runs post-reset.
await this.runFixedUrlCrawl(urls, fingerprint, async () => {
if (entries.length > 0) {
await this.dbCall<void>('setSitemapUrls', [entries]);
}
});
}
/** Normalise + dedupe a raw URL list, dropping entries that don't
* normalise to a valid URL. Shared by List and Sitemap modes. */
private dedupeNormalized(raw: readonly string[]): string[] {
const urls: string[] = [];
const seenInList = new Set<string>();
for (const raw of this.config.urlList) {
const norm = normalizeUrl(raw, undefined, this.urlRewrites);
for (const r of raw) {
const norm = normalizeUrl(r, undefined, this.urlRewrites);
if (!norm) continue;
if (seenInList.has(norm)) continue;
seenInList.add(norm);
urls.push(norm);
}
if (urls.length === 0) {
this.emit('error', 'List mode: urlList is empty (or no entries normalised to valid URLs).');
this.running = false;
this.emitProgress();
if (!this.stopped) {
// Wave 6 — Clean completion clears the checkpoint so the next
// app launch doesn't offer to "resume" a crawl that already
// finished successfully.
return urls;
}
/** Clean-completion path for an empty fixed-URL crawl (List / Sitemap
* with nothing to fetch): stop, clear the resume checkpoint, emit done. */
private finishEmptyFixedCrawl(): void {
this.running = false;
this.emitProgress();
if (!this.stopped) {
try {
this.db.clearQueueCheckpoint();
} catch {
@@ -1469,18 +1585,27 @@ export class Crawler extends EventEmitter {
}
this.emit('done', this.db.getSummary());
}
return;
}
}
// Fingerprint: list signature is "list:<count>:<first-url>". Two crawls
// with the same first URL + same count look identical — good enough
// heuristic; users who really want a fresh start can use Clear.
const fingerprint = `list:${urls.length}:${urls[0] ?? ''}`;
/**
* Shared engine for the two "fetch a fixed set of URLs, don't follow
* links" modes (List and Sitemap). Handles the reset/resume fingerprint,
* forces exact-url scope so outlinks never re-enqueue, runs the crawl to
* idle, then the standard post-crawl passes. `afterReset` (optional) runs
* after the reset/setMeta but before enqueue — used by Sitemap mode to
* (re)populate `sitemap_urls` once the table reset has settled.
*/
private async runFixedUrlCrawl(
urls: string[],
fingerprint: string,
afterReset?: () => Promise<void>,
): Promise<void> {
const previousStart = this.db.getMeta('startUrl');
if (previousStart !== fingerprint) {
this.db.reset();
}
this.db.setMeta('startUrl', fingerprint);
if (afterReset) await afterReset();
// Force exact-url scope so anything fetched in fetchAndProcess never
// re-enqueues its outlinks, and bake the first URL into startUrl so
@@ -1543,6 +1668,15 @@ export class Crawler extends EventEmitter {
* instead of staring at an empty table for 3–4 s while a 20k-URL
* sitemap is fetched. Errors are surfaced via 'error' / 'info' events,
* never thrown — sitemap discovery is best-effort.
*
* Handles two sources in one pass so `sitemap_urls` is written exactly
* once (it is a replace-all table — two concurrent writers would clobber
* each other):
* 1. Auto-discovered roots (robots.txt + conventional paths), when
* `discoverSitemaps` is on. Recorded only.
* 2. User-supplied `seedSitemapUrls` (spider mode). Recorded AND their
* `<loc>` entries are enqueued as extra depth-0 crawl seeds — so a
* sitemap at a non-standard path still feeds discovery + reports.
*/
private async discoverAndIngestSitemaps(origin: string): Promise<void> {
try {
@@ -1555,33 +1689,66 @@ export class Crawler extends EventEmitter {
Math.max(5000, this.config.requestTimeoutMs),
);
try {
const roots = await discoverSitemapUrls(
origin,
this.config.userAgent,
controller.signal,
);
// If stop() ran while we were discovering, bail without ingesting
// — otherwise a zombie 'info' / 'sitemap_urls' write leaks into
// whatever crawl ran next.
if (this.stopped) return;
// Sitemap entry cap follows the crawl-level cap so 1M-URL crawls
// can ingest the full sitemap, with a sensible floor for tiny caps.
const sitemapMaxUrls = Math.max(50_000, this.config.maxUrls);
const result = await fetchSitemaps(roots, {
const fetchOpts = {
userAgent: this.config.userAgent,
signal: controller.signal,
timeoutMs: this.config.requestTimeoutMs,
maxUrls: sitemapMaxUrls,
maxDepth: 3,
});
if (this.stopped) return;
await this.dbCall<void>('setSitemapUrls', [result.entries]);
if (result.entries.length > 0) {
};
// (1) Auto-discovered sitemaps — recorded only.
let autoEntries: SitemapEntry[] = [];
if (this.config.discoverSitemaps) {
const roots = await discoverSitemapUrls(
origin,
this.config.userAgent,
controller.signal,
);
// If stop() ran while we were discovering, bail without ingesting
// — otherwise a zombie 'info' / 'sitemap_urls' write leaks into
// whatever crawl ran next.
if (this.stopped) return;
const res = await fetchSitemaps(roots, fetchOpts);
if (this.stopped) return;
autoEntries = res.entries;
if (res.entries.length > 0) {
this.emit(
'info',
`Sitemap: parsed ${res.sitemapsParsed.length}/${res.sitemapsTried.length}, ${res.entries.length} URLs${res.truncated ? ` (truncated at ${sitemapMaxUrls.toLocaleString()})` : ''}`,
);
}
}
// (2) User-supplied seed sitemaps — recorded AND enqueued.
let seedEntries: SitemapEntry[] = [];
if (this.config.seedSitemapUrls.length > 0) {
const res = await fetchSitemaps(this.config.seedSitemapUrls, fetchOpts);
if (this.stopped) return;
seedEntries = res.entries;
let seeded = 0;
for (const e of res.entries) {
const norm = normalizeUrl(e.url, undefined, this.urlRewrites);
if (!norm) continue;
this.enqueue({ url: norm, depth: 0 });
seeded++;
}
this.emit(
'info',
`Sitemap: parsed ${result.sitemapsParsed.length}/${result.sitemapsTried.length}, ${result.entries.length} URLs${result.truncated ? ` (truncated at ${sitemapMaxUrls.toLocaleString()})` : ''}`,
`Sitemap seed: ${seeded} URL(s) from ${res.sitemapsParsed.length}/${res.sitemapsTried.length} sitemap(s) queued as crawl seeds`,
);
}
// Single merged write — seed entries first so their source wins on
// `<loc>` conflicts (ON CONFLICT DO NOTHING keeps the first).
if (this.stopped) return;
const merged = [...seedEntries, ...autoEntries];
if (merged.length > 0) {
await this.dbCall<void>('setSitemapUrls', [merged]);
}
} finally {
clearTimeout(t);
this.sitemapAbort = null;
+8 -1
View File
@@ -89,7 +89,12 @@ const IMAGES_CSV_HEADER = [
export async function exportImagesToCsv(
db: ProjectDb,
filePath: string,
options: { missingAltOnly?: boolean; search?: string } = {},
options: {
missingAltOnly?: boolean;
emptyAltOnly?: boolean;
duplicateAltOnly?: boolean;
search?: string;
} = {},
): Promise<{ rowsWritten: number }> {
let rowsWritten = 0;
const header = IMAGES_CSV_HEADER.join(',') + '\n';
@@ -98,6 +103,8 @@ export async function exportImagesToCsv(
yield '' + header;
for (const row of db.iterateImages({
missingAltOnly: options.missingAltOnly,
emptyAltOnly: options.emptyAltOnly,
duplicateAltOnly: options.duplicateAltOnly,
search: options.search,
})) {
const cells = [
+1
View File
@@ -140,6 +140,7 @@ const ISSUES: IssueDef[] = [
{ key: 'urlFragment', label: 'Fragment in URL', severity: 'info' },
{ key: 'urlSpaces', label: 'Spaces in URL', severity: 'warn' },
{ key: 'imageEmptyAlt', label: 'Image Empty Alt', severity: 'info' },
{ key: 'imageDuplicateAlt', label: 'Image Duplicate Alt', severity: 'warn' },
{ key: 'linkEmptyAnchor', label: 'Empty Anchor Text', severity: 'warn' },
{ key: 'titlePixelWidthTooLong', label: 'Title Pixel Width >600px', severity: 'warn' },
{ key: 'metaPixelWidthTooLong', label: 'Meta Description Pixel Width >990px', severity: 'warn' },
+16
View File
@@ -31,6 +31,22 @@ let lenientDispatcher: Agent | null = null;
export const BROWSER_FALLBACK_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
/**
* User-Agent sent for every request when the crawl's device mode is
* 'mobile'. A modern iPhone Safari UA — the most universally-recognised
* trigger for a site's mobile layout (dynamic serving / adaptive delivery),
* and far less likely to be blocked than a spoofed Googlebot-Smartphone UA
* (which WAFs verify via reverse DNS). Sites that render a different mobile
* version key off this string.
*/
export const MOBILE_USER_AGENT =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
/** Logical viewport + device metrics that pair with `MOBILE_USER_AGENT`
* (iPhone-class). Applied to the JS-render browser context in mobile mode. */
export const MOBILE_VIEWPORT = { width: 390, height: 844 } as const;
export const MOBILE_DEVICE_SCALE_FACTOR = 3;
/**
* Configure the global undici dispatcher and Node DNS once per process.
*
+5
View File
@@ -40,6 +40,11 @@ export {
type HreflangEntry,
} from './html-parser.js';
export { exportUrlsToCsv, exportBrokenLinksToCsv, exportImagesToCsv } from './csv-export.js';
export {
MOBILE_USER_AGENT,
MOBILE_VIEWPORT,
MOBILE_DEVICE_SCALE_FACTOR,
} from './http-client.js';
export { exportUrlsToJson, type JsonExportOptions } from './json-export.js';
export { exportUrlsToXml } from './xml-export.js';
export {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "./dist/index.js",
+45 -1
View File
@@ -662,6 +662,18 @@ function truncateUtf8(input: string, cap: number): string {
return buf.subarray(0, end).toString('utf8');
}
/**
* SQL predicate (against the `images` table) matching images whose
* non-empty alt text is shared by ≥2 distinct images. The `images` table
* is deduped by `src`, so a self-join via `GROUP BY alt HAVING COUNT(*) > 1`
* yields alt texts reused across different image files — non-descriptive /
* templated alt. Shared by the overview count, `queryImages`, and the CSV
* export so all three agree on what "duplicate alt" means.
*/
const DUPLICATE_ALT_PREDICATE = `alt IS NOT NULL AND alt != '' AND alt IN (
SELECT alt FROM images WHERE alt IS NOT NULL AND alt != '' GROUP BY alt HAVING COUNT(*) > 1
)`;
export class ProjectDb {
private readonly db: DatabaseSync;
private readonly stmtUpsertUrl: StatementSync;
@@ -4434,6 +4446,8 @@ export class ProjectDb {
offset: number;
search?: string;
missingAltOnly?: boolean;
emptyAltOnly?: boolean;
duplicateAltOnly?: boolean;
internalOnly?: boolean;
}): { rows: ImageRow[]; total: number } {
const where: string[] = [];
@@ -4444,6 +4458,12 @@ export class ProjectDb {
if (params.missingAltOnly) {
where.push('alt IS NULL');
}
if (params.emptyAltOnly) {
where.push("alt = ''");
}
if (params.duplicateAltOnly) {
where.push(DUPLICATE_ALT_PREDICATE);
}
if (params.search) {
where.push('(src LIKE ? OR alt LIKE ?)');
const like = `%${params.search}%`;
@@ -4557,6 +4577,9 @@ export class ProjectDb {
const totalNonIndexable = countWhere(
"is_external = 0 AND content_kind = 'html' AND indexability LIKE 'non-indexable%'",
);
const totalImages = (
this.db.prepare('SELECT COUNT(*) AS c FROM images').get() as { c: number }
).c;
return {
summary: {
@@ -4564,6 +4587,7 @@ export class ProjectDb {
totalIndexable,
totalNonIndexable,
totalExternalUrls,
totalImages,
},
internal: {
all: totalInternalUrls,
@@ -4796,6 +4820,15 @@ export class ProjectDb {
c: number;
}
).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.
imageDuplicateAlt: (
this.db
.prepare(`SELECT COUNT(*) AS c FROM images WHERE ${DUPLICATE_ALT_PREDICATE}`)
.get() as { c: number }
).c,
metaRefreshUsed: countWhere(
`${html} AND meta_refresh IS NOT NULL AND meta_refresh != ''`,
),
@@ -4861,7 +4894,14 @@ export class ProjectDb {
`${html} AND (INSTR(url, ' ') > 0 OR INSTR(url, '%20') > 0)`,
),
urlMalformed: countWhere(`${html} AND url_malformed = 1`),
imageEmptyAlt: countWhere(`${html} AND images_empty_alt > 0`),
// Image-level (was page-level `images_empty_alt > 0`): count distinct
// images with an explicit `alt=""` so it's the same unit as Missing
// Alt and matches the Images tab drill-down.
imageEmptyAlt: (
this.db.prepare("SELECT COUNT(*) AS c FROM images WHERE alt = ''").get() as {
c: number;
}
).c,
linkEmptyAnchor: countWhere(`${html} AND empty_anchor_count > 0`),
appleTouchIconMissing: countWhere(
`${html} AND status_code >= 200 AND status_code < 300
@@ -5831,11 +5871,15 @@ export class ProjectDb {
*/
*iterateImages(options: {
missingAltOnly?: boolean;
emptyAltOnly?: boolean;
duplicateAltOnly?: boolean;
search?: string;
} = {}): IterableIterator<ImageRow> {
const where: string[] = [];
const args: (string | number)[] = [];
if (options.missingAltOnly) where.push('alt IS NULL');
if (options.emptyAltOnly) where.push("alt = ''");
if (options.duplicateAltOnly) where.push(DUPLICATE_ALT_PREDICATE);
if (options.search) {
where.push('(src LIKE ? OR alt LIKE ?)');
const like = `%${options.search}%`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "./dist/index.js",
+46 -5
View File
@@ -14,12 +14,17 @@ export type CrawlScope = 'subdomain' | 'subfolder' | 'all-subdomains' | 'exact-u
/**
* Top-level crawl mode.
* - `spider` — start from `startUrl`, follow links by `scope`. Default.
* - `list` — fetch every URL in `urlList` exactly once, no link follow.
* Used to audit a curated set of URLs (sitemap export,
* GSC URL inspection list, etc.).
* - `spider` — start from `startUrl`, follow links by `scope`. Default.
* - `list` — fetch every URL in `urlList` exactly once, no link follow.
* Used to audit a curated set of URLs (sitemap export,
* GSC URL inspection list, etc.).
* - `sitemap` — treat `startUrl` as a sitemap (or sitemap-index) URL,
* fetch + parse it, then crawl every `<loc>` it lists
* exactly once (no link follow — like `list`, but the list
* comes from the sitemap). The parsed entries are also
* written to `sitemap_urls` so orphan / sitemap reports work.
*/
export type CrawlMode = 'spider' | 'list';
export type CrawlMode = 'spider' | 'list' | 'sitemap';
export type UrlCategory =
| 'all'
@@ -100,6 +105,7 @@ export type UrlCategory =
| 'issues:non-200-in-sitemap'
| 'issues:image-missing-alt'
| 'issues:image-empty-alt'
| 'issues:image-duplicate-alt'
| 'issues:meta-refresh-used'
| 'issues:charset-missing'
| 'issues:amp-validation-errors'
@@ -646,7 +652,22 @@ export interface CrawlConfig {
mode: CrawlMode;
/** When `mode === 'list'`, URLs to fetch (one per entry). Ignored in spider mode. */
urlList: string[];
/**
* Spider start URL. When `mode === 'sitemap'` this field instead holds
* the sitemap (or sitemap-index) URL whose `<loc>` entries are crawled.
*/
startUrl: string;
/**
* Optional sitemap URLs used to *seed* a spider crawl (mode `spider`
* only). Their `<loc>` entries are fetched, parsed, and enqueued as
* extra depth-0 seeds alongside `startUrl` — so link discovery is
* faster/more complete and orphan detection runs against the real
* sitemap even when it lives at a non-standard path. Empty = disabled.
* Distinct from `discoverSitemaps` (which only records URLs, never
* enqueues them) and from `mode === 'sitemap'` (which crawls *only*
* the sitemap).
*/
seedSitemapUrls: string[];
scope: CrawlScope;
maxDepth: number;
maxUrls: number;
@@ -654,6 +675,18 @@ export interface CrawlConfig {
maxRps: number;
requestTimeoutMs: number;
userAgent: string;
/**
* Device the crawl emulates.
* - `desktop` — use `userAgent` as-is (default).
* - `mobile` — override every request's User-Agent with a smartphone UA
* (`MOBILE_USER_AGENT`) so servers that serve a different
* mobile HTML (dynamic serving / adaptive delivery) return
* their mobile version; in JS-render mode the browser also
* uses a mobile viewport (`isMobile`, touch, DPR 3).
* Applies to page fetches, robots.txt, sitemap fetches, and the start-URL
* probe — the whole crawl sees the mobile site.
*/
deviceMode: 'desktop' | 'mobile';
followRedirects: boolean;
respectRobotsTxt: boolean;
crawlExternal: boolean;
@@ -1361,6 +1394,9 @@ export interface OverviewCounts {
totalIndexable: number;
totalNonIndexable: number;
totalExternalUrls: number;
/** Total distinct images discovered — the correct denominator for the
* image-level alt issues (missing / empty / duplicate alt). */
totalImages: number;
};
internal: Record<string, number>;
external: Record<string, number>;
@@ -1470,6 +1506,9 @@ export interface OverviewCounts {
/** URLs flagged by `isUrlMalformed` (multiple `?`/`#`, control chars, unescaped reserved chars, double-encoding). */
urlMalformed: number;
imageEmptyAlt: number;
/** Distinct images whose (non-empty) alt text is shared by ≥2 other
* distinct images — non-descriptive / templated alt reuse. */
imageDuplicateAlt: number;
linkEmptyAnchor: number;
appleTouchIconMissing: number;
manifestMissing: number;
@@ -1937,6 +1976,7 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
mode: 'spider',
urlList: [],
startUrl: '',
seedSitemapUrls: [],
scope: 'subdomain',
maxDepth: 10,
maxUrls: 1_000_000,
@@ -1944,6 +1984,7 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
maxRps: 20,
requestTimeoutMs: 20_000,
userAgent: 'FreeCrawlSEO/0.1 (+https://github.com/kemalai/FreeCrawl-SEO-Tool)',
deviceMode: 'desktop',
followRedirects: true,
respectRobotsTxt: true,
crawlExternal: false,
+9
View File
@@ -409,7 +409,12 @@ export interface ImagesQueryInput {
limit: number;
offset: number;
search?: string;
/** Only images with no `alt` attribute (accessibility/SEO issue). */
missingAltOnly?: boolean;
/** Only images with an explicit empty `alt=""` (decorative). */
emptyAltOnly?: boolean;
/** Only images whose non-empty alt text is shared by ≥2 distinct images. */
duplicateAltOnly?: boolean;
internalOnly?: boolean;
}
@@ -522,6 +527,10 @@ export interface ExportImagesInput {
/** When true, export only images with no `alt` attribute. Mirrors
* the Images tab's "Missing Alt only" sidebar filter. */
missingAltOnly?: boolean;
/** Export only images with an explicit empty `alt=""` (decorative). */
emptyAltOnly?: boolean;
/** Export only images whose non-empty alt is shared by ≥2 distinct images. */
duplicateAltOnly?: boolean;
/** Free-text URL/alt filter (substring match, case-insensitive).
* Mirrors the tab's search box. */
search?: string;
@@ -155,6 +155,7 @@ const INFO: UrlCategory[] = [
'issues:favicon-missing',
'issues:url-many-params',
'issues:image-empty-alt',
'issues:image-duplicate-alt',
'issues:high-boilerplate',
'issues:crawled-not-in-sitemap',
'issues:h1-too-long',