mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: integrations panel + rendering mode + broken-links export & cell select
This commit is contained in:
1 parent
5a93a94ca1
commit
dc6f440ff1
25 files changed
+1680
-172
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
Generated
+8
-8
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './crawl.js';
|
||||
export * from './ipc.js';
|
||||
export * from './project.js';
|
||||
export * from './integrations.js';
|
||||
@@ -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>;
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user