mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: a11y + structured-data filters, 7 new tabs, faster clear
This commit is contained in:
1 parent
53a703f63b
commit
d24bde2a2f
21 files changed
+894
-37
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
Generated
+8
-8
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -118,6 +118,12 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
|
||||
'imagesLazy',
|
||||
'imagesResponsive',
|
||||
'pictureCount',
|
||||
'landmarkMain',
|
||||
'skipLinkPresent',
|
||||
'ariaInvalidRoles',
|
||||
'schemaDuplicateIds',
|
||||
'schemaUnknownTypes',
|
||||
'schemaMissingRequired',
|
||||
'jsOnlyLinksCount',
|
||||
'textCodeRatio',
|
||||
'fleschReadingEase',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user