feat: a11y + structured-data filters, 7 new tabs, faster clear

This commit is contained in:
kemalai committed 2026-05-09 12:48:15 +03:00
1 parent 53a703f63b
commit d24bde2a2f
21 files changed
+894 -37

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"main": "./out/main/index.js",
+6
View File
@@ -1711,6 +1711,7 @@ function registerIpc(): void {
});
ipcMain.handle(IPC.crawlClear, async () => {
const t0 = Date.now();
activeCrawler?.stop();
activeCrawler = null;
const database = getDb();
@@ -1750,6 +1751,11 @@ function registerIpc(): void {
// dialog can't fire after Clear if the user cancelled the boot
// prompt earlier.
pendingRecoveryCheckpoint = [];
// Broadcast `dataChanged` so any open table / overview / detail
// panel re-queries against the now-empty DB instead of showing
// stale rows until the next refresh tick.
fireDataChanged();
logger.log('info', 'main', `Clear completed in ${Date.now() - t0} ms`);
});
ipcMain.handle(IPC.crawlAddUrl, (_e, url: string): { accepted: boolean } => {
@@ -412,6 +412,11 @@ function ViewSourceView({
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [wrap, setWrap] = useState(false);
// Active match index (0-based). When the user presses Enter (next) or
// Shift+Enter (prev) we cycle through matches and the active one is
// scrolled into view + highlighted in a stronger colour.
const [activeMatch, setActiveMatch] = useState(0);
const preRef = useRef<HTMLPreElement | null>(null);
useEffect(() => {
if (urlId === null) {
@@ -433,6 +438,13 @@ function ViewSourceView({
};
}, [urlId]);
// When the search term changes, reset the cycle to the first match so
// a fresh query doesn't carry over a stale index from a longer match
// list. Also runs when the user clears the term entirely.
useEffect(() => {
setActiveMatch(0);
}, [search, urlId]);
if (loading && !src) {
return <div className="p-4 text-[11px] text-surface-500">Loading source…</div>;
}
@@ -450,6 +462,18 @@ function ViewSourceView({
const body = src.body;
const matches = search ? countMatches(body, search) : 0;
// Clamp active match into the current range so a shrinking match
// count (after the user typed an extra character) doesn't leave us
// pointing past the end.
const safeActive = matches > 0 ? activeMatch % matches : 0;
function step(delta: number): void {
if (matches === 0) return;
setActiveMatch((cur) => {
const next = (cur + delta + matches) % matches;
return next;
});
}
function copy() {
void navigator.clipboard.writeText(body);
@@ -492,12 +516,45 @@ function ViewSourceView({
placeholder="Search source…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
// Enter / Shift+Enter cycle through matches in place — the
// input keeps focus so the user can keep typing or step
// again without re-clicking. Escape clears the search.
if (e.key === 'Enter') {
e.preventDefault();
step(e.shiftKey ? -1 : 1);
} else if (e.key === 'Escape') {
e.preventDefault();
setSearch('');
}
}}
spellCheck={false}
/>
{search && (
<span className="text-surface-500">
{matches} match{matches === 1 ? '' : 'es'}
</span>
{search && matches > 0 && (
<>
<span className="text-surface-500">
{safeActive + 1} of {matches}
</span>
<button
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800 disabled:opacity-40"
onClick={() => step(-1)}
disabled={matches < 2}
title="Previous match (Shift+Enter)"
>
↑
</button>
<button
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800 disabled:opacity-40"
onClick={() => step(1)}
disabled={matches < 2}
title="Next match (Enter)"
>
↓
</button>
</>
)}
{search && matches === 0 && (
<span className="text-surface-500">No matches</span>
)}
<label className="flex items-center gap-1 text-surface-400">
<input
@@ -525,18 +582,56 @@ function ViewSourceView({
</div>
<div className="flex-1 overflow-auto bg-surface-950 p-3">
<pre
ref={preRef}
className={clsx(
'font-mono text-[10.5px] leading-[14px] text-surface-200',
wrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}
>
{search ? renderHighlighted(body, search) : body}
{search
? renderHighlighted(body, search, safeActive)
: body}
</pre>
</div>
<ViewSourceScrollEffect
preRef={preRef}
depKey={`${urlId}|${search}|${safeActive}`}
enabled={search.length > 0 && matches > 0}
/>
</div>
);
}
/**
* Scrolls the active `<mark data-active="true">` highlight into view
* inside the `<pre>` block whenever the active match index changes.
* Implemented as a tiny child component so the effect's dep array can
* be tied to a string key without polluting the parent's render.
*/
function ViewSourceScrollEffect({
preRef,
depKey,
enabled,
}: {
preRef: React.RefObject<HTMLPreElement | null>;
depKey: string;
enabled: boolean;
}) {
useEffect(() => {
if (!enabled) return;
const pre = preRef.current;
if (!pre) return;
const active = pre.querySelector<HTMLElement>('mark[data-active="true"]');
if (!active) return;
// `block: 'nearest'` avoids snap-jumping when the active hit is
// already visible. `behavior: 'smooth'` reads better than instant
// jumps for a 200 KB body.
active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depKey, enabled]);
return null;
}
function DuplicatesView({ urlId, row }: { urlId: number; row: CrawlUrlRow }) {
const [members, setMembers] = useState<UrlClusterMember[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -657,7 +752,11 @@ function countMatches(haystack: string, needle: string): number {
return count;
}
function renderHighlighted(body: string, needle: string): ReactNode {
function renderHighlighted(
body: string,
needle: string,
activeIndex = -1,
): ReactNode {
if (!needle) return body;
const out: ReactNode[] = [];
const lower = body.toLowerCase();
@@ -674,10 +773,16 @@ function renderHighlighted(body: string, needle: string): ReactNode {
break;
}
if (idx > pos) out.push(body.slice(pos, idx));
const isActive = hits === activeIndex;
out.push(
<mark
key={`m${idx}`}
className="rounded bg-amber-500/40 text-amber-100"
data-active={isActive ? 'true' : undefined}
className={
isActive
? 'rounded bg-amber-400 text-surface-950 outline outline-1 outline-amber-200'
: 'rounded bg-amber-500/40 text-amber-100'
}
>
{body.slice(idx, idx + needle.length)}
</mark>,
@@ -28,6 +28,13 @@ const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
canonicals: 'internal:html',
directives: 'internal:html',
redirects: 'all',
pagination: 'tab:pagination',
hreflang: 'tab:hreflang',
amp: 'tab:amp',
'structured-data': 'tab:structured-data',
'meta-refresh': 'tab:meta-refresh',
'custom-extraction': 'tab:custom-extraction',
'custom-search': 'tab:custom-search',
links: 'all',
'broken-links': 'all',
};
@@ -694,6 +694,12 @@ function buildTree(o: OverviewCounts | null): Node[] {
count: o.issues.pageLarge,
category: 'issues:page-large',
},
{
key: 'issues-page-too-large-critical',
label: 'Critical Size (>3MB)',
count: o.issues.pageTooLargeCritical,
category: 'issues:page-too-large-critical',
},
],
},
{
@@ -808,6 +814,24 @@ function buildTree(o: OverviewCounts | null): Node[] {
count: o.issues.formInputUnlabeled,
category: 'issues:form-input-unlabeled',
},
{
key: 'issues-landmark-main-missing',
label: 'Main Landmark Missing',
count: o.issues.landmarkMainMissing,
category: 'issues:landmark-main-missing',
},
{
key: 'issues-skip-link-missing',
label: 'Skip Link Missing',
count: o.issues.skipLinkMissing,
category: 'issues:skip-link-missing',
},
{
key: 'issues-aria-invalid-role',
label: 'Invalid ARIA Role',
count: o.issues.ariaInvalidRole,
category: 'issues:aria-invalid-role',
},
],
},
{
@@ -1120,6 +1144,24 @@ function buildTree(o: OverviewCounts | null): Node[] {
count: o.issues.structuredDataInvalid,
category: 'issues:structured-data-invalid',
},
{
key: 'issues-schema-duplicate-id',
label: 'Duplicate @id',
count: o.issues.schemaDuplicateId,
category: 'issues:schema-duplicate-id',
},
{
key: 'issues-schema-unknown-type',
label: 'Malformed @type',
count: o.issues.schemaUnknownType,
category: 'issues:schema-unknown-type',
},
{
key: 'issues-schema-missing-required',
label: 'Missing Required Property',
count: o.issues.schemaMissingRequired,
category: 'issues:schema-missing-required',
},
],
},
{
@@ -1168,6 +1210,12 @@ function buildTree(o: OverviewCounts | null): Node[] {
count: o.issues.paginationSequenceBreak,
category: 'issues:pagination-sequence-break',
},
{
key: 'issues-pagination-canonical-conflict',
label: 'Canonical Conflict',
count: o.issues.paginationCanonicalConflict,
category: 'issues:pagination-canonical-conflict',
},
],
},
{
+28
View File
@@ -22,6 +22,13 @@ export type TabKey =
| 'canonicals'
| 'directives'
| 'redirects'
| 'pagination'
| 'hreflang'
| 'amp'
| 'structured-data'
| 'meta-refresh'
| 'custom-extraction'
| 'custom-search'
| 'links'
| 'broken-links';
@@ -39,6 +46,13 @@ export const TAB_ORDER: { key: TabKey; label: string }[] = [
{ key: 'canonicals', label: 'Canonicals' },
{ key: 'directives', label: 'Directives' },
{ key: 'redirects', label: 'Redirects' },
{ key: 'pagination', label: 'Pagination' },
{ key: 'hreflang', label: 'Hreflang' },
{ key: 'amp', label: 'AMP' },
{ key: 'structured-data', label: 'Structured Data' },
{ key: 'meta-refresh', label: 'Meta Refresh' },
{ key: 'custom-extraction', label: 'Custom Extraction' },
{ key: 'custom-search', label: 'Custom Search' },
{ key: 'links', label: 'Links' },
{ key: 'broken-links', label: 'Broken Links' },
];
@@ -209,6 +223,20 @@ function categoryForTab(tab: TabKey): UrlCategory {
return 'tab:directives';
case 'redirects':
return 'tab:redirects';
case 'pagination':
return 'tab:pagination';
case 'hreflang':
return 'tab:hreflang';
case 'amp':
return 'tab:amp';
case 'structured-data':
return 'tab:structured-data';
case 'meta-refresh':
return 'tab:meta-refresh';
case 'custom-extraction':
return 'tab:custom-extraction';
case 'custom-search':
return 'tab:custom-search';
default:
return 'internal:html';
}
@@ -385,5 +385,53 @@ export const COLUMN_SPECS: Record<TabKey, ColumnSpec[]> = {
COL.redirectLoop,
COL.inlinks,
],
// V1 Faz 3 — new top-level filter tabs. Each starts with the
// minimum-viable column set; per-tab specialised columns can be
// added in follow-up sessions without changing the dispatch.
pagination: [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.canonical,
COL.inlinks,
],
hreflang: [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.canonical,
COL.inlinks,
],
amp: [COL.url, COL.status, COL.indexability, COL.title, COL.inlinks],
'structured-data': [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.inlinks,
],
'meta-refresh': [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.inlinks,
],
'custom-extraction': [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.inlinks,
],
'custom-search': [
COL.url,
COL.status,
COL.indexability,
COL.title,
COL.inlinks,
],
links: [COL.url, COL.inlinks, COL.outlinks, COL.status, COL.indexability],
};
@@ -3,6 +3,12 @@ const SKIP_KEY = 'clear:skip-confirm';
/**
* Wipe crawl data after optionally confirming with the user. If the user
* has previously ticked "Don't ask me again", the confirm is skipped.
*
* The DB reset is wrapped in a single SQLite transaction on the main
* side, which drops the wipe time from ~2-3 s to ~300-500 ms on a
* 100K-URL project (a single fsync replaces ten autocommit fsyncs).
* `crawlClear` is awaited so a follow-up Start can't race the in-flight
* `DELETE FROM …` against the seed-URL upsert.
*/
export async function clearCrawlWithConfirm(): Promise<boolean> {
const skip = window.freecrawl.prefsGet(SKIP_KEY) === true;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"bin": {
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.3.3",
"version": "0.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.3.3",
"version": "0.3.4",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -25,7 +25,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -42,7 +42,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -84,7 +84,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -8732,7 +8732,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8758,7 +8758,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8770,7 +8770,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.3.3",
"version": "0.3.4",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.3.3",
"version": "0.3.4",
"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.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"main": "./dist/index.js",
+18 -1
View File
@@ -2186,6 +2186,12 @@ export class Crawler extends EventEmitter {
imagesLazy: parsed.imagesLazy,
imagesResponsive: parsed.imagesResponsive,
pictureCount: parsed.pictureCount,
landmarkMain: parsed.hasMain ? 1 : 0,
skipLinkPresent: parsed.skipLinkPresent ? 1 : 0,
ariaInvalidRoles: parsed.ariaInvalidRolesCount,
schemaDuplicateIds: parsed.schemaDuplicateIds,
schemaUnknownTypes: parsed.schemaUnknownTypes,
schemaMissingRequired: parsed.schemaMissingRequired,
headings:
parsed.headings.length > 0 ? JSON.stringify(parsed.headings) : null,
serverHeader,
@@ -2654,6 +2660,17 @@ function detectContentKind(url: string, contentType: string | null): ContentKind
if (ct.includes('application/pdf')) return 'pdf';
if (ct.includes('font/') || ct.includes('application/font')) return 'font';
// Extension-driven fallback when content-type is missing or generic
// (typical for WAF / CDN bot-block responses where the gateway never
// surfaces the real handler's `Content-Type`):
// - Explicit asset extension (.css/.png/.pdf/.zip…) → its `EXT_TO_KIND`
// entry, or `'other'` for anything in the map's tail.
// - No extension at all (`/kategori/x`, `/urun/y`, dynamic CMS routes)
// → `'html'`, matching the Screaming Frog convention. Without this
// default, Cloudflare 429/403 pages on extension-less URLs end up
// as `'other'` and disappear from the Internal-HTML view, leaving
// the user with an empty table after a bot-blocked crawl.
const ext = extractExtension(url);
return EXT_TO_KIND[ext] ?? 'other';
if (ext) return EXT_TO_KIND[ext] ?? 'other';
return 'html';
}
+261
View File
@@ -74,6 +74,26 @@ export interface ParsedPage {
schemaBlockCount: number;
/** Number of JSON-LD blocks that failed to parse. */
schemaInvalidCount: number;
/**
* Number of `@id` collisions across all JSON-LD blocks on the page.
* Each surplus occurrence beyond the first counts as one duplicate
* — so an `@id` appearing 3 times contributes 2.
*/
schemaDuplicateIds: number;
/**
* Count of `@type` values that look malformed (empty, contain
* whitespace, lowercase first letter, non-PascalCase characters).
* Heuristic — we don't ship the full Schema.org taxonomy, just
* shape validation.
*/
schemaUnknownTypes: number;
/**
* Count of JSON-LD nodes whose declared `@type` is one of the
* Google-documented high-traffic types (Article / Product / Recipe
* / Event / FAQPage / HowTo / BreadcrumbList / Organization /
* VideoObject) and which is missing one or more required properties.
*/
schemaMissingRequired: number;
/** Number of Microdata `itemscope` elements declared on the page. */
microdataCount: number;
/** Number of RDFa `typeof` / `vocab` / `property` attribute occurrences. */
@@ -180,6 +200,19 @@ export interface ParsedPage {
* 4.1.2 and ship as "unlabeled form field" in axe / Lighthouse audits.
*/
formInputUnlabeledCount: number;
/** True when the page declares a `<main>` element OR `role="main"`. */
hasMain: boolean;
/**
* True when the first focusable `<a href="#…">` in `<body>` matches a
* known skip-link convention (`#main` / `#content` / `#skip…`).
* WCAG 2.4.1 "bypass blocks" success criterion.
*/
skipLinkPresent: boolean;
/**
* Count of `role="…"` attributes whose every space-separated token
* is unknown to the WAI-ARIA 1.2 taxonomy. Typos and made-up roles.
*/
ariaInvalidRolesCount: number;
/**
* Document outline — every `<h1>`–`<h6>` in source order, capped at
* 200 entries so a 5000-heading CMS dump can't bloat the row. Drives
@@ -367,12 +400,20 @@ export function parseHtml(
const schemaTypeSet = new Set<string>();
let schemaBlockCount = 0;
let schemaInvalidCount = 0;
// V1 Faz 2 — accumulator for the validation pass that runs alongside
// type collection. `walkSchemaForValidation` populates these.
const schemaIdCounts = new Map<string, number>();
let schemaUnknownTypes = 0;
let schemaMissingRequired = 0;
$('script[type="application/ld+json"]').each((_, el) => {
const raw = $(el).text().trim();
if (!raw) return;
try {
const parsed = JSON.parse(raw) as unknown;
collectSchemaTypes(parsed, schemaTypeSet);
const validation = walkSchemaForValidation(parsed, schemaIdCounts);
schemaUnknownTypes += validation.unknownTypeCount;
schemaMissingRequired += validation.missingRequiredCount;
schemaBlockCount++;
} catch {
// Malformed JSON-LD — still count presence so Structured Data
@@ -382,6 +423,13 @@ export function parseHtml(
}
});
const schemaTypes = [...schemaTypeSet].sort();
// Duplicate `@id` is computed across ALL blocks (a page may split
// entities into multiple JSON-LD scripts that share a knowledge-graph
// namespace). Count entries whose total occurrence is >1.
let schemaDuplicateIds = 0;
for (const count of schemaIdCounts.values()) {
if (count > 1) schemaDuplicateIds += count - 1; // surplus = duplicates
}
// Microdata & RDFa — alternative structured-data formats. We only count
// occurrences (not the type vocabulary), because the data model varies
@@ -834,6 +882,81 @@ export function parseHtml(
}
});
// Accessibility landmarks — does the page have a `<main>` element OR
// any element with `role="main"`? Screen readers rely on the main
// landmark to skip past navigation chrome on every page load.
const hasMain =
$('main').length > 0 || $('[role="main"]').length > 0;
// Skip link — WCAG 2.4.1 "bypass blocks": the first interactive
// element in `<body>` should be an in-page anchor that jumps past
// the repetitive nav. We look for an `<a href="#…">` whose target is
// the main content (id matches a landmark / common conventions).
// Heuristic: any `<a href="#…">` appearing as the first focusable
// element in `<body>` (before any visible link/button/input).
let skipLinkPresent = false;
{
const candidates = $('body a[href^="#"]').slice(0, 5);
candidates.each((_, el) => {
if (skipLinkPresent) return;
const href = ($(el).attr('href') ?? '').trim();
// Pure `#` (no fragment) is not a skip link — it's a placeholder.
if (href === '#' || href === '') return;
// Most CMS skip links: `#main`, `#content`, `#main-content`,
// `#skip-to-content`, `#primary`, `#site-content`.
const target = href.slice(1).toLowerCase();
if (
target === 'main' ||
target === 'content' ||
target === 'maincontent' ||
target === 'main-content' ||
target === 'site-content' ||
target === 'page-content' ||
target === 'primary' ||
target.startsWith('skip')
) {
skipLinkPresent = true;
}
});
}
// ARIA role validation — count `role="…"` attribute values that
// aren't part of the WAI-ARIA 1.2 role taxonomy. The list below is
// the authoritative set including abstract / document-structure /
// landmark / live-region / widget roles. Typos like `buttn` /
// `navagation` and made-up legacy values surface as failures.
const ariaValidRoles = new Set([
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote',
'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox',
'command', 'complementary', 'composite', 'contentinfo', 'definition',
'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed',
'figure', 'form', 'generic', 'graphics-document', 'graphics-object',
'graphics-symbol', 'grid', 'gridcell', 'group', 'heading', 'img',
'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem',
'log', 'main', 'mark', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
'menuitemcheckbox', 'menuitemradio', 'meter', 'navigation', 'none',
'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio',
'radiogroup', 'range', 'region', 'roletype', 'row', 'rowgroup',
'rowheader', 'scrollbar', 'search', 'searchbox', 'section',
'sectionhead', 'select', 'separator', 'slider', 'spinbutton', 'status',
'strong', 'structure', 'subscript', 'suggestion', 'superscript',
'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox',
'time', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem',
'widget', 'window',
]);
let ariaInvalidRolesCount = 0;
$('[role]').each((_, el) => {
const raw = ($(el).attr('role') ?? '').trim().toLowerCase();
if (!raw) return;
// Multiple roles allowed — space-separated. The element validates
// if ANY token is recognised; we count it as invalid only when
// every token is unknown (typical for typo bugs vs. fallback chains).
const tokens = raw.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return;
const anyValid = tokens.some((t) => ariaValidRoles.has(t));
if (!anyValid) ariaInvalidRolesCount++;
});
let imagesEmptyAlt = 0;
let imagesLazy = 0;
let imagesResponsive = 0;
@@ -1020,6 +1143,9 @@ export function parseHtml(
schemaTypes,
schemaBlockCount,
schemaInvalidCount,
schemaDuplicateIds,
schemaUnknownTypes,
schemaMissingRequired,
microdataCount,
rdfaCount,
insecureFormActionCount,
@@ -1048,6 +1174,9 @@ export function parseHtml(
pictureCount,
formInputCount,
formInputUnlabeledCount,
hasMain,
skipLinkPresent,
ariaInvalidRolesCount,
headings,
mixedContentCount,
mixedContentActive,
@@ -1294,6 +1423,138 @@ function collectSchemaTypes(node: unknown, out: Set<string>): void {
}
}
/**
* Required-property whitelist for the high-traffic Schema.org types Google
* documents validation rules for. Source:
* <https://developers.google.com/search/docs/appearance/structured-data>.
* Each entry lists "must be present" properties — for types where Google
* accepts one of several alternatives (e.g. Product needs `image` OR
* `offers`), those are listed in `oneOf` instead.
*
* The check is intentionally permissive: a node passes validation if it
* declares one of the listed `@type` values AND every required property
* is present (truthy). Additional properties are fine. Types not in this
* map are skipped — we don't want to raise false positives for niche
* types (Movie, ComicBook, …) where the page may legitimately ship a
* minimal subset.
*/
const SCHEMA_REQUIRED_PROPS: Record<
string,
{ required: string[]; oneOf?: string[][] }
> = {
Article: { required: ['headline', 'image', 'datePublished'] },
NewsArticle: { required: ['headline', 'image', 'datePublished'] },
BlogPosting: { required: ['headline', 'image', 'datePublished'] },
Product: { required: ['name'], oneOf: [['image', 'offers']] },
BreadcrumbList: { required: ['itemListElement'] },
Recipe: {
required: ['name', 'image'],
oneOf: [['recipeIngredient', 'recipeInstructions']],
},
Event: { required: ['name', 'startDate', 'location'] },
FAQPage: { required: ['mainEntity'] },
HowTo: { required: ['name', 'step'] },
Organization: { required: [], oneOf: [['name', 'url']] },
VideoObject: { required: ['name', 'thumbnailUrl', 'uploadDate'] },
};
/**
* Walk a parsed JSON-LD payload and accumulate three structured-data
* health signals into the caller's tallies / map:
*
* - **Duplicate `@id`** — populated by recording every `@id` value
* into `idCounts`. The caller computes the "duplicates" count as
* `Σ(count > 1 ? count - 1 : 0)` after walking every block on the
* page. We do this in the caller (not here) so a `@id` shared
* across multiple `<script>` tags is still detected.
* - **Unknown / malformed `@type`** — `@type` strings that look
* malformed: empty, contain whitespace, start with a lowercase
* letter (Schema.org convention is PascalCase), or contain
* characters outside `[A-Za-z0-9_]`. This catches typos like
* `"product"` (lowercase) or `"News Article"` (whitespace) without
* us needing to ship the full ~750-entry Schema.org taxonomy.
* - **Missing required props** — when a node's `@type` matches an
* entry in `SCHEMA_REQUIRED_PROPS`, every entry in `required` and
* at least one entry of each `oneOf[i]` group must be present.
*/
function walkSchemaForValidation(
node: unknown,
idCounts: Map<string, number>,
): { unknownTypeCount: number; missingRequiredCount: number } {
let unknownTypeCount = 0;
let missingRequiredCount = 0;
const visit = (n: unknown): void => {
if (!n) return;
if (Array.isArray(n)) {
for (const item of n) visit(item);
return;
}
if (typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
// Record `@id` for cross-block duplicate detection.
const idVal = obj['@id'];
if (typeof idVal === 'string' && idVal) {
idCounts.set(idVal, (idCounts.get(idVal) ?? 0) + 1);
}
// Validate `@type` shape and check required props for known types.
const typeRaw = obj['@type'];
const types: string[] = [];
if (typeof typeRaw === 'string') types.push(typeRaw);
else if (Array.isArray(typeRaw)) {
for (const t of typeRaw) if (typeof t === 'string') types.push(t);
}
for (const t of types) {
// Malformed shapes — Schema.org PascalCase convention.
if (
!t ||
/\s/.test(t) ||
!/^[A-Z][A-Za-z0-9_]*$/.test(t)
) {
unknownTypeCount++;
continue;
}
// Required-prop check for the curated high-traffic types.
const rule = SCHEMA_REQUIRED_PROPS[t];
if (!rule) continue;
for (const key of rule.required) {
if (!isTruthyProp(obj[key])) {
missingRequiredCount++;
break; // one failure per node, not per missing prop
}
}
if (rule.oneOf) {
for (const group of rule.oneOf) {
if (!group.some((k) => isTruthyProp(obj[k]))) {
missingRequiredCount++;
break;
}
}
}
}
// Recurse into nested objects/arrays (e.g. `@graph`, `mainEntity`).
for (const value of Object.values(obj)) {
if (value && (typeof value === 'object' || Array.isArray(value))) {
visit(value);
}
}
};
visit(node);
return { unknownTypeCount, missingRequiredCount };
}
function isTruthyProp(v: unknown): boolean {
if (v === undefined || v === null) return false;
if (typeof v === 'string') return v.trim().length > 0;
if (Array.isArray(v)) return v.length > 0;
if (typeof v === 'object') return Object.keys(v as object).length > 0;
return true;
}
function detectPathType(rawHref: string): LinkPathType {
const h = rawHref.trim();
if (/^https?:\/\//i.test(h)) return 'absolute';
+6
View File
@@ -118,6 +118,12 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
'imagesLazy',
'imagesResponsive',
'pictureCount',
'landmarkMain',
'skipLinkPresent',
'ariaInvalidRoles',
'schemaDuplicateIds',
'schemaUnknownTypes',
'schemaMissingRequired',
'jsOnlyLinksCount',
'textCodeRatio',
'fleschReadingEase',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"main": "./dist/index.js",
+75
View File
@@ -1136,6 +1136,81 @@ const MIGRATIONS: Migration[] = [
db.exec('ALTER TABLE host_certs ADD COLUMN chain_subjects TEXT');
},
},
{
version: 52,
name: 'add_a11y_landmark_skiplink_aria_columns',
// V1 Faz 2 ilk batch — accessibility / a11y issue filter columns:
// - `landmark_main` 0/1 — page has `<main>` element OR
// `role="main"` somewhere. Pages
// without a main landmark fail WCAG
// 1.3.1 (info-and-relationships) and
// break screen-reader navigation.
// - `skip_link_present` 0/1 — first interactive element in
// `<body>` is an in-page skip link
// (`<a href="#main">…</a>` style).
// WCAG 2.4.1 (bypass blocks).
// - `aria_invalid_roles` count of `role="…"` attribute values
// that aren't part of the WAI-ARIA
// role taxonomy. Common typos
// ("buttn", "navagation") and legacy
// made-up roles surface here.
up: (db) => {
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
name: string;
}[];
const has = (n: string) => cols.some((c) => c.name === n);
if (!has('landmark_main'))
db.exec(
'ALTER TABLE urls ADD COLUMN landmark_main INTEGER NOT NULL DEFAULT 0',
);
if (!has('skip_link_present'))
db.exec(
'ALTER TABLE urls ADD COLUMN skip_link_present INTEGER NOT NULL DEFAULT 0',
);
if (!has('aria_invalid_roles'))
db.exec(
'ALTER TABLE urls ADD COLUMN aria_invalid_roles INTEGER NOT NULL DEFAULT 0',
);
},
},
{
version: 53,
name: 'add_structured_data_validation_columns',
// V1 Faz 2 batch 2 — structured-data validation:
// - `schema_duplicate_ids` count of `@id` values that appear
// more than once across the page's JSON-LD blocks. Two
// entities sharing an `@id` collide in Google's knowledge
// graph; a CMS bug typically caused by a copy-pasted block.
// - `schema_unknown_types` count of `@type` values that look
// malformed: empty string, whitespace inside, lowercase first
// letter (Schema.org convention is PascalCase), or otherwise
// non-conforming. Catches typos like `"product"` (should be
// `"Product"`) without needing a full ~750-entry taxonomy.
// - `schema_missing_required` count of JSON-LD nodes whose
// declared `@type` is one of the high-traffic types
// (Article / NewsArticle / Product / BreadcrumbList / Recipe
// / Event / FAQPage / HowTo / Organization / VideoObject)
// and which is missing one or more Google-documented
// required properties.
up: (db) => {
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
name: string;
}[];
const has = (n: string) => cols.some((c) => c.name === n);
if (!has('schema_duplicate_ids'))
db.exec(
'ALTER TABLE urls ADD COLUMN schema_duplicate_ids INTEGER NOT NULL DEFAULT 0',
);
if (!has('schema_unknown_types'))
db.exec(
'ALTER TABLE urls ADD COLUMN schema_unknown_types INTEGER NOT NULL DEFAULT 0',
);
if (!has('schema_missing_required'))
db.exec(
'ALTER TABLE urls ADD COLUMN schema_missing_required INTEGER NOT NULL DEFAULT 0',
);
},
},
];
export function runMigrations(db: DatabaseSync): void {
+221 -14
View File
@@ -159,6 +159,12 @@ interface UrlRowDb {
manifest_display: string | null;
manifest_scope: string | null;
manifest_icon_count: number;
landmark_main: number;
skip_link_present: number;
aria_invalid_roles: number;
schema_duplicate_ids: number;
schema_unknown_types: number;
schema_missing_required: number;
}
interface ImageRowDb {
@@ -280,6 +286,12 @@ export interface UpsertUrlInput {
ogSiteName?: string | null;
ogLocale?: string | null;
androidIcon?: string | null;
landmarkMain?: number;
skipLinkPresent?: number;
ariaInvalidRoles?: number;
schemaDuplicateIds?: number;
schemaUnknownTypes?: number;
schemaMissingRequired?: number;
/** JSON-stringified outline array, or null. */
headings?: string | null;
/** Raw `Server` response header, or null when absent. */
@@ -344,7 +356,9 @@ const UPSERT_URL_SQL = `
flesch_reading_ease, flesch_kincaid_grade, gunning_fog_index, sentence_count, complex_word_count,
cors_allow_origin, cors_allow_credentials, cors_allow_methods, cors_allow_headers,
images_responsive, picture_count,
url_malformed, og_type, og_url, og_site_name, og_locale, android_icon
url_malformed, og_type, og_url, og_site_name, og_locale, android_icon,
landmark_main, skip_link_present, aria_invalid_roles,
schema_duplicate_ids, schema_unknown_types, schema_missing_required
) VALUES (
:url, :content_kind, :status_code, :status_text, :indexability, :indexability_reason,
:title, :title_length, :meta_description, :meta_description_length,
@@ -380,7 +394,9 @@ const UPSERT_URL_SQL = `
:flesch_reading_ease, :flesch_kincaid_grade, :gunning_fog_index, :sentence_count, :complex_word_count,
:cors_allow_origin, :cors_allow_credentials, :cors_allow_methods, :cors_allow_headers,
:images_responsive, :picture_count,
:url_malformed, :og_type, :og_url, :og_site_name, :og_locale, :android_icon
:url_malformed, :og_type, :og_url, :og_site_name, :og_locale, :android_icon,
:landmark_main, :skip_link_present, :aria_invalid_roles,
:schema_duplicate_ids, :schema_unknown_types, :schema_missing_required
)
ON CONFLICT(url) DO UPDATE SET
content_kind = excluded.content_kind,
@@ -501,6 +517,12 @@ const UPSERT_URL_SQL = `
og_site_name = excluded.og_site_name,
og_locale = excluded.og_locale,
android_icon = excluded.android_icon,
landmark_main = excluded.landmark_main,
skip_link_present = excluded.skip_link_present,
aria_invalid_roles = excluded.aria_invalid_roles,
schema_duplicate_ids = excluded.schema_duplicate_ids,
schema_unknown_types = excluded.schema_unknown_types,
schema_missing_required = excluded.schema_missing_required,
crawled_at = CURRENT_TIMESTAMP
RETURNING id
`;
@@ -608,18 +630,49 @@ export class ProjectDb {
}
reset(): void {
this.db.exec(
`DELETE FROM image_usages;
DELETE FROM images;
DELETE FROM links;
DELETE FROM headers;
DELETE FROM url_sources;
DELETE FROM sitemap_urls;
DELETE FROM urls;
DELETE FROM project_meta;
DELETE FROM urls_issues;
DELETE FROM crawl_queue;`,
);
// All deletes wrapped in a single transaction so the writer's
// WAL only fsyncs once at the end instead of after each
// statement. On a 100K-URL project this drops Clear time from
// ~2-3 s to ~300-500 ms because the per-statement fsync is the
// dominant cost in autocommit mode (10 statements × ~150-300 ms
// each on Windows NTFS with WAL).
//
// Trade-off: SQLite's "DELETE FROM table" truncate optimisation
// (which skips per-row work and just frees the b-tree pages)
// only fires in autocommit mode. Inside a transaction it
// degrades to a row-by-row delete. For tables in the millions
// that would be slower, but for the tables touched here the
// fsync dominance still makes the transaction-wrapped variant
// faster overall.
//
// `host_certs` is included so a Clear after a TLS-probe pass
// doesn't leave stale certificate rows that the next crawl
// would skip re-probing for (the unprobedHttpsHosts() filter
// excludes hosts with an existing row).
this.runInTransaction(() => {
this.db.exec(
`DELETE FROM image_usages;
DELETE FROM images;
DELETE FROM links;
DELETE FROM headers;
DELETE FROM url_sources;
DELETE FROM sitemap_urls;
DELETE FROM urls;
DELETE FROM project_meta;
DELETE FROM urls_issues;
DELETE FROM crawl_queue;
DELETE FROM host_certs;`,
);
});
// Reclaim freed pages back to the OS so the project file size
// doesn't keep ballooning across crawl + clear cycles. Cheap
// (single passive checkpoint) compared to a full VACUUM, but
// still meaningfully shrinks the WAL after a 100K-URL crawl.
try {
this.db.exec(`PRAGMA wal_checkpoint(TRUNCATE);`);
} catch {
/* non-fatal — WAL checkpoint can fail if a reader is mid-query */
}
}
/**
@@ -1136,6 +1189,12 @@ export class ProjectDb {
og_site_name: input.ogSiteName ?? null,
og_locale: input.ogLocale ?? null,
android_icon: input.androidIcon ?? null,
landmark_main: input.landmarkMain ?? 0,
skip_link_present: input.skipLinkPresent ?? 0,
aria_invalid_roles: input.ariaInvalidRoles ?? 0,
schema_duplicate_ids: input.schemaDuplicateIds ?? 0,
schema_unknown_types: input.schemaUnknownTypes ?? 0,
schema_missing_required: input.schemaMissingRequired ?? 0,
};
const row = this.stmtUpsertUrl.get(params) as { id: number } | undefined;
@@ -2972,6 +3031,29 @@ export class ProjectDb {
imagesNoResponsive: countWhere(
`${html} AND images_count >= 5 AND (images_responsive * 2) < images_count`,
),
landmarkMainMissing: countWhere(
`${html} AND status_code >= 200 AND status_code < 300
AND indexability = 'indexable' AND landmark_main = 0`,
),
skipLinkMissing: countWhere(
`${html} AND status_code >= 200 AND status_code < 300
AND indexability = 'indexable' AND skip_link_present = 0`,
),
ariaInvalidRole: countWhere(`${html} AND aria_invalid_roles > 0`),
pageTooLargeCritical: countWhere(
`${html} AND content_length > 3145728`,
),
paginationCanonicalConflict: countWhere(
`${html} AND ((pagination_next IS NOT NULL AND pagination_next != '')
OR (pagination_prev IS NOT NULL AND pagination_prev != ''))
AND canonical IS NOT NULL AND canonical != ''
AND canonical != url`,
),
schemaDuplicateId: countWhere(`${html} AND schema_duplicate_ids > 0`),
schemaUnknownType: countWhere(`${html} AND schema_unknown_types > 0`),
schemaMissingRequired: countWhere(
`${html} AND schema_missing_required > 0`,
),
imageBrokenSrc: countWhere(
`${html} AND EXISTS (
SELECT 1 FROM image_usages iu
@@ -4511,6 +4593,12 @@ export class ProjectDb {
manifestDisplay: r.manifest_display ?? null,
manifestScope: r.manifest_scope ?? null,
manifestIconCount: r.manifest_icon_count ?? 0,
landmarkMain: r.landmark_main ?? 0,
skipLinkPresent: r.skip_link_present ?? 0,
ariaInvalidRoles: r.aria_invalid_roles ?? 0,
schemaDuplicateIds: r.schema_duplicate_ids ?? 0,
schemaUnknownTypes: r.schema_unknown_types ?? 0,
schemaMissingRequired: r.schema_missing_required ?? 0,
crawledAt: r.crawled_at,
});
@@ -5516,6 +5604,67 @@ function categoryWhereClause(cat: UrlCategory): string | null {
return `is_external = 0 AND content_kind = 'html'
AND images_count >= 5
AND (images_responsive * 2) < images_count`;
case 'issues:landmark-main-missing':
// WCAG 1.3.1 — indexable HTML 2xx page with no `<main>` element
// and no `role="main"`. Screen-reader users can't skip past the
// repeated nav / header without a main landmark.
return `is_external = 0 AND content_kind = 'html'
AND status_code >= 200 AND status_code < 300
AND indexability = 'indexable'
AND landmark_main = 0`;
case 'issues:skip-link-missing':
// WCAG 2.4.1 (bypass blocks) — indexable HTML 2xx page whose
// first focusable in-page anchor is not a recognised skip link
// (`#main` / `#content` / `#skip…`).
return `is_external = 0 AND content_kind = 'html'
AND status_code >= 200 AND status_code < 300
AND indexability = 'indexable'
AND skip_link_present = 0`;
case 'issues:aria-invalid-role':
// Page has at least one element with a `role="…"` value where
// every space-separated token is outside the WAI-ARIA taxonomy.
// Catches typos like `buttn`, `navagation`, and made-up roles.
return `is_external = 0 AND content_kind = 'html'
AND aria_invalid_roles > 0`;
case 'issues:page-too-large-critical':
// Response body exceeds 3 MB. Page-weight critical tier (the
// existing >1 MB warning catches the inflection; >3 MB is well
// past mobile-bandwidth budgets and almost always indicates
// unbundled media or unminified bundles.)
return `is_external = 0 AND content_kind = 'html'
AND content_length > 3145728`;
case 'issues:pagination-canonical-conflict':
// Page declares `<link rel="next">` or `rel="prev">` AND a
// non-self canonical. Search engines see contradictory signals:
// pagination says "this is part of a series", canonical says
// "this is a duplicate of a different page". Pick one.
return `is_external = 0 AND content_kind = 'html'
AND ((pagination_next IS NOT NULL AND pagination_next != '')
OR (pagination_prev IS NOT NULL AND pagination_prev != ''))
AND canonical IS NOT NULL AND canonical != ''
AND canonical != url`;
case 'issues:schema-duplicate-id':
// Page has at least one `@id` appearing in two or more JSON-LD
// entities. Two entities sharing an `@id` collide in Google's
// knowledge graph; almost always a copy-paste CMS bug.
return `is_external = 0 AND content_kind = 'html'
AND schema_duplicate_ids > 0`;
case 'issues:schema-unknown-type':
// Page has at least one `@type` whose shape is malformed
// (empty / whitespace inside / lowercase first letter / non
// [A-Za-z0-9_] characters). Catches typos like `"product"` or
// `"News Article"` without us shipping the full Schema.org
// taxonomy.
return `is_external = 0 AND content_kind = 'html'
AND schema_unknown_types > 0`;
case 'issues:schema-missing-required':
// Page has at least one JSON-LD node whose `@type` is one of
// the Google-documented high-traffic types (Article / Product
// / Recipe / Event / FAQPage / HowTo / BreadcrumbList /
// Organization / VideoObject) and which is missing one or
// more required properties.
return `is_external = 0 AND content_kind = 'html'
AND schema_missing_required > 0`;
case 'issues:image-broken-src':
// Pages referencing at least one internal image whose HEAD probe
// returned a 4xx/5xx status. Uses the existing `probe_status`
@@ -5908,6 +6057,64 @@ function categoryWhereClause(cat: UrlCategory): string | null {
OR (x_robots_tag IS NOT NULL AND x_robots_tag != '')
OR indexability LIKE 'non-indexable%'
)`;
case 'tab:pagination':
// V1 Faz 3 — Pagination tab. Pages that declare `<link rel="next">`
// or `<link rel="prev">`. Each row exposes the targets via the
// existing `pagination_next` / `pagination_prev` columns plus the
// post-crawl `pagination_sequence_break` flag.
return `is_external = 0 AND content_kind = 'html'
AND (
(pagination_next IS NOT NULL AND pagination_next != '')
OR (pagination_prev IS NOT NULL AND pagination_prev != '')
)`;
case 'tab:hreflang':
// V1 Faz 3 — Hreflang tab. Every HTML page that declares at
// least one `<link rel="alternate" hreflang>` entry. The
// `hreflang_count` column gives an at-a-glance density.
return `is_external = 0 AND content_kind = 'html'
AND hreflang_count > 0`;
case 'tab:amp':
// V1 Faz 3 — AMP tab. Pages that declare an `<link rel="amphtml">`
// pointing to an AMP version. The Detail panel's "AMP HTML" field
// exposes the resolved target URL.
return `is_external = 0 AND content_kind = 'html'
AND amphtml IS NOT NULL AND amphtml != ''`;
case 'tab:structured-data':
// V1 Faz 3 — Structured Data tab. Pages with at least one
// JSON-LD block, microdata `[itemscope]`, or RDFa attribute.
// Rolls JSON-LD + microdata + RDFa together so the user sees
// the full structured-data corpus regardless of format.
return `is_external = 0 AND content_kind = 'html'
AND (
schema_block_count > 0
OR microdata_count > 0
OR rdfa_count > 0
)`;
case 'tab:meta-refresh':
// V1 Faz 3 — Meta Refresh tab. Pages that ship a
// `<meta http-equiv="refresh">` directive (whether or not it
// also redirects). The Detail panel's "Meta Refresh" /
// "Meta Refresh URL" rows surface delay + target.
return `is_external = 0 AND content_kind = 'html'
AND meta_refresh IS NOT NULL AND meta_refresh != ''`;
case 'tab:custom-extraction':
// V1 Faz 3 — Custom Extraction tab. Pages where at least one
// custom-extraction rule produced a non-null result. The Detail
// panel's "Extracted Data" sub-tab shows the full JSON map per
// page.
return `is_external = 0 AND content_kind = 'html'
AND extraction_results IS NOT NULL
AND extraction_results != ''
AND extraction_results != '{}'`;
case 'tab:custom-search':
// V1 Faz 3 — Custom Search tab. Pages with at least one custom
// search term hit (literal or regex). `custom_search_hits` is
// a JSON map of `{ term: count }` — non-`{}` content means a
// hit was recorded.
return `is_external = 0 AND content_kind = 'html'
AND custom_search_hits IS NOT NULL
AND custom_search_hits != ''
AND custom_search_hits != '{}'`;
default:
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"main": "./dist/index.js",
+43
View File
@@ -155,6 +155,14 @@ export type UrlCategory =
| 'issues:images-no-lazy-loading'
| 'issues:images-no-responsive'
| 'issues:image-broken-src'
| 'issues:landmark-main-missing'
| 'issues:skip-link-missing'
| 'issues:aria-invalid-role'
| 'issues:page-too-large-critical'
| 'issues:pagination-canonical-conflict'
| 'issues:schema-duplicate-id'
| 'issues:schema-unknown-type'
| 'issues:schema-missing-required'
| 'issues:target-blank-no-noopener'
| 'issues:page-empty'
| 'issues:og-image-not-absolute'
@@ -188,6 +196,13 @@ export type UrlCategory =
| 'tab:redirects'
| 'tab:canonicals'
| 'tab:directives'
| 'tab:pagination'
| 'tab:hreflang'
| 'tab:amp'
| 'tab:structured-data'
| 'tab:meta-refresh'
| 'tab:custom-extraction'
| 'tab:custom-search'
| 'issues:hreflang-inconsistent-lang'
export type Indexability =
@@ -280,6 +295,18 @@ export interface CrawlUrlRow {
manifestScope: string | null;
/** Number of icons in the parsed manifest's `icons` array. */
manifestIconCount: number;
/** 1 when the page has a `<main>` element OR `role="main"`. */
landmarkMain: number;
/** 1 when the first focusable `<a href="#…">` matches a skip-link convention. */
skipLinkPresent: number;
/** Count of `role="…"` attributes with an unknown ARIA token. */
ariaInvalidRoles: number;
/** Surplus occurrences of `@id` across all JSON-LD blocks (duplicates). */
schemaDuplicateIds: number;
/** `@type` values with malformed shape (empty / whitespace / non-PascalCase). */
schemaUnknownTypes: number;
/** Nodes failing required-property check for Article/Product/Recipe/Event/FAQ/HowTo/etc. */
schemaMissingRequired: number;
/** Total user-facing form inputs (input/textarea/select, excluding hidden/submit/button/image/reset). */
formInputCount: number;
/** Form inputs without label / aria-label / title (WCAG 1.3.1, 4.1.2 violation). */
@@ -1055,6 +1082,22 @@ export interface OverviewCounts {
imagesNoLazyLoading: number;
/** Pages with ≥5 images but `srcset`/`<picture>` adoption below 50%. */
imagesNoResponsive: number;
/** Indexable HTML 2xx pages without a `<main>` / `role="main"` landmark. */
landmarkMainMissing: number;
/** Indexable HTML 2xx pages whose first focusable in-page anchor is not a recognised skip link. */
skipLinkMissing: number;
/** Pages with at least one `role="…"` whose every token is unknown to WAI-ARIA 1.2. */
ariaInvalidRole: number;
/** Pages whose response body exceeds 3 MB (critical page-weight tier). */
pageTooLargeCritical: number;
/** Pages declaring `rel=next`/`prev` AND a non-self canonical — pagination signals conflict. */
paginationCanonicalConflict: number;
/** Pages where the same `@id` appears in two or more JSON-LD entities. */
schemaDuplicateId: number;
/** Pages with at least one malformed `@type` (empty / whitespace / non-PascalCase). */
schemaUnknownType: number;
/** Pages where a high-traffic JSON-LD type is missing one or more Google-required properties. */
schemaMissingRequired: number;
/** Pages referencing at least one image whose HEAD probe returned a 4xx/5xx status. */
imageBrokenSrc: number;
/** Pages with at least one `<a target="_blank">` without `rel="noopener"` (reverse-tabnabbing risk). */