feat: MCP full parity (76 tools) + in-app integration setup guides

This commit is contained in:
kemalai committed 2026-06-01 14:23:20 +03:00
1 parent 436104fc11
commit e6755d79d2
16 files changed
+2830 -93

No files matched your search

+4 -77
View File
@@ -25,86 +25,13 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
---
## ✨ Highlights
## ✨ What FreeCrawl Does
<table>
<tr>
<td width="50%" valign="top">
FreeCrawl is a desktop SEO crawler that scales to **1M+ URLs on a single machine** at 80–150 URL/s via undici with keep-alive, with optional **JavaScript rendering** through headless Chromium (Playwright) that captures the post-JS DOM, full-page / above-fold / mobile screenshots, LCP candidate elements, and a per-URL mobile usability audit. It runs **150+ on-page SEO checks across 30 top-level tabs** — exact and near-duplicate clustering (SimHash + LSH) with a dedicated Cluster view, full hreflang validation (reciprocity, self-reference, inconsistent lang), OpenGraph / Twitter Card / JSON-LD / Web App Manifest parsing, AMP smoke validation, structured-data validation (duplicate `@id`, malformed `@type`, missing required props), WCAG accessibility checks (`<main>` landmark, skip-link, ARIA roles, heading order), security headers + SSL/TLS chain audit + active/passive mixed content split, readability scores (Flesch, Flesch–Kincaid, Gunning Fog), and a 10-rule custom CSS + regex extraction engine with a **live Preview dialog and JSON import / export** for sharing rule sets.
### 🚀 Crawler
- **undici** HTTP client with keep-alive Agent
- **80–150 URL/s** typical throughput
- **1M+ URL** crawls on a single machine
- robots.txt, sitemaps, redirect chains
- HTTP Basic / Bearer auth, proxy override
- Pause / Resume / manual URL injection
- **JavaScript rendering** via headless Chromium (Playwright) — post-JS DOM, screenshot capture (full / above-fold / mobile), LCP candidate, Mobile Usability audit
The **dense dark UI** renders virtualized 1M-row tables that live-stream rows during a crawl, with **List ↔ Tree ↔ Cluster view toggles**, **column pin (sticky-left) + drag-to-reorder + show/hide**, advanced AND/OR filters across 24 fields × 12 operators, per-tab quick-filter dropdowns, a 16-tab Details panel per URL (URL Details, Outline, Inlinks, Outlinks, Images, Resources, Extracted Data, SERP Snippet, HTTP Headers, Cookies, Structured Data, View Source, View Rendered, Screenshot, Duplicates, Analytics), an English + Turkish UI including every Settings panel, **in-app scheduled crawls** (hourly / daily / weekly / custom), a live memory monitor, robots.txt syntax validator, URL rewriting with live preview, project-vs-project compare diff, a Cytoscape link graph in its own native window, and a `.seoproject` file association for OS double-click.
</td>
<td width="50%" valign="top">
### 🔍 Analysis
- **150+ SEO issue checks** across 30 top-level tabs
- Near-duplicate clustering (SimHash + LSH) + exact-duplicate content hash + **Cluster view** that groups every near-duplicate cluster on the Duplicates tab
- Full hreflang validation (reciprocity, self-ref, inconsistent lang)
- OpenGraph / Twitter Card / JSON-LD / AMP / **Web App Manifest** parsing
- **AMP smoke validation** — pages declaring themselves AMP get flagged for missing boilerplate, runtime, canonical, or forbidden script/style/legacy tags
- **Structured-data validation** — duplicate `@id`, malformed `@type`, missing required props
- **Accessibility** — `<main>` landmark, skip-link, ARIA roles, heading order
- SSL/TLS cert chain audit, security headers, **CORS audit**
- **Active vs passive mixed content** split
- Readability (Flesch, Flesch–Kincaid, Gunning Fog)
- Custom CSS + Regex extraction (10 rules) — with **live Preview dialog** + **JSON import / export** for sharing rule sets — plus `/regex/` literal Custom Search
- **PageSpeed Insights** — on-demand Lighthouse audits (mobile + desktop) per URL
- **Google Search Console + GA4** — clicks / impressions / CTR / position / sessions / users joined onto crawled pages
- **URL Inspection** — coverage verdict, last-crawl time, canonical decisions in batches
- **AI per-URL** — OpenAI / Anthropic / local Ollama prompts with shared variables (`{url}`/`{title}`/`{description}`/`{h1}`/`{body}`)
- **SEO Authority** — Ahrefs / Majestic / Moz / Semrush metrics behind one provider dropdown
- **Cross-source orphan detection** — pages PSI / GSC / GA4 / sitemap know about but the crawl never reached
</td>
</tr>
<tr>
<td valign="top">
### 🖥 Desktop UI
- Dense dark theme, virtualized 1M+ row tables
- **Multi-language UI** (English + Turkish) — full coverage including all Settings panels
- Live-streaming rows during crawl
- Advanced AND/OR search (24 fields × 12 ops)
- **Per-tab quick-filter dropdown** — instant filtering by Overview sub-categories without leaving the tab
- **List ↔ Tree ↔ Cluster view toggle** — flat virtual table, URL-path hierarchy with collapsible folders, or near-duplicate cluster groups (Duplicates tab)
- **Column pin (sticky-left) + drag-to-reorder** — pin a column so it stays visible during horizontal scroll, drag any header's grip handle to rearrange
- Detail panel with 16 sub-tabs — adds **View Rendered** (post-JS DOM) + **Screenshot** (full/fold/mobile preview) when JS rendering is on
- **Visualization in its own native window** — open from menu, park on a second monitor while data tables stay free
- Per-URL **Duplicates** view (cluster siblings)
- **Live memory monitor** in status bar (RSS / Sys Free / per-URL cost / capacity projection)
- **Robots.txt syntax validator** in the Robots Tester (typo suggestions, orphan rules, sitemap URL check)
- **In-app scheduled crawl** (hourly / daily / weekly / custom) per project
- **`.seoproject` file association** — double-click a project file from the OS to open it
- URL rewriting (regex + whitelist + live preview)
- Cytoscape graph + anchor-text word cloud + **Top Words** report
- Custom CSS theme override (`custom-theme.css` in user-data folder)
- Diagnostic popups for DNS/TLS/proxy issues
</td>
<td valign="top">
### 📤 Export & Reports
- **22 reports** — histograms, top/bottom URLs, link positions, top words, **cross-source orphan pages**
- **Export Crawl Data dialog** — single menu entry with format picker (**Excel `.xlsx`** / **CSV UTF-8** / **JSON** / **XML**) + hierarchical tree picker (top tables + structural sub-categories) + nested folder output
- Per-tab in-context export button (current category) + dedicated **Images CSV export** (honours missing-alt + search)
- Standalone HTML audit report
- Sitemap generator (image / hreflang / sharded / gz)
- Project-vs-project compare diff
- **Google Sheets** + **BigQuery** direct export (OAuth / service-account)
- **Encrypted project snapshots** — password-protected `.seoproject.enc` (AES-256-GCM + PBKDF2)
- **MCP server** for AI agents — **drive crawls live from Claude Code** with 47 tools covering every tab, detail sub-tab, integration row, and report
- Webhook on completion + OS notifications
</td>
</tr>
</table>
Integrations cover **Google Search Console** (clicks, impressions, CTR, position + URL Inspection coverage), **Google Analytics 4** (sessions, users, bounce, engagement), **PageSpeed Insights** Lighthouse audits, custom **AI prompts via OpenAI / Anthropic Claude / local Ollama** with `{url}`/`{title}`/`{description}`/`{h1}`/`{body}` variables, and **SEO authority providers** (Ahrefs / Majestic / Moz / Semrush) behind a single dropdown — each integration card now ships with an **in-app step-by-step Setup Guide modal** in English and Turkish that walks you through OAuth client creation, test-user setup, API enablement, and the most common errors. Exports go through a unified **Export Crawl Data dialog** (Excel `.xlsx` / CSV UTF-8 / JSON / XML with hierarchical category selection and nested folder output) plus a standalone HTML audit report, sitemap generator (standard / image / hreflang / sharded / gzipped), and direct streaming to **Google Sheets** and **BigQuery**. Project files can be saved as **password-protected encrypted snapshots** (`.seoproject.enc`, AES-256-GCM + PBKDF2). The **MCP server** exposes **76 tools** that let Claude Code or any MCP client drive crawls live, query every UI surface, trigger exports, fetch GSC/GA4 data on demand, and modify settings — every action a human user can take in the desktop is callable from an agent. Crawl-completion webhooks, OS notifications, 22 built-in reports (histograms, top/bottom URLs, link positions, top words, cross-source orphans), per-URL **Duplicates** view, and a custom-CSS theme override round out the suite. Everything runs **fully local** — no telemetry, no cloud, MIT-style license.
<br />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
+489
View File
@@ -245,6 +245,12 @@ let crawlController: {
* invoke it without duplicating the writer-pool serialisation +
* recovery-checkpoint reset logic. */
clearCrawl: () => Promise<void>;
/** Generic MCP action dispatch table. Built once inside the IPC
* registration scope (which captures closures over getDb /
* mainWindow / schedulerStore / etc) and handed to the bridge's
* generic `/v1/action/<name>` route. Keys are short kebab-case
* action names matching the MCP tool's `bridgeRequest` path. */
actions: Record<string, (input: unknown) => Promise<unknown>>;
} | null = null;
/** In-memory snapshot of the previous session's checkpoint, captured
* before `db.reset()` wipes it. Cleared once the user accepts or
@@ -3593,12 +3599,483 @@ function registerIpc(): void {
// Expose the same primitives to the MCP bridge so external agents
// (Claude Code, etc.) can drive Start/Stop/Pause/Resume through the
// exact same code path the renderer does.
// Generic MCP action dispatch table — kebab-case names map to closures
// that perform actions. Each closure reuses the same util functions as
// the desktop's IPC handlers, but skips the user dialog popups (MCP
// requires explicit `filePath`'s instead of blocking the agent on a
// file picker). Validation is intentionally light — the bridge layer
// already requires an authenticated request, and a misshapen payload
// throws which the bridge wraps into a 500 with the underlying error
// message. The MCP tool definitions on the other side enforce schema.
const mcpActions: Record<string, (input: unknown) => Promise<unknown>> = {
'crawl-add-url': async (input) => {
const url = (input as { url?: string }).url ?? '';
if (!activeCrawler) return { accepted: false, reason: 'no-active-crawl' };
const accepted = activeCrawler.enqueueManual(url);
return { accepted };
},
'export-csv': async (input) => {
const i = input as ExportCsvInput;
if (!i.filePath) throw new Error('filePath is required (MCP cannot open dialogs).');
const { rowsWritten } = await exportUrlsToCsv(getDb(), i.filePath, {
selectedIds: i.selectedIds,
});
return { filePath: i.filePath, rowsWritten };
},
'export-json': async (input) => {
const i = input as ExportJsonInput;
if (!i.filePath) throw new Error('filePath is required.');
const { rowsWritten } = await exportUrlsToJson(getDb(), i.filePath, {
selectedIds: i.selectedIds,
pretty: i.pretty,
});
return { filePath: i.filePath, rowsWritten };
},
'export-xml': async (input) => {
const i = input as ExportXmlInput;
if (!i.filePath) throw new Error('filePath is required.');
const { rowsWritten } = await exportUrlsToXml(getDb(), i.filePath, {
selectedIds: i.selectedIds,
category: i.category,
});
return { filePath: i.filePath, rowsWritten };
},
'export-html-report': async (input) => {
const i = input as ExportHtmlReportInput;
if (!i.filePath) throw new Error('filePath is required.');
const result = await exportHtmlReport(getDb(), i.filePath, {
startUrl: getDb().getMeta('lastStartUrl') ?? '',
});
return { filePath: result.filePath, bytesWritten: result.bytesWritten };
},
'sitemap-generate': async (input) => {
const i = input as SitemapGenerateInput;
if (!i.filePath) throw new Error('filePath is required.');
return exportSitemap(getDb(), i.filePath, {
variant: i.variant,
gzip: i.gzip,
splitAtUrlCount: i.splitAtUrlCount,
});
},
'sitemap-validate': async (input) => {
const i = input as SitemapValidateInput;
if (!i.url) throw new Error('url is required.');
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), 30_000);
try {
const ua = i.userAgent || DEFAULT_CRAWL_CONFIG.userAgent;
const result = await fetchSitemaps([i.url], {
userAgent: ua,
signal: ac.signal,
timeoutMs: 30_000,
maxUrls: 100_000,
maxDepth: 3,
});
const lastmodSamples = result.entries
.slice(0, 50)
.map((e) => e.lastmod ?? '')
.filter(Boolean)
.slice(0, 10);
const validation = validateSitemap({
urlCount: result.entries.length,
fileBytes: 0,
lastmodSamples,
});
return {
url: i.url,
sitemapsTried: result.sitemapsTried,
sitemapsParsed: result.sitemapsParsed,
errors: result.errors,
urlCount: result.entries.length,
truncated: result.truncated,
findings: validation.findings,
lastmodSamples,
};
} finally {
clearTimeout(timeout);
}
},
'robots-test': async (input) => {
const i = input as RobotsTestInput;
if (!i.url || !i.userAgent) {
throw new Error('url and userAgent are required.');
}
return testUrlAgainstRobots(i.url, i.userAgent, i.customRobots);
},
'robots-validate': async (input) => {
const text = typeof (input as { text?: string }).text === 'string'
? (input as { text: string }).text
: '';
return validateRobotsTxt(text);
},
'compare-load': async (input) => {
const i = input as CompareLoadInput;
if (!i.filePath) throw new Error('filePath is required (MCP cannot open dialogs).');
const otherDb = new ProjectDb(i.filePath);
try {
const summary = compareCrawls(getDb(), otherDb);
return {
filePath: i.filePath,
totalA: summary.totalA,
totalB: summary.totalB,
counts: summary.counts,
samples: summary.samples,
};
} finally {
otherDb.close?.();
}
},
'schedule-get': async () => {
return getSchedule(schedulerStore, currentProjectPath);
},
'schedule-set': async (input) => {
const spec = (input as { spec?: ScheduleSpec | null }).spec ?? null;
return setSchedule(schedulerStore, currentProjectPath, spec);
},
'report-top-words': async (input) => {
const i = (input ?? {}) as { limit?: number; minLength?: number; locale?: 'en' | 'tr' | 'all' };
const corpus = getDb().seoTextCorpus();
return aggregateTopWords(corpus, {
limit: i.limit ?? 100,
minLength: i.minLength ?? 3,
locale: i.locale ?? 'all',
});
},
// ---- Faz 0.5 Increment 3 — URL mutation, exports, config, integrations, project file ----
'respider-urls': async (input) => {
// Mirrors the URL-bulk context menu's "Re-Spider" item. Requires
// an active crawler — the queued URLs are re-fetched live as part
// of the existing crawl. With no active crawl, the DB rows are
// still marked dirty so the NEXT crawl will pick them up.
const ids = (input as { urlIds?: number[] }).urlIds ?? [];
if (!Array.isArray(ids) || ids.length === 0) {
throw new Error('urlIds is required (non-empty array).');
}
const db = getDb();
db.markUrlsForRecrawl(ids);
let requeued = 0;
if (activeCrawler && activeCrawler.isRunning) {
const urls = db.getUrlsByIds(ids);
for (const u of urls) {
activeCrawler.requeueUrl(u);
requeued++;
}
}
fireDataChanged();
return { marked: ids.length, requeued, hasActiveCrawl: activeCrawler?.isRunning === true };
},
'remove-urls': async (input) => {
const ids = (input as { urlIds?: number[] }).urlIds ?? [];
if (!Array.isArray(ids) || ids.length === 0) {
throw new Error('urlIds is required (non-empty array).');
}
getDb().deleteUrls(ids);
fireDataChanged();
return { removed: ids.length };
},
'data-delete-by-domain': async (input) => {
const domain = (input as { domain?: string }).domain ?? '';
if (!domain.trim()) throw new Error('domain is required.');
const result = getDb().deleteByDomain(domain.trim().toLowerCase());
fireDataChanged();
return result;
},
'export-broken-links': async (input) => {
const i = input as { filePath?: string; internal?: 'all' | 'internal' | 'external' };
if (!i.filePath) throw new Error('filePath is required.');
const { rowsWritten } = await exportBrokenLinksToCsv(getDb(), i.filePath, {
internal: i.internal ?? 'all',
});
return { filePath: i.filePath, rowsWritten };
},
'export-images': async (input) => {
const i = input as { filePath?: string; missingAltOnly?: boolean; search?: string };
if (!i.filePath) throw new Error('filePath is required.');
const { rowsWritten } = await exportImagesToCsv(getDb(), i.filePath, {
missingAltOnly: i.missingAltOnly,
search: i.search,
});
return { filePath: i.filePath, rowsWritten };
},
'export-tabular': async (input) => {
const i = input as ExportTabularInput;
if (!i.filePath) throw new Error('filePath is required.');
return exportTabular(getDb(), i.filePath, {
format: i.format,
sections: i.sections,
columns: i.columns,
selectedIds: i.selectedIds,
});
},
'get-crawl-config': async () => {
// Mirrors what the renderer hydrates from prefs at boot — the
// current saved `lastCrawlConfig` if present, else the factory
// default. Lets an agent see what's saved before calling
// `start_crawl` without overrides.
return lastCrawlConfig ?? { ...DEFAULT_CRAWL_CONFIG };
},
'set-crawl-config': async (input) => {
// Persists the supplied CrawlConfig as the "last used" — the next
// `start_crawl` call without overrides will use this. Whitelist
// is intentionally loose (full CrawlConfig) because the desktop's
// Settings dialog has dozens of fields and gating MCP narrower
// than the UI would be arbitrary. The crawler itself validates
// ranges at launch time.
const cfg = input as CrawlConfig;
if (!cfg || typeof cfg !== 'object') {
throw new Error('CrawlConfig object required.');
}
lastCrawlConfig = { ...DEFAULT_CRAWL_CONFIG, ...cfg };
try {
getDb().setMeta('lastCrawlConfig', JSON.stringify(lastCrawlConfig));
} catch {
/* non-fatal — config still set in memory */
}
return { ok: true, config: lastCrawlConfig };
},
'url-rewrite-preview': async (input) => {
const i = input as UrlRewritePreviewInput;
const errors: Array<{ pattern: string; error: string }> = [];
const compiled = compileUrlRegexRewrites(i.urlRegexRewrites, (p, err) =>
errors.push({ pattern: p, error: err }),
);
let result: string | null = null;
let parseError: string | undefined;
try {
result = normalizeUrl(i.url, undefined, {
stripWww: i.stripWww,
forceHttps: i.forceHttps,
lowercasePath: i.lowercasePath,
trailingSlash: i.trailingSlash,
keepQueryParams: i.keepQueryParams,
regexRewrites: compiled,
});
if (result === null) parseError = 'URL failed to parse or rewrite produced an invalid URL';
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
}
return { result, regexErrors: errors, parseError };
},
'extraction-preview': async (input) => {
// Same shape as the IPC `extraction:preview` handler — fetches
// the URL once and runs rules against it. Used by the Settings →
// Custom Extraction "Preview" button on the desktop; exposed
// here so an agent can iterate on selectors without a full crawl.
const i = input as ExtractionPreviewInput;
if (!i.url) return { ok: false, error: 'URL is required' };
let parsed: URL;
try {
parsed = new URL(i.url);
} catch {
return { ok: false, error: 'Invalid URL' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: 'Only http(s) URLs supported' };
}
const userAgent = (i.userAgent ?? '').trim() || 'FreeCrawl SEO Tool (extraction preview)';
const acceptLanguage = (i.acceptLanguage ?? '').trim() || 'en';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
const startedAt = Date.now();
let response: Response | null = null;
try {
response = (await undiciFetch(parsed.href, {
method: 'GET',
redirect: 'follow',
headers: {
'User-Agent': userAgent,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': acceptLanguage,
},
signal: controller.signal,
})) as unknown as Response;
} catch (err) {
clearTimeout(timer);
return {
ok: false,
error: controller.signal.aborted
? 'Fetch timed out after 15s'
: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
clearTimeout(timer);
const bodyText = await response.text();
const results = previewExtractionRules(bodyText, i.rules ?? []).map((r) => ({
name: r.name,
value: r.value,
...(r.error !== undefined && { error: r.error }),
}));
return {
ok: true,
statusCode: response.status,
finalUrl: response.url || parsed.href,
contentType: response.headers.get('content-type') ?? '',
byteSize: Buffer.byteLength(bodyText, 'utf8'),
fetchMs: Date.now() - startedAt,
results,
};
},
'graph-snapshot': async (input) => {
const i = (input ?? {}) as { nodeLimit?: number };
return getDb().graphSnapshot(i.nodeLimit ?? 5000);
},
// ---- Faz 0.5 Increment 4 — Integration fetch triggers (simple ones). ----
// GSC and GA4 fetches are single blocking API calls, no progress
// streaming — safe to expose to MCP. PSI / AI / SEO have multi-URL
// batches with progress events + cancellation flags; those stay
// desktop-only until a polling tool pattern is designed.
'gsc-list-sites': async () => {
try {
return { ok: true, error: null, sites: await listSites() };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
sites: [],
};
}
},
'gsc-fetch': async (input) => {
// Mirrors the desktop's `gsc:fetch` IPC handler exactly — same
// window-end-lags-by-2-days logic so the date range only covers
// data Search Console has actually published.
const i = input as GscFetchInput;
if (!i.property) {
return {
ok: false,
error: 'property is required (e.g. "sc-domain:example.com").',
rowCount: 0,
meta: null,
};
}
const days = (i.days ?? 28) as 7 | 28 | 90;
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
const end = new Date(Date.now() - 2 * 86_400_000);
const start = new Date(end.getTime() - (days - 1) * 86_400_000);
const startDate = isoDate(start);
const endDate = isoDate(end);
const runDb = getDb();
try {
const rows = await querySearchAnalytics(i.property, startDate, endDate);
const fetchedAt = new Date().toISOString();
runDb.replaceGscResults(rows, fetchedAt);
const meta = {
property: i.property,
startDate,
endDate,
fetchedAt,
rowCount: rows.length,
};
runDb.setMeta('gscFetchMeta', JSON.stringify(meta));
fireDataChanged();
return { ok: true, error: null, rowCount: rows.length, meta };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
rowCount: 0,
meta: null,
};
}
},
'ga4-list-properties': async () => {
try {
return { ok: true, error: null, properties: await ga4ListProperties() };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
properties: [],
};
}
},
'ga4-fetch': async (input) => {
// Mirrors the desktop's `ga4:fetch` IPC handler exactly — GA4 data
// is near-realtime so the range ends at today (unlike GSC's 2-day lag).
const i = input as Ga4FetchInput;
if (!i.property) {
return {
ok: false,
error: 'property is required (e.g. "properties/123456").',
rowCount: 0,
meta: null,
};
}
const days = (i.days ?? 28) as 7 | 28 | 90;
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
const end = new Date();
const start = new Date(end.getTime() - (days - 1) * 86_400_000);
const startDate = isoDate(start);
const endDate = isoDate(end);
const runDb = getDb();
try {
const rows = await ga4RunReport(i.property, startDate, endDate);
const fetchedAt = new Date().toISOString();
runDb.replaceGa4Results(rows, fetchedAt);
const meta = {
property: i.property,
propertyName: i.propertyName || i.property,
startDate,
endDate,
fetchedAt,
rowCount: rows.length,
};
runDb.setMeta('ga4FetchMeta', JSON.stringify(meta));
fireDataChanged();
return { ok: true, error: null, rowCount: rows.length, meta };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
rowCount: 0,
meta: null,
};
}
},
'google-auth-status': async (input) => {
const id = (input as { integrationId?: string }).integrationId ?? '';
if (!id) throw new Error('integrationId is required (e.g. "gsc" or "ga4").');
return getAuthState(id);
},
};
crawlController = {
launchCrawl,
stopCrawl: () => activeCrawler?.stop(),
pauseCrawl: () => activeCrawler?.pause(),
resumeCrawl: () => activeCrawler?.resume(),
clearCrawl: () => clearCrawlDb(),
actions: mcpActions,
};
// Shared between the renderer's IPC `crawl:clear` and the MCP bridge's
@@ -5128,6 +5605,18 @@ void app.whenReady().then(async () => {
clearCrawl: async () => {
if (crawlController) await crawlController.clearCrawl();
},
// Lazy lookup — crawlController is set inside the IPC registration
// scope (which runs before the bridge starts), but using a getter
// here keeps the wire-up symmetric with the other deps and avoids
// a "TDZ between registerIpc() and startMcpBridge()" footgun.
actions: new Proxy({}, {
get: (_t, name) => {
if (typeof name !== 'string') return undefined;
return crawlController?.actions[name];
},
has: (_t, name) =>
typeof name === 'string' && crawlController?.actions[name] !== undefined,
}) as Record<string, (input: unknown) => Promise<unknown>>,
getProgress: () => latestProgress,
getProjectPath: () => currentProjectPath || null,
getUrlCount: () => {
+39
View File
@@ -35,6 +35,10 @@
* POST /v1/crawl/clear
* GET /v1/crawl/progress
* GET /v1/project — { projectPath, urlsCrawled }
* POST /v1/action/<name> — generic action dispatch; <name> is one of
* the keys registered in `McpBridgeDeps.actions`.
* Body shape per-action; defined by the MCP
* tool contract on the other side.
*
* Caller signs every request:
* `Authorization: Bearer <token>`
@@ -72,6 +76,17 @@ export interface McpBridgeDeps {
* completed crawl finds zero pending work and exits immediately.
*/
clearCrawl: () => Promise<void>;
/**
* Generic action dispatch table — keyed by short kebab-case action
* name, value is the closure that performs the action. The bridge's
* `POST /v1/action/<name>` route looks up by name and invokes with
* the request body. Keeps the bridge route surface flat (one route
* for N actions) while letting the main process explicitly allow-list
* which actions MCP can drive. Closures return `unknown` so they can
* be wired up to any util-function shape; the MCP server is the
* point that gives each action a typed contract.
*/
actions: Record<string, (input: unknown) => Promise<unknown>>;
/**
* Snapshot of the latest progress event the Crawler emitted. Null
* when no crawl has ever run in this session. The bridge passes it
@@ -383,6 +398,30 @@ async function handleRequest(
}
return;
}
if (method === 'POST' && path.startsWith('/v1/action/')) {
const name = path.slice('/v1/action/'.length);
const action = deps.actions[name];
if (!action) {
send(res, 404, { error: `unknown-action: ${name}` });
return;
}
let body: unknown;
try {
body = await readJsonBody(req);
} catch (err) {
send(res, 400, { error: err instanceof Error ? err.message : 'bad-body' });
return;
}
try {
const result = await action(body);
send(res, 200, result === undefined ? { ok: true } : result);
} catch (err) {
send(res, 500, {
error: err instanceof Error ? err.message : 'action-failed',
});
}
return;
}
send(res, 404, { error: 'not-found' });
}
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,7 @@ import {
Languages,
Plug,
ExternalLink,
BookOpen,
Chrome,
type LucideIcon,
} from 'lucide-react';
@@ -52,6 +53,7 @@ import { DEFAULT_CRAWL_CONFIG, INTEGRATIONS } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { InfoTip, type FieldInfo } from './InfoTip.js';
import { ExtractionPreviewDialog } from './ExtractionPreviewDialog.js';
import { IntegrationSetupGuideModal } from './IntegrationSetupGuideModal.js';
interface Props {
open: boolean;
@@ -3282,6 +3284,7 @@ function IntegrationCard({
const { t } = useTranslation();
const configured = state?.configured ?? false;
const hasDraft = Object.values(draft).some((v) => v.length > 0);
const [guideOpen, setGuideOpen] = useState(false);
return (
<div className="rounded border border-surface-800 bg-surface-950/40 p-3">
@@ -3376,17 +3379,35 @@ function IntegrationCard({
{t('common.remove', { defaultValue: 'Remove' })}
</button>
)}
<button
type="button"
onClick={() => setGuideOpen(true)}
className="ml-auto inline-flex items-center gap-1 rounded border border-accent-500/50 px-2 py-0.5 text-[10px] text-accent-300 hover:bg-accent-500/15"
title={t('integrations.setupGuideTitle', {
defaultValue:
'Step-by-step setup walkthrough — OAuth client, test users, API enable, common errors.',
})}
>
<BookOpen className="h-3 w-3" />
{t('integrations.setupGuide', { defaultValue: 'Setup Guide' })}
</button>
<a
href={def.helpUrl}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
>
{t('integrations.getCredentials', { defaultValue: 'Get credentials' })}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{notice && <div className="mt-1.5 text-[10px] text-emerald-400">{notice}</div>}
<IntegrationSetupGuideModal
open={guideOpen}
integration={def}
onClose={() => setGuideOpen(false)}
/>
</div>
);
}
@@ -12,10 +12,22 @@
"savedPlaceholder": "Saved — type to replace",
"optional": "(optional)",
"getCredentials": "Get credentials",
"setupGuide": "Setup Guide",
"setupGuideTitle": "Step-by-step setup walkthrough — OAuth client, test users, API enable, common errors.",
"saved": "Saved.",
"cleared": "Removed.",
"confirmClear": "Remove all stored credentials for this integration?"
},
"integrationGuide": {
"title": "{{name}} — Setup Guide",
"lastReviewed": "Last reviewed {{date}}",
"notAvailable": "A guide for this integration is not yet available.",
"fallback": "No detailed guide yet. Click \"Get credentials\" on the card to open the provider's docs.",
"prereqs": "Prerequisites",
"steps": "Step-by-step setup",
"troubleshooting": "Troubleshooting",
"notes": "Notes"
},
"common": {
"save": "Save",
"cancel": "Cancel",
@@ -12,10 +12,22 @@
"savedPlaceholder": "Kayıtlı — değiştirmek için yaz",
"optional": "(isteğe bağlı)",
"getCredentials": "Kimlik bilgisi al",
"setupGuide": "Kurulum Rehberi",
"setupGuideTitle": "Adım adım kurulum rehberi — OAuth client, test user, API enable, sık karşılaşılan hatalar.",
"saved": "Kaydedildi.",
"cleared": "Kaldırıldı.",
"confirmClear": "Bu entegrasyonun saklanan tüm kimlik bilgileri kaldırılsın mı?"
},
"integrationGuide": {
"title": "{{name}} — Kurulum Rehberi",
"lastReviewed": "Son güncelleme {{date}}",
"notAvailable": "Bu entegrasyon için detaylı rehber henüz hazır değil.",
"fallback": "Detaylı rehber yok. Kart üzerindeki \"Kimlik bilgisi al\" butonuyla sağlayıcının kendi dokümantasyonuna git.",
"prereqs": "Ön gereksinimler",
"steps": "Adım adım kurulum",
"troubleshooting": "Sorun giderme",
"notes": "Notlar"
},
"common": {
"save": "Kaydet",
"cancel": "İptal",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"type": "module",
"bin": {
+620
View File
@@ -967,5 +967,625 @@ export function buildTools(): Tool[] {
inputSchema: { type: 'object', properties: {} },
handler: (_args, db) => ({ rows: db.serverHeaderBreakdown() }),
},
// =================================================================
// V2 Faz 0.5 Increment 2 — Action tools. Each one POSTs through the
// bridge's generic `/v1/action/<name>` route; the main process
// dispatches via `crawlController.actions[<name>]`. Unlike the
// desktop IPC handlers, MCP actions require explicit file paths —
// no save / open dialog can pop up because the agent is not the
// one in front of the screen. Pass absolute paths; the agent
// should call `current_project` first if it needs to know where
// the project lives.
// =================================================================
{
requiresDb: false,
name: 'crawl_add_url',
description:
'Enqueue a single URL into the currently running crawl. Same primitive as the TopBar "Add URL" button. No-op when no crawl is active (returns `{accepted: false, reason: "no-active-crawl"}`). URL is filtered by the active crawl\'s scope / include / exclude / robots rules just like any link-discovered URL.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Absolute URL to inject.' },
},
required: ['url'],
},
handler: async (args) =>
bridgeRequest<{ accepted: boolean; reason?: string }>(
'POST',
'/v1/action/crawl-add-url',
{ url: String(args.url ?? '') },
),
},
{
requiresDb: false,
name: 'export_csv',
description:
'Export the active project (or one tab\'s subset, or a list of URL ids) to a CSV file. Path must be absolute. CSV is UTF-8 with formula-injection neutralisation on cells starting with `=`, `+`, `-`, `@`, TAB, or CR.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Absolute output path.' },
category: {
type: 'string',
enum: URL_CATEGORY_VALUES,
description: 'Optional UrlCategory subset filter. Default exports the whole project.',
},
selectedIds: {
type: 'array',
items: { type: 'integer' },
description: 'When set, only these URL ids are exported. Wins over category.',
},
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; rowsWritten: number }>(
'POST',
'/v1/action/export-csv',
args,
),
},
{
requiresDb: false,
name: 'export_json',
description: 'Export to JSON. Same shape as export_csv plus an optional `pretty: true` for 2-space indent (default compact).',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string' },
category: { type: 'string', enum: URL_CATEGORY_VALUES },
selectedIds: { type: 'array', items: { type: 'integer' } },
pretty: { type: 'boolean' },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; rowsWritten: number }>(
'POST',
'/v1/action/export-json',
args,
),
},
{
requiresDb: false,
name: 'export_xml',
description: 'Export to XML. Same shape as export_csv.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string' },
category: { type: 'string', enum: URL_CATEGORY_VALUES },
selectedIds: { type: 'array', items: { type: 'integer' } },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; rowsWritten: number }>(
'POST',
'/v1/action/export-xml',
args,
),
},
{
requiresDb: false,
name: 'export_html_report',
description: 'Generate a standalone HTML audit report (summary + sample tables) — same content as File → Export → HTML Report in the desktop. Embeds top-level counts, top issues, and sample URLs per issue category.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Absolute output path. Use a `.html` extension.' },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; bytesWritten: number }>(
'POST',
'/v1/action/export-html-report',
args,
),
},
{
requiresDb: false,
name: 'sitemap_generate',
description:
'Generate a sitemap.xml from the indexable URLs in the active project. Variant defaults to `standard`; pass `image` for Google Images extension or `hreflang` for international targeting. Gzip and per-file URL caps supported (sharding kicks in over 50,000).',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Absolute output path.' },
variant: {
type: 'string',
enum: ['standard', 'image', 'hreflang'],
},
gzip: { type: 'boolean' },
splitAtUrlCount: { type: 'integer', minimum: 1, maximum: 50_000 },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{
filePath: string;
files?: string[];
urlsWritten: number;
truncated: boolean;
sharded?: boolean;
}>('POST', '/v1/action/sitemap-generate', args),
},
{
requiresDb: false,
name: 'sitemap_validate',
description:
'Fetch a remote sitemap.xml (or sitemap-index.xml) and validate its XML, URL count, and lastmod entries. Walks nested sitemap indexes up to 3 levels deep, capped at 100,000 entries. Returns per-sitemap parse errors plus protocol-level findings.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Sitemap URL to fetch.' },
userAgent: { type: 'string', description: 'Optional UA override.' },
},
required: ['url'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/sitemap-validate', args),
},
{
requiresDb: false,
name: 'robots_test',
description:
'Test whether a URL is allowed by a robots.txt policy. Defaults to fetching the live robots.txt from the URL\'s origin; pass `customRobots` (raw body string) to test a draft policy offline.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
userAgent: { type: 'string' },
customRobots: {
type: 'string',
description: 'Optional raw robots.txt body to test against instead of fetching live.',
},
},
required: ['url', 'userAgent'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/robots-test', args),
},
{
requiresDb: false,
name: 'robots_validate',
description: 'Lint a raw robots.txt body — surface syntax errors, typo suggestions, orphan rules, and sitemap-URL checks. Lines are 1-indexed.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string', description: 'Full robots.txt body to validate.' },
},
required: ['text'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/robots-validate', args),
},
{
requiresDb: false,
name: 'compare_load',
description:
'Diff the active project against another `.seoproject` file — same as File → Compare With Project. Returns counts across 9 categories (added / removed / status / title / meta / h1 / canonical / indexability / response_time) plus sample rows (up to 5K per category).',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the OTHER `.seoproject` to diff against.',
},
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/compare-load', args),
},
{
requiresDb: false,
name: 'schedule_get',
description:
'Read the active project\'s scheduled-crawl spec, or null when none. Same data the Scheduled Crawl dialog shows: cadence (hourly/daily/weekly/custom), next-fire timestamp, last-fire timestamp + status.',
inputSchema: { type: 'object', properties: {} },
handler: async () =>
bridgeRequest<unknown>('POST', '/v1/action/schedule-get', {}),
},
{
requiresDb: false,
name: 'schedule_set',
description:
'Write the active project\'s scheduled-crawl spec, or pass `spec: null` to clear. The in-app scheduler\'s 60-second tick picks up the change immediately — the next fire is computed server-side and returned. Cadence `custom` requires `intervalMinutes` ≥ 15.',
inputSchema: {
type: 'object',
properties: {
spec: {
type: ['object', 'null'],
description: 'ScheduleSpec or null to clear. Required field: `cadence`. Optional: enabled, intervalMinutes, hourOfDay, minuteOfHour, dayOfWeek.',
properties: {
enabled: { type: 'boolean' },
cadence: { type: 'string', enum: ['hourly', 'daily', 'weekly', 'custom'] },
intervalMinutes: { type: 'integer', minimum: 15 },
hourOfDay: { type: 'integer', minimum: 0, maximum: 23 },
minuteOfHour: { type: 'integer', minimum: 0, maximum: 59 },
dayOfWeek: { type: 'integer', minimum: 0, maximum: 6 },
},
},
},
required: ['spec'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/schedule-set', args),
},
{
requiresDb: false,
name: 'report_top_words',
description:
'Most-used words across every indexable page\'s title + meta description + H1. Locale tunes the stopword list — `en` strips English stopwords, `tr` Turkish, `all` strips both. Use for "what does the site talk about" content audits.',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 2000 },
minLength: { type: 'integer', minimum: 1, maximum: 20 },
locale: { type: 'string', enum: ['en', 'tr', 'all'] },
},
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/report-top-words', args),
},
// ---- Faz 0.5 Increment 3 — URL mutation + remaining exports + config + previews ----
{
requiresDb: false,
name: 'respider_urls',
description:
'Bulk re-spider URLs by id — same primitive as the URL multi-select context menu\'s "Re-Spider N URLs". DB rows are marked dirty (`recrawl_pending`); if an active crawl is running, the URLs are also live-requeued. With no active crawl, the marks survive — the next `start_crawl` picks them up. Returns `{marked, requeued, hasActiveCrawl}`.',
inputSchema: {
type: 'object',
properties: {
urlIds: { type: 'array', items: { type: 'integer' }, minItems: 1 },
},
required: ['urlIds'],
},
handler: async (args) =>
bridgeRequest<{ marked: number; requeued: number; hasActiveCrawl: boolean }>(
'POST',
'/v1/action/respider-urls',
args,
),
},
{
requiresDb: false,
name: 'remove_urls',
description:
'Delete URLs from the project by id, plus every dependent row (links / images / headers / cookies / sources / issues). DESTRUCTIVE — no undo. Same primitive as the URL multi-select context menu\'s "Remove N URLs".',
inputSchema: {
type: 'object',
properties: {
urlIds: { type: 'array', items: { type: 'integer' }, minItems: 1 },
},
required: ['urlIds'],
},
handler: async (args) =>
bridgeRequest<{ removed: number }>(
'POST',
'/v1/action/remove-urls',
args,
),
},
{
requiresDb: false,
name: 'data_delete_by_domain',
description:
'GDPR-style domain wipe — deletes every URL whose host matches the given domain (case-insensitive, no scheme/port), plus all dependent rows. Same primitive as Settings → Storage → "Delete Domain Data". DESTRUCTIVE.',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'Hostname only (e.g. "example.com", not "https://example.com/").' },
},
required: ['domain'],
},
handler: async (args) =>
bridgeRequest<{ urlsDeleted: number; linksDeleted: number }>(
'POST',
'/v1/action/data-delete-by-domain',
args,
),
},
{
requiresDb: false,
name: 'export_broken_links',
description: 'Export every 4xx/5xx broken link in the project to a CSV. Honours the same internal/external scope filter the Broken Links tab\'s sidebar exposes.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Absolute output path.' },
internal: { type: 'string', enum: ['all', 'internal', 'external'] },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; rowsWritten: number }>(
'POST',
'/v1/action/export-broken-links',
args,
),
},
{
requiresDb: false,
name: 'export_images',
description: 'Export every image the crawler saw to a CSV with src/alt/dimensions/byte_size/is_internal. Honours the Images tab\'s "Missing Alt only" toggle and search box.',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string' },
missingAltOnly: { type: 'boolean' },
search: { type: 'string', description: 'Substring filter on src or alt.' },
},
required: ['filePath'],
},
handler: async (args) =>
bridgeRequest<{ filePath: string; rowsWritten: number }>(
'POST',
'/v1/action/export-images',
args,
),
},
{
requiresDb: false,
name: 'export_tabular',
description:
'Multi-section export — emit several tabs into one workbook (xlsx) or one file per section (csv/json/xml). `sections` lists which categories to include; `columns` is the CrawlUrlRow key list (in order). With xlsx, each section becomes a sheet; with the others, each section becomes its own file under the chosen folder (or sub-folder via `subdir`).',
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Absolute file (xlsx) or folder (csv/json/xml) path.' },
format: { type: 'string', enum: ['csv', 'xlsx', 'json', 'xml'] },
sections: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string' },
category: { type: 'string', enum: URL_CATEGORY_VALUES },
subdir: { type: 'string' },
filename: { type: 'string' },
},
required: ['label', 'category'],
},
},
columns: { type: 'array', items: { type: 'string' } },
selectedIds: { type: 'array', items: { type: 'integer' } },
},
required: ['filePath', 'format', 'sections', 'columns'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/export-tabular', args),
},
{
requiresDb: false,
name: 'get_crawl_config',
description: 'Read the most recently-used CrawlConfig — the same config `start_crawl` falls back to when called without overrides. Reflects whatever the user (or the previous MCP `set_crawl_config` call) configured in Settings.',
inputSchema: { type: 'object', properties: {} },
handler: async () =>
bridgeRequest<unknown>('POST', '/v1/action/get-crawl-config', {}),
},
{
requiresDb: false,
name: 'set_crawl_config',
description:
'Persist a full CrawlConfig as the saved default — next `start_crawl` without overrides will use these settings. The input is the entire CrawlConfig object (every field the Settings dialog exposes). Pass `get_crawl_config` first to read the current shape, then send the modified copy back. Validation happens at crawl launch time — out-of-range values may be silently clamped or fail at start.',
inputSchema: {
type: 'object',
description: 'Full CrawlConfig object. Pass the result of `get_crawl_config` with your modifications layered on.',
additionalProperties: true,
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/set-crawl-config', args),
},
{
requiresDb: false,
name: 'url_rewrite_preview',
description:
'Test a URL-rewrite pipeline against a sample URL — same primitive as the Settings → URL Rewriting "Preview" button. Returns the rewritten URL plus any regex compile errors. Useful for debugging `urlRegexRewrites` patterns before committing them via `set_crawl_config`.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
stripWww: { type: 'boolean' },
forceHttps: { type: 'boolean' },
lowercasePath: { type: 'boolean' },
trailingSlash: { type: 'string', enum: ['leave', 'strip', 'add'] },
keepQueryParams: { type: 'array', items: { type: 'string' } },
urlRegexRewrites: {
type: 'array',
items: {
type: 'object',
properties: {
pattern: { type: 'string' },
replacement: { type: 'string' },
flags: { type: 'string' },
},
required: ['pattern', 'replacement'],
},
},
},
required: ['url'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/url-rewrite-preview', args),
},
{
requiresDb: false,
name: 'extraction_preview',
description:
'Test custom extraction rules against a live URL fetch — same primitive as Settings → Custom Extraction "Preview". Returns per-rule values (or compile errors) plus the response\'s status / content-type / byte size / fetch ms. The agent can iterate on selectors without running a full crawl.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
rules: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['css', 'regex'] },
selector: { type: 'string' },
attribute: { type: 'string' },
output: { type: 'string', enum: ['text', 'attribute', 'inner_html', 'outer_html', 'count', 'regex_group'] },
multi: { type: 'string', enum: ['first', 'last', 'all', 'concat', 'count'] },
},
required: ['name', 'type', 'selector', 'output', 'multi'],
},
},
userAgent: { type: 'string' },
acceptLanguage: { type: 'string' },
},
required: ['url', 'rules'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/extraction-preview', args),
},
{
requiresDb: false,
name: 'graph_snapshot',
description:
'Internal link graph for the Visualization view — every URL as a node + every internal link as an edge. `nodeLimit` caps the size for large crawls (default 5000). Useful to feed graph-analysis or compute centrality offline.',
inputSchema: {
type: 'object',
properties: {
nodeLimit: { type: 'integer', minimum: 10, maximum: 50_000 },
},
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/graph-snapshot', args),
},
// ---- Faz 0.5 Increment 4 — Integration fetch (GSC + GA4 only;
// PSI/AI/SEO have progress streaming and stay in the desktop UI). ----
{
requiresDb: false,
name: 'google_auth_status',
description:
'Read the OAuth connection state of one Google integration (`gsc` / `ga4` / `sheets` / `bigquery` / `pagespeed`). Returns `{connected, email, scopes, expiresAt, error}`. Use this BEFORE `gsc_fetch` / `ga4_fetch` to verify the user has connected; not-connected paths surface a clear "Settings → Integrations → Connect" hint in the response.',
inputSchema: {
type: 'object',
properties: {
integrationId: {
type: 'string',
enum: ['gsc', 'ga4', 'sheets', 'bigquery', 'pagespeed', 'openai', 'anthropic', 'ollama'],
},
},
required: ['integrationId'],
},
handler: async (args) =>
bridgeRequest<unknown>('POST', '/v1/action/google-auth-status', args),
},
{
requiresDb: false,
name: 'gsc_list_sites',
description:
'List every Search Console property the connected Google account has access to. Returns `{ok, sites: [{siteUrl, permissionLevel}]}`. Pass the desired `siteUrl` to `gsc_fetch` next. Requires the GSC integration to be connected (see google_auth_status).',
inputSchema: { type: 'object', properties: {} },
handler: async () =>
bridgeRequest<unknown>('POST', '/v1/action/gsc-list-sites', {}),
},
{
requiresDb: false,
name: 'gsc_fetch',
description:
'Pull per-page Search Console metrics (clicks, impressions, CTR, position) for one property over a trailing window (7/28/90 days). Data is stored against crawled URLs; query the result via `query_gsc` or `get_url_analytics`. GSC data lags ~2 days, so the window ends 2 days before today. Blocks until the pull finishes (a few seconds to a minute depending on row count). Requires GSC integration connected.',
inputSchema: {
type: 'object',
properties: {
property: {
type: 'string',
description: 'Search Console property `siteUrl` (e.g. "sc-domain:example.com" or "https://example.com/"). Get available values from `gsc_list_sites`.',
},
days: {
type: 'integer',
enum: [7, 28, 90],
description: 'Trailing window length in days. Default 28.',
},
},
required: ['property'],
},
handler: async (args) =>
bridgeRequest<{
ok: boolean;
error: string | null;
rowCount: number;
meta: unknown;
}>('POST', '/v1/action/gsc-fetch', args),
},
{
requiresDb: false,
name: 'ga4_list_properties',
description:
'List every GA4 property the connected Google account has access to. Returns `{ok, properties: [{name, displayName, account}]}`. Pass the desired property resource name to `ga4_fetch` next.',
inputSchema: { type: 'object', properties: {} },
handler: async () =>
bridgeRequest<unknown>('POST', '/v1/action/ga4-list-properties', {}),
},
{
requiresDb: false,
name: 'ga4_fetch',
description:
'Pull per-page GA4 metrics (sessions, users, bounceRate, engagementRate) for one property over a trailing window (7/28/90 days). Data is stored against crawled URLs; query the result via `query_ga4` or `get_url_analytics`. GA4 data is near-realtime so the window ends at today. Blocks until the pull finishes. Requires GA4 integration connected.',
inputSchema: {
type: 'object',
properties: {
property: {
type: 'string',
description: 'GA4 property resource name (`properties/<numeric-id>`). Get available values from `ga4_list_properties`.',
},
propertyName: {
type: 'string',
description: 'Friendly label persisted into the fetch meta for the UI. Optional — falls back to `property`.',
},
days: {
type: 'integer',
enum: [7, 28, 90],
description: 'Trailing window length in days. Default 28.',
},
},
required: ['property'],
},
handler: async (args) =>
bridgeRequest<{
ok: boolean;
error: string | null;
rowCount: number;
meta: unknown;
}>('POST', '/v1/action/ga4-fetch', args),
},
];
}
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.6.6",
"version": "0.6.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.6.6",
"version": "0.6.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -26,7 +26,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.6.6",
"version": "0.6.7",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -43,7 +43,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.6.6",
"version": "0.6.7",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -90,7 +90,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.6.6",
"version": "0.6.7",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -8934,7 +8934,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.6.6",
"version": "0.6.7",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8961,7 +8961,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.6.6",
"version": "0.6.7",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8973,7 +8973,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.6.6",
"version": "0.6.7",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.6.6",
"version": "0.6.7",
"private": true,
"type": "module",
"main": "./dist/index.js",