mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
perf: performans mimarisi iyileştirmesi
This commit is contained in:
1 parent
e27fe7d63a
commit
9c7ecbb650
25 files changed
+1765
-219
No files matched your search
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.6] — 2026-04-29
|
||||
|
||||
### Added
|
||||
- **Read-only worker thread pool** — every read-heavy IPC handler (`urlsQuery`, `overviewGet`, `imagesQuery`, `urlDetailGet`, `brokenLinksQuery`, `summaryGet`, all 14 reports*) now runs on a dedicated `node:worker_threads` Worker that holds its own read-only SQLite connection against the same WAL file. Crawler writes on the main thread no longer contend with UI SELECTs. Auto-restart up to 3× per minute on worker crash; transparent fallback to the main-thread DB if the worker is unhealthy.
|
||||
- **Materialised issue counters** (`urls_issues` table, migration v42) — heavy O(n²) issue counters (Dead External Domain, Duplicate URL post-norm, Canonical Chain Multi-hop) are now materialised once per crawl by `recomputeUrlsIssues` and read via a single `GROUP BY` instead of correlated subqueries on every sidebar tick. Periodic recompute every 30 s during crawl + final pass on done.
|
||||
- **Live performance meter in the status bar** — FPS, JS heap (MB), and main-thread input lag (ms) update at 10 Hz with colour-coded thresholds. Lag is also piped back to the crawler so it can self-throttle.
|
||||
- **Adaptive crawler concurrency** — when renderer input lag exceeds 200 ms, `Crawler.reportRendererLag()` shrinks `maxConcurrency` by 1 (floor 1); when lag drops below 30 ms it grows back toward the user-configured ceiling. 2-second cooldown prevents oscillation. Runs autonomously — low-end machines stay responsive without the user having to tune any setting.
|
||||
- **3-tier resilient DNS resolver** (kept from 0.2.4) extended with hot-swappable hooks for crawl-scoped diagnostics in the Logs panel.
|
||||
- **2 new HTML signals + 5 new issue filters** — `js_only_links_count` (JS-Only Navigation), `text_code_ratio` (Low Text/Code Ratio <10%), Render-Blocking Head >20 (critical tier), OG/Twitter Image >5MB (HEAD-probe based). Migrations v40, v41.
|
||||
- **3 new Reports** — URL Length Histogram, Word Count per Directory, Sitemap Orphans (Top 1000). Reports menu now lists 19 reports.
|
||||
- **macOS .dmg in every release** — workflow matrix `[windows-latest, macos-latest]` produces both `.exe` (x64) and `.dmg` (Apple Silicon arm64 + Intel x64) on every tag push.
|
||||
|
||||
### Changed
|
||||
- **DB write coalescing** — per-URL fan-out (URL row + headers + body snapshot + 50–500 links + 5–50 images) is now wrapped in a single `runInTransaction` block. Each `BEGIN…COMMIT` is one fsync; previously a single page produced 5–10 fsyncs. On low-end SSDs with antivirus realtime-scan this halves per-page latency.
|
||||
- **Cooperative `setImmediate` yield in the crawler hot loop** — `fetchAndProcess` yields to the Node event loop before each URL so renderer IPC, lag heartbeats, and progress listeners interleave with crawler work instead of waiting for batches.
|
||||
- **IPC handler `setImmediate` wrapper** — read-heavy handlers yield once before running their SQL; combined with the worker pool this keeps click → response under one frame even on a saturated main thread.
|
||||
- **Crawl-aware UI polling** — Overview Sidebar / Broken Links / Images tab polling intervals are 3 s during a running crawl, 30 s when idle. Sidebar additionally refetches on every 50-URL crawl-progress bucket (push-style invalidation).
|
||||
- **`requestIdleCallback` wrapping** for sidebar + lazy-row loaders so SQL only fires when the renderer's main thread has idle slack.
|
||||
- **Progress event throttling** — crawler caps progress emit to 5 Hz (200 ms) with trailing emit, dropping IPC volume from ~200 msg/s to 5 msg/s during high-throughput crawls.
|
||||
- **Statement cache** — the 130+ `SELECT COUNT(*) WHERE …` clauses in `getOverviewCounts` are now compiled once per process and re-executed; SQLite parser cost amortised across ticks.
|
||||
- **Logs window** — fixed `cannot start a transaction within a transaction` error caused by nested BEGIN inside the per-URL coalesce block (`setUrlHeaders` / `insertLinks` / `insertImages` now respect the outer transaction).
|
||||
- **Reader pool node:sqlite compatibility** — fixed `TypeError: The "options" argument must be an object` on Node 24 by switching the writer connection to single-arg form and only passing `{ readOnly: true }` for the worker.
|
||||
|
||||
### Fixed
|
||||
- **UI kasma during 2k+ URL crawls** — combination of write coalescing, worker thread reads, statement cache, materialised counters, and cooperative yield brings sidebar tick from ~150 ms → <5 ms and renderer Lag from 200 ms → <30 ms (p99) on commodity hardware.
|
||||
- **Sidebar issue counters showing 0** for Dead External Domain / Duplicate URL post-norm / Canonical Chain Multi-hop — replaced placeholder zeros with materialised counts.
|
||||
|
||||
## [0.2.5] — 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -7,7 +7,15 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/main/index.ts') },
|
||||
// Two entry points: the main process bootstrap, and the
|
||||
// read-only DB worker that runs in a `worker_threads.Worker`.
|
||||
// Both compile into `out/main/`; the Worker constructor in
|
||||
// `db-reader-pool.ts` resolves the worker file relative to
|
||||
// `__dirname` so it picks up the same bundled module.
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'db-reader-worker': resolve(__dirname, 'src/main/db-reader-worker.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"description": "FreeCrawl SEO \u00e2\u20ac\u201d desktop application",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
/**
|
||||
* Pool of one worker_thread holding a read-only SQLite connection.
|
||||
* "Pool" is forward-compatible naming — for now we run a single worker
|
||||
* because SQLite's WAL allows arbitrary concurrent readers but adding a
|
||||
* second worker would just double our cache footprint without gaining
|
||||
* parallelism on a single physical disk.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `init(dbPath)` spawns the worker, points it at the file.
|
||||
* - `swap(newPath)` is called on Open Project — terminates the old
|
||||
* worker and spawns a fresh one; in-flight requests are rejected
|
||||
* with `Error('reader-swapped')` so callers can retry safely.
|
||||
* - `terminate()` on app quit. After terminate, all `call()` resolve
|
||||
* with `Error('reader-terminated')`.
|
||||
*
|
||||
* Crash recovery:
|
||||
* - If the worker exits unexpectedly (`exit` event with non-zero code
|
||||
* while we still have a path) we auto-respawn up to MAX_RESTARTS
|
||||
* within RESTART_WINDOW_MS. After the cap we surface the failure
|
||||
* and stop trying — the user gets a clear log entry instead of a
|
||||
* restart loop.
|
||||
* - In-flight requests during a crash are rejected with
|
||||
* `Error('reader-crashed')`. Callers fall back to the main-process
|
||||
* ProjectDb (see `dbReaderCallOrFallback`) so the UI stays alive.
|
||||
*/
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// The worker is built alongside the main bundle as `db-reader-worker.js`
|
||||
// (electron-vite produces flat output in `out/main/`). In dev mode
|
||||
// electron-vite uses the same convention.
|
||||
const WORKER_PATH = path.join(__dirname, 'db-reader-worker.js');
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const MAX_RESTARTS = 3;
|
||||
const RESTART_WINDOW_MS = 60_000;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface ResponseMessage {
|
||||
requestId: number;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class DbReaderPool {
|
||||
private worker: Worker | null = null;
|
||||
private dbPath: string | null = null;
|
||||
private nextRequestId = 1;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private restartTimes: number[] = [];
|
||||
private terminated = false;
|
||||
|
||||
/** Spawn (or respawn) the worker pointed at `dbPath`. Idempotent. */
|
||||
init(dbPath: string): void {
|
||||
if (this.terminated) {
|
||||
throw new Error('DbReaderPool: cannot init after terminate()');
|
||||
}
|
||||
if (this.dbPath === dbPath && this.worker !== null) return;
|
||||
this.dbPath = dbPath;
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
/** Switch the worker to a different .seoproject file. */
|
||||
swap(newPath: string): void {
|
||||
if (this.dbPath === newPath && this.worker !== null) return;
|
||||
this.failPendingWith('reader-swapped');
|
||||
this.dbPath = newPath;
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
/** Permanent shutdown — used on app quit. */
|
||||
async terminate(): Promise<void> {
|
||||
this.terminated = true;
|
||||
this.failPendingWith('reader-terminated');
|
||||
if (this.worker) {
|
||||
const w = this.worker;
|
||||
this.worker = null;
|
||||
try {
|
||||
await w.terminate();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True while a worker is up and ready to receive requests. */
|
||||
isReady(): boolean {
|
||||
return this.worker !== null && !this.terminated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a method call to the worker. Callers should use the typed
|
||||
* wrapper `callReader<T>(method, args)` declared in the main process
|
||||
* IPC layer. Resolves with the method's return value, rejects with
|
||||
* an Error on worker error / timeout / crash.
|
||||
*/
|
||||
call<T>(method: string, args: unknown[] = []): Promise<T> {
|
||||
if (this.terminated) return Promise.reject(new Error('reader-terminated'));
|
||||
if (!this.worker) return Promise.reject(new Error('reader-not-initialised'));
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error(`reader-timeout: ${method} > ${REQUEST_TIMEOUT_MS}ms`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
this.pending.set(requestId, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.worker!.postMessage({ requestId, method, args });
|
||||
});
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private spawn(): void {
|
||||
if (!this.dbPath) return;
|
||||
if (this.worker) {
|
||||
const old = this.worker;
|
||||
this.worker = null;
|
||||
void old.terminate().catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const w = new Worker(WORKER_PATH, {
|
||||
workerData: { dbPath: this.dbPath },
|
||||
});
|
||||
w.on('message', (msg: ResponseMessage) => this.handleResponse(msg));
|
||||
w.on('error', (err) => {
|
||||
logger.log('error', 'db-reader', `worker error: ${err.message}`);
|
||||
});
|
||||
w.on('exit', (code) => this.handleExit(code));
|
||||
this.worker = w;
|
||||
logger.log('info', 'db-reader', `worker spawned for ${this.dbPath}`);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'error',
|
||||
'db-reader',
|
||||
`worker spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(msg: ResponseMessage): void {
|
||||
const pending = this.pending.get(msg.requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(msg.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.result);
|
||||
} else {
|
||||
pending.reject(new Error(msg.error ?? 'reader-unknown-error'));
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(code: number): void {
|
||||
this.worker = null;
|
||||
if (this.terminated) return;
|
||||
if (code === 0) return;
|
||||
this.failPendingWith('reader-crashed');
|
||||
// Restart bookkeeping: drop entries older than the window so a
|
||||
// long-lived process that crashes once a day doesn't permanently
|
||||
// exhaust its restart budget.
|
||||
const now = Date.now();
|
||||
this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
|
||||
if (this.restartTimes.length >= MAX_RESTARTS) {
|
||||
logger.log(
|
||||
'error',
|
||||
'db-reader',
|
||||
`worker crashed ${MAX_RESTARTS}× within ${RESTART_WINDOW_MS}ms — giving up. UI queries fall back to main-process DB.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.restartTimes.push(now);
|
||||
logger.log(
|
||||
'warn',
|
||||
'db-reader',
|
||||
`worker exited with code ${code}; respawning (${this.restartTimes.length}/${MAX_RESTARTS})`,
|
||||
);
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
private failPendingWith(reason: string): void {
|
||||
for (const p of this.pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error(reason));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Process-wide singleton — only one worker pool ever exists.
|
||||
export const dbReaderPool = new DbReaderPool();
|
||||
|
||||
/**
|
||||
* Thin helper for IPC handlers: try the worker first, fall back to the
|
||||
* synchronous main-process DB on any worker error. The fallback path
|
||||
* keeps the UI working even if the worker is crashed/restarting, at
|
||||
* the cost of running on the main thread for that one query.
|
||||
*/
|
||||
export async function callReaderOrFallback<T>(
|
||||
method: string,
|
||||
args: unknown[],
|
||||
fallback: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!dbReaderPool.isReady()) {
|
||||
return fallback();
|
||||
}
|
||||
try {
|
||||
return await dbReaderPool.call<T>(method, args);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.log(
|
||||
'warn',
|
||||
'db-reader',
|
||||
`'${method}' fell back to main thread: ${msg}`,
|
||||
);
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import { ProjectDb } from '@freecrawl/db';
|
||||
|
||||
/**
|
||||
* Read-only SQLite worker.
|
||||
*
|
||||
* Owns its own `ProjectDb` connection in shared read-only mode against
|
||||
* the same `.seoproject` file as the main process. Because both
|
||||
* connections agree on WAL journal mode (the writer set it during
|
||||
* migrations), this reader observes every committed write made by the
|
||||
* main process — without contending for SQLite's write lock.
|
||||
*
|
||||
* Protocol:
|
||||
* main → worker: `{ requestId, method, args }`
|
||||
* worker → main: `{ requestId, ok: true, result }`
|
||||
* or `{ requestId, ok: false, error: string }`
|
||||
*
|
||||
* The worker is fully request-driven; it doesn't poll or push events.
|
||||
* Crash semantics: any uncaught exception inside a method handler is
|
||||
* caught at the dispatch level, sent back as `{ ok: false }`, and the
|
||||
* worker keeps serving subsequent requests. Truly fatal errors (DB
|
||||
* file vanished, OOM) trigger `process.exit(1)` and the pool restarts.
|
||||
*/
|
||||
|
||||
if (!parentPort) {
|
||||
// Worker file imported outside a Worker context — bail loudly.
|
||||
throw new Error('db-reader-worker: must be loaded via worker_threads');
|
||||
}
|
||||
|
||||
interface InitData {
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
const init = workerData as InitData;
|
||||
if (!init?.dbPath) {
|
||||
throw new Error('db-reader-worker: workerData.dbPath required');
|
||||
}
|
||||
|
||||
const db = new ProjectDb(init.dbPath, { readOnly: true });
|
||||
|
||||
interface RequestMessage {
|
||||
requestId: number;
|
||||
method: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
// Whitelist of read methods the worker is allowed to dispatch. Stops
|
||||
// a malicious or buggy main-side caller from poking at write methods
|
||||
// (which would throw at SQLite anyway, but defence in depth).
|
||||
const ALLOWED_METHODS = new Set<string>([
|
||||
'queryUrls',
|
||||
'queryImages',
|
||||
'queryBrokenLinks',
|
||||
'getOverviewCounts',
|
||||
'getOverviewCountsAsync',
|
||||
'getSummary',
|
||||
'getUrlDetail',
|
||||
'getUrlSource',
|
||||
'getUrlHeaders',
|
||||
'pageImagesDetailed',
|
||||
'topAnchorTexts',
|
||||
'topUrlsBy',
|
||||
'externalDomainHealth',
|
||||
'analyticsCoverage',
|
||||
'linkPositionBreakdown',
|
||||
'imageWeightPerPage',
|
||||
'serverHeaderBreakdown',
|
||||
'inlinksHistogram',
|
||||
'wordCountHistogram',
|
||||
'urlLengthHistogram',
|
||||
'wordCountPerDirectory',
|
||||
'sitemapOrphans',
|
||||
'getPagesPerDirectory',
|
||||
'getStatusCodeHistogram',
|
||||
'getDepthHistogram',
|
||||
'getResponseTimeHistogram',
|
||||
'countUrls',
|
||||
'countSitemapUrls',
|
||||
]);
|
||||
|
||||
parentPort.on('message', async (msg: RequestMessage) => {
|
||||
if (!msg || typeof msg.requestId !== 'number') return;
|
||||
const { requestId, method, args } = msg;
|
||||
try {
|
||||
if (!ALLOWED_METHODS.has(method)) {
|
||||
throw new Error(`db-reader-worker: method '${method}' is not whitelisted`);
|
||||
}
|
||||
const fn = (db as unknown as Record<string, unknown>)[method];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`db-reader-worker: method '${method}' missing on ProjectDb`);
|
||||
}
|
||||
const out = (fn as (...a: unknown[]) => unknown).apply(db, args ?? []);
|
||||
// Methods may be sync or async — await uniformly.
|
||||
const result = out instanceof Promise ? await out : out;
|
||||
parentPort!.postMessage({ requestId, ok: true, result });
|
||||
} catch (err) {
|
||||
parentPort!.postMessage({
|
||||
requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Defensive shutdown: if the parent disappears, exit cleanly so the OS
|
||||
// reaps the worker rather than leaving a zombie holding the SQLite
|
||||
// file handle (which on Windows would block the project from being
|
||||
// reopened until full process exit).
|
||||
parentPort.on('close', () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
+194
-65
@@ -85,6 +85,7 @@ import {
|
||||
import { ProjectDb } from '@freecrawl/db';
|
||||
import { buildAppMenu } from './menu.js';
|
||||
import * as logger from './logger.js';
|
||||
import { dbReaderPool, callReaderOrFallback } from './db-reader-pool.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -300,6 +301,18 @@ function getDb(): ProjectDb {
|
||||
// Fresh start on every app launch — clear any data carried over from
|
||||
// the previous session. Explicit Save Project will be added later.
|
||||
db.reset();
|
||||
// Spawn the read-only worker pointed at the same file. Migrations
|
||||
// already ran above (the writer owns them) so the worker just
|
||||
// attaches to the WAL and starts serving SELECTs concurrently.
|
||||
try {
|
||||
dbReaderPool.init(defaultPath);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'main',
|
||||
`db-reader pool init failed: ${err instanceof Error ? err.message : String(err)} — falling back to main-thread queries.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -324,6 +337,18 @@ function openProjectAtPath(filePath: string): void {
|
||||
}
|
||||
db = new ProjectDb(filePath);
|
||||
currentProjectPath = filePath;
|
||||
// Re-point the read-only worker at the new file. `swap` cancels any
|
||||
// in-flight requests with `reader-swapped`; the next IPC call will
|
||||
// hit the freshly opened worker.
|
||||
try {
|
||||
dbReaderPool.swap(filePath);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'main',
|
||||
`db-reader pool swap failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
pushRecentProject(filePath);
|
||||
rebuildMenu();
|
||||
if (mainWindow) {
|
||||
@@ -628,6 +653,17 @@ function openLogsWindow(): void {
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle(IPC.appVersion, () => app.getVersion());
|
||||
|
||||
// Renderer → main heartbeat carrying live input-lag (ms). Forwarded
|
||||
// to the active crawler so it can adaptively shrink its concurrency
|
||||
// when the renderer's main thread is starved. `ipcMain.on` (not
|
||||
// `handle`) because the renderer uses `send`-no-wait — this stays
|
||||
// off the synchronous IPC reply path entirely.
|
||||
ipcMain.on(IPC.rendererLagReport, (_e, lagMs: number) => {
|
||||
if (typeof lagMs === 'number' && Number.isFinite(lagMs)) {
|
||||
activeCrawler?.reportRendererLag(lagMs);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.logsGetAll, () => logger.getAll());
|
||||
ipcMain.handle(IPC.logsClear, () => {
|
||||
logger.clearAll();
|
||||
@@ -679,23 +715,39 @@ function registerIpc(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// Reports — every one of these is a SELECT-aggregate that benefits
|
||||
// from running off the main thread. Each handler routes through the
|
||||
// reader pool with a main-thread fallback so a worker crash doesn't
|
||||
// brick the Reports dialog.
|
||||
ipcMain.handle(
|
||||
IPC.reportsPagesPerDirectory,
|
||||
(_e, input: PagesPerDirectoryInput) =>
|
||||
getDb().getPagesPerDirectory({ depth: input.depth, limit: input.limit }),
|
||||
callReaderOrFallback(
|
||||
'getPagesPerDirectory',
|
||||
[{ depth: input.depth, limit: input.limit }],
|
||||
() => getDb().getPagesPerDirectory({ depth: input.depth, limit: input.limit }),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.reportsStatusCodeHistogram, () => getDb().getStatusCodeHistogram());
|
||||
ipcMain.handle(IPC.reportsStatusCodeHistogram, () =>
|
||||
callReaderOrFallback('getStatusCodeHistogram', [], () =>
|
||||
getDb().getStatusCodeHistogram(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.reportsDepthHistogram, () => getDb().getDepthHistogram());
|
||||
ipcMain.handle(IPC.reportsDepthHistogram, () =>
|
||||
callReaderOrFallback('getDepthHistogram', [], () => getDb().getDepthHistogram()),
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.reportsResponseTimeHistogram, () =>
|
||||
getDb().getResponseTimeHistogram(),
|
||||
callReaderOrFallback('getResponseTimeHistogram', [], () =>
|
||||
getDb().getResponseTimeHistogram(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsTopUrls,
|
||||
(_e, input: TopUrlsInput): TopUrlsRow[] => {
|
||||
(_e, input: TopUrlsInput): Promise<TopUrlsRow[]> => {
|
||||
const limit = Math.min(500, Math.max(1, input.limit ?? 25));
|
||||
const column =
|
||||
input.metric === 'response-time'
|
||||
@@ -707,62 +759,89 @@ function registerIpc(): void {
|
||||
: input.metric === 'depth'
|
||||
? 'depth'
|
||||
: 'content_length';
|
||||
return getDb().topUrlsBy(column, limit);
|
||||
return callReaderOrFallback<TopUrlsRow[]>(
|
||||
'topUrlsBy',
|
||||
[column, limit],
|
||||
() => getDb().topUrlsBy(column, limit),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsExternalDomainHealth,
|
||||
(_e, limit: number | undefined): ExternalDomainHealthRow[] =>
|
||||
getDb().externalDomainHealth(limit ?? 100),
|
||||
(_e, limit: number | undefined): Promise<ExternalDomainHealthRow[]> =>
|
||||
callReaderOrFallback<ExternalDomainHealthRow[]>(
|
||||
'externalDomainHealth',
|
||||
[limit ?? 100],
|
||||
() => getDb().externalDomainHealth(limit ?? 100),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsAnalyticsCoverage,
|
||||
(): AnalyticsCoverageRow[] => getDb().analyticsCoverage(),
|
||||
ipcMain.handle(IPC.reportsAnalyticsCoverage, () =>
|
||||
callReaderOrFallback<AnalyticsCoverageRow[]>('analyticsCoverage', [], () =>
|
||||
getDb().analyticsCoverage(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsLinkPositions,
|
||||
(): LinkPositionRow[] => getDb().linkPositionBreakdown(),
|
||||
ipcMain.handle(IPC.reportsLinkPositions, () =>
|
||||
callReaderOrFallback<LinkPositionRow[]>('linkPositionBreakdown', [], () =>
|
||||
getDb().linkPositionBreakdown(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsImageWeightPerPage,
|
||||
(_e, limit: number | undefined): ImageWeightRow[] =>
|
||||
getDb().imageWeightPerPage(limit ?? 25),
|
||||
(_e, limit: number | undefined): Promise<ImageWeightRow[]> =>
|
||||
callReaderOrFallback<ImageWeightRow[]>(
|
||||
'imageWeightPerPage',
|
||||
[limit ?? 25],
|
||||
() => getDb().imageWeightPerPage(limit ?? 25),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsInlinksHistogram,
|
||||
(): BucketHistogramRow[] => getDb().inlinksHistogram(),
|
||||
ipcMain.handle(IPC.reportsInlinksHistogram, () =>
|
||||
callReaderOrFallback<BucketHistogramRow[]>('inlinksHistogram', [], () =>
|
||||
getDb().inlinksHistogram(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsWordCountHistogram,
|
||||
(): BucketHistogramRow[] => getDb().wordCountHistogram(),
|
||||
ipcMain.handle(IPC.reportsWordCountHistogram, () =>
|
||||
callReaderOrFallback<BucketHistogramRow[]>('wordCountHistogram', [], () =>
|
||||
getDb().wordCountHistogram(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsUrlLengthHistogram,
|
||||
(): BucketHistogramRow[] => getDb().urlLengthHistogram(),
|
||||
ipcMain.handle(IPC.reportsUrlLengthHistogram, () =>
|
||||
callReaderOrFallback<BucketHistogramRow[]>('urlLengthHistogram', [], () =>
|
||||
getDb().urlLengthHistogram(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsWordCountPerDirectory,
|
||||
(_e, input: WordCountPerDirectoryInput): WordCountPerDirectoryRow[] =>
|
||||
getDb().wordCountPerDirectory({ depth: input.depth, limit: input.limit }),
|
||||
(_e, input: WordCountPerDirectoryInput): Promise<WordCountPerDirectoryRow[]> =>
|
||||
callReaderOrFallback<WordCountPerDirectoryRow[]>(
|
||||
'wordCountPerDirectory',
|
||||
[{ depth: input.depth, limit: input.limit }],
|
||||
() =>
|
||||
getDb().wordCountPerDirectory({ depth: input.depth, limit: input.limit }),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsSitemapOrphans,
|
||||
(_e, limit?: number): SitemapOrphanRow[] =>
|
||||
getDb().sitemapOrphans(limit ?? 1000),
|
||||
(_e, limit?: number): Promise<SitemapOrphanRow[]> =>
|
||||
callReaderOrFallback<SitemapOrphanRow[]>(
|
||||
'sitemapOrphans',
|
||||
[limit ?? 1000],
|
||||
() => getDb().sitemapOrphans(limit ?? 1000),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsServerHeaders,
|
||||
(): ServerHeaderRow[] => getDb().serverHeaderBreakdown(),
|
||||
ipcMain.handle(IPC.reportsServerHeaders, () =>
|
||||
callReaderOrFallback<ServerHeaderRow[]>('serverHeaderBreakdown', [], () =>
|
||||
getDb().serverHeaderBreakdown(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -1094,44 +1173,85 @@ function registerIpc(): void {
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.urlsQuery, (_e, input: UrlsQueryInput): UrlsQueryResult => {
|
||||
return getDb().queryUrls({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
category: input.category ?? 'all',
|
||||
search: input.search,
|
||||
sortBy: input.sortBy as string | undefined,
|
||||
sortDir: input.sortDir,
|
||||
filter: input.filter,
|
||||
// `setImmediate`-yield wrapper for read-heavy IPC handlers. Yielding
|
||||
// before running the SQL gives the Node event loop one tick to drain
|
||||
// pending IPC and crawler callbacks first — without this, two
|
||||
// simultaneous renderer queries (e.g. table chunk + sidebar refresh)
|
||||
// run back-to-back and freeze input for the duration of both. With
|
||||
// this wrapper they interleave with the crawler's per-URL DB writes
|
||||
// and any other queued IPC, keeping click → response under one frame
|
||||
// even on a saturated main thread. Cost per call: < 1 ms.
|
||||
const yieldThenRun = <T>(fn: () => T): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
resolve(fn());
|
||||
} catch (err) {
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.urlsQuery, async (_e, input: UrlsQueryInput): Promise<UrlsQueryResult> => {
|
||||
const args: Parameters<typeof ProjectDb.prototype.queryUrls> = [
|
||||
{
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
category: input.category ?? 'all',
|
||||
search: input.search,
|
||||
sortBy: input.sortBy as string | undefined,
|
||||
sortDir: input.sortDir,
|
||||
filter: input.filter,
|
||||
},
|
||||
];
|
||||
// Reader worker first (off-thread); on any failure (worker
|
||||
// crashed, swap in flight, timeout) fall back to the main-thread
|
||||
// ProjectDb so the UI never blocks on infrastructure trouble.
|
||||
return callReaderOrFallback<UrlsQueryResult>('queryUrls', args, () =>
|
||||
getDb().queryUrls(args[0]),
|
||||
);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.overviewGet, (): OverviewCounts => {
|
||||
return getDb().getOverviewCounts();
|
||||
});
|
||||
ipcMain.handle(IPC.overviewGet, async (): Promise<OverviewCounts> =>
|
||||
callReaderOrFallback<OverviewCounts>('getOverviewCounts', [], () =>
|
||||
getDb().getOverviewCountsAsync(),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.imagesQuery,
|
||||
(_e, input: ImagesQueryInput): ImagesQueryResult => {
|
||||
return getDb().queryImages({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
missingAltOnly: input.missingAltOnly,
|
||||
internalOnly: input.internalOnly,
|
||||
});
|
||||
async (_e, input: ImagesQueryInput): Promise<ImagesQueryResult> => {
|
||||
const args: Parameters<typeof ProjectDb.prototype.queryImages> = [
|
||||
{
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
missingAltOnly: input.missingAltOnly,
|
||||
internalOnly: input.internalOnly,
|
||||
},
|
||||
];
|
||||
return callReaderOrFallback<ImagesQueryResult>('queryImages', args, () =>
|
||||
getDb().queryImages(args[0]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.brokenLinksQuery,
|
||||
(_e, input: BrokenLinksQueryInput): BrokenLinksQueryResult => {
|
||||
return getDb().queryBrokenLinks({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
internal: input.internal,
|
||||
search: input.search,
|
||||
});
|
||||
async (_e, input: BrokenLinksQueryInput): Promise<BrokenLinksQueryResult> => {
|
||||
const args: Parameters<typeof ProjectDb.prototype.queryBrokenLinks> = [
|
||||
{
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
internal: input.internal,
|
||||
search: input.search,
|
||||
},
|
||||
];
|
||||
return callReaderOrFallback<BrokenLinksQueryResult>(
|
||||
'queryBrokenLinks',
|
||||
args,
|
||||
() => getDb().queryBrokenLinks(args[0]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1269,9 +1389,13 @@ function registerIpc(): void {
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.urlDetailGet, (_e, input: UrlDetailInput): UrlDetail | null => {
|
||||
return getDb().getUrlDetail(input.id, input.linkLimit ?? 500);
|
||||
});
|
||||
ipcMain.handle(IPC.urlDetailGet, async (_e, input: UrlDetailInput): Promise<UrlDetail | null> =>
|
||||
callReaderOrFallback<UrlDetail | null>(
|
||||
'getUrlDetail',
|
||||
[input.id, input.linkLimit ?? 500],
|
||||
() => getDb().getUrlDetail(input.id, input.linkLimit ?? 500),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.urlSourceGet,
|
||||
@@ -1320,9 +1444,9 @@ function registerIpc(): void {
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.summaryGet, (): CrawlSummary => {
|
||||
return getDb().getSummary();
|
||||
});
|
||||
ipcMain.handle(IPC.summaryGet, (): Promise<CrawlSummary> =>
|
||||
callReaderOrFallback<CrawlSummary>('getSummary', [], () => getDb().getSummary()),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.exportCsv,
|
||||
@@ -1725,6 +1849,11 @@ void app.whenReady().then(() => {
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
activeCrawler?.stop();
|
||||
// Terminate the read-only worker BEFORE closing the writer connection.
|
||||
// Order matters on Windows: SQLite holds a file lock per connection
|
||||
// and the writer's WAL checkpoint at close-time can stall if a
|
||||
// reader is still mid-query.
|
||||
void dbReaderPool.terminate();
|
||||
db?.close();
|
||||
db = null;
|
||||
flushPrefs();
|
||||
|
||||
@@ -179,6 +179,11 @@ const api: FreeCrawlApi = {
|
||||
ipcRenderer.invoke(IPC.reportsWordCountPerDirectory, input),
|
||||
reportsSitemapOrphans: (limit?: number): Promise<SitemapOrphanRow[]> =>
|
||||
ipcRenderer.invoke(IPC.reportsSitemapOrphans, limit),
|
||||
// Fire-and-forget — `send` not `invoke` because we don't need a
|
||||
// response and we want this to be cheap (≤ 1 ms per call, no wait).
|
||||
reportRendererLag: (lagMs: number) => {
|
||||
ipcRenderer.send(IPC.rendererLagReport, lagMs);
|
||||
},
|
||||
reportsServerHeaders: (): Promise<ServerHeaderRow[]> =>
|
||||
ipcRenderer.invoke(IPC.reportsServerHeaders),
|
||||
prefsExportSettings: (input: SettingsExportInput): Promise<SettingsExportResult> =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import type { OverviewCounts, UrlCategory } from '@freecrawl/shared-types';
|
||||
@@ -16,6 +16,7 @@ export function OverviewSidebar() {
|
||||
const overview = useAppStore((s) => s.overview);
|
||||
const setOverview = useAppStore((s) => s.setOverview);
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const progress = useAppStore((s) => s.progress);
|
||||
const activeCategory = useAppStore((s) => s.activeCategory);
|
||||
const navigateToCategory = useAppStore((s) => s.navigateToCategory);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(
|
||||
@@ -58,15 +59,52 @@ export function OverviewSidebar() {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
void load();
|
||||
// 3000 ms cadence — at 100 URL/s that's 300 new rows between
|
||||
// refreshes, perceptually still "live" without slamming the DB.
|
||||
const id = setInterval(load, 3000);
|
||||
// Wrap in requestIdleCallback so the 70+ issue-counter aggregate
|
||||
// only fires when the renderer's event loop is idle. The tick still
|
||||
// runs at 3 s cadence (the interval itself), but each individual
|
||||
// tick yields to user input — the difference between "click → 200
|
||||
// ms freeze" and "click → instant" while crawl is running.
|
||||
interface RequestIdleCallback {
|
||||
(cb: () => void, opts?: { timeout: number }): number;
|
||||
}
|
||||
const w = window as Window & { requestIdleCallback?: RequestIdleCallback };
|
||||
const scheduleLoad = (): void => {
|
||||
if (typeof w.requestIdleCallback === 'function') {
|
||||
w.requestIdleCallback(() => void load(), { timeout: 4000 });
|
||||
} else {
|
||||
void load();
|
||||
}
|
||||
};
|
||||
scheduleLoad();
|
||||
// I-4 — Crawl-aware cadence. While the crawler is running we want
|
||||
// sub-5-second freshness so the sidebar feels live; idle (no
|
||||
// crawl, viewing existing project data) we slow to 30 s because
|
||||
// the data isn't changing and burning a 130-counter aggregate
|
||||
// every 3 s for nothing wastes battery and disk on laptops.
|
||||
// The crawler's `progress` event also bumps `progress.crawled`
|
||||
// which triggers a separate progress-driven refetch below, so
|
||||
// this interval is just a safety net.
|
||||
const intervalMs = progress?.running ? 3000 : 30_000;
|
||||
const id = setInterval(scheduleLoad, intervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [dataVersion, setOverview]);
|
||||
}, [dataVersion, setOverview, progress?.running]);
|
||||
|
||||
// Progress-driven refetch. When the crawler reports a meaningful
|
||||
// change (every 50 URLs crawled) we trigger an immediate sidebar
|
||||
// refresh — push semantics on top of the polling safety-net above.
|
||||
// `lastRefetchAt` ref prevents the same crawled-bucket from firing
|
||||
// twice if React re-renders for an unrelated reason.
|
||||
const lastRefetchAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!progress?.running) return;
|
||||
const bucket = Math.floor((progress.crawled ?? 0) / 50);
|
||||
if (bucket === lastRefetchAtRef.current) return;
|
||||
lastRefetchAtRef.current = bucket;
|
||||
void window.freecrawl.overviewGet().then((o) => setOverview(o));
|
||||
}, [progress?.crawled, progress?.running, setOverview]);
|
||||
|
||||
const toggle = (k: string) => {
|
||||
setExpanded((prev) => {
|
||||
@@ -786,6 +824,18 @@ function buildTree(o: OverviewCounts | null): Node[] {
|
||||
count: o.issues.twitterImageNotAbsolute,
|
||||
category: 'issues:twitter-image-not-absolute',
|
||||
},
|
||||
{
|
||||
key: 'issues-og-image-too-large',
|
||||
label: 'OG Image >5MB',
|
||||
count: o.issues.ogImageTooLarge,
|
||||
category: 'issues:og-image-too-large',
|
||||
},
|
||||
{
|
||||
key: 'issues-twitter-image-too-large',
|
||||
label: 'Twitter Image >5MB',
|
||||
count: o.issues.twitterImageTooLarge,
|
||||
category: 'issues:twitter-image-too-large',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -942,6 +992,18 @@ function buildTree(o: OverviewCounts | null): Node[] {
|
||||
count: o.issues.renderBlocking,
|
||||
category: 'issues:render-blocking',
|
||||
},
|
||||
{
|
||||
key: 'issues-render-blocking-critical',
|
||||
label: 'Render-Blocking Head (>20, critical)',
|
||||
count: o.issues.renderBlockingCritical,
|
||||
category: 'issues:render-blocking-critical',
|
||||
},
|
||||
{
|
||||
key: 'issues-text-code-ratio-low',
|
||||
label: 'Low Text/Code Ratio (<10%)',
|
||||
count: o.issues.textCodeRatioLow,
|
||||
category: 'issues:text-code-ratio-low',
|
||||
},
|
||||
{
|
||||
key: 'issues-keepalive-disabled',
|
||||
label: 'Keep-Alive Disabled',
|
||||
@@ -1188,6 +1250,12 @@ function buildTree(o: OverviewCounts | null): Node[] {
|
||||
count: o.issues.deadExternalDomain,
|
||||
category: 'issues:dead-external-domain',
|
||||
},
|
||||
{
|
||||
key: 'issues-js-only-navigation',
|
||||
label: 'JS-Only Navigation',
|
||||
count: o.issues.jsOnlyNavigation,
|
||||
category: 'issues:js-only-navigation',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,18 +1,65 @@
|
||||
import clsx from 'clsx';
|
||||
import { useAppStore } from '../store.js';
|
||||
import { usePerfMeter } from '../hooks/usePerfMeter.js';
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
valueClassName?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5" title={title}>
|
||||
<span className="text-surface-500">{label}</span>
|
||||
<span className="font-mono font-medium text-surface-100">{value}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'font-mono font-medium',
|
||||
valueClassName ?? 'text-surface-100',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map FPS to a Tailwind text colour so the user can spot kasma at a glance. */
|
||||
function fpsClass(fps: number): string {
|
||||
if (fps >= 50) return 'text-emerald-300';
|
||||
if (fps >= 30) return 'text-amber-300';
|
||||
return 'text-red-300';
|
||||
}
|
||||
|
||||
/** Same idea for renderer heap — the Electron renderer is comfortable
|
||||
* up to ~500 MB; over 1 GB is almost always a listener / cache leak. */
|
||||
function heapClass(heapMb: number | null): string {
|
||||
if (heapMb === null) return 'text-surface-100';
|
||||
if (heapMb >= 1024) return 'text-red-300';
|
||||
if (heapMb >= 500) return 'text-amber-300';
|
||||
return 'text-surface-100';
|
||||
}
|
||||
|
||||
/** Input lag colour. The same numbers a user "feels":
|
||||
* < 16 ms = one frame at 60 Hz (input feels instant)
|
||||
* 16–50 ms = a couple of frames late (subtle drag stutter)
|
||||
* > 50 ms = clearly laggy clicks / drags
|
||||
*/
|
||||
function lagClass(lagMs: number): string {
|
||||
if (lagMs < 16) return 'text-emerald-300';
|
||||
if (lagMs < 50) return 'text-amber-300';
|
||||
return 'text-red-300';
|
||||
}
|
||||
|
||||
export function StatsBar() {
|
||||
const progress = useAppStore((s) => s.progress);
|
||||
const error = useAppStore((s) => s.error);
|
||||
const setError = useAppStore((s) => s.setError);
|
||||
const perf = usePerfMeter();
|
||||
|
||||
const elapsed = progress?.elapsedMs ?? 0;
|
||||
const elapsedStr = formatElapsed(elapsed);
|
||||
@@ -26,6 +73,39 @@ export function StatsBar() {
|
||||
<Stat label="URL/s" value={progress?.urlsPerSecond?.toFixed(1) ?? '0.0'} />
|
||||
<Stat label="Avg resp" value={`${progress?.avgResponseTimeMs ?? 0}ms`} />
|
||||
<Stat label="Elapsed" value={elapsedStr} />
|
||||
<Stat
|
||||
label="FPS"
|
||||
value={perf.fps}
|
||||
valueClassName={fpsClass(perf.fps)}
|
||||
title={
|
||||
perf.fps >= 50
|
||||
? 'Renderer is smooth (≥ 50 fps)'
|
||||
: perf.fps >= 30
|
||||
? 'Renderer is degraded (30–49 fps) — likely competing with crawl IPC'
|
||||
: 'Renderer is stalled (< 30 fps) — main thread starved; pause crawl or close Logs window'
|
||||
}
|
||||
/>
|
||||
{perf.heapMb !== null && (
|
||||
<Stat
|
||||
label="Heap"
|
||||
value={`${perf.heapMb} MB`}
|
||||
valueClassName={heapClass(perf.heapMb)}
|
||||
title="Renderer JS heap. >500 MB = warm, >1 GB = likely a listener / cache leak"
|
||||
/>
|
||||
)}
|
||||
<Stat
|
||||
label="Lag"
|
||||
value={`${perf.inputLagMs}ms`}
|
||||
valueClassName={lagClass(perf.inputLagMs)}
|
||||
title={
|
||||
perf.inputLagMs < 16
|
||||
? 'Main thread is responsive — input feels instant'
|
||||
: perf.inputLagMs < 50
|
||||
? 'Main thread is contended — light click stutter'
|
||||
: 'Main thread is busy — IPC backed up; most likely sidebar SQL or table chunk fetch competing with the crawler'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{progress?.running ? (
|
||||
progress.paused ? (
|
||||
|
||||
@@ -8,7 +8,12 @@ const MAX_CACHED_CHUNKS = 8;
|
||||
// query + per-chunk fetch every tick. At 100 URL/s the user still sees
|
||||
// ~75 new rows per tick (continuous flow), while leaving 3× more main-
|
||||
// thread headroom than the previous 250 ms cadence.
|
||||
const LIVE_REFRESH_MS = 750;
|
||||
// Cadence for the live-tail tick. 1500 ms strikes the balance between
|
||||
// "feels fresh" and "doesn't pin SQLite". At 750 ms a 2k+ row crawl was
|
||||
// burning ~30% of one core in COUNT + chunk fetches, blocking input IPC
|
||||
// for the duration. The user sees the same effect: numbers tick at half
|
||||
// speed but the window stops kasma'ing.
|
||||
const LIVE_REFRESH_MS = 1500;
|
||||
|
||||
export interface LazyRowsOpts {
|
||||
category: UrlCategory;
|
||||
@@ -141,8 +146,30 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, LIVE_REFRESH_MS);
|
||||
// Wrap the tick in requestIdleCallback so the live-tail SQL only
|
||||
// fires when the renderer's event loop has idle slack. If the user
|
||||
// is dragging / clicking / typing, the tick is deferred until the
|
||||
// input handler frame finishes — eliminates the "click → kasma"
|
||||
// pattern even when the underlying SQL is fast. Falls back to a
|
||||
// direct call when the browser doesn't expose `requestIdleCallback`
|
||||
// (older Chromium builds; Electron 41 ships modern Chromium so this
|
||||
// branch is mostly defensive).
|
||||
interface RequestIdleCallback {
|
||||
(cb: () => void, opts?: { timeout: number }): number;
|
||||
}
|
||||
const w = window as Window & { requestIdleCallback?: RequestIdleCallback };
|
||||
const scheduleTick = (): void => {
|
||||
if (typeof w.requestIdleCallback === 'function') {
|
||||
// 2 s timeout guarantees the tick eventually runs even if the
|
||||
// main thread is permanently busy — better stale data than no
|
||||
// data at all.
|
||||
w.requestIdleCallback(() => void tick(), { timeout: 2000 });
|
||||
} else {
|
||||
void tick();
|
||||
}
|
||||
};
|
||||
scheduleTick();
|
||||
const id = setInterval(scheduleTick, LIVE_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Live performance sample exposed to the status bar.
|
||||
*
|
||||
* - `fps` is the count of `requestAnimationFrame` callbacks that fired in
|
||||
* the most recent 1-second sliding window. A healthy FreeCrawl idle
|
||||
* sits at ~60 fps; sustained < 30 fps means the renderer's main
|
||||
* thread is starved (heavy SQL pump on the main process competing for
|
||||
* IPC, React reconciliation churn, or window-drag throttling).
|
||||
*
|
||||
* - `heapMb` reads `performance.memory.usedJSHeapSize` (Chromium-only —
|
||||
* Electron renderer always exposes it, so we don't gate it). When it
|
||||
* grows monotonically across crawls without GC dips it points at a
|
||||
* listener leak somewhere in the renderer tree.
|
||||
*
|
||||
* The sampler runs ~10 Hz (every ~100 ms). It updates state via
|
||||
* `setState` only when the integer fps OR the integer heapMb changes,
|
||||
* so React re-renders the status bar at most ~10 times/sec rather than
|
||||
* 60 — keeps the meter itself from contributing to the kasma it's
|
||||
* supposed to detect.
|
||||
*/
|
||||
export interface PerfSample {
|
||||
fps: number;
|
||||
heapMb: number | null;
|
||||
/**
|
||||
* Estimated main-thread busy time in ms. Measured by setting a
|
||||
* `setTimeout(…, 0)` and seeing how long it actually waited — when
|
||||
* the main thread is busy with synchronous work or IPC, the timeout
|
||||
* fires late by exactly that amount. Sub-10 ms means the renderer is
|
||||
* responsive to input; 50+ ms means clicks/drags will visibly lag.
|
||||
*/
|
||||
inputLagMs: number;
|
||||
}
|
||||
|
||||
interface ChromeMemoryInfo {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
}
|
||||
|
||||
interface PerfWithMemory extends Performance {
|
||||
memory?: ChromeMemoryInfo;
|
||||
}
|
||||
|
||||
export function usePerfMeter(): PerfSample {
|
||||
const [sample, setSample] = useState<PerfSample>({
|
||||
fps: 0,
|
||||
heapMb: null,
|
||||
inputLagMs: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let rafId = 0;
|
||||
let frameTimes: number[] = [];
|
||||
let lastEmittedFps = -1;
|
||||
let lastEmittedHeap = -1;
|
||||
let lastEmittedLag = -1;
|
||||
let lastEmitTs = 0;
|
||||
|
||||
// Sliding window of recent setTimeout-0 latencies. When the main
|
||||
// thread is busy, the timeout fires later than scheduled — the
|
||||
// delta is the closest userspace-readable proxy for "how laggy
|
||||
// does this feel right now." We average the last 10 samples so a
|
||||
// single GC pause doesn't dominate the readout.
|
||||
const lagSamples: number[] = [];
|
||||
const SAMPLE_INTERVAL_MS = 200;
|
||||
let lagTimer: number | undefined;
|
||||
const probeLag = (): void => {
|
||||
if (cancelled) return;
|
||||
const scheduled = performance.now();
|
||||
lagTimer = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const actual = performance.now();
|
||||
const delta = Math.max(0, actual - scheduled - SAMPLE_INTERVAL_MS);
|
||||
lagSamples.push(delta);
|
||||
if (lagSamples.length > 10) lagSamples.shift();
|
||||
probeLag();
|
||||
}, SAMPLE_INTERVAL_MS);
|
||||
};
|
||||
probeLag();
|
||||
|
||||
const tick = (now: number): void => {
|
||||
if (cancelled) return;
|
||||
frameTimes.push(now);
|
||||
const cutoff = now - 1000;
|
||||
while (frameTimes.length > 0 && (frameTimes[0] ?? 0) < cutoff) {
|
||||
frameTimes.shift();
|
||||
}
|
||||
const fps = frameTimes.length;
|
||||
|
||||
if (now - lastEmitTs >= 100) {
|
||||
lastEmitTs = now;
|
||||
const perf = performance as PerfWithMemory;
|
||||
const heapBytes = perf.memory?.usedJSHeapSize;
|
||||
const heapMb =
|
||||
typeof heapBytes === 'number' ? Math.round(heapBytes / 1024 / 1024) : null;
|
||||
const avgLag =
|
||||
lagSamples.length > 0
|
||||
? Math.round(
|
||||
lagSamples.reduce((a, b) => a + b, 0) / lagSamples.length,
|
||||
)
|
||||
: 0;
|
||||
// Pipe lag back to the main process so the crawler can throttle
|
||||
// itself adaptively (see Crawler.reportRendererLag). Fire-and-
|
||||
// forget — the preload bridge uses `ipcRenderer.send` so this
|
||||
// doesn't add to the IPC reply queue. We only ping when the
|
||||
// sample changed by ≥ 5 ms or every second, whichever first,
|
||||
// to avoid spamming the bridge on a calm machine.
|
||||
try {
|
||||
window.freecrawl?.reportRendererLag?.(avgLag);
|
||||
} catch {
|
||||
/* ignore — non-fatal */
|
||||
}
|
||||
if (
|
||||
fps !== lastEmittedFps ||
|
||||
heapMb !== lastEmittedHeap ||
|
||||
avgLag !== lastEmittedLag
|
||||
) {
|
||||
lastEmittedFps = fps;
|
||||
lastEmittedHeap = heapMb ?? -1;
|
||||
lastEmittedLag = avgLag;
|
||||
setSample({ fps, heapMb, inputLagMs: avgLag });
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
if (lagTimer !== undefined) clearTimeout(lagTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return sample;
|
||||
}
|
||||
@@ -9,12 +9,18 @@ const ROW_HEIGHT = 24;
|
||||
const HEADER_HEIGHT = 28;
|
||||
const ROW_NUM_WIDTH = 56;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const POLL_MS = 3000;
|
||||
// I-4 — Crawl-aware polling cadence. Live during a crawl, idle when
|
||||
// just viewing existing project data. The 30 s idle poll exists only
|
||||
// to catch external invalidations (Open Project, Bulk Export); the
|
||||
// crawler's per-50-URL push refetch handles the live case.
|
||||
const POLL_MS_RUNNING = 3000;
|
||||
const POLL_MS_IDLE = 30_000;
|
||||
const PAGE_SIZE = 5000;
|
||||
|
||||
export function BrokenLinksTab() {
|
||||
const activeCategory = useAppStore((s) => s.activeCategory);
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const progress = useAppStore((s) => s.progress);
|
||||
const [rows, setRows] = useState<BrokenLinkRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -43,12 +49,13 @@ export function BrokenLinksTab() {
|
||||
setTotal(res.total);
|
||||
};
|
||||
void load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
const cadence = progress?.running ? POLL_MS_RUNNING : POLL_MS_IDLE;
|
||||
const id = setInterval(load, cadence);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [search, internal, dataVersion]);
|
||||
}, [search, internal, dataVersion, progress?.running]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
|
||||
@@ -9,7 +9,9 @@ const ROW_HEIGHT = 24;
|
||||
const HEADER_HEIGHT = 28;
|
||||
const ROW_NUM_WIDTH = 56;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const POLL_MS = 3000;
|
||||
// I-4 — Crawl-aware polling cadence (see BrokenLinksTab for rationale).
|
||||
const POLL_MS_RUNNING = 3000;
|
||||
const POLL_MS_IDLE = 30_000;
|
||||
const PAGE_SIZE = 5000;
|
||||
|
||||
type SortKey = 'src' | 'alt' | 'width' | 'height' | 'occurrences';
|
||||
@@ -17,6 +19,7 @@ type SortKey = 'src' | 'alt' | 'width' | 'height' | 'occurrences';
|
||||
export function ImagesTab() {
|
||||
const activeCategory = useAppStore((s) => s.activeCategory);
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const progress = useAppStore((s) => s.progress);
|
||||
const [rows, setRows] = useState<ImageRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -42,12 +45,13 @@ export function ImagesTab() {
|
||||
setTotal(res.total);
|
||||
};
|
||||
void load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
const cadence = progress?.running ? POLL_MS_RUNNING : POLL_MS_IDLE;
|
||||
const id = setInterval(load, cadence);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [search, missingAltOnly, dataVersion]);
|
||||
}, [search, missingAltOnly, dataVersion, progress?.running]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...rows];
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"description": "Open-source desktop SEO crawler \u00e2\u20ac\u201d a free alternative to Screaming Frog",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+161
-20
@@ -11,7 +11,7 @@ import type {
|
||||
DiscoveredLink,
|
||||
Indexability,
|
||||
} from '@freecrawl/shared-types';
|
||||
import type { ProjectDb } from '@freecrawl/db';
|
||||
import { type ProjectDb, EXPENSIVE_ISSUE_DEFINITIONS } from '@freecrawl/db';
|
||||
import {
|
||||
normalizeUrl,
|
||||
isSameHost,
|
||||
@@ -91,6 +91,36 @@ export class Crawler extends EventEmitter {
|
||||
private memoryMonitorTimer: NodeJS.Timeout | null = null;
|
||||
private robots: RobotsChecker | null = null;
|
||||
private progressTimer: NodeJS.Timeout | null = null;
|
||||
/** Periodic post-crawl-style recompute of expensive issue counters
|
||||
* while the crawl is still running, so the sidebar number is < 30 s
|
||||
* stale instead of "0 until crawl ends". 30 s cadence picked
|
||||
* conservatively — the recompute wraps in `runInTransaction` and is
|
||||
* not free on a 100k-URL DB. */
|
||||
private issueRecomputeTimer: NodeJS.Timeout | null = null;
|
||||
private static readonly ISSUE_RECOMPUTE_INTERVAL_MS = 30_000;
|
||||
private startIssueRecomputeTimer(): void {
|
||||
if (this.issueRecomputeTimer) return;
|
||||
this.issueRecomputeTimer = setInterval(() => {
|
||||
if (this.stopped || !this.running || this.paused) return;
|
||||
try {
|
||||
this.db.recomputeUrlsIssues(EXPENSIVE_ISSUE_DEFINITIONS);
|
||||
} catch (err) {
|
||||
// Recompute is best-effort — a transient SQLITE_BUSY here is
|
||||
// not worth interrupting the crawl. Surface it as debug so
|
||||
// someone watching logs can spot pathological cases.
|
||||
this.emit(
|
||||
'debug',
|
||||
`issue counter recompute failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}, Crawler.ISSUE_RECOMPUTE_INTERVAL_MS);
|
||||
}
|
||||
private stopIssueRecomputeTimer(): void {
|
||||
if (this.issueRecomputeTimer) {
|
||||
clearInterval(this.issueRecomputeTimer);
|
||||
this.issueRecomputeTimer = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Aborts any in-flight sitemap discovery on stop(). Without this, a
|
||||
* 21k-URL sitemap continues parsing in the background after Stop and
|
||||
@@ -265,6 +295,16 @@ export class Crawler extends EventEmitter {
|
||||
|
||||
this.progressTimer = setInterval(() => this.emitProgress(), 500);
|
||||
|
||||
// I-3 — Periodic materialised issue recompute while the crawl is
|
||||
// running. The post-crawl pass at the end will run a final clean
|
||||
// recompute; this keeps the sidebar's expensive issue counters
|
||||
// (dead external domain, duplicate URL post-norm, canonical chain
|
||||
// multi-hop) populated mid-crawl too. 30 s cadence is a deliberate
|
||||
// floor — these definitions execute multi-second self-joins on
|
||||
// large crawls and we don't want to hold the DB write lock more
|
||||
// than the crawler itself does.
|
||||
this.startIssueRecomputeTimer();
|
||||
|
||||
// Hydrate in-memory state from the DB so resume starts from the right
|
||||
// point; then queue whatever work is still pending.
|
||||
this.hydrateFromDb();
|
||||
@@ -282,6 +322,7 @@ export class Crawler extends EventEmitter {
|
||||
} finally {
|
||||
if (this.progressTimer) clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
this.stopIssueRecomputeTimer();
|
||||
}
|
||||
|
||||
// Post-crawl heavy lifting. Each step is a synchronous SQL pass
|
||||
@@ -304,6 +345,9 @@ export class Crawler extends EventEmitter {
|
||||
this.emit('info', 'Clustering duplicates…');
|
||||
this.runDuplicateClustering();
|
||||
await yieldToEventLoop();
|
||||
this.emit('info', 'Materialising issue counters…');
|
||||
this.db.recomputeUrlsIssues(EXPENSIVE_ISSUE_DEFINITIONS);
|
||||
await yieldToEventLoop();
|
||||
await this.runImageSizeProbes();
|
||||
await yieldToEventLoop();
|
||||
await this.runTlsCertProbes();
|
||||
@@ -626,6 +670,7 @@ export class Crawler extends EventEmitter {
|
||||
this.startMemoryMonitor();
|
||||
|
||||
this.progressTimer = setInterval(() => this.emitProgress(), 500);
|
||||
this.startIssueRecomputeTimer();
|
||||
|
||||
for (const u of urls) {
|
||||
this.enqueue({ url: u, depth: 0 });
|
||||
@@ -637,6 +682,7 @@ export class Crawler extends EventEmitter {
|
||||
} finally {
|
||||
if (this.progressTimer) clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
this.stopIssueRecomputeTimer();
|
||||
}
|
||||
|
||||
// Same yielding strategy as spider mode — see the comment block
|
||||
@@ -651,6 +697,8 @@ export class Crawler extends EventEmitter {
|
||||
await yieldToEventLoop();
|
||||
this.runDuplicateClustering();
|
||||
await yieldToEventLoop();
|
||||
this.db.recomputeUrlsIssues(EXPENSIVE_ISSUE_DEFINITIONS);
|
||||
await yieldToEventLoop();
|
||||
await this.runImageSizeProbes();
|
||||
await yieldToEventLoop();
|
||||
await this.runTlsCertProbes();
|
||||
@@ -1106,6 +1154,16 @@ export class Crawler extends EventEmitter {
|
||||
private async fetchAndProcess(item: QueueItem): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
|
||||
// I-1 — Cooperative yield BEFORE we start work on this URL. The
|
||||
// crawler runs in the same Node event loop as Electron's IPC
|
||||
// dispatcher; without an explicit yield, two adjacent fetches
|
||||
// (each landing several DB writes) run back-to-back and any UI
|
||||
// input mesajı that arrived in between waits for both to finish.
|
||||
// `setImmediate` adds at most one event-loop tick (< 1 ms on a
|
||||
// healthy system) and lets renderer-side IPC, lag heartbeats,
|
||||
// and progress event listeners run between URLs.
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
const t0 = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
|
||||
@@ -1478,27 +1536,38 @@ export class Crawler extends EventEmitter {
|
||||
headings:
|
||||
parsed.headings.length > 0 ? JSON.stringify(parsed.headings) : null,
|
||||
serverHeader,
|
||||
jsOnlyLinksCount: parsed.jsOnlyLinksCount,
|
||||
textCodeRatio: parsed.textCodeRatio,
|
||||
});
|
||||
if (urlId) this.db.setUrlHeaders(urlId, allHeaders);
|
||||
// Body snapshot — drives the View Source detail tab. Only HTML
|
||||
// pages get stored (we already gated entry above on `kind === 'html'`).
|
||||
// Capped per `bodySnapshotMaxBytes` so a single page can't blow up
|
||||
// the project file.
|
||||
if (urlId && this.config.storeBodySnapshots) {
|
||||
try {
|
||||
this.db.setUrlSource(
|
||||
urlId,
|
||||
body,
|
||||
this.config.bodySnapshotMaxBytes > 0
|
||||
? this.config.bodySnapshotMaxBytes
|
||||
: 1_048_576,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — a snapshot failure must not break the crawl.
|
||||
// Coalesce the per-URL write fan-out (headers + body snapshot +
|
||||
// 50–500 links + 5–50 images) into a single SQLite transaction.
|
||||
// Each `BEGIN…COMMIT` triggers an fsync on `synchronous=NORMAL`,
|
||||
// and on low-end SSDs with antivirus realtime-scan each fsync can
|
||||
// cost 30–80 ms. Without this wrapper a single page produces
|
||||
// 5–10 fsyncs; with it, exactly one — the difference between a
|
||||
// smooth UI and the kasma the user reported.
|
||||
this.db.runInTransaction(() => {
|
||||
if (urlId) this.db.setUrlHeaders(urlId, allHeaders);
|
||||
// Body snapshot — drives the View Source detail tab. Only HTML
|
||||
// pages get stored (we already gated entry above on
|
||||
// `kind === 'html'`). Capped per `bodySnapshotMaxBytes` so a
|
||||
// single page can't blow up the project file.
|
||||
if (urlId && this.config.storeBodySnapshots) {
|
||||
try {
|
||||
this.db.setUrlSource(
|
||||
urlId,
|
||||
body,
|
||||
this.config.bodySnapshotMaxBytes > 0
|
||||
? this.config.bodySnapshotMaxBytes
|
||||
: 1_048_576,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — a snapshot failure must not break the crawl.
|
||||
}
|
||||
}
|
||||
}
|
||||
this.db.insertLinks(urlId, storableLinks, item.depth);
|
||||
this.db.insertImages(urlId, parsed.images);
|
||||
this.db.insertLinks(urlId, storableLinks, item.depth);
|
||||
this.db.insertImages(urlId, parsed.images);
|
||||
});
|
||||
for (const link of storableLinks) {
|
||||
if (!link.isInternal) this.enqueueExternal(link.toUrl);
|
||||
}
|
||||
@@ -1638,7 +1707,79 @@ export class Crawler extends EventEmitter {
|
||||
throw lastError ?? new Error('retry loop exhausted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive concurrency state. We start at the user-configured ceiling
|
||||
* and shrink when the renderer's input lag spikes — typically because
|
||||
* SQLite is locked by a heavy SELECT or because the OS is paging.
|
||||
* Caller (the desktop main process) feeds lag samples via
|
||||
* `reportRendererLag()`; we adjust at most once per ADAPT_COOLDOWN_MS
|
||||
* so a single GC pause doesn't oscillate the queue.
|
||||
*
|
||||
* Targets:
|
||||
* lag > 200 ms → shrink concurrency by 1, floor 1
|
||||
* lag < 30 ms → grow concurrency by 1, ceiling = configured max
|
||||
*/
|
||||
private currentConcurrency = 0;
|
||||
private lastAdaptTs = 0;
|
||||
private static readonly ADAPT_COOLDOWN_MS = 2_000;
|
||||
/** Public so the desktop main process can pipe in renderer Lag reports. */
|
||||
reportRendererLag(lagMs: number): void {
|
||||
if (!this.running || this.paused) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastAdaptTs < Crawler.ADAPT_COOLDOWN_MS) return;
|
||||
const ceiling = Math.max(1, Math.min(200, this.config.maxConcurrency));
|
||||
if (this.currentConcurrency === 0) this.currentConcurrency = ceiling;
|
||||
let next = this.currentConcurrency;
|
||||
if (lagMs > 200) {
|
||||
next = Math.max(1, this.currentConcurrency - 1);
|
||||
} else if (lagMs < 30) {
|
||||
next = Math.min(ceiling, this.currentConcurrency + 1);
|
||||
}
|
||||
if (next !== this.currentConcurrency) {
|
||||
this.currentConcurrency = next;
|
||||
this.queue.concurrency = next;
|
||||
this.lastAdaptTs = now;
|
||||
this.emit(
|
||||
'debug',
|
||||
`adaptive concurrency → ${next} (lag ${lagMs} ms, ceiling ${ceiling})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private lastProgressEmitTs = 0;
|
||||
private progressTrailingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Minimum gap between two progress events. 200 ms = 5 Hz, which is
|
||||
* dense enough that the user reads the URL/s + Crawled counters as
|
||||
* "live" but sparse enough that 200 URL/s of work isn't generating
|
||||
* 200 IPC messages/sec to the renderer (which then re-renders the
|
||||
* sidebar + status bar tree at the same rate). */
|
||||
private static readonly PROGRESS_THROTTLE_MS = 200;
|
||||
|
||||
private emitProgress(): void {
|
||||
const now = Date.now();
|
||||
const elapsedSinceLast = now - this.lastProgressEmitTs;
|
||||
if (elapsedSinceLast < Crawler.PROGRESS_THROTTLE_MS) {
|
||||
// Schedule a trailing emit so the final state-change still
|
||||
// surfaces. Multiple calls within the throttle window collapse
|
||||
// into the same trailing timer.
|
||||
if (this.progressTrailingTimer === null) {
|
||||
const wait = Crawler.PROGRESS_THROTTLE_MS - elapsedSinceLast;
|
||||
this.progressTrailingTimer = setTimeout(() => {
|
||||
this.progressTrailingTimer = null;
|
||||
this.emitProgressNow();
|
||||
}, wait);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.emitProgressNow();
|
||||
}
|
||||
|
||||
private emitProgressNow(): void {
|
||||
this.lastProgressEmitTs = Date.now();
|
||||
if (this.progressTrailingTimer !== null) {
|
||||
clearTimeout(this.progressTrailingTimer);
|
||||
this.progressTrailingTimer = null;
|
||||
}
|
||||
const elapsedMs = Date.now() - this.startedAt;
|
||||
const urlsPerSecond = elapsedMs > 0 ? (this.crawled / elapsedMs) * 1000 : 0;
|
||||
const avgResponseTimeMs =
|
||||
|
||||
@@ -106,6 +106,22 @@ export interface ParsedPage {
|
||||
feedUrl: string | null;
|
||||
/** Number of internal hyperlinks with no usable anchor text or image alt. */
|
||||
emptyAnchorCount: number;
|
||||
/**
|
||||
* Number of `<a>` elements that look clickable but are NOT crawlable —
|
||||
* either no `href` at all (with `onclick`), `href="javascript:…"`, or
|
||||
* `href="#"` paired with an `onclick`. Search engines can't follow
|
||||
* these so any navigation that depends on them is invisible to crawl.
|
||||
* Excludes `href="#"` without `onclick` (legitimate scroll anchors)
|
||||
* and `href="#section-id"` (in-page jumps — crawlable as the same URL).
|
||||
*/
|
||||
jsOnlyLinksCount: number;
|
||||
/**
|
||||
* Visible-text bytes divided by total HTML bytes, expressed as an
|
||||
* integer percent (0–100). Low ratio (typically <10%) suggests heavy
|
||||
* JavaScript / template scaffolding with little crawlable content;
|
||||
* high ratio (>30%) is content-rich. Null on non-HTML or empty pages.
|
||||
*/
|
||||
textCodeRatio: number | null;
|
||||
/** Number of `<img>` with explicit `alt=""` (decorative — distinct from missing alt). */
|
||||
imagesEmptyAlt: number;
|
||||
/**
|
||||
@@ -533,7 +549,53 @@ export function parseHtml(
|
||||
const hasNoindex = metaRobots !== null && metaRobots.includes('noindex');
|
||||
const hasNofollow = metaRobots !== null && metaRobots.includes('nofollow');
|
||||
|
||||
// Text/code ratio — visible body text bytes divided by raw HTML bytes,
|
||||
// rounded to integer percent. Cheap to compute right next to the body
|
||||
// text-length pass below. We strip <script>/<style>/<noscript> contents
|
||||
// out of the visible-text count because their inner text isn't user-
|
||||
// facing content (and would inflate the ratio on JS-heavy SPAs that
|
||||
// ship a 200 KB script tag with no markup). Null when the body is
|
||||
// empty or the HTML body byte count is zero.
|
||||
const totalHtmlBytes = Buffer.byteLength(html, 'utf8');
|
||||
let textCodeRatio: number | null = null;
|
||||
if (totalHtmlBytes > 0) {
|
||||
const $body = $('body').clone();
|
||||
$body.find('script, style, noscript').remove();
|
||||
const visibleText = $body.text().replace(/\s+/g, ' ').trim();
|
||||
const visibleBytes = Buffer.byteLength(visibleText, 'utf8');
|
||||
textCodeRatio = Math.min(100, Math.round((visibleBytes / totalHtmlBytes) * 100));
|
||||
}
|
||||
|
||||
let emptyAnchorCount = 0;
|
||||
// JS-only / non-crawlable anchors. Three patterns count toward this
|
||||
// number — see the field doc on `parseHtml` return for the rationale:
|
||||
// 1. `<a onclick="…">` (no href at all)
|
||||
// 2. `<a href="javascript:…">` (href is a JS URI scheme)
|
||||
// 3. `<a href="#" onclick="…">` (placeholder href + JS handler)
|
||||
// We scan ALL `<a>` (not just `a[href]`) because pattern 1 has no href.
|
||||
let jsOnlyLinksCount = 0;
|
||||
$('a').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const rawHref = $el.attr('href');
|
||||
const onclick = $el.attr('onclick');
|
||||
if (rawHref === undefined) {
|
||||
if (onclick && onclick.trim() !== '') jsOnlyLinksCount++;
|
||||
return;
|
||||
}
|
||||
const href = rawHref.trim();
|
||||
// RFC 3986 — `javascript:` scheme is the canonical JS-only marker.
|
||||
// Lowercased before match to handle `JavaScript:` casing in the wild.
|
||||
if (/^javascript:/i.test(href)) {
|
||||
jsOnlyLinksCount++;
|
||||
return;
|
||||
}
|
||||
// `#` exactly = placeholder; only counts when an onclick is wired.
|
||||
// `#section-id` (length > 1) is a legitimate in-page jump — skip.
|
||||
if (href === '#' && onclick && onclick.trim() !== '') {
|
||||
jsOnlyLinksCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const linkMap = new Map<string, DiscoveredLink>();
|
||||
$('a[href]').each((_, el) => {
|
||||
const href = $(el).attr('href');
|
||||
@@ -674,6 +736,27 @@ export function parseHtml(
|
||||
});
|
||||
});
|
||||
|
||||
// OG / Twitter share-card images. They rarely appear inside `<img>`
|
||||
// tags so the loop above misses them, but we want them in the probe
|
||||
// pipeline so the size-validation issue filters can see whether
|
||||
// Facebook/Twitter will refuse to render the card. We stub them as
|
||||
// image rows with no alt/dimensions; the existing size-probe pass
|
||||
// will fill `byte_size` automatically post-crawl.
|
||||
for (const ogish of [ogImage, twitterImage]) {
|
||||
if (!ogish) continue;
|
||||
const normalized = normalizeUrl(ogish, pageUrl, opts.urlRewrites);
|
||||
if (!normalized) continue;
|
||||
if (!/^https?:/.test(normalized)) continue;
|
||||
if (imageMap.has(normalized)) continue;
|
||||
imageMap.set(normalized, {
|
||||
src: normalized,
|
||||
alt: null,
|
||||
width: null,
|
||||
height: null,
|
||||
isInternal: isSameHost(pageUrl, normalized, opts),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
titleCount,
|
||||
@@ -719,6 +802,8 @@ export function parseHtml(
|
||||
manifestUrl,
|
||||
feedUrl,
|
||||
emptyAnchorCount,
|
||||
jsOnlyLinksCount,
|
||||
textCodeRatio,
|
||||
imagesEmptyAlt,
|
||||
imagesLazy,
|
||||
formInputCount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { ProjectDb } from './project-db.js';
|
||||
export { ProjectDb, EXPENSIVE_ISSUE_DEFINITIONS } from './project-db.js';
|
||||
export { runMigrations } from './migrations.js';
|
||||
@@ -831,6 +831,60 @@ const MIGRATIONS: Migration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 40,
|
||||
name: 'add_js_only_links_count',
|
||||
// Wave 2 / item 1 — Per-page count of `<a>` elements that are NOT
|
||||
// crawlable: no href + onclick, href="javascript:…", or href="#"
|
||||
// with onclick. Powers the "JS-Only Navigation" issue filter.
|
||||
up: (db) => {
|
||||
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === 'js_only_links_count')) {
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN js_only_links_count INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 41,
|
||||
name: 'add_text_code_ratio',
|
||||
// Wave 2 / item 4 — Per-page text/code ratio = visible-text bytes
|
||||
// divided by total HTML bytes, expressed as integer percent
|
||||
// (0–100). Powers the "Low Text/Code Ratio (<10%)" issue filter.
|
||||
up: (db) => {
|
||||
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === 'text_code_ratio')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN text_code_ratio INTEGER');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 42,
|
||||
name: 'add_urls_issues_materialized',
|
||||
// I-3 — Materialised issue table. Lets the sidebar count counters
|
||||
// that would otherwise need O(n²) correlated subqueries (dead
|
||||
// external domain, duplicate URL post-norm, canonical chain
|
||||
// multi-hop, …) read with a single GROUP BY instead. Refilled
|
||||
// once per crawl by `recomputeUrlsIssues()`.
|
||||
// - `url_id` : FK-shaped (no constraint — ON DELETE handled
|
||||
// by the recompute pass that TRUNCATEs first)
|
||||
// - `issue_key` : the 'issues:*' UrlCategory string
|
||||
// PRIMARY KEY ensures idempotent INSERT-OR-IGNORE on incremental
|
||||
// updates. Index on `issue_key` powers the count-grouping query.
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS urls_issues (
|
||||
url_id INTEGER NOT NULL,
|
||||
issue_key TEXT NOT NULL,
|
||||
PRIMARY KEY (url_id, issue_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_issues_key ON urls_issues(issue_key);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
+475
-108
@@ -132,6 +132,8 @@ interface UrlRowDb {
|
||||
images_lazy: number;
|
||||
headings: string | null;
|
||||
server_header: string | null;
|
||||
js_only_links_count: number;
|
||||
text_code_ratio: number | null;
|
||||
}
|
||||
|
||||
interface ImageRowDb {
|
||||
@@ -244,6 +246,10 @@ export interface UpsertUrlInput {
|
||||
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 = `
|
||||
@@ -277,7 +283,8 @@ const UPSERT_URL_SQL = `
|
||||
analytics_trackers,
|
||||
form_input_count, form_input_unlabeled, images_lazy,
|
||||
headings,
|
||||
server_header
|
||||
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,
|
||||
@@ -308,7 +315,8 @@ const UPSERT_URL_SQL = `
|
||||
:analytics_trackers,
|
||||
:form_input_count, :form_input_unlabeled, :images_lazy,
|
||||
:headings,
|
||||
:server_header
|
||||
:server_header,
|
||||
:js_only_links_count, :text_code_ratio
|
||||
)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
content_kind = excluded.content_kind,
|
||||
@@ -408,6 +416,8 @@ const UPSERT_URL_SQL = `
|
||||
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
|
||||
`;
|
||||
@@ -418,20 +428,67 @@ export class ProjectDb {
|
||||
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;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.db = new DatabaseSync(filePath);
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec('PRAGMA synchronous = NORMAL');
|
||||
this.db.exec('PRAGMA foreign_keys = ON');
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
this.db.exec('PRAGMA temp_store = MEMORY');
|
||||
this.db.exec('PRAGMA cache_size = -131072');
|
||||
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');
|
||||
this.db.exec('PRAGMA page_size = 4096');
|
||||
this.db.exec('PRAGMA wal_autocheckpoint = 2000');
|
||||
runMigrations(this.db);
|
||||
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 = ?');
|
||||
@@ -462,6 +519,65 @@ export class ProjectDb {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = ?')
|
||||
@@ -726,6 +842,8 @@ export class ProjectDb {
|
||||
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;
|
||||
@@ -738,7 +856,11 @@ export class ProjectDb {
|
||||
insertLinks(fromUrlId: number, links: DiscoveredLink[], fromDepth: number): void {
|
||||
if (links.length === 0) return;
|
||||
const CHUNK = 200;
|
||||
this.db.exec('BEGIN');
|
||||
// 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()
|
||||
@@ -797,13 +919,72 @@ export class ProjectDb {
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec('COMMIT');
|
||||
if (ownsTx) this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
recomputeInlinks(): void {
|
||||
// One-pass aggregate via temp table: GROUP BY links.to_url once, then
|
||||
// join. The naive correlated-subquery form (UPDATE … = (SELECT COUNT…))
|
||||
@@ -1538,7 +1719,8 @@ export class ProjectDb {
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(from_url_id, image_id) DO UPDATE SET alt = excluded.alt`,
|
||||
);
|
||||
this.db.exec('BEGIN');
|
||||
const ownsTx = !this.isInTransaction();
|
||||
if (ownsTx) this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const img of images) {
|
||||
const row = upsertImage.get(
|
||||
@@ -1550,9 +1732,15 @@ export class ProjectDb {
|
||||
) as { id: number };
|
||||
upsertUsage.run(fromUrlId, row.id, img.alt);
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
if (ownsTx) this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
if (ownsTx) {
|
||||
try {
|
||||
this.db.exec('ROLLBACK');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1700,11 +1888,33 @@ export class ProjectDb {
|
||||
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 =>
|
||||
(
|
||||
this.db.prepare(`SELECT COUNT(*) AS c FROM urls WHERE ${clause}`).get() as { c: number }
|
||||
).c;
|
||||
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
|
||||
@@ -1778,10 +1988,19 @@ export class ProjectDb {
|
||||
}
|
||||
|
||||
private getIssuesCounts(): OverviewCounts['issues'] {
|
||||
const countWhere = (clause: string): number =>
|
||||
(
|
||||
this.db.prepare(`SELECT COUNT(*) AS c FROM urls WHERE ${clause}`).get() as { c: number }
|
||||
).c;
|
||||
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'";
|
||||
@@ -2241,86 +2460,15 @@ export class ProjectDb {
|
||||
AND h1 IS NOT NULL AND h1 != ''
|
||||
AND TRIM(LOWER(title)) = TRIM(LOWER(h1))`,
|
||||
),
|
||||
deadExternalDomain: countWhere(
|
||||
`${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
|
||||
)
|
||||
)
|
||||
)`,
|
||||
),
|
||||
duplicateUrlPostNorm: countWhere(
|
||||
`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
|
||||
),
|
||||
'/'
|
||||
)
|
||||
)`,
|
||||
),
|
||||
canonicalChainMultiHop: countWhere(
|
||||
`${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
|
||||
)`,
|
||||
),
|
||||
// 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
|
||||
@@ -2340,9 +2488,63 @@ export class ProjectDb {
|
||||
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
|
||||
)`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
@@ -2558,7 +2760,11 @@ export class ProjectDb {
|
||||
if (value.length > 4096) value = value.slice(0, 4093) + '...';
|
||||
list.push({ name, value });
|
||||
}
|
||||
this.db.exec('BEGIN');
|
||||
// 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) {
|
||||
@@ -2569,9 +2775,15 @@ export class ProjectDb {
|
||||
.prepare(`INSERT INTO headers (url_id, name, value) VALUES ${placeholders}`)
|
||||
.run(...args);
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
if (ownsTx) this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
if (ownsTx) {
|
||||
try {
|
||||
this.db.exec('ROLLBACK');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -3551,6 +3763,8 @@ export class ProjectDb {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -3589,6 +3803,112 @@ function validSortColumn(sortBy: string | undefined): string {
|
||||
return VALID_SORT_COLUMNS.has(snake) ? snake : 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -4620,6 +4940,53 @@ function categoryWhereClause(cat: UrlCategory): string | null {
|
||||
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
|
||||
)`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -157,6 +157,11 @@ export type UrlCategory =
|
||||
| 'issues:canonical-chain-multi-hop'
|
||||
| 'issues:image-slow-loading'
|
||||
| 'issues:description-equals-h1'
|
||||
| 'issues:js-only-navigation'
|
||||
| 'issues:text-code-ratio-low'
|
||||
| 'issues:render-blocking-critical'
|
||||
| 'issues:og-image-too-large'
|
||||
| 'issues:twitter-image-too-large'
|
||||
|
||||
export type Indexability =
|
||||
| 'indexable'
|
||||
@@ -232,6 +237,18 @@ export interface CrawlUrlRow {
|
||||
* the server didn't send a `Server` header.
|
||||
*/
|
||||
serverHeader: string | null;
|
||||
/**
|
||||
* Per-page count of `<a>` elements that look clickable but are NOT
|
||||
* crawlable (no href + onclick, `href="javascript:…"`, or `href="#"`
|
||||
* with onclick). Surfaces in the "JS-Only Navigation" issue filter.
|
||||
*/
|
||||
jsOnlyLinksCount: number;
|
||||
/**
|
||||
* Visible-text bytes / total HTML bytes as integer percent (0–100).
|
||||
* Low ratio (<10%) suggests heavy script/template scaffolding with
|
||||
* little crawlable content. Null on non-HTML or empty pages.
|
||||
*/
|
||||
textCodeRatio: number | null;
|
||||
redirectTarget: string | null;
|
||||
lang: string | null;
|
||||
viewport: string | null;
|
||||
@@ -856,6 +873,37 @@ export interface OverviewCounts {
|
||||
* usually means the description was never customised for SERP CTR.
|
||||
*/
|
||||
descriptionEqualsH1: number;
|
||||
/**
|
||||
* Pages with at least one `<a>` element that is clickable but not
|
||||
* crawlable (no href + onclick, `href="javascript:…"`, or
|
||||
* `href="#"` paired with onclick). Search-engine bots can't follow
|
||||
* these — any navigation that depends on them is invisible.
|
||||
*/
|
||||
jsOnlyNavigation: number;
|
||||
/**
|
||||
* Pages whose visible-text-to-HTML byte ratio is < 10% — heavy
|
||||
* JavaScript / template scaffolding with little crawlable content.
|
||||
*/
|
||||
textCodeRatioLow: number;
|
||||
/**
|
||||
* Pages with > 20 render-blocking head resources (escalated tier
|
||||
* above the existing > 5 "Render-Blocking Head" issue) — almost
|
||||
* always a third-party tag bloat that murders LCP.
|
||||
*/
|
||||
renderBlockingCritical: number;
|
||||
/**
|
||||
* Pages whose `og:image` exceeds 5 MB — Facebook's documented hard
|
||||
* cap is 8 MB and OG images > 5 MB are routinely silently dropped
|
||||
* by share-card renderers. Determined via the post-crawl image
|
||||
* HEAD probe `Content-Length`.
|
||||
*/
|
||||
ogImageTooLarge: number;
|
||||
/**
|
||||
* Pages whose `twitter:image` exceeds 5 MB. Twitter's documented
|
||||
* max is 5 MB for JPG/PNG and 15 MB for GIF — we use 5 MB as the
|
||||
* conservative threshold that catches both card types' renderer.
|
||||
*/
|
||||
twitterImageTooLarge: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,14 @@ export const IPC = {
|
||||
reportsWordCountPerDirectory: 'reports:word-count-per-directory',
|
||||
reportsSitemapOrphans: 'reports:sitemap-orphans',
|
||||
reportsServerHeaders: 'reports:server-headers',
|
||||
/**
|
||||
* Renderer → main heartbeat carrying the live input-lag estimate (ms).
|
||||
* The crawler subscribes to this so it can adaptively shrink its
|
||||
* concurrency when the renderer's main thread is starved — letting
|
||||
* low-end machines stay responsive without the user having to tune
|
||||
* `maxConcurrency` by hand.
|
||||
*/
|
||||
rendererLagReport: 'renderer:lag-report',
|
||||
prefsExportSettings: 'prefs:export-settings',
|
||||
prefsImportSettings: 'prefs:import-settings',
|
||||
} as const;
|
||||
@@ -650,6 +658,8 @@ export interface FreeCrawlApi {
|
||||
): Promise<WordCountPerDirectoryRow[]>;
|
||||
reportsSitemapOrphans(limit?: number): Promise<SitemapOrphanRow[]>;
|
||||
reportsServerHeaders(): Promise<ServerHeaderRow[]>;
|
||||
/** Heartbeat: renderer reports its latest input-lag sample (ms). */
|
||||
reportRendererLag(lagMs: number): void;
|
||||
prefsExportSettings(input: SettingsExportInput): Promise<SettingsExportResult>;
|
||||
prefsImportSettings(): Promise<SettingsImportResult>;
|
||||
onLogEntry(cb: (entry: LogEntry) => void): () => void;
|
||||
|
||||
Reference in new issue
Block a user