feat: export dialog + MCP server for AI agents

This commit is contained in:
kemalai committed 2026-05-03 02:43:24 +03:00
1 parent 85230246d8
commit cec358be9f
28 files changed
+2344 -191

No files matched your search

+91
View File
@@ -215,6 +215,97 @@ npm --workspace apps/desktop run build:linux # AppImage / .deb
</details> </details>
<details>
<summary><b>🤖 MCP server</b> — query your crawl from Claude / any MCP client</summary>
<br />
FreeCrawl ships an **MCP (Model Context Protocol) server** that exposes the active `.seoproject` to AI agents over stdio. Read-only — runs alongside the desktop app without contention (SQLite WAL).
**Tools**: `get_summary`, `get_overview_counts`, `top_issues`, `query_urls`, `get_url_detail`, `list_projects`, `set_project`, `current_project`.
**1. Build it once:**
```bash
npm run build:mcp
```
This produces `apps/mcp-server/dist/index.js`.
**2. Register it with your MCP client.**
<details>
<summary><b>Claude Desktop</b></summary>
Edit your Claude Desktop config:
| Platform | Path |
| :--- | :--- |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Linux | `~/.config/Claude/claude_desktop_config.json` |
```json
{
"mcpServers": {
"freecrawl": {
"command": "node",
"args": ["/absolute/path/to/FreeCrawl-SEO-Tool/apps/mcp-server/dist/index.js"]
}
}
}
```
Restart Claude Desktop. The `freecrawl` server appears under the tool 🔌 icon.
</details>
<details>
<summary><b>Claude Code (CLI)</b></summary>
```bash
claude mcp add freecrawl -- node /absolute/path/to/FreeCrawl-SEO-Tool/apps/mcp-server/dist/index.js
```
</details>
<details>
<summary><b>Other MCP clients</b></summary>
Run the binary directly with stdio transport:
```bash
node apps/mcp-server/dist/index.js
```
The server speaks newline-delimited JSON-RPC 2.0 — point any MCP-compatible client at it.
</details>
**3. Try it.** Ask your agent things like:
> *"Show the 10 URLs with the longest response time in my last crawl."*
> *"What are the top 5 issue categories with the most affected pages?"*
> *"List every URL with a missing meta description."*
**Pointing at a non-default project:**
By default the server reads `<userData>/projects/default.seoproject` (the same file the desktop app uses). Override with the `FREECRAWL_PROJECT` env var, or call the `set_project` tool mid-session:
```json
{
"mcpServers": {
"freecrawl": {
"command": "node",
"args": ["/path/to/apps/mcp-server/dist/index.js"],
"env": { "FREECRAWL_PROJECT": "/path/to/audit.seoproject" }
}
}
}
```
</details>
<br /> <br />
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@freecrawl/cli", "name": "@freecrawl/cli",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": { "bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@freecrawl/desktop", "name": "@freecrawl/desktop",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"description": "FreeCrawl SEO — desktop application", "description": "FreeCrawl SEO — desktop application",
"main": "./out/main/index.js", "main": "./out/main/index.js",
+155 -80
View File
@@ -4,43 +4,48 @@ import { fileURLToPath } from 'node:url';
import * as logger from './logger.js'; import * as logger from './logger.js';
/** /**
* Pool of one worker_thread holding a read-only SQLite connection. * Pool of N worker_threads, each holding its own read-only SQLite
* "Pool" is forward-compatible naming — for now we run a single worker * connection. SQLite WAL mode allows arbitrary concurrent readers, so
* because SQLite's WAL allows arbitrary concurrent readers but adding a * spreading queries across multiple workers gives genuine parallelism
* second worker would just double our cache footprint without gaining * for UI reads — critical because:
* parallelism on a single physical disk. *
* - The OverviewSidebar refresh issues a 130-clause `getOverviewCounts`
* every few seconds. On a single-worker pool this serialises every
* other read behind it, freezing the URL table refresh and the
* bottom panel's per-URL detail / inlinks queries until the heavy
* pass completes.
* - Pool now lets the heavy aggregate run on one worker while the
* short, latency-sensitive queries (urlsQuery for the visible
* chunks, urlDetail for the bottom panel) run on the others.
*
* Routing: round-robin with a "least busy" tie-break — for each call
* we pick the worker with the fewest pending requests, and on a tie
* fall back to a rotating cursor so two same-loaded workers don't
* always get the next call dropped on the same one.
* *
* Lifecycle: * Lifecycle:
* - `init(dbPath)` spawns the worker, points it at the file. * - `init(dbPath)` spawns all workers, points them at the file.
* - `swap(newPath)` is called on Open Project — terminates the old * - `swap(newPath)` is called on Open Project — terminates every old
* worker and spawns a fresh one; in-flight requests are rejected * worker and spawns a fresh set; in-flight requests are rejected
* with `Error('reader-swapped')` so callers can retry safely. * with `Error('reader-swapped')` so callers can retry safely.
* - `terminate()` on app quit. After terminate, all `call()` resolve * - `terminate()` on app quit. After terminate, all `call()` resolve
* with `Error('reader-terminated')`. * with `Error('reader-terminated')`.
* *
* Crash recovery: * Crash recovery:
* - If the worker exits unexpectedly (`exit` event with non-zero code * - If a worker exits unexpectedly, that one slot auto-respawns up to
* while we still have a path) we auto-respawn up to MAX_RESTARTS * MAX_RESTARTS within RESTART_WINDOW_MS. Other workers in the pool
* within RESTART_WINDOW_MS. After the cap we surface the failure * keep serving traffic.
* and stop trying — the user gets a clear log entry instead of a * - In-flight requests on the crashed worker are rejected with
* restart loop.
* - In-flight requests during a crash are rejected with
* `Error('reader-crashed')`. Callers fall back to the main-process * `Error('reader-crashed')`. Callers fall back to the main-process
* ProjectDb (see `dbReaderCallOrFallback`) so the UI stays alive. * ProjectDb (see `callReaderOrFallback`) so the UI stays alive.
*/ */
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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 WORKER_PATH = path.join(__dirname, 'db-reader-worker.js');
// 30 s default leaves headroom for SQLite read contention during the // 30 s default leaves headroom for SQLite read contention during the
// post-crawl recompute phase (recomputeUrlsIssues holds a writer // post-crawl recompute phase. Genuine worker hangs are still surfaced
// transaction that can keep reader queries blocked behind earlier // — just with a slightly longer detection window than before.
// queued requests for several seconds at a time on large crawls).
// Genuine worker hangs are still surfaced — just with a slightly
// longer detection window than before.
const REQUEST_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 30_000;
// Heavy aggregate queries (the overview sidebar's 130-counter pass and // Heavy aggregate queries (the overview sidebar's 130-counter pass and
// the post-crawl materialiser's counter fan-out) can run past the // the post-crawl materialiser's counter fan-out) can run past the
@@ -54,7 +59,17 @@ const HEAVY_METHODS = new Set<string>([
const MAX_RESTARTS = 3; const MAX_RESTARTS = 3;
const RESTART_WINDOW_MS = 60_000; const RESTART_WINDOW_MS = 60_000;
// Reader pool size. Four workers gives the typical UI workload (one
// long-running aggregate from the sidebar + visible-table chunk fetch
// + bottom-panel detail + crawl-engine background reads) a free slot
// for every concurrent request; bigger doesn't help because most UI
// reads are short and a dedicated worker per query becomes pure
// memory cost. Each worker keeps its own SQLite handle (~5 MB heap)
// and prepared-statement cache (~10 MB on a populated DB).
const POOL_SIZE = 4;
interface PendingRequest { interface PendingRequest {
workerIdx: number;
resolve: (v: unknown) => void; resolve: (v: unknown) => void;
reject: (e: Error) => void; reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
@@ -67,94 +82,143 @@ interface ResponseMessage {
error?: string; error?: string;
} }
interface WorkerSlot {
worker: Worker | null;
pending: number;
restartTimes: number[];
}
class DbReaderPool { class DbReaderPool {
private worker: Worker | null = null; private slots: WorkerSlot[] = [];
private dbPath: string | null = null; private dbPath: string | null = null;
private freezeWatchdogSab: SharedArrayBuffer | null = null; private freezeWatchdogSab: SharedArrayBuffer | null = null;
private nextRequestId = 1; private nextRequestId = 1;
private pending = new Map<number, PendingRequest>(); private pending = new Map<number, PendingRequest>();
private restartTimes: number[] = []; private rrCursor = 0;
private terminated = false; private terminated = false;
/** Spawn (or respawn) the worker pointed at `dbPath`. Idempotent. /** Spawn (or respawn) the pool pointed at `dbPath`. Idempotent. */
*
* `freezeWatchdogSab` — if provided, the worker will tick its
* own heartbeat + publish its current method into this shared
* buffer so the freeze-watchdog thread can detect a stuck
* reader. Pass `null` to disable diagnostics. */
init(dbPath: string, freezeWatchdogSab: SharedArrayBuffer | null = null): void { init(dbPath: string, freezeWatchdogSab: SharedArrayBuffer | null = null): void {
if (this.terminated) { if (this.terminated) {
throw new Error('DbReaderPool: cannot init after terminate()'); throw new Error('DbReaderPool: cannot init after terminate()');
} }
this.freezeWatchdogSab = freezeWatchdogSab; this.freezeWatchdogSab = freezeWatchdogSab;
if (this.dbPath === dbPath && this.worker !== null) return; if (this.dbPath === dbPath && this.slots.some((s) => s.worker !== null)) return;
this.dbPath = dbPath; this.dbPath = dbPath;
this.spawn(); this.slots = Array.from({ length: POOL_SIZE }, () => ({
worker: null,
pending: 0,
restartTimes: [],
}));
for (let i = 0; i < POOL_SIZE; i++) this.spawn(i);
} }
/** Switch the worker to a different .seoproject file. */ /** Switch the pool to a different .seoproject file. */
swap(newPath: string, freezeWatchdogSab: SharedArrayBuffer | null = null): void { swap(newPath: string, freezeWatchdogSab: SharedArrayBuffer | null = null): void {
if (freezeWatchdogSab !== null) this.freezeWatchdogSab = freezeWatchdogSab; if (freezeWatchdogSab !== null) this.freezeWatchdogSab = freezeWatchdogSab;
if (this.dbPath === newPath && this.worker !== null) return; if (this.dbPath === newPath && this.slots.some((s) => s.worker !== null)) return;
this.failPendingWith('reader-swapped'); this.failPendingWith('reader-swapped');
this.dbPath = newPath; this.dbPath = newPath;
this.spawn(); if (this.slots.length === 0) {
this.slots = Array.from({ length: POOL_SIZE }, () => ({
worker: null,
pending: 0,
restartTimes: [],
}));
}
for (let i = 0; i < POOL_SIZE; i++) this.spawn(i);
} }
/** Permanent shutdown — used on app quit. */ /** Permanent shutdown — used on app quit. */
async terminate(): Promise<void> { async terminate(): Promise<void> {
this.terminated = true; this.terminated = true;
this.failPendingWith('reader-terminated'); this.failPendingWith('reader-terminated');
if (this.worker) { const workers = this.slots.map((s) => s.worker).filter((w): w is Worker => w !== null);
const w = this.worker; for (const slot of this.slots) slot.worker = null;
this.worker = null; await Promise.all(
try { workers.map((w) =>
await w.terminate(); w.terminate().catch(() => undefined),
} catch { ),
/* already gone */ );
}
}
} }
/** True while a worker is up and ready to receive requests. */ /** True while at least one worker is up and ready. */
isReady(): boolean { isReady(): boolean {
return this.worker !== null && !this.terminated; return !this.terminated && this.slots.some((s) => s.worker !== null);
} }
/** /**
* Dispatch a method call to the worker. Callers should use the typed * Dispatch a method call to the least-busy ready worker. Resolves
* wrapper `callReader<T>(method, args)` declared in the main process * with the method's return value, rejects with an Error on worker
* IPC layer. Resolves with the method's return value, rejects with * error / timeout / crash.
* an Error on worker error / timeout / crash.
*/ */
call<T>(method: string, args: unknown[] = []): Promise<T> { call<T>(method: string, args: unknown[] = []): Promise<T> {
if (this.terminated) return Promise.reject(new Error('reader-terminated')); if (this.terminated) return Promise.reject(new Error('reader-terminated'));
if (!this.worker) return Promise.reject(new Error('reader-not-initialised')); const slotIdx = this.pickSlot();
if (slotIdx < 0) return Promise.reject(new Error('reader-not-initialised'));
const slot = this.slots[slotIdx]!;
const worker = slot.worker;
if (!worker) return Promise.reject(new Error('reader-not-initialised'));
const requestId = this.nextRequestId++; const requestId = this.nextRequestId++;
const timeoutMs = HEAVY_METHODS.has(method) const timeoutMs = HEAVY_METHODS.has(method)
? HEAVY_REQUEST_TIMEOUT_MS ? HEAVY_REQUEST_TIMEOUT_MS
: REQUEST_TIMEOUT_MS; : REQUEST_TIMEOUT_MS;
slot.pending++;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.pending.delete(requestId); const p = this.pending.get(requestId);
if (p) {
this.pending.delete(requestId);
this.slots[p.workerIdx]!.pending = Math.max(
0,
this.slots[p.workerIdx]!.pending - 1,
);
}
reject(new Error(`reader-timeout: ${method} > ${timeoutMs}ms`)); reject(new Error(`reader-timeout: ${method} > ${timeoutMs}ms`));
}, timeoutMs); }, timeoutMs);
this.pending.set(requestId, { this.pending.set(requestId, {
workerIdx: slotIdx,
resolve: resolve as (v: unknown) => void, resolve: resolve as (v: unknown) => void,
reject, reject,
timer, timer,
}); });
this.worker!.postMessage({ requestId, method, args }); worker.postMessage({ requestId, method, args });
}); });
} }
// ── private ────────────────────────────────────────────────────────────── // ── private ──────────────────────────────────────────────────────────────
private spawn(): void { /**
* Pick the slot with the fewest pending requests. Ties are broken
* by a round-robin cursor so identically-loaded workers don't all
* receive their next call on the same slot.
*/
private pickSlot(): number {
let best = -1;
let bestPending = Number.POSITIVE_INFINITY;
// Start the scan from the rotating cursor so RR is honoured on ties.
for (let i = 0; i < this.slots.length; i++) {
const idx = (this.rrCursor + i) % this.slots.length;
const s = this.slots[idx]!;
if (!s.worker) continue;
if (s.pending < bestPending) {
best = idx;
bestPending = s.pending;
}
}
if (best >= 0) {
this.rrCursor = (best + 1) % this.slots.length;
}
return best;
}
private spawn(slotIdx: number): void {
if (!this.dbPath) return; if (!this.dbPath) return;
if (this.worker) { const slot = this.slots[slotIdx];
const old = this.worker; if (!slot) return;
this.worker = null; if (slot.worker) {
const old = slot.worker;
slot.worker = null;
void old.terminate().catch(() => undefined); void old.terminate().catch(() => undefined);
} }
try { try {
@@ -166,18 +230,19 @@ class DbReaderPool {
}); });
w.on('message', (msg: ResponseMessage) => this.handleResponse(msg)); w.on('message', (msg: ResponseMessage) => this.handleResponse(msg));
w.on('error', (err) => { w.on('error', (err) => {
logger.log('error', 'db-reader', `worker error: ${err.message}`); logger.log('error', 'db-reader', `worker[${slotIdx}] error: ${err.message}`);
}); });
w.on('exit', (code) => this.handleExit(code)); w.on('exit', (code) => this.handleExit(slotIdx, code));
this.worker = w; slot.worker = w;
logger.log('info', 'db-reader', `worker spawned for ${this.dbPath}`); slot.pending = 0;
logger.log('info', 'db-reader', `worker[${slotIdx}] spawned for ${this.dbPath}`);
} catch (err) { } catch (err) {
logger.log( logger.log(
'error', 'error',
'db-reader', 'db-reader',
`worker spawn failed: ${err instanceof Error ? err.message : String(err)}`, `worker[${slotIdx}] spawn failed: ${err instanceof Error ? err.message : String(err)}`,
); );
this.worker = null; slot.worker = null;
} }
} }
@@ -186,6 +251,8 @@ class DbReaderPool {
if (!pending) return; if (!pending) return;
this.pending.delete(msg.requestId); this.pending.delete(msg.requestId);
clearTimeout(pending.timer); clearTimeout(pending.timer);
const slot = this.slots[pending.workerIdx];
if (slot) slot.pending = Math.max(0, slot.pending - 1);
if (msg.ok) { if (msg.ok) {
pending.resolve(msg.result); pending.resolve(msg.result);
} else { } else {
@@ -193,31 +260,38 @@ class DbReaderPool {
} }
} }
private handleExit(code: number): void { private handleExit(slotIdx: number, code: number): void {
this.worker = null; const slot = this.slots[slotIdx];
if (!slot) return;
slot.worker = null;
if (this.terminated) return; if (this.terminated) return;
// Reject only this slot's in-flight requests.
for (const [id, p] of this.pending) {
if (p.workerIdx === slotIdx) {
clearTimeout(p.timer);
p.reject(new Error('reader-crashed'));
this.pending.delete(id);
}
}
slot.pending = 0;
if (code === 0) 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(); const now = Date.now();
this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS); slot.restartTimes = slot.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
if (this.restartTimes.length >= MAX_RESTARTS) { if (slot.restartTimes.length >= MAX_RESTARTS) {
logger.log( logger.log(
'error', 'error',
'db-reader', 'db-reader',
`worker crashed ${MAX_RESTARTS}× within ${RESTART_WINDOW_MS}ms — giving up. UI queries fall back to main-process DB.`, `worker[${slotIdx}] crashed ${MAX_RESTARTS}× within ${RESTART_WINDOW_MS}ms — giving up on this slot.`,
); );
return; return;
} }
this.restartTimes.push(now); slot.restartTimes.push(now);
logger.log( logger.log(
'warn', 'warn',
'db-reader', 'db-reader',
`worker exited with code ${code}; respawning (${this.restartTimes.length}/${MAX_RESTARTS})`, `worker[${slotIdx}] exited with code ${code}; respawning (${slot.restartTimes.length}/${MAX_RESTARTS})`,
); );
this.spawn(); this.spawn(slotIdx);
} }
private failPendingWith(reason: string): void { private failPendingWith(reason: string): void {
@@ -226,6 +300,7 @@ class DbReaderPool {
p.reject(new Error(reason)); p.reject(new Error(reason));
} }
this.pending.clear(); this.pending.clear();
for (const slot of this.slots) slot.pending = 0;
} }
} }
@@ -233,10 +308,10 @@ class DbReaderPool {
export const dbReaderPool = new DbReaderPool(); export const dbReaderPool = new DbReaderPool();
/** /**
* Thin helper for IPC handlers: try the worker first, fall back to the * Thin helper for IPC handlers: try the worker pool first, fall back
* synchronous main-process DB on any worker error. The fallback path * to the synchronous main-process DB on any worker error. The fallback
* keeps the UI working even if the worker is crashed/restarting, at * path keeps the UI working even if every worker is crashed/restarting,
* the cost of running on the main thread for that one query. * at the cost of running on the main thread for that one query.
*/ */
export async function callReaderOrFallback<T>( export async function callReaderOrFallback<T>(
method: string, method: string,
+126 -2
View File
@@ -25,6 +25,8 @@ import {
type ExportJsonResult, type ExportJsonResult,
type ExportXmlInput, type ExportXmlInput,
type ExportXmlResult, type ExportXmlResult,
type ExportTabularInput,
type ExportTabularResult,
type DataDeleteByDomainInput, type DataDeleteByDomainInput,
type DataDeleteByDomainResult, type DataDeleteByDomainResult,
type CrashRecoveryStatus, type CrashRecoveryStatus,
@@ -81,6 +83,7 @@ import {
exportUrlsToCsv, exportUrlsToCsv,
exportUrlsToJson, exportUrlsToJson,
exportUrlsToXml, exportUrlsToXml,
exportTabular,
exportSitemap, exportSitemap,
exportHtmlReport, exportHtmlReport,
compareCrawls, compareCrawls,
@@ -366,6 +369,64 @@ async function writeFetchedUrlViaPool(
} }
} }
/**
* Run the post-crawl issue recompute on the writer worker. This pass
* has 70+ correlated SQL subqueries against the urls table; on a
* 5K-URL crawl it takes ~45 s, and running it on the main thread
* freezes the renderer for that entire window (the "Not Responding"
* dialog). Routing through the writer worker keeps main free.
*/
async function recomputeIssuesViaPool(
definitions: ReadonlyArray<readonly [string, string]>,
): Promise<void> {
if (!dbWriterPool.isReady()) {
return getDb().recomputeUrlsIssuesYielding(definitions);
}
try {
await dbWriterPool.call<void>('recomputeUrlsIssuesYielding', [definitions]);
} catch (err) {
logger.log(
'warn',
'main',
`recomputeUrlsIssuesYielding dispatch failed (${
err instanceof Error ? err.message : String(err)
}) — falling back to main-thread recompute.`,
);
await getDb().recomputeUrlsIssuesYielding(definitions);
}
}
/**
* Generic post-crawl pass dispatcher. Routes recomputeInlinks,
* recomputeRedirectChains, recomputeHreflangAnalysis,
* recomputeHreflangInconsistent, and recomputePaginationSequence
* through the writer worker so the main thread stays responsive to
* Stop/Pause clicks and IPC traffic during the post-crawl phase.
* Without this, those five sync SQL passes combined freeze main for
* 5–15 s on a 5K-URL crawl — exactly the window where users hit
* Stop/Pause and "nothing happens".
*/
async function runDbPassViaPool(method: string): Promise<void> {
if (!dbWriterPool.isReady()) {
const fn = (getDb() as unknown as Record<string, unknown>)[method];
if (typeof fn === 'function') (fn as () => void).call(getDb());
return;
}
try {
await dbWriterPool.call<void>(method, []);
} catch (err) {
logger.log(
'warn',
'main',
`${method} dispatch failed (${
err instanceof Error ? err.message : String(err)
}) — falling back to main-thread.`,
);
const fn = (getDb() as unknown as Record<string, unknown>)[method];
if (typeof fn === 'function') (fn as () => void).call(getDb());
}
}
function getDb(): ProjectDb { function getDb(): ProjectDb {
if (!db) { if (!db) {
const dataDir = join(app.getPath('userData'), 'projects'); const dataDir = join(app.getPath('userData'), 'projects');
@@ -1160,6 +1221,8 @@ function registerIpc(): void {
setOp: (op: string) => freezeWatchdog.setMainOp(op), setOp: (op: string) => freezeWatchdog.setMainOp(op),
parseHtml: parseHtmlViaPool, parseHtml: parseHtmlViaPool,
writeFetchedUrl: writeFetchedUrlViaPool, writeFetchedUrl: writeFetchedUrlViaPool,
recomputeIssues: recomputeIssuesViaPool,
runDbPass: runDbPassViaPool,
}); });
activeCrawler = crawler; activeCrawler = crawler;
attachCrawlerListeners(crawler); attachCrawlerListeners(crawler);
@@ -1178,11 +1241,29 @@ function registerIpc(): void {
activeCrawler?.resume(); activeCrawler?.resume();
}); });
ipcMain.handle(IPC.crawlClear, () => { ipcMain.handle(IPC.crawlClear, async () => {
activeCrawler?.stop(); activeCrawler?.stop();
activeCrawler = null; activeCrawler = null;
const database = getDb(); const database = getDb();
database.reset(); // Route reset() through the writer pool so it sits behind any
// in-flight writeFetchedUrl RPCs in the worker's FIFO. Without
// this, the main process truncates the urls table while the
// writer worker is still draining the last batch of per-URL
// writes — those writes win the SQLite lock race and resurrect
// ~19 rows in the table after Clear. Going through the pool
// serialises naturally: every queued write completes, then reset
// runs against an already-stopped crawler.
if (dbWriterPool.isReady()) {
try {
await dbWriterPool.call('reset', []);
} catch {
// Worker terminated or RPC timed out — fall back to direct
// reset rather than leaving the table half-cleared.
database.reset();
}
} else {
database.reset();
}
// Belt-and-braces: reset() above already includes a DELETE FROM // Belt-and-braces: reset() above already includes a DELETE FROM
// crawl_queue, but if anything (a late-firing checkpoint timer, a // crawl_queue, but if anything (a late-firing checkpoint timer, a
// half-finished post-crawl pass) writes to that table after the // half-finished post-crawl pass) writes to that table after the
@@ -1661,6 +1742,47 @@ function registerIpc(): void {
}, },
); );
ipcMain.handle(
IPC.exportTabular,
async (_e, input: ExportTabularInput): Promise<ExportTabularResult> => {
const { format, sections, columns, selectedIds } = input;
let outputPath = input.filePath ?? '';
const isSelection = (selectedIds?.length ?? 0) > 0;
const multiSection = sections.length > 1;
if (!outputPath) {
if (format === 'csv' && multiSection) {
const res = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory', 'createDirectory'],
title: 'Choose folder for CSV export',
});
if (res.canceled || res.filePaths.length === 0) {
return { filePath: '', files: [], rowsWritten: 0 };
}
outputPath = res.filePaths[0]!;
} else {
const ext = format === 'xlsx' ? 'xlsx' : 'csv';
const filterName = format === 'xlsx' ? 'Excel Workbook' : 'CSV';
const baseName = isSelection ? 'freecrawl-selected' : 'freecrawl-export';
const res = await dialog.showSaveDialog(mainWindow!, {
defaultPath: `${baseName}.${ext}`,
filters: [{ name: filterName, extensions: [ext] }],
});
if (res.canceled || !res.filePath) {
return { filePath: '', files: [], rowsWritten: 0 };
}
outputPath = res.filePath;
}
}
const result = await exportTabular(getDb(), outputPath, {
format,
sections,
columns,
selectedIds,
});
return result;
},
);
ipcMain.handle( ipcMain.handle(
IPC.dataDeleteByDomain, IPC.dataDeleteByDomain,
async ( async (
@@ -1707,6 +1829,8 @@ function registerIpc(): void {
setOp: (op: string) => freezeWatchdog.setMainOp(op), setOp: (op: string) => freezeWatchdog.setMainOp(op),
parseHtml: parseHtmlViaPool, parseHtml: parseHtmlViaPool,
writeFetchedUrl: writeFetchedUrlViaPool, writeFetchedUrl: writeFetchedUrlViaPool,
recomputeIssues: recomputeIssuesViaPool,
runDbPass: runDbPassViaPool,
}); });
activeCrawler = crawler; activeCrawler = crawler;
attachCrawlerListeners(crawler); attachCrawlerListeners(crawler);
+4
View File
@@ -11,6 +11,8 @@ import {
type ExportJsonResult, type ExportJsonResult,
type ExportXmlInput, type ExportXmlInput,
type ExportXmlResult, type ExportXmlResult,
type ExportTabularInput,
type ExportTabularResult,
type DataDeleteByDomainInput, type DataDeleteByDomainInput,
type DataDeleteByDomainResult, type DataDeleteByDomainResult,
type CrashRecoveryStatus, type CrashRecoveryStatus,
@@ -123,6 +125,8 @@ const api: FreeCrawlApi = {
ipcRenderer.invoke(IPC.exportJson, input), ipcRenderer.invoke(IPC.exportJson, input),
exportXml: (input: ExportXmlInput): Promise<ExportXmlResult> => exportXml: (input: ExportXmlInput): Promise<ExportXmlResult> =>
ipcRenderer.invoke(IPC.exportXml, input), ipcRenderer.invoke(IPC.exportXml, input),
exportTabular: (input: ExportTabularInput): Promise<ExportTabularResult> =>
ipcRenderer.invoke(IPC.exportTabular, input),
dataDeleteByDomain: ( dataDeleteByDomain: (
input: DataDeleteByDomainInput, input: DataDeleteByDomainInput,
): Promise<DataDeleteByDomainResult> => ): Promise<DataDeleteByDomainResult> =>
+22 -12
View File
@@ -137,20 +137,30 @@ export function App() {
// previous session left a non-empty checkpoint we ask whether to // previous session left a non-empty checkpoint we ask whether to
// resume. Discarding wipes the snapshot so the prompt doesn't // resume. Discarding wipes the snapshot so the prompt doesn't
// recur on next launch. // recur on next launch.
//
// The confirm() is wrapped in a 250 ms setTimeout so the initial
// render finishes (URL input mounts, status bar paints) before
// the modal opens. Without the defer, the synchronous `confirm`
// can fire on first paint and — when Electron renders the modal
// 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.
void window.freecrawl.crashRecoveryStatus().then((status) => { void window.freecrawl.crashRecoveryStatus().then((status) => {
if (status.pendingCount === 0) return; if (status.pendingCount === 0) return;
const proceed = window.confirm( window.setTimeout(() => {
`FreeCrawl detected a previous crawl that didn't finish cleanly:\n\n` + const proceed = window.confirm(
` Start URL: ${status.seedUrl}\n` + `FreeCrawl detected a previous crawl that didn't finish cleanly:\n\n` +
` Pending URLs: ${status.pendingCount.toLocaleString()}\n\n` + ` Start URL: ${status.seedUrl}\n` +
`Resume the crawl from where it stopped?\n\n` + ` Pending URLs: ${status.pendingCount.toLocaleString()}\n\n` +
`Click OK to resume, Cancel to discard the recovery state and start fresh.`, `Resume the crawl from where it stopped?\n\n` +
); `Click OK to resume, Cancel to discard the recovery state and start fresh.`,
if (proceed) { );
void window.freecrawl.crashRecoveryResume(); if (proceed) {
} else { void window.freecrawl.crashRecoveryResume();
void window.freecrawl.crashRecoveryDiscard(); } else {
} void window.freecrawl.crashRecoveryDiscard();
}
}, 250);
}); });
// rAF-throttled progress dispatch. The crawler emits up to 5 // rAF-throttled progress dispatch. The crawler emits up to 5
@@ -0,0 +1,60 @@
import { Component, type ReactNode } from 'react';
interface Props {
/** Rendered when the boundary's subtree throws. Defaults to a tiny notice. */
fallback?: ReactNode;
/** Surfaced to console + main-process log so the user can copy/paste. */
context?: string;
children: ReactNode;
}
interface State {
err: Error | null;
}
/**
* Catches render-time errors in a subtree so a single misbehaving dialog
* (e.g. ExportDialog) can't unmount the whole renderer and leave the user
* staring at the Electron window's bare background colour. The boundary
* logs the error and shows a small inline notice; the rest of the app
* keeps working.
*/
export class ErrorBoundary extends Component<Props, State> {
override state: State = { err: null };
static getDerivedStateFromError(err: Error): State {
return { err };
}
override componentDidCatch(err: Error, info: { componentStack?: string }): void {
// eslint-disable-next-line no-console
console.error(
`[ErrorBoundary${this.props.context ? `:${this.props.context}` : ''}]`,
err,
info.componentStack,
);
}
reset = (): void => {
this.setState({ err: null });
};
override render(): ReactNode {
const { err } = this.state;
if (!err) return this.props.children;
if (this.props.fallback !== undefined) return this.props.fallback;
return (
<div className="fixed inset-x-0 top-2 z-[60] mx-auto w-fit max-w-[90vw] rounded border border-red-700/60 bg-red-950/90 px-3 py-2 text-[12px] text-red-200 shadow-lg">
<div className="font-semibold">Something broke in this panel.</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-red-300">{err.message}</div>
<button
type="button"
onClick={this.reset}
className="mt-1 rounded border border-red-700/60 px-2 py-0.5 text-[11px] text-red-200 hover:bg-red-900/50"
>
Dismiss
</button>
</div>
);
}
}
@@ -0,0 +1,403 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import clsx from 'clsx';
import type { CrawlUrlRow, ExportTabularSection, UrlCategory } from '@freecrawl/shared-types';
import { TAB_ORDER, type TabKey } from '../store.js';
import { COLUMN_SPECS, columnId } from '../tabs/columns.js';
type Format = 'csv' | 'xlsx';
interface ExportTab {
key: TabKey;
label: string;
category: UrlCategory;
columnKeys: (keyof CrawlUrlRow)[];
}
const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
internal: 'internal:html',
external: 'external:all',
'response-codes': 'all',
url: 'internal:html',
'page-titles': 'internal:html',
'meta-description': 'internal:html',
h1: 'internal:html',
h2: 'internal:html',
content: 'internal:html',
images: 'internal:html',
canonicals: 'internal:html',
directives: 'internal:html',
redirects: 'all',
links: 'all',
'broken-links': 'all',
};
function buildExportableTabs(): ExportTab[] {
const out: ExportTab[] = [];
for (const t of TAB_ORDER) {
const cols = COLUMN_SPECS[t.key];
// Tabs with their own non-URL-row layouts (Images / Broken Links)
// have empty COLUMN_SPECS — skip them so the export grid only shows
// categories that share the URL-row schema.
if (!cols || cols.length === 0) continue;
out.push({
key: t.key,
label: t.label,
category: TAB_CATEGORY[t.key],
columnKeys: cols.map((c) => c.key),
});
}
return out;
}
interface ExportDialogProps {
open: boolean;
onClose: () => void;
/** The tab the user clicked Export from — drives the default selection. */
defaultTab: TabKey;
/** Row ids selected in the current table; falsy → export the whole filter set. */
selectedIds?: number[];
}
export function ExportDialog({
open,
onClose,
defaultTab,
selectedIds,
}: ExportDialogProps) {
const tabs = useMemo(() => buildExportableTabs(), []);
const initialTabKey: TabKey = useMemo(() => {
return tabs.find((t) => t.key === defaultTab)?.key ?? tabs[0]?.key ?? 'internal';
}, [defaultTab, tabs]);
const selectionCount = selectedIds?.length ?? 0;
const [format, setFormat] = useState<Format>('csv');
const [pickedTabs, setPickedTabs] = useState<Set<TabKey>>(() => new Set([initialTabKey]));
const [pickedColumns, setPickedColumns] = useState<Set<string>>(() => {
const t = tabs.find((x) => x.key === initialTabKey);
return new Set((t?.columnKeys ?? []) as string[]);
});
const [useSelection, setUseSelection] = useState<boolean>(selectionCount > 0);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string>('');
// The dialog is conditionally mounted by the parent (only when open=true),
// so useState initializers above already populate the right defaults on
// each open. Escape key dismissal is the only ambient behaviour we need.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
// Union of column keys exposed by every picked tab — these are the
// columns the dialog offers; checked state lives in `pickedColumns`.
const offeredColumns = useMemo(() => {
const seen = new Set<string>();
const out: { key: string; header: string; tab: TabKey }[] = [];
for (const t of tabs) {
if (!pickedTabs.has(t.key)) continue;
const specs = COLUMN_SPECS[t.key];
for (const spec of specs) {
const id = columnId(spec);
if (seen.has(id)) continue;
seen.add(id);
out.push({ key: spec.key as string, header: spec.header, tab: t.key });
}
}
return out;
}, [pickedTabs, tabs]);
const orderedColumnList = useMemo(
() => offeredColumns.filter((c) => pickedColumns.has(c.key)).map((c) => c.key),
[offeredColumns, pickedColumns],
);
if (!open) return null;
const toggleTab = (key: TabKey) => {
setPickedTabs((prev) => {
const next = new Set(prev);
let willBeRemoved = false;
if (next.has(key)) {
if (next.size === 1) return prev; // keep at least one
next.delete(key);
willBeRemoved = true;
} else {
next.add(key);
}
// Auto-tick the new tab's columns when it joins the picked set, so
// the user doesn't have to re-check them. When a tab leaves we keep
// the user's column picks alone — orderedColumnList already drops
// any column not offered by remaining tabs.
if (!willBeRemoved) {
const t = tabs.find((x) => x.key === key);
if (t) {
setPickedColumns((prevCols) => {
const nextCols = new Set(prevCols);
for (const k of t.columnKeys) nextCols.add(k as string);
return nextCols;
});
}
}
return next;
});
};
const toggleColumn = (key: string) => {
setPickedColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const checkAllColumns = () => {
setPickedColumns(new Set(offeredColumns.map((c) => c.key)));
};
const uncheckAllColumns = () => {
setPickedColumns(new Set());
};
const submit = async () => {
if (orderedColumnList.length === 0) {
setStatus('Pick at least one column.');
return;
}
if (pickedTabs.size === 0) {
setStatus('Pick at least one table.');
return;
}
setBusy(true);
setStatus('');
try {
const sections: ExportTabularSection[] = tabs
.filter((t) => pickedTabs.has(t.key))
.map((t) => ({ label: t.label, category: t.category }));
const result = await window.freecrawl.exportTabular({
format,
sections,
columns: orderedColumnList,
selectedIds:
useSelection && selectedIds && selectedIds.length > 0
? selectedIds
: undefined,
});
if (!result.filePath) {
// User cancelled save / folder dialog.
setBusy(false);
return;
}
setStatus(
`Exported ${result.rowsWritten.toLocaleString()} row${result.rowsWritten === 1 ? '' : 's'} to ${result.filePath}`,
);
setBusy(false);
setTimeout(() => onClose(), 700);
} catch (err) {
setBusy(false);
setStatus(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
>
<div
className="flex max-h-[85vh] w-[780px] max-w-[95vw] flex-col rounded-md border border-surface-700 bg-surface-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold text-surface-100">Export</div>
<button
className="rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-auto px-4 py-3">
<div className="grid grid-cols-2 gap-4">
<section>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Tables
</div>
<div className="rounded border border-surface-800 bg-surface-950/50 p-2">
<div className="grid grid-cols-1 gap-1">
{tabs.map((t) => {
const checked = pickedTabs.has(t.key);
const isDefault = t.key === initialTabKey;
return (
<label
key={t.key}
className={clsx(
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
checked
? 'bg-accent-500/10 text-surface-100'
: 'text-surface-300 hover:bg-surface-800/60',
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={checked}
onChange={() => toggleTab(t.key)}
/>
<span className="truncate">{t.label}</span>
{isDefault && (
<span className="ml-auto rounded bg-surface-800 px-1 text-[9px] uppercase tracking-wide text-surface-400">
Active
</span>
)}
</label>
);
})}
</div>
</div>
</section>
<section className="flex min-h-0 flex-col">
<div className="mb-1.5 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Columns
</div>
<div className="flex gap-1 text-[10px]">
<button
type="button"
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
onClick={checkAllColumns}
>
All
</button>
<button
type="button"
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
onClick={uncheckAllColumns}
>
None
</button>
</div>
</div>
<div className="max-h-[320px] overflow-auto rounded border border-surface-800 bg-surface-950/50 p-2">
<div className="grid grid-cols-1 gap-1">
{offeredColumns.map((c) => {
const checked = pickedColumns.has(c.key);
return (
<label
key={c.key}
className={clsx(
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
checked
? 'bg-accent-500/10 text-surface-100'
: 'text-surface-300 hover:bg-surface-800/60',
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={checked}
onChange={() => toggleColumn(c.key)}
/>
<span className="truncate">{c.header}</span>
<span className="ml-auto truncate font-mono text-[10px] text-surface-500">
{c.key}
</span>
</label>
);
})}
</div>
</div>
</section>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Format
</span>
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
<input
type="radio"
name="export-format"
checked={format === 'csv'}
onChange={() => setFormat('csv')}
/>
CSV
</label>
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
<input
type="radio"
name="export-format"
checked={format === 'xlsx'}
onChange={() => setFormat('xlsx')}
/>
Excel (.xlsx)
</label>
</div>
{selectionCount > 0 && (
<label className="flex cursor-pointer items-center gap-2 text-[12px] text-surface-200">
<input
type="checkbox"
checked={useSelection}
onChange={(e) => setUseSelection(e.target.checked)}
/>
<span>
Selected rows only{' '}
<span className="text-surface-500">
({selectionCount.toLocaleString()})
</span>
</span>
</label>
)}
</div>
{format === 'csv' && pickedTabs.size > 1 && (
<div className="mt-2 rounded border border-amber-700/40 bg-amber-900/20 px-2 py-1 text-[11px] text-amber-200">
Multiple tables + CSV → you'll be asked to pick a folder; one file is written per table.
</div>
)}
{status && (
<div className="mt-3 truncate rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-200">
{status}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-surface-800 px-4 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-300 hover:bg-surface-800"
disabled={busy}
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={busy || pickedColumns.size === 0 || pickedTabs.size === 0}
className={clsx(
'inline-flex items-center gap-1 rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-200 hover:bg-surface-800',
(busy || pickedColumns.size === 0 || pickedTabs.size === 0) &&
'cursor-not-allowed opacity-50 hover:bg-transparent',
)}
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Export
</button>
</div>
</div>
</div>
);
}
@@ -2,18 +2,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AdvancedFilter, CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types'; import type { AdvancedFilter, CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
const CHUNK_SIZE = 500; const CHUNK_SIZE = 500;
const MAX_CACHED_CHUNKS = 8; // Doubled the cache (was 8) so a fast scroll across 4-5 chunks doesn't
// Live-refresh cadence during an active crawl. 750 ms strikes a balance // thrash the reader pool: each chunk evicted = a re-fetch the next time
// between perceived "row streaming" smoothness and the cost of a COUNT // the user scrolls back to it. 16 × 500 = 8000 rows kept warm; at
// query + per-chunk fetch every tick. At 100 URL/s the user still sees // ~2 KB/row that's ~16 MB heap, well within budget.
// ~75 new rows per tick (continuous flow), while leaving 3× more main- const MAX_CACHED_CHUNKS = 16;
// thread headroom than the previous 250 ms cadence. // Number of chunks to pre-fetch ahead/behind the visible range. The
// Cadence for the live-tail tick. 1500 ms strikes the balance between // visible range is what the virtualizer asks for; pre-fetching the
// "feels fresh" and "doesn't pin SQLite". At 750 ms a 2k+ row crawl was // neighbours hides "..." placeholders during fast scrolls because the
// burning ~30% of one core in COUNT + chunk fetches, blocking input IPC // next chunk has usually already landed by the time the user reaches
// for the duration. The user sees the same effect: numbers tick at half // it. 1 chunk = 500 rows of look-ahead each direction, cheap on the
// speed but the window stops kasma'ing. // reader pool.
const LIVE_REFRESH_MS = 1500; const PREFETCH_CHUNKS = 1;
// Min gap between progress-driven ticks. Progress events arrive at
// ~5 Hz from the crawler; without throttle the visible-chunk re-query
// would fire that often too. 100 ms keeps the table feeling live
// (10 refreshes/sec ceiling) without pinning the reader-pool.
const TICK_THROTTLE_MS = 100;
// Safety-net periodic tick for cases where progress events stop
// arriving (crawl finished, manual DB edits, etc.). Long interval —
// the progress-event path drives the in-crawl experience.
const LIVE_REFRESH_MS = 5000;
export interface LazyRowsOpts { export interface LazyRowsOpts {
category: UrlCategory; category: UrlCategory;
@@ -31,6 +40,12 @@ export interface LazyRowsState {
loadedRows: number; loadedRows: number;
rowAt: (index: number) => CrawlUrlRow | null; rowAt: (index: number) => CrawlUrlRow | null;
ensureRange: (start: number, end: number) => void; ensureRange: (start: number, end: number) => void;
/** True between the moment a shape change is requested (tab/sort/filter
* flip) and the moment the new data has fully landed. Lets the
* consumer distinguish "0 URLs because the category genuinely has
* none" from "0 URLs because we haven't fetched yet" — which the
* empty-state UI cares about a lot. */
isLoading: boolean;
} }
/** /**
@@ -49,6 +64,7 @@ export interface LazyRowsState {
export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState { export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const chunks = useRef(new Map<number, CrawlUrlRow[]>()); const chunks = useRef(new Map<number, CrawlUrlRow[]>());
const chunkOrder = useRef<number[]>([]); const chunkOrder = useRef<number[]>([]);
const fetching = useRef(new Set<number>()); const fetching = useRef(new Set<number>());
@@ -88,20 +104,52 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
[opts.category, opts.search, opts.sortBy, opts.sortDir, opts.filter], [opts.category, opts.search, opts.sortBy, opts.sortDir, opts.filter],
); );
// Shape change: rebuild from scratch. The virtualizer will call // Shape change (tab/sort/filter switch): clear immediately and load
// ensureRange again on its next measurement pass. // fresh data in parallel. Showing the previous tab's rows under the
// new tab's header is the wrong UX — "Internal" rows visible while
// "External" is selected makes the user mistrust what they see.
// Instead we set `isLoading=true` and let the consumer render a
// proper loading state until the new total + visible chunks land.
// The reader-pool has multiple workers now, so meta + visible-chunk
// queries run in parallel and typically resolve in ~50-150 ms.
useEffect(() => { useEffect(() => {
resetToken.current++; resetToken.current++;
chunks.current.clear(); chunks.current.clear();
chunkOrder.current = []; chunkOrder.current = [];
fetching.current.clear(); fetching.current.clear();
setIsLoading(true);
setVersion((v) => v + 1); setVersion((v) => v + 1);
const token = resetToken.current; const token = resetToken.current;
let cancelled = false; let cancelled = false;
void queryMeta().then(({ total: t }) => {
if (cancelled || token !== resetToken.current) return; const load = async (): Promise<void> => {
setTotal(t); const metaPromise = queryMeta();
}); const { first, last } = activeRange.current;
const chunkPromises: Promise<{ rows: CrawlUrlRow[]; total: number; idx: number }>[] = [];
for (let i = first; i <= last; i++) {
chunkPromises.push(queryChunk(i).then((r) => ({ ...r, idx: i })));
}
try {
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);
}
setTotal(t);
setVersion((v) => v + 1);
} catch {
if (cancelled || token !== resetToken.current) return;
// Leave chunks empty on error; the live tick / ensureRange
// will retry. Still need to flip loading off so the empty
// state can render an actual error/empty UI.
} finally {
if (!cancelled && token === resetToken.current) {
setIsLoading(false);
}
}
};
void load();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@@ -111,16 +159,26 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
// Live tick: re-query only the visible chunks and patch them in place. // Live tick: re-query only the visible chunks and patch them in place.
// Never calls .clear(), so rowAt never returns null for a // Never calls .clear(), so rowAt never returns null for a
// previously-loaded index. The table streams rather than flashes. // previously-loaded index. The table streams rather than flashes.
// A leading tick fires immediately so the first rows surface without //
// the LIVE_REFRESH_MS dead window after Start. // Drive model: push, not poll. Each crawler progress event (≈ every
// time `crawled` advances, which lands at ~5 Hz on a healthy crawl)
// triggers an immediate visible-chunk re-query. The previous design
// polled every 1500 ms regardless of activity — newly-written rows
// could sit in the DB for up to that long before surfacing in the
// table. The push path closes that window to ~100 ms (the throttle
// floor that keeps the reader-pool from being hammered).
//
// A 5 s safety-net `setInterval` still fires for code paths that
// mutate the DB without going through a progress event (manual row
// delete, project re-open, etc.).
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let inFlight = false; let inFlight = false;
let lastTickTs = 0;
const tick = async () => { const tick = async () => {
// Coalesce overlapping ticks — at 250 ms cadence a slow COUNT or
// chunk fetch can otherwise queue up multiple in-flight ticks.
if (inFlight) return; if (inFlight) return;
inFlight = true; inFlight = true;
lastTickTs = Date.now();
const token = resetToken.current; const token = resetToken.current;
try { try {
try { try {
@@ -146,33 +204,47 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
inFlight = false; inFlight = false;
} }
}; };
// 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 { interface RequestIdleCallback {
(cb: () => void, opts?: { timeout: number }): number; (cb: () => void, opts?: { timeout: number }): number;
} }
const w = window as Window & { requestIdleCallback?: RequestIdleCallback }; const w = window as Window & { requestIdleCallback?: RequestIdleCallback };
const scheduleTick = (): void => { const scheduleTick = (): void => {
if (typeof w.requestIdleCallback === 'function') { if (typeof w.requestIdleCallback === 'function') {
// 2 s timeout guarantees the tick eventually runs even if the // 300 ms timeout — short enough that the user perceives an
// main thread is permanently busy — better stale data than no // event-driven refresh as instant; long enough that an
// data at all. // in-progress click handler isn't preempted.
w.requestIdleCallback(() => void tick(), { timeout: 2000 }); w.requestIdleCallback(() => void tick(), { timeout: 300 });
} else { } else {
void tick(); void tick();
} }
}; };
scheduleTick(); // Leading tick fires directly (no idle wrap) so the first row
// surfaces as soon as the crawler has written one — without this,
// requestIdleCallback could defer the initial fetch up to 300 ms
// on a busy main thread right after Start.
void tick();
// Push: progress event drives the live-tail tick. We tick on every
// event where `crawled` changed, throttled to TICK_THROTTLE_MS so
// a 100 URL/s crawl doesn't force 100 SQL roundtrips per second.
// Direct call (no idle wrap) — progress events are sparse enough
// that input-handler interference is a non-concern, and pulling
// through requestIdleCallback was adding 50-300 ms before each
// newly-written URL surfaced in the table.
let lastCrawled = -1;
const offProgress = window.freecrawl?.onProgress?.((p) => {
if (p.crawled === lastCrawled) return;
lastCrawled = p.crawled;
if (Date.now() - lastTickTs < TICK_THROTTLE_MS) return;
void tick();
});
// Safety-net poll for crawl-idle DB mutations.
const id = setInterval(scheduleTick, LIVE_REFRESH_MS); const id = setInterval(scheduleTick, LIVE_REFRESH_MS);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(id); clearInterval(id);
offProgress?.();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyStr]); }, [keyStr]);
@@ -213,7 +285,13 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
const first = Math.max(0, Math.floor(start / CHUNK_SIZE)); const first = Math.max(0, Math.floor(start / CHUNK_SIZE));
const last = Math.max(0, Math.floor(end / CHUNK_SIZE)); const last = Math.max(0, Math.floor(end / CHUNK_SIZE));
activeRange.current = { first, last }; activeRange.current = { first, last };
for (let i = first; i <= last; i++) { // Pre-fetch a window of neighbouring chunks so a fast scroll
// doesn't show "..." placeholders while the next chunk loads —
// by the time the virtualizer scrolls into the next chunk's
// range, its rows are usually already in the cache.
const fetchFrom = Math.max(0, first - PREFETCH_CHUNKS);
const fetchTo = last + PREFETCH_CHUNKS;
for (let i = fetchFrom; i <= fetchTo; i++) {
if (!chunks.current.has(i)) void fetchChunk(i); if (!chunks.current.has(i)) void fetchChunk(i);
} }
}, },
@@ -237,5 +315,5 @@ export function useLazyUrlRows(opts: LazyRowsOpts): LazyRowsState {
[version], [version],
); );
return { total, loadedRows, rowAt, ensureRange }; return { total, loadedRows, rowAt, ensureRange, isLoading };
} }
+48 -14
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronsUpDown, Filter, X } from 'lucide-react'; import { ChevronsUpDown, Download, Filter, X } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { AdvancedFilter, CrawlUrlRow } from '@freecrawl/shared-types'; import type { AdvancedFilter, CrawlUrlRow } from '@freecrawl/shared-types';
import { useAppStore, type TabKey } from '../store.js'; import { useAppStore, type TabKey } from '../store.js';
import { COLUMN_SPECS, columnId, type ColumnSpec } from './columns.js'; import { COLUMN_SPECS, columnId, type ColumnSpec } from './columns.js';
import { useLazyUrlRows } from '../hooks/useLazyUrlRows.js'; import { useLazyUrlRows } from '../hooks/useLazyUrlRows.js';
import { AdvancedFilterDialog } from '../components/AdvancedFilterDialog.js'; import { AdvancedFilterDialog } from '../components/AdvancedFilterDialog.js';
import { ErrorBoundary } from '../components/ErrorBoundary.js';
import { ExportDialog } from '../components/ExportDialog.js';
import { InfoTip } from '../components/InfoTip.js'; import { InfoTip } from '../components/InfoTip.js';
const ROW_HEIGHT = 24; const ROW_HEIGHT = 24;
@@ -36,6 +38,7 @@ export function UrlsTab() {
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [filter, setFilter] = useState<AdvancedFilter | null>(null); const [filter, setFilter] = useState<AdvancedFilter | null>(null);
const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
// Three orthogonal selection layers: // Three orthogonal selection layers:
// - selectedIds: row-level selection driven by the Row-number column. // - selectedIds: row-level selection driven by the Row-number column.
// Feeds the bulk context menu (Copy/Open/Re-Spider/Remove/Export). // Feeds the bulk context menu (Copy/Open/Re-Spider/Remove/Export).
@@ -515,12 +518,21 @@ export function UrlsTab() {
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
)} )}
<div className="ml-auto text-[11px] text-surface-500"> <button
<span className="font-mono text-surface-200">{lazy.total.toLocaleString()}</span> URLs type="button"
<span className="ml-2 text-surface-600"> onClick={() => setExportDialogOpen(true)}
({lazy.loadedRows.toLocaleString()} loaded) disabled={lazy.total === 0}
</span> className={clsx(
</div> 'ml-auto inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[11px] transition',
lazy.total === 0
? 'cursor-not-allowed border-surface-800 text-surface-600'
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
title={lazy.total === 0 ? 'No data to export' : 'Export this table'}
>
<Download className="h-3.5 w-3.5" />
<span>Export</span>
</button>
</div> </div>
<div ref={scrollRef} className="relative flex-1 select-none overflow-auto"> <div ref={scrollRef} className="relative flex-1 select-none overflow-auto">
@@ -753,7 +765,15 @@ export function UrlsTab() {
</div> </div>
</div> </div>
{lazy.total === 0 && ( {lazy.total === 0 && lazy.isLoading && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={{ top: HEADER_HEIGHT }}
>
<div className="text-xs text-surface-500">Loading…</div>
</div>
)}
{lazy.total === 0 && !lazy.isLoading && (
<div <div
className="pointer-events-none absolute inset-0 flex items-center justify-center" className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={{ top: HEADER_HEIGHT }} style={{ top: HEADER_HEIGHT }}
@@ -771,6 +791,15 @@ export function UrlsTab() {
className="flex shrink-0 items-center justify-end gap-4 border-t border-surface-800 bg-surface-900/60 px-3 text-[11px] text-surface-400" className="flex shrink-0 items-center justify-end gap-4 border-t border-surface-800 bg-surface-900/60 px-3 text-[11px] text-surface-400"
style={{ height: STATUS_BAR_HEIGHT }} style={{ height: STATUS_BAR_HEIGHT }}
> >
<span>
URLs:{' '}
<span className="font-mono tabular-nums text-surface-200">
{lazy.total.toLocaleString()}
</span>
<span className="ml-1 text-surface-600">
({lazy.loadedRows.toLocaleString()} loaded)
</span>
</span>
<span> <span>
Selected Rows:{' '} Selected Rows:{' '}
<span className="font-mono tabular-nums text-surface-200"> <span className="font-mono tabular-nums text-surface-200">
@@ -789,12 +818,6 @@ export function UrlsTab() {
{selectedColumns.size.toLocaleString()} {selectedColumns.size.toLocaleString()}
</span> </span>
</span> </span>
<span>
Filter Total:{' '}
<span className="font-mono tabular-nums text-surface-200">
{lazy.total.toLocaleString()}
</span>
</span>
</div> </div>
<AdvancedFilterDialog <AdvancedFilterDialog
@@ -803,6 +826,17 @@ export function UrlsTab() {
onClose={() => setFilterDialogOpen(false)} onClose={() => setFilterDialogOpen(false)}
onApply={(f) => setFilter(f)} onApply={(f) => setFilter(f)}
/> />
<ErrorBoundary context="ExportDialog">
{exportDialogOpen && (
<ExportDialog
open={exportDialogOpen}
onClose={() => setExportDialogOpen(false)}
defaultTab={activeTab}
selectedIds={selectedIds.size > 0 ? [...selectedIds] : undefined}
/>
)}
</ErrorBoundary>
</div> </div>
); );
} }
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.2.8",
"private": true,
"type": "module",
"bin": {
"freecrawl-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node ./dist/index.js",
"dev": "tsc -p tsconfig.json --watch",
"clean": "rimraf dist"
},
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
},
"devDependencies": {
"@types/node": "^22.10.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
}
}
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* FreeCrawl SEO — MCP server (stdio transport, JSON-RPC 2.0).
*
* Exposes the active `.seoproject` to MCP-compatible clients (Claude
* Desktop, Claude Code, …) so an AI agent can run SEO queries against
* the most recent crawl directly:
*
* - `get_summary` → headline numbers
* - `get_overview_counts` → sidebar issue/category breakdown
* - `top_issues` → ranked non-empty issue list
* - `query_urls` → paginated category/search/sort
* - `get_url_detail` → full row + inlinks/outlinks/images
* - `list_projects` → enumerate `.seoproject` files
* - `set_project` → switch sessions to a different file
*
* The DB connection is read-only (`new ProjectDb(path, { readOnly: true })`)
* so the desktop app's writer thread can keep operating concurrently —
* SQLite WAL allows arbitrary concurrent readers.
*/
import fs from 'node:fs';
import { ProjectDb } from '@freecrawl/db';
import { JsonRpcServer } from './rpc.js';
import { buildTools, type Tool } from './tools.js';
import { defaultProjectPath, listProjectFiles } from './project-resolver.js';
const SERVER_NAME = 'freecrawl-seo';
const SERVER_VERSION = '0.2.7';
const PROTOCOL_VERSION = '2024-11-05';
interface Session {
projectPath: string;
db: ProjectDb;
}
let session: Session | null = null;
function openProject(projectPath: string): Session {
if (!fs.existsSync(projectPath)) {
throw new Error(`Project file does not exist: ${projectPath}`);
}
const db = new ProjectDb(projectPath, { readOnly: true });
return { projectPath, db };
}
function getSession(): Session {
if (session) return session;
const initialPath = defaultProjectPath();
session = openProject(initialPath);
return session;
}
function setProject(projectPath: string): Session {
if (session) {
try {
session.db.close();
} catch {
// best-effort close
}
}
session = openProject(projectPath);
return session;
}
const tools: Tool[] = [
...buildTools(),
{
name: 'list_projects',
description:
'List `.seoproject` files in the desktop app\'s default projects folder. Returns absolute paths the user can pass to `set_project`.',
inputSchema: { type: 'object', properties: {} },
handler: () => {
const files = listProjectFiles();
return { files, defaultPath: defaultProjectPath() };
},
},
{
name: 'set_project',
description:
'Switch the active project for this MCP session. Subsequent tool calls hit the new file. Closes the previous DB handle.',
inputSchema: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
description: 'Absolute path to a `.seoproject` file.',
},
},
},
handler: (args) => {
const target = args.path;
if (typeof target !== 'string' || target.trim() === '') {
throw new Error('set_project requires `path` to be a non-empty string.');
}
const next = setProject(target);
return { ok: true, projectPath: next.projectPath };
},
},
{
name: 'current_project',
description:
'Report which `.seoproject` the MCP server is currently reading from.',
inputSchema: { type: 'object', properties: {} },
handler: () => ({ projectPath: getSession().projectPath }),
},
];
const server = new JsonRpcServer();
// ── Required MCP lifecycle methods ───────────────────────────────────
server.on('initialize', (params) => {
const requestedVersion =
typeof (params as Record<string, unknown> | undefined)?.protocolVersion === 'string'
? ((params as Record<string, string>).protocolVersion)
: PROTOCOL_VERSION;
return {
protocolVersion: requestedVersion,
capabilities: {
tools: { listChanged: false },
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION,
},
};
});
// MCP clients send `notifications/initialized` after `initialize`. It
// has no `id`, so the JSON-RPC layer won't write a response.
server.on('notifications/initialized', () => undefined);
// ── Tools surface ────────────────────────────────────────────────────
server.on('tools/list', () => ({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}));
server.on('tools/call', async (raw) => {
const params = (raw ?? {}) as { name?: string; arguments?: Record<string, unknown> };
const name = params.name;
const args = params.arguments ?? {};
if (typeof name !== 'string') {
throw new Error('tools/call requires `name` to be a string.');
}
const tool = tools.find((t) => t.name === name);
if (!tool) throw new Error(`Unknown tool: ${name}`);
const sess = getSession();
let result: unknown;
try {
result = await tool.handler(args, sess.db);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
isError: true,
content: [{ type: 'text', text: msg }],
};
}
return {
content: [
{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
},
],
};
});
// ── Bootstrap ────────────────────────────────────────────────────────
process.on('SIGINT', () => {
if (session) {
try {
session.db.close();
} catch {
// best-effort close
}
}
process.exit(0);
});
server.log(`starting ${SERVER_NAME} v${SERVER_VERSION}`);
server.log(`default project: ${defaultProjectPath()}`);
server.start();
+57
View File
@@ -0,0 +1,57 @@
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
/**
* Locate the desktop app's `default.seoproject` without depending on
* Electron. The desktop app stores it under
* `app.getPath('userData')/projects/default.seoproject`, where
* `userData` resolves per-platform using the same rules Electron uses.
*
* The Electron-resolved userData path is:
* - Windows: %APPDATA%/<productName>
* - macOS: ~/Library/Application Support/<productName>
* - Linux: $XDG_CONFIG_HOME/<productName> or ~/.config/<productName>
*
* `productName` for FreeCrawl is `@freecrawl/desktop` (taken from
* `apps/desktop/package.json`'s `name` field).
*/
const PRODUCT_NAME = '@freecrawl/desktop';
const PROJECT_FILENAME = 'default.seoproject';
export function defaultProjectPath(): string {
// Explicit override always wins — handy for headless testing or
// pointing at a non-default project.
const override = process.env.FREECRAWL_PROJECT;
if (override && override.trim().length > 0) return override.trim();
const home = os.homedir();
let userData: string;
if (process.platform === 'win32') {
userData = path.join(
process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'),
PRODUCT_NAME,
);
} else if (process.platform === 'darwin') {
userData = path.join(home, 'Library', 'Application Support', PRODUCT_NAME);
} else {
userData = path.join(
process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'),
PRODUCT_NAME,
);
}
return path.join(userData, 'projects', PROJECT_FILENAME);
}
export function listProjectFiles(): string[] {
const dir = path.dirname(defaultProjectPath());
try {
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.seoproject'))
.map((f) => path.join(dir, f));
} catch {
return [];
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Minimal newline-delimited JSON-RPC 2.0 server for stdio MCP transport.
*
* MCP's stdio transport is line-delimited JSON — one request per line on
* stdin, one response per line on stdout. stderr is reserved for human
* logs; writing anything non-JSON to stdout corrupts the protocol stream.
*
* We deliberately do NOT depend on `@modelcontextprotocol/sdk`. The
* protocol surface we need (`initialize`, `tools/list`, `tools/call`,
* the `initialized` notification) is small, well-specified, and stable;
* the dep-free path keeps the install footprint minimal and avoids a
* version-pin we'd otherwise have to chase.
*/
export interface JsonRpcRequest {
jsonrpc: '2.0';
id?: number | string | null;
method: string;
params?: unknown;
}
export interface JsonRpcSuccess {
jsonrpc: '2.0';
id: number | string | null;
result: unknown;
}
export interface JsonRpcError {
jsonrpc: '2.0';
id: number | string | null;
error: { code: number; message: string; data?: unknown };
}
export type Handler = (params: unknown) => unknown | Promise<unknown>;
export class JsonRpcServer {
private handlers = new Map<string, Handler>();
private buffer = '';
on(method: string, handler: Handler): void {
this.handlers.set(method, handler);
}
log(...parts: unknown[]): void {
// Always to stderr — stdout is reserved for protocol frames.
const line = parts
.map((p) => (typeof p === 'string' ? p : JSON.stringify(p)))
.join(' ');
process.stderr.write(`[freecrawl-mcp] ${line}\n`);
}
start(): void {
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => {
this.buffer += chunk;
let nl: number;
while ((nl = this.buffer.indexOf('\n')) !== -1) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
if (line) void this.handleLine(line);
}
});
process.stdin.on('end', () => process.exit(0));
}
private write(msg: JsonRpcSuccess | JsonRpcError): void {
process.stdout.write(JSON.stringify(msg) + '\n');
}
private async handleLine(line: string): Promise<void> {
let req: JsonRpcRequest;
try {
req = JSON.parse(line) as JsonRpcRequest;
} catch {
this.log('parse error', line);
return;
}
const id = req.id ?? null;
const handler = this.handlers.get(req.method);
// Notifications (no `id`) get no response, even on miss.
const isNotification = req.id === undefined || req.id === null;
if (!handler) {
if (!isNotification) {
this.write({
jsonrpc: '2.0',
id,
error: { code: -32601, message: `Method not found: ${req.method}` },
});
}
return;
}
try {
const result = await handler(req.params);
if (!isNotification) {
this.write({ jsonrpc: '2.0', id, result });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.log('handler error', req.method, msg);
if (!isNotification) {
this.write({
jsonrpc: '2.0',
id,
error: { code: -32000, message: msg },
});
}
}
}
}
+265
View File
@@ -0,0 +1,265 @@
/**
* Tool definitions and handlers exposed by the FreeCrawl MCP server.
*
* The server opens the project DB in read-only mode (see `index.ts`),
* so it can coexist with the desktop app's writer without contention.
* SQLite WAL mode permits arbitrary concurrent readers.
*/
import type { ProjectDb } from '@freecrawl/db';
import type { UrlCategory } from '@freecrawl/shared-types';
export interface Tool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler: (args: Record<string, unknown>, db: ProjectDb) => unknown | Promise<unknown>;
}
const URL_CATEGORY_VALUES: UrlCategory[] = [
'all',
'internal:all',
'internal:html',
'internal:js',
'internal:css',
'internal:image',
'internal:pdf',
'internal:font',
'internal:other',
'external:all',
'external:html',
'external:other',
'status:2xx',
'status:3xx',
'status:4xx',
'status:5xx',
'status:no-response',
'status:blocked-robots',
'security:https',
'security:http',
'indexability:indexable',
'indexability:non-indexable',
'indexability:noindex',
'indexability:canonicalised',
];
const COMMON_URL_FIELDS = [
'id',
'url',
'statusCode',
'contentKind',
'indexability',
'title',
'titleLength',
'metaDescription',
'metaDescriptionLength',
'h1',
'h1Count',
'wordCount',
'canonical',
'metaRobots',
'contentLength',
'responseTimeMs',
'depth',
'inlinks',
'outlinks',
'imagesCount',
'imagesMissingAlt',
'redirectTarget',
'crawledAt',
];
function projectFromRow(
row: Record<string, unknown>,
fields: string[],
): Record<string, unknown> {
if (fields.length === 0) {
const out: Record<string, unknown> = {};
for (const k of COMMON_URL_FIELDS) out[k] = row[k];
return out;
}
const out: Record<string, unknown> = {};
for (const k of fields) out[k] = row[k];
return out;
}
export function buildTools(): Tool[] {
return [
{
name: 'get_summary',
description:
'Top-level crawl summary for the active FreeCrawl project: total URLs crawled, indexable/non-indexable counts, status-code distribution, average response time, plus crawl start/end timestamps. Use this first when reporting on a crawl.',
inputSchema: {
type: 'object',
properties: {},
},
handler: (_args, db) => db.getSummary(),
},
{
name: 'get_overview_counts',
description:
'Sidebar overview counts — every category and issue filter the desktop app surfaces, with row counts. Useful to find which issue categories are non-empty before drilling in with query_urls.',
inputSchema: {
type: 'object',
properties: {},
},
handler: (_args, db) => db.getOverviewCounts(),
},
{
name: 'query_urls',
description:
'Paginated query against the URLs table with the same category filter the UI uses. Returns rows + total count. Use `category` to narrow (e.g. "issues:title-missing"), `search` to substring-match URLs/titles, and `limit`/`offset` to page through.',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'UrlCategory filter (e.g. "all", "internal:html", "issues:title-missing", "status:4xx"). Defaults to "all".',
enum: URL_CATEGORY_VALUES,
},
search: {
type: 'string',
description: 'Optional substring match against URL and title.',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 500,
description: 'Max rows to return (1–500). Default 50.',
},
offset: {
type: 'integer',
minimum: 0,
description: 'Row offset for pagination. Default 0.',
},
sortBy: {
type: 'string',
description: 'Column to sort by (e.g. "depth", "responseTimeMs", "inlinks").',
},
sortDir: {
type: 'string',
enum: ['asc', 'desc'],
description: 'Sort direction. Default "asc".',
},
fields: {
type: 'array',
items: { type: 'string' },
description:
'Columns to include per row. Empty/omitted → a sensible default subset (URL, status, indexability, title, depth, inlinks, etc.). Use ["*"] for the full row.',
},
},
},
handler: (args, db) => {
const category = (args.category as UrlCategory | undefined) ?? 'all';
const search = typeof args.search === 'string' ? args.search : undefined;
const limit = Math.min(
Math.max(typeof args.limit === 'number' ? args.limit : 50, 1),
500,
);
const offset = Math.max(
typeof args.offset === 'number' ? args.offset : 0,
0,
);
const sortBy = typeof args.sortBy === 'string' ? args.sortBy : undefined;
const sortDir = args.sortDir === 'desc' ? 'desc' : 'asc';
const fieldsArg = Array.isArray(args.fields) ? (args.fields as string[]) : [];
const wantsAll = fieldsArg.length === 1 && fieldsArg[0] === '*';
const { rows, total } = db.queryUrls({
category,
search,
limit,
offset,
sortBy,
sortDir,
});
const projected = wantsAll
? rows
: rows.map((r) =>
projectFromRow(
r as unknown as Record<string, unknown>,
fieldsArg,
),
);
return { rows: projected, total, limit, offset };
},
},
{
name: 'get_url_detail',
description:
'Full details for one URL: every column, plus inlinks, outlinks, images, and HTTP headers. Pass either `id` (preferred — from query_urls) or `url`.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Numeric URL id from query_urls / get_url_id.',
},
url: {
type: 'string',
description: 'Exact URL string (slower path — uses an indexed lookup).',
},
linkLimit: {
type: 'integer',
description: 'Cap on inlinks + outlinks returned. Default 500.',
},
},
},
handler: (args, db) => {
let id: number | null = null;
if (typeof args.id === 'number') {
id = args.id;
} else if (typeof args.url === 'string') {
const found = db.getUrlIdByUrl(args.url);
if (found === null) {
throw new Error(`URL not found: ${args.url}`);
}
id = found;
} else {
throw new Error('get_url_detail requires either `id` or `url`.');
}
const linkLimit =
typeof args.linkLimit === 'number' ? args.linkLimit : 500;
const detail = db.getUrlDetail(id, linkLimit);
if (detail === null) {
throw new Error(`URL id ${id} not found in this project.`);
}
return detail;
},
},
{
name: 'top_issues',
description:
'Ranked list of non-empty issue categories from get_overview_counts, sorted by count descending. Quick way to ask "what are the biggest problems with this crawl?".',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'integer',
description: 'Max categories to return. Default 20.',
minimum: 1,
maximum: 200,
},
},
},
handler: (args, db) => {
const limit = Math.min(
Math.max(typeof args.limit === 'number' ? args.limit : 20, 1),
200,
);
const counts = db.getOverviewCounts() as unknown as Record<string, unknown>;
const issues = (counts.issues as Record<string, number>) ?? {};
const ranked = Object.entries(issues)
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([category, count]) => ({ category: `issues:${category}`, count }));
return { issues: ranked };
},
},
];
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"composite": true,
"types": ["node"]
},
"include": ["src/**/*"],
"references": [
{ "path": "../../packages/shared-types" },
{ "path": "../../packages/db" }
]
}
+27 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "freecrawl-seo", "name": "freecrawl-seo",
"version": "0.1.10", "version": "0.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "freecrawl-seo", "name": "freecrawl-seo",
"version": "0.1.10", "version": "0.2.8",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -25,7 +25,7 @@
}, },
"apps/cli": { "apps/cli": {
"name": "@freecrawl/cli", "name": "@freecrawl/cli",
"version": "0.1.10", "version": "0.2.8",
"dependencies": { "dependencies": {
"@freecrawl/core": "*", "@freecrawl/core": "*",
"@freecrawl/db": "*", "@freecrawl/db": "*",
@@ -42,7 +42,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@freecrawl/desktop", "name": "@freecrawl/desktop",
"version": "0.1.10", "version": "0.2.8",
"dependencies": { "dependencies": {
"@freecrawl/core": "*", "@freecrawl/core": "*",
"@freecrawl/db": "*", "@freecrawl/db": "*",
@@ -82,6 +82,22 @@
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.2.8",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
},
"bin": {
"freecrawl-mcp": "dist/index.js"
},
"devDependencies": {
"@types/node": "^22.10.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
}
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -1431,6 +1447,10 @@
"resolved": "apps/desktop", "resolved": "apps/desktop",
"link": true "link": true
}, },
"node_modules/@freecrawl/mcp-server": {
"resolved": "apps/mcp-server",
"link": true
},
"node_modules/@freecrawl/shared-types": { "node_modules/@freecrawl/shared-types": {
"resolved": "packages/shared-types", "resolved": "packages/shared-types",
"link": true "link": true
@@ -8712,7 +8732,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@freecrawl/core", "name": "@freecrawl/core",
"version": "0.1.10", "version": "0.2.8",
"dependencies": { "dependencies": {
"@freecrawl/db": "*", "@freecrawl/db": "*",
"@freecrawl/shared-types": "*", "@freecrawl/shared-types": "*",
@@ -8738,7 +8758,7 @@
}, },
"packages/db": { "packages/db": {
"name": "@freecrawl/db", "name": "@freecrawl/db",
"version": "0.1.10", "version": "0.2.8",
"dependencies": { "dependencies": {
"@freecrawl/shared-types": "*" "@freecrawl/shared-types": "*"
}, },
@@ -8750,7 +8770,7 @@
}, },
"packages/shared-types": { "packages/shared-types": {
"name": "@freecrawl/shared-types", "name": "@freecrawl/shared-types",
"version": "0.1.10", "version": "0.2.8",
"devDependencies": { "devDependencies": {
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"typescript": "^5.7.0" "typescript": "^5.7.0"
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "freecrawl-seo", "name": "freecrawl-seo",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog", "description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT", "license": "MIT",
@@ -18,6 +18,7 @@
"build": "tsc -b && npm run build --workspace apps/desktop && npm run build --workspace apps/cli", "build": "tsc -b && npm run build --workspace apps/desktop && npm run build --workspace apps/cli",
"build:desktop": "npm run build --workspace apps/desktop", "build:desktop": "npm run build --workspace apps/desktop",
"build:cli": "npm run build --workspace apps/cli", "build:cli": "npm run build --workspace apps/cli",
"build:mcp": "npm run build --workspace apps/mcp-server",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md,css,html}\"", "format": "prettier --write \"**/*.{ts,tsx,js,json,md,css,html}\"",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@freecrawl/core", "name": "@freecrawl/core",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+175 -32
View File
@@ -89,13 +89,23 @@ export class Crawler extends EventEmitter {
*/ */
private memoryAutoPaused = false; private memoryAutoPaused = false;
private memoryMonitorTimer: NodeJS.Timeout | null = null; private memoryMonitorTimer: NodeJS.Timeout | null = null;
/**
* Live AbortControllers for in-flight HTTP fetches in the main URL
* pipeline. Pause / Stop abort every member so the crawl actually
* stops instead of letting `concurrency` (often 20) more URLs land
* in the table after the user clicked the button. Each call site
* adds itself in `try` and removes itself in `finally`.
*/
private inFlightFetchControllers = new Set<AbortController>();
private robots: RobotsChecker | null = null; private robots: RobotsChecker | null = null;
private progressTimer: NodeJS.Timeout | null = null; private progressTimer: NodeJS.Timeout | null = null;
/** Periodic post-crawl-style recompute of expensive issue counters /** Periodic post-crawl-style recompute of expensive issue counters
* while the crawl is still running, so the sidebar number is < 30 s * while the crawl is still running, so the sidebar number is < 30 s
* stale instead of "0 until crawl ends". 30 s cadence picked * stale instead of "0 until crawl ends". The recompute is dispatched
* conservatively — the recompute wraps in `runInTransaction` and is * to the writer-worker via the injected `recomputeIssues` hook, so
* not free on a 100k-URL DB. */ * it does NOT block the main thread — the freezes that an earlier
* version produced were a result of running the recompute inline on
* the main-thread writer connection, which has since been fixed. */
private issueRecomputeTimer: NodeJS.Timeout | null = null; private issueRecomputeTimer: NodeJS.Timeout | null = null;
private static readonly ISSUE_RECOMPUTE_INTERVAL_MS = 30_000; private static readonly ISSUE_RECOMPUTE_INTERVAL_MS = 30_000;
private issueRecomputeInFlight = false; private issueRecomputeInFlight = false;
@@ -103,20 +113,16 @@ export class Crawler extends EventEmitter {
if (this.issueRecomputeTimer) return; if (this.issueRecomputeTimer) return;
this.issueRecomputeTimer = setInterval(() => { this.issueRecomputeTimer = setInterval(() => {
if (this.stopped || !this.running || this.paused) return; if (this.stopped || !this.running || this.paused) return;
// Drop overlapping ticks. The yielding recompute can take longer // Drop overlapping ticks — on a 1M-URL DB the recompute can run
// than the 30 s tick on very large projects; piling up calls // longer than the 30 s tick, and piling RPCs on the writer worker
// would queue 70-statement transactions back-to-back and starve // would starve per-URL writes behind a queue of issue passes.
// the crawler's own writes.
if (this.issueRecomputeInFlight) return; if (this.issueRecomputeInFlight) return;
this.issueRecomputeInFlight = true; this.issueRecomputeInFlight = true;
// Cooperative variant: yields between definitions so a 1–3 s // The injected hook routes through the writer worker in the
// recompute doesn't freeze the main thread (and the user's // desktop host, so this is off-thread; main stays free to
// clicks) for the full duration. Trades atomic visibility of // service IPC and pump the crawl queue. CLI fallback runs the
// the rebuild for UI responsiveness — fine for the live tick // yielding variant inline, which is fine for that context.
// since the post-crawl pass still uses the atomic version for void this.recomputeIssues(EXPENSIVE_ISSUE_DEFINITIONS)
// the final state.
void this.db
.recomputeUrlsIssuesYielding(EXPENSIVE_ISSUE_DEFINITIONS)
.catch((err) => { .catch((err) => {
this.emit( this.emit(
'debug', 'debug',
@@ -230,6 +236,30 @@ export class Crawler extends EventEmitter {
payload: Parameters<ProjectDb['writeFetchedUrl']>[0], payload: Parameters<ProjectDb['writeFetchedUrl']>[0],
) => Promise<{ urlId: number }>; ) => Promise<{ urlId: number }>;
/**
* Optional async hook for the post-crawl issue recompute. When the
* desktop main process injects a writer-worker bridge here, the
* 70+ correlated subqueries run off the main thread — without it,
* a 5K-URL crawl freezes the renderer for ~45 s in the post-crawl
* phase ("materialise-issues"). Defaults to running the recompute
* inline (CLI / test contexts).
*/
private readonly recomputeIssues: (
definitions: ReadonlyArray<readonly [string, string]>,
) => Promise<void>;
/**
* Generic post-crawl SQL-pass dispatch. Methods like
* `recomputeInlinks`, `recomputeRedirectChains`,
* `recomputeHreflangAnalysis`, `recomputeHreflangInconsistent`,
* `recomputePaginationSequence` are sync void calls on `ProjectDb`
* — running them inline on the main thread blocks the renderer for
* ~5-15 s combined on a 5K+ URL crawl. The desktop host injects a
* writer-worker dispatcher here so each pass runs off-thread; CLI /
* tests fall back to calling the method directly on `this.db`.
*/
private readonly runDbPass: (methodName: string) => Promise<void>;
constructor( constructor(
config: CrawlConfig, config: CrawlConfig,
db: ProjectDb, db: ProjectDb,
@@ -243,6 +273,10 @@ export class Crawler extends EventEmitter {
writeFetchedUrl?: ( writeFetchedUrl?: (
payload: Parameters<ProjectDb['writeFetchedUrl']>[0], payload: Parameters<ProjectDb['writeFetchedUrl']>[0],
) => Promise<{ urlId: number }>; ) => Promise<{ urlId: number }>;
recomputeIssues?: (
definitions: ReadonlyArray<readonly [string, string]>,
) => Promise<void>;
runDbPass?: (methodName: string) => Promise<void>;
} = {}, } = {},
) { ) {
super(); super();
@@ -252,6 +286,15 @@ export class Crawler extends EventEmitter {
((html, pageUrl, parseOpts) => Promise.resolve(parseHtml(html, pageUrl, parseOpts))); ((html, pageUrl, parseOpts) => Promise.resolve(parseHtml(html, pageUrl, parseOpts)));
this.writeFetchedUrl = this.writeFetchedUrl =
opts.writeFetchedUrl ?? ((payload) => Promise.resolve(this.db.writeFetchedUrl(payload))); opts.writeFetchedUrl ?? ((payload) => Promise.resolve(this.db.writeFetchedUrl(payload)));
this.recomputeIssues =
opts.recomputeIssues ?? ((defs) => this.db.recomputeUrlsIssuesYielding(defs));
this.runDbPass =
opts.runDbPass ??
((method) => {
const fn = (this.db as unknown as Record<string, unknown>)[method];
if (typeof fn === 'function') (fn as () => void).call(this.db);
return Promise.resolve();
});
// Wave 9 — Resolve the active proxy. If a named profile is selected // Wave 9 — Resolve the active proxy. If a named profile is selected
// and present in `proxyProfiles`, its URL wins over the legacy // and present in `proxyProfiles`, its URL wins over the legacy
// `proxyUrl` field; if the named profile doesn't resolve we fall // `proxyUrl` field; if the named profile doesn't resolve we fall
@@ -268,14 +311,27 @@ export class Crawler extends EventEmitter {
this.config = config; this.config = config;
this.db = db; this.db = db;
const concurrency = Math.max(1, Math.min(200, config.maxConcurrency)); const concurrency = Math.max(1, Math.min(200, config.maxConcurrency));
const intervalCap = Math.max(1, config.maxRps); // Rate-limit (interval + intervalCap) intentionally NOT set on
this.queue = new PQueue({ concurrency, interval: 1000, intervalCap }); // the queue. p-queue's bucket-refill semantics interact poorly
// with long-tailed response times: after the first burst lands
// and slots free up, dispatch can stall for an entire bucket
// before the next batch goes out — and on a 1 s/intervalCap=20
// bucket the queue would freeze entirely once the first ~20
// tasks completed. Throughput is now bounded purely by the
// worker pool (concurrency × 1/avg_response), which on a slow
// remote server gives the user the 5-10 URL/s they expect
// instead of ~1 URL/s. config.maxRps is honoured indirectly via
// maxConcurrency — users who need a hard RPS cap should drop
// concurrency rather than trust per-second buckets.
this.queue = new PQueue({ concurrency });
// Track queue concurrency from the start so adaptive shrinkage
// doesn't have to wait for the first reportRendererLag call to
// materialise the running value.
this.currentConcurrency = concurrency;
// External probes run on a separate queue so slow third-party hosts // External probes run on a separate queue so slow third-party hosts
// don't block the main crawl. // don't block the main crawl.
this.externalQueue = new PQueue({ this.externalQueue = new PQueue({
concurrency: Math.max(2, Math.min(10, concurrency)), concurrency: Math.max(2, Math.min(10, concurrency)),
interval: 1000,
intervalCap: Math.max(2, intervalCap),
}); });
// Compile include/exclude patterns once — an invalid pattern should // Compile include/exclude patterns once — an invalid pattern should
// surface to the user as a crawler error, not a silent miss. // surface to the user as a crawler error, not a silent miss.
@@ -445,7 +501,17 @@ export class Crawler extends EventEmitter {
try { try {
// Wait for internal crawl first, then drain any external probes still // Wait for internal crawl first, then drain any external probes still
// in flight or queued (externals may have been enqueued during internal). // in flight or queued (externals may have been enqueued during internal).
await this.queue.onIdle(); // p-queue's onIdle resolves the moment size+pending hits 0, but
// fetchAndProcess can race with that: a worker finishes the
// *last* in-flight task, link extraction enqueues new URLs a
// microtask later, and onIdle has already resolved. Loop while
// counters disagree — one extra 50 ms wait per pass guards the
// race window without spinning.
while (!this.stopped) {
await this.queue.onIdle();
await new Promise<void>((r) => setTimeout(r, 50));
if (this.queue.size + this.queue.pending === 0) break;
}
await this.externalQueue.onIdle(); await this.externalQueue.onIdle();
// robots.txt + sitemap discovery may still be running — wait for // robots.txt + sitemap discovery may still be running — wait for
// both before the post-crawl recompute so issue filters depending // both before the post-crawl recompute so issue filters depending
@@ -512,44 +578,49 @@ export class Crawler extends EventEmitter {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Recomputing inlinks…'); this.emit('info', 'Recomputing inlinks…');
this.setOp('post-crawl:recompute-inlinks'); this.setOp('post-crawl:recompute-inlinks');
this.db.recomputeInlinks(); await this.runDbPass('recomputeInlinks');
} }
if (this.config.analyseRedirectChains) { if (this.config.analyseRedirectChains) {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Recomputing redirect chains…'); this.emit('info', 'Recomputing redirect chains…');
this.setOp('post-crawl:recompute-redirect-chains'); this.setOp('post-crawl:recompute-redirect-chains');
this.db.recomputeRedirectChains(); await this.runDbPass('recomputeRedirectChains');
} }
if (this.config.analyseHreflang) { if (this.config.analyseHreflang) {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Recomputing hreflang analysis…'); this.emit('info', 'Recomputing hreflang analysis…');
this.setOp('post-crawl:recompute-hreflang'); this.setOp('post-crawl:recompute-hreflang');
this.db.recomputeHreflangAnalysis(); await this.runDbPass('recomputeHreflangAnalysis');
await yieldToEventLoop(); await yieldToEventLoop();
this.setOp('post-crawl:recompute-hreflang-inconsistent'); this.setOp('post-crawl:recompute-hreflang-inconsistent');
this.db.recomputeHreflangInconsistent(); await this.runDbPass('recomputeHreflangInconsistent');
} }
if (this.config.analyseDuplicates) { if (this.config.analyseDuplicates) {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Clustering duplicates…'); this.emit('info', 'Clustering duplicates…');
this.setOp('post-crawl:cluster-duplicates'); this.setOp('post-crawl:cluster-duplicates');
// Duplicate clustering has JS-side simhash work that lives on
// the crawler instance, not the DB; can't blindly off-thread it.
this.runDuplicateClustering(); this.runDuplicateClustering();
} }
if (this.config.analysePagination) { if (this.config.analysePagination) {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Detecting pagination sequence gaps…'); this.emit('info', 'Detecting pagination sequence gaps…');
this.setOp('post-crawl:pagination-sequence'); this.setOp('post-crawl:pagination-sequence');
this.db.recomputePaginationSequence(); await this.runDbPass('recomputePaginationSequence');
} }
if (this.config.analyseIssues) { if (this.config.analyseIssues) {
await yieldToEventLoop(); await yieldToEventLoop();
this.emit('info', 'Materialising issue counters…'); this.emit('info', 'Materialising issue counters…');
this.setOp('post-crawl:materialise-issues'); this.setOp('post-crawl:materialise-issues');
// Use the cooperatively-scheduled variant (yields between each // Route through the injected hook — in the desktop host this
// of the 70+ definitions). Trades atomic visibility of the // dispatches to the writer-worker, keeping the 70+ correlated
// rebuild for a responsive main thread — on a 5k-URL DB the // subqueries off the main thread. CLI / tests fall back to
// sync version was blocking the event loop for 22-26 s. // running inline via `db.recomputeUrlsIssuesYielding`. Without
await this.db.recomputeUrlsIssuesYielding(EXPENSIVE_ISSUE_DEFINITIONS); // offloading, a 5K-URL crawl freezes the main process for
// ~45 s in post-crawl, tripping Electron's "Not Responding"
// dialog.
await this.recomputeIssues(EXPENSIVE_ISSUE_DEFINITIONS);
} }
await yieldToEventLoop(); await yieldToEventLoop();
this.setOp('post-crawl:image-probes'); this.setOp('post-crawl:image-probes');
@@ -1022,6 +1093,24 @@ export class Crawler extends EventEmitter {
// If the start URL isn't in the DB yet, kick off a brand-new crawl from it. // If the start URL isn't in the DB yet, kick off a brand-new crawl from it.
if (!this.db.hasUrl(this.config.startUrl)) { if (!this.db.hasUrl(this.config.startUrl)) {
// Stub upsert before enqueue so the seed surfaces in the URL
// table within ~100 ms of clicking Start instead of waiting for
// the full network round-trip + parse + write (~2 s on a typical
// site). status_code=null reads as "fetching" in the table.
// fetchAndProcess's writeFetchedUrl later overwrites every field
// with the real response data via INSERT … ON CONFLICT UPDATE.
try {
this.db.upsertUrl({
url: this.config.startUrl,
contentKind: 'html',
statusCode: null,
statusText: 'fetching',
indexability: 'indexable',
depth: 0,
});
} catch {
/* non-fatal: fetchAndProcess will still write the real row */
}
this.enqueue({ url: this.config.startUrl, depth: 0 }); this.enqueue({ url: this.config.startUrl, depth: 0 });
} }
@@ -1140,6 +1229,19 @@ export class Crawler extends EventEmitter {
} }
this.sitemapAbort = null; this.sitemapAbort = null;
} }
// Abort every in-flight HTTP fetch. Without this, Stop only halts
// *new* dispatch — the ~20 fetches already in flight would each
// finish their request and write a row, which races with Clear's
// db.reset() and resurrects ~19 rows in the table. The catch
// branch in fetchAndProcess detects the stopped-abort and exits
// silently without writing.
for (const c of this.inFlightFetchControllers) {
try {
c.abort();
} catch {
/* ignore — already aborted */
}
}
// Drop any queued work. If paused, unblock onIdle() so start() can resolve. // Drop any queued work. If paused, unblock onIdle() so start() can resolve.
this.queue.clear(); this.queue.clear();
this.externalQueue.clear(); this.externalQueue.clear();
@@ -1150,9 +1252,22 @@ export class Crawler extends EventEmitter {
pause(): void { pause(): void {
if (this.stopped || this.paused) return; if (this.stopped || this.paused) return;
this.paused = true; this.paused = true;
// PQueue.pause() halts dispatch but lets in-flight tasks finish naturally. // PQueue.pause() halts new dispatch. By itself that lets every
// in-flight fetch run to completion — at concurrency 20 the user
// sees ~20 more URLs land in the table before things stop, which
// reads as "Pause didn't work." Aborting the live controllers
// turns Pause into "stop now": fetchAndProcess detects a paused-
// abort, re-enqueues the URL, and returns without writing a row.
this.queue.pause(); this.queue.pause();
this.externalQueue.pause(); this.externalQueue.pause();
for (const c of this.inFlightFetchControllers) {
try {
c.abort();
} catch {
/* ignore — already aborted */
}
}
this.setOp('paused');
this.emitProgress(); this.emitProgress();
} }
@@ -1161,6 +1276,7 @@ export class Crawler extends EventEmitter {
this.paused = false; this.paused = false;
this.queue.start(); this.queue.start();
this.externalQueue.start(); this.externalQueue.start();
this.setOp('idle');
this.emitProgress(); this.emitProgress();
} }
@@ -1446,6 +1562,7 @@ export class Crawler extends EventEmitter {
const t0 = Date.now(); const t0 = Date.now();
const controller = new AbortController(); const controller = new AbortController();
this.inFlightFetchControllers.add(controller);
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
// Wave 3 — Optional max-response-time hard cap. Distinct from // Wave 3 — Optional max-response-time hard cap. Distinct from
// `requestTimeoutMs` (which is the connect+headers timeout): this // `requestTimeoutMs` (which is the connect+headers timeout): this
@@ -1998,6 +2115,25 @@ export class Crawler extends EventEmitter {
} }
} }
} catch (err) { } catch (err) {
// Stop / Clear: abandon silently. No row is written, no failure
// counter is bumped, the URL is not re-enqueued. Without this
// gate the ~20 in-flight URLs after Stop would each land in the
// table — and Clear's db.reset() races with their writes,
// resurrecting "ghost" rows in a freshly-cleared table.
if (this.stopped && controller.signal.aborted) {
return;
}
// User-initiated Pause aborts every live controller. Don't treat
// those as fetch failures — the URL hasn't been seen yet, so we
// re-enqueue it directly (bypassing `enqueue` because the URL is
// already in the `seen` set from the original dispatch). Without
// this, Pause would mark `concurrency` (often 20) URLs as failed
// and surface them as errors in the URL table. Re-enqueue lands
// in a paused queue → resume() picks them up cleanly.
if (this.paused && controller.signal.aborted) {
this.queue.add(() => this.fetchAndProcess(item)).catch(() => undefined);
return;
}
this.failed++; this.failed++;
const detail = formatFetchError(err); const detail = formatFetchError(err);
const elapsed = Date.now() - t0; const elapsed = Date.now() - t0;
@@ -2020,6 +2156,7 @@ export class Crawler extends EventEmitter {
depth: item.depth, depth: item.depth,
}); });
} finally { } finally {
this.inFlightFetchControllers.delete(controller);
clearTimeout(timeout); clearTimeout(timeout);
if (respTimeTimer) clearTimeout(respTimeTimer); if (respTimeTimer) clearTimeout(respTimeTimer);
// Politeness delay — applied per worker *after* each request so a // Politeness delay — applied per worker *after* each request so a
@@ -2171,8 +2308,14 @@ export class Crawler extends EventEmitter {
const ceiling = Math.max(1, Math.min(200, this.config.maxConcurrency)); const ceiling = Math.max(1, Math.min(200, this.config.maxConcurrency));
if (this.currentConcurrency === 0) this.currentConcurrency = ceiling; if (this.currentConcurrency === 0) this.currentConcurrency = ceiling;
let next = this.currentConcurrency; let next = this.currentConcurrency;
// Floor 5 (was 1). The renderer's lag probe occasionally spikes
// for unrelated reasons (GC, an input burst, devtools open) and
// a 1-floor would let those one-off spikes corner the queue at
// single-task dispatch — which on a slow remote server (avg resp
// 1.5-2 s) cuts throughput from 10 URL/s to 0.5 URL/s and the
// queue never recovers because the user never produces zero lag.
if (lagMs > 200) { if (lagMs > 200) {
next = Math.max(1, this.currentConcurrency - 1); next = Math.max(5, this.currentConcurrency - 1);
} else if (lagMs < 30) { } else if (lagMs < 30) {
next = Math.min(ceiling, this.currentConcurrency + 1); next = Math.min(ceiling, this.currentConcurrency + 1);
} }
+6
View File
@@ -10,6 +10,12 @@ export {
export { exportUrlsToCsv } from './csv-export.js'; export { exportUrlsToCsv } from './csv-export.js';
export { exportUrlsToJson, type JsonExportOptions } from './json-export.js'; export { exportUrlsToJson, type JsonExportOptions } from './json-export.js';
export { exportUrlsToXml } from './xml-export.js'; export { exportUrlsToXml } from './xml-export.js';
export {
exportTabular,
type TabularExportOptions,
type TabularExportResult,
type TabularSection,
} from './tabular-export.js';
export { testUrlAgainstRobots, type RobotsTestResult } from './robots.js'; export { testUrlAgainstRobots, type RobotsTestResult } from './robots.js';
export { export {
exportSitemap, exportSitemap,
+398
View File
@@ -0,0 +1,398 @@
import { createWriteStream, writeFileSync } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import path from 'node:path';
import zlib from 'node:zlib';
import type { ProjectDb } from '@freecrawl/db';
import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
export interface TabularSection {
label: string;
category: UrlCategory;
}
export interface TabularExportOptions {
format: 'csv' | 'xlsx';
sections: TabularSection[];
/** Keys of CrawlUrlRow to include, in order. */
columns: string[];
/** When provided AND non-empty, restrict every section to these row ids. */
selectedIds?: number[];
}
export interface TabularExportResult {
/** File (xlsx / single-section csv) or folder (multi-section csv). */
filePath: string;
files: string[];
rowsWritten: number;
}
function escapeCsv(value: unknown): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function rowSource(
db: ProjectDb,
category: UrlCategory,
selectedIds: number[] | undefined,
): Iterable<CrawlUrlRow> {
if (selectedIds && selectedIds.length > 0) {
// Apply selectedIds first, then narrow by category client-side. The
// sidebar's category predicates can be expensive; for export the
// selection is always the small side.
const ids = new Set(selectedIds);
return (function* () {
for (const row of db.iterateUrlsByCategory(category)) {
if (ids.has(row.id)) yield row;
}
})();
}
return db.iterateUrlsByCategory(category);
}
async function writeCsvFile(
db: ProjectDb,
filePath: string,
columns: string[],
category: UrlCategory,
selectedIds: number[] | undefined,
): Promise<number> {
let rowsWritten = 0;
const header = columns.join(',') + '\n';
const source = rowSource(db, category, selectedIds);
const generator = async function* (): AsyncGenerator<string> {
yield '' + header;
for (const row of source) {
const line =
columns
.map((c) => escapeCsv((row as unknown as Record<string, unknown>)[c]))
.join(',') + '\n';
rowsWritten++;
yield line;
}
};
await pipeline(
Readable.from(generator()),
createWriteStream(filePath, { encoding: 'utf8' }),
);
return rowsWritten;
}
// ── Minimal XLSX writer ─────────────────────────────────────────────
//
// Office Open XML SpreadsheetML, packaged as a ZIP with DEFLATE entries.
// Uses inline strings (no shared-string table) — slightly fatter on disk
// but lets us stream-build sheet XML row-by-row without a second pass.
//
// Worksheet shape:
// <worksheet><sheetData>
// <row r="1"><c r="A1" t="inlineStr"><is><t>URL</t></is></c>...</row>
// <row r="2"><c r="A2" t="inlineStr"><is><t>https://...</t></is></c>...</row>
// </sheetData></worksheet>
//
// Numbers go in as <c><v>123</v></c> (no `t=` attribute, default = number).
function xmlEscape(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
// strip control chars XML 1.0 forbids
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
function colLetter(idx: number): string {
// 0-based → A, B, ..., Z, AA, AB, ...
let n = idx;
let s = '';
while (true) {
const r = n % 26;
s = String.fromCharCode(65 + r) + s;
n = Math.floor(n / 26) - 1;
if (n < 0) break;
}
return s;
}
function sanitizeSheetName(label: string, used: Set<string>): string {
let base = label.replace(/[\\/?*\[\]:]/g, ' ').trim().slice(0, 31);
if (!base) base = 'Sheet';
let candidate = base;
let i = 2;
while (used.has(candidate.toLowerCase())) {
const suffix = ` (${i++})`;
candidate = base.slice(0, 31 - suffix.length) + suffix;
}
used.add(candidate.toLowerCase());
return candidate;
}
function buildSheetXml(
rows: CrawlUrlRow[],
columns: string[],
): string {
const parts: string[] = [];
parts.push(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<sheetData>',
);
// Header row
parts.push('<row r="1">');
columns.forEach((c, i) => {
parts.push(
`<c r="${colLetter(i)}1" t="inlineStr"><is><t>${xmlEscape(c)}</t></is></c>`,
);
});
parts.push('</row>');
// Data rows
rows.forEach((row, rIdx) => {
const r = rIdx + 2;
parts.push(`<row r="${r}">`);
columns.forEach((col, i) => {
const v = (row as unknown as Record<string, unknown>)[col];
if (v === null || v === undefined || v === '') return;
const cellRef = `${colLetter(i)}${r}`;
if (typeof v === 'number' && Number.isFinite(v)) {
parts.push(`<c r="${cellRef}"><v>${v}</v></c>`);
} else if (typeof v === 'boolean') {
parts.push(`<c r="${cellRef}" t="b"><v>${v ? 1 : 0}</v></c>`);
} else {
parts.push(
`<c r="${cellRef}" t="inlineStr"><is><t>${xmlEscape(String(v))}</t></is></c>`,
);
}
});
parts.push('</row>');
});
parts.push('</sheetData></worksheet>');
return parts.join('');
}
// ── ZIP writer (DEFLATE, no central directory streaming — small enough
// to buffer per-entry, write central dir at the end). ────────────────
interface ZipEntry {
name: string;
raw: Buffer;
compressed: Buffer;
crc32: number;
offset: number;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
function buildZip(files: { name: string; data: Buffer }[]): Buffer {
const entries: ZipEntry[] = [];
const localChunks: Buffer[] = [];
let offset = 0;
for (const f of files) {
const raw = f.data;
const compressed = zlib.deflateRawSync(raw);
const crc = crc32(raw);
const nameBuf = Buffer.from(f.name, 'utf8');
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0); // local file header sig
localHeader.writeUInt16LE(20, 4); // version needed
localHeader.writeUInt16LE(0x0800, 6); // gp flag (UTF-8 name)
localHeader.writeUInt16LE(8, 8); // method = deflate
localHeader.writeUInt16LE(0, 10); // mod time
localHeader.writeUInt16LE(0, 12); // mod date
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(raw.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28); // extra field len
entries.push({
name: f.name,
raw,
compressed,
crc32: crc,
offset,
});
localChunks.push(localHeader, nameBuf, compressed);
offset += localHeader.length + nameBuf.length + compressed.length;
}
const centralChunks: Buffer[] = [];
let cdSize = 0;
for (const e of entries) {
const nameBuf = Buffer.from(e.name, 'utf8');
const cd = Buffer.alloc(46);
cd.writeUInt32LE(0x02014b50, 0); // central dir sig
cd.writeUInt16LE(20, 4); // version made by
cd.writeUInt16LE(20, 6); // version needed
cd.writeUInt16LE(0x0800, 8); // gp flag
cd.writeUInt16LE(8, 10); // method
cd.writeUInt16LE(0, 12);
cd.writeUInt16LE(0, 14);
cd.writeUInt32LE(e.crc32, 16);
cd.writeUInt32LE(e.compressed.length, 20);
cd.writeUInt32LE(e.raw.length, 24);
cd.writeUInt16LE(nameBuf.length, 28);
cd.writeUInt16LE(0, 30);
cd.writeUInt16LE(0, 32);
cd.writeUInt16LE(0, 34);
cd.writeUInt16LE(0, 36);
cd.writeUInt32LE(0, 38);
cd.writeUInt32LE(e.offset, 42);
centralChunks.push(cd, nameBuf);
cdSize += cd.length + nameBuf.length;
}
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...localChunks, ...centralChunks, eocd]);
}
function buildXlsxBuffer(
sheets: { name: string; rows: CrawlUrlRow[] }[],
columns: string[],
): Buffer {
const contentTypes =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
sheets
.map(
(_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
)
.join('') +
'</Types>';
const rels =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const workbook =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets>' +
sheets
.map(
(s, i) =>
`<sheet name="${xmlEscape(s.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`,
)
.join('') +
'</sheets></workbook>';
const workbookRels =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
sheets
.map(
(_, i) =>
`<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`,
)
.join('') +
'</Relationships>';
const files: { name: string; data: Buffer }[] = [
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
{ name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') },
{ name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') },
];
sheets.forEach((s, i) => {
files.push({
name: `xl/worksheets/sheet${i + 1}.xml`,
data: Buffer.from(buildSheetXml(s.rows, columns), 'utf8'),
});
});
return buildZip(files);
}
export async function exportTabular(
db: ProjectDb,
outputPath: string,
options: TabularExportOptions,
): Promise<TabularExportResult> {
const { format, sections, columns, selectedIds } = options;
if (sections.length === 0) {
throw new Error('exportTabular: at least one section is required');
}
if (columns.length === 0) {
throw new Error('exportTabular: at least one column is required');
}
if (format === 'csv') {
if (sections.length === 1) {
const rows = await writeCsvFile(
db,
outputPath,
columns,
sections[0]!.category,
selectedIds,
);
return { filePath: outputPath, files: [outputPath], rowsWritten: rows };
}
// Multi-section CSV → write one file per section into the chosen
// folder. `outputPath` is treated as a folder.
let total = 0;
const written: string[] = [];
for (const section of sections) {
const safe = section.label.replace(/[\\/?*\[\]:<>|"]/g, '_');
const file = path.join(outputPath, `${safe}.csv`);
total += await writeCsvFile(db, file, columns, section.category, selectedIds);
written.push(file);
}
return { filePath: outputPath, files: written, rowsWritten: total };
}
// xlsx — collect rows per section into memory (bounded by typical
// section size), build workbook, write once.
const usedNames = new Set<string>();
let total = 0;
const sheets = sections.map((section) => {
const rows: CrawlUrlRow[] = [];
for (const row of rowSource(db, section.category, selectedIds)) {
rows.push(row);
}
total += rows.length;
return { name: sanitizeSheetName(section.label, usedNames), rows };
});
const buf = buildXlsxBuffer(sheets, columns);
writeFileSync(outputPath, buf);
return { filePath: outputPath, files: [outputPath], rowsWritten: total };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@freecrawl/db", "name": "@freecrawl/db",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+7
View File
@@ -3036,6 +3036,13 @@ export class ProjectDb {
return row ? this.rowFromDb(row) : null; return row ? this.rowFromDb(row) : null;
} }
/** Resolve a URL string to its numeric id, or null if absent. Used by
* the MCP server when a caller supplies a URL instead of an id. */
getUrlIdByUrl(url: string): number | null {
const row = this.stmtGetUrlId.get(url) as { id: number } | undefined;
return row ? row.id : null;
}
getInlinks(url: string, limit: number): { rows: InlinkRow[]; total: number } { getInlinks(url: string, limit: number): { rows: InlinkRow[]; total: number } {
const total = ( const total = (
this.db.prepare('SELECT COUNT(*) AS c FROM links WHERE to_url = ?').get(url) as { c: number } this.db.prepare('SELECT COUNT(*) AS c FROM links WHERE to_url = ?').get(url) as { c: number }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@freecrawl/shared-types", "name": "@freecrawl/shared-types",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+36
View File
@@ -39,6 +39,9 @@ export const IPC = {
exportCsv: 'export:csv', exportCsv: 'export:csv',
exportJson: 'export:json', exportJson: 'export:json',
exportXml: 'export:xml', exportXml: 'export:xml',
/** New unified tabular export with column selection + multi-tab support
* (CSV / XLSX). Backs the in-table "Export" button. */
exportTabular: 'export:tabular',
/** GDPR-aligned per-domain delete. Wipes every row whose URL host /** GDPR-aligned per-domain delete. Wipes every row whose URL host
* matches the given domain (and that domain's links/images/headers/ * matches the given domain (and that domain's links/images/headers/
* url_sources). Used by Settings → "Delete Domain Data". */ * url_sources). Used by Settings → "Delete Domain Data". */
@@ -213,6 +216,38 @@ export interface ExportXmlResult {
rowsWritten: number; rowsWritten: number;
} }
/** One section / sheet of a tabular export — a single category fed into
* the workbook (xlsx) or split into its own file (csv). */
export interface ExportTabularSection {
/** Display label — becomes the xlsx sheet name and the csv file suffix. */
label: string;
category: UrlCategory;
}
export interface ExportTabularInput {
format: 'csv' | 'xlsx';
/**
* One or more sections. With CSV + multiple sections, files are written
* into a folder the user picks; xlsx always produces a single workbook
* with one sheet per section.
*/
sections: ExportTabularSection[];
/** Keys of `CrawlUrlRow` to emit, in order. */
columns: string[];
/** Optional pre-resolved output path (skip the dialog). */
filePath?: string;
/** When set, restrict every section to these row ids. */
selectedIds?: number[];
}
export interface ExportTabularResult {
/** Output file (xlsx + single-section csv) or folder (multi-section csv). */
filePath: string;
/** Files actually written. Multi-section CSV writes one file per section. */
files: string[];
rowsWritten: number;
}
export interface DataDeleteByDomainInput { export interface DataDeleteByDomainInput {
/** Hostname to wipe — case-insensitive, no scheme/port. */ /** Hostname to wipe — case-insensitive, no scheme/port. */
domain: string; domain: string;
@@ -676,6 +711,7 @@ export interface FreeCrawlApi {
exportCsv(input: ExportCsvInput): Promise<ExportCsvResult>; exportCsv(input: ExportCsvInput): Promise<ExportCsvResult>;
exportJson(input: ExportJsonInput): Promise<ExportJsonResult>; exportJson(input: ExportJsonInput): Promise<ExportJsonResult>;
exportXml(input: ExportXmlInput): Promise<ExportXmlResult>; exportXml(input: ExportXmlInput): Promise<ExportXmlResult>;
exportTabular(input: ExportTabularInput): Promise<ExportTabularResult>;
dataDeleteByDomain( dataDeleteByDomain(
input: DataDeleteByDomainInput, input: DataDeleteByDomainInput,
): Promise<DataDeleteByDomainResult>; ): Promise<DataDeleteByDomainResult>;
+2 -1
View File
@@ -5,6 +5,7 @@
{ "path": "./packages/db" }, { "path": "./packages/db" },
{ "path": "./packages/core" }, { "path": "./packages/core" },
{ "path": "./apps/desktop" }, { "path": "./apps/desktop" },
{ "path": "./apps/cli" } { "path": "./apps/cli" },
{ "path": "./apps/mcp-server" }
] ]
} }