mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: sitemap crawl mode + mobile device toggle + duplicate-alt check
This commit is contained in:
1 parent
65e434fed4
commit
663e977f4b
28 files changed
+782
-121
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user