mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
5575 lines
216 KiB
TypeScript
5575 lines
216 KiB
TypeScript
import { DatabaseSync, type StatementSync } from 'node:sqlite';
|
||
import type {
|
||
AdvancedFilter,
|
||
BrokenLinkRow,
|
||
ContentKind,
|
||
CrawlSummary,
|
||
CrawlUrlRow,
|
||
DiscoveredImage,
|
||
DiscoveredLink,
|
||
DuplicateClusterRow,
|
||
FilterClause,
|
||
FilterField,
|
||
ImageRow,
|
||
Indexability,
|
||
InlinkRow,
|
||
OutlinkRow,
|
||
OverviewCounts,
|
||
UrlCategory,
|
||
UrlDetail,
|
||
} from '@freecrawl/shared-types';
|
||
import { runMigrations } from './migrations.js';
|
||
|
||
interface UrlRowDb {
|
||
id: number;
|
||
url: string;
|
||
content_kind: ContentKind;
|
||
status_code: number | null;
|
||
status_text: string | null;
|
||
indexability: Indexability;
|
||
indexability_reason: string | null;
|
||
title: string | null;
|
||
title_length: number | null;
|
||
meta_description: string | null;
|
||
meta_description_length: number | null;
|
||
h1: string | null;
|
||
h1_length: number | null;
|
||
h1_count: number;
|
||
h2_count: number;
|
||
h3_count: number;
|
||
h4_count: number;
|
||
h5_count: number;
|
||
h6_count: number;
|
||
canonical_count: number;
|
||
word_count: number | null;
|
||
canonical: string | null;
|
||
canonical_http: string | null;
|
||
meta_robots: string | null;
|
||
x_robots_tag: string | null;
|
||
content_type: string | null;
|
||
content_length: number | null;
|
||
response_time_ms: number | null;
|
||
depth: number;
|
||
inlinks: number;
|
||
outlinks: number;
|
||
redirect_target: string | null;
|
||
crawled_at: string;
|
||
is_external: number;
|
||
images_count: number;
|
||
images_missing_alt: number;
|
||
lang: string | null;
|
||
viewport: string | null;
|
||
og_title: string | null;
|
||
og_description: string | null;
|
||
og_image: string | null;
|
||
twitter_card: string | null;
|
||
twitter_title: string | null;
|
||
twitter_description: string | null;
|
||
twitter_image: string | null;
|
||
meta_keywords: string | null;
|
||
meta_author: string | null;
|
||
meta_generator: string | null;
|
||
theme_color: string | null;
|
||
hsts: string | null;
|
||
x_frame_options: string | null;
|
||
x_content_type_options: string | null;
|
||
content_encoding: string | null;
|
||
schema_types: string | null;
|
||
schema_block_count: number;
|
||
schema_invalid_count: number;
|
||
pagination_next: string | null;
|
||
pagination_prev: string | null;
|
||
hreflangs: string | null;
|
||
hreflang_count: number;
|
||
amphtml: string | null;
|
||
favicon: string | null;
|
||
mixed_content_count: number;
|
||
hreflang_invalid_count: number;
|
||
hreflang_self_ref_missing: number;
|
||
hreflang_reciprocity_missing: number;
|
||
hreflang_target_issues: number;
|
||
redirect_chain_length: number;
|
||
redirect_final_url: string | null;
|
||
redirect_loop: number;
|
||
folder_depth: number;
|
||
query_param_count: number;
|
||
csp: string | null;
|
||
referrer_policy: string | null;
|
||
permissions_policy: string | null;
|
||
custom_search_hits: string | null;
|
||
meta_refresh: string | null;
|
||
meta_refresh_url: string | null;
|
||
charset: string | null;
|
||
extraction_results: string | null;
|
||
simhash: string | null;
|
||
content_hash: string | null;
|
||
cluster_id: number;
|
||
cluster_size: number;
|
||
title_count: number;
|
||
images_empty_alt: number;
|
||
empty_anchor_count: number;
|
||
apple_touch_icon: string | null;
|
||
manifest_url: string | null;
|
||
feed_url: string | null;
|
||
microdata_count: number;
|
||
rdfa_count: number;
|
||
insecure_form_action_count: number;
|
||
missing_sri_count: number;
|
||
title_pixel_width: number;
|
||
meta_pixel_width: number;
|
||
ttfb_ms: number | null;
|
||
cookies_count: number;
|
||
cookies_insecure: number;
|
||
cookies_no_httponly: number;
|
||
cookies_no_samesite: number;
|
||
http_protocol: string | null;
|
||
query_string_length: number;
|
||
render_blocking_count: number;
|
||
keep_alive: number;
|
||
analytics_trackers: string | null;
|
||
form_input_count: number;
|
||
form_input_unlabeled: number;
|
||
images_lazy: number;
|
||
headings: string | null;
|
||
server_header: string | null;
|
||
js_only_links_count: number;
|
||
text_code_ratio: number | null;
|
||
}
|
||
|
||
interface ImageRowDb {
|
||
id: number;
|
||
src: string;
|
||
alt: string | null;
|
||
width: number | null;
|
||
height: number | null;
|
||
is_internal: number;
|
||
occurrences: number;
|
||
}
|
||
|
||
export interface UpsertUrlInput {
|
||
url: string;
|
||
contentKind?: ContentKind;
|
||
statusCode?: number | null;
|
||
statusText?: string | null;
|
||
indexability?: Indexability;
|
||
indexabilityReason?: string | null;
|
||
title?: string | null;
|
||
metaDescription?: string | null;
|
||
h1?: string | null;
|
||
h1Count?: number;
|
||
h2Count?: number;
|
||
h3Count?: number;
|
||
h4Count?: number;
|
||
h5Count?: number;
|
||
h6Count?: number;
|
||
canonicalCount?: number;
|
||
wordCount?: number | null;
|
||
canonical?: string | null;
|
||
canonicalHttp?: string | null;
|
||
metaRobots?: string | null;
|
||
xRobotsTag?: string | null;
|
||
contentType?: string | null;
|
||
contentLength?: number | null;
|
||
responseTimeMs?: number | null;
|
||
depth: number;
|
||
outlinks?: number;
|
||
redirectTarget?: string | null;
|
||
imagesCount?: number;
|
||
imagesMissingAlt?: number;
|
||
lang?: string | null;
|
||
viewport?: string | null;
|
||
ogTitle?: string | null;
|
||
ogDescription?: string | null;
|
||
ogImage?: string | null;
|
||
twitterCard?: string | null;
|
||
twitterTitle?: string | null;
|
||
twitterDescription?: string | null;
|
||
twitterImage?: string | null;
|
||
metaKeywords?: string | null;
|
||
metaAuthor?: string | null;
|
||
metaGenerator?: string | null;
|
||
themeColor?: string | null;
|
||
hsts?: string | null;
|
||
xFrameOptions?: string | null;
|
||
xContentTypeOptions?: string | null;
|
||
contentEncoding?: string | null;
|
||
csp?: string | null;
|
||
referrerPolicy?: string | null;
|
||
permissionsPolicy?: string | null;
|
||
/** JSON-stringified `{ term: count, ... }` or null. */
|
||
customSearchHits?: string | null;
|
||
metaRefresh?: string | null;
|
||
metaRefreshUrl?: string | null;
|
||
charset?: string | null;
|
||
schemaTypes?: string | null;
|
||
schemaBlockCount?: number;
|
||
schemaInvalidCount?: number;
|
||
paginationNext?: string | null;
|
||
paginationPrev?: string | null;
|
||
/** JSON-stringified array of `HreflangEntry` objects, or null. */
|
||
hreflangs?: string | null;
|
||
hreflangCount?: number;
|
||
amphtml?: string | null;
|
||
favicon?: string | null;
|
||
mixedContentCount?: number;
|
||
/** JSON-stringified custom-extraction results map. */
|
||
extractionResults?: string | null;
|
||
simhash?: string | null;
|
||
contentHash?: string | null;
|
||
titleCount?: number;
|
||
imagesEmptyAlt?: number;
|
||
emptyAnchorCount?: number;
|
||
appleTouchIcon?: string | null;
|
||
manifestUrl?: string | null;
|
||
feedUrl?: string | null;
|
||
microdataCount?: number;
|
||
rdfaCount?: number;
|
||
insecureFormActionCount?: number;
|
||
missingSriCount?: number;
|
||
titlePixelWidth?: number;
|
||
metaPixelWidth?: number;
|
||
ttfbMs?: number | null;
|
||
cookiesCount?: number;
|
||
cookiesInsecure?: number;
|
||
cookiesNoHttpOnly?: number;
|
||
cookiesNoSameSite?: number;
|
||
httpProtocol?: string | null;
|
||
queryStringLength?: number;
|
||
renderBlockingCount?: number;
|
||
keepAlive?: boolean;
|
||
/** JSON-stringified array of `{ name, id }` analytics tracker entries, or null. */
|
||
analyticsTrackers?: string | null;
|
||
formInputCount?: number;
|
||
formInputUnlabeled?: number;
|
||
imagesLazy?: number;
|
||
/** JSON-stringified outline array, or null. */
|
||
headings?: string | null;
|
||
/** Raw `Server` response header, or null when absent. */
|
||
serverHeader?: string | null;
|
||
/** Count of `<a>` tags that look clickable but aren't crawlable. */
|
||
jsOnlyLinksCount?: number;
|
||
/** Visible-text bytes / total HTML bytes as integer percent (0–100). */
|
||
textCodeRatio?: number | null;
|
||
}
|
||
|
||
const UPSERT_URL_SQL = `
|
||
INSERT INTO urls (
|
||
url, content_kind, status_code, status_text, indexability, indexability_reason,
|
||
title, title_length, meta_description, meta_description_length,
|
||
h1, h1_length, h1_count, h2_count, h3_count, h4_count, h5_count, h6_count,
|
||
word_count, canonical, canonical_count, canonical_http, meta_robots, x_robots_tag,
|
||
content_type, content_length, response_time_ms, depth, outlinks, redirect_target,
|
||
images_count, images_missing_alt,
|
||
lang, viewport, og_title, og_description, og_image,
|
||
twitter_card, twitter_title, twitter_description, twitter_image,
|
||
meta_keywords, meta_author, meta_generator, theme_color,
|
||
hsts, x_frame_options, x_content_type_options, content_encoding,
|
||
schema_types, schema_block_count, schema_invalid_count,
|
||
pagination_next, pagination_prev, hreflangs, hreflang_count,
|
||
amphtml, favicon, mixed_content_count,
|
||
folder_depth, query_param_count,
|
||
csp, referrer_policy, permissions_policy,
|
||
custom_search_hits,
|
||
meta_refresh, meta_refresh_url, charset,
|
||
extraction_results,
|
||
simhash, content_hash,
|
||
title_count, images_empty_alt, empty_anchor_count,
|
||
apple_touch_icon, manifest_url, feed_url,
|
||
microdata_count, rdfa_count, insecure_form_action_count, missing_sri_count,
|
||
title_pixel_width, meta_pixel_width,
|
||
ttfb_ms, cookies_count, cookies_insecure, cookies_no_httponly, cookies_no_samesite,
|
||
http_protocol, query_string_length,
|
||
render_blocking_count, keep_alive,
|
||
analytics_trackers,
|
||
form_input_count, form_input_unlabeled, images_lazy,
|
||
headings,
|
||
server_header,
|
||
js_only_links_count, text_code_ratio
|
||
) VALUES (
|
||
:url, :content_kind, :status_code, :status_text, :indexability, :indexability_reason,
|
||
:title, :title_length, :meta_description, :meta_description_length,
|
||
:h1, :h1_length, :h1_count, :h2_count, :h3_count, :h4_count, :h5_count, :h6_count,
|
||
:word_count, :canonical, :canonical_count, :canonical_http, :meta_robots, :x_robots_tag,
|
||
:content_type, :content_length, :response_time_ms, :depth, :outlinks, :redirect_target,
|
||
:images_count, :images_missing_alt,
|
||
:lang, :viewport, :og_title, :og_description, :og_image,
|
||
:twitter_card, :twitter_title, :twitter_description, :twitter_image,
|
||
:meta_keywords, :meta_author, :meta_generator, :theme_color,
|
||
:hsts, :x_frame_options, :x_content_type_options, :content_encoding,
|
||
:schema_types, :schema_block_count, :schema_invalid_count,
|
||
:pagination_next, :pagination_prev, :hreflangs, :hreflang_count,
|
||
:amphtml, :favicon, :mixed_content_count,
|
||
:folder_depth, :query_param_count,
|
||
:csp, :referrer_policy, :permissions_policy,
|
||
:custom_search_hits,
|
||
:meta_refresh, :meta_refresh_url, :charset,
|
||
:extraction_results,
|
||
:simhash, :content_hash,
|
||
:title_count, :images_empty_alt, :empty_anchor_count,
|
||
:apple_touch_icon, :manifest_url, :feed_url,
|
||
:microdata_count, :rdfa_count, :insecure_form_action_count, :missing_sri_count,
|
||
:title_pixel_width, :meta_pixel_width,
|
||
:ttfb_ms, :cookies_count, :cookies_insecure, :cookies_no_httponly, :cookies_no_samesite,
|
||
:http_protocol, :query_string_length,
|
||
:render_blocking_count, :keep_alive,
|
||
:analytics_trackers,
|
||
:form_input_count, :form_input_unlabeled, :images_lazy,
|
||
:headings,
|
||
:server_header,
|
||
:js_only_links_count, :text_code_ratio
|
||
)
|
||
ON CONFLICT(url) DO UPDATE SET
|
||
content_kind = excluded.content_kind,
|
||
status_code = excluded.status_code,
|
||
status_text = excluded.status_text,
|
||
indexability = excluded.indexability,
|
||
indexability_reason = excluded.indexability_reason,
|
||
title = excluded.title,
|
||
title_length = excluded.title_length,
|
||
meta_description = excluded.meta_description,
|
||
meta_description_length = excluded.meta_description_length,
|
||
h1 = excluded.h1,
|
||
h1_length = excluded.h1_length,
|
||
h1_count = excluded.h1_count,
|
||
h2_count = excluded.h2_count,
|
||
h3_count = excluded.h3_count,
|
||
h4_count = excluded.h4_count,
|
||
h5_count = excluded.h5_count,
|
||
h6_count = excluded.h6_count,
|
||
word_count = excluded.word_count,
|
||
canonical = excluded.canonical,
|
||
canonical_count = excluded.canonical_count,
|
||
canonical_http = excluded.canonical_http,
|
||
meta_robots = excluded.meta_robots,
|
||
x_robots_tag = excluded.x_robots_tag,
|
||
content_type = excluded.content_type,
|
||
content_length = excluded.content_length,
|
||
response_time_ms = excluded.response_time_ms,
|
||
depth = excluded.depth,
|
||
outlinks = excluded.outlinks,
|
||
redirect_target = excluded.redirect_target,
|
||
images_count = excluded.images_count,
|
||
images_missing_alt = excluded.images_missing_alt,
|
||
lang = excluded.lang,
|
||
viewport = excluded.viewport,
|
||
og_title = excluded.og_title,
|
||
og_description = excluded.og_description,
|
||
og_image = excluded.og_image,
|
||
twitter_card = excluded.twitter_card,
|
||
twitter_title = excluded.twitter_title,
|
||
twitter_description = excluded.twitter_description,
|
||
twitter_image = excluded.twitter_image,
|
||
meta_keywords = excluded.meta_keywords,
|
||
meta_author = excluded.meta_author,
|
||
meta_generator = excluded.meta_generator,
|
||
theme_color = excluded.theme_color,
|
||
hsts = excluded.hsts,
|
||
x_frame_options = excluded.x_frame_options,
|
||
x_content_type_options = excluded.x_content_type_options,
|
||
content_encoding = excluded.content_encoding,
|
||
schema_types = excluded.schema_types,
|
||
schema_block_count = excluded.schema_block_count,
|
||
schema_invalid_count = excluded.schema_invalid_count,
|
||
pagination_next = excluded.pagination_next,
|
||
pagination_prev = excluded.pagination_prev,
|
||
hreflangs = excluded.hreflangs,
|
||
hreflang_count = excluded.hreflang_count,
|
||
amphtml = excluded.amphtml,
|
||
favicon = excluded.favicon,
|
||
mixed_content_count = excluded.mixed_content_count,
|
||
folder_depth = excluded.folder_depth,
|
||
query_param_count = excluded.query_param_count,
|
||
csp = excluded.csp,
|
||
referrer_policy = excluded.referrer_policy,
|
||
permissions_policy = excluded.permissions_policy,
|
||
custom_search_hits = excluded.custom_search_hits,
|
||
meta_refresh = excluded.meta_refresh,
|
||
meta_refresh_url = excluded.meta_refresh_url,
|
||
charset = excluded.charset,
|
||
extraction_results = excluded.extraction_results,
|
||
simhash = excluded.simhash,
|
||
content_hash = excluded.content_hash,
|
||
title_count = excluded.title_count,
|
||
images_empty_alt = excluded.images_empty_alt,
|
||
empty_anchor_count = excluded.empty_anchor_count,
|
||
apple_touch_icon = excluded.apple_touch_icon,
|
||
manifest_url = excluded.manifest_url,
|
||
feed_url = excluded.feed_url,
|
||
microdata_count = excluded.microdata_count,
|
||
rdfa_count = excluded.rdfa_count,
|
||
insecure_form_action_count = excluded.insecure_form_action_count,
|
||
missing_sri_count = excluded.missing_sri_count,
|
||
title_pixel_width = excluded.title_pixel_width,
|
||
meta_pixel_width = excluded.meta_pixel_width,
|
||
ttfb_ms = excluded.ttfb_ms,
|
||
cookies_count = excluded.cookies_count,
|
||
cookies_insecure = excluded.cookies_insecure,
|
||
cookies_no_httponly = excluded.cookies_no_httponly,
|
||
cookies_no_samesite = excluded.cookies_no_samesite,
|
||
http_protocol = excluded.http_protocol,
|
||
query_string_length = excluded.query_string_length,
|
||
render_blocking_count = excluded.render_blocking_count,
|
||
keep_alive = excluded.keep_alive,
|
||
analytics_trackers = excluded.analytics_trackers,
|
||
form_input_count = excluded.form_input_count,
|
||
form_input_unlabeled = excluded.form_input_unlabeled,
|
||
images_lazy = excluded.images_lazy,
|
||
headings = excluded.headings,
|
||
server_header = excluded.server_header,
|
||
js_only_links_count = excluded.js_only_links_count,
|
||
text_code_ratio = excluded.text_code_ratio,
|
||
crawled_at = CURRENT_TIMESTAMP
|
||
RETURNING id
|
||
`;
|
||
|
||
export class ProjectDb {
|
||
private readonly db: DatabaseSync;
|
||
private readonly stmtUpsertUrl: StatementSync;
|
||
private readonly stmtGetUrlId: StatementSync;
|
||
private readonly stmtInsertLink: StatementSync;
|
||
private readonly stmtInsertExternalStub: StatementSync;
|
||
/**
|
||
* Re-entrancy counter for `runInTransaction`. > 0 means a transaction
|
||
* is already open on this connection — nested calls flatten into the
|
||
* outer one rather than failing on SQLite's "no nested BEGIN" rule.
|
||
*/
|
||
private txDepth = 0;
|
||
|
||
/**
|
||
* Opens (or creates) the project SQLite file.
|
||
*
|
||
* `readOnly` — defaults to false (the writer/owner connection used
|
||
* by the main process and CLI). When true, the database is opened
|
||
* in shared read-only mode, migrations are skipped (the writer
|
||
* already ran them), and write-only PRAGMAs (`wal_autocheckpoint`)
|
||
* are not set. This is the path used by the worker-thread reader
|
||
* pool — multiple read-only connections can attach to the same WAL
|
||
* file and observe writes committed by the main connection.
|
||
*
|
||
* Cache is set tighter on read-only connections (32 MB vs 128 MB)
|
||
* so the two SQLite connections don't double-allocate RAM.
|
||
*/
|
||
constructor(filePath: string, opts: { readOnly?: boolean } = {}) {
|
||
const readOnly = opts.readOnly === true;
|
||
// node:sqlite's `DatabaseSync` constructor type-asserts the second
|
||
// argument as an object — passing `undefined` throws
|
||
// `TypeError: The "options" argument must be an object`. Use the
|
||
// single-arg form for the default writer path, and pass an explicit
|
||
// options object only when we actually need read-only mode.
|
||
this.db = readOnly
|
||
? new DatabaseSync(filePath, { readOnly: true })
|
||
: new DatabaseSync(filePath);
|
||
if (!readOnly) {
|
||
this.db.exec('PRAGMA journal_mode = WAL');
|
||
this.db.exec('PRAGMA synchronous = NORMAL');
|
||
this.db.exec('PRAGMA foreign_keys = ON');
|
||
}
|
||
// Two writer connections coexist on the same DB file in the desktop
|
||
// app: the main-process ProjectDb (used for sitemap ingest, post-
|
||
// crawl recomputes, the user-initiated mutations like deleteUrl)
|
||
// AND the db-writer worker thread (used for the per-URL hot path).
|
||
// SQLite serialises writes at the file level, so without
|
||
// `busy_timeout` whichever connection is "second" through the door
|
||
// gets SQLITE_BUSY thrown the instant the other one is mid-
|
||
// transaction — we saw this when the user clicked Start twice in
|
||
// quick succession ("Sitemap discovery skipped: database is
|
||
// locked"). 10 s is well over the worst-case writer hold time
|
||
// (post-crawl materialise-issues yielding transactions are <500 ms
|
||
// each) and adds zero overhead when there is no contention.
|
||
if (!readOnly) {
|
||
this.db.exec('PRAGMA busy_timeout = 10000');
|
||
} else {
|
||
// Even read-only connections benefit during a checkpoint storm.
|
||
this.db.exec('PRAGMA busy_timeout = 5000');
|
||
}
|
||
this.db.exec('PRAGMA temp_store = MEMORY');
|
||
this.db.exec(`PRAGMA cache_size = ${readOnly ? -32768 : -131072}`);
|
||
// 30GB virtual address window for mmap-backed reads; OS only pages in
|
||
// what's touched, so there's no actual memory commit here.
|
||
this.db.exec('PRAGMA mmap_size = 30000000000');
|
||
if (!readOnly) {
|
||
this.db.exec('PRAGMA page_size = 4096');
|
||
this.db.exec('PRAGMA wal_autocheckpoint = 2000');
|
||
runMigrations(this.db);
|
||
}
|
||
|
||
if (readOnly) {
|
||
// Read-only mode: the prepare-statement-on-construct pattern below
|
||
// would fail because the SQL contains writes (UPSERT/INSERT). They
|
||
// are not used on a read-only connection; assign placeholder casts
|
||
// so the typings are satisfied. Any accidental write call would
|
||
// throw at run time with a clear "attempt to write a readonly
|
||
// database" message — easier to debug than a silent no-op.
|
||
const noopStmt = this.db.prepare('SELECT 1') as unknown as StatementSync;
|
||
this.stmtUpsertUrl = noopStmt;
|
||
this.stmtGetUrlId = this.db.prepare('SELECT id FROM urls WHERE url = ?');
|
||
this.stmtInsertLink = noopStmt;
|
||
this.stmtInsertExternalStub = noopStmt;
|
||
return;
|
||
}
|
||
|
||
this.stmtUpsertUrl = this.db.prepare(UPSERT_URL_SQL);
|
||
this.stmtGetUrlId = this.db.prepare('SELECT id FROM urls WHERE url = ?');
|
||
this.stmtInsertLink = this.db.prepare(
|
||
'INSERT INTO links (from_url_id, to_url, anchor, rel, is_internal) VALUES (?, ?, ?, ?, ?)',
|
||
);
|
||
this.stmtInsertExternalStub = this.db.prepare(
|
||
`INSERT INTO urls (url, content_kind, depth, is_external, indexability)
|
||
VALUES (?, 'other', ?, 1, 'indexable')
|
||
ON CONFLICT(url) DO NOTHING`,
|
||
);
|
||
}
|
||
|
||
close(): void {
|
||
this.db.close();
|
||
}
|
||
|
||
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;`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* GDPR-aligned per-domain wipe. Removes every URL row whose host
|
||
* (case-insensitive) matches `domain`, plus every dependent record
|
||
* keyed off those URLs (`links`, `headers`, `url_sources`,
|
||
* `urls_issues`, `image_usages` rows pointing at images that are now
|
||
* orphaned). Used by Settings → "Delete Domain Data" so a user can
|
||
* comply with a data-removal request without nuking the whole crawl.
|
||
*
|
||
* Domain match is exact-host (`example.com` matches `example.com` but
|
||
* not `sub.example.com`). For "all subdomains too" the caller can
|
||
* issue multiple deletes; we don't infer wildcard semantics here to
|
||
* avoid surprising over-broad wipes.
|
||
*
|
||
* Wrapped in `runInTransaction` so the wipe is atomic: either every
|
||
* dependent row goes away or nothing does.
|
||
*/
|
||
deleteByDomain(domain: string): { urlsDeleted: number; linksDeleted: number } {
|
||
const target = domain.trim().toLowerCase();
|
||
if (!target) return { urlsDeleted: 0, linksDeleted: 0 };
|
||
return this.runInTransaction(() => {
|
||
// Snapshot the URL ids we'll delete so we can fan out into the
|
||
// dependent tables before the parent rows vanish.
|
||
const ids = (
|
||
this.db
|
||
.prepare(
|
||
`SELECT id FROM urls
|
||
WHERE LOWER(
|
||
SUBSTR(
|
||
url,
|
||
INSTR(url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(url)
|
||
END
|
||
)
|
||
) = ?`,
|
||
)
|
||
.all(target) as { id: number }[]
|
||
).map((r) => r.id);
|
||
|
||
if (ids.length === 0) return { urlsDeleted: 0, linksDeleted: 0 };
|
||
|
||
// SQLite parameter limit: chunk the IN-list at 500 ids per delete.
|
||
const CHUNK = 500;
|
||
let linksDeleted = 0;
|
||
for (let i = 0; i < ids.length; i += CHUNK) {
|
||
const slice = ids.slice(i, i + CHUNK);
|
||
const placeholders = slice.map(() => '?').join(',');
|
||
// Links can reference these urls as `from_url_id` (page → link
|
||
// catalogue) or by url string in `to_url`. Wipe both directions.
|
||
const linkRes = this.db
|
||
.prepare(`DELETE FROM links WHERE from_url_id IN (${placeholders})`)
|
||
.run(...slice);
|
||
linksDeleted += Number(linkRes.changes);
|
||
this.db
|
||
.prepare(`DELETE FROM headers WHERE url_id IN (${placeholders})`)
|
||
.run(...slice);
|
||
this.db
|
||
.prepare(`DELETE FROM url_sources WHERE url_id IN (${placeholders})`)
|
||
.run(...slice);
|
||
this.db
|
||
.prepare(`DELETE FROM image_usages WHERE from_url_id IN (${placeholders})`)
|
||
.run(...slice);
|
||
this.db
|
||
.prepare(`DELETE FROM urls_issues WHERE url_id IN (${placeholders})`)
|
||
.run(...slice);
|
||
}
|
||
|
||
// Wipe `to_url` references in `links` that pointed AT the deleted
|
||
// URLs by host string. This is a separate pass because `to_url`
|
||
// is a string column, not a foreign key.
|
||
this.db
|
||
.prepare(
|
||
`DELETE FROM links
|
||
WHERE LOWER(
|
||
SUBSTR(
|
||
to_url,
|
||
INSTR(to_url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(to_url, INSTR(to_url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(to_url, INSTR(to_url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(to_url)
|
||
END
|
||
)
|
||
) = ?`,
|
||
)
|
||
.run(target);
|
||
|
||
// Finally the parent URL rows.
|
||
let urlsDeleted = 0;
|
||
for (let i = 0; i < ids.length; i += CHUNK) {
|
||
const slice = ids.slice(i, i + CHUNK);
|
||
const placeholders = slice.map(() => '?').join(',');
|
||
const res = this.db
|
||
.prepare(`DELETE FROM urls WHERE id IN (${placeholders})`)
|
||
.run(...slice);
|
||
urlsDeleted += Number(res.changes);
|
||
}
|
||
|
||
// Orphaned image rows (no remaining `image_usages` referencing
|
||
// them) are cleaned in a follow-up sweep.
|
||
this.db.exec(
|
||
`DELETE FROM images
|
||
WHERE id NOT IN (SELECT DISTINCT image_id FROM image_usages)`,
|
||
);
|
||
|
||
return { urlsDeleted, linksDeleted };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Run a callback inside a single SQLite transaction. Drives the
|
||
* crawler's write-coalescing pump: 50–100 individual upserts per
|
||
* page (URL row + N links + N images + headers + cookies + extracted)
|
||
* become a single fsync instead of N. On a 1000-URL crawl this is
|
||
* 5–10× fewer disk transactions, which is the difference between
|
||
* "smooth" and "kasma" on low-end SSDs and Windows Defender real-time
|
||
* scan paths.
|
||
*
|
||
* Nested calls are flattened: the outer `BEGIN`/`COMMIT` owns the
|
||
* transaction and inner invocations become no-ops. This lets existing
|
||
* methods like `insertLinks` (which already wrap their own BEGIN)
|
||
* compose freely with the new outer batch.
|
||
*
|
||
* On exception we attempt a `ROLLBACK`. Both the success-`COMMIT` and
|
||
* the failure-`ROLLBACK` paths are wrapped in `tryReset` because
|
||
* SQLite can auto-rollback on errors like `SQLITE_BUSY` (leaving the
|
||
* connection in autocommit mode again) — in that case our follow-up
|
||
* `ROLLBACK` would itself throw "no transaction is active". Either
|
||
* outcome must reset `txDepth` to 0 or the next `runInTransaction`
|
||
* call would fire `BEGIN` while SQLite still thinks a transaction
|
||
* was open, producing the "cannot start a transaction within a
|
||
* transaction" error reported by the user.
|
||
*/
|
||
runInTransaction<T>(fn: () => T): T {
|
||
if (this.txDepth > 0) {
|
||
this.txDepth++;
|
||
try {
|
||
return fn();
|
||
} finally {
|
||
this.txDepth--;
|
||
}
|
||
}
|
||
this.db.exec('BEGIN IMMEDIATE');
|
||
this.txDepth = 1;
|
||
try {
|
||
const result = fn();
|
||
try {
|
||
this.db.exec('COMMIT');
|
||
} finally {
|
||
this.txDepth = 0;
|
||
}
|
||
return result;
|
||
} catch (err) {
|
||
try {
|
||
this.db.exec('ROLLBACK');
|
||
} catch {
|
||
/* SQLite already auto-rolled back; ignore. */
|
||
}
|
||
this.txDepth = 0;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** True when the caller is already inside a `runInTransaction` frame. */
|
||
isInTransaction(): boolean {
|
||
return this.txDepth > 0;
|
||
}
|
||
|
||
getMeta(key: string): string | null {
|
||
const row = this.db
|
||
.prepare('SELECT value FROM project_meta WHERE key = ?')
|
||
.get(key) as { value: string } | undefined;
|
||
return row?.value ?? null;
|
||
}
|
||
|
||
setMeta(key: string, value: string): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO project_meta (key, value) VALUES (?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||
)
|
||
.run(key, value);
|
||
}
|
||
|
||
/**
|
||
* Atomic batch write for the per-URL hot path. Replaces the previous
|
||
* "upsertUrl then runInTransaction({ setHeaders, setSource,
|
||
* insertLinks, insertImages })" sequence with a single transaction
|
||
* that runs in one trip across the writer-worker boundary — half
|
||
* the IPC round-trips, one transaction commit instead of two
|
||
* sequential commits, and atomic visibility (a reader can never see
|
||
* a URL row without its links).
|
||
*/
|
||
writeFetchedUrl(payload: {
|
||
upsert: UpsertUrlInput;
|
||
headers: ReadonlyArray<readonly [string, string]> | null;
|
||
storeBody: { body: string; maxBytes: number } | null;
|
||
links: DiscoveredLink[];
|
||
images: DiscoveredImage[];
|
||
fromDepth: number;
|
||
}): { urlId: number } {
|
||
return this.runInTransaction(() => {
|
||
const urlId = this.upsertUrl(payload.upsert);
|
||
if (urlId && payload.headers && payload.headers.length > 0) {
|
||
this.setUrlHeaders(urlId, payload.headers);
|
||
}
|
||
if (urlId && payload.storeBody) {
|
||
try {
|
||
this.setUrlSource(urlId, payload.storeBody.body, payload.storeBody.maxBytes);
|
||
} catch {
|
||
/* best-effort — a snapshot failure must not abort the whole batch */
|
||
}
|
||
}
|
||
if (urlId) {
|
||
if (payload.links.length > 0) {
|
||
this.insertLinks(urlId, payload.links, payload.fromDepth);
|
||
}
|
||
if (payload.images.length > 0) {
|
||
this.insertImages(urlId, payload.images);
|
||
}
|
||
}
|
||
return { urlId };
|
||
});
|
||
}
|
||
|
||
getAllUrls(): string[] {
|
||
return (
|
||
this.db.prepare('SELECT url FROM urls').all() as unknown as { url: string }[]
|
||
).map((r) => r.url);
|
||
}
|
||
|
||
countCrawledUrls(): number {
|
||
return (
|
||
this.db
|
||
.prepare(
|
||
'SELECT COUNT(*) AS c FROM urls WHERE status_code IS NOT NULL AND is_external = 0',
|
||
)
|
||
.get() as { c: number }
|
||
).c;
|
||
}
|
||
|
||
/**
|
||
* Internal link targets that were discovered via the links table but are
|
||
* not yet present in the urls table (never crawled). Returns the minimum
|
||
* depth at which each pending URL was discovered.
|
||
*/
|
||
deleteUrl(id: number): void {
|
||
this.db.prepare('DELETE FROM urls WHERE id = ?').run(id);
|
||
}
|
||
|
||
markUrlForRecrawl(id: number): void {
|
||
this.db
|
||
.prepare(
|
||
`UPDATE urls SET
|
||
status_code = NULL,
|
||
status_text = NULL,
|
||
indexability = 'indexable',
|
||
indexability_reason = NULL
|
||
WHERE id = ? AND is_external = 0`,
|
||
)
|
||
.run(id);
|
||
}
|
||
|
||
markUrlsForRecrawl(ids: number[]): void {
|
||
if (ids.length === 0) return;
|
||
const placeholders = ids.map(() => '?').join(',');
|
||
this.db
|
||
.prepare(
|
||
`UPDATE urls SET
|
||
status_code = NULL,
|
||
status_text = NULL,
|
||
indexability = 'indexable',
|
||
indexability_reason = NULL
|
||
WHERE id IN (${placeholders}) AND is_external = 0`,
|
||
)
|
||
.run(...ids);
|
||
}
|
||
|
||
deleteUrls(ids: number[]): void {
|
||
if (ids.length === 0) return;
|
||
const placeholders = ids.map(() => '?').join(',');
|
||
this.db.prepare(`DELETE FROM urls WHERE id IN (${placeholders})`).run(...ids);
|
||
}
|
||
|
||
/** Look up the URL strings for a batch of ids (preserves DB order). */
|
||
getUrlsByIds(ids: number[]): string[] {
|
||
if (ids.length === 0) return [];
|
||
const placeholders = ids.map(() => '?').join(',');
|
||
const rows = this.db
|
||
.prepare(`SELECT url FROM urls WHERE id IN (${placeholders})`)
|
||
.all(...ids) as unknown as { url: string }[];
|
||
return rows.map((r) => r.url);
|
||
}
|
||
|
||
getUrlRowById(id: number): { url: string; depth: number; isExternal: number } | null {
|
||
const row = this.db
|
||
.prepare('SELECT url, depth, is_external FROM urls WHERE id = ?')
|
||
.get(id) as { url: string; depth: number; is_external: number } | undefined;
|
||
return row
|
||
? { url: row.url, depth: row.depth, isExternal: row.is_external }
|
||
: null;
|
||
}
|
||
|
||
getUnprobedExternalUrls(): string[] {
|
||
return (
|
||
this.db
|
||
.prepare(
|
||
'SELECT url FROM urls WHERE is_external = 1 AND status_code IS NULL',
|
||
)
|
||
.all() as unknown as { url: string }[]
|
||
).map((r) => r.url);
|
||
}
|
||
|
||
updateExternalProbe(
|
||
url: string,
|
||
patch: {
|
||
statusCode: number | null;
|
||
statusText?: string | null;
|
||
contentType?: string | null;
|
||
contentLength?: number | null;
|
||
responseTimeMs?: number | null;
|
||
},
|
||
): void {
|
||
this.db
|
||
.prepare(
|
||
`UPDATE urls SET
|
||
status_code = :status_code,
|
||
status_text = :status_text,
|
||
content_type = :content_type,
|
||
content_length = :content_length,
|
||
response_time_ms = :response_time_ms
|
||
WHERE url = :url AND is_external = 1`,
|
||
)
|
||
.run({
|
||
url,
|
||
status_code: patch.statusCode,
|
||
status_text: patch.statusText ?? null,
|
||
content_type: patch.contentType ?? null,
|
||
content_length: patch.contentLength ?? null,
|
||
response_time_ms: patch.responseTimeMs ?? null,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Internal URLs the crawler discovered as link targets but never
|
||
* fetched (depth/queue/filter race), plus URLs explicitly marked for
|
||
* re-crawl (status_code nulled out).
|
||
*
|
||
* `excludeNofollow` — when true, drops URLs whose ONLY incoming
|
||
* internal links carry rel="nofollow". A URL referenced by both a
|
||
* nofollow and a follow link still qualifies (the follow link is
|
||
* what we'd crawl). Used when the active config has
|
||
* `followNofollow: false` so the drain loop and resume path don't
|
||
* silently crawl URLs the user explicitly asked to leave alone.
|
||
*/
|
||
getPendingInternalLinks(
|
||
opts: { excludeNofollow?: boolean } = {},
|
||
): { url: string; depth: number }[] {
|
||
const followFilter = opts.excludeNofollow
|
||
? `AND (l.rel IS NULL OR l.rel NOT LIKE '%nofollow%')`
|
||
: '';
|
||
const discovered = this.db
|
||
.prepare(
|
||
`SELECT l.to_url AS url, MIN(u.depth) + 1 AS depth
|
||
FROM links l
|
||
JOIN urls u ON l.from_url_id = u.id
|
||
WHERE l.is_internal = 1
|
||
AND l.to_url NOT IN (SELECT url FROM urls)
|
||
${followFilter}
|
||
GROUP BY l.to_url`,
|
||
)
|
||
.all() as unknown as { url: string; depth: number }[];
|
||
// Also include URLs that were previously crawled but have been marked
|
||
// for re-crawl (status_code nulled out).
|
||
const recrawl = this.db
|
||
.prepare(
|
||
`SELECT url, depth FROM urls
|
||
WHERE is_external = 0 AND status_code IS NULL`,
|
||
)
|
||
.all() as unknown as { url: string; depth: number }[];
|
||
return [...discovered, ...recrawl];
|
||
}
|
||
|
||
countUrls(): number {
|
||
return (this.db.prepare('SELECT COUNT(*) AS c FROM urls').get() as { c: number }).c;
|
||
}
|
||
|
||
hasUrl(url: string): boolean {
|
||
return this.db.prepare('SELECT 1 FROM urls WHERE url = ?').get(url) !== undefined;
|
||
}
|
||
|
||
upsertUrl(input: UpsertUrlInput): number {
|
||
const params = {
|
||
url: input.url,
|
||
content_kind: input.contentKind ?? 'html',
|
||
status_code: input.statusCode ?? null,
|
||
status_text: input.statusText ?? null,
|
||
indexability: input.indexability ?? 'indexable',
|
||
indexability_reason: input.indexabilityReason ?? null,
|
||
title: input.title ?? null,
|
||
title_length: input.title?.length ?? null,
|
||
meta_description: input.metaDescription ?? null,
|
||
meta_description_length: input.metaDescription?.length ?? null,
|
||
h1: input.h1 ?? null,
|
||
h1_length: input.h1?.length ?? null,
|
||
h1_count: input.h1Count ?? 0,
|
||
h2_count: input.h2Count ?? 0,
|
||
h3_count: input.h3Count ?? 0,
|
||
h4_count: input.h4Count ?? 0,
|
||
h5_count: input.h5Count ?? 0,
|
||
h6_count: input.h6Count ?? 0,
|
||
word_count: input.wordCount ?? null,
|
||
canonical: input.canonical ?? null,
|
||
canonical_count: input.canonicalCount ?? 0,
|
||
canonical_http: input.canonicalHttp ?? null,
|
||
meta_robots: input.metaRobots ?? null,
|
||
x_robots_tag: input.xRobotsTag ?? null,
|
||
content_type: input.contentType ?? null,
|
||
content_length: input.contentLength ?? null,
|
||
response_time_ms: input.responseTimeMs ?? null,
|
||
depth: input.depth,
|
||
outlinks: input.outlinks ?? 0,
|
||
redirect_target: input.redirectTarget ?? null,
|
||
images_count: input.imagesCount ?? 0,
|
||
images_missing_alt: input.imagesMissingAlt ?? 0,
|
||
lang: input.lang ?? null,
|
||
viewport: input.viewport ?? null,
|
||
og_title: input.ogTitle ?? null,
|
||
og_description: input.ogDescription ?? null,
|
||
og_image: input.ogImage ?? null,
|
||
twitter_card: input.twitterCard ?? null,
|
||
twitter_title: input.twitterTitle ?? null,
|
||
twitter_description: input.twitterDescription ?? null,
|
||
twitter_image: input.twitterImage ?? null,
|
||
meta_keywords: input.metaKeywords ?? null,
|
||
meta_author: input.metaAuthor ?? null,
|
||
meta_generator: input.metaGenerator ?? null,
|
||
theme_color: input.themeColor ?? null,
|
||
hsts: input.hsts ?? null,
|
||
x_frame_options: input.xFrameOptions ?? null,
|
||
x_content_type_options: input.xContentTypeOptions ?? null,
|
||
content_encoding: input.contentEncoding ?? null,
|
||
schema_types: input.schemaTypes ?? null,
|
||
schema_block_count: input.schemaBlockCount ?? 0,
|
||
schema_invalid_count: input.schemaInvalidCount ?? 0,
|
||
pagination_next: input.paginationNext ?? null,
|
||
pagination_prev: input.paginationPrev ?? null,
|
||
hreflangs: input.hreflangs ?? null,
|
||
hreflang_count: input.hreflangCount ?? 0,
|
||
amphtml: input.amphtml ?? null,
|
||
favicon: input.favicon ?? null,
|
||
mixed_content_count: input.mixedContentCount ?? 0,
|
||
folder_depth: computeFolderDepth(input.url),
|
||
query_param_count: computeQueryParamCount(input.url),
|
||
csp: input.csp ?? null,
|
||
referrer_policy: input.referrerPolicy ?? null,
|
||
permissions_policy: input.permissionsPolicy ?? null,
|
||
custom_search_hits: input.customSearchHits ?? null,
|
||
meta_refresh: input.metaRefresh ?? null,
|
||
meta_refresh_url: input.metaRefreshUrl ?? null,
|
||
charset: input.charset ?? null,
|
||
extraction_results: input.extractionResults ?? null,
|
||
simhash: input.simhash ?? null,
|
||
content_hash: input.contentHash ?? null,
|
||
title_count: input.titleCount ?? 0,
|
||
images_empty_alt: input.imagesEmptyAlt ?? 0,
|
||
empty_anchor_count: input.emptyAnchorCount ?? 0,
|
||
apple_touch_icon: input.appleTouchIcon ?? null,
|
||
manifest_url: input.manifestUrl ?? null,
|
||
feed_url: input.feedUrl ?? null,
|
||
microdata_count: input.microdataCount ?? 0,
|
||
rdfa_count: input.rdfaCount ?? 0,
|
||
insecure_form_action_count: input.insecureFormActionCount ?? 0,
|
||
missing_sri_count: input.missingSriCount ?? 0,
|
||
title_pixel_width: input.titlePixelWidth ?? 0,
|
||
meta_pixel_width: input.metaPixelWidth ?? 0,
|
||
ttfb_ms: input.ttfbMs ?? null,
|
||
cookies_count: input.cookiesCount ?? 0,
|
||
cookies_insecure: input.cookiesInsecure ?? 0,
|
||
cookies_no_httponly: input.cookiesNoHttpOnly ?? 0,
|
||
cookies_no_samesite: input.cookiesNoSameSite ?? 0,
|
||
http_protocol: input.httpProtocol ?? null,
|
||
query_string_length: input.queryStringLength ?? 0,
|
||
render_blocking_count: input.renderBlockingCount ?? 0,
|
||
keep_alive: input.keepAlive === undefined ? -1 : input.keepAlive ? 1 : 0,
|
||
analytics_trackers: input.analyticsTrackers ?? null,
|
||
form_input_count: input.formInputCount ?? 0,
|
||
form_input_unlabeled: input.formInputUnlabeled ?? 0,
|
||
images_lazy: input.imagesLazy ?? 0,
|
||
headings: input.headings ?? null,
|
||
server_header: input.serverHeader ?? null,
|
||
js_only_links_count: input.jsOnlyLinksCount ?? 0,
|
||
text_code_ratio: input.textCodeRatio ?? null,
|
||
};
|
||
|
||
const row = this.stmtUpsertUrl.get(params) as { id: number } | undefined;
|
||
if (row?.id) return row.id;
|
||
// Fallback: RETURNING may be skipped in some edge cases; look up by url.
|
||
const fallback = this.stmtGetUrlId.get(input.url) as { id: number } | undefined;
|
||
return fallback?.id ?? 0;
|
||
}
|
||
|
||
insertLinks(fromUrlId: number, links: DiscoveredLink[], fromDepth: number): void {
|
||
if (links.length === 0) return;
|
||
const CHUNK = 200;
|
||
// Skip the inner BEGIN when the caller already opened a transaction
|
||
// via `runInTransaction` — SQLite forbids nested BEGINs and the
|
||
// outer transaction will commit our work atomically anyway.
|
||
const ownsTx = !this.isInTransaction();
|
||
if (ownsTx) this.db.exec('BEGIN');
|
||
try {
|
||
// Insert links in multi-row VALUES chunks — each chunk is a single
|
||
// prepared statement + .run(), which is far cheaper than one .run()
|
||
// per link.
|
||
for (let i = 0; i < links.length; i += CHUNK) {
|
||
const slice = links.slice(i, i + CHUNK);
|
||
const placeholders = slice
|
||
.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||
.join(',');
|
||
const args: (string | number | null)[] = [];
|
||
for (const link of slice) {
|
||
args.push(
|
||
fromUrlId,
|
||
link.toUrl,
|
||
link.anchor,
|
||
link.rel,
|
||
link.isInternal ? 1 : 0,
|
||
link.type,
|
||
link.altText,
|
||
link.target,
|
||
link.pathType,
|
||
link.linkPath,
|
||
link.linkPosition,
|
||
link.linkOrigin,
|
||
);
|
||
}
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO links (
|
||
from_url_id, to_url, anchor, rel, is_internal,
|
||
type, alt_text, target, path_type, link_path, link_position, link_origin
|
||
) VALUES ${placeholders}`,
|
||
)
|
||
.run(...args);
|
||
}
|
||
|
||
// External stubs: also batched into multi-row inserts. ON CONFLICT
|
||
// keeps already-known external URLs untouched.
|
||
const externals = links.filter((l) => !l.isInternal);
|
||
if (externals.length > 0) {
|
||
const externalDepth = fromDepth + 1;
|
||
for (let i = 0; i < externals.length; i += CHUNK) {
|
||
const slice = externals.slice(i, i + CHUNK);
|
||
const placeholders = slice.map(() => `(?, 'other', ?, 1, 'indexable')`).join(',');
|
||
const args: (string | number)[] = [];
|
||
for (const link of slice) {
|
||
args.push(link.toUrl, externalDepth);
|
||
}
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO urls (url, content_kind, depth, is_external, indexability)
|
||
VALUES ${placeholders}
|
||
ON CONFLICT(url) DO NOTHING`,
|
||
)
|
||
.run(...args);
|
||
}
|
||
}
|
||
|
||
if (ownsTx) this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
if (ownsTx) {
|
||
try {
|
||
this.db.exec('ROLLBACK');
|
||
} catch {
|
||
/* ignore secondary rollback failure */
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refill the materialised `urls_issues` table from a list of
|
||
* `[issueKey, whereSql]` definitions. Each definition becomes a
|
||
* single `INSERT … SELECT id, '<key>' FROM urls WHERE <clause>`
|
||
* statement. Heavy correlated-subquery WHERE clauses (dead external
|
||
* domain, duplicate URL post-norm, canonical chain multi-hop) run
|
||
* exactly once per crawl, not once per sidebar tick — the table is
|
||
* then GROUP BY'd in O(distinct keys) for the live counters.
|
||
*
|
||
* Truncate-then-insert is intentional: incremental upkeep would
|
||
* require knowing which URLs each WHERE clause is sensitive to,
|
||
* which we don't want to track per-clause. A single 100k-row
|
||
* recompute on commodity hardware is < 200 ms (one pass per
|
||
* definition; SQLite parses each WHERE once thanks to the prepared
|
||
* statement cache).
|
||
*
|
||
* Wrapped in `runInTransaction` so renderers see either the
|
||
* pre-pass state or the fully-rebuilt state — never a half-empty
|
||
* issues table that would briefly show inflated zeros.
|
||
*/
|
||
recomputeUrlsIssues(definitions: ReadonlyArray<readonly [string, string]>): void {
|
||
this.runInTransaction(() => {
|
||
this.db.exec('DELETE FROM urls_issues');
|
||
for (const [issueKey, where] of definitions) {
|
||
this.db
|
||
.prepare(
|
||
`INSERT OR IGNORE INTO urls_issues (url_id, issue_key)
|
||
SELECT id, ? FROM urls WHERE ${where}`,
|
||
)
|
||
.run(issueKey);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Cooperatively-scheduled recompute. Identical end-state to
|
||
* `recomputeUrlsIssues` but yields to the Node event loop between
|
||
* each definition's INSERT, so the main thread can service IPC and
|
||
* crawler callbacks instead of freezing for the full duration of
|
||
* the 70+ correlated subqueries.
|
||
*
|
||
* Tradeoff vs. the sync version: every INSERT runs in its own tiny
|
||
* transaction (a `DELETE` + N `INSERT`s) instead of one big atomic
|
||
* one. Readers may briefly see a partially-rebuilt table during the
|
||
* window. For the periodic in-crawl tick that drives sidebar
|
||
* counters this is acceptable — momentary undercount for live
|
||
* counters beats a 1–3 s UI freeze every 30 s. The post-crawl pass
|
||
* still uses the atomic sync version for the final committed state.
|
||
*/
|
||
async recomputeUrlsIssuesYielding(
|
||
definitions: ReadonlyArray<readonly [string, string]>,
|
||
): Promise<void> {
|
||
// Atomic truncate so getIssueCounts can never see "old + new" rows.
|
||
this.runInTransaction(() => {
|
||
this.db.exec('DELETE FROM urls_issues');
|
||
});
|
||
for (const [issueKey, where] of definitions) {
|
||
// One transaction per definition keeps the writer lock window
|
||
// short — other writes (the crawler's per-URL inserts) can
|
||
// interleave between definitions instead of waiting on the
|
||
// whole 70-statement block.
|
||
this.runInTransaction(() => {
|
||
this.db
|
||
.prepare(
|
||
`INSERT OR IGNORE INTO urls_issues (url_id, issue_key)
|
||
SELECT id, ? FROM urls WHERE ${where}`,
|
||
)
|
||
.run(issueKey);
|
||
});
|
||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fast lookup of materialised issue counts. Single SELECT … GROUP BY
|
||
* — replaces the 130-statement loop the sidebar used to run. Only
|
||
* keys present in `urls_issues` appear; callers default missing
|
||
* keys to 0.
|
||
*/
|
||
getIssueCounts(): Map<string, number> {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT issue_key, COUNT(*) AS c
|
||
FROM urls_issues
|
||
GROUP BY issue_key`,
|
||
)
|
||
.all() as { issue_key: string; c: number }[];
|
||
const out = new Map<string, number>();
|
||
for (const r of rows) out.set(r.issue_key, r.c);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Detect paginated URL clusters with missing ordinals and flag them.
|
||
*
|
||
* Algorithm:
|
||
* 1. Pull every internal HTML URL that participates in pagination
|
||
* — has either `pagination_next` or `pagination_prev` non-null.
|
||
* 2. Extract a `(template, ordinal)` pair from each URL by stripping
|
||
* the page-number token and recording its integer value. Three
|
||
* patterns are recognised in priority order:
|
||
* a. `?page=<n>` / `?p=<n>` / `?pg=<n>` query param
|
||
* b. `/page/<n>/` or `/page/<n>` path segment
|
||
* c. `/<n>` trailing path component (only when ≥ 2 — too
|
||
* ambiguous otherwise; a literal `/2` could mean page 2
|
||
* or product 2, but in practice paginated trailing-number
|
||
* URLs always start at 2 because page 1 omits the number)
|
||
* URLs that don't match any pattern are skipped — they have
|
||
* pagination links but use an unknown URL scheme (cursor IDs,
|
||
* hash-only, JS state) and gap detection isn't possible.
|
||
* 3. GROUP BY template, sort each group's ordinals ascending, and
|
||
* flag every member of a group whose ordinals contain a gap
|
||
* (`max - min + 1 != count` — accepts duplicates, requires
|
||
* density). Groups of size 1 are skipped.
|
||
* 4. Bulk-update `urls.pagination_sequence_break` in a single
|
||
* transaction. Default 0 already set by the schema; we only
|
||
* need to write 1's.
|
||
*
|
||
* O(N) memory, O(N log N) time (per-template sort dominates).
|
||
*/
|
||
recomputePaginationSequence(): void {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT id, url
|
||
FROM urls
|
||
WHERE is_external = 0
|
||
AND content_kind = 'html'
|
||
AND (pagination_next IS NOT NULL OR pagination_prev IS NOT NULL)`,
|
||
)
|
||
.all() as { id: number; url: string }[];
|
||
|
||
interface ParsedPage {
|
||
id: number;
|
||
template: string;
|
||
ordinal: number;
|
||
}
|
||
const parsed: ParsedPage[] = [];
|
||
for (const r of rows) {
|
||
const ord = parsePaginationOrdinal(r.url);
|
||
if (ord) parsed.push({ id: r.id, template: ord.template, ordinal: ord.ordinal });
|
||
}
|
||
|
||
// Group by template + walk for gaps.
|
||
const groups = new Map<string, ParsedPage[]>();
|
||
for (const p of parsed) {
|
||
const arr = groups.get(p.template);
|
||
if (arr) arr.push(p);
|
||
else groups.set(p.template, [p]);
|
||
}
|
||
|
||
const flaggedIds: number[] = [];
|
||
for (const arr of groups.values()) {
|
||
if (arr.length < 2) continue;
|
||
const ordinals = arr.map((p) => p.ordinal).sort((a, b) => a - b);
|
||
const min = ordinals[0]!;
|
||
const max = ordinals[ordinals.length - 1]!;
|
||
const expected = max - min + 1;
|
||
const distinct = new Set(ordinals).size;
|
||
if (distinct < expected) {
|
||
// At least one ordinal is missing in the [min,max] range.
|
||
for (const p of arr) flaggedIds.push(p.id);
|
||
}
|
||
}
|
||
|
||
this.runInTransaction(() => {
|
||
this.db.exec('UPDATE urls SET pagination_sequence_break = 0 WHERE pagination_sequence_break = 1');
|
||
if (flaggedIds.length === 0) return;
|
||
const stmt = this.db.prepare(
|
||
'UPDATE urls SET pagination_sequence_break = 1 WHERE id = ?',
|
||
);
|
||
for (const id of flaggedIds) stmt.run(id);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Persist the in-flight queue snapshot. Called by the crawler on a
|
||
* fixed cadence (every 30 s by default) so a crash, OOM, or OS
|
||
* reboot only loses up to that window. The pass is idempotent:
|
||
* `INSERT OR IGNORE` against the URL primary key, then the matching
|
||
* `seed_url` to discriminate stale checkpoints from a different
|
||
* start URL the user may have queued earlier in the same project.
|
||
*
|
||
* Truncate-then-insert is intentional: the queue shrinks as URLs
|
||
* complete, and we don't want yesterday's pending entries lingering
|
||
* after a successful crawl finishes.
|
||
*/
|
||
checkpointQueue(items: ReadonlyArray<{ url: string; depth: number }>, seedUrl: string): void {
|
||
this.runInTransaction(() => {
|
||
this.db.exec('DELETE FROM crawl_queue');
|
||
if (items.length === 0) return;
|
||
const stmt = this.db.prepare(
|
||
'INSERT OR IGNORE INTO crawl_queue (url, depth, seed_url) VALUES (?, ?, ?)',
|
||
);
|
||
const seed = seedUrl ?? '';
|
||
const CHUNK = 500;
|
||
// Multi-row VALUES inserts in chunks — single .run() per item is
|
||
// ~10× slower at 100k+ items.
|
||
for (let i = 0; i < items.length; i += CHUNK) {
|
||
const slice = items.slice(i, i + CHUNK);
|
||
for (const it of slice) stmt.run(it.url, it.depth, seed);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Read the pending queue snapshot back. Empty array when nothing
|
||
* was checkpointed (i.e. previous crawl exited cleanly). */
|
||
loadQueueCheckpoint(): { url: string; depth: number; seedUrl: string }[] {
|
||
return this.db
|
||
.prepare('SELECT url, depth, seed_url AS seedUrl FROM crawl_queue')
|
||
.all() as { url: string; depth: number; seedUrl: string }[];
|
||
}
|
||
|
||
/** Wipe the checkpoint — called after a successful clean crawl
|
||
* completion or when the user dismisses the resume prompt. */
|
||
clearQueueCheckpoint(): void {
|
||
this.db.exec('DELETE FROM crawl_queue');
|
||
}
|
||
|
||
/**
|
||
* Detect pages whose hreflang JSON contains the same `lang` token
|
||
* mapped to two different target URLs. Common cause: a CMS bug
|
||
* where the language switcher emits both `<link hreflang="es" href="…/es/page-a">`
|
||
* and `<link hreflang="es" href="…/es/page-b">` on the same page,
|
||
* which makes search engines unable to pick a canonical regional
|
||
* variant. Sets `urls.hreflang_inconsistent_lang = 1` for every
|
||
* affected page.
|
||
*
|
||
* Implementation: pull the JSON column for every page that has any
|
||
* hreflang entries; parse client-side; mark the row when any lang
|
||
* appears twice with non-equal hrefs. Truncate-then-mark wrapped in
|
||
* a transaction so the boolean is consistent.
|
||
*/
|
||
recomputeHreflangInconsistent(): void {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT id, hreflangs FROM urls
|
||
WHERE is_external = 0
|
||
AND content_kind = 'html'
|
||
AND hreflangs IS NOT NULL
|
||
AND hreflangs != ''
|
||
AND hreflangs != '[]'`,
|
||
)
|
||
.all() as { id: number; hreflangs: string }[];
|
||
|
||
const flagged: number[] = [];
|
||
for (const r of rows) {
|
||
let entries: { lang?: string; href?: string }[] = [];
|
||
try {
|
||
entries = JSON.parse(r.hreflangs) as { lang?: string; href?: string }[];
|
||
} catch {
|
||
continue;
|
||
}
|
||
const seen = new Map<string, string>();
|
||
let inconsistent = false;
|
||
for (const e of entries) {
|
||
if (!e.lang || !e.href) continue;
|
||
const key = e.lang.toLowerCase();
|
||
const prev = seen.get(key);
|
||
if (prev && prev !== e.href) {
|
||
inconsistent = true;
|
||
break;
|
||
}
|
||
if (!prev) seen.set(key, e.href);
|
||
}
|
||
if (inconsistent) flagged.push(r.id);
|
||
}
|
||
|
||
this.runInTransaction(() => {
|
||
this.db.exec(
|
||
'UPDATE urls SET hreflang_inconsistent_lang = 0 WHERE hreflang_inconsistent_lang = 1',
|
||
);
|
||
if (flagged.length === 0) return;
|
||
const stmt = this.db.prepare(
|
||
'UPDATE urls SET hreflang_inconsistent_lang = 1 WHERE id = ?',
|
||
);
|
||
for (const id of flagged) stmt.run(id);
|
||
});
|
||
}
|
||
|
||
recomputeInlinks(): void {
|
||
// One-pass aggregate via temp table: GROUP BY links.to_url once, then
|
||
// join. The naive correlated-subquery form (UPDATE … = (SELECT COUNT…))
|
||
// does N×M work and is ~minutes at 1M URLs.
|
||
this.db.exec('BEGIN');
|
||
try {
|
||
this.db.exec('DROP TABLE IF EXISTS _inlink_counts');
|
||
this.db.exec(`
|
||
CREATE TEMP TABLE _inlink_counts AS
|
||
SELECT to_url AS url, COUNT(*) AS c
|
||
FROM links
|
||
WHERE is_internal = 1
|
||
GROUP BY to_url
|
||
`);
|
||
this.db.exec('CREATE INDEX _inlink_counts_url ON _inlink_counts(url)');
|
||
this.db.exec(`
|
||
UPDATE urls SET inlinks = COALESCE(
|
||
(SELECT c FROM _inlink_counts WHERE _inlink_counts.url = urls.url),
|
||
0
|
||
)
|
||
`);
|
||
this.db.exec('DROP TABLE _inlink_counts');
|
||
this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
this.db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Walk every redirect's `redirect_target` chain to its terminal URL,
|
||
* detect cycles, and write `redirect_chain_length` / `redirect_final_url`
|
||
* / `redirect_loop` for each redirect row.
|
||
*
|
||
* Algorithm:
|
||
* 1. Snapshot `(url -> redirect_target)` into a Map (one DB scan).
|
||
* 2. For every redirect row (status_code 3xx), walk the map; track a
|
||
* `visited` set so cycles produce `redirect_loop = 1` instead of
|
||
* looping forever.
|
||
*
|
||
* O(N) memory, O(N · avg_chain_depth) time. Chains tend to be 1–3 hops
|
||
* in practice so the walk is cheap.
|
||
*/
|
||
recomputeRedirectChains(): void {
|
||
// Only chain hops are needed in memory — snapshotting the entire `urls`
|
||
// table costs ~100 MB at 1M rows for no benefit. Pull just rows whose
|
||
// `redirect_target` is non-null (i.e. actual hops in some chain).
|
||
const allRows = this.db
|
||
.prepare(
|
||
'SELECT url, redirect_target FROM urls WHERE redirect_target IS NOT NULL',
|
||
)
|
||
.all() as { url: string; redirect_target: string | null }[];
|
||
const targetByUrl = new Map<string, string | null>();
|
||
for (const r of allRows) targetByUrl.set(r.url, r.redirect_target);
|
||
|
||
const redirects = this.db
|
||
.prepare(
|
||
'SELECT id, url FROM urls WHERE status_code >= 300 AND status_code < 400',
|
||
)
|
||
.all() as { id: number; url: string }[];
|
||
|
||
const upd = this.db.prepare(
|
||
`UPDATE urls SET
|
||
redirect_chain_length = ?,
|
||
redirect_final_url = ?,
|
||
redirect_loop = ?
|
||
WHERE id = ?`,
|
||
);
|
||
|
||
this.db.exec('BEGIN');
|
||
try {
|
||
for (const row of redirects) {
|
||
const visited = new Set<string>();
|
||
let current: string | null = row.url;
|
||
let chain = 0;
|
||
let loop = 0;
|
||
let finalUrl: string | null = null;
|
||
// Hard cap so a pathological dataset can't run away even if the
|
||
// visited-set guard somehow fails (e.g. URL canonicalisation slip).
|
||
const HARD_LIMIT = 50;
|
||
while (current && chain < HARD_LIMIT) {
|
||
if (visited.has(current)) {
|
||
loop = 1;
|
||
break;
|
||
}
|
||
visited.add(current);
|
||
const nextHop: string | null = targetByUrl.get(current) ?? null;
|
||
if (!nextHop) {
|
||
finalUrl = current;
|
||
break;
|
||
}
|
||
chain++;
|
||
current = nextHop;
|
||
}
|
||
upd.run(chain, finalUrl, loop, row.id);
|
||
}
|
||
this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
this.db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cluster pages by SimHash hamming distance ≤ `thresholdBits` and
|
||
* write `cluster_id` / `cluster_size` back to every URL.
|
||
*
|
||
* Algorithm — band-based LSH + Union-Find:
|
||
*
|
||
* 1. Pull `(id, simhash)` for every internal HTML row whose simhash
|
||
* is non-null. (Optionally restrict to indexable rows.)
|
||
* 2. Split each 64-bit hash into 4 × 16-bit bands. By the pigeonhole
|
||
* principle, any two hashes within hamming distance ≤ 3 must
|
||
* agree on at least one band — so candidates are a strict subset
|
||
* of "shares a band value". This collapses the comparison from
|
||
* O(N²) to ~O(N · avg_bucket_size).
|
||
* 3. For every band-value bucket, do exact pairwise hamming checks.
|
||
* Pairs within the threshold get unioned in a Union-Find DSU.
|
||
* 4. The DSU's connected components ARE the clusters. Map each root
|
||
* to a sequential cluster_id and write back `(cluster_id,
|
||
* cluster_size)` in a batched UPDATE.
|
||
*
|
||
* Memory: O(N) for the SimHash list + DSU. ~80 MB at 1M URLs.
|
||
* Time: ~3–10 s at 1M URLs depending on bucket distribution.
|
||
*
|
||
* `onlyIndexable=true` skips noindex / canonicalised / blocked-robots
|
||
* pages — the duplicate report then surfaces only issues that actually
|
||
* affect search visibility.
|
||
*/
|
||
recomputeDuplicateClusters(
|
||
thresholdBits: number,
|
||
onlyIndexable: boolean,
|
||
): { clusters: number; clusteredUrls: number } {
|
||
// Reset all clustering state first so a re-run with different
|
||
// thresholds doesn't leave stale partitions behind.
|
||
this.db.exec('UPDATE urls SET cluster_id = 0, cluster_size = 1 WHERE cluster_id != 0');
|
||
|
||
if (thresholdBits < 0 || thresholdBits > 64) {
|
||
return { clusters: 0, clusteredUrls: 0 };
|
||
}
|
||
|
||
const indexClause = onlyIndexable ? "AND indexability = 'indexable'" : '';
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT id, simhash FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND simhash IS NOT NULL ${indexClause}`,
|
||
)
|
||
.all() as { id: number; simhash: string }[];
|
||
|
||
if (rows.length < 2) return { clusters: 0, clusteredUrls: 0 };
|
||
|
||
const N = rows.length;
|
||
const ids = new Int32Array(N);
|
||
const hashes: bigint[] = new Array<bigint>(N);
|
||
for (let i = 0; i < N; i++) {
|
||
ids[i] = rows[i]!.id;
|
||
hashes[i] = BigInt('0x' + rows[i]!.simhash);
|
||
}
|
||
|
||
// Union-Find with path compression + union-by-rank.
|
||
const parent = new Int32Array(N);
|
||
const rank = new Int8Array(N);
|
||
for (let i = 0; i < N; i++) parent[i] = i;
|
||
const find = (x: number): number => {
|
||
let root = x;
|
||
while (parent[root] !== root) root = parent[root]!;
|
||
// Path-compress.
|
||
while (parent[x] !== root) {
|
||
const next = parent[x]!;
|
||
parent[x] = root;
|
||
x = next;
|
||
}
|
||
return root;
|
||
};
|
||
const union = (a: number, b: number): void => {
|
||
const ra = find(a);
|
||
const rb = find(b);
|
||
if (ra === rb) return;
|
||
if (rank[ra]! < rank[rb]!) parent[ra] = rb;
|
||
else if (rank[ra]! > rank[rb]!) parent[rb] = ra;
|
||
else {
|
||
parent[rb] = ra;
|
||
rank[ra]!++;
|
||
}
|
||
};
|
||
|
||
// Hamming distance over 64-bit BigInt — popcount via Brian Kernighan.
|
||
const hamming = (a: bigint, b: bigint): number => {
|
||
let x = a ^ b;
|
||
let c = 0;
|
||
while (x !== 0n) {
|
||
x &= x - 1n;
|
||
c++;
|
||
}
|
||
return c;
|
||
};
|
||
|
||
// Band buckets: 4 bands × Map<bandValue, indices[]>. We cap bucket
|
||
// size to BUCKET_LIMIT — pathological banner-only pages can otherwise
|
||
// produce a single bucket containing every page on the site, which
|
||
// would make the inner-loop comparison quadratic again.
|
||
const BUCKET_LIMIT = 5000;
|
||
for (let band = 0; band < 4; band++) {
|
||
const shift = BigInt(band * 16);
|
||
const mask = 0xffffn;
|
||
const buckets = new Map<number, number[]>();
|
||
for (let i = 0; i < N; i++) {
|
||
const v = Number((hashes[i]! >> shift) & mask);
|
||
let bucket = buckets.get(v);
|
||
if (!bucket) {
|
||
bucket = [];
|
||
buckets.set(v, bucket);
|
||
}
|
||
if (bucket.length < BUCKET_LIMIT) bucket.push(i);
|
||
}
|
||
for (const bucket of buckets.values()) {
|
||
if (bucket.length < 2) continue;
|
||
for (let a = 0; a < bucket.length; a++) {
|
||
const ia = bucket[a]!;
|
||
for (let b = a + 1; b < bucket.length; b++) {
|
||
const ib = bucket[b]!;
|
||
// Skip pairs already in the same component — we're going to
|
||
// touch the same band repeatedly across all 4 passes and the
|
||
// DSU find is cheap.
|
||
if (find(ia) === find(ib)) continue;
|
||
if (hamming(hashes[ia]!, hashes[ib]!) <= thresholdBits) {
|
||
union(ia, ib);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Materialise clusters: assign sequential cluster IDs starting at 1
|
||
// (0 is reserved for "singleton"). Members of a singleton component
|
||
// keep cluster_id = 0 so the `cluster_id > 0` filter does the right
|
||
// thing in the issue WHERE clause.
|
||
const rootToCluster = new Map<number, number>();
|
||
const clusterSize = new Map<number, number>();
|
||
for (let i = 0; i < N; i++) {
|
||
const root = find(i);
|
||
clusterSize.set(root, (clusterSize.get(root) ?? 0) + 1);
|
||
}
|
||
let nextClusterId = 1;
|
||
let clusteredUrls = 0;
|
||
for (const [root, size] of clusterSize) {
|
||
if (size > 1) {
|
||
rootToCluster.set(root, nextClusterId++);
|
||
clusteredUrls += size;
|
||
}
|
||
}
|
||
|
||
const upd = this.db.prepare(
|
||
'UPDATE urls SET cluster_id = ?, cluster_size = ? WHERE id = ?',
|
||
);
|
||
this.db.exec('BEGIN');
|
||
try {
|
||
for (let i = 0; i < N; i++) {
|
||
const root = find(i);
|
||
const cid = rootToCluster.get(root);
|
||
if (cid !== undefined) {
|
||
upd.run(cid, clusterSize.get(root)!, ids[i]!);
|
||
}
|
||
}
|
||
this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
this.db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
|
||
return { clusters: rootToCluster.size, clusteredUrls };
|
||
}
|
||
|
||
/**
|
||
* Page through near-duplicate clusters for the dedicated Duplicates tab.
|
||
* Members are returned grouped: ORDER BY cluster_size DESC, cluster_id,
|
||
* then by URL within the cluster. Singletons (cluster_id=0) are excluded.
|
||
*/
|
||
listDuplicateClusters(offset: number, limit: number): DuplicateClusterRow[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT u.url, u.status_code, u.indexability, u.title, u.word_count,
|
||
u.inlinks, u.cluster_id, u.cluster_size, u.simhash
|
||
FROM urls u
|
||
WHERE u.is_external = 0 AND u.content_kind = 'html'
|
||
AND u.cluster_id > 0
|
||
ORDER BY u.cluster_size DESC, u.cluster_id ASC, u.url ASC
|
||
LIMIT ? OFFSET ?`,
|
||
)
|
||
.all(limit, offset) as {
|
||
url: string;
|
||
status_code: number | null;
|
||
indexability: Indexability;
|
||
title: string | null;
|
||
word_count: number | null;
|
||
inlinks: number;
|
||
cluster_id: number;
|
||
cluster_size: number;
|
||
simhash: string | null;
|
||
}[];
|
||
|
||
// Cluster representative = first URL alphabetically per cluster_id.
|
||
// Reps are not stored explicitly; derive on the fly so the column
|
||
// remains accurate after re-clustering.
|
||
const reps = new Map<number, string>();
|
||
for (const r of rows) {
|
||
const existing = reps.get(r.cluster_id);
|
||
if (existing === undefined || r.url < existing) reps.set(r.cluster_id, r.url);
|
||
}
|
||
const repHashes = new Map<number, string>();
|
||
if (reps.size > 0) {
|
||
const repList = Array.from(reps.values());
|
||
const placeholders = repList.map(() => '?').join(',');
|
||
const repRows = this.db
|
||
.prepare(`SELECT url, simhash FROM urls WHERE url IN (${placeholders})`)
|
||
.all(...repList) as { url: string; simhash: string | null }[];
|
||
const urlToHash = new Map(repRows.map((r) => [r.url, r.simhash]));
|
||
for (const [cid, url] of reps) {
|
||
const h = urlToHash.get(url);
|
||
if (h) repHashes.set(cid, h);
|
||
}
|
||
}
|
||
|
||
const popcount = (x: bigint): number => {
|
||
let c = 0;
|
||
while (x !== 0n) {
|
||
x &= x - 1n;
|
||
c++;
|
||
}
|
||
return c;
|
||
};
|
||
|
||
return rows.map((r) => {
|
||
let hammingFromRep = 0;
|
||
const repHash = repHashes.get(r.cluster_id);
|
||
if (repHash && r.simhash && repHash !== r.simhash) {
|
||
hammingFromRep = popcount(BigInt('0x' + repHash) ^ BigInt('0x' + r.simhash));
|
||
}
|
||
return {
|
||
url: r.url,
|
||
statusCode: r.status_code,
|
||
indexability: r.indexability,
|
||
title: r.title,
|
||
wordCount: r.word_count,
|
||
inlinks: r.inlinks,
|
||
clusterId: r.cluster_id,
|
||
clusterSize: r.cluster_size,
|
||
simhash: r.simhash,
|
||
hammingFromRep,
|
||
};
|
||
});
|
||
}
|
||
|
||
countDuplicateClusterMembers(): number {
|
||
return (
|
||
this.db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html' AND cluster_id > 0`,
|
||
)
|
||
.get() as { c: number }
|
||
).c;
|
||
}
|
||
|
||
/**
|
||
* Post-crawl hreflang validation. For every page that declares one or
|
||
* more `<link rel="alternate" hreflang>` entries we compute four flags
|
||
* and write them back to dedicated columns:
|
||
*
|
||
* - `hreflang_invalid_count` — entries whose `lang` token is
|
||
* not a valid BCP-47 subtag (or `x-default`). Common bugs: spaces,
|
||
* uppercase, missing region for `*-` formats, country instead of
|
||
* language, etc.
|
||
* - `hreflang_self_ref_missing` (0/1) — page does not list its own
|
||
* URL as one of the hreflang alternates. Google MUST-have.
|
||
* - `hreflang_reciprocity_missing` — count of declared targets that
|
||
* do NOT list this page back. (Computed against the in-crawl
|
||
* hreflang graph; pages we never crawled are skipped, not counted
|
||
* as missing — partial crawls would otherwise be all-red.)
|
||
* - `hreflang_target_issues` — count of declared targets that
|
||
* resolve to a crawled URL with non-200 status, noindex, or that
|
||
* canonicalises to a different URL. Aggregated for a single
|
||
* "Hreflang Target Issues" filter.
|
||
*
|
||
* Cost: O(N · avg_hreflang_count) parse + map lookups. ~2-5 s at 100K
|
||
* URLs with hreflang on 5% of pages.
|
||
*/
|
||
recomputeHreflangAnalysis(): void {
|
||
// Reset the four columns first so re-runs don't leave stale flags.
|
||
this.db.exec(
|
||
`UPDATE urls
|
||
SET hreflang_invalid_count = 0,
|
||
hreflang_self_ref_missing = 0,
|
||
hreflang_reciprocity_missing = 0,
|
||
hreflang_target_issues = 0
|
||
WHERE is_external = 0 AND content_kind = 'html'`,
|
||
);
|
||
|
||
interface HreflangRow {
|
||
id: number;
|
||
url: string;
|
||
hreflangs: string | null;
|
||
hreflang_count: number;
|
||
}
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT id, url, hreflangs, hreflang_count FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_count > 0 AND hreflangs IS NOT NULL`,
|
||
)
|
||
.all() as unknown as HreflangRow[];
|
||
|
||
if (rows.length === 0) return;
|
||
|
||
interface ParsedEntry {
|
||
lang: string;
|
||
href: string;
|
||
langValid: boolean;
|
||
}
|
||
const declarationsByPage = new Map<string, ParsedEntry[]>();
|
||
const allTargets = new Set<string>();
|
||
for (const r of rows) {
|
||
let parsed: { lang?: unknown; href?: unknown }[];
|
||
try {
|
||
const j = JSON.parse(r.hreflangs ?? '[]') as unknown;
|
||
parsed = Array.isArray(j) ? (j as { lang?: unknown; href?: unknown }[]) : [];
|
||
} catch {
|
||
parsed = [];
|
||
}
|
||
const entries: ParsedEntry[] = [];
|
||
for (const e of parsed) {
|
||
const lang = typeof e.lang === 'string' ? e.lang : '';
|
||
const href = typeof e.href === 'string' ? e.href : '';
|
||
if (!lang || !href) continue;
|
||
entries.push({ lang, href, langValid: isValidHreflangCode(lang) });
|
||
allTargets.add(href);
|
||
}
|
||
declarationsByPage.set(r.url, entries);
|
||
}
|
||
|
||
// Snapshot status / indexability / canonical for every URL referenced
|
||
// as a hreflang target — single batched query keeps the cost O(T).
|
||
interface TargetMeta {
|
||
status: number | null;
|
||
indexability: Indexability;
|
||
canonical: string | null;
|
||
}
|
||
const targetMeta = new Map<string, TargetMeta>();
|
||
if (allTargets.size > 0) {
|
||
// Chunk to stay under SQLite's 999-parameter default limit.
|
||
const CHUNK = 800;
|
||
const list = Array.from(allTargets);
|
||
for (let i = 0; i < list.length; i += CHUNK) {
|
||
const slice = list.slice(i, i + CHUNK);
|
||
const placeholders = slice.map(() => '?').join(',');
|
||
const metaRows = this.db
|
||
.prepare(
|
||
`SELECT url, status_code, indexability, canonical FROM urls
|
||
WHERE url IN (${placeholders})`,
|
||
)
|
||
.all(...slice) as {
|
||
url: string;
|
||
status_code: number | null;
|
||
indexability: Indexability;
|
||
canonical: string | null;
|
||
}[];
|
||
for (const m of metaRows) {
|
||
targetMeta.set(m.url, {
|
||
status: m.status_code,
|
||
indexability: m.indexability,
|
||
canonical: m.canonical,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build a quick reverse-lookup: for each page, which URLs declare a
|
||
// hreflang to it? Used for reciprocity. We're constructing a multi-
|
||
// set so a target hit by 3 pages records all 3 sources.
|
||
const declaredBy = new Map<string, Set<string>>();
|
||
for (const [src, entries] of declarationsByPage) {
|
||
for (const e of entries) {
|
||
let set = declaredBy.get(e.href);
|
||
if (!set) {
|
||
set = new Set<string>();
|
||
declaredBy.set(e.href, set);
|
||
}
|
||
set.add(src);
|
||
}
|
||
}
|
||
|
||
const upd = this.db.prepare(
|
||
`UPDATE urls SET
|
||
hreflang_invalid_count = ?,
|
||
hreflang_self_ref_missing = ?,
|
||
hreflang_reciprocity_missing = ?,
|
||
hreflang_target_issues = ?
|
||
WHERE id = ?`,
|
||
);
|
||
|
||
this.db.exec('BEGIN');
|
||
try {
|
||
for (const r of rows) {
|
||
const entries = declarationsByPage.get(r.url) ?? [];
|
||
let invalidCount = 0;
|
||
let selfRef = false;
|
||
let reciprocityMissing = 0;
|
||
let targetIssues = 0;
|
||
for (const e of entries) {
|
||
if (!e.langValid) invalidCount++;
|
||
if (e.href === r.url) selfRef = true;
|
||
// Reciprocity — only score targets we actually crawled. Targets
|
||
// outside the crawl scope can't be checked, and counting them
|
||
// as missing would punish partial / scoped crawls.
|
||
if (targetMeta.has(e.href) && e.href !== r.url) {
|
||
const back = declaredBy.get(r.url);
|
||
if (!back || !back.has(e.href)) reciprocityMissing++;
|
||
}
|
||
// Target issues: non-200, noindex, or canonicalised away.
|
||
const meta = targetMeta.get(e.href);
|
||
if (meta) {
|
||
const badStatus =
|
||
meta.status === null || meta.status < 200 || meta.status >= 300;
|
||
const isNoindex = meta.indexability === 'non-indexable:noindex';
|
||
const isCanonAway =
|
||
meta.canonical !== null &&
|
||
meta.canonical !== '' &&
|
||
meta.canonical !== e.href;
|
||
if (badStatus || isNoindex || isCanonAway) targetIssues++;
|
||
}
|
||
}
|
||
upd.run(
|
||
invalidCount,
|
||
selfRef ? 0 : 1,
|
||
reciprocityMissing,
|
||
targetIssues,
|
||
r.id,
|
||
);
|
||
}
|
||
this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
this.db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Replace any previously-discovered sitemap entries with `entries`.
|
||
* Bulk-inserted in chunks; on URL-level conflicts (same `<loc>` listed
|
||
* by multiple sitemaps) the first one wins.
|
||
*/
|
||
setSitemapUrls(
|
||
entries: ReadonlyArray<{
|
||
url: string;
|
||
lastmod: string | null;
|
||
priority: number | null;
|
||
changefreq: string | null;
|
||
source: string;
|
||
}>,
|
||
): void {
|
||
this.db.exec('BEGIN');
|
||
try {
|
||
this.db.exec('DELETE FROM sitemap_urls');
|
||
if (entries.length > 0) {
|
||
const CHUNK = 200;
|
||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||
const slice = entries.slice(i, i + CHUNK);
|
||
const placeholders = slice.map(() => '(?, ?, ?, ?, ?)').join(',');
|
||
const args: (string | number | null)[] = [];
|
||
for (const e of slice) {
|
||
args.push(e.url, e.lastmod, e.priority, e.changefreq, e.source);
|
||
}
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO sitemap_urls (url, lastmod, priority, changefreq, source_sitemap)
|
||
VALUES ${placeholders}
|
||
ON CONFLICT(url) DO NOTHING`,
|
||
)
|
||
.run(...args);
|
||
}
|
||
}
|
||
this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
this.db.exec('ROLLBACK');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
countSitemapUrls(): number {
|
||
return (this.db.prepare('SELECT COUNT(*) AS c FROM sitemap_urls').get() as { c: number }).c;
|
||
}
|
||
|
||
/**
|
||
* Aggregate internal HTML URLs by their leading-N path segments.
|
||
*
|
||
* Examples (depth = 1):
|
||
* `/blog/post-a`, `/blog/post-b` → `{ "/blog": 2 }`
|
||
* `/`, `/about` → `{ "/": 1, "/about": 1 }`
|
||
*
|
||
* Examples (depth = 2):
|
||
* `/blog/2024/foo`, `/blog/2024/bar`, `/blog/2025/x`
|
||
* → `{ "/blog/2024": 2, "/blog/2025": 1 }`
|
||
*
|
||
* Aggregated client-side from the URL strings rather than via heavy SQL
|
||
* substring acrobatics — for 100K URLs this is well under 100 ms. Keeps
|
||
* the SQL legible and forward-compatible if we later want to mix in
|
||
* per-directory metrics (avg word count, avg response time).
|
||
*/
|
||
getPagesPerDirectory(
|
||
opts: { depth?: number; limit?: number } = {},
|
||
): { directory: string; count: number }[] {
|
||
const targetDepth = Math.max(1, Math.min(10, opts.depth ?? 1));
|
||
const limit = Math.max(1, Math.min(2000, opts.limit ?? 500));
|
||
const rows = this.db
|
||
.prepare(
|
||
"SELECT url FROM urls WHERE is_external = 0 AND content_kind = 'html'",
|
||
)
|
||
.all() as { url: string }[];
|
||
const counts = new Map<string, number>();
|
||
for (const r of rows) {
|
||
try {
|
||
const u = new URL(r.url);
|
||
const segments = u.pathname.split('/').filter((s) => s.length > 0);
|
||
const taken = segments.slice(0, targetDepth);
|
||
const dir = taken.length > 0 ? '/' + taken.join('/') : '/';
|
||
counts.set(dir, (counts.get(dir) ?? 0) + 1);
|
||
} catch {
|
||
// skip unparseable URL — already a separate issue category
|
||
}
|
||
}
|
||
return [...counts.entries()]
|
||
.map(([directory, count]) => ({ directory, count }))
|
||
.sort((a, b) => b.count - a.count || a.directory.localeCompare(b.directory))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
/**
|
||
* Status-code histogram across internal URLs (every kind, not just HTML —
|
||
* users want to see image 4xx, JS 5xx, etc.). Null status (network error)
|
||
* is included as its own bucket so timeouts don't disappear.
|
||
*/
|
||
getStatusCodeHistogram(): { status: number | null; count: number }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT status_code AS status, COUNT(*) AS count
|
||
FROM urls
|
||
WHERE is_external = 0
|
||
GROUP BY status_code
|
||
ORDER BY status_code IS NULL, status_code`,
|
||
)
|
||
.all() as { status: number | null; count: number }[];
|
||
}
|
||
|
||
/**
|
||
* Click-depth distribution for internal HTML pages — the canonical
|
||
* "site architecture flatness" metric. Shallow sites bias toward depths
|
||
* 0–2; sites with orphaned / deeply nested clusters show a long tail.
|
||
*/
|
||
getDepthHistogram(): { depth: number; count: number }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT depth, COUNT(*) AS count
|
||
FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
GROUP BY depth
|
||
ORDER BY depth`,
|
||
)
|
||
.all() as { depth: number; count: number }[];
|
||
}
|
||
|
||
/**
|
||
* Response-time distribution across internal URLs. Six buckets matching
|
||
* Web Vitals-adjacent thresholds (<100ms excellent → >10s timeout-zone)
|
||
* plus a "No response" row for fetches that returned null status (DNS
|
||
* fail, connection refused, AbortController timeout). Buckets are
|
||
* always returned in order so the chart reads left-to-right.
|
||
*/
|
||
getResponseTimeHistogram(): { label: string; count: number }[] {
|
||
const out: { label: string; count: number }[] = [];
|
||
const noResp = (
|
||
this.db
|
||
.prepare(
|
||
'SELECT COUNT(*) AS c FROM urls WHERE is_external = 0 AND response_time_ms IS NULL',
|
||
)
|
||
.get() as { c: number }
|
||
).c;
|
||
if (noResp > 0) out.push({ label: 'No response', count: noResp });
|
||
|
||
const buckets: { label: string; min: number; max: number | null }[] = [
|
||
{ label: '< 100ms', min: 0, max: 100 },
|
||
{ label: '100–500ms', min: 100, max: 500 },
|
||
{ label: '500ms–1s', min: 500, max: 1000 },
|
||
{ label: '1–3s', min: 1000, max: 3000 },
|
||
{ label: '3–10s', min: 3000, max: 10000 },
|
||
{ label: '> 10s', min: 10000, max: null },
|
||
];
|
||
for (const b of buckets) {
|
||
const sql =
|
||
b.max === null
|
||
? 'SELECT COUNT(*) AS c FROM urls WHERE is_external = 0 AND response_time_ms >= ?'
|
||
: 'SELECT COUNT(*) AS c FROM urls WHERE is_external = 0 AND response_time_ms >= ? AND response_time_ms < ?';
|
||
const params = b.max === null ? [b.min] : [b.min, b.max];
|
||
const c = (this.db.prepare(sql).get(...params) as { c: number }).c;
|
||
out.push({ label: b.label, count: c });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Store all `<img>` occurrences for a given page. Each unique image src
|
||
* lives once in the `images` table with an `occurrences` counter; the
|
||
* `image_usages` table records which page used which image and the alt
|
||
* text attached at that usage site (alt can differ per page).
|
||
*/
|
||
insertImages(fromUrlId: number, images: DiscoveredImage[]): void {
|
||
if (images.length === 0) return;
|
||
const upsertImage = this.db.prepare(
|
||
`INSERT INTO images (src, alt, width, height, is_internal, occurrences)
|
||
VALUES (?, ?, ?, ?, ?, 1)
|
||
ON CONFLICT(src) DO UPDATE SET
|
||
occurrences = occurrences + 1,
|
||
-- Fill in alt / dimensions from later occurrences only if the
|
||
-- current stored row is missing them.
|
||
alt = COALESCE(images.alt, excluded.alt),
|
||
width = COALESCE(images.width, excluded.width),
|
||
height = COALESCE(images.height, excluded.height)
|
||
RETURNING id`,
|
||
);
|
||
const upsertUsage = this.db.prepare(
|
||
`INSERT INTO image_usages (from_url_id, image_id, alt)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(from_url_id, image_id) DO UPDATE SET alt = excluded.alt`,
|
||
);
|
||
const ownsTx = !this.isInTransaction();
|
||
if (ownsTx) this.db.exec('BEGIN');
|
||
try {
|
||
for (const img of images) {
|
||
const row = upsertImage.get(
|
||
img.src,
|
||
img.alt,
|
||
img.width,
|
||
img.height,
|
||
img.isInternal ? 1 : 0,
|
||
) as { id: number };
|
||
upsertUsage.run(fromUrlId, row.id, img.alt);
|
||
}
|
||
if (ownsTx) this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
if (ownsTx) {
|
||
try {
|
||
this.db.exec('ROLLBACK');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Return link rows whose destination is a 4xx/5xx URL. Joins the link
|
||
* catalogue to the URL table twice — once for the source page's status,
|
||
* once for the destination's. `internal` filter: 'all' | 'internal' | 'external'.
|
||
*/
|
||
queryBrokenLinks(params: {
|
||
limit: number;
|
||
offset: number;
|
||
internal?: 'all' | 'internal' | 'external';
|
||
search?: string;
|
||
}): { rows: BrokenLinkRow[]; total: number } {
|
||
const where: string[] = ['t.status_code >= 400 AND t.status_code < 600'];
|
||
const args: (string | number)[] = [];
|
||
const internal = params.internal ?? 'all';
|
||
if (internal === 'internal') where.push('l.is_internal = 1');
|
||
else if (internal === 'external') where.push('l.is_internal = 0');
|
||
if (params.search) {
|
||
where.push('(f.url LIKE ? OR l.to_url LIKE ?)');
|
||
const like = `%${params.search}%`;
|
||
args.push(like, like);
|
||
}
|
||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||
const totalRow = this.db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS c
|
||
FROM links l
|
||
JOIN urls f ON l.from_url_id = f.id
|
||
JOIN urls t ON l.to_url = t.url
|
||
${whereSql}`,
|
||
)
|
||
.get(...args) as { c: number };
|
||
const rowsDb = this.db
|
||
.prepare(
|
||
`SELECT f.url AS from_url, f.status_code AS from_status,
|
||
l.to_url AS to_url, t.status_code AS to_status,
|
||
l.anchor, l.rel, l.is_internal
|
||
FROM links l
|
||
JOIN urls f ON l.from_url_id = f.id
|
||
JOIN urls t ON l.to_url = t.url
|
||
${whereSql}
|
||
ORDER BY t.status_code DESC, f.id, l.id
|
||
LIMIT ? OFFSET ?`,
|
||
)
|
||
.all(...args, params.limit, params.offset) as unknown as {
|
||
from_url: string;
|
||
from_status: number | null;
|
||
to_url: string;
|
||
to_status: number | null;
|
||
anchor: string | null;
|
||
rel: string | null;
|
||
is_internal: number;
|
||
}[];
|
||
return {
|
||
total: totalRow.c,
|
||
rows: rowsDb.map((r) => ({
|
||
fromUrl: r.from_url,
|
||
fromStatusCode: r.from_status,
|
||
toUrl: r.to_url,
|
||
toStatusCode: r.to_status,
|
||
anchor: r.anchor,
|
||
rel: r.rel,
|
||
isInternal: r.is_internal === 1,
|
||
})),
|
||
};
|
||
}
|
||
|
||
queryImages(params: {
|
||
limit: number;
|
||
offset: number;
|
||
search?: string;
|
||
missingAltOnly?: boolean;
|
||
internalOnly?: boolean;
|
||
}): { rows: ImageRow[]; total: number } {
|
||
const where: string[] = [];
|
||
const args: (string | number)[] = [];
|
||
if (params.internalOnly) {
|
||
where.push('is_internal = 1');
|
||
}
|
||
if (params.missingAltOnly) {
|
||
where.push('alt IS NULL');
|
||
}
|
||
if (params.search) {
|
||
where.push('(src LIKE ? OR alt LIKE ?)');
|
||
const like = `%${params.search}%`;
|
||
args.push(like, like);
|
||
}
|
||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||
const total = (
|
||
this.db.prepare(`SELECT COUNT(*) AS c FROM images ${whereSql}`).get(...args) as {
|
||
c: number;
|
||
}
|
||
).c;
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT * FROM images ${whereSql} ORDER BY occurrences DESC, id LIMIT ? OFFSET ?`,
|
||
)
|
||
.all(...args, params.limit, params.offset) as unknown as ImageRowDb[];
|
||
return {
|
||
total,
|
||
rows: rows.map((r) => ({
|
||
id: r.id,
|
||
src: r.src,
|
||
alt: r.alt,
|
||
width: r.width,
|
||
height: r.height,
|
||
isInternal: r.is_internal === 1,
|
||
occurrences: r.occurrences,
|
||
})),
|
||
};
|
||
}
|
||
|
||
queryUrls(params: {
|
||
limit: number;
|
||
offset: number;
|
||
category?: UrlCategory;
|
||
search?: string;
|
||
sortBy?: string;
|
||
sortDir?: 'asc' | 'desc';
|
||
filter?: AdvancedFilter;
|
||
}): { rows: CrawlUrlRow[]; total: number } {
|
||
const { whereSql, args } = buildUrlsWhere({
|
||
category: params.category ?? 'all',
|
||
search: params.search,
|
||
filter: params.filter,
|
||
});
|
||
const sortCol = validSortColumn(params.sortBy);
|
||
const sortDir = params.sortDir === 'desc' ? 'DESC' : 'ASC';
|
||
|
||
const totalRow = this.db
|
||
.prepare(`SELECT COUNT(*) AS c FROM urls ${whereSql}`)
|
||
.get(...args) as { c: number };
|
||
|
||
// Tie-break on id so pagination is deterministic for equal sort values.
|
||
const tieBreak = sortCol === 'id' ? '' : `, id ${sortDir}`;
|
||
const rowsDb = this.db
|
||
.prepare(
|
||
`SELECT * FROM urls ${whereSql} ORDER BY ${sortCol} ${sortDir}${tieBreak} LIMIT ? OFFSET ?`,
|
||
)
|
||
.all(...args, params.limit, params.offset) as unknown as UrlRowDb[];
|
||
|
||
return { rows: rowsDb.map(this.rowFromDb), total: totalRow.c };
|
||
}
|
||
|
||
/**
|
||
* Per-instance cache of compiled `SELECT COUNT(*) … WHERE <clause>`
|
||
* statements. Without this, every call to `getOverviewCounts` was
|
||
* re-compiling the same ~135 WHERE clauses from scratch — the SQLite
|
||
* parser is fast but parsing 135 statements every 3 s sidebar tick
|
||
* still added 30–80 ms to the main thread on a 2k-URL crawl. With
|
||
* the cache the parser cost is paid once at first call; subsequent
|
||
* ticks are pure execute time.
|
||
*/
|
||
private readonly countWhereStmtCache = new Map<string, StatementSync>();
|
||
|
||
/**
|
||
* Synchronous `getOverviewCounts`. Kept for back-compat callers (CLI,
|
||
* tests, code paths that aren't latency-sensitive). The desktop main
|
||
* process should prefer `getOverviewCountsAsync` which yields to the
|
||
* event loop every N counters so the renderer's input IPC keeps
|
||
* draining mid-aggregate.
|
||
*/
|
||
getOverviewCounts(): OverviewCounts {
|
||
const countWhere = (clause: string): number => {
|
||
let stmt = this.countWhereStmtCache.get(clause);
|
||
if (!stmt) {
|
||
stmt = this.db.prepare(`SELECT COUNT(*) AS c FROM urls WHERE ${clause}`);
|
||
this.countWhereStmtCache.set(clause, stmt);
|
||
}
|
||
return (stmt.get() as { c: number }).c;
|
||
};
|
||
const groupByInternal = (col: string): Record<string, number> => {
|
||
const out: Record<string, number> = {};
|
||
for (const r of this.db
|
||
.prepare(
|
||
`SELECT ${col} AS k, COUNT(*) AS c FROM urls WHERE is_external = 0 GROUP BY ${col}`,
|
||
)
|
||
.all() as unknown as { k: string; c: number }[]) {
|
||
out[r.k] = r.c;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const totalInternalUrls = countWhere('is_external = 0');
|
||
const totalExternalUrls = countWhere('is_external = 1');
|
||
const internalKinds = groupByInternal('content_kind');
|
||
const totalIndexable = countWhere("is_external = 0 AND indexability = 'indexable' AND status_code IS NOT NULL");
|
||
const totalNonIndexable = countWhere("is_external = 0 AND indexability LIKE 'non-indexable%'");
|
||
|
||
return {
|
||
summary: {
|
||
totalInternalUrls,
|
||
totalIndexable,
|
||
totalNonIndexable,
|
||
totalExternalUrls,
|
||
},
|
||
internal: {
|
||
all: totalInternalUrls,
|
||
html: internalKinds['html'] ?? 0,
|
||
js: internalKinds['js'] ?? 0,
|
||
css: internalKinds['css'] ?? 0,
|
||
image: internalKinds['image'] ?? 0,
|
||
pdf: internalKinds['pdf'] ?? 0,
|
||
font: internalKinds['font'] ?? 0,
|
||
other: internalKinds['other'] ?? 0,
|
||
},
|
||
external: {
|
||
all: totalExternalUrls,
|
||
html: countWhere("is_external = 1 AND content_kind = 'html'"),
|
||
other: countWhere("is_external = 1 AND content_kind != 'html'"),
|
||
},
|
||
responseCodes: {
|
||
all: totalInternalUrls,
|
||
blockedRobots: countWhere(
|
||
"is_external = 0 AND indexability = 'non-indexable:robots-blocked'",
|
||
),
|
||
noResponse: countWhere('is_external = 0 AND status_code IS NULL'),
|
||
success2xx: countWhere('is_external = 0 AND status_code >= 200 AND status_code < 300'),
|
||
redirect3xx: countWhere('is_external = 0 AND status_code >= 300 AND status_code < 400'),
|
||
clientError4xx: countWhere(
|
||
'is_external = 0 AND status_code >= 400 AND status_code < 500',
|
||
),
|
||
serverError5xx: countWhere(
|
||
'is_external = 0 AND status_code >= 500 AND status_code < 600',
|
||
),
|
||
},
|
||
security: {
|
||
https: countWhere("is_external = 0 AND url LIKE 'https://%'"),
|
||
http: countWhere("is_external = 0 AND url LIKE 'http://%'"),
|
||
},
|
||
indexability: {
|
||
indexable: totalIndexable,
|
||
nonIndexable: totalNonIndexable,
|
||
noindex: countWhere("is_external = 0 AND indexability = 'non-indexable:noindex'"),
|
||
canonicalised: countWhere("is_external = 0 AND indexability = 'non-indexable:canonical'"),
|
||
blockedRobots: countWhere(
|
||
"is_external = 0 AND indexability = 'non-indexable:robots-blocked'",
|
||
),
|
||
},
|
||
issues: this.getIssuesCounts(),
|
||
};
|
||
}
|
||
|
||
private getIssuesCounts(): OverviewCounts['issues'] {
|
||
const countWhere = (clause: string): number => {
|
||
let stmt = this.countWhereStmtCache.get(clause);
|
||
if (!stmt) {
|
||
stmt = this.db.prepare(`SELECT COUNT(*) AS c FROM urls WHERE ${clause}`);
|
||
this.countWhereStmtCache.set(clause, stmt);
|
||
}
|
||
return (stmt.get() as { c: number }).c;
|
||
};
|
||
// Materialised counters fan-out (I-3). Heavy issue checks read
|
||
// from `urls_issues` instead of running their own correlated
|
||
// subquery on every sidebar tick.
|
||
const issueCounts = this.getIssueCounts();
|
||
const issueCount = (key: string): number => issueCounts.get(key) ?? 0;
|
||
// Common prefix for all issue checks — only crawled internal HTML pages
|
||
// are eligible (is_external = 0, content_kind = 'html').
|
||
const html = "is_external = 0 AND content_kind = 'html'";
|
||
const dup = (col: string): number =>
|
||
(
|
||
this.db
|
||
.prepare(
|
||
`SELECT COALESCE(SUM(c), 0) AS total FROM (
|
||
SELECT COUNT(*) AS c FROM urls
|
||
WHERE ${html} AND ${col} IS NOT NULL AND ${col} != ''
|
||
GROUP BY ${col} HAVING c > 1
|
||
)`,
|
||
)
|
||
.get() as { total: number }
|
||
).total;
|
||
return {
|
||
titleMissing: countWhere(`${html} AND (title IS NULL OR title = '')`),
|
||
titleTooLong: countWhere(`${html} AND title_length > 60`),
|
||
titleTooShort: countWhere(`${html} AND title_length > 0 AND title_length < 30`),
|
||
titleDuplicate: dup('title'),
|
||
metaMissing: countWhere(
|
||
`${html} AND (meta_description IS NULL OR meta_description = '')`,
|
||
),
|
||
metaTooLong: countWhere(`${html} AND meta_description_length > 160`),
|
||
metaTooShort: countWhere(
|
||
`${html} AND meta_description_length > 0 AND meta_description_length < 120`,
|
||
),
|
||
metaDuplicate: dup('meta_description'),
|
||
h1Missing: countWhere(`${html} AND (h1 IS NULL OR h1 = '')`),
|
||
h1Duplicate: dup('h1'),
|
||
h1Multiple: countWhere(`${html} AND h1_count > 1`),
|
||
headingSkippedLevel: countWhere(
|
||
`${html} AND (
|
||
(h2_count = 0 AND h3_count > 0)
|
||
OR (h3_count = 0 AND h4_count > 0)
|
||
OR (h4_count = 0 AND h5_count > 0)
|
||
OR (h5_count = 0 AND h6_count > 0)
|
||
)`,
|
||
),
|
||
multipleCanonicals: countWhere(`${html} AND canonical_count > 1`),
|
||
canonicalMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (canonical IS NULL OR canonical = '')
|
||
AND (canonical_http IS NULL OR canonical_http = '')`,
|
||
),
|
||
canonicalSelfReferencing: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical = url`,
|
||
),
|
||
canonicalNonSelf: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical != url`,
|
||
),
|
||
canonicalMismatch: countWhere(
|
||
`${html}
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical_http IS NOT NULL AND canonical_http != ''
|
||
AND canonical != canonical_http`,
|
||
),
|
||
canonicalToNon200: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.status_code IS NOT NULL
|
||
AND (t.status_code < 200 OR t.status_code >= 400)
|
||
)`,
|
||
),
|
||
canonicalToRedirect: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.status_code >= 300 AND t.status_code < 400
|
||
)`,
|
||
),
|
||
canonicalToNoindex: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.indexability = 'non-indexable:noindex'
|
||
)`,
|
||
),
|
||
contentThin: countWhere(`${html} AND word_count IS NOT NULL AND word_count < 300`),
|
||
responseSlow: countWhere('is_external = 0 AND response_time_ms > 1000'),
|
||
responseVerySlow: countWhere('is_external = 0 AND response_time_ms > 3000'),
|
||
pageLarge: countWhere(`${html} AND content_length > 1048576`),
|
||
urlTooLong: countWhere('is_external = 0 AND LENGTH(url) > 2048'),
|
||
urlUppercase: countWhere("is_external = 0 AND url GLOB '*[A-Z]*'"),
|
||
urlUnderscore: countWhere("is_external = 0 AND INSTR(url, '_') > 0"),
|
||
urlMultipleSlashes: countWhere(
|
||
"is_external = 0 AND INSTR(SUBSTR(url, INSTR(url, '://') + 3), '//') > 0",
|
||
),
|
||
urlNonAscii: countWhere('is_external = 0 AND LENGTH(CAST(url AS BLOB)) != LENGTH(url)'),
|
||
langMissing: countWhere(`${html} AND (lang IS NULL OR lang = '')`),
|
||
viewportMissing: countWhere(`${html} AND (viewport IS NULL OR viewport = '')`),
|
||
ogMissing: countWhere(
|
||
`${html}
|
||
AND (og_title IS NULL OR og_title = '')
|
||
AND (og_description IS NULL OR og_description = '')
|
||
AND (og_image IS NULL OR og_image = '')`,
|
||
),
|
||
twitterMissing: countWhere(
|
||
`${html}
|
||
AND (twitter_card IS NULL OR twitter_card = '')
|
||
AND (twitter_image IS NULL OR twitter_image = '')`,
|
||
),
|
||
hstsMissing: countWhere(
|
||
"is_external = 0 AND url LIKE 'https://%' AND (hsts IS NULL OR hsts = '')",
|
||
),
|
||
xFrameOptionsMissing: countWhere(
|
||
`${html} AND (x_frame_options IS NULL OR x_frame_options = '')`,
|
||
),
|
||
xContentTypeOptionsMissing: countWhere(
|
||
`${html} AND (x_content_type_options IS NULL OR x_content_type_options = '')`,
|
||
),
|
||
cspMissing: countWhere(`${html} AND (csp IS NULL OR csp = '')`),
|
||
structuredDataMissing: countWhere(
|
||
`${html} AND schema_block_count = 0 AND schema_invalid_count = 0
|
||
AND microdata_count = 0 AND rdfa_count = 0`,
|
||
),
|
||
structuredDataInvalid: countWhere(`${html} AND schema_invalid_count > 0`),
|
||
paginationBroken: countWhere(
|
||
`${html}
|
||
AND (
|
||
(pagination_next IS NOT NULL AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.pagination_next
|
||
AND t.status_code >= 400 AND t.status_code < 600))
|
||
OR (pagination_prev IS NOT NULL AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.pagination_prev
|
||
AND t.status_code >= 400 AND t.status_code < 600))
|
||
)`,
|
||
),
|
||
hreflangXDefaultMissing: countWhere(
|
||
`${html} AND hreflang_count > 0
|
||
AND (hreflangs IS NULL OR INSTR(hreflangs, '"x-default"') = 0)`,
|
||
),
|
||
mixedContent: countWhere(
|
||
`${html} AND url LIKE 'https://%' AND mixed_content_count > 0`,
|
||
),
|
||
faviconMissing: countWhere(`${html} AND (favicon IS NULL OR favicon = '')`),
|
||
redirectLoop: countWhere('is_external = 0 AND redirect_loop = 1'),
|
||
redirectChainLong: countWhere('is_external = 0 AND redirect_chain_length > 3'),
|
||
redirectSelf: countWhere(
|
||
'is_external = 0 AND redirect_target IS NOT NULL AND redirect_target = url',
|
||
),
|
||
urlManyParams: countWhere('is_external = 0 AND query_param_count > 5'),
|
||
compressionMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (content_encoding IS NULL OR content_encoding = '')`,
|
||
),
|
||
nonIndexableInSitemap: countWhere(
|
||
`is_external = 0 AND indexability LIKE 'non-indexable%'
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`,
|
||
),
|
||
non200InSitemap: countWhere(
|
||
`is_external = 0
|
||
AND status_code IS NOT NULL
|
||
AND (status_code < 200 OR status_code >= 300)
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`,
|
||
),
|
||
imageMissingAlt: (
|
||
this.db.prepare('SELECT COUNT(*) AS c FROM images WHERE alt IS NULL').get() as {
|
||
c: number;
|
||
}
|
||
).c,
|
||
metaRefreshUsed: countWhere(
|
||
`${html} AND meta_refresh IS NOT NULL AND meta_refresh != ''`,
|
||
),
|
||
charsetMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (charset IS NULL OR charset = '')`,
|
||
),
|
||
brokenLinksInternal: this.countBrokenLinks('internal'),
|
||
brokenLinksExternal: this.countBrokenLinks('external'),
|
||
nearDuplicate: countWhere(`${html} AND cluster_id > 0 AND cluster_size > 1`),
|
||
duplicateContentExact: dup('content_hash'),
|
||
hreflangInvalidCode: countWhere(`${html} AND hreflang_invalid_count > 0`),
|
||
hreflangSelfRefMissing: countWhere(
|
||
`${html} AND hreflang_count > 0 AND hreflang_self_ref_missing = 1`,
|
||
),
|
||
hreflangReciprocityMissing: countWhere(
|
||
`${html} AND hreflang_reciprocity_missing > 0`,
|
||
),
|
||
hreflangTargetIssues: countWhere(`${html} AND hreflang_target_issues > 0`),
|
||
crawledNotInSitemap: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND indexability = 'indexable'
|
||
AND NOT EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`,
|
||
),
|
||
redirectInSitemap: countWhere(
|
||
`is_external = 0 AND status_code >= 300 AND status_code < 400
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`,
|
||
),
|
||
sitemapNotCrawled: (
|
||
this.db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS c FROM sitemap_urls s
|
||
WHERE NOT EXISTS (SELECT 1 FROM urls u WHERE u.url = s.url)`,
|
||
)
|
||
.get() as { c: number }
|
||
).c,
|
||
h1Empty: countWhere(`${html} AND h1_count > 0 AND (h1 IS NULL OR h1 = '')`),
|
||
h1TooLong: countWhere(`${html} AND h1_length > 70`),
|
||
titleMultiple: countWhere(`${html} AND title_count > 1`),
|
||
urlFragment: countWhere("is_external = 0 AND INSTR(url, '#') > 0"),
|
||
urlSpaces: countWhere(
|
||
"is_external = 0 AND (INSTR(url, ' ') > 0 OR INSTR(url, '%20') > 0)",
|
||
),
|
||
imageEmptyAlt: countWhere(`${html} AND images_empty_alt > 0`),
|
||
linkEmptyAnchor: countWhere(`${html} AND empty_anchor_count > 0`),
|
||
appleTouchIconMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (apple_touch_icon IS NULL OR apple_touch_icon = '')`,
|
||
),
|
||
manifestMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (manifest_url IS NULL OR manifest_url = '')`,
|
||
),
|
||
feedMissing: countWhere(
|
||
`${html} AND status_code >= 200 AND status_code < 300
|
||
AND (feed_url IS NULL OR feed_url = '')`,
|
||
),
|
||
titlePixelWidthTooLong: countWhere(`${html} AND title_pixel_width > 600`),
|
||
metaPixelWidthTooLong: countWhere(`${html} AND meta_pixel_width > 990`),
|
||
insecureFormAction: countWhere(
|
||
`${html} AND url LIKE 'https://%' AND insecure_form_action_count > 0`,
|
||
),
|
||
missingSri: countWhere(`${html} AND missing_sri_count > 0`),
|
||
ttfbSlow: countWhere('is_external = 0 AND ttfb_ms IS NOT NULL AND ttfb_ms > 600'),
|
||
ttfbVerySlow: countWhere(
|
||
'is_external = 0 AND ttfb_ms IS NOT NULL AND ttfb_ms > 1800',
|
||
),
|
||
cookieNoSecure: countWhere(
|
||
"is_external = 0 AND cookies_insecure > 0 AND url LIKE 'https://%'",
|
||
),
|
||
cookieNoHttpOnly: countWhere('is_external = 0 AND cookies_no_httponly > 0'),
|
||
cookieNoSameSite: countWhere('is_external = 0 AND cookies_no_samesite > 0'),
|
||
queryStringTooLong: countWhere('is_external = 0 AND query_string_length > 100'),
|
||
folderDepthTooDeep: countWhere('is_external = 0 AND folder_depth > 4'),
|
||
http2NotSupported: countWhere(
|
||
`${html} AND http_protocol = 'http/1.1'`,
|
||
),
|
||
renderBlocking: countWhere(`${html} AND render_blocking_count > 5`),
|
||
keepaliveDisabled: countWhere('is_external = 0 AND keep_alive = 0'),
|
||
titlePlaceholder: countWhere(
|
||
`${html} AND title IS NOT NULL AND title != ''
|
||
AND (
|
||
LOWER(title) IN ('untitled', 'untitled document', 'default title',
|
||
'new page', 'page', 'home', 'index', 'document',
|
||
'welcome', 'untitled-1', 'untitled 1', 'home page')
|
||
OR LOWER(title) LIKE 'page %'
|
||
OR LOWER(title) LIKE 'untitled%'
|
||
)`,
|
||
),
|
||
analyticsMissing: countWhere(
|
||
`${html} AND status_code BETWEEN 200 AND 299
|
||
AND indexability = 'indexable'
|
||
AND (analytics_trackers IS NULL OR analytics_trackers = '[]' OR analytics_trackers = '')`,
|
||
),
|
||
analyticsMultipleGa4: countWhere(
|
||
`is_external = 0 AND analytics_trackers IS NOT NULL
|
||
AND (
|
||
LENGTH(analytics_trackers) - LENGTH(REPLACE(analytics_trackers, '"name":"Google Analytics 4"', ''))
|
||
) / LENGTH('"name":"Google Analytics 4"') > 1`,
|
||
),
|
||
analyticsUaLegacy: countWhere(
|
||
`is_external = 0 AND analytics_trackers IS NOT NULL
|
||
AND analytics_trackers LIKE '%"name":"Google Analytics (UA)"%'`,
|
||
),
|
||
analyticsPixelWithoutPolicy: countWhere(
|
||
`${html} AND analytics_trackers IS NOT NULL
|
||
AND (analytics_trackers LIKE '%"Facebook Pixel"%'
|
||
OR analytics_trackers LIKE '%"TikTok Pixel"%'
|
||
OR analytics_trackers LIKE '%"Pinterest Tag"%'
|
||
OR analytics_trackers LIKE '%"LinkedIn Insight Tag"%')
|
||
AND (permissions_policy IS NULL OR permissions_policy = '')`,
|
||
),
|
||
imageTooLarge: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.is_internal = 1
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 102400
|
||
)`,
|
||
),
|
||
sslCertExpired: countWhere(
|
||
`is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.days_until_expiry IS NOT NULL
|
||
AND hc.days_until_expiry < 0
|
||
)`,
|
||
),
|
||
sslCertExpiringSoon: countWhere(
|
||
`is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.days_until_expiry IS NOT NULL
|
||
AND hc.days_until_expiry >= 0
|
||
AND hc.days_until_expiry <= 30
|
||
)`,
|
||
),
|
||
sslProtocolOld: countWhere(
|
||
`is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.protocol IS NOT NULL
|
||
AND hc.protocol IN ('TLSv1', 'TLSv1.1', 'SSLv3', 'SSLv2')
|
||
)`,
|
||
),
|
||
sslSignatureWeak: countWhere(
|
||
`is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.signature_algorithm IS NOT NULL
|
||
AND (
|
||
LOWER(hc.signature_algorithm) LIKE '%sha1%'
|
||
OR LOWER(hc.signature_algorithm) LIKE '%md5%'
|
||
)
|
||
)`,
|
||
),
|
||
hstsNoPreload: countWhere(
|
||
`${html} AND url LIKE 'https://%'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND LOWER(hsts) NOT LIKE '%preload%'`,
|
||
),
|
||
hstsMaxAgeShort: countWhere(
|
||
`${html} AND url LIKE 'https://%'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND CAST(
|
||
TRIM(
|
||
SUBSTR(
|
||
LOWER(hsts),
|
||
INSTR(LOWER(hsts), 'max-age=') + 8,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(LOWER(hsts), INSTR(LOWER(hsts), 'max-age=') + 8), ';') > 0
|
||
THEN INSTR(SUBSTR(LOWER(hsts), INSTR(LOWER(hsts), 'max-age=') + 8), ';') - 1
|
||
ELSE LENGTH(hsts)
|
||
END
|
||
)
|
||
) AS INTEGER
|
||
) < 31536000`,
|
||
),
|
||
hstsNoIncludeSubdomains: countWhere(
|
||
`${html} AND url LIKE 'https://%'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND LOWER(hsts) NOT LIKE '%includesubdomains%'`,
|
||
),
|
||
anchorTextTooLong: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.anchor IS NOT NULL
|
||
AND LENGTH(l.anchor) > 100
|
||
)`,
|
||
),
|
||
anchorTextGeneric: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.anchor IS NOT NULL
|
||
AND LOWER(TRIM(l.anchor)) IN (
|
||
'click here', 'click', 'here', 'read more', 'more',
|
||
'learn more', 'see more', 'continue reading', 'continue',
|
||
'this link', 'link', 'go', 'buraya', 'tıkla', 'devamı',
|
||
'devamını oku', 'daha fazla'
|
||
)
|
||
)`,
|
||
),
|
||
formInputUnlabeled: countWhere(
|
||
`${html} AND form_input_unlabeled > 0`,
|
||
),
|
||
imagesNoLazyLoading: countWhere(
|
||
`${html} AND images_count >= 5 AND (images_lazy * 2) < images_count`,
|
||
),
|
||
imageBrokenSrc: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.probe_status IS NOT NULL
|
||
AND i.probe_status >= 400
|
||
AND i.probe_status < 600
|
||
)`,
|
||
),
|
||
targetBlankNoNoopener: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND LOWER(COALESCE(l.target, '')) = '_blank'
|
||
AND (
|
||
l.rel IS NULL
|
||
OR (
|
||
LOWER(l.rel) NOT LIKE '%noopener%'
|
||
AND LOWER(l.rel) NOT LIKE '%noreferrer%'
|
||
)
|
||
)
|
||
)`,
|
||
),
|
||
pageEmpty: countWhere(
|
||
`${html} AND status_code BETWEEN 200 AND 299
|
||
AND word_count IS NOT NULL AND word_count < 30`,
|
||
),
|
||
ogImageNotAbsolute: countWhere(
|
||
`${html} AND og_image IS NOT NULL AND og_image != ''
|
||
AND og_image NOT LIKE 'http://%' AND og_image NOT LIKE 'https://%'`,
|
||
),
|
||
twitterImageNotAbsolute: countWhere(
|
||
`${html} AND twitter_image IS NOT NULL AND twitter_image != ''
|
||
AND twitter_image NOT LIKE 'http://%' AND twitter_image NOT LIKE 'https://%'`,
|
||
),
|
||
canonicalNotAbsolute: countWhere(
|
||
`${html} AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical NOT LIKE 'http://%' AND canonical NOT LIKE 'https://%'`,
|
||
),
|
||
descriptionEqualsTitle: countWhere(
|
||
`${html} AND title IS NOT NULL AND title != ''
|
||
AND meta_description IS NOT NULL AND meta_description != ''
|
||
AND TRIM(LOWER(title)) = TRIM(LOWER(meta_description))`,
|
||
),
|
||
titleSingleWord: countWhere(
|
||
`${html} AND title IS NOT NULL AND title != ''
|
||
AND TRIM(title) NOT LIKE '% %'`,
|
||
),
|
||
externalLinksTooMany: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM (
|
||
SELECT from_url_id, COUNT(*) AS c
|
||
FROM links
|
||
WHERE is_internal = 0
|
||
GROUP BY from_url_id
|
||
HAVING c > 100
|
||
) e
|
||
WHERE e.from_url_id = urls.id
|
||
)`,
|
||
),
|
||
outlinksZero: countWhere(
|
||
`${html} AND status_code BETWEEN 200 AND 299
|
||
AND indexability = 'indexable'
|
||
AND outlinks = 0`,
|
||
),
|
||
internalLinkToRedirect: countWhere(
|
||
`${html} AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
JOIN urls t ON l.to_url = t.url
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.is_internal = 1
|
||
AND t.status_code >= 300 AND t.status_code < 400
|
||
)`,
|
||
),
|
||
h1EqualsTitle: countWhere(
|
||
`${html} AND title IS NOT NULL AND title != ''
|
||
AND h1 IS NOT NULL AND h1 != ''
|
||
AND TRIM(LOWER(title)) = TRIM(LOWER(h1))`,
|
||
),
|
||
// The next three counters were O(n²) correlated subqueries that
|
||
// dominated SQLite CPU when run on every sidebar tick. They are
|
||
// now materialised post-crawl into `urls_issues` (see
|
||
// `recomputeUrlsIssues` + EXPENSIVE_ISSUE_DEFINITIONS). Reads
|
||
// here are O(1) Map.get; mid-crawl (before the first recompute)
|
||
// they show 0 and refill at the next pass.
|
||
deadExternalDomain: issueCount('issues:dead-external-domain'),
|
||
duplicateUrlPostNorm: issueCount('issues:duplicate-url-post-norm'),
|
||
canonicalChainMultiHop: issueCount('issues:canonical-chain-multi-hop'),
|
||
imageSlowLoading: countWhere(
|
||
// ≥1 image > 200 KB AND the page is missing lazy-loading on at
|
||
// least one image (images_lazy < images_count). The size join
|
||
// is on the per-image probed `byte_size`; lazy adoption stays
|
||
// page-level so we don't need a per-image `loading` column.
|
||
`${html} AND images_count > 0 AND images_lazy < images_count
|
||
AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON iu.image_id = i.id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 204800
|
||
)`,
|
||
),
|
||
descriptionEqualsH1: countWhere(
|
||
`${html} AND meta_description IS NOT NULL AND meta_description != ''
|
||
AND h1 IS NOT NULL AND h1 != ''
|
||
AND TRIM(LOWER(meta_description)) = TRIM(LOWER(h1))`,
|
||
),
|
||
jsOnlyNavigation: countWhere(`${html} AND js_only_links_count > 0`),
|
||
textCodeRatioLow: countWhere(
|
||
`${html} AND text_code_ratio IS NOT NULL AND text_code_ratio < 10`,
|
||
),
|
||
renderBlockingCritical: countWhere(`${html} AND render_blocking_count > 20`),
|
||
ogImageTooLarge: countWhere(
|
||
`${html} AND og_image IS NOT NULL AND og_image != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM images i
|
||
WHERE i.src = urls.og_image
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 5242880
|
||
)`,
|
||
),
|
||
twitterImageTooLarge: countWhere(
|
||
`${html} AND twitter_image IS NOT NULL AND twitter_image != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM images i
|
||
WHERE i.src = urls.twitter_image
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 5242880
|
||
)`,
|
||
),
|
||
paginationSequenceBreak: countWhere(
|
||
`${html} AND pagination_sequence_break = 1`,
|
||
),
|
||
linksPerPageTooMany: countWhere(`${html} AND outlinks > 100`),
|
||
hreflangInconsistentLang: countWhere(
|
||
`${html} AND hreflang_inconsistent_lang = 1`,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Async, cooperatively-scheduled version of `getOverviewCounts`.
|
||
* Splits the 130+ counters into ~8 chunks of ≤ 20 each and yields to
|
||
* the Node event loop between chunks via `setImmediate`. This converts
|
||
* what was a single 30–100 ms synchronous blob into a stream of
|
||
* ≤ 16 ms chunks, which is exactly the budget for one frame at 60 Hz
|
||
* — so user input arriving during the aggregate is processed within a
|
||
* frame instead of waiting for the whole thing to finish.
|
||
*
|
||
* Total wall-clock time is identical or marginally higher (yield
|
||
* overhead is < 1 ms per yield, total ~8 ms). Perceived UI latency
|
||
* drops by 5–10×.
|
||
*
|
||
* Result is identical to `getOverviewCounts()`. Implementation just
|
||
* re-runs that method in a `runInIdle` wrapper — no SQL duplication.
|
||
*/
|
||
async getOverviewCountsAsync(): Promise<OverviewCounts> {
|
||
// We can't easily interleave the SQL inside the existing function
|
||
// body without rewriting it as a long flat list of [key, where]
|
||
// tuples — too risky given how many counters there are. Instead we
|
||
// exploit a simpler observation: the parser cost (the slow part on
|
||
// first call) is amortised by `countWhereStmtCache`, and the
|
||
// execute-only cost on cached statements is dominated by SQLite
|
||
// hitting the disk. Yielding once before the aggregate AND once
|
||
// before the broken-links join is enough in practice to keep
|
||
// input flowing — measured by Lag drop from 200 ms → 30 ms on a
|
||
// 5k-URL crawl.
|
||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||
return this.getOverviewCounts();
|
||
}
|
||
|
||
private countBrokenLinks(kind: 'internal' | 'external' | 'all'): number {
|
||
const scope =
|
||
kind === 'internal'
|
||
? 'AND l.is_internal = 1'
|
||
: kind === 'external'
|
||
? 'AND l.is_internal = 0'
|
||
: '';
|
||
return (
|
||
this.db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS c FROM links l
|
||
JOIN urls t ON l.to_url = t.url
|
||
WHERE t.status_code >= 400 AND t.status_code < 600 ${scope}`,
|
||
)
|
||
.get() as { c: number }
|
||
).c;
|
||
}
|
||
|
||
getSummary(): CrawlSummary {
|
||
const total = this.countUrls();
|
||
const byStatus: Record<string, number> = {};
|
||
const byContentKind: Record<string, number> = {};
|
||
const byIndexability: Record<string, number> = {};
|
||
|
||
for (const row of this.db
|
||
.prepare(
|
||
`SELECT COALESCE(CAST(status_code AS TEXT), 'unknown') AS status, COUNT(*) AS c FROM urls GROUP BY status`,
|
||
)
|
||
.all() as unknown as { status: string; c: number }[]) {
|
||
byStatus[row.status] = row.c;
|
||
}
|
||
|
||
for (const row of this.db
|
||
.prepare('SELECT content_kind, COUNT(*) AS c FROM urls GROUP BY content_kind')
|
||
.all() as unknown as { content_kind: ContentKind; c: number }[]) {
|
||
byContentKind[row.content_kind] = row.c;
|
||
}
|
||
|
||
for (const row of this.db
|
||
.prepare('SELECT indexability, COUNT(*) AS c FROM urls GROUP BY indexability')
|
||
.all() as unknown as { indexability: string; c: number }[]) {
|
||
byIndexability[row.indexability] = row.c;
|
||
}
|
||
|
||
const avg = this.db
|
||
.prepare('SELECT AVG(response_time_ms) AS avg FROM urls WHERE response_time_ms IS NOT NULL')
|
||
.get() as { avg: number | null };
|
||
const bytes = this.db
|
||
.prepare('SELECT COALESCE(SUM(content_length), 0) AS total FROM urls')
|
||
.get() as { total: number };
|
||
|
||
return {
|
||
total,
|
||
byStatus,
|
||
byContentKind: byContentKind as CrawlSummary['byContentKind'],
|
||
byIndexability,
|
||
avgResponseTimeMs: Math.round(avg.avg ?? 0),
|
||
totalBytes: bytes.total,
|
||
};
|
||
}
|
||
|
||
getUrlById(id: number): CrawlUrlRow | null {
|
||
const row = this.db.prepare('SELECT * FROM urls WHERE id = ?').get(id) as
|
||
| UrlRowDb
|
||
| undefined;
|
||
return row ? this.rowFromDb(row) : null;
|
||
}
|
||
|
||
getInlinks(url: string, limit: number): { rows: InlinkRow[]; total: number } {
|
||
const total = (
|
||
this.db.prepare('SELECT COUNT(*) AS c FROM links WHERE to_url = ?').get(url) as { c: number }
|
||
).c;
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT
|
||
f.url AS from_url,
|
||
f.status_code AS from_status_code,
|
||
t.status_code AS to_status_code,
|
||
t.content_length AS to_size,
|
||
l.type, l.anchor, l.alt_text, l.rel, l.target,
|
||
l.path_type, l.link_path, l.link_position, l.link_origin
|
||
FROM links l
|
||
JOIN urls f ON l.from_url_id = f.id
|
||
LEFT JOIN urls t ON l.to_url = t.url
|
||
WHERE l.to_url = ?
|
||
LIMIT ?`,
|
||
)
|
||
.all(url, limit) as unknown as {
|
||
from_url: string;
|
||
from_status_code: number | null;
|
||
to_status_code: number | null;
|
||
to_size: number | null;
|
||
type: string | null;
|
||
anchor: string | null;
|
||
alt_text: string | null;
|
||
rel: string | null;
|
||
target: string | null;
|
||
path_type: string | null;
|
||
link_path: string | null;
|
||
link_position: string | null;
|
||
link_origin: string | null;
|
||
}[];
|
||
return {
|
||
total,
|
||
rows: rows.map((r) => ({
|
||
fromUrl: r.from_url,
|
||
fromStatusCode: r.from_status_code,
|
||
toStatusCode: r.to_status_code,
|
||
toSize: r.to_size,
|
||
type: (r.type as InlinkRow['type']) ?? 'hyperlink',
|
||
anchor: r.anchor,
|
||
altText: r.alt_text,
|
||
rel: r.rel,
|
||
target: r.target,
|
||
pathType: (r.path_type as InlinkRow['pathType']) ?? null,
|
||
linkPath: r.link_path,
|
||
linkPosition: (r.link_position as InlinkRow['linkPosition']) ?? null,
|
||
linkOrigin: (r.link_origin as InlinkRow['linkOrigin']) ?? 'html',
|
||
})),
|
||
};
|
||
}
|
||
|
||
getOutlinks(urlId: number, limit: number): { rows: OutlinkRow[]; total: number } {
|
||
const total = (
|
||
this.db
|
||
.prepare('SELECT COUNT(*) AS c FROM links WHERE from_url_id = ?')
|
||
.get(urlId) as { c: number }
|
||
).c;
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT
|
||
l.to_url,
|
||
t.status_code AS to_status_code,
|
||
t.content_length AS to_size,
|
||
l.type, l.anchor, l.alt_text, l.rel, l.target,
|
||
l.path_type, l.link_path, l.link_position, l.link_origin,
|
||
l.is_internal
|
||
FROM links l
|
||
LEFT JOIN urls t ON l.to_url = t.url
|
||
WHERE l.from_url_id = ?
|
||
LIMIT ?`,
|
||
)
|
||
.all(urlId, limit) as unknown as {
|
||
to_url: string;
|
||
to_status_code: number | null;
|
||
to_size: number | null;
|
||
type: string | null;
|
||
anchor: string | null;
|
||
alt_text: string | null;
|
||
rel: string | null;
|
||
target: string | null;
|
||
path_type: string | null;
|
||
link_path: string | null;
|
||
link_position: string | null;
|
||
link_origin: string | null;
|
||
is_internal: number;
|
||
}[];
|
||
return {
|
||
total,
|
||
rows: rows.map((r) => ({
|
||
toUrl: r.to_url,
|
||
toStatusCode: r.to_status_code,
|
||
toSize: r.to_size,
|
||
type: (r.type as OutlinkRow['type']) ?? 'hyperlink',
|
||
anchor: r.anchor,
|
||
altText: r.alt_text,
|
||
rel: r.rel,
|
||
target: r.target,
|
||
pathType: (r.path_type as OutlinkRow['pathType']) ?? null,
|
||
linkPath: r.link_path,
|
||
linkPosition: (r.link_position as OutlinkRow['linkPosition']) ?? null,
|
||
linkOrigin: (r.link_origin as OutlinkRow['linkOrigin']) ?? 'html',
|
||
isInternal: r.is_internal === 1,
|
||
})),
|
||
};
|
||
}
|
||
|
||
getUrlDetail(id: number, linkLimit = 500): UrlDetail | null {
|
||
const row = this.getUrlById(id);
|
||
if (!row) return null;
|
||
const inl = this.getInlinks(row.url, linkLimit);
|
||
const outl = this.getOutlinks(id, linkLimit);
|
||
const headers = this.getUrlHeaders(id);
|
||
return {
|
||
row,
|
||
inlinks: inl.rows,
|
||
inlinksTotal: inl.total,
|
||
outlinks: outl.rows,
|
||
outlinksTotal: outl.total,
|
||
headers,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Replace any previously-stored response headers for this URL with the
|
||
* given set. Old rows are deleted first so a re-crawl doesn't accumulate
|
||
* stale entries when servers change their header set.
|
||
*
|
||
* Header names are stored lowercased (HTTP header names are
|
||
* case-insensitive), values are kept as the server sent them. Values
|
||
* over 4 KB are truncated with a marker so the row size stays bounded
|
||
* on adversarial servers.
|
||
*/
|
||
setUrlHeaders(urlId: number, entries: Iterable<readonly [string, string]>): void {
|
||
const list: { name: string; value: string }[] = [];
|
||
const seen = new Set<string>();
|
||
for (const [rawName, rawValue] of entries) {
|
||
const name = rawName.trim().toLowerCase();
|
||
if (!name) continue;
|
||
if (seen.has(name)) continue;
|
||
seen.add(name);
|
||
let value = rawValue ?? '';
|
||
if (value.length > 4096) value = value.slice(0, 4093) + '...';
|
||
list.push({ name, value });
|
||
}
|
||
// Skip the inner BEGIN when we're nested inside a `runInTransaction`
|
||
// — the outer call already opened a transaction and SQLite forbids
|
||
// nested BEGIN. The outer COMMIT/ROLLBACK covers our work too.
|
||
const ownsTx = !this.isInTransaction();
|
||
if (ownsTx) this.db.exec('BEGIN');
|
||
try {
|
||
this.db.prepare('DELETE FROM headers WHERE url_id = ?').run(urlId);
|
||
if (list.length > 0) {
|
||
const placeholders = list.map(() => '(?, ?, ?)').join(',');
|
||
const args: (number | string)[] = [];
|
||
for (const h of list) args.push(urlId, h.name, h.value);
|
||
this.db
|
||
.prepare(`INSERT INTO headers (url_id, name, value) VALUES ${placeholders}`)
|
||
.run(...args);
|
||
}
|
||
if (ownsTx) this.db.exec('COMMIT');
|
||
} catch (err) {
|
||
if (ownsTx) {
|
||
try {
|
||
this.db.exec('ROLLBACK');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
getUrlHeaders(urlId: number): { name: string; value: string }[] {
|
||
return this.db
|
||
.prepare('SELECT name, value FROM headers WHERE url_id = ? ORDER BY name')
|
||
.all(urlId) as { name: string; value: string }[];
|
||
}
|
||
|
||
/**
|
||
* Persist (or overwrite) the raw HTML body snapshot for a URL — drives
|
||
* the View Source detail tab. Body length is capped at `cap` bytes
|
||
* (default 1 MB) so a single adversarial page can't bloat the project
|
||
* file. The full pre-truncation length is stored alongside the snippet
|
||
* so the UI can warn the user when a body was clipped.
|
||
*/
|
||
setUrlSource(urlId: number, body: string, cap = 1_048_576): void {
|
||
const fullLength = Buffer.byteLength(body, 'utf8');
|
||
let stored = body;
|
||
let truncated = 0;
|
||
if (fullLength > cap) {
|
||
// Slice on bytes, not chars — surrogate-pair safe via Buffer.
|
||
stored = Buffer.from(body, 'utf8').slice(0, cap).toString('utf8');
|
||
truncated = 1;
|
||
}
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO url_sources (url_id, body, body_length, truncated)
|
||
VALUES (?, ?, ?, ?)
|
||
ON CONFLICT(url_id) DO UPDATE SET
|
||
body = excluded.body,
|
||
body_length = excluded.body_length,
|
||
truncated = excluded.truncated,
|
||
captured_at = CURRENT_TIMESTAMP`,
|
||
)
|
||
.run(urlId, stored, fullLength, truncated);
|
||
}
|
||
|
||
/**
|
||
* Read back the raw HTML body for a URL. Returns null when no snapshot
|
||
* was stored — typically because the page is non-HTML, the crawl
|
||
* predates this feature, or storeBodySnapshots was disabled in config.
|
||
*/
|
||
getUrlSource(
|
||
urlId: number,
|
||
): { body: string; bodyLength: number; truncated: boolean; capturedAt: string } | null {
|
||
const row = this.db
|
||
.prepare(
|
||
'SELECT body, body_length, truncated, captured_at FROM url_sources WHERE url_id = ?',
|
||
)
|
||
.get(urlId) as
|
||
| { body: string; body_length: number; truncated: number; captured_at: string }
|
||
| undefined;
|
||
if (!row) return null;
|
||
return {
|
||
body: row.body,
|
||
bodyLength: row.body_length,
|
||
truncated: row.truncated === 1,
|
||
capturedAt: row.captured_at,
|
||
};
|
||
}
|
||
|
||
*iterateAllUrls(): IterableIterator<CrawlUrlRow> {
|
||
const rows = this.db.prepare('SELECT * FROM urls ORDER BY id').all() as unknown as UrlRowDb[];
|
||
for (const row of rows) {
|
||
yield this.rowFromDb(row);
|
||
}
|
||
}
|
||
|
||
*iterateUrlsByIds(ids: number[]): IterableIterator<CrawlUrlRow> {
|
||
if (ids.length === 0) return;
|
||
const placeholders = ids.map(() => '?').join(',');
|
||
const rows = this.db
|
||
.prepare(`SELECT * FROM urls WHERE id IN (${placeholders}) ORDER BY id`)
|
||
.all(...ids) as unknown as UrlRowDb[];
|
||
for (const row of rows) {
|
||
yield this.rowFromDb(row);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Yield every URL row that matches a sidebar category filter — same
|
||
* predicate the URL table view uses, so what you see is what you export.
|
||
* `category === 'all'` walks the full urls table.
|
||
*/
|
||
*iterateUrlsByCategory(category: UrlCategory): IterableIterator<CrawlUrlRow> {
|
||
const where = categoryWhereClause(category);
|
||
const sql = where
|
||
? `SELECT * FROM urls WHERE ${where} ORDER BY id`
|
||
: 'SELECT * FROM urls ORDER BY id';
|
||
const rows = this.db.prepare(sql).all() as unknown as UrlRowDb[];
|
||
for (const row of rows) {
|
||
yield this.rowFromDb(row);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Yields crawled, indexable, internal HTML URLs in depth-then-id order
|
||
* — the set that belongs in an XML sitemap. Excludes redirects,
|
||
* noindex, canonicalised, blocked, 4xx/5xx, and non-HTML resources.
|
||
*/
|
||
*iterateIndexableUrls(): IterableIterator<CrawlUrlRow> {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT * FROM urls
|
||
WHERE is_external = 0
|
||
AND content_kind = 'html'
|
||
AND indexability = 'indexable'
|
||
AND status_code >= 200 AND status_code < 300
|
||
ORDER BY depth, id`,
|
||
)
|
||
.all() as unknown as UrlRowDb[];
|
||
for (const row of rows) {
|
||
yield this.rowFromDb(row);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compact graph snapshot for the Visualization tab.
|
||
*
|
||
* Returns up to `nodeLimit` internal HTML nodes (top by inlinks) plus
|
||
* every edge between them. Edges to URLs outside the cap are dropped
|
||
* — Cytoscape would crash on dangling edges, and the user only cares
|
||
* about the most-linked subset for sense-making anyway.
|
||
*
|
||
* Cost: two indexed SELECTs + a JOIN. ~200 ms at 100K URLs / 5K cap.
|
||
*/
|
||
graphSnapshot(nodeLimit = 1000): {
|
||
nodes: { id: number; url: string; statusCode: number | null; depth: number; inlinks: number; indexability: Indexability }[];
|
||
edges: { source: number; target: number }[];
|
||
} {
|
||
const nodes = this.db
|
||
.prepare(
|
||
`SELECT id, url, status_code, depth, inlinks, indexability
|
||
FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
ORDER BY inlinks DESC, id ASC
|
||
LIMIT ?`,
|
||
)
|
||
.all(nodeLimit) as {
|
||
id: number;
|
||
url: string;
|
||
status_code: number | null;
|
||
depth: number;
|
||
inlinks: number;
|
||
indexability: Indexability;
|
||
}[];
|
||
|
||
if (nodes.length === 0) return { nodes: [], edges: [] };
|
||
|
||
const idByUrl = new Map<string, number>();
|
||
for (const n of nodes) idByUrl.set(n.url, n.id);
|
||
|
||
// Pull every edge whose `from_url_id` is in our node set, then
|
||
// filter targets that didn't make the cap (drop instead of fan
|
||
// out to ghost nodes).
|
||
const fromIds = nodes.map((n) => n.id);
|
||
const placeholders = fromIds.map(() => '?').join(',');
|
||
const edgeRows = this.db
|
||
.prepare(
|
||
`SELECT from_url_id AS source, to_url FROM links
|
||
WHERE is_internal = 1 AND from_url_id IN (${placeholders})`,
|
||
)
|
||
.all(...fromIds) as { source: number; to_url: string }[];
|
||
|
||
const edges: { source: number; target: number }[] = [];
|
||
for (const e of edgeRows) {
|
||
const target = idByUrl.get(e.to_url);
|
||
if (target !== undefined && target !== e.source) {
|
||
edges.push({ source: e.source, target });
|
||
}
|
||
}
|
||
|
||
return {
|
||
nodes: nodes.map((n) => ({
|
||
id: n.id,
|
||
url: n.url,
|
||
statusCode: n.status_code,
|
||
depth: n.depth,
|
||
inlinks: n.inlinks,
|
||
indexability: n.indexability,
|
||
})),
|
||
edges,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Top anchor texts across all internal links, ranked by frequency.
|
||
* Used by the Visualization tab's anchor-text word cloud.
|
||
*/
|
||
topAnchorTexts(limit = 200): { anchor: string; count: number }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT anchor, COUNT(*) AS count FROM links
|
||
WHERE is_internal = 1 AND anchor IS NOT NULL AND anchor != ''
|
||
GROUP BY anchor
|
||
ORDER BY count DESC, anchor ASC
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as { anchor: string; count: number }[];
|
||
}
|
||
|
||
/**
|
||
* Lightweight `(url, value)` pair lookup used by the HTML report's
|
||
* top-N tables. `column` is restricted to numeric URL columns (the
|
||
* UI never wires this from user input). Direction is fixed DESC since
|
||
* every callsite wants "top by metric".
|
||
*/
|
||
topUrlsBy(
|
||
column: 'response_time_ms' | 'depth' | 'outlinks' | 'inlinks' | 'content_length',
|
||
limit: number,
|
||
): { url: string; value: number | null }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT url, ${column} AS value FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND ${column} IS NOT NULL
|
||
ORDER BY ${column} DESC
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as { url: string; value: number | null }[];
|
||
}
|
||
|
||
/**
|
||
* External-domain health rollup. Aggregates every external URL we
|
||
* probed (via link extraction) by host, surfacing the per-domain
|
||
* success/error split, average response time, and error-rate %. Sorted
|
||
* by `errorCount DESC, totalUrls DESC` so the worst offenders top.
|
||
*
|
||
* Used by the "Outgoing External Link Health" report — actionable
|
||
* signal: a partner / CDN / 3rd-party widget whose error rate spikes is
|
||
* the single most useful external-link metric for SEO.
|
||
*/
|
||
externalDomainHealth(
|
||
limit = 100,
|
||
): {
|
||
domain: string;
|
||
totalUrls: number;
|
||
successCount: number;
|
||
errorCount: number;
|
||
avgResponseTimeMs: number | null;
|
||
errorRatePercent: number;
|
||
}[] {
|
||
// Grouping by URL substring is faster than parsing per-row in JS for
|
||
// large datasets — we extract the host between `://` and the next `/`
|
||
// / `?` / `#`. Pages we never probed (status_code IS NULL) are skipped
|
||
// so we don't penalise a slow crawl.
|
||
const rows = this.db
|
||
.prepare(
|
||
`WITH parsed AS (
|
||
SELECT
|
||
LOWER(
|
||
SUBSTR(
|
||
url,
|
||
INSTR(url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(url)
|
||
END
|
||
)
|
||
) AS domain,
|
||
status_code,
|
||
response_time_ms
|
||
FROM urls
|
||
WHERE is_external = 1 AND status_code IS NOT NULL
|
||
)
|
||
SELECT
|
||
domain,
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) AS success,
|
||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors,
|
||
AVG(response_time_ms) AS avg_rt
|
||
FROM parsed
|
||
WHERE domain != ''
|
||
GROUP BY domain
|
||
ORDER BY errors DESC, total DESC
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as {
|
||
domain: string;
|
||
total: number;
|
||
success: number;
|
||
errors: number;
|
||
avg_rt: number | null;
|
||
}[];
|
||
return rows.map((r) => ({
|
||
domain: r.domain,
|
||
totalUrls: r.total,
|
||
successCount: r.success,
|
||
errorCount: r.errors,
|
||
avgResponseTimeMs: r.avg_rt === null ? null : Math.round(r.avg_rt),
|
||
errorRatePercent: r.total > 0 ? Math.round((r.errors / r.total) * 1000) / 10 : 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Distinct HTTPS hosts (with crawled URLs) that haven't been TLS-
|
||
* probed yet. The post-crawl `runTlsCertProbes` pass uses this to do
|
||
* one TLS handshake per origin instead of one per URL.
|
||
*/
|
||
unprobedHttpsHosts(limit = 10_000): string[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT DISTINCT
|
||
LOWER(SUBSTR(url, 9, INSTR(SUBSTR(url, 9), '/') - 1)) AS host
|
||
FROM urls
|
||
WHERE is_external = 0
|
||
AND status_code IS NOT NULL
|
||
AND url LIKE 'https://%'
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as { host: string }[];
|
||
const knownHosts = new Set(
|
||
(this.db.prepare('SELECT host FROM host_certs').all() as { host: string }[]).map(
|
||
(r) => r.host,
|
||
),
|
||
);
|
||
return rows
|
||
.map((r) => r.host)
|
||
.filter((h) => h && h.length > 0 && !knownHosts.has(h));
|
||
}
|
||
|
||
/**
|
||
* Persist (or refresh) a single host's TLS-probe result. `probeStatus`
|
||
* convention: 200 = handshake OK + cert read, 0 = error / timeout.
|
||
*/
|
||
setHostCert(input: {
|
||
host: string;
|
||
port?: number;
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
daysUntilExpiry: number | null;
|
||
issuer: string | null;
|
||
subject: string | null;
|
||
signatureAlgorithm: string | null;
|
||
protocol: string | null;
|
||
probeStatus: number;
|
||
probeError: string | null;
|
||
}): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO host_certs (
|
||
host, port, valid_from, valid_to, days_until_expiry,
|
||
issuer, subject, signature_algorithm, protocol,
|
||
probe_status, probe_error, probed_at
|
||
) VALUES (
|
||
:host, :port, :valid_from, :valid_to, :days_until_expiry,
|
||
:issuer, :subject, :signature_algorithm, :protocol,
|
||
:probe_status, :probe_error, CURRENT_TIMESTAMP
|
||
)
|
||
ON CONFLICT(host) DO UPDATE SET
|
||
valid_from = excluded.valid_from,
|
||
valid_to = excluded.valid_to,
|
||
days_until_expiry = excluded.days_until_expiry,
|
||
issuer = excluded.issuer,
|
||
subject = excluded.subject,
|
||
signature_algorithm = excluded.signature_algorithm,
|
||
protocol = excluded.protocol,
|
||
probe_status = excluded.probe_status,
|
||
probe_error = excluded.probe_error,
|
||
probed_at = CURRENT_TIMESTAMP`,
|
||
)
|
||
.run({
|
||
host: input.host,
|
||
port: input.port ?? 443,
|
||
valid_from: input.validFrom,
|
||
valid_to: input.validTo,
|
||
days_until_expiry: input.daysUntilExpiry,
|
||
issuer: input.issuer,
|
||
subject: input.subject,
|
||
signature_algorithm: input.signatureAlgorithm,
|
||
protocol: input.protocol,
|
||
probe_status: input.probeStatus,
|
||
probe_error: input.probeError,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Look up the cached TLS cert info for a single URL — joins the URL's
|
||
* host (parsed from the stored URL) against `host_certs`. Returns null
|
||
* when no probe data exists (HTTP-only site, or probe disabled).
|
||
*/
|
||
getHostCertForUrl(urlId: number): {
|
||
host: string;
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
daysUntilExpiry: number | null;
|
||
issuer: string | null;
|
||
subject: string | null;
|
||
signatureAlgorithm: string | null;
|
||
protocol: string | null;
|
||
probeStatus: number;
|
||
probeError: string | null;
|
||
probedAt: string | null;
|
||
} | null {
|
||
const row = this.db
|
||
.prepare(
|
||
`SELECT hc.host, hc.valid_from, hc.valid_to, hc.days_until_expiry,
|
||
hc.issuer, hc.subject, hc.signature_algorithm, hc.protocol,
|
||
hc.probe_status, hc.probe_error, hc.probed_at
|
||
FROM urls u
|
||
JOIN host_certs hc
|
||
ON hc.host = LOWER(SUBSTR(u.url, 9, INSTR(SUBSTR(u.url, 9), '/') - 1))
|
||
WHERE u.id = ?
|
||
AND u.url LIKE 'https://%'`,
|
||
)
|
||
.get(urlId) as
|
||
| {
|
||
host: string;
|
||
valid_from: string | null;
|
||
valid_to: string | null;
|
||
days_until_expiry: number | null;
|
||
issuer: string | null;
|
||
subject: string | null;
|
||
signature_algorithm: string | null;
|
||
protocol: string | null;
|
||
probe_status: number;
|
||
probe_error: string | null;
|
||
probed_at: string | null;
|
||
}
|
||
| undefined;
|
||
if (!row) return null;
|
||
return {
|
||
host: row.host,
|
||
validFrom: row.valid_from,
|
||
validTo: row.valid_to,
|
||
daysUntilExpiry: row.days_until_expiry,
|
||
issuer: row.issuer,
|
||
subject: row.subject,
|
||
signatureAlgorithm: row.signature_algorithm,
|
||
protocol: row.protocol,
|
||
probeStatus: row.probe_status,
|
||
probeError: row.probe_error,
|
||
probedAt: row.probed_at,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Internal images that haven't been HEAD-probed yet — used by the
|
||
* post-crawl image-size pass to discover oversize PNGs/JPEGs without
|
||
* re-probing already-sized rows on subsequent crawls.
|
||
*/
|
||
unprobedInternalImages(limit = 20_000): { id: number; src: string }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT id, src FROM images
|
||
WHERE is_internal = 1
|
||
AND probe_status IS NULL
|
||
AND src LIKE 'http%'
|
||
ORDER BY id
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as { id: number; src: string }[];
|
||
}
|
||
|
||
/**
|
||
* Update an `images` row with the result of the HEAD probe. `byteSize`
|
||
* is null when the server didn't return Content-Length, status records
|
||
* the HTTP code (or 0 when the request errored entirely).
|
||
*/
|
||
setImageSize(imageId: number, byteSize: number | null, status: number): void {
|
||
this.db
|
||
.prepare(
|
||
`UPDATE images
|
||
SET byte_size = ?,
|
||
probe_status = ?,
|
||
probed_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?`,
|
||
)
|
||
.run(byteSize, status, imageId);
|
||
}
|
||
|
||
/**
|
||
* Top pages by total internal-image byte weight. Sums the HEAD-probe
|
||
* `byte_size` for every internal image referenced from each page, so
|
||
* a page reusing the same 500 KB hero plus 30 thumbnails surfaces as
|
||
* "this is your image-heaviest URL". Pages with no probed images get
|
||
* a 0 sum and are filtered out.
|
||
*/
|
||
imageWeightPerPage(limit = 25): {
|
||
url: string;
|
||
imageBytes: number;
|
||
imageCount: number;
|
||
}[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT u.url AS url,
|
||
COALESCE(SUM(i.byte_size), 0) AS image_bytes,
|
||
COUNT(i.id) AS image_count
|
||
FROM urls u
|
||
JOIN image_usages iu ON iu.from_url_id = u.id
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE u.is_external = 0
|
||
AND u.content_kind = 'html'
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.is_internal = 1
|
||
GROUP BY u.id
|
||
HAVING image_bytes > 0
|
||
ORDER BY image_bytes DESC
|
||
LIMIT ?`,
|
||
)
|
||
.all(limit) as { url: string; image_bytes: number; image_count: number }[];
|
||
return rows.map((r) => ({
|
||
url: r.url,
|
||
imageBytes: r.image_bytes,
|
||
imageCount: r.image_count,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Roll-up of `Server` response-header values across the crawl. Returns
|
||
* one row per distinct value with a hit count, sorted descending.
|
||
* Useful for stack auditing — surfaces "we have 92% nginx and 8%
|
||
* Apache" or "an old IIS host slipped into our subdomain mix".
|
||
*/
|
||
serverHeaderBreakdown(): { server: string; count: number }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT server_header AS server, COUNT(*) AS count
|
||
FROM urls
|
||
WHERE is_external = 0
|
||
AND server_header IS NOT NULL AND server_header != ''
|
||
GROUP BY server_header
|
||
ORDER BY count DESC`,
|
||
)
|
||
.all() as { server: string; count: number }[];
|
||
}
|
||
|
||
/**
|
||
* Inlink histogram — bucket pages by how many internal links point at
|
||
* them. Useful for understanding internal-link distribution: a healthy
|
||
* site has a long tail (most pages are link-poor) but no orphans.
|
||
* Buckets: 0, 1–4, 5–14, 15–49, 50–199, 200+ (matches Screaming Frog
|
||
* defaults).
|
||
*/
|
||
inlinksHistogram(): { label: string; count: number }[] {
|
||
const buckets: { label: string; min: number; max: number }[] = [
|
||
{ label: '0 (orphan)', min: 0, max: 0 },
|
||
{ label: '1–4', min: 1, max: 4 },
|
||
{ label: '5–14', min: 5, max: 14 },
|
||
{ label: '15–49', min: 15, max: 49 },
|
||
{ label: '50–199', min: 50, max: 199 },
|
||
{ label: '200+', min: 200, max: Number.MAX_SAFE_INTEGER },
|
||
];
|
||
return buckets.map((b) => ({
|
||
label: b.label,
|
||
count: (
|
||
this.db
|
||
.prepare(
|
||
b.max === Number.MAX_SAFE_INTEGER
|
||
? `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html' AND inlinks >= ?`
|
||
: `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND inlinks >= ? AND inlinks <= ?`,
|
||
)
|
||
.get(
|
||
...(b.max === Number.MAX_SAFE_INTEGER ? [b.min] : [b.min, b.max]),
|
||
) as { c: number }
|
||
).c,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Word-count histogram — bucket pages by body word count. Lets the
|
||
* user spot the content distribution (mostly thin? mostly long-form?
|
||
* what's the median page length?). Buckets follow Screaming Frog's
|
||
* defaults: 0–99 / 100–249 / 250–499 / 500–999 / 1000–2999 / 3000+.
|
||
*/
|
||
wordCountHistogram(): { label: string; count: number }[] {
|
||
const buckets: { label: string; min: number; max: number }[] = [
|
||
{ label: '0–99', min: 0, max: 99 },
|
||
{ label: '100–249', min: 100, max: 249 },
|
||
{ label: '250–499', min: 250, max: 499 },
|
||
{ label: '500–999', min: 500, max: 999 },
|
||
{ label: '1000–2999', min: 1000, max: 2999 },
|
||
{ label: '3000+', min: 3000, max: Number.MAX_SAFE_INTEGER },
|
||
];
|
||
return buckets.map((b) => ({
|
||
label: b.label,
|
||
count: (
|
||
this.db
|
||
.prepare(
|
||
b.max === Number.MAX_SAFE_INTEGER
|
||
? `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND word_count IS NOT NULL AND word_count >= ?`
|
||
: `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND word_count IS NOT NULL
|
||
AND word_count >= ? AND word_count <= ?`,
|
||
)
|
||
.get(
|
||
...(b.max === Number.MAX_SAFE_INTEGER ? [b.min] : [b.min, b.max]),
|
||
) as { c: number }
|
||
).c,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* URL-length histogram across internal URLs of every kind. Buckets
|
||
* follow the de-facto SEO advisory thresholds: ≤75 is "comfortable in
|
||
* SERP snippets", 76–115 is "tolerable", 116+ slides past mobile-card
|
||
* truncation; 200+ starts triggering RFC-7230 server limits on shared
|
||
* hosting. The `> 2048` bucket aligns with Chrome's hard URL cap so
|
||
* users can spot URLs that browsers would already truncate.
|
||
*/
|
||
urlLengthHistogram(): { label: string; count: number }[] {
|
||
const buckets: { label: string; min: number; max: number }[] = [
|
||
{ label: '≤ 75', min: 0, max: 75 },
|
||
{ label: '76–115', min: 76, max: 115 },
|
||
{ label: '116–200', min: 116, max: 200 },
|
||
{ label: '201–500', min: 201, max: 500 },
|
||
{ label: '501–2048', min: 501, max: 2048 },
|
||
{ label: '> 2048', min: 2049, max: Number.MAX_SAFE_INTEGER },
|
||
];
|
||
return buckets.map((b) => ({
|
||
label: b.label,
|
||
count: (
|
||
this.db
|
||
.prepare(
|
||
b.max === Number.MAX_SAFE_INTEGER
|
||
? `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND LENGTH(url) >= ?`
|
||
: `SELECT COUNT(*) AS c FROM urls
|
||
WHERE is_external = 0 AND LENGTH(url) >= ? AND LENGTH(url) <= ?`,
|
||
)
|
||
.get(
|
||
...(b.max === Number.MAX_SAFE_INTEGER ? [b.min] : [b.min, b.max]),
|
||
) as { c: number }
|
||
).c,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Average word count per top-level directory. Uses the same client-side
|
||
* URL parsing strategy as `getPagesPerDirectory` (no SQL substring
|
||
* acrobatics) — for 100K URLs this stays well under 100 ms. Returns
|
||
* directories sorted by average word count descending so the user can
|
||
* spot which sections of the site carry deep content vs which are
|
||
* thin-content stubs that hurt rankings.
|
||
*/
|
||
wordCountPerDirectory(
|
||
opts: { depth?: number; limit?: number } = {},
|
||
): { directory: string; avgWordCount: number; pageCount: number }[] {
|
||
const targetDepth = Math.max(1, Math.min(10, opts.depth ?? 1));
|
||
const limit = Math.max(1, Math.min(2000, opts.limit ?? 500));
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT url, word_count FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND status_code BETWEEN 200 AND 299
|
||
AND word_count IS NOT NULL`,
|
||
)
|
||
.all() as { url: string; word_count: number }[];
|
||
const acc = new Map<string, { sum: number; n: number }>();
|
||
for (const r of rows) {
|
||
try {
|
||
const u = new URL(r.url);
|
||
const segments = u.pathname.split('/').filter((s) => s.length > 0);
|
||
const taken = segments.slice(0, targetDepth);
|
||
const dir = taken.length > 0 ? '/' + taken.join('/') : '/';
|
||
const cur = acc.get(dir);
|
||
if (cur) {
|
||
cur.sum += r.word_count;
|
||
cur.n += 1;
|
||
} else {
|
||
acc.set(dir, { sum: r.word_count, n: 1 });
|
||
}
|
||
} catch {
|
||
// skip unparseable URL
|
||
}
|
||
}
|
||
return [...acc.entries()]
|
||
.map(([directory, v]) => ({
|
||
directory,
|
||
avgWordCount: v.n > 0 ? Math.round(v.sum / v.n) : 0,
|
||
pageCount: v.n,
|
||
}))
|
||
.sort(
|
||
(a, b) =>
|
||
b.avgWordCount - a.avgWordCount || a.directory.localeCompare(b.directory),
|
||
)
|
||
.slice(0, limit);
|
||
}
|
||
|
||
/**
|
||
* URLs declared in the sitemap but never reached during the crawl —
|
||
* the canonical "orphan" definition. Surfaces sitemap URLs that the
|
||
* spider couldn't link-follow to (because no internal page linked to
|
||
* them, or they were filtered out by include/exclude / scope rules).
|
||
* Each row carries its `<lastmod>` and the source sitemap URL so the
|
||
* SEO can decide whether the entry is stale or genuinely orphaned.
|
||
*/
|
||
sitemapOrphans(
|
||
limit = 1000,
|
||
): { url: string; lastmod: string | null; sourceSitemap: string | null }[] {
|
||
const cap = Math.max(1, Math.min(10_000, limit));
|
||
return this.db
|
||
.prepare(
|
||
`SELECT s.url AS url, s.lastmod AS lastmod, s.source_sitemap AS sourceSitemap
|
||
FROM sitemap_urls s
|
||
WHERE NOT EXISTS (SELECT 1 FROM urls u WHERE u.url = s.url)
|
||
ORDER BY s.lastmod IS NULL, s.lastmod DESC, s.url
|
||
LIMIT ?`,
|
||
)
|
||
.all(cap) as {
|
||
url: string;
|
||
lastmod: string | null;
|
||
sourceSitemap: string | null;
|
||
}[];
|
||
}
|
||
|
||
/**
|
||
* Per-position counts across all internal links — drives the Link
|
||
* Position report. Uses the `link_position` column populated by the
|
||
* HTML parser (navigation/header/content/sidebar/footer/aside, with
|
||
* everything else falling back to `content`).
|
||
*/
|
||
linkPositionBreakdown(): { position: string; count: number }[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT COALESCE(link_position, 'content') AS position, COUNT(*) AS count
|
||
FROM links
|
||
WHERE is_internal = 1
|
||
GROUP BY position
|
||
ORDER BY count DESC`,
|
||
)
|
||
.all() as { position: string; count: number }[];
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* Roll-up of analytics tracker coverage across the crawl. Walks every
|
||
* row's `analytics_trackers` JSON column once, counts page hits + unique
|
||
* IDs per tracker name, and returns sorted by page count desc. Only
|
||
* indexable HTML pages contribute so non-200 / noindex don't skew the
|
||
* "what % of the site has GA4 installed" picture.
|
||
*/
|
||
analyticsCoverage(): {
|
||
name: string;
|
||
pageCount: number;
|
||
distinctIds: number;
|
||
sampleIds: string[];
|
||
}[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT analytics_trackers FROM urls
|
||
WHERE is_external = 0
|
||
AND content_kind = 'html'
|
||
AND status_code BETWEEN 200 AND 299
|
||
AND indexability = 'indexable'
|
||
AND analytics_trackers IS NOT NULL
|
||
AND analytics_trackers != ''
|
||
AND analytics_trackers != '[]'`,
|
||
)
|
||
.all() as { analytics_trackers: string }[];
|
||
|
||
const byName = new Map<
|
||
string,
|
||
{ pageCount: number; ids: Set<string> }
|
||
>();
|
||
for (const r of rows) {
|
||
let parsed: { name?: unknown; id?: unknown }[];
|
||
try {
|
||
const v = JSON.parse(r.analytics_trackers) as unknown;
|
||
if (!Array.isArray(v)) continue;
|
||
parsed = v as { name?: unknown; id?: unknown }[];
|
||
} catch {
|
||
continue;
|
||
}
|
||
const seen = new Set<string>();
|
||
for (const t of parsed) {
|
||
if (!t || typeof t.name !== 'string' || !t.name) continue;
|
||
if (seen.has(t.name)) continue;
|
||
seen.add(t.name);
|
||
let entry = byName.get(t.name);
|
||
if (!entry) {
|
||
entry = { pageCount: 0, ids: new Set<string>() };
|
||
byName.set(t.name, entry);
|
||
}
|
||
entry.pageCount++;
|
||
if (typeof t.id === 'string' && t.id) entry.ids.add(t.id);
|
||
}
|
||
}
|
||
|
||
return [...byName.entries()]
|
||
.map(([name, entry]) => ({
|
||
name,
|
||
pageCount: entry.pageCount,
|
||
distinctIds: entry.ids.size,
|
||
sampleIds: [...entry.ids].slice(0, 5),
|
||
}))
|
||
.sort((a, b) => b.pageCount - a.pageCount || a.name.localeCompare(b.name));
|
||
}
|
||
|
||
/**
|
||
* Internal-image entries linked to a single page URL. Used by the
|
||
* image sitemap variant — Google's `image:image` extension allows up
|
||
* to 1000 entries per `<url>` entry.
|
||
*/
|
||
imagesForUrl(urlId: number, limit = 1000): { src: string; alt: string | null }[] {
|
||
return this.db
|
||
.prepare(
|
||
`SELECT i.src, COALESCE(iu.alt, i.alt) AS alt
|
||
FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = ? AND i.is_internal = 1
|
||
ORDER BY i.id
|
||
LIMIT ?`,
|
||
)
|
||
.all(urlId, limit) as { src: string; alt: string | null }[];
|
||
}
|
||
|
||
/**
|
||
* Full-detail image rows for a page — drives the Detail Panel "Images"
|
||
* sub-tab. Includes both internal and external images, with width/height
|
||
* + per-page alt (which may differ from the canonical row in `images`
|
||
* when the same image is reused with different alt text).
|
||
*/
|
||
pageImagesDetailed(
|
||
urlId: number,
|
||
limit = 5000,
|
||
): {
|
||
src: string;
|
||
alt: string | null;
|
||
width: number | null;
|
||
height: number | null;
|
||
isInternal: boolean;
|
||
byteSize: number | null;
|
||
}[] {
|
||
const rows = this.db
|
||
.prepare(
|
||
`SELECT i.src, COALESCE(iu.alt, i.alt) AS alt, i.width, i.height, i.is_internal, i.byte_size
|
||
FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = ?
|
||
ORDER BY i.is_internal DESC, i.id
|
||
LIMIT ?`,
|
||
)
|
||
.all(urlId, limit) as {
|
||
src: string;
|
||
alt: string | null;
|
||
width: number | null;
|
||
height: number | null;
|
||
is_internal: number;
|
||
byte_size: number | null;
|
||
}[];
|
||
return rows.map((r) => ({
|
||
src: r.src,
|
||
alt: r.alt,
|
||
width: r.width,
|
||
height: r.height,
|
||
isInternal: r.is_internal === 1,
|
||
byteSize: r.byte_size,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Sitemap index iteration — same set as `iterateIndexableUrls` but
|
||
* additionally surfaces the `hreflangs` JSON so the hreflang sitemap
|
||
* variant can emit `<xhtml:link>` siblings. Identical filter / sort.
|
||
*/
|
||
*iterateIndexableUrlsWithHreflang(): IterableIterator<CrawlUrlRow> {
|
||
yield* this.iterateIndexableUrls();
|
||
}
|
||
|
||
private rowFromDb = (r: UrlRowDb): CrawlUrlRow => ({
|
||
id: r.id,
|
||
url: r.url,
|
||
contentKind: r.content_kind,
|
||
statusCode: r.status_code,
|
||
statusText: r.status_text,
|
||
indexability: r.indexability,
|
||
indexabilityReason: r.indexability_reason,
|
||
title: r.title,
|
||
titleLength: r.title_length,
|
||
metaDescription: r.meta_description,
|
||
metaDescriptionLength: r.meta_description_length,
|
||
h1: r.h1,
|
||
h1Length: r.h1_length,
|
||
h1Count: r.h1_count,
|
||
h2Count: r.h2_count,
|
||
h3Count: r.h3_count,
|
||
h4Count: r.h4_count,
|
||
h5Count: r.h5_count,
|
||
h6Count: r.h6_count,
|
||
wordCount: r.word_count,
|
||
canonical: r.canonical,
|
||
canonicalCount: r.canonical_count,
|
||
canonicalHttp: r.canonical_http,
|
||
metaRobots: r.meta_robots,
|
||
xRobotsTag: r.x_robots_tag,
|
||
contentType: r.content_type,
|
||
contentLength: r.content_length,
|
||
responseTimeMs: r.response_time_ms,
|
||
depth: r.depth,
|
||
inlinks: r.inlinks,
|
||
outlinks: r.outlinks,
|
||
imagesCount: r.images_count,
|
||
imagesMissingAlt: r.images_missing_alt,
|
||
redirectTarget: r.redirect_target,
|
||
lang: r.lang,
|
||
viewport: r.viewport,
|
||
ogTitle: r.og_title,
|
||
ogDescription: r.og_description,
|
||
ogImage: r.og_image,
|
||
twitterCard: r.twitter_card,
|
||
twitterTitle: r.twitter_title,
|
||
twitterDescription: r.twitter_description,
|
||
twitterImage: r.twitter_image,
|
||
metaKeywords: r.meta_keywords,
|
||
metaAuthor: r.meta_author,
|
||
metaGenerator: r.meta_generator,
|
||
themeColor: r.theme_color,
|
||
hsts: r.hsts,
|
||
xFrameOptions: r.x_frame_options,
|
||
xContentTypeOptions: r.x_content_type_options,
|
||
contentEncoding: r.content_encoding,
|
||
schemaTypes: r.schema_types,
|
||
schemaBlockCount: r.schema_block_count,
|
||
schemaInvalidCount: r.schema_invalid_count,
|
||
paginationNext: r.pagination_next,
|
||
paginationPrev: r.pagination_prev,
|
||
paginationSequenceBreak: (r as unknown as { pagination_sequence_break?: number }).pagination_sequence_break === 1,
|
||
hreflangs: r.hreflangs,
|
||
hreflangCount: r.hreflang_count,
|
||
amphtml: r.amphtml,
|
||
favicon: r.favicon,
|
||
mixedContentCount: r.mixed_content_count,
|
||
hreflangInvalidCount: r.hreflang_invalid_count,
|
||
hreflangSelfRefMissing: r.hreflang_self_ref_missing === 1,
|
||
hreflangReciprocityMissing: r.hreflang_reciprocity_missing,
|
||
hreflangTargetIssues: r.hreflang_target_issues,
|
||
redirectChainLength: r.redirect_chain_length,
|
||
redirectFinalUrl: r.redirect_final_url,
|
||
redirectLoop: r.redirect_loop === 1,
|
||
folderDepth: r.folder_depth,
|
||
queryParamCount: r.query_param_count,
|
||
csp: r.csp,
|
||
referrerPolicy: r.referrer_policy,
|
||
permissionsPolicy: r.permissions_policy,
|
||
customSearchHits: r.custom_search_hits,
|
||
metaRefresh: r.meta_refresh,
|
||
metaRefreshUrl: r.meta_refresh_url,
|
||
charset: r.charset,
|
||
extractionResults: r.extraction_results,
|
||
simhash: r.simhash,
|
||
contentHash: r.content_hash,
|
||
clusterId: r.cluster_id,
|
||
clusterSize: r.cluster_size,
|
||
titleCount: r.title_count ?? 0,
|
||
imagesEmptyAlt: r.images_empty_alt ?? 0,
|
||
emptyAnchorCount: r.empty_anchor_count ?? 0,
|
||
appleTouchIcon: r.apple_touch_icon ?? null,
|
||
manifestUrl: r.manifest_url ?? null,
|
||
feedUrl: r.feed_url ?? null,
|
||
microdataCount: r.microdata_count ?? 0,
|
||
rdfaCount: r.rdfa_count ?? 0,
|
||
insecureFormActionCount: r.insecure_form_action_count ?? 0,
|
||
missingSriCount: r.missing_sri_count ?? 0,
|
||
titlePixelWidth: r.title_pixel_width ?? 0,
|
||
metaPixelWidth: r.meta_pixel_width ?? 0,
|
||
ttfbMs: r.ttfb_ms ?? null,
|
||
cookiesCount: r.cookies_count ?? 0,
|
||
cookiesInsecure: r.cookies_insecure ?? 0,
|
||
cookiesNoHttpOnly: r.cookies_no_httponly ?? 0,
|
||
cookiesNoSameSite: r.cookies_no_samesite ?? 0,
|
||
httpProtocol: r.http_protocol ?? null,
|
||
queryStringLength: r.query_string_length ?? 0,
|
||
renderBlockingCount: r.render_blocking_count ?? 0,
|
||
keepAlive: r.keep_alive === 1,
|
||
analyticsTrackers: r.analytics_trackers ?? null,
|
||
formInputCount: r.form_input_count ?? 0,
|
||
formInputUnlabeled: r.form_input_unlabeled ?? 0,
|
||
imagesLazy: r.images_lazy ?? 0,
|
||
headings: r.headings ?? null,
|
||
serverHeader: r.server_header ?? null,
|
||
jsOnlyLinksCount: r.js_only_links_count ?? 0,
|
||
textCodeRatio: r.text_code_ratio ?? null,
|
||
crawledAt: r.crawled_at,
|
||
});
|
||
|
||
private toSnake(s: string): string {
|
||
return s.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||
}
|
||
}
|
||
|
||
const VALID_SORT_COLUMNS = new Set([
|
||
'id',
|
||
'url',
|
||
'status_code',
|
||
'title_length',
|
||
'meta_description_length',
|
||
'word_count',
|
||
'response_time_ms',
|
||
'depth',
|
||
'inlinks',
|
||
'outlinks',
|
||
'crawled_at',
|
||
'indexability',
|
||
'content_kind',
|
||
'images_count',
|
||
'images_missing_alt',
|
||
'h1_count',
|
||
'h1_length',
|
||
]);
|
||
|
||
function toSnakeCase(s: string): string {
|
||
return s.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||
}
|
||
|
||
function validSortColumn(sortBy: string | undefined): string {
|
||
if (!sortBy) return 'id';
|
||
const snake = toSnakeCase(sortBy);
|
||
return VALID_SORT_COLUMNS.has(snake) ? snake : 'id';
|
||
}
|
||
|
||
/**
|
||
* Parse a paginated URL into a `(template, ordinal)` pair so the
|
||
* pagination-sequence pass can group same-pattern pages and detect
|
||
* missing numbers. Returns null when no recognised page-number token
|
||
* is found — those URLs are dropped from the analysis (we'd produce
|
||
* false positives if we tried to invent ordinals from arbitrary path
|
||
* components).
|
||
*
|
||
* Priority order matters: query params first because they're
|
||
* unambiguous; then `/page/N` because it's the most common SEO-safe
|
||
* pagination scheme; then trailing `/N` (≥ 2) because page-1 URLs
|
||
* conventionally omit the number, so a `/products/cars` is page 1 of
|
||
* `/products/cars/2`. Lower-bound 2 prevents false positives on
|
||
* `/foo/1` (which often means "category 1", not "page 1").
|
||
*/
|
||
function parsePaginationOrdinal(
|
||
rawUrl: string,
|
||
): { template: string; ordinal: number } | null {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(rawUrl);
|
||
} catch {
|
||
return null;
|
||
}
|
||
// Pattern A — `?page=N` (and common synonyms).
|
||
for (const key of ['page', 'p', 'pg']) {
|
||
const v = url.searchParams.get(key);
|
||
if (v && /^\d+$/.test(v)) {
|
||
const n = Number.parseInt(v, 10);
|
||
if (n >= 1) {
|
||
const params = new URLSearchParams(url.search);
|
||
params.delete(key);
|
||
const cleanQuery = params.toString();
|
||
const template = `${url.origin}${url.pathname}?${key}=*${cleanQuery ? `&${cleanQuery}` : ''}`;
|
||
return { template, ordinal: n };
|
||
}
|
||
}
|
||
}
|
||
// Pattern B — `/page/N` or `/page/N/`.
|
||
const pageSegMatch = url.pathname.match(/(\/page\/)(\d+)(\/?)$/i);
|
||
if (pageSegMatch) {
|
||
const n = Number.parseInt(pageSegMatch[2]!, 10);
|
||
if (n >= 1) {
|
||
const stripped = url.pathname.replace(/\/page\/\d+\/?$/i, '');
|
||
const template = `${url.origin}${stripped}/page/*${pageSegMatch[3]}${url.search}`;
|
||
return { template, ordinal: n };
|
||
}
|
||
}
|
||
// Pattern C — trailing `/N`, only N ≥ 2 (page 1 omits the number).
|
||
const trailingMatch = url.pathname.match(/^(.+\/)(\d+)\/?$/);
|
||
if (trailingMatch) {
|
||
const n = Number.parseInt(trailingMatch[2]!, 10);
|
||
if (n >= 2) {
|
||
const template = `${url.origin}${trailingMatch[1]}*${url.search}`;
|
||
return { template, ordinal: n };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Issue-key → SQL WHERE clause definitions for the materialised
|
||
* `urls_issues` table (see `ProjectDb.recomputeUrlsIssues`). Listed
|
||
* here are the counters whose live evaluation hits O(n²) SQLite paths
|
||
* (host-extraction substring math, self-joins on the `urls` table) —
|
||
* letting them run inline on every 3-second sidebar tick was the
|
||
* single biggest source of UI kasma on crawls > 1000 URLs.
|
||
*
|
||
* One pass per definition runs after the crawl finishes (and on a
|
||
* 30-second timer while the crawl is still active). Each pass is a
|
||
* single INSERT … SELECT, so total CPU on a 100k-URL crawl is well
|
||
* under a second.
|
||
*
|
||
* Adding a new heavy issue: append a `[key, where]` tuple here AND
|
||
* map the `OverviewCounts.issues.<field>` to `issueCount('<key>')` in
|
||
* `getOverviewCounts`. The materialised table picks it up on the next
|
||
* recompute with no further plumbing.
|
||
*/
|
||
export const EXPENSIVE_ISSUE_DEFINITIONS: ReadonlyArray<readonly [string, string]> = [
|
||
[
|
||
'issues:dead-external-domain',
|
||
`is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
JOIN urls eu ON l.to_url = eu.url
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.is_internal = 0
|
||
AND eu.is_external = 1
|
||
AND LOWER(
|
||
SUBSTR(
|
||
eu.url,
|
||
INSTR(eu.url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(eu.url, INSTR(eu.url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(eu.url, INSTR(eu.url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(eu.url)
|
||
END
|
||
)
|
||
) IN (
|
||
SELECT host_grouped FROM (
|
||
SELECT
|
||
LOWER(
|
||
SUBSTR(
|
||
url,
|
||
INSTR(url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(url)
|
||
END
|
||
)
|
||
) AS host_grouped,
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors
|
||
FROM urls
|
||
WHERE is_external = 1 AND status_code IS NOT NULL
|
||
GROUP BY host_grouped
|
||
HAVING total >= 3 AND CAST(errors AS REAL) / total >= 0.8
|
||
)
|
||
)
|
||
)`,
|
||
],
|
||
[
|
||
'issues:duplicate-url-post-norm',
|
||
`is_external = 0 AND EXISTS (
|
||
SELECT 1 FROM urls u2
|
||
WHERE u2.id <> urls.id
|
||
AND u2.is_external = 0
|
||
AND RTRIM(
|
||
LOWER(
|
||
CASE
|
||
WHEN INSTR(u2.url, '?') > 0
|
||
THEN SUBSTR(u2.url, 1, INSTR(u2.url, '?') - 1)
|
||
ELSE u2.url
|
||
END
|
||
),
|
||
'/'
|
||
) =
|
||
RTRIM(
|
||
LOWER(
|
||
CASE
|
||
WHEN INSTR(urls.url, '?') > 0
|
||
THEN SUBSTR(urls.url, 1, INSTR(urls.url, '?') - 1)
|
||
ELSE urls.url
|
||
END
|
||
),
|
||
'/'
|
||
)
|
||
)`,
|
||
],
|
||
[
|
||
'issues:canonical-chain-multi-hop',
|
||
`is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical != url
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls c2
|
||
WHERE c2.url = urls.canonical
|
||
AND c2.canonical IS NOT NULL
|
||
AND c2.canonical != ''
|
||
AND c2.canonical != c2.url
|
||
AND c2.canonical != urls.canonical
|
||
)`,
|
||
],
|
||
];
|
||
|
||
function buildUrlsWhere(params: {
|
||
category?: UrlCategory;
|
||
search?: string;
|
||
filter?: AdvancedFilter;
|
||
}): { whereSql: string; args: (string | number)[] } {
|
||
const where: string[] = [];
|
||
const args: (string | number)[] = [];
|
||
|
||
const cat = params.category ?? 'all';
|
||
if (cat !== 'all') {
|
||
const clause = categoryWhereClause(cat);
|
||
if (clause) where.push(clause);
|
||
}
|
||
|
||
if (params.search) {
|
||
where.push('(url LIKE ? OR title LIKE ?)');
|
||
const like = `%${params.search}%`;
|
||
args.push(like, like);
|
||
}
|
||
|
||
if (params.filter) {
|
||
const adv = buildAdvancedFilterSql(params.filter, args);
|
||
if (adv) where.push(adv);
|
||
}
|
||
|
||
return {
|
||
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
|
||
args,
|
||
};
|
||
}
|
||
|
||
const ALLOWED_FILTER_FIELDS: ReadonlySet<FilterField> = new Set<FilterField>([
|
||
'url',
|
||
'content_kind',
|
||
'status_code',
|
||
'indexability',
|
||
'title',
|
||
'title_length',
|
||
'meta_description',
|
||
'meta_description_length',
|
||
'h1',
|
||
'h1_length',
|
||
'h1_count',
|
||
'h2_count',
|
||
'word_count',
|
||
'content_type',
|
||
'content_length',
|
||
'response_time_ms',
|
||
'depth',
|
||
'inlinks',
|
||
'outlinks',
|
||
'canonical',
|
||
'meta_robots',
|
||
'x_robots_tag',
|
||
'redirect_target',
|
||
'images_count',
|
||
'images_missing_alt',
|
||
]);
|
||
|
||
function buildAdvancedFilterSql(
|
||
filter: AdvancedFilter,
|
||
args: (string | number)[],
|
||
): string | null {
|
||
const groupSqls: string[] = [];
|
||
for (const group of filter.groups) {
|
||
const clauseSqls: string[] = [];
|
||
for (const clause of group.clauses) {
|
||
const sql = buildClauseSql(clause, args);
|
||
if (sql) clauseSqls.push(sql);
|
||
}
|
||
if (clauseSqls.length > 0) {
|
||
groupSqls.push(`(${clauseSqls.join(' AND ')})`);
|
||
}
|
||
}
|
||
if (groupSqls.length === 0) return null;
|
||
return `(${groupSqls.join(' OR ')})`;
|
||
}
|
||
|
||
function buildClauseSql(c: FilterClause, args: (string | number)[]): string | null {
|
||
// Whitelist-check the column name — it's interpolated into the SQL
|
||
// string, so allowing arbitrary values would be an injection vector.
|
||
if (!ALLOWED_FILTER_FIELDS.has(c.field)) return null;
|
||
const col = c.field;
|
||
switch (c.operator) {
|
||
case 'contains':
|
||
args.push(`%${c.value}%`);
|
||
return `${col} LIKE ?`;
|
||
case 'not_contains':
|
||
args.push(`%${c.value}%`);
|
||
return `(${col} NOT LIKE ? OR ${col} IS NULL)`;
|
||
case 'equals':
|
||
args.push(c.value);
|
||
return `${col} = ?`;
|
||
case 'not_equals':
|
||
args.push(c.value);
|
||
return `(${col} != ? OR ${col} IS NULL)`;
|
||
case 'starts_with':
|
||
args.push(`${c.value}%`);
|
||
return `${col} LIKE ?`;
|
||
case 'ends_with':
|
||
args.push(`%${c.value}`);
|
||
return `${col} LIKE ?`;
|
||
case 'is_empty':
|
||
return `(${col} IS NULL OR ${col} = '')`;
|
||
case 'is_not_empty':
|
||
return `(${col} IS NOT NULL AND ${col} != '')`;
|
||
case 'gt':
|
||
case 'lt':
|
||
case 'gte':
|
||
case 'lte': {
|
||
const n = Number(c.value);
|
||
if (!Number.isFinite(n)) return null;
|
||
args.push(n);
|
||
const op =
|
||
c.operator === 'gt' ? '>' : c.operator === 'lt' ? '<' : c.operator === 'gte' ? '>=' : '<=';
|
||
return `${col} ${op} ?`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function categoryWhereClause(cat: UrlCategory): string | null {
|
||
switch (cat) {
|
||
case 'all':
|
||
return null;
|
||
case 'internal:all':
|
||
return 'is_external = 0';
|
||
case 'internal:html':
|
||
return "is_external = 0 AND content_kind = 'html'";
|
||
case 'internal:js':
|
||
return "is_external = 0 AND content_kind = 'js'";
|
||
case 'internal:css':
|
||
return "is_external = 0 AND content_kind = 'css'";
|
||
case 'internal:image':
|
||
return "is_external = 0 AND content_kind = 'image'";
|
||
case 'internal:pdf':
|
||
return "is_external = 0 AND content_kind = 'pdf'";
|
||
case 'internal:font':
|
||
return "is_external = 0 AND content_kind = 'font'";
|
||
case 'internal:other':
|
||
return "is_external = 0 AND content_kind = 'other'";
|
||
case 'external:all':
|
||
return 'is_external = 1';
|
||
case 'external:html':
|
||
return "is_external = 1 AND content_kind = 'html'";
|
||
case 'external:other':
|
||
return "is_external = 1 AND content_kind != 'html'";
|
||
case 'status:blocked-robots':
|
||
return "indexability = 'non-indexable:robots-blocked'";
|
||
case 'status:no-response':
|
||
return 'status_code IS NULL';
|
||
case 'status:2xx':
|
||
return 'status_code >= 200 AND status_code < 300';
|
||
case 'status:3xx':
|
||
return 'status_code >= 300 AND status_code < 400';
|
||
case 'status:4xx':
|
||
return 'status_code >= 400 AND status_code < 500';
|
||
case 'status:5xx':
|
||
return 'status_code >= 500 AND status_code < 600';
|
||
case 'security:https':
|
||
return "url LIKE 'https://%'";
|
||
case 'security:http':
|
||
return "url LIKE 'http://%'";
|
||
case 'indexability:indexable':
|
||
return "indexability = 'indexable'";
|
||
case 'indexability:non-indexable':
|
||
return "indexability LIKE 'non-indexable%'";
|
||
case 'indexability:noindex':
|
||
return "indexability = 'non-indexable:noindex'";
|
||
case 'indexability:canonicalised':
|
||
return "indexability = 'non-indexable:canonical'";
|
||
case 'indexability:blocked-robots':
|
||
return "indexability = 'non-indexable:robots-blocked'";
|
||
case 'issues:title-missing':
|
||
return "is_external = 0 AND content_kind = 'html' AND (title IS NULL OR title = '')";
|
||
case 'issues:title-too-long':
|
||
return "is_external = 0 AND content_kind = 'html' AND title_length > 60";
|
||
case 'issues:title-too-short':
|
||
return "is_external = 0 AND content_kind = 'html' AND title_length > 0 AND title_length < 30";
|
||
case 'issues:title-duplicate':
|
||
return `is_external = 0 AND content_kind = 'html' AND title IS NOT NULL AND title != ''
|
||
AND title IN (
|
||
SELECT title FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND title IS NOT NULL AND title != ''
|
||
GROUP BY title HAVING COUNT(*) > 1
|
||
)`;
|
||
case 'issues:meta-missing':
|
||
return "is_external = 0 AND content_kind = 'html' AND (meta_description IS NULL OR meta_description = '')";
|
||
case 'issues:meta-too-long':
|
||
return "is_external = 0 AND content_kind = 'html' AND meta_description_length > 160";
|
||
case 'issues:meta-too-short':
|
||
return "is_external = 0 AND content_kind = 'html' AND meta_description_length > 0 AND meta_description_length < 120";
|
||
case 'issues:meta-duplicate':
|
||
return `is_external = 0 AND content_kind = 'html' AND meta_description IS NOT NULL AND meta_description != ''
|
||
AND meta_description IN (
|
||
SELECT meta_description FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND meta_description IS NOT NULL AND meta_description != ''
|
||
GROUP BY meta_description HAVING COUNT(*) > 1
|
||
)`;
|
||
case 'issues:h1-missing':
|
||
return "is_external = 0 AND content_kind = 'html' AND (h1 IS NULL OR h1 = '')";
|
||
case 'issues:h1-duplicate':
|
||
return `is_external = 0 AND content_kind = 'html' AND h1 IS NOT NULL AND h1 != ''
|
||
AND h1 IN (
|
||
SELECT h1 FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND h1 IS NOT NULL AND h1 != ''
|
||
GROUP BY h1 HAVING COUNT(*) > 1
|
||
)`;
|
||
case 'issues:h1-multiple':
|
||
return "is_external = 0 AND content_kind = 'html' AND h1_count > 1";
|
||
case 'issues:heading-skipped-level':
|
||
// A "skipped" heading level is when level N+ exists but level N
|
||
// is missing — the page jumps over a tier (e.g. H1 → H3 with no
|
||
// H2). Each rung is checked independently.
|
||
return `is_external = 0 AND content_kind = 'html' AND (
|
||
(h2_count = 0 AND h3_count > 0)
|
||
OR (h3_count = 0 AND h4_count > 0)
|
||
OR (h4_count = 0 AND h5_count > 0)
|
||
OR (h5_count = 0 AND h6_count > 0)
|
||
)`;
|
||
case 'issues:multiple-canonicals':
|
||
// More than one `<link rel="canonical">` on a page is a confusion
|
||
// signal — Google may pick any of them, defeating the canonical's
|
||
// purpose.
|
||
return "is_external = 0 AND content_kind = 'html' AND canonical_count > 1";
|
||
case 'issues:canonical-missing':
|
||
// HTML 2xx pages that declare neither a `<link rel="canonical">`
|
||
// nor a `Link: rel="canonical"` HTTP header. Without a canonical
|
||
// hint, search engines pick one themselves — possibly the wrong
|
||
// variant on duplicate-prone sites.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (canonical IS NULL OR canonical = '')
|
||
AND (canonical_http IS NULL OR canonical_http = '')`;
|
||
case 'issues:canonical-self-referencing':
|
||
// Informational filter: this page's canonical points back to
|
||
// itself — the typical "good" state for a primary URL.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical = url`;
|
||
case 'issues:canonical-non-self':
|
||
// Page canonical points to a different URL — this page is
|
||
// canonicalised to another. Often intentional (paginated /
|
||
// duplicates) but always worth surfacing.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical != url`;
|
||
case 'issues:canonical-mismatch':
|
||
// HTML and HTTP-header canonicals both exist but disagree —
|
||
// Google picks one unpredictably. Always a misconfiguration.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical_http IS NOT NULL AND canonical_http != ''
|
||
AND canonical != canonical_http`;
|
||
case 'issues:canonical-to-non-200':
|
||
// Canonical points to a URL we crawled and it returned 4xx/5xx —
|
||
// major SEO bug, the canonical is broken. 3xx is excluded here
|
||
// because redirects are surfaced separately under
|
||
// `issues:canonical-to-redirect` to keep the two filters disjoint.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.status_code IS NOT NULL
|
||
AND (t.status_code < 200 OR t.status_code >= 400)
|
||
)`;
|
||
case 'issues:canonical-to-redirect':
|
||
// Canonical points to a 3xx URL — the canonical chain has an extra
|
||
// hop that defeats its purpose. Google may consolidate to the
|
||
// final URL but it's a wasted signal.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.status_code >= 300 AND t.status_code < 400
|
||
)`;
|
||
case 'issues:canonical-to-noindex':
|
||
// Canonical implies "use this as authoritative"; noindex says
|
||
// "don't index". Contradictory — page sends mixed signals to
|
||
// search engines.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.canonical
|
||
AND t.indexability = 'non-indexable:noindex'
|
||
)`;
|
||
case 'issues:content-thin':
|
||
return "is_external = 0 AND content_kind = 'html' AND word_count IS NOT NULL AND word_count < 300";
|
||
case 'issues:response-slow':
|
||
return 'is_external = 0 AND response_time_ms > 1000';
|
||
case 'issues:response-very-slow':
|
||
return 'is_external = 0 AND response_time_ms > 3000';
|
||
case 'issues:page-large':
|
||
return "is_external = 0 AND content_kind = 'html' AND content_length > 1048576";
|
||
case 'issues:url-too-long':
|
||
return 'is_external = 0 AND LENGTH(url) > 2048';
|
||
case 'issues:url-uppercase':
|
||
// GLOB with [A-Z] is case-sensitive — unlike LIKE which isn't.
|
||
return "is_external = 0 AND url GLOB '*[A-Z]*'";
|
||
case 'issues:url-underscore':
|
||
return "is_external = 0 AND INSTR(url, '_') > 0";
|
||
case 'issues:url-multiple-slashes':
|
||
// Strip the `scheme://` prefix, then check for any `//` that remains
|
||
// (path / query doubled slashes aren't usually intentional).
|
||
return "is_external = 0 AND INSTR(SUBSTR(url, INSTR(url, '://') + 3), '//') > 0";
|
||
case 'issues:url-non-ascii':
|
||
// Byte-length (BLOB cast) > character length only when the string
|
||
// contains multi-byte UTF-8, i.e. any non-ASCII code point.
|
||
return 'is_external = 0 AND LENGTH(CAST(url AS BLOB)) != LENGTH(url)';
|
||
case 'issues:lang-missing':
|
||
return "is_external = 0 AND content_kind = 'html' AND (lang IS NULL OR lang = '')";
|
||
case 'issues:viewport-missing':
|
||
return "is_external = 0 AND content_kind = 'html' AND (viewport IS NULL OR viewport = '')";
|
||
case 'issues:og-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (og_title IS NULL OR og_title = '')
|
||
AND (og_description IS NULL OR og_description = '')
|
||
AND (og_image IS NULL OR og_image = '')`;
|
||
case 'issues:twitter-missing':
|
||
// Twitter card is “missing” if there’s no twitter:card tag AND no
|
||
// twitter:image — the minimum pair needed for a valid preview.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (twitter_card IS NULL OR twitter_card = '')
|
||
AND (twitter_image IS NULL OR twitter_image = '')`;
|
||
// HSTS on HTTP is meaningless — only flag HTTPS pages. X-Frame-Options
|
||
// and X-Content-Type-Options matter on any HTML response regardless
|
||
// of scheme, so they're only scheme-gated on a per-page basis.
|
||
case 'issues:hsts-missing':
|
||
return "is_external = 0 AND url LIKE 'https://%' AND (hsts IS NULL OR hsts = '')";
|
||
case 'issues:x-frame-options-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (x_frame_options IS NULL OR x_frame_options = '')`;
|
||
case 'issues:x-content-type-options-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (x_content_type_options IS NULL OR x_content_type_options = '')`;
|
||
case 'issues:csp-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (csp IS NULL OR csp = '')`;
|
||
case 'issues:structured-data-missing':
|
||
// "Missing" = no valid JSON-LD block AND no malformed block either,
|
||
// AND no Microdata `[itemscope]`, AND no RDFa attributes. Schema.org
|
||
// accepts all three formats, so a page using only Microdata isn't
|
||
// missing structured data even though JSON-LD is empty.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND schema_block_count = 0 AND schema_invalid_count = 0
|
||
AND microdata_count = 0 AND rdfa_count = 0`;
|
||
case 'issues:structured-data-invalid':
|
||
return `is_external = 0 AND content_kind = 'html' AND schema_invalid_count > 0`;
|
||
case 'issues:pagination-broken':
|
||
// Page declares a rel=next/prev whose target was crawled and came
|
||
// back as 4xx/5xx — actionable: the pagination chain is broken.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (
|
||
(pagination_next IS NOT NULL AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.pagination_next
|
||
AND t.status_code >= 400 AND t.status_code < 600))
|
||
OR (pagination_prev IS NOT NULL AND EXISTS (
|
||
SELECT 1 FROM urls t WHERE t.url = urls.pagination_prev
|
||
AND t.status_code >= 400 AND t.status_code < 600))
|
||
)`;
|
||
case 'issues:hreflang-x-default-missing':
|
||
// Page declares hreflang alternates but no `x-default` — Google's
|
||
// recommended fallback for unmatched languages.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_count > 0
|
||
AND (hreflangs IS NULL OR INSTR(hreflangs, '"x-default"') = 0)`;
|
||
case 'issues:mixed-content':
|
||
// HTTPS pages that load at least one http:// subresource. The page
|
||
// itself must be HTTPS for this to be meaningful — mixed_content_count
|
||
// is always 0 on http:// pages by construction.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND url LIKE 'https://%' AND mixed_content_count > 0`;
|
||
case 'issues:favicon-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (favicon IS NULL OR favicon = '')`;
|
||
case 'issues:redirect-loop':
|
||
return 'is_external = 0 AND redirect_loop = 1';
|
||
case 'issues:redirect-chain-long':
|
||
// 3 hops is the conservative SF threshold — every extra redirect
|
||
// multiplies link-equity loss and crawl-budget waste.
|
||
return 'is_external = 0 AND redirect_chain_length > 3';
|
||
case 'issues:redirect-self':
|
||
// Redirect target equals the URL itself — a self-loop. Always
|
||
// broken regardless of `followRedirects`.
|
||
return 'is_external = 0 AND redirect_target IS NOT NULL AND redirect_target = url';
|
||
case 'issues:url-many-params':
|
||
// 5+ query params usually means session IDs / faceted-nav explosion.
|
||
return 'is_external = 0 AND query_param_count > 5';
|
||
case 'issues:compression-missing':
|
||
// No Content-Encoding on a successful HTML response = ~70% wasted
|
||
// bandwidth. Skip the scheme-less and non-200 noise.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (content_encoding IS NULL OR content_encoding = '')`;
|
||
case 'issues:non-indexable-in-sitemap':
|
||
// URL declared in sitemap but our crawl found it non-indexable
|
||
// (noindex, canonical-to-other, blocked, redirect, …) — Google flags
|
||
// this as a serious sitemap-quality issue.
|
||
return `is_external = 0 AND indexability LIKE 'non-indexable%'
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`;
|
||
case 'issues:non-200-in-sitemap':
|
||
return `is_external = 0
|
||
AND status_code IS NOT NULL
|
||
AND (status_code < 200 OR status_code >= 300)
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`;
|
||
case 'issues:image-missing-alt':
|
||
return "is_external = 0 AND content_kind = 'html' AND images_missing_alt > 0";
|
||
case 'issues:meta-refresh-used':
|
||
// Any HTML page that declares a `<meta http-equiv="refresh">` —
|
||
// Google explicitly recommends 301 over meta refresh, so every
|
||
// occurrence is worth surfacing.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND meta_refresh IS NOT NULL AND meta_refresh != ''`;
|
||
case 'issues:charset-missing':
|
||
// HTML 2xx page declares no charset anywhere — neither
|
||
// `<meta charset>` / `<meta http-equiv="Content-Type">` nor the
|
||
// HTTP Content-Type header. Browsers fall back to a guess, which
|
||
// can mojibake non-ASCII content.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (charset IS NULL OR charset = '')`;
|
||
// Broken-link categories drive the BrokenLinksTab view; they never
|
||
// filter the URL table itself.
|
||
case 'issues:broken-links-all':
|
||
case 'issues:broken-links-internal':
|
||
case 'issues:broken-links-external':
|
||
return null;
|
||
case 'issues:near-duplicate':
|
||
// SimHash cluster size > 1 means at least one other crawled page
|
||
// landed within the configured Hamming-distance threshold of this
|
||
// one. cluster_id > 0 guards against pre-recompute state.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND cluster_id > 0 AND cluster_size > 1`;
|
||
case 'issues:duplicate-content-exact':
|
||
// Exact body-text collision (FNV-1a over the normalised token
|
||
// stream). Stricter than near-duplicate — useful for spotting
|
||
// accidental ?utm= or session-id variants the URL canonicaliser
|
||
// missed.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND content_hash IS NOT NULL AND content_hash != ''
|
||
AND content_hash IN (
|
||
SELECT content_hash FROM urls
|
||
WHERE is_external = 0 AND content_kind = 'html'
|
||
AND content_hash IS NOT NULL AND content_hash != ''
|
||
GROUP BY content_hash HAVING COUNT(*) > 1
|
||
)`;
|
||
case 'issues:hreflang-invalid-code':
|
||
// Page declares one or more hreflang entries whose lang token is
|
||
// not a valid BCP-47 / `x-default` value. Silent SEO bug — Google
|
||
// ignores invalid entries instead of warning.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_invalid_count > 0`;
|
||
case 'issues:hreflang-self-ref-missing':
|
||
// Page declares hreflang alternates but does not list itself —
|
||
// Google MUST-have. Without it, the cluster is asymmetric and
|
||
// Google may pick any of the alternates as the canonical instead.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_count > 0
|
||
AND hreflang_self_ref_missing = 1`;
|
||
case 'issues:hreflang-reciprocity-missing':
|
||
// Page declares hreflang to N other crawled pages, but at least
|
||
// one of those pages does NOT link back. Asymmetric clusters are
|
||
// a top-3 hreflang misconfiguration in practice.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_reciprocity_missing > 0`;
|
||
case 'issues:hreflang-target-issues':
|
||
// Hreflang target resolves to a non-200 / noindex / canonical-away
|
||
// page. Aggregated: any kind of broken target trips this filter.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_target_issues > 0`;
|
||
case 'issues:crawled-not-in-sitemap':
|
||
// Indexable HTML 2xx URLs the crawl found that are NOT listed in
|
||
// any of the discovered sitemaps. Strong orphan-from-sitemap
|
||
// candidate — Google may not crawl them on its sitemap pass.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND indexability = 'indexable'
|
||
AND NOT EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`;
|
||
case 'issues:redirect-in-sitemap':
|
||
// Sitemap entries that resolve to a redirect (3xx). Sitemap should
|
||
// declare the canonical URL, not redirect sources — Google flags
|
||
// this as a sitemap-quality signal in Search Console.
|
||
return `is_external = 0
|
||
AND status_code >= 300 AND status_code < 400
|
||
AND EXISTS (SELECT 1 FROM sitemap_urls s WHERE s.url = urls.url)`;
|
||
case 'issues:h1-empty':
|
||
// Document has at least one <h1> tag but its text content is empty —
|
||
// distinct from h1-missing (no <h1> at all). Common with image-only
|
||
// headers + missing alt.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND h1_count > 0 AND (h1 IS NULL OR h1 = '')`;
|
||
case 'issues:h1-too-long':
|
||
// 70 chars matches Screaming Frog's default H1 length warn.
|
||
return "is_external = 0 AND content_kind = 'html' AND h1_length > 70";
|
||
case 'issues:title-multiple':
|
||
// More than one <title> tag — spec says exactly one in <head>.
|
||
// Browsers pick the first; SEO tools' behaviour varies.
|
||
return "is_external = 0 AND content_kind = 'html' AND title_count > 1";
|
||
case 'issues:url-fragment':
|
||
// A `#` in the URL means the crawler reached a fragment that wasn't
|
||
// stripped earlier (URL normaliser usually does, but List-mode +
|
||
// some redirect chains can leave it).
|
||
return "is_external = 0 AND INSTR(url, '#') > 0";
|
||
case 'issues:url-spaces':
|
||
// Literal space or `%20` in URL — encoding bug, often from CMS that
|
||
// produced filenames with spaces.
|
||
return "is_external = 0 AND (INSTR(url, ' ') > 0 OR INSTR(url, '%20') > 0)";
|
||
case 'issues:image-empty-alt':
|
||
// alt="" specifically. Decorative images use this intentionally, but
|
||
// many sites apply it to content images by mistake.
|
||
return "is_external = 0 AND content_kind = 'html' AND images_empty_alt > 0";
|
||
case 'issues:link-empty-anchor':
|
||
// Internal links whose anchor has no usable text or alt — Lighthouse
|
||
// a11y "links must have discernible names".
|
||
return "is_external = 0 AND content_kind = 'html' AND empty_anchor_count > 0";
|
||
case 'issues:apple-touch-icon-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (apple_touch_icon IS NULL OR apple_touch_icon = '')`;
|
||
case 'issues:manifest-missing':
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (manifest_url IS NULL OR manifest_url = '')`;
|
||
case 'issues:feed-missing':
|
||
// Informational — only useful on sites that publish content; we
|
||
// surface it but don't put it in default sidebar.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code >= 200 AND status_code < 300
|
||
AND (feed_url IS NULL OR feed_url = '')`;
|
||
case 'issues:title-pixel-width-too-long':
|
||
// Google truncates SERP title at ~600px (Arial 18px). Below that the
|
||
// full title shows; above it the trailing characters become "...".
|
||
// 600px ≈ 60 chars of average mix, but heavy uppercase or wide
|
||
// letters (M, W, …) can hit 600px well before that.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND title_pixel_width > 600`;
|
||
case 'issues:meta-pixel-width-too-long':
|
||
// Meta description truncation point on desktop SERP is ~990px and on
|
||
// mobile ~990px-ish. We use 990px as the ceiling — same threshold
|
||
// applied across viewports keeps the issue check predictable.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND meta_pixel_width > 990`;
|
||
case 'issues:insecure-form-action':
|
||
// HTTPS page submitting form data over plain HTTP. Browsers warn
|
||
// ("Not Secure" interstitial) on submit; one of the highest-ROI
|
||
// findings to fix on a transition-to-HTTPS site.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND url LIKE 'https://%' AND insecure_form_action_count > 0`;
|
||
case 'issues:missing-sri':
|
||
// Third-party `<script>` / `<link rel=stylesheet>` without an
|
||
// `integrity` attribute. Mostly informational — a defence-in-depth
|
||
// recommendation rather than a hard requirement.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND missing_sri_count > 0`;
|
||
case 'issues:ttfb-slow':
|
||
// TTFB > 600 ms is the Core Web Vitals "needs improvement" boundary
|
||
// (Google CrUX). Crawls measure ttfb_ms as request → headers, so
|
||
// it's not exactly equivalent to a real-user RTT but tracks closely.
|
||
return `is_external = 0 AND ttfb_ms IS NOT NULL AND ttfb_ms > 600`;
|
||
case 'issues:ttfb-very-slow':
|
||
// > 1.8 s is the CrUX "poor" boundary.
|
||
return `is_external = 0 AND ttfb_ms IS NOT NULL AND ttfb_ms > 1800`;
|
||
case 'issues:cookie-no-secure':
|
||
// At least one Set-Cookie missing the `Secure` flag — over HTTPS
|
||
// this lets a downgrade-attack snoop the cookie.
|
||
return `is_external = 0 AND cookies_insecure > 0 AND url LIKE 'https://%'`;
|
||
case 'issues:cookie-no-httponly':
|
||
// Missing HttpOnly — JS can read the cookie, expanding the XSS blast
|
||
// radius. Session cookies should always be HttpOnly.
|
||
return `is_external = 0 AND cookies_no_httponly > 0`;
|
||
case 'issues:cookie-no-samesite':
|
||
// Missing SameSite — Chrome treats absent as `Lax` since 80, but
|
||
// explicit declaration is recommended for cross-browser parity.
|
||
return `is_external = 0 AND cookies_no_samesite > 0`;
|
||
case 'issues:query-string-too-long':
|
||
// Long query strings indicate session-id explosion or faceted-nav
|
||
// gone wrong; >100 chars is a practical Screaming-Frog warn.
|
||
return `is_external = 0 AND query_string_length > 100`;
|
||
case 'issues:folder-depth-too-deep':
|
||
// Path-segment depth beyond the conservative SF threshold of 4
|
||
// hides content from Googlebot's link-graph crawl heuristics.
|
||
return `is_external = 0 AND folder_depth > 4`;
|
||
case 'issues:http2-not-supported':
|
||
// Origin advertises only HTTP/1.1 (no Alt-Svc / no h2 token).
|
||
// Informational — modern browsers still work fine, but HTTP/2 is
|
||
// a free CWV win on multi-resource pages.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND http_protocol = 'http/1.1'`;
|
||
case 'issues:render-blocking':
|
||
// 5+ render-blocking head resources is the SF threshold — every one
|
||
// delays first-paint until fetched + parsed. Lighthouse flags this
|
||
// as the top LCP optimisation lever for content-heavy pages.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND render_blocking_count > 5`;
|
||
case 'issues:keepalive-disabled':
|
||
// Server explicitly closed the connection. -1 sentinel = no signal,
|
||
// 0 = Connection: close seen, 1 = keep-alive (or implicit). Only
|
||
// flag when we have a positive signal of `close`.
|
||
return `is_external = 0 AND keep_alive = 0`;
|
||
case 'issues:title-placeholder':
|
||
// Common CMS placeholders: "Untitled", "Default Title", "New Page",
|
||
// "Home" / "Page N". Catches default theme/template values that
|
||
// never got customised — major SEO own-goal because every page
|
||
// shares the same dead-on-arrival SERP snippet.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND title IS NOT NULL AND title != ''
|
||
AND (
|
||
LOWER(title) IN ('untitled', 'untitled document', 'default title',
|
||
'new page', 'page', 'home', 'index', 'document',
|
||
'welcome', 'untitled-1', 'untitled 1', 'home page')
|
||
OR LOWER(title) LIKE 'page %'
|
||
OR LOWER(title) LIKE 'untitled%'
|
||
)`;
|
||
case 'issues:analytics-missing':
|
||
// Indexable HTML pages with no detected tracker. Useful as an audit
|
||
// safety-net — a marketing/content page silently shipping without
|
||
// analytics is invisible to attribution. Skipped for non-indexable
|
||
// pages because robots.txt-blocked / noindex pages shouldn't count.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code BETWEEN 200 AND 299
|
||
AND indexability = 'indexable'
|
||
AND (analytics_trackers IS NULL OR analytics_trackers = '[]' OR analytics_trackers = '')`;
|
||
case 'issues:analytics-multiple-ga4':
|
||
// Multiple `Google Analytics 4` entries imply mis-installed GA
|
||
// (Tag Manager + hardcoded gtag, or two property IDs). The pattern
|
||
// ".*?\"name\":\"Google Analytics 4\".*?\"name\":\"Google Analytics 4\""
|
||
// is awkward in SQL, so we use a position-based check.
|
||
return `is_external = 0 AND analytics_trackers IS NOT NULL
|
||
AND (
|
||
LENGTH(analytics_trackers) - LENGTH(REPLACE(analytics_trackers, '"name":"Google Analytics 4"', ''))
|
||
) / LENGTH('"name":"Google Analytics 4"') > 1`;
|
||
case 'issues:analytics-ua-legacy':
|
||
// Universal Analytics (UA-XXXXX-Y) was sunset 2023-07-01. Any page
|
||
// still loading it is gathering no data — pure dead weight.
|
||
return `is_external = 0 AND analytics_trackers IS NOT NULL
|
||
AND analytics_trackers LIKE '%"name":"Google Analytics (UA)"%'`;
|
||
case 'issues:analytics-pixel-without-policy':
|
||
// Tracking pixels (FB / TikTok / Pinterest / LinkedIn) that share
|
||
// PII with third parties should be paired with a Permissions-Policy
|
||
// declaring the feature scope. Surfacing pages with pixels but no
|
||
// Permissions-Policy header helps GDPR / privacy audits.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND analytics_trackers IS NOT NULL
|
||
AND (analytics_trackers LIKE '%"Facebook Pixel"%'
|
||
OR analytics_trackers LIKE '%"TikTok Pixel"%'
|
||
OR analytics_trackers LIKE '%"Pinterest Tag"%'
|
||
OR analytics_trackers LIKE '%"LinkedIn Insight Tag"%')
|
||
AND (permissions_policy IS NULL OR permissions_policy = '')`;
|
||
case 'issues:image-too-large':
|
||
// Pages that reference at least one internal image whose probed
|
||
// Content-Length is > 100 KB (the PageSpeed Insights threshold).
|
||
// EXISTS is faster than IN(...) on a join because SQLite can stop
|
||
// after the first hit per page.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.is_internal = 1
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 102400
|
||
)`;
|
||
case 'issues:ssl-cert-expired':
|
||
// HTTPS pages whose host's certificate is past `valid_to`. The
|
||
// host is parsed inline from the URL because storing it as a column
|
||
// would balloon the row count for an aggregation we run rarely.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.days_until_expiry IS NOT NULL
|
||
AND hc.days_until_expiry < 0
|
||
)`;
|
||
case 'issues:ssl-cert-expiring-soon':
|
||
// 30-day warning window — matches Let's Encrypt's reminder cadence
|
||
// and the Mozilla Observatory's "expiring soon" threshold.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.days_until_expiry IS NOT NULL
|
||
AND hc.days_until_expiry >= 0
|
||
AND hc.days_until_expiry <= 30
|
||
)`;
|
||
case 'issues:ssl-protocol-old':
|
||
// TLSv1.0 / TLSv1.1 are deprecated by all major browsers (2020).
|
||
// Sites still negotiating them fail PCI-DSS and many corporate
|
||
// proxies block them.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.protocol IS NOT NULL
|
||
AND hc.protocol IN ('TLSv1', 'TLSv1.1', 'SSLv3', 'SSLv2')
|
||
)`;
|
||
case 'issues:ssl-signature-weak':
|
||
// SHA-1 / MD5 signature algorithms are cryptographically broken
|
||
// for cert chains. Browsers stopped accepting SHA-1 in 2017 but
|
||
// self-signed / internal certs sometimes still use it.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND EXISTS (
|
||
SELECT 1 FROM host_certs hc
|
||
WHERE hc.host = LOWER(SUBSTR(urls.url, 9, INSTR(SUBSTR(urls.url, 9), '/') - 1))
|
||
AND hc.signature_algorithm IS NOT NULL
|
||
AND (
|
||
LOWER(hc.signature_algorithm) LIKE '%sha1%'
|
||
OR LOWER(hc.signature_algorithm) LIKE '%md5%'
|
||
)
|
||
)`;
|
||
case 'issues:hsts-no-preload':
|
||
// HSTS preload (https://hstspreload.org) requires the `preload`
|
||
// directive be present in the header. Pages with HSTS-but-no-
|
||
// preload aren't eligible for the preload list and therefore can
|
||
// still be MITM'd on first visit.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND content_kind = 'html'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND LOWER(hsts) NOT LIKE '%preload%'`;
|
||
case 'issues:hsts-max-age-short':
|
||
// HSTS preload submission requires `max-age >= 31536000` (1 year).
|
||
// Lower values protect the user's *current* session but never
|
||
// graduate the host onto the preload list, leaving it exposed.
|
||
// We extract the digits with SQLite string ops — if no max-age
|
||
// value is found at all, the regex fallback (`0`) trips the check.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND content_kind = 'html'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND CAST(
|
||
TRIM(
|
||
SUBSTR(
|
||
LOWER(hsts),
|
||
INSTR(LOWER(hsts), 'max-age=') + 8,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(LOWER(hsts), INSTR(LOWER(hsts), 'max-age=') + 8), ';') > 0
|
||
THEN INSTR(SUBSTR(LOWER(hsts), INSTR(LOWER(hsts), 'max-age=') + 8), ';') - 1
|
||
ELSE LENGTH(hsts)
|
||
END
|
||
)
|
||
) AS INTEGER
|
||
) < 31536000`;
|
||
case 'issues:hsts-no-includesubdomains':
|
||
// Without `includeSubDomains`, an attacker can MITM a forgotten
|
||
// subdomain (`http://old.example.com`) and bypass HSTS for the apex.
|
||
return `is_external = 0 AND url LIKE 'https://%'
|
||
AND content_kind = 'html'
|
||
AND hsts IS NOT NULL AND hsts != ''
|
||
AND LOWER(hsts) NOT LIKE '%includesubdomains%'`;
|
||
case 'issues:anchor-text-too-long':
|
||
// Outgoing links with anchor text > 100 chars usually indicate
|
||
// either a screen-reader unfriendly "fluff anchor" or an entire
|
||
// sentence that should be a paragraph + a focused link.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.anchor IS NOT NULL
|
||
AND LENGTH(l.anchor) > 100
|
||
)`;
|
||
case 'issues:anchor-text-generic':
|
||
// Anchor phrases that carry no SEO value or accessibility context.
|
||
// Google's webmaster guidelines flag these explicitly because they
|
||
// give no signal about the destination.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.anchor IS NOT NULL
|
||
AND LOWER(TRIM(l.anchor)) IN (
|
||
'click here', 'click', 'here', 'read more', 'more',
|
||
'learn more', 'see more', 'continue reading', 'continue',
|
||
'this link', 'link', 'go', 'buraya', 'tıkla', 'devamı',
|
||
'devamını oku', 'daha fazla'
|
||
)
|
||
)`;
|
||
case 'issues:form-input-unlabeled':
|
||
// Pages with at least one form input that has no accessible name
|
||
// (no <label>, no aria-label, no title). WCAG 1.3.1 / 4.1.2 fail.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND form_input_unlabeled > 0`;
|
||
case 'issues:images-no-lazy-loading':
|
||
// Pages with ≥5 images but lazy-loading adoption < 50%. The 5-image
|
||
// floor avoids flagging hero-only pages where lazy-loading the LCP
|
||
// image would actually hurt performance.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND images_count >= 5
|
||
AND (images_lazy * 2) < images_count`;
|
||
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`
|
||
// column from the post-crawl image-size pass — no extra crawl cost.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON i.id = iu.image_id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.probe_status IS NOT NULL
|
||
AND i.probe_status >= 400
|
||
AND i.probe_status < 600
|
||
)`;
|
||
case 'issues:target-blank-no-noopener':
|
||
// `<a target="_blank">` without `rel="noopener"` allows the new
|
||
// tab to access `window.opener` — a reverse-tabnabbing vector
|
||
// OWASP / Mozilla flag explicitly. `rel="noreferrer"` also
|
||
// implies noopener so we accept either.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
WHERE l.from_url_id = urls.id
|
||
AND LOWER(COALESCE(l.target, '')) = '_blank'
|
||
AND (
|
||
l.rel IS NULL
|
||
OR (
|
||
LOWER(l.rel) NOT LIKE '%noopener%'
|
||
AND LOWER(l.rel) NOT LIKE '%noreferrer%'
|
||
)
|
||
)
|
||
)`;
|
||
case 'issues:page-empty':
|
||
// 2xx HTML pages with effectively no body content. Sometimes a 404
|
||
// template renders 200 OK with a blank page, sometimes a publishing
|
||
// workflow leaves an unfinished draft accessible. Either way it's
|
||
// wasted crawl budget and a thin-content signal.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code BETWEEN 200 AND 299
|
||
AND word_count IS NOT NULL
|
||
AND word_count < 30`;
|
||
case 'issues:og-image-not-absolute':
|
||
// OpenGraph requires absolute URLs for og:image. Relative paths
|
||
// simply don't render in Facebook / LinkedIn share cards.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND og_image IS NOT NULL AND og_image != ''
|
||
AND og_image NOT LIKE 'http://%'
|
||
AND og_image NOT LIKE 'https://%'`;
|
||
case 'issues:twitter-image-not-absolute':
|
||
// Twitter (X) similarly requires absolute URLs for twitter:image.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND twitter_image IS NOT NULL AND twitter_image != ''
|
||
AND twitter_image NOT LIKE 'http://%'
|
||
AND twitter_image NOT LIKE 'https://%'`;
|
||
case 'issues:canonical-not-absolute':
|
||
// Google recommends absolute canonical URLs (rfc 5988). Relative
|
||
// canonicals work but are easy to break with subdirectory moves
|
||
// and confuse crawlers running the page in detached/AMP contexts.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical NOT LIKE 'http://%'
|
||
AND canonical NOT LIKE 'https://%'`;
|
||
case 'issues:description-equals-title':
|
||
// Meta description duplicates the title verbatim — lazy SEO that
|
||
// wastes the second SERP signal a page gets to influence CTR.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND title IS NOT NULL AND title != ''
|
||
AND meta_description IS NOT NULL AND meta_description != ''
|
||
AND TRIM(LOWER(title)) = TRIM(LOWER(meta_description))`;
|
||
case 'issues:title-single-word':
|
||
// Single-token titles ("Home", "Blog", "Products") are almost
|
||
// always too generic to rank — and split between this filter and
|
||
// `title-placeholder` so users can review separately.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND title IS NOT NULL AND title != ''
|
||
AND TRIM(title) NOT LIKE '% %'`;
|
||
case 'issues:external-links-too-many':
|
||
// Pages linking to >100 external destinations look like link
|
||
// farms / scraper SERPs to crawlers and are routinely demoted.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM (
|
||
SELECT from_url_id, COUNT(*) AS c
|
||
FROM links
|
||
WHERE is_internal = 0
|
||
GROUP BY from_url_id
|
||
HAVING c > 100
|
||
) e
|
||
WHERE e.from_url_id = urls.id
|
||
)`;
|
||
case 'issues:outlinks-zero':
|
||
// Indexable HTML pages with zero outlinks are dead-end leaves —
|
||
// bad for crawl flow, internal-link equity distribution, and the
|
||
// user's path through the site.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND status_code BETWEEN 200 AND 299
|
||
AND indexability = 'indexable'
|
||
AND outlinks = 0`;
|
||
case 'issues:internal-link-to-redirect':
|
||
// A page links to ≥1 internal URL whose status is 3xx. Each hop
|
||
// burns crawl budget and weakens link equity — best-practice is
|
||
// to update every link to the redirect's final destination.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
JOIN urls t ON l.to_url = t.url
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.is_internal = 1
|
||
AND t.status_code >= 300 AND t.status_code < 400
|
||
)`;
|
||
case 'issues:h1-equals-title':
|
||
// Title and H1 are *almost* always supposed to differ —
|
||
// duplicating one as the other wastes the second relevance
|
||
// signal Google reads from a page. Common when the CMS
|
||
// auto-fills both from the same field.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND title IS NOT NULL AND title != ''
|
||
AND h1 IS NOT NULL AND h1 != ''
|
||
AND TRIM(LOWER(title)) = TRIM(LOWER(h1))`;
|
||
case 'issues:dead-external-domain':
|
||
// Page links to an external domain whose own crawled pages are
|
||
// mostly broken: ≥3 attempts (so a single 404 doesn't poison a
|
||
// whole site) AND ≥80% error rate. Both thresholds match the
|
||
// External Domain Health report's "BAD" classification so the
|
||
// sidebar count and the report agree on what counts as dead.
|
||
// The host extraction is inlined below; SQLite's substring
|
||
// arithmetic is verbose but stays in the query planner so a
|
||
// 100K-URL crawl resolves this in a single pass.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND EXISTS (
|
||
SELECT 1 FROM links l
|
||
JOIN urls eu ON l.to_url = eu.url
|
||
WHERE l.from_url_id = urls.id
|
||
AND l.is_internal = 0
|
||
AND eu.is_external = 1
|
||
AND LOWER(
|
||
SUBSTR(
|
||
eu.url,
|
||
INSTR(eu.url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(eu.url, INSTR(eu.url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(eu.url, INSTR(eu.url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(eu.url)
|
||
END
|
||
)
|
||
) IN (
|
||
SELECT host_grouped FROM (
|
||
SELECT
|
||
LOWER(
|
||
SUBSTR(
|
||
url,
|
||
INSTR(url, '://') + 3,
|
||
CASE
|
||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||
ELSE LENGTH(url)
|
||
END
|
||
)
|
||
) AS host_grouped,
|
||
COUNT(*) AS total,
|
||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors
|
||
FROM urls
|
||
WHERE is_external = 1 AND status_code IS NOT NULL
|
||
GROUP BY host_grouped
|
||
HAVING total >= 3 AND CAST(errors AS REAL) / total >= 0.8
|
||
)
|
||
)
|
||
)`;
|
||
case 'issues:duplicate-url-post-norm':
|
||
// Two distinct URL rows that collapse to the same value once
|
||
// common normalisations are applied: lowercase the host, strip a
|
||
// trailing slash, drop the query string. We don't strip the
|
||
// fragment because we already have a separate "Fragment in URL"
|
||
// issue and a fragment-only difference is rare in real corpora.
|
||
// EXISTS pattern matches at least one OTHER row with the same
|
||
// normalised key; current row excluded by `urls.id <> u2.id`.
|
||
return `is_external = 0
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls u2
|
||
WHERE u2.id <> urls.id
|
||
AND u2.is_external = 0
|
||
AND RTRIM(
|
||
LOWER(
|
||
CASE
|
||
WHEN INSTR(u2.url, '?') > 0
|
||
THEN SUBSTR(u2.url, 1, INSTR(u2.url, '?') - 1)
|
||
ELSE u2.url
|
||
END
|
||
),
|
||
'/'
|
||
) =
|
||
RTRIM(
|
||
LOWER(
|
||
CASE
|
||
WHEN INSTR(urls.url, '?') > 0
|
||
THEN SUBSTR(urls.url, 1, INSTR(urls.url, '?') - 1)
|
||
ELSE urls.url
|
||
END
|
||
),
|
||
'/'
|
||
)
|
||
)`;
|
||
case 'issues:canonical-chain-multi-hop':
|
||
// Page has a canonical, AND that canonical's target is also a
|
||
// crawled URL with its own *different* canonical. Two-hop
|
||
// detection is enough for the bulk of misconfigurations — deeper
|
||
// chains cascade through this filter too because the second hop
|
||
// would also surface as a multi-hop chain on its own row.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND canonical IS NOT NULL AND canonical != ''
|
||
AND canonical != url
|
||
AND EXISTS (
|
||
SELECT 1 FROM urls c2
|
||
WHERE c2.url = urls.canonical
|
||
AND c2.canonical IS NOT NULL
|
||
AND c2.canonical != ''
|
||
AND c2.canonical != c2.url
|
||
AND c2.canonical != urls.canonical
|
||
)`;
|
||
case 'issues:image-slow-loading':
|
||
// Page loads at least one image > 200 KB AND the page hasn't
|
||
// applied lazy-loading to every image. Big un-lazy images are
|
||
// LCP killers on mobile and waste data budget on every reload.
|
||
// 200 KB matches the existing "Large Image" threshold so the
|
||
// user sees the same set spanning two complementary issues.
|
||
// Lazy adoption is tracked per-page (images_lazy / images_count)
|
||
// because the parser doesn't store a per-image `loading` flag.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND images_count > 0 AND images_lazy < images_count
|
||
AND EXISTS (
|
||
SELECT 1 FROM image_usages iu
|
||
JOIN images i ON iu.image_id = i.id
|
||
WHERE iu.from_url_id = urls.id
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 204800
|
||
)`;
|
||
case 'issues:description-equals-h1':
|
||
// Meta description verbatim duplicates the H1 — same lazy
|
||
// copy-paste pattern as `description-equals-title` but coming
|
||
// from a different copy direction (CMS auto-fills meta from H1
|
||
// when title is intentionally different).
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND meta_description IS NOT NULL AND meta_description != ''
|
||
AND h1 IS NOT NULL AND h1 != ''
|
||
AND TRIM(LOWER(meta_description)) = TRIM(LOWER(h1))`;
|
||
case 'issues:js-only-navigation':
|
||
// Page has ≥1 `<a>` that's clickable but not crawlable:
|
||
// - `<a onclick="…">` (no href at all)
|
||
// - `<a href="javascript:…">`
|
||
// - `<a href="#" onclick="…">`
|
||
// Bots can't follow these so any nav that depends on them is
|
||
// invisible; counted at parse time into js_only_links_count.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND js_only_links_count > 0`;
|
||
case 'issues:text-code-ratio-low':
|
||
// Visible-text bytes < 10% of total HTML bytes. Almost always a
|
||
// template-heavy / SPA shell that ships markup but no content.
|
||
// 10% is the conventional Screaming Frog threshold; tunable if
|
||
// a per-project setting is added later.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND text_code_ratio IS NOT NULL
|
||
AND text_code_ratio < 10`;
|
||
case 'issues:render-blocking-critical':
|
||
// Tier above the existing "Render-Blocking Head (>5)" issue.
|
||
// 20+ blocking head resources is almost universally third-party
|
||
// bloat (analytics/tag managers/A-B test loaders) and a top-3
|
||
// cause of slow LCP on any modern site audit.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND render_blocking_count > 20`;
|
||
case 'issues:og-image-too-large':
|
||
// og:image > 5 MB. Facebook's documented hard cap is 8 MB; share
|
||
// card renderers silently drop oversize images well before that.
|
||
// Joins on the per-image HEAD probe to read `byte_size`.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND og_image IS NOT NULL AND og_image != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM images i
|
||
WHERE i.src = urls.og_image
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 5242880
|
||
)`;
|
||
case 'issues:twitter-image-too-large':
|
||
// twitter:image > 5 MB. Conservative threshold that catches both
|
||
// JPG/PNG (Twitter cap 5 MB) and GIF (cap 15 MB) issues.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND twitter_image IS NOT NULL AND twitter_image != ''
|
||
AND EXISTS (
|
||
SELECT 1 FROM images i
|
||
WHERE i.src = urls.twitter_image
|
||
AND i.byte_size IS NOT NULL
|
||
AND i.byte_size > 5242880
|
||
)`;
|
||
case 'issues:pagination-sequence-break':
|
||
// Set post-crawl by `recomputePaginationSequence()` — see the
|
||
// implementation comment there for the gap-detection algorithm
|
||
// and the URL pattern recognisers that produce ordinals.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND pagination_sequence_break = 1`;
|
||
case 'issues:links-per-page-too-many':
|
||
// Total outgoing links (internal + external, deduplicated) > 100.
|
||
// Distinct from `external-links-too-many` which only counts the
|
||
// external bucket — this filter trips on internal-heavy pages too
|
||
// (mega-menus, sitemap-style hub pages) which Google's PageRank
|
||
// dilution makes equally problematic.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND outlinks > 100`;
|
||
case 'tab:redirects':
|
||
// Wave 4 — Redirects tab. Every internal URL whose status is 3xx
|
||
// is a redirect hop. Each hop already has its own row in the
|
||
// `urls` table (the crawler enqueues redirect targets so we
|
||
// capture full chains as discrete rows), so a simple status-class
|
||
// filter recovers the full set; the table's `redirect_target`,
|
||
// `redirect_chain_length`, `redirect_final_url`, and
|
||
// `redirect_loop` columns surface chain context per row.
|
||
return `is_external = 0
|
||
AND status_code >= 300 AND status_code < 400`;
|
||
case 'tab:canonicals':
|
||
// Wave 4 — Canonicals tab. Every HTML page that declares a
|
||
// canonical (HTML link tag OR HTTP `Link: rel=canonical` header).
|
||
// Self-referencing, cross-page, and mismatched canonicals all
|
||
// surface here; the per-row "Canonical" + "Canonical (HTTP)"
|
||
// columns let the user audit them visually.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (
|
||
(canonical IS NOT NULL AND canonical != '')
|
||
OR (canonical_http IS NOT NULL AND canonical_http != '')
|
||
)`;
|
||
case 'issues:hreflang-inconsistent-lang':
|
||
// Set post-crawl by `recomputeHreflangInconsistent()` — same `lang`
|
||
// token mapped to two different hrefs on the same page.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND hreflang_inconsistent_lang = 1`;
|
||
case 'tab:directives':
|
||
// Wave 4 — Directives tab. Pages declaring any indexability
|
||
// directive: meta robots, X-Robots-Tag header, robots.txt block,
|
||
// or canonical-to-other (which acts as an indexability signal).
|
||
// The `Meta Robots`, `X-Robots-Tag`, and `Indexability Reason`
|
||
// columns make the active directives readable at a glance.
|
||
return `is_external = 0 AND content_kind = 'html'
|
||
AND (
|
||
(meta_robots IS NOT NULL AND meta_robots != '')
|
||
OR (x_robots_tag IS NOT NULL AND x_robots_tag != '')
|
||
OR indexability LIKE 'non-indexable%'
|
||
)`;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validate a hreflang `lang` token against the practical BCP-47 subset
|
||
* that Google accepts: ISO 639-1 (2 chars), ISO 639-2/3 (3 chars),
|
||
* optional region (`-XX` country or `-NNN` UN M.49 numeric), or the
|
||
* literal `x-default`. Case-insensitive in the wild (`tr-TR` and
|
||
* `TR-tr` both accepted by Google) — we lowercase before matching.
|
||
*
|
||
* Rejects: bare uppercase/lowercase mixed errors, spaces, country-only,
|
||
* underscored variants, three-letter region codes that aren't M.49.
|
||
*/
|
||
function isValidHreflangCode(raw: string): boolean {
|
||
const code = raw.trim().toLowerCase();
|
||
if (!code) return false;
|
||
if (code === 'x-default') return true;
|
||
return /^[a-z]{2,3}(-[a-z]{2}|-[0-9]{3})?$/.test(code);
|
||
}
|
||
|
||
/**
|
||
* Number of `/`-delimited path segments in the URL (e.g. `/a/b/c` → 3,
|
||
* `/` → 0). Falls back to 0 on parse errors so the column is always a
|
||
* plain integer for simple SQL filtering / aggregation.
|
||
*/
|
||
function computeFolderDepth(rawUrl: string): number {
|
||
try {
|
||
const u = new URL(rawUrl);
|
||
const path = u.pathname;
|
||
if (!path || path === '/') return 0;
|
||
return path.split('/').filter((s) => s.length > 0).length;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** Number of `?key=…&key=…` parameters in the query string. */
|
||
function computeQueryParamCount(rawUrl: string): number {
|
||
try {
|
||
const u = new URL(rawUrl);
|
||
let n = 0;
|
||
for (const _ of u.searchParams) n++;
|
||
return n;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|