feat: integrations panel + rendering mode + broken-links export & cell select

This commit is contained in:
kemalai committed 2026-05-21 18:18:05 +03:00
1 parent 5a93a94ca1
commit dc6f440ff1
25 files changed
+1680 -172

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
+201
View File
@@ -0,0 +1,201 @@
/**
* Faz 7 — encrypted integration credential store.
*
* Secrets (API keys, OAuth client secrets, service-account JSON) are
* encrypted at rest with Electron `safeStorage` — OS-backed encryption
* (DPAPI on Windows, Keychain on macOS, libsecret/kwallet on Linux).
* This keeps FreeCrawl's zero-native-dependency promise: `safeStorage`
* is built into Electron, unlike `keytar` which needs node-gyp.
*
* On disk the ciphertext lives in `<userData>/credentials.enc.json`,
* deliberately separate from `preferences.json` so the sensitive file
* is obvious and a prefs wipe never touches it.
*
* File shape: `{ "<integrationId>": { "<fieldKey>": "<base64>" } }`
* where each value is either `safeStorage`-encrypted ciphertext (when
* OS encryption is available) or, as a clearly-flagged fallback on
* systems without a keyring, base64 plaintext.
*
* The renderer never receives secret plaintext — `getState()` returns
* only a `set` boolean per secret field. Non-secret fields (endpoint
* URLs, OAuth client IDs) do round-trip so the user can see/edit them.
*/
import { app, safeStorage } from 'electron';
import { join } from 'node:path';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import {
INTEGRATIONS,
getIntegration,
type IntegrationsState,
} from '@freecrawl/shared-types';
import * as logger from './logger.js';
/** Marks a stored value as base64 plaintext (no OS encryption available). */
const PLAINTEXT_PREFIX = 'pt:';
/** Marks a stored value as safeStorage ciphertext. */
const CIPHER_PREFIX = 'sb:';
type CredentialFile = Record<string, Record<string, string>>;
let cache: CredentialFile | null = null;
function filePath(): string {
return join(app.getPath('userData'), 'credentials.enc.json');
}
function load(): CredentialFile {
if (cache) return cache;
const path = filePath();
try {
if (existsSync(path)) {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
cache = parsed as CredentialFile;
return cache;
}
}
} catch (err) {
logger.log(
'warn',
'main',
`credentials store unreadable, starting fresh: ${(err as Error).message}`,
);
}
cache = {};
return cache;
}
function persist(): void {
try {
writeFileSync(filePath(), JSON.stringify(cache ?? {}, null, 2), 'utf8');
} catch (err) {
logger.log('error', 'main', `credentials store write failed: ${(err as Error).message}`);
}
}
/** Encrypt a value for storage. Falls back to base64 when the OS has no keyring. */
function encrypt(value: string): string {
try {
if (safeStorage.isEncryptionAvailable()) {
return CIPHER_PREFIX + safeStorage.encryptString(value).toString('base64');
}
} catch (err) {
logger.log('warn', 'main', `safeStorage encrypt failed: ${(err as Error).message}`);
}
return PLAINTEXT_PREFIX + Buffer.from(value, 'utf8').toString('base64');
}
/** Decrypt a stored value back to plaintext. Returns '' on any failure. */
function decrypt(stored: string): string {
try {
if (stored.startsWith(CIPHER_PREFIX)) {
const buf = Buffer.from(stored.slice(CIPHER_PREFIX.length), 'base64');
return safeStorage.decryptString(buf);
}
if (stored.startsWith(PLAINTEXT_PREFIX)) {
return Buffer.from(stored.slice(PLAINTEXT_PREFIX.length), 'base64').toString('utf8');
}
} catch (err) {
logger.log('warn', 'main', `credential decrypt failed: ${(err as Error).message}`);
}
return '';
}
/**
* Whether OS-backed encryption is active. The Settings panel surfaces a
* warning when this is false so the user knows secrets are only base64
* on this machine (typically a Linux box with no keyring).
*/
export function isSecureStorageAvailable(): boolean {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
/**
* Build the redacted state map for the renderer. Secret field values
* are blanked (only the `set` flag survives); non-secret fields carry
* their real value so the UI can show + edit them.
*/
export function getState(): IntegrationsState {
const store = load();
const out: IntegrationsState = {};
for (const def of INTEGRATIONS) {
const raw = store[def.id] ?? {};
const fields: IntegrationsState[string]['fields'] = {};
let allRequiredSet = true;
for (const field of def.fields) {
const stored = raw[field.key];
const set = typeof stored === 'string' && stored.length > 0;
fields[field.key] = {
set,
value: set && !field.secret ? decrypt(stored) : '',
};
if (!set && !field.optional) allRequiredSet = false;
}
out[def.id] = {
configured: def.fields.length > 0 && allRequiredSet,
fields,
};
}
return out;
}
/**
* Merge credential fields for one integration. Only the supplied keys
* are written; an omitted field keeps its stored value (so the UI can
* leave a "saved" secret untouched by not re-sending it). An empty
* string value deletes that field.
*/
export function setCredentials(id: string, fields: Record<string, string>): IntegrationsState {
const def = getIntegration(id);
if (!def) {
logger.log('warn', 'main', `integrationsSet: unknown integration "${id}"`);
return getState();
}
const store = load();
const entry = store[id] ?? {};
for (const field of def.fields) {
if (!(field.key in fields)) continue;
const value = fields[field.key] ?? '';
if (value.length === 0) {
delete entry[field.key];
} else {
entry[field.key] = encrypt(value);
}
}
if (Object.keys(entry).length > 0) {
store[id] = entry;
} else {
delete store[id];
}
persist();
return getState();
}
/** Wipe every stored field of one integration. */
export function clearCredentials(id: string): IntegrationsState {
const store = load();
if (store[id]) {
delete store[id];
persist();
}
return getState();
}
/**
* Decrypt the credentials of one integration for in-process API use.
* Returns plaintext field values — callers in the main process only;
* this must never cross the IPC boundary.
*/
export function resolveCredentials(id: string): Record<string, string> {
const store = load();
const raw = store[id] ?? {};
const out: Record<string, string> = {};
for (const [key, stored] of Object.entries(raw)) {
out[key] = decrypt(stored);
}
return out;
}
+46
View File
@@ -28,6 +28,8 @@ import {
type ExportJsonResult,
type ExportXmlInput,
type ExportXmlResult,
type ExportBrokenLinksInput,
type ExportBrokenLinksResult,
type ExportTabularInput,
type ExportTabularResult,
type DataDeleteByDomainInput,
@@ -90,6 +92,7 @@ import {
import {
Crawler,
exportUrlsToCsv,
exportBrokenLinksToCsv,
exportUrlsToJson,
exportUrlsToXml,
exportTabular,
@@ -107,6 +110,11 @@ import {
import { ProjectDb } from '@freecrawl/db';
import { buildAppMenu } from './menu.js';
import { isMenuLang, type MenuLang } from './menu-i18n.js';
import {
getState as getIntegrationsState,
setCredentials as setIntegrationCredentials,
clearCredentials as clearIntegrationCredentials,
} from './credentials.js';
import * as logger from './logger.js';
import { dbReaderPool, callReaderOrFallback } from './db-reader-pool.js';
import { dbWriterPool } from './db-writer-pool.js';
@@ -1837,6 +1845,20 @@ function registerIpc(): void {
return app.getPath('documents');
});
// V1 Faz 7 — integration credential store. Secrets are encrypted at
// rest with safeStorage; the renderer only ever sees the redacted
// state map (secret values blanked, non-secret values round-tripped).
ipcMain.handle(IPC.integrationsGetAll, () => getIntegrationsState());
ipcMain.handle(
IPC.integrationsSet,
(_e, id: string, fields: Record<string, string>) => {
return setIntegrationCredentials(id, fields);
},
);
ipcMain.handle(IPC.integrationsClear, (_e, id: string) => {
return clearIntegrationCredentials(id);
});
// Stream new entries to the logs window in coalesced batches. A heavy
// crawl emits 100–300 logs/s; sending each as its own IPC message
// saturated the renderer's event loop and caused visible UI stutters
@@ -2543,6 +2565,30 @@ function registerIpc(): void {
},
);
ipcMain.handle(
IPC.exportBrokenLinks,
async (
_e,
input: ExportBrokenLinksInput,
): Promise<ExportBrokenLinksResult> => {
let filePath = input.filePath;
if (!filePath) {
const res = await dialog.showSaveDialog(mainWindow!, {
defaultPath: 'freecrawl-broken-links.csv',
filters: [{ name: 'CSV', extensions: ['csv'] }],
});
if (res.canceled || !res.filePath) {
return { filePath: '', rowsWritten: 0 };
}
filePath = res.filePath;
}
const { rowsWritten } = await exportBrokenLinksToCsv(getDb(), filePath, {
internal: input.internal,
});
return { filePath, rowsWritten };
},
);
ipcMain.handle(
IPC.exportTabular,
async (_e, input: ExportTabularInput): Promise<ExportTabularResult> => {
+15
View File
@@ -11,6 +11,8 @@ import {
type ExportJsonResult,
type ExportXmlInput,
type ExportXmlResult,
type ExportBrokenLinksInput,
type ExportBrokenLinksResult,
type ExportTabularInput,
type ExportTabularResult,
type DataDeleteByDomainInput,
@@ -64,6 +66,7 @@ import {
type SettingsImportResult,
type ScheduleEntry,
type ScheduleSpec,
type IntegrationsState,
type OverviewCounts,
type SitemapGenerateInput,
type SitemapGenerateResult,
@@ -136,6 +139,10 @@ const api: FreeCrawlApi = {
ipcRenderer.invoke(IPC.exportJson, input),
exportXml: (input: ExportXmlInput): Promise<ExportXmlResult> =>
ipcRenderer.invoke(IPC.exportXml, input),
exportBrokenLinks: (
input: ExportBrokenLinksInput,
): Promise<ExportBrokenLinksResult> =>
ipcRenderer.invoke(IPC.exportBrokenLinks, input),
exportTabular: (input: ExportTabularInput): Promise<ExportTabularResult> =>
ipcRenderer.invoke(IPC.exportTabular, input),
dataDeleteByDomain: (
@@ -242,6 +249,14 @@ const api: FreeCrawlApi = {
pickDirectory: (input?: { title?: string; defaultPath?: string }): Promise<string | null> =>
ipcRenderer.invoke(IPC.pickDirectory, input),
defaultProjectDir: (): Promise<string> => ipcRenderer.invoke(IPC.defaultProjectDir),
integrationsGetAll: (): Promise<IntegrationsState> =>
ipcRenderer.invoke(IPC.integrationsGetAll),
integrationsSet: (
id: string,
fields: Record<string, string>,
): Promise<IntegrationsState> => ipcRenderer.invoke(IPC.integrationsSet, id, fields),
integrationsClear: (id: string): Promise<IntegrationsState> =>
ipcRenderer.invoke(IPC.integrationsClear, id),
onLogEntry: (cb) => subscribe<LogEntry>(IPC.logsEntry, cb),
onLogsBatch: (cb) => subscribe<LogEntry[]>(IPC.logsBatch, cb),
onLogsBusy: (cb) => subscribe<boolean>(IPC.logsBusy, cb),
@@ -31,6 +31,8 @@ import {
FolderOpen,
Lock,
Languages,
Plug,
ExternalLink,
type LucideIcon,
} from 'lucide-react';
import clsx from 'clsx';
@@ -39,8 +41,10 @@ import type {
CrawlMode,
CustomExtractionRule,
HttpAuth,
IntegrationDef,
IntegrationsState,
} from '@freecrawl/shared-types';
import { DEFAULT_CRAWL_CONFIG } from '@freecrawl/shared-types';
import { DEFAULT_CRAWL_CONFIG, INTEGRATIONS } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { InfoTip, type FieldInfo } from './InfoTip.js';
@@ -133,6 +137,8 @@ interface FormState {
perHostUserAgents: { hostPattern: string; userAgent: string }[];
proxyProfiles: { name: string; url: string }[];
proxyProfileActive: string;
// Faz 10 — page rendering strategy
renderingMode: 'text' | 'ajax';
}
type SectionKey =
@@ -156,6 +162,7 @@ type SectionKey =
| 'advanced'
| 'cookies'
| 'per-host-ua'
| 'integrations'
| 'storage'
| 'privacy'
| 'language';
@@ -295,6 +302,13 @@ const SECTIONS: SectionDef[] = [
icon: Send,
keywords: 'per host user agent subdomain mobile desktop pattern wildcard',
},
{
key: 'integrations',
label: 'Integrations',
icon: Plug,
keywords:
'integrations api key oauth google search console analytics gsc ga4 pagespeed ahrefs moz semrush majestic openai anthropic claude ollama sheets bigquery credentials',
},
{
key: 'storage',
label: 'Storage',
@@ -381,6 +395,7 @@ function configToForm(c: CrawlConfig): FormState {
perHostUserAgents: (c.perHostUserAgents ?? []).map((r) => ({ ...r })),
proxyProfiles: (c.proxyProfiles ?? []).map((p) => ({ ...p })),
proxyProfileActive: c.proxyProfileActive ?? '',
renderingMode: c.renderingMode ?? 'text',
};
}
@@ -576,6 +591,7 @@ export function SettingsDialog({ open, onClose }: Props) {
.map((p) => ({ name: p.name.trim(), url: p.url.trim() }))
.filter((p) => p.name && p.url),
proxyProfileActive: form.proxyProfileActive.trim(),
renderingMode: form.renderingMode,
});
onClose();
}
@@ -732,6 +748,7 @@ export function SettingsDialog({ open, onClose }: Props) {
{active === 'per-host-ua' && (
<PerHostUaPanel form={form} update={update} />
)}
{active === 'integrations' && <IntegrationsPanel />}
{active === 'storage' && <StoragePanel />}
{active === 'privacy' && <PrivacyPanel />}
{active === 'language' && <LanguagePanel />}
@@ -786,6 +803,28 @@ const UA_GOOGLEBOT_DESKTOP =
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
const UA_GOOGLEBOT_MOBILE =
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
const UA_CHROME_DESKTOP =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
const UA_CHROME_MOBILE =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36';
const UA_BINGBOT =
'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)';
const UA_FREECRAWL = DEFAULT_CRAWL_CONFIG.userAgent;
/**
* Quick-switch User-Agent presets. Picking one fills the User-Agent
* field below — the user can still hand-edit afterwards. The first
* entry is a no-op placeholder so the select can show "Custom / pick a
* preset…" while the field holds an unrecognised string.
*/
const UA_PRESETS: { label: string; ua: string }[] = [
{ label: 'Googlebot — Smartphone', ua: UA_GOOGLEBOT_MOBILE },
{ label: 'Googlebot — Desktop', ua: UA_GOOGLEBOT_DESKTOP },
{ label: 'Chrome — Desktop', ua: UA_CHROME_DESKTOP },
{ label: 'Chrome — Mobile (Pixel)', ua: UA_CHROME_MOBILE },
{ label: 'Bingbot', ua: UA_BINGBOT },
{ label: 'FreeCrawl SEO (default)', ua: UA_FREECRAWL },
];
const PRESETS: PresetDef[] = [
{
@@ -1078,6 +1117,31 @@ function CrawlerPanel({ form, update }: PanelProps) {
example="On (default) — cheap I/O, high SEO value."
/>
</div>
<div className="mt-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<label className="flex flex-col gap-1">
<FieldLabel
label={t('settingsPanels.crawler.renderingMode', { defaultValue: 'Rendering Mode' })}
info="Text Only fetches the raw HTML response as-is — fast and deterministic. Old AJAX Crawling Scheme rewrites hashbang (#!) URLs to Google's deprecated ?_escaped_fragment_= form so a pre-rendering server returns the snapshot. Full JavaScript rendering is a V2 item."
example="Text Only for server-rendered / static sites; Old AJAX only for legacy hashbang SPAs."
/>
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={form.renderingMode}
onChange={(e) => update('renderingMode', e.target.value as 'text' | 'ajax')}
>
<option value="text">
{t('settingsPanels.crawler.renderTextOnly', { defaultValue: 'Text Only (default)' })}
</option>
<option value="ajax">
{t('settingsPanels.crawler.renderAjax', { defaultValue: 'Old AJAX Crawling Scheme (_escaped_fragment_)' })}
</option>
<option value="js" disabled>
{t('settingsPanels.crawler.renderJs', { defaultValue: 'JavaScript Rendering (coming in V2)' })}
</option>
</select>
</label>
</div>
</>
);
}
@@ -1187,6 +1251,29 @@ function RequestsPanel({ form, update }: PanelProps) {
<p className="mb-3 text-[11px] text-surface-400">
{t('settingsPanels.requests.intro', { defaultValue: 'HTTP headers sent with every request.' })}
</p>
<label className="mb-2 flex flex-col gap-1">
<FieldLabel
label={t('settingsPanels.requests.uaPreset', { defaultValue: 'Quick User-Agent preset' })}
info="Picking a preset fills the User-Agent field below — you can still hand-edit it afterwards. Switch between Googlebot Smartphone / Desktop to compare how a site responds to mobile vs desktop crawlers."
example="Googlebot — Smartphone matches Google's mobile-first indexing crawler."
/>
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={UA_PRESETS.find((p) => p.ua === form.userAgent)?.ua ?? ''}
onChange={(e) => {
if (e.target.value) update('userAgent', e.target.value);
}}
>
<option value="">
{t('settingsPanels.requests.uaCustom', { defaultValue: 'Custom / pick a preset…' })}
</option>
{UA_PRESETS.map((p) => (
<option key={p.label} value={p.ua}>
{p.label}
</option>
))}
</select>
</label>
<Text
label="User-Agent"
value={form.userAgent}
@@ -2504,6 +2591,248 @@ function BoolLabel({
);
}
/**
* V1 Faz 7 — Integrations panel. The credential-management foundation:
* lists every integration from the `INTEGRATIONS` catalog grouped by
* category, lets the user paste API keys / OAuth client credentials,
* and stores them safeStorage-encrypted via the credential-store IPC.
*
* Secret field values never come back from the main process — a
* configured secret renders as a "saved" placeholder; typing replaces
* it. The actual provider API calls land in follow-up work; this panel
* is the storage + status surface they will read from.
*/
const INTEGRATION_CATEGORIES: { key: IntegrationDef['category']; label: string }[] = [
{ key: 'ai', label: 'AI' },
{ key: 'performance', label: 'Performance' },
{ key: 'seo', label: 'SEO Data' },
{ key: 'google', label: 'Google' },
];
const AUTH_TYPE_LABEL: Record<IntegrationDef['authType'], string> = {
'api-key': 'API Key',
'oauth-byoc': 'OAuth (your own client)',
'service-account': 'Service Account',
local: 'Local',
};
function IntegrationsPanel() {
const { t } = useTranslation();
const [state, setState] = useState<IntegrationsState | null>(null);
// Per-integration → per-field draft text. Only fields the user has
// actually typed into are tracked; a save sends just these so an
// untouched "saved" secret is never overwritten.
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({});
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<Record<string, string>>({});
useEffect(() => {
void window.freecrawl.integrationsGetAll().then(setState);
}, []);
const setDraft = (id: string, key: string, value: string) => {
setDrafts((d) => ({ ...d, [id]: { ...(d[id] ?? {}), [key]: value } }));
};
const save = async (id: string) => {
const fields = drafts[id];
if (!fields || Object.keys(fields).length === 0) return;
setBusy(id);
try {
const next = await window.freecrawl.integrationsSet(id, fields);
setState(next);
setDrafts((d) => ({ ...d, [id]: {} }));
setNotice((n) => ({ ...n, [id]: t('integrations.saved', { defaultValue: 'Saved.' }) }));
} finally {
setBusy(null);
}
};
const clear = async (id: string) => {
if (!window.confirm(t('integrations.confirmClear', { defaultValue: 'Remove all stored credentials for this integration?' }))) {
return;
}
setBusy(id);
try {
const next = await window.freecrawl.integrationsClear(id);
setState(next);
setDrafts((d) => ({ ...d, [id]: {} }));
setNotice((n) => ({ ...n, [id]: t('integrations.cleared', { defaultValue: 'Removed.' }) }));
} finally {
setBusy(null);
}
};
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
{t('integrations.intro', { defaultValue: 'Connect external services with your own credentials. API keys and secrets are encrypted at rest using your operating system\'s secure storage and never leave this machine.' })}
</p>
<div className="mb-3 rounded border border-blue-700/40 bg-blue-900/15 px-3 py-2 text-[11px] text-blue-200">
{t('integrations.byocNote', { defaultValue: 'Google integrations use a "bring your own client" model — you create your own Google Cloud OAuth client and paste its ID/secret, so no shared FreeCrawl app or verification is involved.' })}
</div>
{INTEGRATION_CATEGORIES.map((cat) => {
const items = INTEGRATIONS.filter((i) => i.category === cat.key);
if (items.length === 0) return null;
return (
<div key={cat.key} className="mb-4">
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
{cat.label}
</div>
<div className="space-y-2">
{items.map((def) => (
<IntegrationCard
key={def.id}
def={def}
state={state?.[def.id]}
draft={drafts[def.id] ?? {}}
busy={busy === def.id}
notice={notice[def.id]}
onDraft={(k, v) => setDraft(def.id, k, v)}
onSave={() => void save(def.id)}
onClear={() => void clear(def.id)}
/>
))}
</div>
</div>
);
})}
</>
);
}
function IntegrationCard({
def,
state,
draft,
busy,
notice,
onDraft,
onSave,
onClear,
}: {
def: IntegrationDef;
state: IntegrationsState[string] | undefined;
draft: Record<string, string>;
busy: boolean;
notice: string | undefined;
onDraft: (key: string, value: string) => void;
onSave: () => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const configured = state?.configured ?? false;
const hasDraft = Object.values(draft).some((v) => v.length > 0);
return (
<div className="rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-1 flex items-center gap-2">
<span className="text-[12px] font-medium text-surface-100">{def.name}</span>
<span className="rounded bg-surface-800 px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-surface-400">
{AUTH_TYPE_LABEL[def.authType]}
</span>
<span
className={clsx(
'ml-auto rounded px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide',
configured
? 'bg-emerald-900/40 text-emerald-300'
: 'bg-surface-800 text-surface-500',
)}
>
{configured
? t('integrations.connected', { defaultValue: 'Configured' })
: t('integrations.notConfigured', { defaultValue: 'Not set' })}
</span>
</div>
<p className="mb-2 text-[11px] text-surface-400">{def.description}</p>
<div className="space-y-2">
{def.fields.map((field) => {
const fieldState = state?.fields[field.key];
const savedSecret = field.secret && (fieldState?.set ?? false);
// Non-secret fields show their stored value until edited;
// secret fields show a "saved" placeholder and only carry a
// draft once the user types.
const value =
field.key in draft
? draft[field.key]!
: field.secret
? ''
: (fieldState?.value ?? '');
const placeholder = savedSecret
? t('integrations.savedPlaceholder', { defaultValue: 'Saved — type to replace' })
: (field.placeholder ?? '');
return (
<label key={field.key} className="flex flex-col gap-0.5">
<span className="text-[10px] text-surface-400">
{field.label}
{field.optional && (
<span className="ml-1 text-surface-600">
{t('integrations.optional', { defaultValue: '(optional)' })}
</span>
)}
</span>
{field.multiline ? (
<textarea
className="h-20 resize-y rounded border border-surface-700 bg-surface-950 px-2 py-1 font-mono text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={value}
placeholder={placeholder}
onChange={(e) => onDraft(field.key, e.target.value)}
spellCheck={false}
/>
) : (
<input
type={field.secret ? 'password' : 'text'}
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={value}
placeholder={placeholder}
onChange={(e) => onDraft(field.key, e.target.value)}
spellCheck={false}
autoComplete="off"
/>
)}
</label>
);
})}
</div>
<p className="mt-2 text-[10px] leading-relaxed text-surface-500">{def.setupHint}</p>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
className="rounded bg-blue-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSave}
disabled={busy || !hasDraft}
>
{t('common.save', { defaultValue: 'Save' })}
</button>
{configured && (
<button
type="button"
className="rounded border border-red-700/60 px-2.5 py-1 text-[11px] text-red-300 hover:bg-red-900/20 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClear}
disabled={busy}
>
{t('common.remove', { defaultValue: 'Remove' })}
</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"
>
{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>}
</div>
);
}
/**
* V1 Faz 9 — Storage panel. Lets the user pick a default folder for new
* `.seoproject` files. Saved immediately to app prefs (not the per-crawl
@@ -4,6 +4,18 @@
"urls": "URLs",
"percentOfTotal": "% of Total"
},
"integrations": {
"intro": "Connect external services with your own credentials. API keys and secrets are encrypted at rest using your operating system's secure storage and never leave this machine.",
"byocNote": "Google integrations use a \"bring your own client\" model — you create your own Google Cloud OAuth client and paste its ID/secret, so no shared FreeCrawl app or verification is involved.",
"connected": "Configured",
"notConfigured": "Not set",
"savedPlaceholder": "Saved — type to replace",
"optional": "(optional)",
"getCredentials": "Get credentials",
"saved": "Saved.",
"cleared": "Removed.",
"confirmClear": "Remove all stored credentials for this integration?"
},
"common": {
"save": "Save",
"cancel": "Cancel",
@@ -303,13 +315,21 @@
"speedHintPrefix": "Looking for crawl speed?",
"speedHintBody": "Thread count, requests per second, per-request delay, and retries live in the",
"speedHintLink": "Speed",
"speedHintSuffix": "section — that's where you cap how fast the crawler hits a server."
"speedHintSuffix": "section — that's where you cap how fast the crawler hits a server.",
"renderingMode": "Rendering Mode",
"renderTextOnly": "Text Only (default)",
"renderAjax": "Old AJAX Crawling Scheme (_escaped_fragment_)",
"renderJs": "JavaScript Rendering (coming in V2)"
},
"speed": {
"intro": "Control crawl throughput. Increasing parallelism speeds the crawl, but every extra request adds load on the target server — pick numbers a host that you don't own can absorb without rate-limiting / 429s.",
"effectiveCeiling": "Effective ceiling"
},
"requests": { "intro": "HTTP headers sent with every request." },
"requests": {
"intro": "HTTP headers sent with every request.",
"uaPreset": "Quick User-Agent preset",
"uaCustom": "Custom / pick a preset…"
},
"filters": { "intro": "URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL." },
"customSearch": {
"intro1": "Flag pages whose body contains any of these terms. Two modes per line:",
@@ -489,7 +509,11 @@
"copyUrl": "Copy URL",
"copyUrls": "Copy {{n}} URLs",
"copyValue": "Copy Value",
"openInBrowser": "Open in Browser"
"openInBrowser": "Open in Browser",
"copySourceUrl": "Copy Source URL",
"copyTargetUrl": "Copy Target URL",
"openSourceUrl": "Open Source URL",
"openTargetUrl": "Open Target URL"
},
"diag": {
"fromHeaders": "From the response headers",
@@ -602,7 +626,8 @@
"externalOnly": "External only",
"linksUnit": "broken links",
"noBrokenLinks": "No broken links",
"noBrokenLinksHint": "Run a crawl that includes external links to see broken links."
"noBrokenLinksHint": "Run a crawl that includes external links to see broken links.",
"cellsSelected": "{{n}} cell(s) selected"
},
"urlsTab": {
"searchPlaceholder": "Search URLs / titles…",
@@ -4,6 +4,18 @@
"urls": "URL'ler",
"percentOfTotal": "Toplamın %"
},
"integrations": {
"intro": "Harici servisleri kendi kimlik bilgilerinle bağla. API anahtarları ve secret'lar işletim sisteminin güvenli depolaması ile şifreli saklanır ve bu makineden asla çıkmaz.",
"byocNote": "Google entegrasyonları \"kendi client'ını getir\" modeli kullanır — kendi Google Cloud OAuth client'ını oluşturup ID/secret'ını yapıştırırsın; ortak bir FreeCrawl uygulaması veya doğrulama gerekmez.",
"connected": "Yapılandırıldı",
"notConfigured": "Ayarlanmadı",
"savedPlaceholder": "Kayıtlı — değiştirmek için yaz",
"optional": "(isteğe bağlı)",
"getCredentials": "Kimlik bilgisi al",
"saved": "Kaydedildi.",
"cleared": "Kaldırıldı.",
"confirmClear": "Bu entegrasyonun saklanan tüm kimlik bilgileri kaldırılsın mı?"
},
"common": {
"save": "Kaydet",
"cancel": "İptal",
@@ -302,13 +314,21 @@
"speedHintPrefix": "Crawl hızı mı arıyorsun?",
"speedHintBody": "Thread sayısı, saniyedeki istek, istek-başına gecikme ve yeniden denemeler şurada yaşar:",
"speedHintLink": "Hız",
"speedHintSuffix": "bölümü — crawler'ın sunucuya ne hızda vurduğunu orada kıs."
"speedHintSuffix": "bölümü — crawler'ın sunucuya ne hızda vurduğunu orada kıs.",
"renderingMode": "Render Modu",
"renderTextOnly": "Yalnızca Metin (varsayılan)",
"renderAjax": "Eski AJAX Crawl Şeması (_escaped_fragment_)",
"renderJs": "JavaScript Render (V2'de gelecek)"
},
"speed": {
"intro": "Crawl throughput'unu kontrol et. Paralelliği artırmak crawl'u hızlandırır, ama her ek istek hedef sunucuya yük bindirir — sahip olmadığın bir host'un rate-limit / 429 olmadan emebileceği sayılar seç.",
"effectiveCeiling": "Etkin tavan"
},
"requests": { "intro": "Her istekle gönderilen HTTP başlıkları." },
"requests": {
"intro": "Her istekle gönderilen HTTP başlıkları.",
"uaPreset": "Hızlı User-Agent ön ayarı",
"uaCustom": "Özel / bir ön ayar seç…"
},
"filters": { "intro": "URL izin/yasak listesi. Kalıplar URL'nin tamamına karşı test edilen JavaScript regex'tir." },
"customSearch": {
"intro1": "Gövdesinde bu terimlerden herhangi birini içeren sayfaları işaretle. Satır başına iki mod:",
@@ -488,7 +508,11 @@
"copyUrl": "URL'yi Kopyala",
"copyUrls": "{{n}} URL Kopyala",
"copyValue": "Değeri Kopyala",
"openInBrowser": "Tarayıcıda Aç"
"openInBrowser": "Tarayıcıda Aç",
"copySourceUrl": "Kaynak URL'yi Kopyala",
"copyTargetUrl": "Hedef URL'yi Kopyala",
"openSourceUrl": "Kaynak URL'yi Aç",
"openTargetUrl": "Hedef URL'yi Aç"
},
"diag": {
"fromHeaders": "Yanıt başlıklarından",
@@ -601,7 +625,8 @@
"externalOnly": "Yalnızca Harici",
"linksUnit": "kırık link",
"noBrokenLinks": "Kırık link yok",
"noBrokenLinksHint": "Kırık linkleri görmek için harici linkleri de içeren bir crawl çalıştır."
"noBrokenLinksHint": "Kırık linkleri görmek için harici linkleri de içeren bir crawl çalıştır.",
"cellsSelected": "{{n}} hücre seçili"
},
"urlsTab": {
"searchPlaceholder": "URL / başlık ara…",
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Download } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { BrokenLinkRow } from '@freecrawl/shared-types';
@@ -19,6 +20,82 @@ const POLL_MS_RUNNING = 3000;
const POLL_MS_IDLE = 30_000;
const PAGE_SIZE = 5000;
interface BrokenColumn {
key: string;
label: string;
width: number;
align?: 'right';
info?: string;
example?: string;
}
const COLUMNS: BrokenColumn[] = [
{
key: 'fromUrl',
label: 'Source URL',
width: 400,
info: 'Page that contains the broken link.',
example: 'https://example.com/blog/post-1',
},
{
key: 'fromStatus',
label: 'Source Status',
width: 110,
align: 'right',
info: 'HTTP status of the source page itself. Usually 200; if non-2xx the broken link may be inherited.',
example: '200',
},
{
key: 'toUrl',
label: 'Target URL',
width: 400,
info: 'The URL that fails to resolve (4xx/5xx/network error).',
example: 'https://other.com/missing-page',
},
{
key: 'toStatus',
label: 'Target Status',
width: 110,
align: 'right',
info: 'HTTP status returned by the target. 0 = network failure (DNS, TLS, timeout).',
example: '404',
},
{
key: 'anchor',
label: 'Anchor',
width: 240,
info: 'Anchor text of the broken link as rendered in the source page.',
example: 'Read the full article →',
},
{
key: 'isInternal',
label: 'Type',
width: 80,
info: 'Whether the broken target is on the same site (internal) or a different host (external).',
example: 'internal / external',
},
];
/** Plain-text value of one cell — used for copy + clipboard. */
function cellValue(row: BrokenLinkRow, colIdx: number): string {
switch (COLUMNS[colIdx]?.key) {
case 'fromUrl':
return row.fromUrl;
case 'fromStatus':
return row.fromStatusCode === null ? '' : String(row.fromStatusCode);
case 'toUrl':
return row.toUrl;
case 'toStatus':
return row.toStatusCode === null ? '' : String(row.toStatusCode);
case 'anchor':
return row.anchor ?? '';
case 'isInternal':
return row.isInternal ? 'internal' : 'external';
default:
return '';
}
}
export function BrokenLinksTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -28,8 +105,30 @@ export function BrokenLinksTab() {
const [rows, setRows] = useState<BrokenLinkRow[]>([]);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [exporting, setExporting] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// ---- Cell selection -----------------------------------------------------
// Keyed "rowIdx:colIdx". Index-based (matches the detail panel's
// Links view): reset whenever the row set is rebuilt by a filter
// change so stale indices can't linger.
const [selected, setSelected] = useState<Set<string>>(new Set());
const anchorRef = useRef<{ r: number; c: number } | null>(null);
// Drag-selection snapshot. `base` is the pre-drag selection so a
// Ctrl+drag unions the dragged rectangle with prior picks.
const dragRef = useRef<
| { aR: number; aC: number; additive: boolean; base: Set<string> }
| null
>(null);
const [menu, setMenu] = useState<
{ x: number; y: number; rowIdx: number; colIdx: number } | null
>(null);
// Latest selection + rows for the document-level Ctrl+C listener.
const selectedRef = useRef(selected);
const rowsRef = useRef(rows);
selectedRef.current = selected;
rowsRef.current = rows;
// The sidebar toggles between "all", "internal-only", and "external-only"
// via activeCategory; everything else stays "all".
const internal: 'all' | 'internal' | 'external' =
@@ -61,6 +160,62 @@ export function BrokenLinksTab() {
};
}, [search, internal, dataVersion, progress?.running]);
// Drop the cell selection when the filter changes — row indices are
// about to be reassigned, so keeping the old keys would highlight the
// wrong cells. A poll refresh keeps the same filter, so selection
// survives that.
useEffect(() => {
setSelected(new Set());
setMenu(null);
anchorRef.current = null;
dragRef.current = null;
}, [search, internal]);
// Any mouseup ends a drag — even one released outside the table.
useEffect(() => {
const onUp = () => {
dragRef.current = null;
};
document.addEventListener('mouseup', onUp);
return () => document.removeEventListener('mouseup', onUp);
}, []);
// Ctrl/Cmd+C copies the selected cells as TSV.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key !== 'c' && e.key !== 'C') return;
const sel = selectedRef.current;
if (sel.size === 0) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) {
return;
}
e.preventDefault();
void copySelectedCells(sel, rowsRef.current);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
// Close the context menu on outside click / scroll / Escape.
useEffect(() => {
if (!menu) return;
const close = () => setMenu(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
document.addEventListener('mousedown', close);
document.addEventListener('scroll', close, true);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('scroll', close, true);
document.removeEventListener('keydown', onKey);
};
}, [menu]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
@@ -72,60 +227,71 @@ export function BrokenLinksTab() {
},
});
const columns: {
key: string;
label: string;
width: number;
align?: 'right';
info?: string;
example?: string;
}[] = [
{
key: 'fromUrl',
label: 'Source URL',
width: 400,
info: 'Page that contains the broken link.',
example: 'https://example.com/blog/post-1',
},
{
key: 'fromStatus',
label: 'Source Status',
width: 110,
align: 'right',
info: 'HTTP status of the source page itself. Usually 200; if non-2xx the broken link may be inherited.',
example: '200',
},
{
key: 'toUrl',
label: 'Target URL',
width: 400,
info: 'The URL that fails to resolve (4xx/5xx/network error).',
example: 'https://other.com/missing-page',
},
{
key: 'toStatus',
label: 'Target Status',
width: 110,
align: 'right',
info: 'HTTP status returned by the target. 0 = network failure (DNS, TLS, timeout).',
example: '404',
},
{
key: 'anchor',
label: 'Anchor',
width: 240,
info: 'Anchor text of the broken link as rendered in the source page.',
example: 'Read the full article →',
},
{
key: 'isInternal',
label: 'Type',
width: 80,
info: 'Whether the broken target is on the same site (internal) or a different host (external).',
example: 'internal / external',
},
];
const totalWidth = ROW_NUM_WIDTH + columns.reduce((n, c) => n + c.width, 0);
const totalWidth = ROW_NUM_WIDTH + COLUMNS.reduce((n, c) => n + c.width, 0);
// Export every broken link (not just the loaded page) to CSV. The
// export honours the active internal/external scope filter so what
// you see is what you get.
const handleExport = async () => {
if (total === 0 || exporting) return;
setExporting(true);
try {
await window.freecrawl.exportBrokenLinks({ internal });
} finally {
setExporting(false);
}
};
// --- selection handlers --------------------------------------------------
const cellMouseDown = (r: number, c: number, e: React.MouseEvent) => {
if (e.button !== 0) return; // left button only
if (e.shiftKey && anchorRef.current) {
// Extend a rectangle from the existing anchor.
setSelected(rangeKeys(anchorRef.current.r, anchorRef.current.c, r, c));
dragRef.current = { aR: anchorRef.current.r, aC: anchorRef.current.c, additive: false, base: new Set() };
return;
}
if (e.ctrlKey || e.metaKey) {
// Toggle this cell, keep the rest.
setSelected((prev) => {
const next = new Set(prev);
const k = `${r}:${c}`;
if (next.has(k)) next.delete(k);
else next.add(k);
anchorRef.current = { r, c };
dragRef.current = { aR: r, aC: c, additive: true, base: new Set(next) };
return next;
});
return;
}
// Plain click — select just this cell and arm a drag from it.
anchorRef.current = { r, c };
dragRef.current = { aR: r, aC: c, additive: false, base: new Set() };
setSelected(new Set([`${r}:${c}`]));
};
const cellMouseEnter = (r: number, c: number) => {
const drag = dragRef.current;
if (!drag) return;
const rect = rangeKeys(drag.aR, drag.aC, r, c);
if (drag.additive) {
for (const k of drag.base) rect.add(k);
}
setSelected(rect);
};
const cellContextMenu = (r: number, c: number, e: React.MouseEvent) => {
e.preventDefault();
// If the right-clicked cell isn't already selected, select just it
// so the menu's "Copy cells" scope is unambiguous.
if (!selected.has(`${r}:${c}`)) {
anchorRef.current = { r, c };
setSelected(new Set([`${r}:${c}`]));
}
setMenu({ x: e.clientX, y: e.clientY, rowIdx: r, colIdx: c });
};
const isSelected = (r: number, c: number): boolean => selected.has(`${r}:${c}`);
return (
<div className="flex h-full flex-col">
@@ -146,6 +312,25 @@ export function BrokenLinksTab() {
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('brokenTab.linksUnit', { defaultValue: 'broken links' })}
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: rows.length.toLocaleString() })})</span>
</div>
<button
type="button"
onClick={handleExport}
disabled={total === 0 || exporting}
className={clsx(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[11px] transition',
total === 0 || exporting
? 'cursor-not-allowed border-surface-800 text-surface-600'
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
title={
total === 0
? t('urlsTab.noDataToExport', { defaultValue: 'No data to export' })
: t('urlsTab.exportThisTable', { defaultValue: 'Export this table' })
}
>
<Download className="h-3.5 w-3.5" />
<span>{t('urlsTab.export', { defaultValue: 'Export' })}</span>
</button>
</div>
<div ref={scrollRef} className="relative flex-1 select-none overflow-auto">
@@ -164,7 +349,7 @@ export function BrokenLinksTab() {
>
{t('imagesTab.row', { defaultValue: 'Row' })}
</div>
{columns.map((c) => (
{COLUMNS.map((c) => (
<div
key={c.key}
className="flex items-center gap-1 border-b border-r border-surface-800 pl-2 pr-3 font-medium text-surface-300"
@@ -198,7 +383,7 @@ export function BrokenLinksTab() {
<div
key={vi.key}
data-index={vi.index}
className="absolute left-0 top-0 flex items-center border-b border-surface-900 text-[11px] hover:bg-surface-900/60"
className="absolute left-0 top-0 flex items-center border-b border-surface-900 text-[11px] hover:bg-surface-900/40"
style={{
transform: `translateY(${vi.start}px)`,
height: ROW_HEIGHT,
@@ -216,96 +401,27 @@ export function BrokenLinksTab() {
>
{vi.index + 1}
</div>
<div
className="overflow-hidden px-2"
style={{
width: columns[0]!.width,
minWidth: columns[0]!.width,
flex: `0 0 ${columns[0]!.width}px`,
}}
>
<span className="block truncate font-mono text-surface-100" title={row.fromUrl}>
{row.fromUrl}
</span>
</div>
<div
className="overflow-hidden px-2 text-right"
style={{
width: columns[1]!.width,
minWidth: columns[1]!.width,
flex: `0 0 ${columns[1]!.width}px`,
}}
>
<span
{COLUMNS.map((c, colIdx) => (
<div
key={c.key}
className={clsx(
'inline-block rounded px-1.5 font-mono text-[10px]',
statusClasses(row.fromStatusCode),
'flex cursor-cell items-center overflow-hidden border-r border-surface-900 px-2',
c.align === 'right' && 'justify-end',
isSelected(vi.index, colIdx) && 'bg-accent-500/30 text-surface-50',
)}
style={{
width: c.width,
minWidth: c.width,
flex: `0 0 ${c.width}px`,
height: '100%',
}}
onMouseDown={(e) => cellMouseDown(vi.index, colIdx, e)}
onMouseEnter={() => cellMouseEnter(vi.index, colIdx)}
onContextMenu={(e) => cellContextMenu(vi.index, colIdx, e)}
>
{row.fromStatusCode ?? '—'}
</span>
</div>
<div
className="overflow-hidden px-2"
style={{
width: columns[2]!.width,
minWidth: columns[2]!.width,
flex: `0 0 ${columns[2]!.width}px`,
}}
>
<span className="block truncate font-mono text-surface-100" title={row.toUrl}>
{row.toUrl}
</span>
</div>
<div
className="overflow-hidden px-2 text-right"
style={{
width: columns[3]!.width,
minWidth: columns[3]!.width,
flex: `0 0 ${columns[3]!.width}px`,
}}
>
<span
className={clsx(
'inline-block rounded px-1.5 font-mono text-[10px]',
statusClasses(row.toStatusCode),
)}
>
{row.toStatusCode ?? '—'}
</span>
</div>
<div
className="overflow-hidden px-2"
style={{
width: columns[4]!.width,
minWidth: columns[4]!.width,
flex: `0 0 ${columns[4]!.width}px`,
}}
>
<span
className="block truncate text-surface-200"
title={row.anchor ?? undefined}
>
{row.anchor ?? <span className="text-surface-700">—</span>}
</span>
</div>
<div
className="overflow-hidden px-2"
style={{
width: columns[5]!.width,
minWidth: columns[5]!.width,
flex: `0 0 ${columns[5]!.width}px`,
}}
>
<span
className={clsx(
'text-[10px]',
row.isInternal ? 'text-surface-300' : 'text-surface-500',
)}
>
{row.isInternal ? 'internal' : 'external'}
</span>
</div>
{renderCellContent(row, c.key)}
</div>
))}
<div className="flex-1" />
</div>
);
@@ -332,6 +448,11 @@ export function BrokenLinksTab() {
className="flex shrink-0 items-center justify-end gap-4 border-t border-surface-800 bg-surface-900/60 px-3 text-[11px] text-surface-400"
style={{ height: STATUS_BAR_HEIGHT }}
>
{selected.size > 0 && (
<span className="mr-auto text-surface-500">
{t('brokenTab.cellsSelected', { defaultValue: '{{n}} cell(s) selected', n: selected.size.toLocaleString() })}
</span>
)}
<span>
Total:{' '}
<span className="font-mono tabular-nums text-surface-200">
@@ -339,10 +460,163 @@ export function BrokenLinksTab() {
</span>
</span>
</div>
{menu && rows[menu.rowIdx] && (
<BrokenLinkCellMenu
x={menu.x}
y={menu.y}
row={rows[menu.rowIdx]!}
selectionSize={selected.size}
onCopyCells={() => {
void copySelectedCells(selected, rows);
setMenu(null);
}}
onCopySource={() => {
void writeTextToClipboard(rows[menu.rowIdx]!.fromUrl);
setMenu(null);
}}
onCopyTarget={() => {
void writeTextToClipboard(rows[menu.rowIdx]!.toUrl);
setMenu(null);
}}
onOpenSource={() => {
window.open(rows[menu.rowIdx]!.fromUrl, '_blank');
setMenu(null);
}}
onOpenTarget={() => {
window.open(rows[menu.rowIdx]!.toUrl, '_blank');
setMenu(null);
}}
onClose={() => setMenu(null)}
/>
)}
</div>
);
}
/** Render the visible content of one broken-link cell. */
function renderCellContent(row: BrokenLinkRow, colKey: string): ReactNode {
switch (colKey) {
case 'fromUrl':
return (
<span className="block truncate font-mono text-surface-100" title={row.fromUrl}>
{row.fromUrl}
</span>
);
case 'fromStatus':
return (
<span
className={clsx(
'inline-block rounded px-1.5 font-mono text-[10px]',
statusClasses(row.fromStatusCode),
)}
>
{row.fromStatusCode ?? '—'}
</span>
);
case 'toUrl':
return (
<span className="block truncate font-mono text-surface-100" title={row.toUrl}>
{row.toUrl}
</span>
);
case 'toStatus':
return (
<span
className={clsx(
'inline-block rounded px-1.5 font-mono text-[10px]',
statusClasses(row.toStatusCode),
)}
>
{row.toStatusCode ?? '—'}
</span>
);
case 'anchor':
return (
<span className="block truncate text-surface-200" title={row.anchor ?? undefined}>
{row.anchor ?? <span className="text-surface-700">—</span>}
</span>
);
case 'isInternal':
return (
<span
className={clsx(
'text-[10px]',
row.isInternal ? 'text-surface-300' : 'text-surface-500',
)}
>
{row.isInternal ? 'internal' : 'external'}
</span>
);
default:
return null;
}
}
/** Every "rowIdx:colIdx" key inside the rectangle spanned by two cells. */
function rangeKeys(aR: number, aC: number, r: number, c: number): Set<string> {
const minR = Math.min(aR, r);
const maxR = Math.max(aR, r);
const minC = Math.min(aC, c);
const maxC = Math.max(aC, c);
const out = new Set<string>();
for (let row = minR; row <= maxR; row++) {
for (let col = minC; col <= maxC; col++) {
out.add(`${row}:${col}`);
}
}
return out;
}
/**
* Copy the selected cells to the clipboard as TSV — grouped by row,
* columns ascending, so a paste into a spreadsheet lands in matching
* grid positions.
*/
async function copySelectedCells(
selected: Set<string>,
rows: BrokenLinkRow[],
): Promise<void> {
if (selected.size === 0) return;
const byRow = new Map<number, number[]>();
for (const k of selected) {
const [rs, cs] = k.split(':');
const r = Number(rs);
const c = Number(cs);
if (!Number.isFinite(r) || !Number.isFinite(c)) continue;
const list = byRow.get(r);
if (list) list.push(c);
else byRow.set(r, [c]);
}
const lines: string[] = [];
for (const r of [...byRow.keys()].sort((a, b) => a - b)) {
const row = rows[r];
if (!row) continue;
const cols = (byRow.get(r) ?? []).sort((a, b) => a - b);
lines.push(cols.map((c) => cellValue(row, c)).join('\t'));
}
await writeTextToClipboard(lines.join('\n'));
}
/** Clipboard write with a hidden-textarea fallback for unfocused windows. */
async function writeTextToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(ta);
}
}
}
function statusClasses(code: number | null): string {
if (code === null) return 'bg-surface-800 text-surface-400';
if (code >= 400 && code < 500) return 'bg-orange-900/60 text-orange-300';
@@ -351,3 +625,74 @@ function statusClasses(code: number | null): string {
if (code >= 200 && code < 300) return 'bg-emerald-900/60 text-emerald-300';
return 'bg-surface-800 text-surface-400';
}
/** In-page right-click menu for a broken-link cell. */
function BrokenLinkCellMenu({
x,
y,
row,
selectionSize,
onCopyCells,
onCopySource,
onCopyTarget,
onOpenSource,
onOpenTarget,
onClose,
}: {
x: number;
y: number;
row: BrokenLinkRow;
selectionSize: number;
onCopyCells: () => void;
onCopySource: () => void;
onCopyTarget: () => void;
onOpenSource: () => void;
onOpenTarget: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items: { label: string; action: () => void; disabled?: boolean }[] = [
{
label:
selectionSize > 1
? t('cellMenu.copyCells', { defaultValue: 'Copy {{n}} Cells', n: selectionSize.toLocaleString() })
: t('cellMenu.copyCell', { defaultValue: 'Copy Cell' }),
action: onCopyCells,
disabled: selectionSize === 0,
},
{ label: t('cellMenu.copySourceUrl', { defaultValue: 'Copy Source URL' }), action: onCopySource },
{ label: t('cellMenu.copyTargetUrl', { defaultValue: 'Copy Target URL' }), action: onCopyTarget },
{ label: t('cellMenu.openSourceUrl', { defaultValue: 'Open Source URL' }), action: onOpenSource },
{ label: t('cellMenu.openTargetUrl', { defaultValue: 'Open Target URL' }), action: onOpenTarget },
];
// Reference `row` so the menu re-anchors if the underlying row swaps.
void row;
return (
<div
className="fixed z-50 min-w-[180px] rounded border border-surface-700 bg-surface-900 py-1 text-[11px] shadow-lg"
style={{ left: x, top: y }}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
>
{items.map((it, i) => (
<button
key={i}
type="button"
disabled={it.disabled}
onClick={() => !it.disabled && it.action()}
className={clsx(
'block w-full px-3 py-1 text-left',
it.disabled
? 'cursor-not-allowed text-surface-600'
: 'text-surface-200 hover:bg-accent-500/30 hover:text-surface-50',
)}
>
{it.label}
</button>
))}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"type": "module",
"bin": {
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.5.2",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.5.2",
"version": "0.5.5",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -25,7 +25,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.5.2",
"version": "0.5.5",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -42,7 +42,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.5.2",
"version": "0.5.5",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -86,7 +86,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.5.2",
"version": "0.5.5",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -8826,7 +8826,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.5.2",
"version": "0.5.5",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8852,7 +8852,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.5.2",
"version": "0.5.5",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8864,7 +8864,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.5.2",
"version": "0.5.5",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"type": "module",
"main": "./dist/index.js",
+10 -1
View File
@@ -20,6 +20,7 @@ import {
isUrlMalformed,
resolveStartUrl,
compileUrlRegexRewrites,
toEscapedFragmentUrl,
} from './url-utils.js';
import { parseHtml, estimatePixelWidth } from './html-parser.js';
import { analyseCookies, extractSetCookies } from './cookies.js';
@@ -2400,7 +2401,15 @@ export class Crawler extends EventEmitter {
// the right place to mark the timestamp. Per-attempt so a retry's
// TTFB doesn't include the failed first attempt's overhead.
const tStart = Date.now();
const res = await undiciFetch(url, {
// Old AJAX Crawling Scheme — when enabled, hashbang URLs are
// fetched in their `?_escaped_fragment_=` form so a pre-rendering
// server returns the snapshot HTML. The DB row keeps the original
// `url`; only the network request uses the transformed address.
const fetchUrl =
this.config.renderingMode === 'ajax'
? toEscapedFragmentUrl(url)
: url;
const res = await undiciFetch(fetchUrl, {
method: 'GET',
headers: defaultRequestHeaders(
this.resolveUserAgent(url),
+47
View File
@@ -68,3 +68,50 @@ export async function exportUrlsToCsv(
return { rowsWritten };
}
/** Fixed column order for the Broken Links CSV export. */
const BROKEN_LINK_CSV_HEADER = [
'Source URL',
'Source Status',
'Target URL',
'Target Status',
'Anchor',
'Rel',
'Type',
] as const;
/**
* Stream every broken link (target 4xx/5xx) to a CSV file. Mirrors the
* Broken Links tab grid; `internal` scopes the export the same way the
* tab's sidebar filter does. Streamed row-by-row so a large set never
* builds a big string in memory.
*/
export async function exportBrokenLinksToCsv(
db: ProjectDb,
filePath: string,
options: { internal?: 'all' | 'internal' | 'external' } = {},
): Promise<{ rowsWritten: number }> {
let rowsWritten = 0;
const header = BROKEN_LINK_CSV_HEADER.join(',') + '\n';
const generator = async function* (): AsyncGenerator<string> {
yield '' + header;
for (const row of db.iterateBrokenLinks(options.internal ?? 'all')) {
const cells = [
row.fromUrl,
row.fromStatusCode,
row.toUrl,
row.toStatusCode,
row.anchor,
row.rel,
row.isInternal ? 'internal' : 'external',
];
rowsWritten++;
yield cells.map(escapeCsv).join(',') + '\n';
}
};
await pipeline(Readable.from(generator()), createWriteStream(filePath, { encoding: 'utf8' }));
return { rowsWritten };
}
+57 -2
View File
@@ -300,6 +300,57 @@ export interface ParsedPage {
hasNofollow: boolean;
}
/**
* Extract the page's main-content text for duplicate fingerprinting —
* the "content area based" duplicate check (boilerplate detection).
*
* Site-wide chrome (navigation, header, footer, sidebars) is identical
* across every page of a template-driven site. Hashing the full
* `<body>` makes two otherwise-distinct pages look like near-duplicates
* just because they share a big navbar + footer. Stripping the
* boilerplate before computing the SimHash / content hash focuses the
* comparison on what actually differs — the real content.
*
* Strategy, first non-empty match wins:
* 1. `<main>` / `[role="main"]` — the explicit semantic content region
* 2. `<article>` — common on blog / news / product pages
* 3. `<body>` with `<nav>` / `<header>` / `<footer>` / `<aside>` (and
* their ARIA-role equivalents) removed
* 4. full `<body>` text — last resort for pages with no structure
*
* Returns whitespace-collapsed, trimmed text. Only the duplicate
* fingerprint uses this; `wordCount` / readability stay on the full
* body so existing thin-content thresholds don't shift.
*/
function extractMainContentText($: cheerio.CheerioAPI): string {
const collapse = (s: string): string => s.replace(/\s+/g, ' ').trim();
const main = $('main, [role="main"]').first();
if (main.length > 0) {
const t = collapse(main.text());
if (t.length > 0) return t;
}
const article = $('article').first();
if (article.length > 0) {
const t = collapse(article.text());
if (t.length > 0) return t;
}
// Clone so `.remove()` doesn't mutate the live tree the rest of the
// parser still reads from.
const body = $('body').clone();
body
.find(
'nav, header, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"], [role="complementary"]',
)
.remove();
const stripped = collapse(body.text());
if (stripped.length > 0) return stripped;
return collapse($('body').text());
}
export function parseHtml(
html: string,
pageUrl: string,
@@ -710,8 +761,12 @@ export function parseHtml(
const analyticsTrackers = detectAnalyticsTrackers($, html);
// Content fingerprint for the post-crawl duplicate clustering pass.
// Uses the same trimmed body text as wordCount so the work is reused.
const { simhash, contentHash } = computeContentFingerprint(text);
// Computed from the main-content region (boilerplate stripped) so two
// pages that share a big nav/footer template but differ in body
// content aren't falsely clustered as near-duplicates.
const { simhash, contentHash } = computeContentFingerprint(
extractMainContentText($),
);
// Custom Extraction — runs after all standard fields so the cheerio
// tree is fully populated. Per-rule failures are isolated; the worst
+2 -1
View File
@@ -5,6 +5,7 @@ export {
isSameHost,
resolveStartUrl,
compileUrlRegexRewrites,
toEscapedFragmentUrl,
} from './url-utils.js';
export type {
UrlRewriteOptions,
@@ -17,7 +18,7 @@ export {
type AnalyticsTracker,
type HreflangEntry,
} from './html-parser.js';
export { exportUrlsToCsv } from './csv-export.js';
export { exportUrlsToCsv, exportBrokenLinksToCsv } from './csv-export.js';
export { exportUrlsToJson, type JsonExportOptions } from './json-export.js';
export { exportUrlsToXml } from './xml-export.js';
export {
+32
View File
@@ -390,3 +390,35 @@ export async function resolveStartUrl(
// the crawler will surface a network error with that URL.
return normalizeUrl(httpsUrl);
}
/**
* Old AJAX Crawling Scheme transform — Google's deprecated
* `_escaped_fragment_` protocol, kept for the `renderingMode: 'ajax'`
* config option.
*
* A hashbang URL carries its state after `#!`:
* `https://example.com/page#!section=about`
* Fragments are never sent to the server, so an AJAX app served the
* snapshot from an `?_escaped_fragment_=` query instead:
* `https://example.com/page?_escaped_fragment_=section=about`
*
* Rules:
* - Only URLs containing `#!` are rewritten; everything else is
* returned unchanged.
* - The fragment payload (everything after `#!`) becomes the
* `_escaped_fragment_` value, appended with `&` when the URL
* already has a query string, `?` otherwise.
* - A bare `#!` with no payload yields an empty `_escaped_fragment_=`.
*
* No JS is executed — this is purely a URL rewrite applied at fetch
* time. The crawler keeps the original hashbang URL as the row key;
* only the network request uses the transformed form.
*/
export function toEscapedFragmentUrl(url: string): string {
const idx = url.indexOf('#!');
if (idx === -1) return url;
const base = url.slice(0, idx);
const fragment = url.slice(idx + 2);
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}_escaped_fragment_=${fragment}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"type": "module",
"main": "./dist/index.js",
+46
View File
@@ -3608,6 +3608,52 @@ export class ProjectDb {
}
}
/**
* Yield every broken link (target status 4xx/5xx) for CSV export —
* the unbounded counterpart of `queryBrokenLinks`, which paginates
* for the table view. `internal` filters to same-site / off-site
* targets; `all` walks everything. Same JOIN + ordering as the table
* so the export matches what the user sees.
*/
*iterateBrokenLinks(
internal: 'all' | 'internal' | 'external' = 'all',
): IterableIterator<BrokenLinkRow> {
const where: string[] = ['t.status_code >= 400 AND t.status_code < 600'];
if (internal === 'internal') where.push('l.is_internal = 1');
else if (internal === 'external') where.push('l.is_internal = 0');
const rows = this.db
.prepare(
`SELECT f.url AS from_url, f.status_code AS from_status,
l.to_url AS to_url, t.status_code AS to_status,
l.anchor, l.rel, l.is_internal
FROM links l
JOIN urls f ON l.from_url_id = f.id
JOIN urls t ON l.to_url = t.url
WHERE ${where.join(' AND ')}
ORDER BY t.status_code DESC, f.id, l.id`,
)
.all() as unknown as {
from_url: string;
from_status: number | null;
to_url: string;
to_status: number | null;
anchor: string | null;
rel: string | null;
is_internal: number;
}[];
for (const r of rows) {
yield {
fromUrl: r.from_url,
fromStatusCode: r.from_status,
toUrl: r.to_url,
toStatusCode: r.to_status,
anchor: r.anchor,
rel: r.rel,
isInternal: r.is_internal === 1,
};
}
}
*iterateUrlsByIds(ids: number[]): IterableIterator<CrawlUrlRow> {
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.5.2",
"version": "0.5.5",
"private": true,
"type": "module",
"main": "./dist/index.js",
+13
View File
@@ -890,6 +890,18 @@ export interface CrawlConfig {
/** Materialise the heavy `urls_issues` counters (Dead External Domain,
* Duplicate URL post-norm, Canonical Chain Multi-hop). */
analyseIssues: boolean;
/**
* Page-rendering strategy for the fetch layer.
* - `text` (default) — fetch the raw HTML response as-is. Fast,
* deterministic, covers server-rendered + static sites.
* - `ajax` — Old AJAX Crawling Scheme. Hashbang URLs (`…#!key=value`)
* are rewritten to Google's deprecated `?_escaped_fragment_=` form
* before fetch, so a pre-rendering server returns the snapshot HTML.
* No JS execution — just the URL transform.
* Full JavaScript rendering (a headless-browser pass) is a V2 item and
* intentionally absent from this union.
*/
renderingMode: 'text' | 'ajax';
}
export interface HttpAuth {
@@ -1527,4 +1539,5 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
perHostUserAgents: [],
proxyProfiles: [],
proxyProfileActive: '',
renderingMode: 'text',
};
+1
View File
@@ -1,3 +1,4 @@
export * from './crawl.js';
export * from './ipc.js';
export * from './project.js';
export * from './integrations.js';
+285
View File
@@ -0,0 +1,285 @@
/**
* Faz 7 — Integration catalog + credential model.
*
* FreeCrawl is a local-first open-source desktop app: there is no
* FreeCrawl backend, so every integration is connected with the
* end-user's OWN credentials. Three credential models:
*
* - `api-key` — paste a provider API key (OpenAI, Ahrefs, …).
* - `oauth-byoc` — "bring your own client": the user creates their
* own Google Cloud OAuth Desktop client and
* pastes the client ID + secret. No FreeCrawl-
* owned OAuth app, no Google verification needed.
* - `service-account` — paste a Google service-account JSON key.
* - `local` — no secret; a local endpoint URL (Ollama).
*
* Secrets never leave the main process in plaintext — they are
* encrypted at rest with Electron `safeStorage` (OS-backed: DPAPI /
* Keychain / libsecret) and only decrypted in-process when an API call
* actually needs them.
*
* This file is the single source of truth for the catalog; the
* Settings → Integrations panel renders straight from `INTEGRATIONS`.
*/
export type IntegrationAuthType =
| 'api-key'
| 'oauth-byoc'
| 'service-account'
| 'local';
export type IntegrationCategory = 'ai' | 'seo' | 'google' | 'performance';
/** One credential field a user fills in for an integration. */
export interface IntegrationField {
/** Storage key — unique within the integration. */
key: string;
/** UI label. */
label: string;
/**
* Secret fields are masked in the UI, never echoed back to the
* renderer once saved, and stored encrypted. Non-secret fields
* (endpoint URLs, OAuth client IDs) round-trip in plaintext so the
* user can see and edit them.
*/
secret: boolean;
/** Placeholder / example text. */
placeholder?: string;
/** When true the field may be left blank. */
optional?: boolean;
/** Multi-line input (service-account JSON). */
multiline?: boolean;
}
export interface IntegrationDef {
/** Stable id — credential storage key + IPC reference. */
id: string;
/** Display name. */
name: string;
category: IntegrationCategory;
authType: IntegrationAuthType;
/** One-line description of what the integration does. */
description: string;
/** Credential fields the user must provide. */
fields: IntegrationField[];
/** Where to obtain the credentials — opened in the browser. */
helpUrl: string;
/**
* Short "how to get credentials" hint shown under the card. For
* `oauth-byoc` this is the BYOC explanation.
*/
setupHint: string;
}
const KEY_FIELD: IntegrationField = {
key: 'apiKey',
label: 'API Key',
secret: true,
placeholder: 'Paste your API key',
};
/** OAuth bring-your-own-client field pair (Google Desktop OAuth client). */
const OAUTH_BYOC_FIELDS: IntegrationField[] = [
{
key: 'clientId',
label: 'OAuth Client ID',
secret: false,
placeholder: '....apps.googleusercontent.com',
},
{
key: 'clientSecret',
label: 'OAuth Client Secret',
secret: true,
placeholder: 'Paste the client secret',
},
];
const BYOC_HINT =
'Create your own OAuth client: Google Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth client ID → Application type "Desktop app". Enable the matching API for the project first, then paste the client ID + secret here.';
/**
* The integration catalog. Order = display order. Google OAuth-BYOC
* entries are listed but only become functional once the user pastes
* their own client credentials.
*/
export const INTEGRATIONS: IntegrationDef[] = [
// ---- AI ----
{
id: 'openai',
name: 'OpenAI',
category: 'ai',
authType: 'api-key',
description: 'Run a custom prompt per URL — content analysis, title ideas, summaries.',
fields: [KEY_FIELD],
helpUrl: 'https://platform.openai.com/api-keys',
setupHint: 'Create an API key at platform.openai.com → API keys. Usage is billed to your OpenAI account.',
},
{
id: 'anthropic',
name: 'Anthropic Claude',
category: 'ai',
authType: 'api-key',
description: 'Run a custom prompt per URL with Claude models.',
fields: [KEY_FIELD],
helpUrl: 'https://console.anthropic.com/settings/keys',
setupHint: 'Create an API key at console.anthropic.com → Settings → API Keys. Usage is billed to your Anthropic account.',
},
{
id: 'ollama',
name: 'Ollama (local LLM)',
category: 'ai',
authType: 'local',
description: 'Run prompts against a locally-hosted model — no API key, no cost.',
fields: [
{
key: 'endpoint',
label: 'Ollama endpoint',
secret: false,
placeholder: 'http://localhost:11434',
},
{
key: 'model',
label: 'Model',
secret: false,
placeholder: 'llama3.2',
optional: true,
},
],
helpUrl: 'https://ollama.com/download',
setupHint: 'Install Ollama and run a model locally (e.g. `ollama run llama3.2`). The default endpoint is http://localhost:11434.',
},
// ---- Performance ----
{
id: 'pagespeed',
name: 'Google PageSpeed Insights',
category: 'performance',
authType: 'api-key',
description: 'Lighthouse audit scores per URL (mobile + desktop).',
fields: [{ ...KEY_FIELD, optional: true, label: 'API Key (optional)' }],
helpUrl: 'https://developers.google.com/speed/docs/insights/v5/get-started',
setupHint: 'Works without a key at a low rate limit. For higher throughput create a free API key in Google Cloud Console and enable the PageSpeed Insights API.',
},
// ---- SEO data ----
{
id: 'ahrefs',
name: 'Ahrefs',
category: 'seo',
authType: 'api-key',
description: 'Backlinks, domain rating, referring domains per URL.',
fields: [KEY_FIELD],
helpUrl: 'https://ahrefs.com/api',
setupHint: 'Requires an Ahrefs subscription with API access. Generate a token in your Ahrefs account → API.',
},
{
id: 'majestic',
name: 'Majestic',
category: 'seo',
authType: 'api-key',
description: 'Trust Flow, Citation Flow, backlink counts.',
fields: [KEY_FIELD],
helpUrl: 'https://developer-support.majestic.com/api/',
setupHint: 'Requires a Majestic subscription. Get your API key from the Majestic developer dashboard.',
},
{
id: 'moz',
name: 'Moz',
category: 'seo',
authType: 'api-key',
description: 'Domain Authority, Page Authority, Spam Score.',
fields: [
{ key: 'accessId', label: 'Access ID', secret: false, placeholder: 'Moz Access ID' },
{ key: 'secretKey', label: 'Secret Key', secret: true, placeholder: 'Moz Secret Key' },
],
helpUrl: 'https://moz.com/api',
setupHint: 'Requires a Moz API subscription. The Access ID + Secret Key are in your Moz account → API.',
},
{
id: 'semrush',
name: 'Semrush',
category: 'seo',
authType: 'api-key',
description: 'Organic keywords, traffic estimates, SERP features.',
fields: [KEY_FIELD],
helpUrl: 'https://www.semrush.com/api-documentation/',
setupHint: 'Requires a Semrush subscription with API units. Get your API key from Semrush → Subscription info → API.',
},
// ---- Google (OAuth bring-your-own-client) ----
{
id: 'gsc',
name: 'Google Search Console',
category: 'google',
authType: 'oauth-byoc',
description: 'Clicks, impressions, CTR, position + URL Inspection per page.',
fields: OAUTH_BYOC_FIELDS,
helpUrl: 'https://console.cloud.google.com/apis/credentials',
setupHint: `${BYOC_HINT} Enable the "Google Search Console API".`,
},
{
id: 'ga4',
name: 'Google Analytics 4',
category: 'google',
authType: 'oauth-byoc',
description: 'Pageviews, users, engagement, bounce, conversions per URL.',
fields: OAUTH_BYOC_FIELDS,
helpUrl: 'https://console.cloud.google.com/apis/credentials',
setupHint: `${BYOC_HINT} Enable the "Google Analytics Data API".`,
},
{
id: 'sheets',
name: 'Google Sheets',
category: 'google',
authType: 'oauth-byoc',
description: 'Export crawl results straight to a Google Sheet.',
fields: OAUTH_BYOC_FIELDS,
helpUrl: 'https://console.cloud.google.com/apis/credentials',
setupHint: `${BYOC_HINT} Enable the "Google Sheets API".`,
},
{
id: 'bigquery',
name: 'Google BigQuery',
category: 'google',
authType: 'service-account',
description: 'Stream crawl results into a BigQuery dataset for warehousing.',
fields: [
{
key: 'serviceAccountJson',
label: 'Service Account JSON',
secret: true,
placeholder: 'Paste the full service-account key JSON',
multiline: true,
},
{
key: 'projectId',
label: 'GCP Project ID',
secret: false,
placeholder: 'my-gcp-project',
},
],
helpUrl: 'https://cloud.google.com/iam/docs/service-accounts-create',
setupHint: 'Create a service account in Google Cloud Console with BigQuery Data Editor role, generate a JSON key, and paste it here along with the project ID.',
},
];
/** Lookup an integration definition by id. */
export function getIntegration(id: string): IntegrationDef | undefined {
return INTEGRATIONS.find((i) => i.id === id);
}
/**
* Per-integration credential state sent to the renderer. Secret field
* values are NEVER included — only a `set` flag — so a configured key
* can't be read back out of the UI. Non-secret field values round-trip
* so the user can see/edit them.
*/
export interface IntegrationCredentialState {
/** True when every required (non-optional) field has a value. */
configured: boolean;
/** Per-field state. `value` is '' for secret fields even when set. */
fields: Record<string, { set: boolean; value: string }>;
}
/** Map of integration id → credential state. */
export type IntegrationsState = Record<string, IntegrationCredentialState>;
+33
View File
@@ -11,6 +11,7 @@ import type {
UrlCategory,
UrlDetail,
} from './crawl.js';
import type { IntegrationsState } from './integrations.js';
export const IPC = {
crawlStart: 'crawl:start',
@@ -39,6 +40,9 @@ export const IPC = {
exportCsv: 'export:csv',
exportJson: 'export:json',
exportXml: 'export:xml',
/** Export the Broken Links tab to a CSV file. Honours the tab's
* internal/external scope filter. */
exportBrokenLinks: 'export:broken-links',
/** New unified tabular export with column selection + multi-tab support
* (CSV / XLSX). Backs the in-table "Export" button. */
exportTabular: 'export:tabular',
@@ -125,6 +129,13 @@ export const IPC = {
prefsImportSettings: 'prefs:import-settings',
scheduleGet: 'schedule:get',
scheduleSet: 'schedule:set',
/** Faz 7 — integration credential store (safeStorage-encrypted).
* `integrationsGetAll` returns the redacted state map (secret values
* never leave the main process); `set` merges the supplied fields;
* `clear` wipes every field of one integration. */
integrationsGetAll: 'integrations:get-all',
integrationsSet: 'integrations:set',
integrationsClear: 'integrations:clear',
/** Open a native directory picker. Used by Settings → Storage to let the
* user choose the default folder for new `.seoproject` files. */
pickDirectory: 'app:pick-directory',
@@ -271,6 +282,19 @@ export interface ExportXmlResult {
rowsWritten: number;
}
export interface ExportBrokenLinksInput {
/** Optional pre-resolved output path (skips the save dialog). */
filePath?: string;
/** Scope filter — matches the Broken Links tab's sidebar filter. */
internal?: 'all' | 'internal' | 'external';
}
export interface ExportBrokenLinksResult {
/** Empty when the user cancelled the save dialog. */
filePath: string;
rowsWritten: number;
}
/** One section / sheet of a tabular export — a single category fed into
* the workbook (xlsx) or split into its own file (csv). */
export interface ExportTabularSection {
@@ -898,6 +922,7 @@ export interface FreeCrawlApi {
exportCsv(input: ExportCsvInput): Promise<ExportCsvResult>;
exportJson(input: ExportJsonInput): Promise<ExportJsonResult>;
exportXml(input: ExportXmlInput): Promise<ExportXmlResult>;
exportBrokenLinks(input: ExportBrokenLinksInput): Promise<ExportBrokenLinksResult>;
exportTabular(input: ExportTabularInput): Promise<ExportTabularResult>;
dataDeleteByDomain(
input: DataDeleteByDomainInput,
@@ -956,6 +981,14 @@ export interface FreeCrawlApi {
/** Save / update the schedule for the currently-loaded project. Passing
* `null` removes the schedule entirely. */
scheduleSet(spec: ScheduleSpec | null): Promise<ScheduleEntry | null>;
/** Faz 7 — read the redacted credential state of every integration.
* Secret field values are never returned, only a `set` flag. */
integrationsGetAll(): Promise<IntegrationsState>;
/** Save (merge) credential fields for one integration. Only the
* supplied fields are written; omitted fields keep their stored value. */
integrationsSet(id: string, fields: Record<string, string>): Promise<IntegrationsState>;
/** Wipe every stored credential field of one integration. */
integrationsClear(id: string): Promise<IntegrationsState>;
/** Open a native directory picker. Returns the chosen path or null on cancel. */
pickDirectory(input?: { title?: string; defaultPath?: string }): Promise<string | null>;
/** Resolved default save directory for new projects (Documents fallback when unset). */