feat: PageSpeed Insights + crawl stability fixes

This commit is contained in:
kemalai committed 2026-05-22 13:33:14 +03:00
1 parent 902cf98fa5
commit 8214718e9e
33 files changed
+1732 -103

No files matched your search

+4 -3
View File
@@ -43,7 +43,7 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
<td width="50%" valign="top">
### 🔍 Analysis
- **150+ SEO issue checks** across 24 top-level tabs
- **150+ SEO issue checks** across 25 top-level tabs
- Near-duplicate clustering (SimHash + LSH) + exact-duplicate content hash
- Full hreflang validation (reciprocity, self-ref, inconsistent lang)
- OpenGraph / Twitter Card / JSON-LD / AMP / **Web App Manifest** parsing
@@ -53,6 +53,7 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
- **Active vs passive mixed content** split
- Readability (Flesch, Flesch–Kincaid, Gunning Fog)
- Custom CSS + Regex extraction (10 rules) + `/regex/` literal Custom Search
- **PageSpeed Insights** — on-demand Lighthouse audits (mobile + desktop) per URL
</td>
</tr>
@@ -439,9 +440,9 @@ graph LR
## 📈 Status
> [!NOTE]
> **Active development.** All 24 analysis tabs, advanced search, 150+ issue categories, sitemap export variants, JSON / CSV / XLSX / HTML reports, list mode, custom extraction, near-duplicate + exact-duplicate detection, hreflang validation, project compare, Cytoscape visualization, auth, proxy, webhook, **MCP server with crawl control + live progress**, OS notifications, **robots.txt syntax validator**, URL rewriting + preview, **status-code diagnosis banner**, **live memory monitor**, **in-app scheduled crawl**, **multi-language UI (EN + TR)**, **`.seoproject` file association**, in-app logs, and diagnostic popups are working. Cross-platform installers (Windows `.exe` + portable, macOS `.dmg`, Linux `.AppImage` / `.deb` / `.rpm`). Live-streaming UX with **first row in ~1 s**.
> **Active development.** All 25 analysis tabs, advanced search, 150+ issue categories, sitemap export variants, JSON / CSV / XLSX / HTML reports, list mode, custom extraction, near-duplicate + exact-duplicate detection, hreflang validation, project compare, Cytoscape visualization, auth, proxy, webhook, **MCP server with crawl control + live progress**, **Google PageSpeed Insights integration**, OS notifications, **robots.txt syntax validator**, URL rewriting + preview, **status-code diagnosis banner**, **live memory monitor**, **in-app scheduled crawl**, **multi-language UI (EN + TR)**, **`.seoproject` file association**, in-app logs, and diagnostic popups are working. Cross-platform installers (Windows `.exe` + portable, macOS `.dmg`, Linux `.AppImage` / `.deb` / `.rpm`). Live-streaming UX with **first row in ~1 s**.
>
> **Upcoming:** plugin system, JavaScript rendering, log analyzer, PageSpeed API integration.
> **Upcoming:** plugin system, JavaScript rendering, log analyzer.
<br />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
@@ -69,6 +69,7 @@ const ALLOWED_METHODS = new Set<string>([
'queryUrls',
'queryImages',
'queryBrokenLinks',
'queryPagespeed',
'getOverviewCounts',
'getOverviewCountsAsync',
'getSummary',
+166 -2
View File
@@ -88,6 +88,11 @@ import {
type UrlCertInfoResult,
type UrlsQueryInput,
type UrlsQueryResult,
type PagespeedQueryInput,
type PagespeedQueryResult,
type PagespeedRunInput,
type PagespeedRunResult,
type PagespeedStrategy,
} from '@freecrawl/shared-types';
import {
Crawler,
@@ -114,7 +119,9 @@ import {
getState as getIntegrationsState,
setCredentials as setIntegrationCredentials,
clearCredentials as clearIntegrationCredentials,
resolveCredentials,
} from './credentials.js';
import { runPagespeedBatch, type PagespeedBatchItem } from './pagespeed.js';
import * as logger from './logger.js';
import { dbReaderPool, callReaderOrFallback } from './db-reader-pool.js';
import { dbWriterPool } from './db-writer-pool.js';
@@ -141,6 +148,10 @@ let mainWindow: BrowserWindow | null = null;
let logsWindow: BrowserWindow | null = null;
let db: ProjectDb | null = null;
let activeCrawler: Crawler | null = null;
/** Faz 7 — PageSpeed run guard. Only one PSI batch may run at a time;
* `pagespeedCancelRequested` is the cooperative cancel flag. */
let pagespeedRunActive = false;
let pagespeedCancelRequested = false;
/** Most-recent CrawlConfig — needed by the crash-recovery resume
* flow so we can re-create the Crawler with the same knobs the user
* had set, without re-prompting. Hydrated lazily from `project_meta`
@@ -213,6 +224,11 @@ function flushPrefs(): void {
clearTimeout(prefsWriteTimer);
prefsWriteTimer = null;
}
// Never write a cache that was never loaded — `prefsCache` is still
// the empty default. This guards the rejected second instance: its
// `before-quit` → `performShutdown` runs `flushPrefs`, and writing
// `{}` here would clobber the running first instance's saved prefs.
if (!prefsLoaded) return;
try {
writeFileSync(prefsFilePath(), JSON.stringify(prefsCache, null, 2), 'utf8');
} catch {
@@ -872,7 +888,18 @@ function pickInstallerAsset(assets: GitHubReleaseAsset[]): GitHubReleaseAsset |
(a) => a && typeof a.name === 'string' && !a.name.endsWith('.blockmap'),
);
if (process.platform === 'win32') {
return list.find((a) => /\.exe$/i.test(a.name)) ?? null;
// The Windows release ships two `.exe` assets — the NSIS installer
// (`…Setup….exe`) and the standalone portable build
// (`…-portable.exe`). "Check for Updates" must offer the installer:
// prefer the `Setup` asset, fall back to any non-portable `.exe`,
// and only as a last resort take whatever `.exe` exists.
const exes = list.filter((a) => /\.exe$/i.test(a.name));
return (
exes.find((a) => /setup/i.test(a.name)) ??
exes.find((a) => !/portable/i.test(a.name)) ??
exes[0] ??
null
);
}
if (process.platform === 'darwin') {
const isArm = process.arch === 'arm64';
@@ -1276,6 +1303,13 @@ function createWindow(): void {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
// Keep the renderer's frame loop at full rate even when the window
// loses focus — otherwise focusing the Logs window throttles this
// window's requestAnimationFrame, dropping its FPS and starving
// the freeze-watchdog's renderer heartbeat (it then logs a false
// STALL:RENDERER). The Logs window already sets this; the main
// window needs it for the same reason.
backgroundThrottling: false,
},
});
@@ -1859,6 +1893,117 @@ function registerIpc(): void {
return clearIntegrationCredentials(id);
});
// V1 Faz 7 — Google PageSpeed Insights. `pagespeedQuery` lists the
// crawled internal HTML pages joined with any stored audit results;
// `pagespeedRun` audits a user-selected subset (the slow part — PSI
// takes ~10–30 s per URL — so it streams `pagespeedProgress` events
// and can be cancelled mid-run).
ipcMain.handle(
IPC.pagespeedQuery,
async (_e, input: PagespeedQueryInput): Promise<PagespeedQueryResult> => {
const params = {
limit: input.limit,
offset: input.offset,
search: input.search,
filter: input.filter,
};
return callReaderOrFallback<PagespeedQueryResult>(
'queryPagespeed',
[params],
() => getDb().queryPagespeed(params),
);
},
);
ipcMain.handle(
IPC.pagespeedRun,
async (_e, input: PagespeedRunInput): Promise<PagespeedRunResult> => {
if (pagespeedRunActive) {
return { completed: 0, failed: 0, cancelled: true };
}
const urls = Array.from(
new Set(
(input.urls ?? []).filter(
(u): u is string => typeof u === 'string' && u.length > 0,
),
),
);
if (urls.length === 0) {
return { completed: 0, failed: 0, cancelled: false };
}
const strategies: PagespeedStrategy[] =
input.strategy === 'both' ? ['mobile', 'desktop'] : [input.strategy];
const items: PagespeedBatchItem[] = [];
for (const url of urls) {
for (const strategy of strategies) items.push({ url, strategy });
}
const apiKeyRaw = resolveCredentials('pagespeed')['apiKey'];
const apiKey =
apiKeyRaw && apiKeyRaw.trim().length > 0 ? apiKeyRaw.trim() : undefined;
pagespeedRunActive = true;
pagespeedCancelRequested = false;
// Pin writes to the project that was active when the run started —
// a PSI run can last minutes, and resolving `getDb()` per result
// would leak audits into a different project if the user opens
// one mid-run.
const runDb = getDb();
logger.log(
'info',
'pagespeed',
`run started — ${items.length} audit(s): ${urls.length} URL(s) × ${strategies.length} strategy${
apiKey ? ' (keyed)' : ' (keyless — low rate limit)'
}`,
);
try {
const result = await runPagespeedBatch({
items,
apiKey,
isCancelled: () => pagespeedCancelRequested,
onResult: (url, strategy, metrics) => {
try {
runDb.upsertPagespeedResult(url, strategy, metrics);
} catch (err) {
logger.log(
'error',
'pagespeed',
`DB write failed for ${url}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
},
onProgress: (done, total, currentUrl) => {
mainWindow?.webContents.send(IPC.pagespeedProgress, {
done,
total,
currentUrl,
running: true,
});
},
});
mainWindow?.webContents.send(IPC.pagespeedProgress, {
done: result.completed + result.failed,
total: items.length,
currentUrl: null,
running: false,
});
fireDataChanged();
return result;
} finally {
pagespeedRunActive = false;
pagespeedCancelRequested = false;
}
},
);
ipcMain.handle(IPC.pagespeedCancel, () => {
if (pagespeedRunActive) {
pagespeedCancelRequested = true;
logger.log('info', 'pagespeed', 'run cancellation requested');
}
});
// Stream new entries to the logs window in coalesced batches. A heavy
// crawl emits 100–300 logs/s; sending each as its own IPC message
// saturated the renderer's event loop and caused visible UI stutters
@@ -3403,7 +3548,15 @@ void app.whenReady().then(async () => {
});
});
app.on('window-all-closed', () => {
// Full teardown — DB, worker pools, watchdog, MCP bridge, prefs flush.
// This lives in `before-quit`, NOT `window-all-closed`: on macOS the app
// stays resident after its window closes (standard platform behaviour),
// and terminating the worker pools there would make a later reopen from
// the dock throw `cannot init after terminate()`. Runs exactly once.
let shutdownComplete = false;
function performShutdown(): void {
if (shutdownComplete) return;
shutdownComplete = true;
activeCrawler?.stop();
stopScheduler();
// Tear down the MCP bridge first so any in-flight HTTP calls fail
@@ -3428,5 +3581,16 @@ app.on('window-all-closed', () => {
// Flush the disk log stream before exit so the last lines aren't lost
// if the OS kills the process during a Quit-All cycle.
logger.flushFileLogging();
}
app.on('before-quit', () => {
performShutdown();
});
app.on('window-all-closed', () => {
// Stop any active crawl when the UI goes away, but keep the DB and
// worker pools alive so macOS reopen-from-dock works. The full
// teardown happens in `before-quit`.
activeCrawler?.stop();
if (process.platform !== 'darwin') app.quit();
});
+229
View File
@@ -0,0 +1,229 @@
/**
* Faz 7 — Google PageSpeed Insights API client.
*
* PSI runs a Lighthouse audit on a publicly reachable URL and returns
* lab performance metrics. It works without a key at a low shared rate
* limit; a free Google Cloud API key (the `pagespeed` integration in
* the credential store) raises throughput. The key is resolved in the
* main process only and never crosses into the renderer.
*
* Two layers:
* - `fetchPagespeedAudit` — one URL + strategy → metrics (or an
* `error` result on any failure, so a single bad URL never aborts
* a batch).
* - `runPagespeedBatch` — a small concurrency pool over many
* (url, strategy) items, with progress callbacks and cooperative
* cancellation. PSI is slow (~10–30 s per audit), so the renderer
* drives this on-demand against a user-selected subset, never the
* whole crawl.
*/
import type { PagespeedMetrics, PagespeedStrategy } from '@freecrawl/shared-types';
import * as logger from './logger.js';
const PSI_ENDPOINT =
'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
/** A single Lighthouse audit can legitimately take ~30 s; cap at 90 s. */
const REQUEST_TIMEOUT_MS = 90_000;
/** Concurrency: a key lifts the quota enough for modest parallelism;
* keyless calls share a tiny global pool and 429 instantly, so they
* run strictly one at a time. */
const CONCURRENCY_WITH_KEY = 4;
const CONCURRENCY_NO_KEY = 1;
function errorMetrics(fetchedAt: string, message: string): PagespeedMetrics {
return {
performance: null,
lcp: null,
cls: null,
fcp: null,
tbt: null,
speedIndex: null,
status: 'error',
error: message,
fetchedAt,
};
}
/** Pull `numericValue` out of a Lighthouse audit object, rounded. */
function auditValue(
audits: Record<string, unknown>,
key: string,
decimals = 0,
): number | null {
const audit = audits[key];
if (!audit || typeof audit !== 'object') return null;
const v = (audit as { numericValue?: unknown }).numericValue;
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
const factor = 10 ** decimals;
return Math.round(v * factor) / factor;
}
/**
* Audit one URL for one form factor. Never throws — any network /
* quota / Lighthouse failure resolves to an `error` PagespeedMetrics
* so the caller can record it and move on.
*/
export async function fetchPagespeedAudit(
url: string,
strategy: PagespeedStrategy,
apiKey: string | undefined,
): Promise<PagespeedMetrics> {
const fetchedAt = new Date().toISOString();
const params = new URLSearchParams({
url,
strategy,
category: 'performance',
});
if (apiKey) params.set('key', apiKey);
try {
const res = await fetch(`${PSI_ENDPOINT}?${params.toString()}`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: { Accept: 'application/json' },
});
const json = (await res.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!res.ok) {
const apiErr = json?.['error'] as { message?: string } | undefined;
return errorMetrics(
fetchedAt,
apiErr?.message ?? `PageSpeed API returned HTTP ${res.status}`,
);
}
if (!json) {
return errorMetrics(fetchedAt, 'PageSpeed API returned an empty response');
}
const lhr = json['lighthouseResult'] as Record<string, unknown> | undefined;
if (!lhr) {
return errorMetrics(fetchedAt, 'No Lighthouse result in PageSpeed response');
}
const audits = (lhr['audits'] as Record<string, unknown>) ?? {};
const categories = lhr['categories'] as
| { performance?: { score?: unknown } }
| undefined;
const score = categories?.performance?.score;
return {
performance:
typeof score === 'number' ? Math.round(score * 100) : null,
lcp: auditValue(audits, 'largest-contentful-paint'),
cls: auditValue(audits, 'cumulative-layout-shift', 3),
fcp: auditValue(audits, 'first-contentful-paint'),
tbt: auditValue(audits, 'total-blocking-time'),
speedIndex: auditValue(audits, 'speed-index'),
status: 'ok',
error: null,
fetchedAt,
};
} catch (err) {
// `AbortSignal.timeout` rejects with a DOMException, which is not
// always an `instanceof Error` in Node — sniff `.name` directly.
const name = (err as { name?: string } | null)?.name;
const message =
name === 'TimeoutError' || name === 'AbortError'
? `PageSpeed audit timed out after ${REQUEST_TIMEOUT_MS / 1000}s`
: err instanceof Error
? err.message
: String(err);
return errorMetrics(fetchedAt, message);
}
}
/** One (url, strategy) audit unit of work. */
export interface PagespeedBatchItem {
url: string;
strategy: PagespeedStrategy;
}
export interface PagespeedBatchOptions {
items: PagespeedBatchItem[];
/** Resolved PSI API key, or undefined for keyless (low-rate) calls. */
apiKey: string | undefined;
/** Cooperative cancel — checked before each item is dequeued. */
isCancelled: () => boolean;
/** Persist one completed audit. */
onResult: (
url: string,
strategy: PagespeedStrategy,
metrics: PagespeedMetrics,
) => void;
/** Live progress — `currentUrl` is the URL just about to be audited. */
onProgress: (done: number, total: number, currentUrl: string | null) => void;
}
export interface PagespeedBatchResult {
completed: number;
failed: number;
cancelled: boolean;
}
/**
* Run a PageSpeed audit over many items with a small concurrency pool.
* Cancellation gates new items only — audits already in flight finish
* and are persisted (a completed audit is always worth keeping).
*/
export async function runPagespeedBatch(
opts: PagespeedBatchOptions,
): Promise<PagespeedBatchResult> {
const { items, apiKey, isCancelled, onResult, onProgress } = opts;
const total = items.length;
let done = 0;
let completed = 0;
let failed = 0;
let cursor = 0;
onProgress(0, total, null);
const worker = async (): Promise<void> => {
for (;;) {
if (isCancelled()) return;
const index = cursor++;
if (index >= total) return;
const item = items[index];
if (!item) return;
onProgress(done, total, item.url);
const metrics = await fetchPagespeedAudit(
item.url,
item.strategy,
apiKey,
);
try {
onResult(item.url, item.strategy, metrics);
} catch (err) {
logger.log(
'error',
'pagespeed',
`failed to persist audit for ${item.url}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
done++;
if (metrics.status === 'ok') completed++;
else failed++;
onProgress(done, total, null);
}
};
const poolSize = Math.min(
apiKey ? CONCURRENCY_WITH_KEY : CONCURRENCY_NO_KEY,
Math.max(1, total),
);
await Promise.all(Array.from({ length: poolSize }, () => worker()));
const cancelled = isCancelled() && done < total;
logger.log(
'info',
'pagespeed',
`batch finished — ${completed} ok, ${failed} failed${
cancelled ? ', cancelled early' : ''
} (${done}/${total})`,
);
return { completed, failed, cancelled };
}
+25 -10
View File
@@ -72,7 +72,7 @@ class ParserPool {
size ?? Math.max(2, Math.min(8, cpuCount - 2));
for (let i = 0; i < target; i++) {
try {
this.workers.push(this.spawnWorker(i));
this.workers.push(this.spawnWorker());
} catch (err) {
logger.log(
'warn',
@@ -140,35 +140,50 @@ class ParserPool {
// ── private ──────────────────────────────────────────────────────
private spawnWorker(index: number): Worker {
private spawnWorker(): Worker {
const w = new Worker(WORKER_PATH);
w.on('message', (msg: ResponseMessage) => this.handleResponse(msg));
w.on('error', (err) => {
logger.log('error', 'main', `parser-worker[${index}] error: ${err.message}`);
logger.log('error', 'main', `parser-worker error: ${err.message}`);
});
w.on('exit', (code) => {
if (this.terminated) return;
if (code === 0) return;
// Resolve this worker's CURRENT slot by identity — never trust a
// captured index. An init-time spawn failure leaves the array
// shorter than the logical worker count, so a captured index
// would point at the wrong slot (or past the end).
const slot = this.workers.indexOf(w);
if (slot < 0) return; // already replaced / removed
if (code === 0) {
// Clean exit — drop the slot so `parse()` never dispatches to it.
this.workers.splice(slot, 1);
return;
}
logger.log(
'warn',
'main',
`parser-worker[${index}] exited (code=${code}) — respawning`,
`parser-worker exited (code=${code}) — respawning`,
);
const respawn = this.tryRespawn(index);
if (respawn) this.workers[index] = respawn;
const respawn = this.tryRespawn();
if (respawn) {
this.workers[slot] = respawn;
} else {
// Respawn failed — remove the dead worker so it is never picked.
this.workers.splice(slot, 1);
}
});
return w;
}
private tryRespawn(index: number): Worker | null {
private tryRespawn(): Worker | null {
if (this.terminated) return null;
try {
return this.spawnWorker(index);
return this.spawnWorker();
} catch (err) {
logger.log(
'error',
'main',
`parser-worker[${index}] respawn failed: ${
`parser-worker respawn failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
+17 -10
View File
@@ -144,17 +144,24 @@ export function computeNextFire(
now: number,
lastFiredAt: number | null,
): number {
if (spec.cadence === 'hourly') {
const base = lastFiredAt ?? now;
return base + 60 * 60 * 1000;
}
if (spec.cadence === 'custom') {
if (spec.cadence === 'hourly' || spec.cadence === 'custom') {
const interval =
Math.max(MIN_CUSTOM_INTERVAL_MIN, spec.intervalMinutes ?? MIN_CUSTOM_INTERVAL_MIN) *
60 *
1000;
const base = lastFiredAt ?? now;
return base + interval;
spec.cadence === 'hourly'
? 60 * 60 * 1000
: Math.max(
MIN_CUSTOM_INTERVAL_MIN,
spec.intervalMinutes ?? MIN_CUSTOM_INTERVAL_MIN,
) *
60 *
1000;
// Advance from the last fire, but never return a time in the past:
// if a crawl ran longer than the interval, `base + interval` would
// already be behind `now` and the next tick would fire immediately,
// back-to-back with no gap. Step forward by whole intervals until
// the result is in the future.
let next = (lastFiredAt ?? now) + interval;
while (next <= now) next += interval;
return next;
}
// Daily / weekly — pin to time-of-day in local timezone. We always
// advance by at least 1 minute past `now` to avoid an immediate
+12
View File
@@ -83,6 +83,11 @@ import {
type UrlsQueryInput,
type UrlsQueryResult,
type MemoryStats,
type PagespeedQueryInput,
type PagespeedQueryResult,
type PagespeedRunInput,
type PagespeedRunResult,
type PagespeedProgress,
} from '@freecrawl/shared-types';
function subscribe<T>(
@@ -257,6 +262,13 @@ const api: FreeCrawlApi = {
): Promise<IntegrationsState> => ipcRenderer.invoke(IPC.integrationsSet, id, fields),
integrationsClear: (id: string): Promise<IntegrationsState> =>
ipcRenderer.invoke(IPC.integrationsClear, id),
pagespeedQuery: (input: PagespeedQueryInput): Promise<PagespeedQueryResult> =>
ipcRenderer.invoke(IPC.pagespeedQuery, input),
pagespeedRun: (input: PagespeedRunInput): Promise<PagespeedRunResult> =>
ipcRenderer.invoke(IPC.pagespeedRun, input),
pagespeedCancel: (): Promise<void> => ipcRenderer.invoke(IPC.pagespeedCancel),
onPagespeedProgress: (cb) =>
subscribe<PagespeedProgress>(IPC.pagespeedProgress, cb),
onLogEntry: (cb) => subscribe<LogEntry>(IPC.logsEntry, cb),
onLogsBatch: (cb) => subscribe<LogEntry[]>(IPC.logsBatch, cb),
onLogsBusy: (cb) => subscribe<boolean>(IPC.logsBusy, cb),
+11 -2
View File
@@ -16,6 +16,7 @@ import { UrlsTab } from './tabs/UrlsTab.js';
import { ImagesTab } from './tabs/ImagesTab.js';
import { BrokenLinksTab } from './tabs/BrokenLinksTab.js';
import { SerpTab } from './tabs/SerpTab.js';
import { PageSpeedTab } from './tabs/PageSpeedTab.js';
import { VisualizationTab } from './tabs/VisualizationTab.js';
import { useAppStore } from './store.js';
import type { MenuEvent } from '@freecrawl/shared-types';
@@ -157,9 +158,11 @@ export function App() {
// behind the main window on some Windows configurations — leaves
// the user staring at an "unresponsive" UI: the URL input can't
// be clicked because a hidden modal is blocking the renderer.
let recoveryTimer: number | undefined;
let recoveryCancelled = false;
void window.freecrawl.crashRecoveryStatus().then((status) => {
if (status.pendingCount === 0) return;
window.setTimeout(() => {
if (recoveryCancelled || status.pendingCount === 0) return;
recoveryTimer = window.setTimeout(() => {
const proceed = window.confirm(
t('app.crashRecovery', {
defaultValue:
@@ -308,6 +311,10 @@ export function App() {
}
});
return () => {
// Cancel the deferred crash-recovery prompt so it can't fire a
// `window.confirm` after this component has unmounted.
recoveryCancelled = true;
if (recoveryTimer !== undefined) window.clearTimeout(recoveryTimer);
off1();
off2();
off3();
@@ -357,6 +364,8 @@ export function App() {
<BrokenLinksTab />
) : activeTab === 'serp' ? (
<SerpTab />
) : activeTab === 'pagespeed' ? (
<PageSpeedTab />
) : activeTab === 'visualization' ? (
<VisualizationTab />
) : (
@@ -42,6 +42,7 @@ const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
links: 'all',
'broken-links': 'all',
serp: 'tab:serp',
pagespeed: 'all',
visualization: 'all',
};
@@ -29,6 +29,13 @@ export function OverviewSidebar() {
const progressCrawled = useAppStore((s) => s.progress?.crawled ?? 0);
const activeCategory = useAppStore((s) => s.activeCategory);
const navigateToCategory = useAppStore((s) => s.navigateToCategory);
// Monotonic guard shared by the polling effect and the progress-driven
// refetch — both call overviewGet(), and on the multi-worker reader
// pool a slower response can resolve after a fresher one. Applying
// results strictly in issue order stops a stale count overwriting a
// newer one.
const overviewSeqRef = useRef(0);
const overviewAppliedRef = useRef(0);
const [expanded, setExpanded] = useState<Set<string>>(
new Set([
'summary',
@@ -62,9 +69,13 @@ export function OverviewSidebar() {
// overlapping calls just queue up and starve other IPCs.
if (inFlight) return;
inFlight = true;
const seq = ++overviewSeqRef.current;
try {
const o = await window.freecrawl.overviewGet();
if (!cancelled) setOverview(o);
if (!cancelled && seq > overviewAppliedRef.current) {
overviewAppliedRef.current = seq;
setOverview(o);
}
} finally {
inFlight = false;
}
@@ -113,7 +124,13 @@ export function OverviewSidebar() {
const bucket = Math.floor(progressCrawled / 50);
if (bucket === lastRefetchAtRef.current) return;
lastRefetchAtRef.current = bucket;
void window.freecrawl.overviewGet().then((o) => setOverview(o));
const seq = ++overviewSeqRef.current;
void window.freecrawl.overviewGet().then((o) => {
if (seq > overviewAppliedRef.current) {
overviewAppliedRef.current = seq;
setOverview(o);
}
});
}, [progressCrawled, progressRunning, setOverview]);
const toggle = (k: string) => {
@@ -104,6 +104,31 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
[opts.category, opts.search, opts.sortBy, opts.sortDir, opts.filter],
);
// Single entry point for writing a chunk into the cache. Guarantees
// `chunkOrder` tracks every chunk present in `chunks` — the live tick
// previously called `chunks.set` directly, leaving untracked chunks
// the LRU could never evict (a slow heap leak on long live crawls,
// and `chunkOrder` undercounting so eviction kicked in too late).
const storeChunk = useCallback(
(idx: number, rows: CrawlUrlRow[]): void => {
const isNew = !chunks.current.has(idx);
chunks.current.set(idx, rows);
if (isNew) chunkOrder.current.push(idx);
// Evict chunks not currently visible once the cache exceeds cap.
while (chunkOrder.current.length > MAX_CACHED_CHUNKS) {
const evict = chunkOrder.current.shift();
if (evict === undefined) break;
const { first, last } = activeRange.current;
if (evict >= first && evict <= last) {
chunkOrder.current.push(evict);
continue;
}
chunks.current.delete(evict);
}
},
[],
);
// Shape change (tab/sort/filter switch): clear immediately and load
// fresh data in parallel. Showing the previous tab's rows under the
// new tab's header is the wrong UX — "Internal" rows visible while
@@ -133,8 +158,7 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
const [{ total: t }, ...newChunks] = await Promise.all([metaPromise, ...chunkPromises]);
if (cancelled || token !== resetToken.current) return;
for (const ch of newChunks) {
chunks.current.set(ch.idx, ch.rows);
chunkOrder.current.push(ch.idx);
storeChunk(ch.idx, ch.rows);
}
setTotal(t);
setVersion((v) => v + 1);
@@ -193,7 +217,7 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
try {
const { rows } = await queryChunk(i);
if (cancelled || token !== resetToken.current) return;
chunks.current.set(i, rows);
storeChunk(i, rows);
} catch {
/* ignore */
}
@@ -258,26 +282,14 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
try {
const { rows, total: t } = await queryChunk(chunkIdx);
if (token !== resetToken.current) return;
chunks.current.set(chunkIdx, rows);
chunkOrder.current.push(chunkIdx);
// Evict chunks not currently visible once cache exceeds cap.
while (chunkOrder.current.length > MAX_CACHED_CHUNKS) {
const evict = chunkOrder.current.shift();
if (evict === undefined) break;
const { first, last } = activeRange.current;
if (evict >= first && evict <= last) {
chunkOrder.current.push(evict);
continue;
}
chunks.current.delete(evict);
}
storeChunk(chunkIdx, rows);
setTotal(t);
setVersion((v) => v + 1);
} finally {
fetching.current.delete(chunkIdx);
}
},
[queryChunk],
[queryChunk, storeChunk],
);
const ensureRange = useCallback(
@@ -143,6 +143,7 @@
"links": "Links",
"brokenLinks": "Broken Links",
"serp": "SERP",
"pagespeed": "PageSpeed",
"visualization": "Visualization"
},
"export": {
@@ -617,6 +618,37 @@
"emptyNoData": "No indexable HTML pages with a title yet — run a crawl to populate SERP previews.",
"emptyNoMatch": "No pages match the current filter."
},
"pagespeedTab": {
"title": "PageSpeed Insights",
"filterPlaceholder": "Filter by URL…",
"filterAll": "All pages",
"filterTested": "Tested only",
"filterUntested": "Untested only",
"strategy": "Device:",
"deviceMobile": "Mobile",
"deviceDesktop": "Desktop",
"deviceBoth": "Mobile + Desktop",
"selectUntested": "Select untested",
"run": "Run PageSpeed ({{count}})",
"runProgress": "Auditing {{done}} / {{total}}",
"cancel": "Cancel",
"confirmRun": "This will request {{count}} PageSpeed audits. Each audit takes ~10–30s and{{keyless}} may take a while. Continue?",
"confirmKeyless": " — without an API key (low rate limit) —",
"noKeyBanner": "No PageSpeed API key — audits run keyless at a low rate limit. Add a free key in Settings → Integrations for faster runs.",
"colUrl": "URL",
"colStatus": "Status",
"colPerf": "Perf",
"colLcp": "LCP",
"colCls": "CLS",
"colLastRun": "Last run",
"groupMobile": "Mobile",
"groupDesktop": "Desktop",
"summary": "{{shown}} pages · {{tested}} tested · {{selected}} selected",
"firstN": "(first {{n}})",
"auditing": "Auditing: {{url}}",
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then select pages to audit.",
"emptyNoMatch": "No pages match the current filter."
},
"brokenTab": {
"searchPlaceholder": "Search broken target URLs…",
"scopeAll": "All",
@@ -625,6 +657,9 @@
"internalOnly": "Internal only",
"externalOnly": "External only",
"linksUnit": "broken links",
"uniqueUnit": "unique URLs",
"uniqueLabel": "Unique broken URLs",
"totalLabel": "Total rows",
"noBrokenLinks": "No broken links",
"noBrokenLinksHint": "Run a crawl that includes external links to see broken links.",
"cellsSelected": "{{n}} cell(s) selected"
@@ -143,6 +143,7 @@
"links": "Linkler",
"brokenLinks": "Kırık Linkler",
"serp": "SERP",
"pagespeed": "PageSpeed",
"visualization": "Görselleştirme"
},
"export": {
@@ -616,6 +617,37 @@
"emptyNoData": "Henüz başlığı olan indekslenebilir HTML sayfası yok — SERP önizlemelerini doldurmak için bir crawl çalıştır.",
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
},
"pagespeedTab": {
"title": "PageSpeed Insights",
"filterPlaceholder": "URL'ye göre filtrele…",
"filterAll": "Tüm sayfalar",
"filterTested": "Yalnızca test edilenler",
"filterUntested": "Yalnızca test edilmeyenler",
"strategy": "Cihaz:",
"deviceMobile": "Mobil",
"deviceDesktop": "Masaüstü",
"deviceBoth": "Mobil + Masaüstü",
"selectUntested": "Test edilmeyenleri seç",
"run": "PageSpeed Çalıştır ({{count}})",
"runProgress": "Denetleniyor {{done}} / {{total}}",
"cancel": "İptal",
"confirmRun": "Bu işlem {{count}} PageSpeed denetimi isteyecek. Her denetim ~10–30sn sürer ve{{keyless}} biraz zaman alabilir. Devam edilsin mi?",
"confirmKeyless": " — API anahtarı olmadan (düşük hız sınırı) —",
"noKeyBanner": "PageSpeed API anahtarı yok — denetimler düşük hız sınırıyla anahtarsız çalışır. Daha hızlı çalıştırmak için Ayarlar → Entegrasyonlar'dan ücretsiz bir anahtar ekleyin.",
"colUrl": "URL",
"colStatus": "Durum",
"colPerf": "Perf",
"colLcp": "LCP",
"colCls": "CLS",
"colLastRun": "Son çalıştırma",
"groupMobile": "Mobil",
"groupDesktop": "Masaüstü",
"summary": "{{shown}} sayfa · {{tested}} test edildi · {{selected}} seçildi",
"firstN": "(ilk {{n}})",
"auditing": "Denetleniyor: {{url}}",
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra denetlenecek sayfaları seç.",
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
},
"brokenTab": {
"searchPlaceholder": "Kırık hedef URL ara…",
"scopeAll": "Tümü",
@@ -624,6 +656,9 @@
"internalOnly": "Yalnızca Dahili",
"externalOnly": "Yalnızca Harici",
"linksUnit": "kırık link",
"uniqueUnit": "benzersiz URL",
"uniqueLabel": "Benzersiz kırık URL",
"totalLabel": "Toplam satır",
"noBrokenLinks": "Kırık link yok",
"noBrokenLinksHint": "Kırık linkleri görmek için harici linkleri de içeren bir crawl çalıştır.",
"cellsSelected": "{{n}} hücre seçili"
+2
View File
@@ -34,6 +34,7 @@ export type TabKey =
| 'links'
| 'broken-links'
| 'serp'
| 'pagespeed'
| 'visualization';
export const TAB_ORDER: { key: TabKey; label: string }[] = [
@@ -62,6 +63,7 @@ export const TAB_ORDER: { key: TabKey; label: string }[] = [
{ key: 'links', label: 'Links' },
{ key: 'broken-links', label: 'Broken Links' },
{ key: 'serp', label: 'SERP' },
{ key: 'pagespeed', label: 'PageSpeed' },
{ key: 'visualization', label: 'Visualization' },
];
@@ -96,6 +96,21 @@ function cellValue(row: BrokenLinkRow, colIdx: number): string {
}
}
/** True when the row set shifted enough to invalidate index-keyed
* cell selection (a live-crawl poll can insert / reorder rows). */
function brokenRowsChanged(
prev: BrokenLinkRow[],
next: BrokenLinkRow[],
): boolean {
if (prev.length !== next.length) return true;
for (let i = 0; i < next.length; i++) {
const p = prev[i];
const n = next[i];
if (!p || !n || p.fromUrl !== n.fromUrl || p.toUrl !== n.toUrl) return true;
}
return false;
}
export function BrokenLinksTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -104,6 +119,10 @@ export function BrokenLinksTab() {
const progress = useAppStore((s) => s.progress);
const [rows, setRows] = useState<BrokenLinkRow[]>([]);
const [total, setTotal] = useState(0);
// Distinct broken target URLs — stable across crawls of different
// sizes, unlike the row `total` which scales with how many pages the
// crawl reached.
const [uniqueTargets, setUniqueTargets] = useState(0);
const [search, setSearch] = useState('');
const [exporting, setExporting] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -148,8 +167,22 @@ export function BrokenLinksTab() {
internal,
});
if (cancelled) return;
// A live-crawl poll can insert / reorder broken-link rows. The
// cell selection is index-keyed ("rowIdx:colIdx"), so once the row
// set shifts the old keys point at different rows — drop the
// selection rather than highlight / copy the wrong cells.
if (
selectedRef.current.size > 0 &&
brokenRowsChanged(rowsRef.current, res.rows)
) {
setSelected(new Set());
setMenu(null);
anchorRef.current = null;
dragRef.current = null;
}
setRows(res.rows);
setTotal(res.total);
setUniqueTargets(res.uniqueTargets);
};
void load();
const cadence = progress?.running ? POLL_MS_RUNNING : POLL_MS_IDLE;
@@ -310,6 +343,8 @@ export function BrokenLinksTab() {
)}
<div className="ml-auto text-[11px] text-surface-500">
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('brokenTab.linksUnit', { defaultValue: 'broken links' })}
<span className="mx-1 text-surface-700">·</span>
<span className="font-mono text-surface-200">{uniqueTargets.toLocaleString()}</span> {t('brokenTab.uniqueUnit', { defaultValue: 'unique URLs' })}
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: rows.length.toLocaleString() })})</span>
</div>
<button
@@ -454,7 +489,13 @@ export function BrokenLinksTab() {
</span>
)}
<span>
Total:{' '}
{t('brokenTab.uniqueLabel', { defaultValue: 'Unique broken URLs' })}:{' '}
<span className="font-mono tabular-nums text-surface-200">
{uniqueTargets.toLocaleString()}
</span>
</span>
<span>
{t('brokenTab.totalLabel', { defaultValue: 'Total rows' })}:{' '}
<span className="font-mono tabular-nums text-surface-200">
{total.toLocaleString()}
</span>
@@ -0,0 +1,584 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTranslation } from 'react-i18next';
import { Smartphone, Monitor, Loader2 } from 'lucide-react';
import type {
PagespeedMetrics,
PagespeedProgress,
PagespeedRow,
PagespeedRunStrategy,
} from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
const TOOLBAR_HEIGHT = 36;
const HEADER_HEIGHT = 46;
const STATUS_BAR_HEIGHT = 22;
const ROW_HEIGHT = 30;
const PAGE_SIZE = 2000;
const POLL_MS_RUNNING = 5000;
/** Above this many audits, warn before kicking off the run. */
const CONFIRM_THRESHOLD = 30;
type FilterMode = 'all' | 'tested' | 'untested';
/** Lighthouse score bands → cell colour. */
function scoreClass(v: number | null): string {
if (v === null) return 'text-surface-600';
if (v >= 90) return 'text-emerald-400';
if (v >= 50) return 'text-amber-400';
return 'text-red-400';
}
/** Core Web Vitals thresholds → cell colour. */
function cwvClass(
v: number | null,
good: number,
poor: number,
): string {
if (v === null) return 'text-surface-600';
if (v <= good) return 'text-emerald-400';
if (v <= poor) return 'text-amber-400';
return 'text-red-400';
}
/** Format a millisecond timing compactly (e.g. `1.8s`, `420ms`). */
function fmtMs(v: number | null): string {
if (v === null) return '—';
if (v >= 1000) return `${(v / 1000).toFixed(1)}s`;
return `${Math.round(v)}ms`;
}
function fmtCls(v: number | null): string {
return v === null ? '—' : v.toFixed(3);
}
function fmtScore(v: number | null): string {
return v === null ? '—' : String(v);
}
/** Short relative time for the "Last run" column. */
function relativeTime(iso: string): string {
if (!iso) return '—';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '—';
const diff = Date.now() - then;
const min = Math.floor(diff / 60_000);
if (min < 1) return 'just now';
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString();
}
export function PageSpeedTab() {
const { t } = useTranslation();
const dataVersion = useAppStore((s) => s.dataVersion);
const crawlProgress = useAppStore((s) => s.progress);
const [rows, setRows] = useState<PagespeedRow[]>([]);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterMode>('all');
const [strategy, setStrategy] = useState<PagespeedRunStrategy>('mobile');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [running, setRunning] = useState(false);
const [psProgress, setPsProgress] = useState<PagespeedProgress | null>(null);
const [hasApiKey, setHasApiKey] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const reload = useCallback(async () => {
const res = await window.freecrawl.pagespeedQuery({
limit: PAGE_SIZE,
offset: 0,
search: search || undefined,
filter,
});
setRows(res.rows);
setTotal(res.total);
}, [search, filter]);
// Candidate list — reloads on filter/search/crawl-data changes, and
// polls while a crawl is discovering new pages.
useEffect(() => {
let cancelled = false;
void reload();
if (!crawlProgress?.running) return;
const id = setInterval(() => {
if (!cancelled) void reload();
}, POLL_MS_RUNNING);
return () => {
cancelled = true;
clearInterval(id);
};
}, [reload, dataVersion, crawlProgress?.running]);
// Whether the PageSpeed integration has an API key — surfaces the
// keyless-mode warning banner when it doesn't.
useEffect(() => {
void window.freecrawl.integrationsGetAll().then((state) => {
setHasApiKey(state['pagespeed']?.fields?.['apiKey']?.set ?? false);
});
}, []);
// Live progress of an in-flight run. Refresh the table whenever an
// audit completes (events without a `currentUrl`) so partial results
// appear as the run proceeds.
useEffect(() => {
const off = window.freecrawl.onPagespeedProgress((p) => {
setPsProgress(p.running ? p : null);
if (!p.currentUrl) void reload();
});
return off;
}, [reload]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 16,
getItemKey: (index) => rows[index]?.url ?? `idx-${index}`,
});
const allLoadedSelected =
rows.length > 0 && rows.every((r) => selected.has(r.url));
const toggleRow = useCallback((url: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(url)) next.delete(url);
else next.add(url);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) => {
if (rows.length > 0 && rows.every((r) => prev.has(r.url))) {
return new Set();
}
return new Set(rows.map((r) => r.url));
});
}, [rows]);
const selectUntested = useCallback(() => {
setSelected(
new Set(
rows.filter((r) => !r.mobile && !r.desktop).map((r) => r.url),
),
);
}, [rows]);
const strategyCount = strategy === 'both' ? 2 : 1;
const auditCount = selected.size * strategyCount;
const runAudit = useCallback(async () => {
const urls = [...selected];
if (urls.length === 0 || running) return;
if (
auditCount > CONFIRM_THRESHOLD &&
!window.confirm(
t('pagespeedTab.confirmRun', {
defaultValue:
'This will request {{count}} PageSpeed audits. Each audit takes ~10–30s and{{keyless}} may take a while. Continue?',
count: auditCount,
keyless: hasApiKey
? ''
: t('pagespeedTab.confirmKeyless', {
defaultValue: ' — without an API key (low rate limit) —',
}),
}),
)
) {
return;
}
setRunning(true);
setPsProgress({ done: 0, total: auditCount, currentUrl: null, running: true });
try {
await window.freecrawl.pagespeedRun({ urls, strategy });
} finally {
setRunning(false);
setPsProgress(null);
void reload();
}
}, [selected, running, auditCount, strategy, hasApiKey, t, reload]);
const cancelRun = useCallback(() => {
void window.freecrawl.pagespeedCancel();
}, []);
const testedCount = useMemo(
() => rows.filter((r) => r.mobile || r.desktop).length,
[rows],
);
return (
<div className="flex h-full w-full flex-col bg-surface-950">
{/* Toolbar */}
<div
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
style={{ height: TOOLBAR_HEIGHT }}
>
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
{t('pagespeedTab.title', { defaultValue: 'PageSpeed Insights' })}
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pagespeedTab.filterPlaceholder', {
defaultValue: 'Filter by URL…',
})}
className="h-6 w-56 rounded border border-surface-700 bg-surface-950 px-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
/>
<select
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={filter}
onChange={(e) => setFilter(e.target.value as FilterMode)}
>
<option value="all">
{t('pagespeedTab.filterAll', { defaultValue: 'All pages' })}
</option>
<option value="tested">
{t('pagespeedTab.filterTested', { defaultValue: 'Tested only' })}
</option>
<option value="untested">
{t('pagespeedTab.filterUntested', { defaultValue: 'Untested only' })}
</option>
</select>
<label className="flex items-center gap-1 text-[11px] text-surface-400">
{t('pagespeedTab.strategy', { defaultValue: 'Device:' })}
<select
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={strategy}
onChange={(e) =>
setStrategy(e.target.value as PagespeedRunStrategy)
}
disabled={running}
>
<option value="mobile">
{t('pagespeedTab.deviceMobile', { defaultValue: 'Mobile' })}
</option>
<option value="desktop">
{t('pagespeedTab.deviceDesktop', { defaultValue: 'Desktop' })}
</option>
<option value="both">
{t('pagespeedTab.deviceBoth', { defaultValue: 'Mobile + Desktop' })}
</option>
</select>
</label>
<button
type="button"
onClick={selectUntested}
disabled={running || rows.length === 0}
className="h-6 rounded border border-surface-700 bg-surface-800 px-2 text-[11px] text-surface-200 hover:bg-surface-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{t('pagespeedTab.selectUntested', { defaultValue: 'Select untested' })}
</button>
<div className="ml-auto flex items-center gap-2">
{running && psProgress ? (
<>
<span className="flex items-center gap-1.5 text-[11px] text-surface-300">
<Loader2 size={12} className="animate-spin text-blue-400" />
{t('pagespeedTab.runProgress', {
defaultValue: 'Auditing {{done}} / {{total}}',
done: psProgress.done,
total: psProgress.total,
})}
</span>
<button
type="button"
onClick={cancelRun}
className="h-6 rounded border border-red-700 bg-red-900/40 px-2.5 text-[11px] font-medium text-red-200 hover:bg-red-900/70"
>
{t('pagespeedTab.cancel', { defaultValue: 'Cancel' })}
</button>
</>
) : (
<button
type="button"
onClick={() => void runAudit()}
disabled={selected.size === 0 || running}
className="h-6 rounded bg-blue-600 px-3 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
>
{t('pagespeedTab.run', {
defaultValue: 'Run PageSpeed ({{count}})',
count: auditCount,
})}
</button>
)}
</div>
</div>
{/* Keyless-mode banner */}
{!hasApiKey && (
<div className="border-b border-amber-700/40 bg-amber-900/20 px-3 py-1 text-[10px] text-amber-200">
{t('pagespeedTab.noKeyBanner', {
defaultValue:
'No PageSpeed API key — audits run keyless at a low rate limit. Add a free key in Settings → Integrations for faster runs.',
})}
</div>
)}
{/* Column header (two-tier: device groups + metric labels) */}
<div
className="flex shrink-0 select-none items-stretch border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
style={{ height: HEADER_HEIGHT }}
>
<div className="flex w-[30px] items-center justify-center border-r border-surface-800">
<input
type="checkbox"
checked={allLoadedSelected}
ref={(el) => {
if (el) {
el.indeterminate =
selected.size > 0 && !allLoadedSelected;
}
}}
onChange={toggleAll}
disabled={rows.length === 0}
className="h-3 w-3 accent-blue-500"
/>
</div>
<div className="flex flex-1 flex-col justify-end border-r border-surface-800 px-2 pb-1">
{t('pagespeedTab.colUrl', { defaultValue: 'URL' })}
</div>
<div className="flex w-[54px] flex-col justify-end border-r border-surface-800 px-1 pb-1 text-center">
{t('pagespeedTab.colStatus', { defaultValue: 'Status' })}
</div>
{/* Mobile group */}
<div className="flex w-[162px] flex-col border-r border-surface-800">
<div className="flex h-[22px] items-center justify-center gap-1 border-b border-surface-800 bg-surface-900/80 text-surface-300">
<Smartphone size={11} />
{t('pagespeedTab.groupMobile', { defaultValue: 'Mobile' })}
</div>
<div className="flex flex-1 items-end pb-1 text-center">
<div className="w-[50px]">{t('pagespeedTab.colPerf', { defaultValue: 'Perf' })}</div>
<div className="w-[60px]">{t('pagespeedTab.colLcp', { defaultValue: 'LCP' })}</div>
<div className="w-[52px]">{t('pagespeedTab.colCls', { defaultValue: 'CLS' })}</div>
</div>
</div>
{/* Desktop group */}
<div className="flex w-[162px] flex-col border-r border-surface-800">
<div className="flex h-[22px] items-center justify-center gap-1 border-b border-surface-800 bg-surface-900/80 text-surface-300">
<Monitor size={11} />
{t('pagespeedTab.groupDesktop', { defaultValue: 'Desktop' })}
</div>
<div className="flex flex-1 items-end pb-1 text-center">
<div className="w-[50px]">{t('pagespeedTab.colPerf', { defaultValue: 'Perf' })}</div>
<div className="w-[60px]">{t('pagespeedTab.colLcp', { defaultValue: 'LCP' })}</div>
<div className="w-[52px]">{t('pagespeedTab.colCls', { defaultValue: 'CLS' })}</div>
</div>
</div>
<div className="flex w-[96px] flex-col justify-end px-2 pb-1 text-right">
{t('pagespeedTab.colLastRun', { defaultValue: 'Last run' })}
</div>
</div>
{/* Body */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{rows.length === 0 ? (
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
{total === 0
? t('pagespeedTab.emptyNoData', {
defaultValue:
'No internal HTML pages crawled yet — run a crawl, then select pages to audit.',
})
: t('pagespeedTab.emptyNoMatch', {
defaultValue: 'No pages match the current filter.',
})}
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vRow) => {
const row = rows[vRow.index];
if (!row) return null;
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${vRow.start}px)`,
}}
>
<PageSpeedRow
row={row}
selected={selected.has(row.url)}
onToggle={() => toggleRow(row.url)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Status bar */}
<div
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
style={{ height: STATUS_BAR_HEIGHT }}
>
<span>
{t('pagespeedTab.summary', {
defaultValue: '{{shown}} pages · {{tested}} tested · {{selected}} selected',
shown: rows.length.toLocaleString(),
tested: testedCount.toLocaleString(),
selected: selected.size.toLocaleString(),
})}
{total > rows.length &&
' ' +
t('pagespeedTab.firstN', {
defaultValue: '(first {{n}})',
n: PAGE_SIZE.toLocaleString(),
})}
</span>
{running && psProgress?.currentUrl && (
<span className="truncate text-surface-500">
{t('pagespeedTab.auditing', {
defaultValue: 'Auditing: {{url}}',
url: psProgress.currentUrl,
})}
</span>
)}
</div>
</div>
);
}
function MetricCell({
text,
cls,
width,
title,
}: {
text: string;
cls: string;
width: number;
title?: string;
}) {
return (
<div
className={`shrink-0 text-center tabular-nums ${cls}`}
style={{ width }}
title={title}
>
{text}
</div>
);
}
function StrategyCells({ m }: { m: PagespeedMetrics | null }) {
if (!m) {
return (
<>
<MetricCell text="—" cls="text-surface-700" width={50} />
<MetricCell text="—" cls="text-surface-700" width={60} />
<MetricCell text="—" cls="text-surface-700" width={52} />
</>
);
}
if (m.status === 'error') {
return (
<div
className="flex w-[162px] shrink-0 items-center justify-center text-red-400"
title={m.error ?? 'Audit failed'}
>
ERR
</div>
);
}
return (
<>
<MetricCell
text={fmtScore(m.performance)}
cls={scoreClass(m.performance)}
width={50}
/>
<MetricCell
text={fmtMs(m.lcp)}
cls={cwvClass(m.lcp, 2500, 4000)}
width={60}
/>
<MetricCell
text={fmtCls(m.cls)}
cls={cwvClass(m.cls, 0.1, 0.25)}
width={52}
/>
</>
);
}
function PageSpeedRow({
row,
selected,
onToggle,
}: {
row: PagespeedRow;
selected: boolean;
onToggle: () => void;
}) {
const lastRun = useMemo(() => {
const candidates = [row.mobile?.fetchedAt, row.desktop?.fetchedAt].filter(
(v): v is string => !!v,
);
if (candidates.length === 0) return '';
return candidates.sort().reverse()[0] ?? '';
}, [row.mobile, row.desktop]);
const status = row.statusCode;
const statusCls =
status === null
? 'text-surface-600'
: status >= 200 && status < 300
? 'text-emerald-400'
: status >= 300 && status < 400
? 'text-blue-400'
: 'text-red-400';
return (
<div
onClick={onToggle}
className={`flex h-full cursor-pointer items-center text-[11px] ${
selected
? 'bg-blue-900/30'
: 'odd:bg-surface-900/20 hover:bg-surface-800/40'
}`}
>
<div className="flex w-[30px] items-center justify-center">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="h-3 w-3 accent-blue-500"
/>
</div>
<div
className="flex-1 truncate px-2 text-surface-200"
title={row.url}
>
{row.url}
</div>
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
{status ?? '—'}
</div>
<StrategyCells m={row.mobile} />
<StrategyCells m={row.desktop} />
<div className="w-[96px] shrink-0 truncate px-2 text-right text-surface-500">
{lastRun ? relativeTime(lastRun) : '—'}
</div>
</div>
);
}
@@ -219,6 +219,16 @@ export function VisualizationTab() {
const [hover, setHover] = useState<string | null>(null);
const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
const [tuning, setTuning] = useState<VisTuning>(() => loadTuning());
// Debounced copy that drives the expensive cytoscape rebuild. A tuning
// slider drag fires onChange on every step; rebuilding the graph and
// re-running the cose layout on each step thrashes the view. The
// slider itself stays instant (bound to `tuning`) — the graph settles
// ~250 ms after the last change.
const [debouncedTuning, setDebouncedTuning] = useState<VisTuning>(tuning);
useEffect(() => {
const id = setTimeout(() => setDebouncedTuning(tuning), 250);
return () => clearTimeout(id);
}, [tuning]);
const [tunerOpen, setTunerOpen] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
@@ -249,17 +259,24 @@ export function VisualizationTab() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeLimit, dataVersion]);
// Monotonic token so a slow graphSnapshot (e.g. a large `nodeLimit`)
// can't resolve after a newer request and render a graph that no
// longer matches the controls. Covers both the effect and the
// Reload / Tuning buttons.
const loadTokenRef = useRef(0);
async function loadGraph() {
const token = ++loadTokenRef.current;
setLoading(true);
try {
const [g, a] = await Promise.all([
window.freecrawl.graphSnapshot({ nodeLimit }),
window.freecrawl.topAnchorTexts(120),
]);
if (token !== loadTokenRef.current) return;
setGraph(g);
setAnchors(a);
} finally {
setLoading(false);
if (token === loadTokenRef.current) setLoading(false);
}
}
@@ -287,7 +304,7 @@ export function VisualizationTab() {
statusCode: n.statusCode ?? '',
inlinks: n.inlinks,
color: colorFn(n),
size: nodeSize(n.inlinks, tuning.nodeSizeScale),
size: nodeSize(n.inlinks, debouncedTuning.nodeSizeScale),
isTop: topIds.has(String(n.id)) ? 1 : 0,
},
})),
@@ -318,15 +335,15 @@ export function VisualizationTab() {
padding: 30,
};
if (layout === 'cose') {
layoutCfg.nodeRepulsion = () => 1_000_000 * tuning.repulsionScale;
layoutCfg.idealEdgeLength = () => 400 * tuning.edgeLengthScale;
layoutCfg.nodeRepulsion = () => 1_000_000 * debouncedTuning.repulsionScale;
layoutCfg.idealEdgeLength = () => 400 * debouncedTuning.edgeLengthScale;
layoutCfg.edgeElasticity = () => 20;
layoutCfg.gravity = 0;
layoutCfg.gravityRange = 5.0;
layoutCfg.gravityCompound = 0;
layoutCfg.numIter = 6000;
layoutCfg.nodeOverlap = 200;
layoutCfg.componentSpacing = 400 * tuning.componentSpacingScale;
layoutCfg.componentSpacing = 400 * debouncedTuning.componentSpacingScale;
layoutCfg.nestingFactor = 1.2;
layoutCfg.initialTemp = 2000;
layoutCfg.coolingFactor = 0.995;
@@ -412,7 +429,7 @@ export function VisualizationTab() {
'target-arrow-color': '#525252',
'target-arrow-shape': 'triangle',
'arrow-scale': 0.6,
opacity: tuning.edgeOpacity,
opacity: debouncedTuning.edgeOpacity,
},
},
{
@@ -534,7 +551,7 @@ export function VisualizationTab() {
cy.destroy();
cyRef.current = null;
};
}, [graph, layout, colorMode, labelMode, tuning]);
}, [graph, layout, colorMode, labelMode, debouncedTuning]);
return (
<div className="flex h-full w-full flex-col bg-surface-950">
@@ -605,6 +605,7 @@ export const COLUMN_SPECS: Record<TabKey, ColumnSpec[]> = {
images: [],
'broken-links': [],
serp: [],
pagespeed: [],
visualization: [],
canonicals: [
COL.url,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"type": "module",
"bin": {
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.5.5",
"version": "0.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.5.5",
"version": "0.5.6",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -25,7 +25,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.5.5",
"version": "0.5.6",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -42,7 +42,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.5.5",
"version": "0.5.6",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -86,7 +86,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.5.5",
"version": "0.5.6",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -8826,7 +8826,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.5.5",
"version": "0.5.6",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8852,7 +8852,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.5.5",
"version": "0.5.6",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8864,7 +8864,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.5.5",
"version": "0.5.6",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"type": "module",
"main": "./dist/index.js",
+12
View File
@@ -1246,6 +1246,18 @@ export class Crawler extends EventEmitter {
private enqueueExternal(url: string): void {
if (this.stopped) return;
if (this.externalSeen.has(url)) return;
// Bound external discovery the same way internal crawling is bounded.
// Without these caps `externalSeen` and the probe queue grow without
// limit on sites with a huge external-link graph (directories,
// aggregators) — the internal `enqueue` has the same guards.
if (this.externalSeen.size >= this.config.maxUrls) return;
if (
this.config.maxQueueSize > 0 &&
this.externalQueue.size + this.externalQueue.pending >=
this.config.maxQueueSize
) {
return;
}
if (!this.passesUrlFilter(url)) return;
this.externalSeen.add(url);
this.externalQueue
+23 -4
View File
@@ -1314,7 +1314,23 @@ function detectAnalyticsTrackers(
rawHtml: string,
): AnalyticsTracker[] {
const found = new Map<string, AnalyticsTracker>();
const add = (name: string, id: string | null = null): void => {
const add = (
name: string,
id: string | null = null,
allowMultiple = false,
): void => {
// `allowMultiple` trackers (e.g. GA4) keep a separate entry per
// distinct id — two GA4 property tags on one page is a real
// misconfiguration the "Multiple GA4" issue filter needs to see.
// Without this the name-keyed map collapses them into one entry and
// the filter can never fire.
if (allowMultiple && id !== null) {
const key = `${name}|${id}`;
if (!found.has(key)) found.set(key, { name, id });
// Drop a prior id-less placeholder for this tracker, if any.
found.delete(name);
return;
}
const existing = found.get(name);
// Keep the more specific (id-bearing) record if we see the same tracker twice.
if (!existing || (existing.id === null && id !== null)) {
@@ -1348,10 +1364,13 @@ function detectAnalyticsTrackers(
// them as a separate tracker since Google deprecated UA in July 2023.
for (const src of scriptSrcs) {
const m = src.match(/gtag\/js\?id=(G-[A-Z0-9]+)/i);
if (m && m[1]) add('Google Analytics 4', m[1].toUpperCase());
if (m && m[1]) add('Google Analytics 4', m[1].toUpperCase(), true);
}
// Match every distinct inline `G-…` id, not just the first — a page
// wiring up two GA4 properties typically lists both inline.
for (const m of inlineBlob.matchAll(/['"](G-[A-Z0-9]{6,})['"]/g)) {
if (m[1]) add('Google Analytics 4', m[1].toUpperCase(), true);
}
const ga4Inline = inlineBlob.match(/['"](G-[A-Z0-9]{6,})['"]/);
if (ga4Inline && ga4Inline[1]) add('Google Analytics 4', ga4Inline[1].toUpperCase());
const uaInline = inlineBlob.match(/UA-\d{4,10}-\d{1,4}/);
if (uaInline) add('Google Analytics (UA)', uaInline[0]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"type": "module",
"main": "./dist/index.js",
+50
View File
@@ -1242,6 +1242,56 @@ const MIGRATIONS: Migration[] = [
);
},
},
{
version: 55,
name: 'add_pagespeed_results_table',
// V1 Faz 7 — Google PageSpeed Insights integration. Audit results
// live in their own table rather than as `urls` columns: PSI is run
// on-demand against a user-selected subset (never the whole crawl),
// each URL can have up to two rows (mobile + desktop), and keeping
// it separate avoids widening the already-very-wide `urls` table.
//
// url — the audited page URL (joins back to `urls.url`).
// strategy — 'mobile' | 'desktop' form factor.
// performance — Lighthouse Performance score 0–100 (null if N/A).
// lcp/fcp/tbt/speed_index — lab timing metrics in milliseconds.
// cls — Cumulative Layout Shift (unitless).
// status — 'ok' when the audit completed, 'error' otherwise.
// error — failure reason when status='error'.
// fetched_at — ISO timestamp of the audit.
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS pagespeed_results (
url TEXT NOT NULL,
strategy TEXT NOT NULL,
performance INTEGER,
lcp REAL,
cls REAL,
fcp REAL,
tbt REAL,
speed_index REAL,
status TEXT NOT NULL,
error TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (url, strategy)
);
`);
},
},
{
version: 56,
name: 'add_image_usages_from_index',
// `image_usages` had an index on `image_id` but none on
// `from_url_id`. The per-URL write path now deletes a page's prior
// `image_usages` rows before re-inserting (so a re-crawl doesn't
// duplicate them) — without this index that DELETE would full-scan
// the table on every URL write, turning a re-crawl into O(n²).
up: (db) => {
db.exec(
'CREATE INDEX IF NOT EXISTS idx_image_usages_from ON image_usages(from_url_id);',
);
},
},
];
export function runMigrations(db: DatabaseSync): void {
+257 -30
View File
@@ -15,6 +15,9 @@ import type {
InlinkRow,
OutlinkRow,
OverviewCounts,
PagespeedMetrics,
PagespeedRow,
PagespeedStrategy,
UrlCategory,
UrlDetail,
} from '@freecrawl/shared-types';
@@ -671,7 +674,8 @@ export class ProjectDb {
DELETE FROM project_meta;
DELETE FROM urls_issues;
DELETE FROM crawl_queue;
DELETE FROM host_certs;`,
DELETE FROM host_certs;
DELETE FROM pagespeed_results;`,
);
});
// Reclaim freed pages back to the OS so the project file size
@@ -792,6 +796,27 @@ export class ProjectDb {
WHERE id NOT IN (SELECT DISTINCT image_id FROM image_usages)`,
);
// PageSpeed audit rows are keyed by URL string (no FK to `urls`),
// so wipe them by the same host match — otherwise a GDPR delete
// leaves stale PSI data that would re-attach if the URL is
// re-crawled later.
this.db
.prepare(
`DELETE FROM pagespeed_results
WHERE LOWER(
SUBSTR(
url,
INSTR(url, '://') + 3,
CASE
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
ELSE LENGTH(url)
END
)
) = ?`,
)
.run(target);
return { urlsDeleted, linksDeleted };
});
}
@@ -901,6 +926,19 @@ export class ProjectDb {
}
}
if (urlId) {
// Re-crawl safety. A URL can be fetched more than once — manual
// "Re-Spider", crash-recovery resume, or re-running a crawl
// without Clear. `insertLinks` / `insertImages` are plain
// INSERTs, so without clearing the page's previous rows first
// every re-fetch would DUPLICATE its link + image-usage rows,
// inflating broken-link counts and every other link/image
// metric. Delete unconditionally (a no-op on a first crawl, both
// DELETEs are index-backed) so the page's link + image set
// always reflects the latest fetch.
this.db.prepare('DELETE FROM links WHERE from_url_id = ?').run(urlId);
this.db
.prepare('DELETE FROM image_usages WHERE from_url_id = ?')
.run(urlId);
if (payload.links.length > 0) {
this.insertLinks(urlId, payload.links, payload.fromDepth);
}
@@ -934,7 +972,21 @@ export class ProjectDb {
* depth at which each pending URL was discovered.
*/
deleteUrl(id: number): void {
this.db.prepare('DELETE FROM urls WHERE id = ?').run(id);
// `urls_issues` and `pagespeed_results` have no FK to `urls`, so the
// `ON DELETE CASCADE` that cleans links/headers/images doesn't reach
// them — wipe them explicitly or a deleted URL leaves orphan rows
// that inflate `getIssueCounts()` and re-attach stale audit data on
// re-crawl. The `pagespeed_results` delete must run before the parent
// `urls` row vanishes (it resolves the URL string from `urls`).
this.runInTransaction(() => {
this.db
.prepare(
'DELETE FROM pagespeed_results WHERE url = (SELECT url FROM urls WHERE id = ?)',
)
.run(id);
this.db.prepare('DELETE FROM urls_issues WHERE url_id = ?').run(id);
this.db.prepare('DELETE FROM urls WHERE id = ?').run(id);
});
}
markUrlForRecrawl(id: number): void {
@@ -952,33 +1004,73 @@ export class ProjectDb {
markUrlsForRecrawl(ids: number[]): void {
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
this.db
.prepare(
`UPDATE urls SET
status_code = NULL,
status_text = NULL,
indexability = 'indexable',
indexability_reason = NULL
WHERE id IN (${placeholders}) AND is_external = 0`,
)
.run(...ids);
// Chunk the id list — a multi-row table selection can exceed SQLite's
// ~32 K bound-parameter limit, which would throw `too many SQL
// variables` and fail the whole operation.
const CHUNK = 500;
for (let i = 0; i < ids.length; i += CHUNK) {
const slice = ids.slice(i, i + CHUNK);
const placeholders = slice.map(() => '?').join(',');
this.db
.prepare(
`UPDATE urls SET
status_code = NULL,
status_text = NULL,
indexability = 'indexable',
indexability_reason = NULL
WHERE id IN (${placeholders}) AND is_external = 0`,
)
.run(...slice);
}
}
deleteUrls(ids: number[]): void {
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
this.db.prepare(`DELETE FROM urls WHERE id IN (${placeholders})`).run(...ids);
// Chunk to stay under SQLite's bound-parameter limit; wrap in one
// transaction so the urls + urls_issues + pagespeed_results deletes
// are atomic. `urls_issues` / `pagespeed_results` have no FK to
// `urls` (see `deleteUrl`), so they're wiped explicitly.
const CHUNK = 500;
this.runInTransaction(() => {
for (let i = 0; i < ids.length; i += CHUNK) {
const slice = ids.slice(i, i + CHUNK);
const placeholders = slice.map(() => '?').join(',');
this.db
.prepare(
`DELETE FROM pagespeed_results
WHERE url IN (SELECT url FROM urls WHERE id IN (${placeholders}))`,
)
.run(...slice);
this.db
.prepare(`DELETE FROM urls_issues WHERE url_id IN (${placeholders})`)
.run(...slice);
this.db
.prepare(`DELETE FROM urls WHERE id IN (${placeholders})`)
.run(...slice);
}
});
}
/** Look up the URL strings for a batch of ids (preserves DB order). */
/** Look up the URL strings for a batch of ids, in ascending id order. */
getUrlsByIds(ids: number[]): string[] {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT url FROM urls WHERE id IN (${placeholders})`)
.all(...ids) as unknown as { url: string }[];
return rows.map((r) => r.url);
// Sort then chunk: each chunk query is `ORDER BY id`, so the
// concatenated result stays globally id-ordered while staying under
// SQLite's bound-parameter limit.
const CHUNK = 500;
const sorted = [...ids].sort((a, b) => a - b);
const out: string[] = [];
for (let i = 0; i < sorted.length; i += CHUNK) {
const slice = sorted.slice(i, i + CHUNK);
const placeholders = slice.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT url FROM urls WHERE id IN (${placeholders}) ORDER BY id`,
)
.all(...slice) as unknown as { url: string }[];
for (const r of rows) out.push(r.url);
}
return out;
}
getUrlRowById(id: number): { url: string; depth: number; isExternal: number } | null {
@@ -2444,7 +2536,7 @@ export class ProjectDb {
offset: number;
internal?: 'all' | 'internal' | 'external';
search?: string;
}): { rows: BrokenLinkRow[]; total: number } {
}): { rows: BrokenLinkRow[]; total: number; uniqueTargets: number } {
const where: string[] = ['t.status_code >= 400 AND t.status_code < 600'];
const args: (string | number)[] = [];
const internal = params.internal ?? 'all';
@@ -2458,13 +2550,13 @@ export class ProjectDb {
const whereSql = `WHERE ${where.join(' AND ')}`;
const totalRow = this.db
.prepare(
`SELECT COUNT(*) AS c
`SELECT COUNT(*) AS c, COUNT(DISTINCT l.to_url) AS u
FROM links l
JOIN urls f ON l.from_url_id = f.id
JOIN urls t ON l.to_url = t.url
${whereSql}`,
)
.get(...args) as { c: number };
.get(...args) as { c: number; u: number };
const rowsDb = this.db
.prepare(
`SELECT f.url AS from_url, f.status_code AS from_status,
@@ -2488,6 +2580,7 @@ export class ProjectDb {
}[];
return {
total: totalRow.c,
uniqueTargets: totalRow.u,
rows: rowsDb.map((r) => ({
fromUrl: r.from_url,
fromStatusCode: r.from_status,
@@ -2500,6 +2593,132 @@ export class ProjectDb {
};
}
/**
* Faz 7 — store one PageSpeed Insights audit result. Keyed by
* (url, strategy); re-running an audit overwrites the previous row.
*/
upsertPagespeedResult(
url: string,
strategy: PagespeedStrategy,
m: PagespeedMetrics,
): void {
this.db
.prepare(
`INSERT INTO pagespeed_results
(url, strategy, performance, lcp, cls, fcp, tbt, speed_index,
status, error, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url, strategy) DO UPDATE SET
performance = excluded.performance,
lcp = excluded.lcp,
cls = excluded.cls,
fcp = excluded.fcp,
tbt = excluded.tbt,
speed_index = excluded.speed_index,
status = excluded.status,
error = excluded.error,
fetched_at = excluded.fetched_at`,
)
.run(
url,
strategy,
m.performance,
m.lcp,
m.cls,
m.fcp,
m.tbt,
m.speedIndex,
m.status,
m.error,
m.fetchedAt,
);
}
/**
* Faz 7 — candidate query for the PageSpeed tab. Returns crawled
* internal HTML 2xx pages (the universe PSI can be run against)
* left-joined with any stored mobile + desktop audit results.
*/
queryPagespeed(params: {
limit: number;
offset: number;
search?: string;
filter?: 'all' | 'tested' | 'untested';
}): { rows: PagespeedRow[]; total: number } {
const where: string[] = [
"u.is_external = 0 AND u.content_kind = 'html'",
'u.status_code >= 200 AND u.status_code < 300',
];
const args: (string | number)[] = [];
if (params.search) {
where.push('u.url LIKE ?');
args.push(`%${params.search}%`);
}
const filter = params.filter ?? 'all';
if (filter === 'tested') {
where.push('(pm.url IS NOT NULL OR pd.url IS NOT NULL)');
} else if (filter === 'untested') {
where.push('pm.url IS NULL AND pd.url IS NULL');
}
const whereSql = `WHERE ${where.join(' AND ')}`;
const joinSql = `
FROM urls u
LEFT JOIN pagespeed_results pm ON pm.url = u.url AND pm.strategy = 'mobile'
LEFT JOIN pagespeed_results pd ON pd.url = u.url AND pd.strategy = 'desktop'`;
const totalRow = this.db
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
.get(...args) as { c: number };
const rowsDb = this.db
.prepare(
`SELECT u.url AS url, u.status_code AS status_code,
pm.performance AS m_perf, pm.lcp AS m_lcp, pm.cls AS m_cls,
pm.fcp AS m_fcp, pm.tbt AS m_tbt, pm.speed_index AS m_si,
pm.status AS m_status, pm.error AS m_error, pm.fetched_at AS m_fetched,
pd.performance AS d_perf, pd.lcp AS d_lcp, pd.cls AS d_cls,
pd.fcp AS d_fcp, pd.tbt AS d_tbt, pd.speed_index AS d_si,
pd.status AS d_status, pd.error AS d_error, pd.fetched_at AS d_fetched
${joinSql}
${whereSql}
ORDER BY u.inlinks DESC, u.url ASC
LIMIT ? OFFSET ?`,
)
.all(...args, params.limit, params.offset) as unknown as Record<
string,
unknown
>[];
const metrics = (
r: Record<string, unknown>,
p: string,
): PagespeedMetrics | null => {
const status = r[`${p}_status`];
if (typeof status !== 'string') return null;
return {
performance: (r[`${p}_perf`] as number | null) ?? null,
lcp: (r[`${p}_lcp`] as number | null) ?? null,
cls: (r[`${p}_cls`] as number | null) ?? null,
fcp: (r[`${p}_fcp`] as number | null) ?? null,
tbt: (r[`${p}_tbt`] as number | null) ?? null,
speedIndex: (r[`${p}_si`] as number | null) ?? null,
status: status === 'ok' ? 'ok' : 'error',
error: (r[`${p}_error`] as string | null) ?? null,
fetchedAt: (r[`${p}_fetched`] as string | null) ?? '',
};
};
return {
total: totalRow.c,
rows: rowsDb.map((r) => ({
url: r['url'] as string,
statusCode: (r['status_code'] as number | null) ?? null,
mobile: metrics(r, 'm'),
desktop: metrics(r, 'd'),
})),
};
}
queryImages(params: {
limit: number;
offset: number;
@@ -3656,12 +3875,20 @@ export class ProjectDb {
*iterateUrlsByIds(ids: number[]): IterableIterator<CrawlUrlRow> {
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM urls WHERE id IN (${placeholders}) ORDER BY id`)
.all(...ids) as unknown as UrlRowDb[];
for (const row of rows) {
yield this.rowFromDb(row);
// Sort then chunk: keeps the yielded rows globally id-ordered while
// staying under SQLite's ~32 K bound-parameter limit (a large table
// selection can otherwise throw `too many SQL variables`).
const CHUNK = 500;
const sorted = [...ids].sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i += CHUNK) {
const slice = sorted.slice(i, i + CHUNK);
const placeholders = slice.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM urls WHERE id IN (${placeholders}) ORDER BY id`)
.all(...slice) as unknown as UrlRowDb[];
for (const row of rows) {
yield this.rowFromDb(row);
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1
View File
@@ -2,3 +2,4 @@ export * from './crawl.js';
export * from './ipc.js';
export * from './project.js';
export * from './integrations.js';
export * from './pagespeed.js';
+79
View File
@@ -12,6 +12,10 @@ import type {
UrlDetail,
} from './crawl.js';
import type { IntegrationsState } from './integrations.js';
import type {
PagespeedRow,
PagespeedRunStrategy,
} from './pagespeed.js';
export const IPC = {
crawlStart: 'crawl:start',
@@ -143,6 +147,16 @@ export const IPC = {
* `projectSaveDir` pref when set, otherwise the OS Documents folder.
* Used by Settings → Storage to display the active path. */
defaultProjectDir: 'app:default-project-dir',
/** Faz 7 — Google PageSpeed Insights integration.
* `pagespeedQuery` lists crawled internal HTML pages joined with any
* stored audit results; `pagespeedRun` audits a user-selected set of
* URLs (the slow part — emits `pagespeedProgress` as it goes);
* `pagespeedCancel` stops an in-flight run early. */
pagespeedQuery: 'pagespeed:query',
pagespeedRun: 'pagespeed:run',
pagespeedCancel: 'pagespeed:cancel',
/** main → renderer: live progress of an in-flight PageSpeed run. */
pagespeedProgress: 'pagespeed:progress',
} as const;
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
@@ -220,7 +234,13 @@ export interface BrokenLinksQueryInput {
export interface BrokenLinksQueryResult {
rows: BrokenLinkRow[];
/** Total broken-link rows (each = one from→to link instance). Scales
* with how many pages the crawl reached. */
total: number;
/** Distinct broken target URLs. Far more stable across crawls than
* `total` — one broken URL counts once no matter how many pages link
* to it. */
uniqueTargets: number;
}
export interface ExportCsvInput {
@@ -887,6 +907,54 @@ export interface UrlRewritePreviewResult {
parseError?: string;
}
/**
* Inputs for the PageSpeed tab's candidate query. Returns crawled
* internal HTML pages (the universe PSI can be run against) left-joined
* with any audit results already stored for them.
*/
export interface PagespeedQueryInput {
limit: number;
offset: number;
/** Substring match against the URL. */
search?: string;
/** `all` (default), `tested` (has ≥1 stored result), `untested`. */
filter?: 'all' | 'tested' | 'untested';
}
export interface PagespeedQueryResult {
rows: PagespeedRow[];
/** Total candidate rows matching the filter (before pagination). */
total: number;
}
export interface PagespeedRunInput {
/** URLs to audit — taken from the candidate rows in the tab. */
urls: string[];
/** Which form factor(s) to audit. `both` doubles the API calls. */
strategy: PagespeedRunStrategy;
}
export interface PagespeedRunResult {
/** Number of (url × strategy) audits that completed successfully. */
completed: number;
/** Number that returned an error (URL unreachable, quota, etc.). */
failed: number;
/** True when the user cancelled the run before it finished. */
cancelled: boolean;
}
/** main → renderer live progress while a PageSpeed run is in flight. */
export interface PagespeedProgress {
/** Audits finished so far (success + failure). */
done: number;
/** Total audits in this run (urls × strategy count). */
total: number;
/** URL currently being audited, or null between items. */
currentUrl: string | null;
/** False once the run has fully stopped. */
running: boolean;
}
export interface LogEntry {
/** Monotonic sequence id, increments on every log call this session. */
id: number;
@@ -993,6 +1061,17 @@ export interface FreeCrawlApi {
pickDirectory(input?: { title?: string; defaultPath?: string }): Promise<string | null>;
/** Resolved default save directory for new projects (Documents fallback when unset). */
defaultProjectDir(): Promise<string>;
/** Faz 7 — list crawled internal HTML pages joined with their stored
* PageSpeed Insights audit results. */
pagespeedQuery(input: PagespeedQueryInput): Promise<PagespeedQueryResult>;
/** Run a PageSpeed Insights audit over the given URLs. Resolves when
* the whole run finishes; subscribe to `onPagespeedProgress` for
* live progress. */
pagespeedRun(input: PagespeedRunInput): Promise<PagespeedRunResult>;
/** Request cancellation of an in-flight PageSpeed run. */
pagespeedCancel(): Promise<void>;
/** Live progress of an in-flight PageSpeed run. */
onPagespeedProgress(cb: (p: PagespeedProgress) => void): () => void;
onLogEntry(cb: (entry: LogEntry) => void): () => void;
onLogsBatch(cb: (entries: LogEntry[]) => void): () => void;
onLogsBusy(cb: (busy: boolean) => void): () => void;
+58
View File
@@ -0,0 +1,58 @@
/**
* Faz 7 — Google PageSpeed Insights integration types.
*
* PSI runs a Lighthouse audit on a public URL and returns lab
* performance metrics. FreeCrawl drives it on-demand against a
* user-selected subset of crawled pages (PSI is slow — ~10–30 s per
* URL — and rate-limited, so auto-probing a full crawl is never an
* option). Results are stored per URL + strategy in the project DB.
*
* The PSI API works without a key at a low shared rate limit; a free
* Google Cloud API key (the `pagespeed` integration in the credential
* store) raises the throughput. The key is resolved in the main
* process only — it never crosses into the renderer.
*/
/** Which form factor Lighthouse emulates for the audit. */
export type PagespeedStrategy = 'mobile' | 'desktop';
/** A run can target one form factor or both. */
export type PagespeedRunStrategy = PagespeedStrategy | 'both';
/**
* Lighthouse lab metrics for one URL + strategy. All timing values are
* milliseconds; `cls` is the unitless Cumulative Layout Shift score.
* Any metric may be `null` when Lighthouse could not compute it.
*/
export interface PagespeedMetrics {
/** Lighthouse Performance category score, 0–100. */
performance: number | null;
/** Largest Contentful Paint (ms). */
lcp: number | null;
/** Cumulative Layout Shift (unitless). */
cls: number | null;
/** First Contentful Paint (ms). */
fcp: number | null;
/** Total Blocking Time (ms). */
tbt: number | null;
/** Speed Index (ms). */
speedIndex: number | null;
/** `ok` when the audit completed, `error` when PSI/Lighthouse failed. */
status: 'ok' | 'error';
/** Human-readable failure reason when `status` is `error`. */
error: string | null;
/** ISO 8601 timestamp of when the audit was fetched. */
fetchedAt: string;
}
/**
* One row in the PageSpeed tab — a crawled internal HTML page with its
* stored audit results. `mobile` / `desktop` are `null` until that
* strategy has been run for the URL.
*/
export interface PagespeedRow {
url: string;
statusCode: number | null;
mobile: PagespeedMetrics | null;
desktop: PagespeedMetrics | null;
}