mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: MCP full parity (76 tools) + in-app integration setup guides
This commit is contained in:
1 parent
436104fc11
commit
e6755d79d2
16 files changed
+2830
-93
No files matched your search
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
Generated
+8
-8
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
Reference in new issue
Block a user