mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: GSC + GA4 + AI + SEO Authority + Sheets/BigQuery + encrypted snapshots
This commit is contained in:
1 parent
837bf94958
commit
be49dcf39a
53 files changed
+7955
-58
No files matched your search
@@ -54,6 +54,11 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
|
||||
- Readability (Flesch, Flesch–Kincaid, Gunning Fog)
|
||||
- Custom CSS + Regex extraction (10 rules) + `/regex/` literal Custom Search
|
||||
- **PageSpeed Insights** — on-demand Lighthouse audits (mobile + desktop) per URL
|
||||
- **Google Search Console + GA4** — clicks / impressions / CTR / position / sessions / users joined onto crawled pages
|
||||
- **URL Inspection** — coverage verdict, last-crawl time, canonical decisions in batches
|
||||
- **AI per-URL** — OpenAI / Anthropic / local Ollama prompts with shared variables (`{url}`/`{title}`/`{description}`/`{h1}`/`{body}`)
|
||||
- **SEO Authority** — Ahrefs / Majestic / Moz / Semrush metrics behind one provider dropdown
|
||||
- **Cross-source orphan detection** — pages PSI / GSC / GA4 / sitemap know about but the crawl never reached
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -80,12 +85,14 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
|
||||
<td valign="top">
|
||||
|
||||
### 📤 Export & Reports
|
||||
- **21 reports** — histograms, top/bottom URLs, link positions, top words
|
||||
- **22 reports** — histograms, top/bottom URLs, link positions, top words, **cross-source orphan pages**
|
||||
- **Export Dialog** with column picker — CSV or **XLSX** (multi-sheet)
|
||||
- Bulk export (22 categorised CSVs)
|
||||
- Bulk export (22 categorised CSVs) + dedicated **Images CSV export** (honours missing-alt + search)
|
||||
- Standalone HTML audit report
|
||||
- Sitemap generator (image / hreflang / sharded / gz)
|
||||
- Project-vs-project compare diff
|
||||
- **Google Sheets** + **BigQuery** direct export (OAuth / service-account)
|
||||
- **Encrypted project snapshots** — password-protected `.seoproject.enc` (AES-256-GCM + PBKDF2)
|
||||
- **MCP server** for AI agents — **drive crawls live from Claude Code**, poll progress, plus full read-only DB access
|
||||
- Webhook on completion + OS notifications
|
||||
|
||||
@@ -229,6 +236,8 @@ node apps/cli/dist/index.js https://example.com --depth 2 --max 500 --out out.cs
|
||||
node apps/cli/dist/index.js --list urls.txt --out out.json # list mode + JSON
|
||||
```
|
||||
|
||||
**CI / CD recipes** — ready-to-use [GitHub Actions](docs/ci/github-actions-example.yml) and [GitLab CI](docs/ci/gitlab-ci-example.yml) examples that crawl your site on a schedule, fail the build when broken-URL count exceeds a threshold, and upload the crawl as an artifact.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -440,9 +449,9 @@ graph LR
|
||||
## 📈 Status
|
||||
|
||||
> [!NOTE]
|
||||
> **Active development.** All 25 analysis tabs, advanced search, 150+ issue categories, sitemap export variants, JSON / CSV / XLSX / HTML reports, list mode, custom extraction, near-duplicate + exact-duplicate detection, hreflang validation, project compare, Cytoscape visualization, auth, proxy, webhook, **MCP server with crawl control + live progress**, **Google PageSpeed Insights integration**, OS notifications, **robots.txt syntax validator**, URL rewriting + preview, **status-code diagnosis banner**, **live memory monitor**, **in-app scheduled crawl**, **multi-language UI (EN + TR)**, **`.seoproject` file association**, in-app logs, and diagnostic popups are working. Cross-platform installers (Windows `.exe` + portable, macOS `.dmg`, Linux `.AppImage` / `.deb` / `.rpm`). Live-streaming UX with **first row in ~1 s**.
|
||||
> **Active development.** All 30 analysis tabs (Internal, External, Response Codes, URL, Page Titles, Meta Description, H1, H2, Content, Images, Canonicals, Directives, Redirects, Pagination, Hreflang, AMP, Structured Data, Meta Refresh, Custom Extraction, Custom Search, Security, Duplicates, Links, Broken Links, SERP, **PageSpeed, Search Console, GA4, AI, SEO Authority**, Visualization), advanced search, 150+ issue categories, sitemap export variants, JSON / CSV / XLSX / HTML reports, list mode, custom extraction, near-duplicate + exact-duplicate detection, hreflang validation, project compare, Cytoscape visualization, auth, proxy, webhook, **MCP server with crawl control + live progress**, **Google PageSpeed / Search Console / URL Inspection / GA4 integrations**, **AI per-URL prompts (OpenAI / Anthropic / Ollama)**, **SEO Authority providers (Ahrefs / Majestic / Moz / Semrush)**, **Google Sheets + BigQuery direct export**, **encrypted project snapshots (AES-256-GCM)**, **cross-source orphan detection**, **memory-limit auto-pause watchdog**, OS notifications, **robots.txt syntax validator**, URL rewriting + preview, **status-code diagnosis banner**, **live memory monitor**, **in-app scheduled crawl**, **multi-language UI (EN + TR)**, **`.seoproject` file association**, in-app logs, and diagnostic popups are working. Cross-platform installers (Windows `.exe` + portable, macOS `.dmg`, Linux `.AppImage` / `.deb` / `.rpm`). Live-streaming UX with **first row in ~1 s**.
|
||||
>
|
||||
> **Upcoming:** plugin system, JavaScript rendering, log analyzer.
|
||||
> **Upcoming (V2):** JavaScript rendering (Chromium pool), Screenshot capture, Log file analyzer, Plugin system, Light theme, Multi-window, Code-signing + auto-update.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "FreeCrawl SEO — desktop application",
|
||||
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Faz 7 — AI batch runner.
|
||||
*
|
||||
* Same shape as the PageSpeed batch (`pagespeed.ts#runPagespeedBatch`):
|
||||
* a small concurrency pool walks a list of `(url, prompt)` items,
|
||||
* dispatches each to the selected provider, and reports completed
|
||||
* results + live progress through callbacks. The orchestrating IPC
|
||||
* handler owns prompt substitution and DB writes.
|
||||
*/
|
||||
import type { AiProvider } from '@freecrawl/shared-types';
|
||||
import { runPrompt, AiRunError, type AiRunOutput } from './ai-providers.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
export interface AiBatchItem {
|
||||
url: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AiBatchOutcome {
|
||||
url: string;
|
||||
ok: boolean;
|
||||
output: AiRunOutput | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AiBatchOptions {
|
||||
provider: AiProvider;
|
||||
model: string;
|
||||
concurrency: number;
|
||||
items: AiBatchItem[];
|
||||
isCancelled: () => boolean;
|
||||
onResult: (outcome: AiBatchOutcome) => void;
|
||||
onProgress: (done: number, total: number, currentUrl: string | null) => void;
|
||||
}
|
||||
|
||||
export interface AiBatchResult {
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export async function runAiBatch(opts: AiBatchOptions): Promise<AiBatchResult> {
|
||||
const { provider, model, concurrency, items, isCancelled, onResult, onProgress } = opts;
|
||||
const total = items.length;
|
||||
let done = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let cursor = 0;
|
||||
|
||||
onProgress(0, total, null);
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (isCancelled()) return;
|
||||
const index = cursor++;
|
||||
if (index >= total) return;
|
||||
const item = items[index];
|
||||
if (!item) return;
|
||||
onProgress(done, total, item.url);
|
||||
let outcome: AiBatchOutcome;
|
||||
try {
|
||||
const output = await runPrompt(provider, model, item.prompt);
|
||||
outcome = { url: item.url, ok: true, output, error: null };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AiRunError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
outcome = { url: item.url, ok: false, output: null, error: message };
|
||||
}
|
||||
try {
|
||||
onResult(outcome);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'error',
|
||||
'ai',
|
||||
`failed to persist AI result for ${item.url}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
done++;
|
||||
if (outcome.ok) completed++;
|
||||
else failed++;
|
||||
onProgress(done, total, null);
|
||||
}
|
||||
};
|
||||
|
||||
const pool = Math.min(Math.max(1, concurrency), Math.max(1, total));
|
||||
await Promise.all(Array.from({ length: pool }, () => worker()));
|
||||
|
||||
const cancelled = isCancelled() && done < total;
|
||||
logger.log(
|
||||
'info',
|
||||
'ai',
|
||||
`${provider}/${model} batch finished — ${completed} ok, ${failed} failed${
|
||||
cancelled ? ', cancelled early' : ''
|
||||
} (${done}/${total})`,
|
||||
);
|
||||
return { completed, failed, cancelled };
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Faz 7 — AI integrations: OpenAI, Anthropic, Ollama.
|
||||
*
|
||||
* One thin abstraction so the AI tab and batch runner don't have to
|
||||
* know which provider they're driving. Each provider exposes the same
|
||||
* `runPrompt` shape — give it a prompt, get back text + token counts
|
||||
* (when the API reports them). Credentials are resolved here from the
|
||||
* encrypted credential store; nothing leaks to the renderer.
|
||||
*
|
||||
* OpenAI — Bearer key, Chat Completions API.
|
||||
* Anthropic — `x-api-key`, Messages API.
|
||||
* Ollama — local HTTP endpoint, no auth.
|
||||
*/
|
||||
import type { AiProvider } from '@freecrawl/shared-types';
|
||||
import { resolveCredentials } from './credentials.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_MAX_TOKENS = 800;
|
||||
|
||||
const DEFAULT_MODELS: Record<AiProvider, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-haiku-4-5',
|
||||
ollama: 'llama3.2',
|
||||
};
|
||||
|
||||
/** Default concurrency per provider — Ollama is local + single-model,
|
||||
* so 1 in flight; cloud providers tolerate a small parallel burst. */
|
||||
const DEFAULT_CONCURRENCY: Record<AiProvider, number> = {
|
||||
openai: 3,
|
||||
anthropic: 3,
|
||||
ollama: 1,
|
||||
};
|
||||
|
||||
export interface AiRunOutput {
|
||||
/** Response text returned by the provider. */
|
||||
text: string;
|
||||
/** Tokens billed for the prompt — null when the API doesn't report it. */
|
||||
tokensIn: number | null;
|
||||
/** Tokens billed for the completion — null when not reported. */
|
||||
tokensOut: number | null;
|
||||
/** The model that produced this response — useful when defaults change. */
|
||||
model: string;
|
||||
}
|
||||
|
||||
export class AiRunError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly model: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiRunError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the effective model for a provider — explicit override → user
|
||||
* pref (Ollama only) → built-in default. */
|
||||
export function resolveModel(provider: AiProvider, override?: string): string {
|
||||
if (override && override.trim()) return override.trim();
|
||||
if (provider === 'ollama') {
|
||||
const pref = (resolveCredentials('ollama')['model'] ?? '').trim();
|
||||
if (pref) return pref;
|
||||
}
|
||||
return DEFAULT_MODELS[provider];
|
||||
}
|
||||
|
||||
export function defaultConcurrency(provider: AiProvider): number {
|
||||
return DEFAULT_CONCURRENCY[provider];
|
||||
}
|
||||
|
||||
/** Run one prompt through the selected provider. Throws `AiRunError`
|
||||
* on any failure — the batch runner converts it into a stored error
|
||||
* row so a single bad URL never aborts the whole batch. */
|
||||
export async function runPrompt(
|
||||
provider: AiProvider,
|
||||
model: string,
|
||||
prompt: string,
|
||||
): Promise<AiRunOutput> {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return runOpenAi(model, prompt);
|
||||
case 'anthropic':
|
||||
return runAnthropic(model, prompt);
|
||||
case 'ollama':
|
||||
return runOllama(model, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAI ───────────────────────────────────────────────────────────
|
||||
|
||||
async function runOpenAi(model: string, prompt: string): Promise<AiRunOutput> {
|
||||
const apiKey = (resolveCredentials('openai')['apiKey'] ?? '').trim();
|
||||
if (!apiKey) {
|
||||
throw new AiRunError(
|
||||
'No OpenAI API key — paste yours in Settings → Integrations.',
|
||||
model,
|
||||
);
|
||||
}
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: DEFAULT_MAX_TOKENS,
|
||||
}),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
const err = (json?.['error'] as { message?: string } | undefined)?.message;
|
||||
throw new AiRunError(err ?? `OpenAI API error (HTTP ${res.status})`, model);
|
||||
}
|
||||
const choices = json['choices'] as { message?: { content?: unknown } }[] | undefined;
|
||||
const text =
|
||||
(choices?.[0]?.message?.content as string | undefined)?.trim() ?? '';
|
||||
const usage = json['usage'] as
|
||||
| { prompt_tokens?: number; completion_tokens?: number }
|
||||
| undefined;
|
||||
return {
|
||||
text,
|
||||
tokensIn: usage?.prompt_tokens ?? null,
|
||||
tokensOut: usage?.completion_tokens ?? null,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Anthropic ────────────────────────────────────────────────────────
|
||||
|
||||
async function runAnthropic(model: string, prompt: string): Promise<AiRunOutput> {
|
||||
const apiKey = (resolveCredentials('anthropic')['apiKey'] ?? '').trim();
|
||||
if (!apiKey) {
|
||||
throw new AiRunError(
|
||||
'No Anthropic API key — paste yours in Settings → Integrations.',
|
||||
model,
|
||||
);
|
||||
}
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: DEFAULT_MAX_TOKENS,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
const err = (json?.['error'] as { message?: string } | undefined)?.message;
|
||||
throw new AiRunError(
|
||||
err ?? `Anthropic API error (HTTP ${res.status})`,
|
||||
model,
|
||||
);
|
||||
}
|
||||
const content = json['content'] as { type?: unknown; text?: unknown }[] | undefined;
|
||||
const text =
|
||||
content
|
||||
?.filter((c) => c?.type === 'text' && typeof c?.text === 'string')
|
||||
.map((c) => c.text as string)
|
||||
.join('\n')
|
||||
.trim() ?? '';
|
||||
const usage = json['usage'] as
|
||||
| { input_tokens?: number; output_tokens?: number }
|
||||
| undefined;
|
||||
return {
|
||||
text,
|
||||
tokensIn: usage?.input_tokens ?? null,
|
||||
tokensOut: usage?.output_tokens ?? null,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Ollama ───────────────────────────────────────────────────────────
|
||||
|
||||
async function runOllama(model: string, prompt: string): Promise<AiRunOutput> {
|
||||
const creds = resolveCredentials('ollama');
|
||||
const endpoint = (creds['endpoint'] ?? 'http://localhost:11434').trim() || 'http://localhost:11434';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${endpoint.replace(/\/+$/, '')}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
throw new AiRunError(
|
||||
`Couldn't reach Ollama at ${endpoint}: ${msg}`,
|
||||
model,
|
||||
);
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
const errMsg =
|
||||
(json?.['error'] as string | undefined) ??
|
||||
`Ollama returned HTTP ${res.status}`;
|
||||
throw new AiRunError(errMsg, model);
|
||||
}
|
||||
return {
|
||||
text: (json['response'] as string | undefined)?.trim() ?? '',
|
||||
tokensIn:
|
||||
typeof json['prompt_eval_count'] === 'number'
|
||||
? (json['prompt_eval_count'] as number)
|
||||
: null,
|
||||
tokensOut:
|
||||
typeof json['eval_count'] === 'number'
|
||||
? (json['eval_count'] as number)
|
||||
: null,
|
||||
model,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Faz 7 — Google BigQuery export.
|
||||
*
|
||||
* Service-account auth (no OAuth keystone — BigQuery clients typically
|
||||
* authenticate as a *service*, not a user). The user pastes a Google
|
||||
* Cloud service-account JSON in Settings → Integrations; this module
|
||||
* signs a short-lived JWT with the embedded private key (RS256),
|
||||
* exchanges it at Google's token endpoint for an access token, then
|
||||
* uses that token to:
|
||||
* 1. Ensure the `freecrawl_seo` dataset exists in the user's project.
|
||||
* 2. Create a fresh timestamped `urls_<unix>` table with a fixed
|
||||
* schema.
|
||||
* 3. Stream the category's URL rows in via `tabledata.insertAll` in
|
||||
* 500-row chunks.
|
||||
*
|
||||
* Per-process token cache — JWT round-trips are cheap but a single
|
||||
* export typically fits inside one token lifetime (1 hr).
|
||||
*/
|
||||
import { createSign } from 'node:crypto';
|
||||
import type { ProjectDb } from '@freecrawl/db';
|
||||
import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
|
||||
import { resolveCredentials } from './credentials.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
const BIGQUERY_API = 'https://bigquery.googleapis.com/bigquery/v2';
|
||||
const SCOPE = 'https://www.googleapis.com/auth/bigquery';
|
||||
|
||||
/** Fixed dataset FreeCrawl writes into. The user can move tables out
|
||||
* afterwards; using a stable name keeps the export idempotent. */
|
||||
const DATASET_ID = 'freecrawl_seo';
|
||||
const INSERT_CHUNK = 500;
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
/** BigQuery schema for the exported URL rows — matches the Sheets
|
||||
* export so both targets receive the same shape. */
|
||||
const URL_TABLE_SCHEMA = {
|
||||
fields: [
|
||||
{ name: 'url', type: 'STRING', mode: 'REQUIRED' },
|
||||
{ name: 'status_code', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
{ name: 'title', type: 'STRING', mode: 'NULLABLE' },
|
||||
{ name: 'meta_description', type: 'STRING', mode: 'NULLABLE' },
|
||||
{ name: 'h1', type: 'STRING', mode: 'NULLABLE' },
|
||||
{ name: 'indexability', type: 'STRING', mode: 'NULLABLE' },
|
||||
{ name: 'response_time_ms', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
{ name: 'depth', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
{ name: 'inlinks', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
{ name: 'outlinks', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
{ name: 'word_count', type: 'INTEGER', mode: 'NULLABLE' },
|
||||
],
|
||||
};
|
||||
|
||||
interface ServiceAccount {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
let tokenCache: CachedToken | null = null;
|
||||
|
||||
function base64url(buf: Buffer | string): string {
|
||||
return (Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).toString('base64url');
|
||||
}
|
||||
|
||||
/** Parse and validate the stored service-account JSON. */
|
||||
function parseServiceAccount(raw: string): ServiceAccount {
|
||||
if (!raw.trim()) {
|
||||
throw new Error(
|
||||
'No BigQuery service account — paste the JSON key in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Service account JSON failed to parse: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Service account JSON is not an object.');
|
||||
}
|
||||
const sa = parsed as Partial<ServiceAccount>;
|
||||
if (typeof sa.client_email !== 'string' || !sa.client_email) {
|
||||
throw new Error('Service account JSON missing `client_email`.');
|
||||
}
|
||||
if (typeof sa.private_key !== 'string' || !sa.private_key.includes('PRIVATE KEY')) {
|
||||
throw new Error('Service account JSON missing `private_key`.');
|
||||
}
|
||||
return sa as ServiceAccount;
|
||||
}
|
||||
|
||||
/** Mint a Google access token via the JWT-bearer grant. */
|
||||
async function mintAccessToken(sa: ServiceAccount): Promise<string> {
|
||||
if (tokenCache && tokenCache.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
|
||||
return tokenCache.token;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claim = {
|
||||
iss: sa.client_email,
|
||||
scope: SCOPE,
|
||||
aud: TOKEN_ENDPOINT,
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
};
|
||||
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const body = base64url(JSON.stringify(claim));
|
||||
const signingInput = `${header}.${body}`;
|
||||
const signature = createSign('RSA-SHA256')
|
||||
.update(signingInput)
|
||||
.sign(sa.private_key, 'base64url');
|
||||
const jwt = `${signingInput}.${signature}`;
|
||||
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
access_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
error?: unknown;
|
||||
error_description?: unknown;
|
||||
} | null;
|
||||
if (!res.ok || !json || typeof json.access_token !== 'string') {
|
||||
const msg =
|
||||
(json?.error_description as string | undefined) ??
|
||||
(json?.error as string | undefined) ??
|
||||
`token exchange failed (HTTP ${res.status})`;
|
||||
throw new Error(`BigQuery auth: ${msg}`);
|
||||
}
|
||||
const expiresIn =
|
||||
typeof json.expires_in === 'number' ? (json.expires_in as number) : 3600;
|
||||
tokenCache = {
|
||||
token: json.access_token,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
/** Helper — turn a non-2xx BigQuery response into a useful error. */
|
||||
async function bqError(res: Response, fallback: string): Promise<Error> {
|
||||
let msg = fallback;
|
||||
try {
|
||||
const j = (await res.json()) as { error?: { message?: unknown } };
|
||||
if (j?.error?.message && typeof j.error.message === 'string') {
|
||||
msg = j.error.message;
|
||||
}
|
||||
} catch {
|
||||
/* response wasn't JSON */
|
||||
}
|
||||
return new Error(`BigQuery: ${msg}`);
|
||||
}
|
||||
|
||||
/** Create the dataset if it doesn't already exist (409 conflict OK). */
|
||||
async function ensureDataset(
|
||||
projectId: string,
|
||||
token: string,
|
||||
datasetId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasetReference: { projectId, datasetId },
|
||||
location: 'US',
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (res.ok) return;
|
||||
if (res.status === 409) return; // already exists
|
||||
throw await bqError(res, `dataset create failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
async function createTable(
|
||||
projectId: string,
|
||||
token: string,
|
||||
datasetId: string,
|
||||
tableId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(
|
||||
datasetId,
|
||||
)}/tables`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tableReference: { projectId, datasetId, tableId },
|
||||
schema: URL_TABLE_SCHEMA,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw await bqError(res, `table create failed (HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
interface InsertAllResponse {
|
||||
insertErrors?: { index?: number; errors?: { message?: string }[] }[];
|
||||
}
|
||||
|
||||
async function insertAllChunk(
|
||||
projectId: string,
|
||||
token: string,
|
||||
datasetId: string,
|
||||
tableId: string,
|
||||
rows: { json: Record<string, string | number | null> }[],
|
||||
): Promise<number> {
|
||||
const res = await fetch(
|
||||
`${BIGQUERY_API}/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(
|
||||
datasetId,
|
||||
)}/tables/${encodeURIComponent(tableId)}/insertAll`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ rows }),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw await bqError(res, `insertAll failed (HTTP ${res.status})`);
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as InsertAllResponse | null;
|
||||
const errors = json?.insertErrors?.length ?? 0;
|
||||
if (errors > 0) {
|
||||
const first = json?.insertErrors?.[0]?.errors?.[0]?.message;
|
||||
logger.log(
|
||||
'warn',
|
||||
'bigquery',
|
||||
`insertAll: ${errors} row(s) rejected; first error: ${first ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
return rows.length - errors;
|
||||
}
|
||||
|
||||
function rowToJson(r: CrawlUrlRow): Record<string, string | number | null> {
|
||||
return {
|
||||
url: r.url,
|
||||
status_code: r.statusCode ?? null,
|
||||
title: r.title ?? null,
|
||||
meta_description: r.metaDescription ?? null,
|
||||
h1: r.h1 ?? null,
|
||||
indexability: r.indexability ?? null,
|
||||
response_time_ms: r.responseTimeMs ?? null,
|
||||
depth: r.depth ?? null,
|
||||
inlinks: r.inlinks ?? null,
|
||||
outlinks: r.outlinks ?? null,
|
||||
word_count: r.wordCount ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BigQueryExportOutput {
|
||||
tableId: string;
|
||||
tableRef: string;
|
||||
consoleUrl: string;
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
export async function exportCategoryToBigQuery(
|
||||
db: ProjectDb,
|
||||
category: UrlCategory,
|
||||
): Promise<BigQueryExportOutput> {
|
||||
const creds = resolveCredentials('bigquery');
|
||||
const projectId = (creds['projectId'] ?? '').trim();
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
'No BigQuery project — paste the GCP project ID in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
const sa = parseServiceAccount(creds['serviceAccountJson'] ?? '');
|
||||
const token = await mintAccessToken(sa);
|
||||
|
||||
await ensureDataset(projectId, token, DATASET_ID);
|
||||
|
||||
const tableId = `urls_${Math.floor(Date.now() / 1000)}`;
|
||||
await createTable(projectId, token, DATASET_ID, tableId);
|
||||
logger.log(
|
||||
'info',
|
||||
'bigquery',
|
||||
`created table ${projectId}.${DATASET_ID}.${tableId}`,
|
||||
);
|
||||
|
||||
// Stream rows in chunks. `tabledata.insertAll` caps at 10 MB / request;
|
||||
// 500 rows × ~600 bytes ≈ 300 KB so we stay well under.
|
||||
let rowsWritten = 0;
|
||||
let buf: { json: Record<string, string | number | null> }[] = [];
|
||||
for (const row of db.iterateUrlsByCategory(category)) {
|
||||
buf.push({ json: rowToJson(row) });
|
||||
if (buf.length >= INSERT_CHUNK) {
|
||||
rowsWritten += await insertAllChunk(projectId, token, DATASET_ID, tableId, buf);
|
||||
buf = [];
|
||||
}
|
||||
}
|
||||
if (buf.length > 0) {
|
||||
rowsWritten += await insertAllChunk(projectId, token, DATASET_ID, tableId, buf);
|
||||
}
|
||||
|
||||
const tableRef = `${projectId}.${DATASET_ID}.${tableId}`;
|
||||
const consoleUrl =
|
||||
`https://console.cloud.google.com/bigquery?` +
|
||||
`project=${encodeURIComponent(projectId)}&p=${encodeURIComponent(
|
||||
projectId,
|
||||
)}&d=${DATASET_ID}&t=${tableId}&page=table`;
|
||||
logger.log(
|
||||
'info',
|
||||
'bigquery',
|
||||
`exported ${rowsWritten} row(s) into ${tableRef}`,
|
||||
);
|
||||
return { tableId, tableRef, consoleUrl, rowsWritten };
|
||||
}
|
||||
@@ -70,6 +70,11 @@ const ALLOWED_METHODS = new Set<string>([
|
||||
'queryImages',
|
||||
'queryBrokenLinks',
|
||||
'queryPagespeed',
|
||||
'queryGsc',
|
||||
'queryGa4',
|
||||
'queryAi',
|
||||
'querySeo',
|
||||
'getUrlAnalytics',
|
||||
'getOverviewCounts',
|
||||
'getOverviewCountsAsync',
|
||||
'getSummary',
|
||||
@@ -89,6 +94,7 @@ const ALLOWED_METHODS = new Set<string>([
|
||||
'urlLengthHistogram',
|
||||
'wordCountPerDirectory',
|
||||
'sitemapOrphans',
|
||||
'orphanPagesCrossSource',
|
||||
'getPagesPerDirectory',
|
||||
'getStatusCodeHistogram',
|
||||
'getIndexabilityDistribution',
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Faz 7 — Google Analytics 4 API client.
|
||||
*
|
||||
* - `listProperties()` — every GA4 property the connected account
|
||||
* can read (Analytics Admin API `accountSummaries`).
|
||||
* - `runReport()` — per-page sessions / users / pageviews /
|
||||
* engagement-rate / avg-session-duration for a date range, joined
|
||||
* against the crawled URLs via the `pageLocation` dimension
|
||||
* (full URL — no host-prefix gymnastics required).
|
||||
*
|
||||
* Auth runs through the shared OAuth keystone (`google-oauth.ts`) —
|
||||
* `getAccessToken('ga4')` mints / refreshes the bearer token; nothing
|
||||
* here touches credentials directly.
|
||||
*/
|
||||
import type { Ga4Property } from '@freecrawl/shared-types';
|
||||
import { getAccessToken } from './google-oauth.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const ADMIN_BASE = 'https://analyticsadmin.googleapis.com/v1beta';
|
||||
const DATA_BASE = 'https://analyticsdata.googleapis.com/v1beta';
|
||||
/** GA4 Data API hard cap is 100 000 per request; 25 000 keeps payloads modest. */
|
||||
const PAGE_LIMIT = 25_000;
|
||||
/** Hard ceiling so a runaway property can't loop forever. */
|
||||
const MAX_ROWS = 250_000;
|
||||
|
||||
/** Metrics requested (order matters — used by index when parsing rows). */
|
||||
const METRICS = [
|
||||
'sessions',
|
||||
'activeUsers',
|
||||
'screenPageViews',
|
||||
'engagementRate',
|
||||
'averageSessionDuration',
|
||||
] as const;
|
||||
|
||||
function apiError(json: unknown, status: number): string {
|
||||
const err = (json as { error?: unknown } | null)?.error;
|
||||
if (err && typeof err === 'object') {
|
||||
const msg = (err as { message?: unknown }).message;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'GA4 denied access — confirm the account can read this property and the Analytics Admin / Data APIs are enabled.';
|
||||
}
|
||||
return `Google Analytics API error (HTTP ${status})`;
|
||||
}
|
||||
|
||||
/** Flatten the GA4 Admin `accountSummaries` tree into a flat property list. */
|
||||
export async function listProperties(): Promise<Ga4Property[]> {
|
||||
const token = await getAccessToken('ga4');
|
||||
const out: Ga4Property[] = [];
|
||||
let pageToken: string | undefined;
|
||||
for (;;) {
|
||||
const url = `${ADMIN_BASE}/accountSummaries?pageSize=200${
|
||||
pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ''
|
||||
}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
accountSummaries?: {
|
||||
displayName?: unknown;
|
||||
propertySummaries?: {
|
||||
property?: unknown;
|
||||
displayName?: unknown;
|
||||
}[];
|
||||
}[];
|
||||
nextPageToken?: unknown;
|
||||
} | null;
|
||||
if (!res.ok || !json) throw new Error(apiError(json, res.status));
|
||||
|
||||
const summaries = Array.isArray(json.accountSummaries)
|
||||
? json.accountSummaries
|
||||
: [];
|
||||
for (const acct of summaries) {
|
||||
const accountName = typeof acct.displayName === 'string' ? acct.displayName : '';
|
||||
const props = Array.isArray(acct.propertySummaries) ? acct.propertySummaries : [];
|
||||
for (const p of props) {
|
||||
const propertyId = typeof p.property === 'string' ? p.property : '';
|
||||
if (!propertyId) continue;
|
||||
out.push({
|
||||
propertyId,
|
||||
displayName:
|
||||
typeof p.displayName === 'string' && p.displayName
|
||||
? p.displayName
|
||||
: propertyId,
|
||||
accountName,
|
||||
});
|
||||
}
|
||||
}
|
||||
pageToken = typeof json.nextPageToken === 'string' ? json.nextPageToken : undefined;
|
||||
if (!pageToken) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One per-page GA4 metrics row. */
|
||||
export interface Ga4PageRow {
|
||||
url: string;
|
||||
sessions: number;
|
||||
users: number;
|
||||
pageviews: number;
|
||||
engagementRate: number;
|
||||
avgSessionDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull per-page metrics for `[startDate, endDate]` (`YYYY-MM-DD`).
|
||||
* Transparently pages past `PAGE_LIMIT` rows and returns the flat list.
|
||||
*/
|
||||
export async function runReport(
|
||||
property: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): Promise<Ga4PageRow[]> {
|
||||
const token = await getAccessToken('ga4');
|
||||
const endpoint = `${DATA_BASE}/${property}:runReport`;
|
||||
const out: Ga4PageRow[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (;;) {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dateRanges: [{ startDate, endDate }],
|
||||
dimensions: [{ name: 'pageLocation' }],
|
||||
metrics: METRICS.map((name) => ({ name })),
|
||||
limit: PAGE_LIMIT,
|
||||
offset,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
rows?: {
|
||||
dimensionValues?: { value?: unknown }[];
|
||||
metricValues?: { value?: unknown }[];
|
||||
}[];
|
||||
rowCount?: unknown;
|
||||
} | null;
|
||||
if (!res.ok || !json) throw new Error(apiError(json, res.status));
|
||||
|
||||
const rows = Array.isArray(json.rows) ? json.rows : [];
|
||||
for (const r of rows) {
|
||||
const url =
|
||||
Array.isArray(r.dimensionValues) && r.dimensionValues[0]
|
||||
? String(r.dimensionValues[0].value ?? '')
|
||||
: '';
|
||||
if (!url) continue;
|
||||
const m = Array.isArray(r.metricValues) ? r.metricValues : [];
|
||||
const n = (i: number): number =>
|
||||
i < m.length ? Number(m[i]?.value ?? 0) || 0 : 0;
|
||||
out.push({
|
||||
url,
|
||||
sessions: n(0),
|
||||
users: n(1),
|
||||
pageviews: n(2),
|
||||
engagementRate: n(3),
|
||||
avgSessionDuration: n(4),
|
||||
});
|
||||
}
|
||||
const totalRowCount =
|
||||
typeof json.rowCount === 'number' ? json.rowCount : out.length;
|
||||
offset += rows.length;
|
||||
if (
|
||||
rows.length === 0 ||
|
||||
offset >= totalRowCount ||
|
||||
out.length >= MAX_ROWS
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
logger.log(
|
||||
'info',
|
||||
'ga4',
|
||||
`GA4 report: ${out.length} page row(s) for ${property} (${startDate}…${endDate})`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Faz 7 — Google OAuth "loopback" keystone.
|
||||
*
|
||||
* Shared by every Google integration (Search Console, Analytics 4,
|
||||
* Sheets). FreeCrawl is local-first — there is no FreeCrawl backend —
|
||||
* so each user brings their own Google Cloud OAuth **Desktop** client
|
||||
* (BYOC: the client id + secret are entered in Settings → Integrations
|
||||
* and resolved here via the encrypted credential store).
|
||||
*
|
||||
* Flow (RFC 8252 native-app loopback):
|
||||
* 1. Spin up a throwaway HTTP server on `127.0.0.1:<random port>`.
|
||||
* 2. Open the Google consent screen in the system browser, with that
|
||||
* loopback address as the redirect URI + a PKCE challenge + a
|
||||
* CSRF `state` token.
|
||||
* 3. The browser redirects back to the loopback server with an auth
|
||||
* `code`; exchange it (with the PKCE verifier) for an access +
|
||||
* refresh token at Google's token endpoint.
|
||||
* 4. Persist the refresh token, encrypted at rest with `safeStorage`
|
||||
* (same OS-backed crypto the API-key store uses), in
|
||||
* `<userData>/google-tokens.enc.json`.
|
||||
*
|
||||
* Access tokens are short-lived and kept only in memory; they are
|
||||
* minted on demand from the refresh token. Nothing secret crosses the
|
||||
* IPC boundary — the renderer only ever sees `GoogleAuthState`.
|
||||
*/
|
||||
import { app, safeStorage, shell } from 'electron';
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import type { GoogleAuthState } from '@freecrawl/shared-types';
|
||||
import { resolveCredentials } from './credentials.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
const USERINFO_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
||||
const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
|
||||
|
||||
/** How long to wait for the user to finish in the browser. */
|
||||
const CONSENT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
/** Refresh the access token this many ms before it actually expires. */
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* OAuth scope per integration. `openid email` is always appended so the
|
||||
* connected account's address can be shown in the UI.
|
||||
*/
|
||||
const INTEGRATION_SCOPES: Record<string, string> = {
|
||||
gsc: 'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
ga4: 'https://www.googleapis.com/auth/analytics.readonly',
|
||||
sheets: 'https://www.googleapis.com/auth/spreadsheets',
|
||||
};
|
||||
|
||||
const CIPHER_PREFIX = 'sb:';
|
||||
const PLAINTEXT_PREFIX = 'pt:';
|
||||
|
||||
interface TokenRecord {
|
||||
refreshToken: string;
|
||||
email: string | null;
|
||||
connectedAt: string;
|
||||
}
|
||||
|
||||
/** On-disk shape: integration id → encrypted JSON of a `TokenRecord`. */
|
||||
type TokenFile = Record<string, string>;
|
||||
|
||||
let cache: TokenFile | null = null;
|
||||
/** In-memory short-lived access tokens — never persisted. */
|
||||
const accessTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||
/** Guards against two consent flows running at once. */
|
||||
let authInProgress = false;
|
||||
|
||||
function filePath(): string {
|
||||
return join(app.getPath('userData'), 'google-tokens.enc.json');
|
||||
}
|
||||
|
||||
function loadStore(): TokenFile {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const path = filePath();
|
||||
if (existsSync(path)) {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
cache = parsed as TokenFile;
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'main',
|
||||
`google token 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', `google token store write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function encrypt(value: string): string {
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return CIPHER_PREFIX + safeStorage.encryptString(value).toString('base64');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', 'main', `google token encrypt failed: ${(err as Error).message}`);
|
||||
}
|
||||
return PLAINTEXT_PREFIX + Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
function decrypt(stored: string): string {
|
||||
try {
|
||||
if (stored.startsWith(CIPHER_PREFIX)) {
|
||||
return safeStorage.decryptString(
|
||||
Buffer.from(stored.slice(CIPHER_PREFIX.length), 'base64'),
|
||||
);
|
||||
}
|
||||
if (stored.startsWith(PLAINTEXT_PREFIX)) {
|
||||
return Buffer.from(stored.slice(PLAINTEXT_PREFIX.length), 'base64').toString('utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('warn', 'main', `google token decrypt failed: ${(err as Error).message}`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readRecord(integrationId: string): TokenRecord | null {
|
||||
const raw = loadStore()[integrationId];
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const json = decrypt(raw);
|
||||
if (!json) return null;
|
||||
const parsed = JSON.parse(json) as TokenRecord;
|
||||
if (parsed && typeof parsed.refreshToken === 'string' && parsed.refreshToken) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* corrupt record — treat as not connected */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeRecord(integrationId: string, record: TokenRecord): void {
|
||||
const store = loadStore();
|
||||
store[integrationId] = encrypt(JSON.stringify(record));
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Redacted connection state for the renderer — no tokens cross IPC. */
|
||||
export function getAuthState(integrationId: string): GoogleAuthState {
|
||||
const record = readRecord(integrationId);
|
||||
if (!record) return { connected: false, email: null, connectedAt: null };
|
||||
return {
|
||||
connected: true,
|
||||
email: record.email,
|
||||
connectedAt: record.connectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the integration id is an OAuth-keystone Google integration. */
|
||||
export function isGoogleOAuthIntegration(id: string): boolean {
|
||||
return id in INTEGRATION_SCOPES;
|
||||
}
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
/** Resolve the user's BYOC client id + secret, or throw a clear error. */
|
||||
function resolveClient(integrationId: string): {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
} {
|
||||
const creds = resolveCredentials(integrationId);
|
||||
const clientId = (creds['clientId'] ?? '').trim();
|
||||
const clientSecret = (creds['clientSecret'] ?? '').trim();
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'Missing OAuth client — enter your Google Cloud client ID and secret in Settings → Integrations first.',
|
||||
);
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive consent flow for one integration. Opens the
|
||||
* system browser, catches the loopback redirect, and stores the
|
||||
* resulting refresh token. Resolves once the user finishes or cancels.
|
||||
*/
|
||||
export async function startAuth(integrationId: string): Promise<GoogleAuthState> {
|
||||
const scope = INTEGRATION_SCOPES[integrationId];
|
||||
if (!scope) {
|
||||
throw new Error(`Unknown Google integration "${integrationId}"`);
|
||||
}
|
||||
if (authInProgress) {
|
||||
throw new Error('Another Google sign-in is already in progress.');
|
||||
}
|
||||
const { clientId, clientSecret } = resolveClient(integrationId);
|
||||
|
||||
authInProgress = true;
|
||||
let server: Server | null = null;
|
||||
try {
|
||||
const codeVerifier = base64url(randomBytes(32));
|
||||
const codeChallenge = base64url(
|
||||
createHash('sha256').update(codeVerifier).digest(),
|
||||
);
|
||||
const stateToken = base64url(randomBytes(16));
|
||||
|
||||
const { code, redirectUri } = await new Promise<{
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}>((resolve, reject) => {
|
||||
const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const code = parsed.searchParams.get('code');
|
||||
const error = parsed.searchParams.get('error');
|
||||
const returnedState = parsed.searchParams.get('state');
|
||||
// Ignore stray requests (favicon, etc.) that carry neither.
|
||||
if (!code && !error) {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
if (error || !code) {
|
||||
res.end(resultPage('FreeCrawl — sign-in cancelled', false));
|
||||
reject(new Error(error ? `Consent denied (${error})` : 'No authorization code returned'));
|
||||
return;
|
||||
}
|
||||
if (returnedState !== stateToken) {
|
||||
res.end(resultPage('FreeCrawl — sign-in failed', false));
|
||||
reject(new Error('OAuth state mismatch — sign-in rejected for safety.'));
|
||||
return;
|
||||
}
|
||||
res.end(resultPage('FreeCrawl — connected', true));
|
||||
const addr = server?.address();
|
||||
const port = addr && typeof addr === 'object' ? addr.port : 0;
|
||||
resolve({ code, redirectUri: `http://127.0.0.1:${port}` });
|
||||
};
|
||||
|
||||
server = createServer((req, res) => onRequest(req, res));
|
||||
server.on('error', (err) => reject(err));
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server?.address();
|
||||
const port = addr && typeof addr === 'object' ? addr.port : 0;
|
||||
if (!port) {
|
||||
reject(new Error('Could not open a local callback port.'));
|
||||
return;
|
||||
}
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
const authUrl =
|
||||
`${AUTH_ENDPOINT}?` +
|
||||
new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: `${scope} openid email`,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state: stateToken,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}).toString();
|
||||
logger.log('info', 'main', `google-oauth: opening consent for "${integrationId}"`);
|
||||
void shell.openExternal(authUrl);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for the browser sign-in.'));
|
||||
}, CONSENT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
// ── Exchange the code for tokens ──────────────────────────────────
|
||||
const tokenRes = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const tokenJson = (await tokenRes.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!tokenRes.ok || !tokenJson) {
|
||||
const msg =
|
||||
(tokenJson?.['error_description'] as string | undefined) ??
|
||||
(tokenJson?.['error'] as string | undefined) ??
|
||||
`token exchange failed (HTTP ${tokenRes.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
const refreshToken = tokenJson['refresh_token'];
|
||||
const accessToken = tokenJson['access_token'];
|
||||
if (typeof refreshToken !== 'string' || !refreshToken) {
|
||||
throw new Error(
|
||||
'Google did not return a refresh token — revoke FreeCrawl access in your Google account and reconnect.',
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort: resolve the connected account email for the UI.
|
||||
let email: string | null = null;
|
||||
if (typeof accessToken === 'string' && accessToken) {
|
||||
try {
|
||||
const uiRes = await fetch(USERINFO_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (uiRes.ok) {
|
||||
const ui = (await uiRes.json()) as { email?: unknown };
|
||||
if (typeof ui.email === 'string') email = ui.email;
|
||||
}
|
||||
} catch {
|
||||
/* email is cosmetic — ignore */
|
||||
}
|
||||
const expiresIn =
|
||||
typeof tokenJson['expires_in'] === 'number'
|
||||
? (tokenJson['expires_in'] as number)
|
||||
: 3600;
|
||||
accessTokens.set(integrationId, {
|
||||
token: accessToken,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
writeRecord(integrationId, {
|
||||
refreshToken,
|
||||
email,
|
||||
connectedAt: new Date().toISOString(),
|
||||
});
|
||||
logger.log(
|
||||
'info',
|
||||
'main',
|
||||
`google-oauth: "${integrationId}" connected${email ? ` as ${email}` : ''}`,
|
||||
);
|
||||
return getAuthState(integrationId);
|
||||
} finally {
|
||||
authInProgress = false;
|
||||
if (server) {
|
||||
const s = server as Server & { closeAllConnections?: () => void };
|
||||
try {
|
||||
s.closeAllConnections?.();
|
||||
} catch {
|
||||
/* older runtimes */
|
||||
}
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a valid access token for an in-process API call. Uses the cached
|
||||
* token when still fresh, otherwise refreshes it from the stored
|
||||
* refresh token. Main-process only — never expose the result over IPC.
|
||||
*/
|
||||
export async function getAccessToken(integrationId: string): Promise<string> {
|
||||
const cached = accessTokens.get(integrationId);
|
||||
if (cached && cached.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
|
||||
return cached.token;
|
||||
}
|
||||
const record = readRecord(integrationId);
|
||||
if (!record) {
|
||||
throw new Error(`"${integrationId}" is not connected — sign in with Google first.`);
|
||||
}
|
||||
const { clientId, clientSecret } = resolveClient(integrationId);
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: record.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json || typeof json['access_token'] !== 'string') {
|
||||
const msg =
|
||||
(json?.['error_description'] as string | undefined) ??
|
||||
(json?.['error'] as string | undefined) ??
|
||||
`token refresh failed (HTTP ${res.status})`;
|
||||
throw new Error(`${msg} — you may need to reconnect this integration.`);
|
||||
}
|
||||
const token = json['access_token'] as string;
|
||||
const expiresIn = typeof json['expires_in'] === 'number' ? (json['expires_in'] as number) : 3600;
|
||||
accessTokens.set(integrationId, {
|
||||
token,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Disconnect — revoke at Google (best-effort) and wipe the stored token. */
|
||||
export async function revokeAuth(integrationId: string): Promise<GoogleAuthState> {
|
||||
const record = readRecord(integrationId);
|
||||
if (record) {
|
||||
try {
|
||||
await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(record.refreshToken)}`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort — local wipe below is what matters */
|
||||
}
|
||||
}
|
||||
const store = loadStore();
|
||||
if (store[integrationId]) {
|
||||
delete store[integrationId];
|
||||
persist();
|
||||
}
|
||||
accessTokens.delete(integrationId);
|
||||
logger.log('info', 'main', `google-oauth: "${integrationId}" disconnected`);
|
||||
return getAuthState(integrationId);
|
||||
}
|
||||
|
||||
/** Minimal styled HTML shown in the browser tab after the redirect. */
|
||||
function resultPage(title: string, ok: boolean): string {
|
||||
const accent = ok ? '#34d399' : '#f87171';
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>
|
||||
<body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;background:#0a0a0a;font-family:system-ui,sans-serif">
|
||||
<div style="text-align:center;color:#e5e5e5">
|
||||
<div style="font-size:42px;color:${accent}">${ok ? '✓' : '✕'}</div>
|
||||
<h2 style="font-weight:600">${title}</h2>
|
||||
<p style="color:#a3a3a3">You can close this tab and return to FreeCrawl.</p>
|
||||
</div></body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Faz 7 — Google Search Console API client.
|
||||
*
|
||||
* Two calls back the Search Console tab:
|
||||
* - `listSites()` — the connected account's verified properties.
|
||||
* - `querySearchAnalytics()` — per-page clicks / impressions / CTR /
|
||||
* position for a date range (one bulk `page`-dimension query,
|
||||
* transparently paged past the 25 000-row API limit).
|
||||
*
|
||||
* Authentication runs through the shared OAuth keystone
|
||||
* (`google-oauth.ts`) — `getAccessToken('gsc')` mints / refreshes the
|
||||
* bearer token; nothing here touches credentials directly.
|
||||
*/
|
||||
import type { GscSite } from '@freecrawl/shared-types';
|
||||
import { getAccessToken } from './google-oauth.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const API_BASE = 'https://www.googleapis.com/webmasters/v3';
|
||||
/** Search Console caps a single Search Analytics response at 25 000 rows. */
|
||||
const ROW_LIMIT = 25000;
|
||||
/** Hard ceiling on paging so a pathological site can't loop forever. */
|
||||
const MAX_ROWS = 200_000;
|
||||
|
||||
/** Extract the human-readable message from a Google API error envelope. */
|
||||
function apiError(json: unknown, status: number): string {
|
||||
const err = (json as { error?: unknown } | null)?.error;
|
||||
if (err && typeof err === 'object') {
|
||||
const msg = (err as { message?: unknown }).message;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'Search Console denied access — check the account has access to this property and the Search Console API is enabled.';
|
||||
}
|
||||
return `Search Console API error (HTTP ${status})`;
|
||||
}
|
||||
|
||||
/** List the connected account's Search Console properties. */
|
||||
export async function listSites(): Promise<GscSite[]> {
|
||||
const token = await getAccessToken('gsc');
|
||||
const res = await fetch(`${API_BASE}/sites`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
siteEntry?: { siteUrl?: unknown; permissionLevel?: unknown }[];
|
||||
} | null;
|
||||
if (!res.ok || !json) throw new Error(apiError(json, res.status));
|
||||
const entries = Array.isArray(json.siteEntry) ? json.siteEntry : [];
|
||||
return entries
|
||||
.map((e) => ({
|
||||
siteUrl: typeof e.siteUrl === 'string' ? e.siteUrl : '',
|
||||
permissionLevel:
|
||||
typeof e.permissionLevel === 'string' ? e.permissionLevel : 'unknown',
|
||||
}))
|
||||
.filter((e) => e.siteUrl.length > 0);
|
||||
}
|
||||
|
||||
/** One page-level Search Console metrics row. */
|
||||
export interface GscPageRow {
|
||||
url: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull per-page Search Console metrics for `[startDate, endDate]`
|
||||
* (`YYYY-MM-DD`, inclusive). Pages transparently past the 25 000-row
|
||||
* API limit and returns the flattened list.
|
||||
*/
|
||||
export async function querySearchAnalytics(
|
||||
property: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): Promise<GscPageRow[]> {
|
||||
const token = await getAccessToken('gsc');
|
||||
const endpoint = `${API_BASE}/sites/${encodeURIComponent(
|
||||
property,
|
||||
)}/searchAnalytics/query`;
|
||||
const out: GscPageRow[] = [];
|
||||
let startRow = 0;
|
||||
|
||||
for (;;) {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
startDate,
|
||||
endDate,
|
||||
dimensions: ['page'],
|
||||
rowLimit: ROW_LIMIT,
|
||||
startRow,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
rows?: {
|
||||
keys?: unknown;
|
||||
clicks?: unknown;
|
||||
impressions?: unknown;
|
||||
ctr?: unknown;
|
||||
position?: unknown;
|
||||
}[];
|
||||
} | null;
|
||||
if (!res.ok || !json) throw new Error(apiError(json, res.status));
|
||||
|
||||
const rows = Array.isArray(json.rows) ? json.rows : [];
|
||||
for (const r of rows) {
|
||||
const url = Array.isArray(r.keys) ? String(r.keys[0] ?? '') : '';
|
||||
if (!url) continue;
|
||||
out.push({
|
||||
url,
|
||||
clicks: Number(r.clicks) || 0,
|
||||
impressions: Number(r.impressions) || 0,
|
||||
ctr: Number(r.ctr) || 0,
|
||||
position: Number(r.position) || 0,
|
||||
});
|
||||
}
|
||||
if (rows.length < ROW_LIMIT || out.length >= MAX_ROWS) break;
|
||||
startRow += ROW_LIMIT;
|
||||
}
|
||||
logger.log(
|
||||
'info',
|
||||
'gsc',
|
||||
`Search Analytics: ${out.length} page row(s) for ${property} (${startDate}…${endDate})`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Result of one URL Inspection call — flat shape matching the DB row. */
|
||||
export interface GscInspectionRaw {
|
||||
verdict: string | null;
|
||||
coverageState: string | null;
|
||||
robotsTxtState: string | null;
|
||||
indexingState: string | null;
|
||||
lastCrawlTime: string | null;
|
||||
googleCanonical: string | null;
|
||||
userCanonical: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one URL via the GSC URL Inspection API. Quota is 2 000
|
||||
* calls / day per property — callers must throttle their batch.
|
||||
*/
|
||||
export async function inspectUrl(
|
||||
property: string,
|
||||
url: string,
|
||||
): Promise<GscInspectionRaw> {
|
||||
const token = await getAccessToken('gsc');
|
||||
const res = await fetch(
|
||||
'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ inspectionUrl: url, siteUrl: property }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
inspectionResult?: { indexStatusResult?: Record<string, unknown> };
|
||||
} | null;
|
||||
if (!res.ok || !json) throw new Error(apiError(json, res.status));
|
||||
const idx = json.inspectionResult?.indexStatusResult ?? {};
|
||||
const s = (k: string): string | null =>
|
||||
typeof idx[k] === 'string' ? (idx[k] as string) : null;
|
||||
return {
|
||||
verdict: s('verdict'),
|
||||
coverageState: s('coverageState'),
|
||||
robotsTxtState: s('robotsTxtState'),
|
||||
indexingState: s('indexingState'),
|
||||
lastCrawlTime: s('lastCrawlTime'),
|
||||
googleCanonical: s('googleCanonical'),
|
||||
userCanonical: s('userCanonical'),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
type ExportXmlResult,
|
||||
type ExportBrokenLinksInput,
|
||||
type ExportBrokenLinksResult,
|
||||
type ExportImagesInput,
|
||||
type ExportImagesResult,
|
||||
type ExportTabularInput,
|
||||
type ExportTabularResult,
|
||||
type DataDeleteByDomainInput,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
type WordCountPerDirectoryInput,
|
||||
type WordCountPerDirectoryRow,
|
||||
type SitemapOrphanRow,
|
||||
type OrphanCrossSourceRow,
|
||||
type SettingsExportInput,
|
||||
type SettingsExportResult,
|
||||
type SettingsImportResult,
|
||||
@@ -93,11 +96,42 @@ import {
|
||||
type PagespeedRunInput,
|
||||
type PagespeedRunResult,
|
||||
type PagespeedStrategy,
|
||||
type GoogleAuthState,
|
||||
type GoogleAuthResult,
|
||||
type GscListSitesResult,
|
||||
type GscFetchInput,
|
||||
type GscFetchResult,
|
||||
type GscFetchMeta,
|
||||
type GscQueryInput,
|
||||
type GscQueryResult,
|
||||
type Ga4ListPropertiesResult,
|
||||
type Ga4FetchInput,
|
||||
type Ga4FetchResult,
|
||||
type Ga4FetchMeta,
|
||||
type Ga4QueryInput,
|
||||
type Ga4QueryResult,
|
||||
type AiRunInput,
|
||||
type AiRunResult,
|
||||
type AiQueryInput,
|
||||
type AiQueryResult,
|
||||
type SeoRunInput,
|
||||
type SeoRunResult,
|
||||
type SeoQueryInput,
|
||||
type SeoQueryResult,
|
||||
type GscInspectRunInput,
|
||||
type GscInspectRunResult,
|
||||
type ExportSheetsInput,
|
||||
type ExportSheetsResult,
|
||||
type ExportBigqueryInput,
|
||||
type ExportBigqueryResult,
|
||||
type GscInspectionResult,
|
||||
type UrlAnalyticsDetail,
|
||||
} from '@freecrawl/shared-types';
|
||||
import {
|
||||
Crawler,
|
||||
exportUrlsToCsv,
|
||||
exportBrokenLinksToCsv,
|
||||
exportImagesToCsv,
|
||||
exportUrlsToJson,
|
||||
exportUrlsToXml,
|
||||
exportTabular,
|
||||
@@ -122,6 +156,14 @@ import {
|
||||
resolveCredentials,
|
||||
} from './credentials.js';
|
||||
import { runPagespeedBatch, type PagespeedBatchItem } from './pagespeed.js';
|
||||
import { startAuth, getAuthState, revokeAuth } from './google-oauth.js';
|
||||
import { listSites, querySearchAnalytics, inspectUrl } from './gsc.js';
|
||||
import { exportCategoryToSheets } from './sheets-export.js';
|
||||
import { exportCategoryToBigQuery } from './bigquery-export.js';
|
||||
import { listProperties as ga4ListProperties, runReport as ga4RunReport } from './ga4.js';
|
||||
import { runAiBatch, type AiBatchItem } from './ai-batch.js';
|
||||
import { resolveModel, defaultConcurrency } from './ai-providers.js';
|
||||
import { runSeoBatch } from './seo-batch.js';
|
||||
import * as logger from './logger.js';
|
||||
import { dbReaderPool, callReaderOrFallback } from './db-reader-pool.js';
|
||||
import { dbWriterPool } from './db-writer-pool.js';
|
||||
@@ -152,6 +194,15 @@ let activeCrawler: Crawler | null = null;
|
||||
* `pagespeedCancelRequested` is the cooperative cancel flag. */
|
||||
let pagespeedRunActive = false;
|
||||
let pagespeedCancelRequested = false;
|
||||
/** Faz 7 — AI run guard. Same shape as the PSI guard. */
|
||||
let aiRunActive = false;
|
||||
let aiCancelRequested = false;
|
||||
/** Faz 7 — SEO provider run guard. */
|
||||
let seoRunActive = false;
|
||||
let seoCancelRequested = false;
|
||||
/** Faz 7 — GSC URL Inspection run guard. */
|
||||
let gscInspectRunActive = false;
|
||||
let gscInspectCancelRequested = false;
|
||||
/** Most-recent CrawlConfig — needed by the crash-recovery resume
|
||||
* flow so we can re-create the Crawler with the same knobs the user
|
||||
* had set, without re-prompting. Hydrated lazily from `project_meta`
|
||||
@@ -1745,6 +1796,16 @@ function registerIpc(): void {
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.reportsOrphanCrossSource,
|
||||
(_e, limit?: number): Promise<OrphanCrossSourceRow[]> =>
|
||||
callReaderOrFallback<OrphanCrossSourceRow[]>(
|
||||
'orphanPagesCrossSource',
|
||||
[limit ?? 1000],
|
||||
() => getDb().orphanPagesCrossSource(limit ?? 1000),
|
||||
),
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.reportsServerHeaders, () =>
|
||||
callReaderOrFallback<ServerHeaderRow[]>('serverHeaderBreakdown', [], () =>
|
||||
getDb().serverHeaderBreakdown(),
|
||||
@@ -2004,6 +2065,635 @@ function registerIpc(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// V1 Faz 7 — Google OAuth keystone. The interactive consent flow runs
|
||||
// entirely in the main process (loopback HTTP server + system
|
||||
// browser); the renderer only ever sees the redacted GoogleAuthState.
|
||||
ipcMain.handle(
|
||||
IPC.googleAuthStatus,
|
||||
(_e, id: string): GoogleAuthState => getAuthState(id),
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.googleAuthStart,
|
||||
async (_e, id: string): Promise<GoogleAuthResult> => {
|
||||
try {
|
||||
const state = await startAuth(id);
|
||||
return { ok: true, error: null, state };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
state: getAuthState(id),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.googleAuthRevoke,
|
||||
(_e, id: string): Promise<GoogleAuthState> => revokeAuth(id),
|
||||
);
|
||||
|
||||
// V1 Faz 7 — Google Search Console. `gscFetch` pulls per-page metrics
|
||||
// for a date range and wholesale-replaces the stored snapshot;
|
||||
// `gscQuery` lists crawled pages joined with that data.
|
||||
ipcMain.handle(
|
||||
IPC.gscListSites,
|
||||
async (): Promise<GscListSitesResult> => {
|
||||
try {
|
||||
return { ok: true, error: null, sites: await listSites() };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
sites: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.gscFetch,
|
||||
async (_e, input: GscFetchInput): Promise<GscFetchResult> => {
|
||||
if (!input.property) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'No Search Console property selected.',
|
||||
rowCount: 0,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
// Search Console data lags ~2 days — end the window there and
|
||||
// span `days` back so the range only covers data that exists.
|
||||
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
const end = new Date(Date.now() - 2 * 86_400_000);
|
||||
const start = new Date(end.getTime() - (input.days - 1) * 86_400_000);
|
||||
const startDate = isoDate(start);
|
||||
const endDate = isoDate(end);
|
||||
// Pin the DB to the project active when the pull started.
|
||||
const runDb = getDb();
|
||||
try {
|
||||
const rows = await querySearchAnalytics(
|
||||
input.property,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
runDb.replaceGscResults(rows, fetchedAt);
|
||||
const meta: GscFetchMeta = {
|
||||
property: input.property,
|
||||
startDate,
|
||||
endDate,
|
||||
fetchedAt,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
runDb.setMeta('gscFetchMeta', JSON.stringify(meta));
|
||||
fireDataChanged();
|
||||
logger.log(
|
||||
'info',
|
||||
'gsc',
|
||||
`Search Console pull stored — ${rows.length} page row(s)`,
|
||||
);
|
||||
return { ok: true, error: null, rowCount: rows.length, meta };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
rowCount: 0,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.gscQuery,
|
||||
async (_e, input: GscQueryInput): Promise<GscQueryResult> => {
|
||||
const params = {
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
filter: input.filter,
|
||||
};
|
||||
const result = await callReaderOrFallback<{
|
||||
rows: GscQueryResult['rows'];
|
||||
total: number;
|
||||
}>('queryGsc', [params], () => getDb().queryGsc(params));
|
||||
let meta: GscFetchMeta | null = null;
|
||||
try {
|
||||
const raw = getDb().getMeta('gscFetchMeta');
|
||||
if (raw) meta = JSON.parse(raw) as GscFetchMeta;
|
||||
} catch {
|
||||
/* no / corrupt meta — render without the context line */
|
||||
}
|
||||
return { rows: result.rows, total: result.total, meta };
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — Google Analytics 4. Same shape as GSC: list properties,
|
||||
// fetch a date-range snapshot, query crawled pages joined with the
|
||||
// stored data. Auth runs through the shared OAuth keystone.
|
||||
ipcMain.handle(
|
||||
IPC.ga4ListProperties,
|
||||
async (): Promise<Ga4ListPropertiesResult> => {
|
||||
try {
|
||||
return { ok: true, error: null, properties: await ga4ListProperties() };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.ga4Fetch,
|
||||
async (_e, input: Ga4FetchInput): Promise<Ga4FetchResult> => {
|
||||
if (!input.property) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'No GA4 property selected.',
|
||||
rowCount: 0,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
// GA4 data is near-realtime — end the range at today.
|
||||
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (input.days - 1) * 86_400_000);
|
||||
const startDate = isoDate(start);
|
||||
const endDate = isoDate(end);
|
||||
const runDb = getDb();
|
||||
try {
|
||||
const rows = await ga4RunReport(input.property, startDate, endDate);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
runDb.replaceGa4Results(rows, fetchedAt);
|
||||
const meta: Ga4FetchMeta = {
|
||||
property: input.property,
|
||||
propertyName: input.propertyName || input.property,
|
||||
startDate,
|
||||
endDate,
|
||||
fetchedAt,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
runDb.setMeta('ga4FetchMeta', JSON.stringify(meta));
|
||||
fireDataChanged();
|
||||
logger.log(
|
||||
'info',
|
||||
'ga4',
|
||||
`GA4 pull stored — ${rows.length} page row(s)`,
|
||||
);
|
||||
return { ok: true, error: null, rowCount: rows.length, meta };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
rowCount: 0,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
IPC.ga4Query,
|
||||
async (_e, input: Ga4QueryInput): Promise<Ga4QueryResult> => {
|
||||
const params = {
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
filter: input.filter,
|
||||
};
|
||||
const result = await callReaderOrFallback<{
|
||||
rows: Ga4QueryResult['rows'];
|
||||
total: number;
|
||||
}>('queryGa4', [params], () => getDb().queryGa4(params));
|
||||
let meta: Ga4FetchMeta | null = null;
|
||||
try {
|
||||
const raw = getDb().getMeta('ga4FetchMeta');
|
||||
if (raw) meta = JSON.parse(raw) as Ga4FetchMeta;
|
||||
} catch {
|
||||
/* no / corrupt meta — render without the context line */
|
||||
}
|
||||
return { rows: result.rows, total: result.total, meta };
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — AI integrations (OpenAI / Anthropic / Ollama). The
|
||||
// renderer hands over a prompt template + URL list; the handler
|
||||
// substitutes per-URL variables from the crawl DB, then drives a
|
||||
// small concurrency pool through the chosen provider.
|
||||
const substitutePrompt = (
|
||||
template: string,
|
||||
ctx: {
|
||||
url: string;
|
||||
title: string | null;
|
||||
metaDescription: string | null;
|
||||
h1: string | null;
|
||||
body: string | null;
|
||||
},
|
||||
): string => {
|
||||
/** Body cap — keep prompts under control on long pages. */
|
||||
const BODY_MAX = 2000;
|
||||
const body = (ctx.body ?? '').slice(0, BODY_MAX);
|
||||
return template
|
||||
.replace(/\{url\}/g, ctx.url)
|
||||
.replace(/\{title\}/g, ctx.title ?? '')
|
||||
.replace(/\{description\}/g, ctx.metaDescription ?? '')
|
||||
.replace(/\{h1\}/g, ctx.h1 ?? '')
|
||||
.replace(/\{body\}/g, body);
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.aiRun,
|
||||
async (_e, input: AiRunInput): Promise<AiRunResult> => {
|
||||
if (aiRunActive) {
|
||||
return { completed: 0, failed: 0, cancelled: true };
|
||||
}
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(input.urls ?? []).filter(
|
||||
(u): u is string => typeof u === 'string' && u.length > 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (urls.length === 0 || !input.prompt?.trim()) {
|
||||
return { completed: 0, failed: 0, cancelled: false };
|
||||
}
|
||||
const runDb = getDb();
|
||||
const model = resolveModel(input.provider, input.model);
|
||||
const concurrency = defaultConcurrency(input.provider);
|
||||
|
||||
// Build per-URL prompts up-front so a missing page doesn't get
|
||||
// queued; URLs that aren't in the DB are skipped silently.
|
||||
const items: AiBatchItem[] = [];
|
||||
for (const url of urls) {
|
||||
const ctx = runDb.getAiContext(url);
|
||||
if (!ctx) continue;
|
||||
items.push({
|
||||
url,
|
||||
prompt: substitutePrompt(input.prompt, ctx),
|
||||
});
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return { completed: 0, failed: 0, cancelled: false };
|
||||
}
|
||||
|
||||
aiRunActive = true;
|
||||
aiCancelRequested = false;
|
||||
logger.log(
|
||||
'info',
|
||||
'ai',
|
||||
`${input.provider}/${model} run started — ${items.length} prompt(s) (concurrency=${concurrency})`,
|
||||
);
|
||||
try {
|
||||
const result = await runAiBatch({
|
||||
provider: input.provider,
|
||||
model,
|
||||
concurrency,
|
||||
items,
|
||||
isCancelled: () => aiCancelRequested,
|
||||
onResult: (outcome) => {
|
||||
try {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
if (outcome.ok && outcome.output) {
|
||||
runDb.upsertAiResult(
|
||||
outcome.url,
|
||||
input.provider,
|
||||
outcome.output.model,
|
||||
outcome.output.text,
|
||||
outcome.output.tokensIn,
|
||||
outcome.output.tokensOut,
|
||||
'ok',
|
||||
null,
|
||||
fetchedAt,
|
||||
);
|
||||
} else {
|
||||
runDb.upsertAiResult(
|
||||
outcome.url,
|
||||
input.provider,
|
||||
model,
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
'error',
|
||||
outcome.error ?? 'Unknown error',
|
||||
fetchedAt,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'error',
|
||||
'ai',
|
||||
`DB write failed for ${outcome.url}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onProgress: (done, total, currentUrl) => {
|
||||
mainWindow?.webContents.send(IPC.aiProgress, {
|
||||
done,
|
||||
total,
|
||||
currentUrl,
|
||||
running: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
mainWindow?.webContents.send(IPC.aiProgress, {
|
||||
done: result.completed + result.failed,
|
||||
total: items.length,
|
||||
currentUrl: null,
|
||||
running: false,
|
||||
});
|
||||
fireDataChanged();
|
||||
return result;
|
||||
} finally {
|
||||
aiRunActive = false;
|
||||
aiCancelRequested = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.aiCancel, () => {
|
||||
if (aiRunActive) {
|
||||
aiCancelRequested = true;
|
||||
logger.log('info', 'ai', 'run cancellation requested');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.aiQuery,
|
||||
async (_e, input: AiQueryInput): Promise<AiQueryResult> => {
|
||||
const params = {
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
provider: input.provider,
|
||||
filter: input.filter,
|
||||
};
|
||||
return callReaderOrFallback<AiQueryResult>(
|
||||
'queryAi',
|
||||
[params],
|
||||
() => getDb().queryAi(params),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — third-party SEO authority providers (Ahrefs / Majestic /
|
||||
// Moz / Semrush). Same shape as the AI run: pin the DB to the active
|
||||
// project, fan URLs through the provider's per-URL endpoint, persist
|
||||
// ok/error rows so partial outages are visible in the UI.
|
||||
ipcMain.handle(
|
||||
IPC.seoRun,
|
||||
async (_e, input: SeoRunInput): Promise<SeoRunResult> => {
|
||||
if (seoRunActive) return { completed: 0, failed: 0, cancelled: true };
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(input.urls ?? []).filter(
|
||||
(u): u is string => typeof u === 'string' && u.length > 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (urls.length === 0) return { completed: 0, failed: 0, cancelled: false };
|
||||
const runDb = getDb();
|
||||
seoRunActive = true;
|
||||
seoCancelRequested = false;
|
||||
logger.log('info', 'seo', `${input.provider} run started — ${urls.length} URL(s)`);
|
||||
try {
|
||||
const result = await runSeoBatch({
|
||||
provider: input.provider,
|
||||
urls,
|
||||
isCancelled: () => seoCancelRequested,
|
||||
onResult: (outcome) => {
|
||||
try {
|
||||
runDb.upsertSeoResult(
|
||||
outcome.url,
|
||||
input.provider,
|
||||
outcome.metrics,
|
||||
outcome.ok ? 'ok' : 'error',
|
||||
outcome.error,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'error',
|
||||
'seo',
|
||||
`DB write failed for ${outcome.url}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onProgress: (done, total, currentUrl) => {
|
||||
mainWindow?.webContents.send(IPC.seoProgress, {
|
||||
done,
|
||||
total,
|
||||
currentUrl,
|
||||
running: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
mainWindow?.webContents.send(IPC.seoProgress, {
|
||||
done: result.completed + result.failed,
|
||||
total: urls.length,
|
||||
currentUrl: null,
|
||||
running: false,
|
||||
});
|
||||
fireDataChanged();
|
||||
return result;
|
||||
} finally {
|
||||
seoRunActive = false;
|
||||
seoCancelRequested = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(IPC.seoCancel, () => {
|
||||
if (seoRunActive) {
|
||||
seoCancelRequested = true;
|
||||
logger.log('info', 'seo', 'run cancellation requested');
|
||||
}
|
||||
});
|
||||
ipcMain.handle(
|
||||
IPC.seoQuery,
|
||||
async (_e, input: SeoQueryInput): Promise<SeoQueryResult> => {
|
||||
const params = {
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
provider: input.provider,
|
||||
filter: input.filter,
|
||||
};
|
||||
return callReaderOrFallback<SeoQueryResult>(
|
||||
'querySeo',
|
||||
[params],
|
||||
() => getDb().querySeo(params),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — Google Search Console URL Inspection. Sequential — the
|
||||
// API caps at 2 000 calls / day per property; running in parallel
|
||||
// doesn't speed it up materially and risks burning quota on errors.
|
||||
ipcMain.handle(
|
||||
IPC.gscInspectRun,
|
||||
async (_e, input: GscInspectRunInput): Promise<GscInspectRunResult> => {
|
||||
if (gscInspectRunActive) {
|
||||
return { completed: 0, failed: 0, cancelled: true };
|
||||
}
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(input.urls ?? []).filter(
|
||||
(u): u is string => typeof u === 'string' && u.length > 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!input.property || urls.length === 0) {
|
||||
return { completed: 0, failed: 0, cancelled: false };
|
||||
}
|
||||
const runDb = getDb();
|
||||
gscInspectRunActive = true;
|
||||
gscInspectCancelRequested = false;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let done = 0;
|
||||
logger.log(
|
||||
'info',
|
||||
'gsc',
|
||||
`URL Inspection run started — ${urls.length} URL(s) for ${input.property}`,
|
||||
);
|
||||
mainWindow?.webContents.send(IPC.gscInspectProgress, {
|
||||
done: 0,
|
||||
total: urls.length,
|
||||
currentUrl: null,
|
||||
running: true,
|
||||
});
|
||||
try {
|
||||
for (const url of urls) {
|
||||
if (gscInspectCancelRequested) break;
|
||||
mainWindow?.webContents.send(IPC.gscInspectProgress, {
|
||||
done,
|
||||
total: urls.length,
|
||||
currentUrl: url,
|
||||
running: true,
|
||||
});
|
||||
const fetchedAt = new Date().toISOString();
|
||||
try {
|
||||
const raw = await inspectUrl(input.property, url);
|
||||
const result: GscInspectionResult = {
|
||||
...raw,
|
||||
status: 'ok',
|
||||
error: null,
|
||||
fetchedAt,
|
||||
};
|
||||
runDb.upsertGscInspection(url, result);
|
||||
completed++;
|
||||
} catch (err) {
|
||||
runDb.upsertGscInspection(url, {
|
||||
verdict: null,
|
||||
coverageState: null,
|
||||
robotsTxtState: null,
|
||||
indexingState: null,
|
||||
lastCrawlTime: null,
|
||||
googleCanonical: null,
|
||||
userCanonical: null,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
fetchedAt,
|
||||
});
|
||||
failed++;
|
||||
}
|
||||
done++;
|
||||
}
|
||||
mainWindow?.webContents.send(IPC.gscInspectProgress, {
|
||||
done,
|
||||
total: urls.length,
|
||||
currentUrl: null,
|
||||
running: false,
|
||||
});
|
||||
fireDataChanged();
|
||||
return {
|
||||
completed,
|
||||
failed,
|
||||
cancelled: gscInspectCancelRequested && done < urls.length,
|
||||
};
|
||||
} finally {
|
||||
gscInspectRunActive = false;
|
||||
gscInspectCancelRequested = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(IPC.gscInspectCancel, () => {
|
||||
if (gscInspectRunActive) {
|
||||
gscInspectCancelRequested = true;
|
||||
logger.log('info', 'gsc', 'URL Inspection cancellation requested');
|
||||
}
|
||||
});
|
||||
|
||||
// V1 Faz 7 — Google Sheets export. Uses the shared OAuth keystone
|
||||
// (the user connects once on the GSC/GA4 flows or via the Sheets
|
||||
// integration card). Creates a fresh spreadsheet per export.
|
||||
ipcMain.handle(
|
||||
IPC.exportSheets,
|
||||
async (_e, input: ExportSheetsInput): Promise<ExportSheetsResult> => {
|
||||
try {
|
||||
const title =
|
||||
input.title?.trim() ||
|
||||
`FreeCrawl — ${input.category} — ${new Date().toISOString().slice(0, 10)}`;
|
||||
const result = await exportCategoryToSheets(getDb(), input.category, title);
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
url: result.spreadsheetUrl,
|
||||
rowsWritten: result.rowsWritten,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
url: null,
|
||||
rowsWritten: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — BigQuery export. Service-account auth (JWT-bearer
|
||||
// grant); paste the JSON key + project ID in Settings → Integrations.
|
||||
// V1 closeout — Detail Panel Analytics sub-tab. One read query
|
||||
// merges GSC + GA4 + URL Inspection for the selected URL.
|
||||
ipcMain.handle(
|
||||
IPC.urlAnalyticsGet,
|
||||
async (_e, url: string): Promise<UrlAnalyticsDetail | null> => {
|
||||
if (!url) return null;
|
||||
return callReaderOrFallback<UrlAnalyticsDetail | null>(
|
||||
'getUrlAnalytics',
|
||||
[url],
|
||||
() => getDb().getUrlAnalytics(url),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.exportBigquery,
|
||||
async (_e, input: ExportBigqueryInput): Promise<ExportBigqueryResult> => {
|
||||
try {
|
||||
const result = await exportCategoryToBigQuery(getDb(), input.category);
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
consoleUrl: result.consoleUrl,
|
||||
tableRef: result.tableRef,
|
||||
rowsWritten: result.rowsWritten,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
consoleUrl: null,
|
||||
tableRef: null,
|
||||
rowsWritten: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -2337,6 +3027,134 @@ function registerIpc(): void {
|
||||
return currentProjectPath || null;
|
||||
});
|
||||
|
||||
// V1 #4 — Encrypted snapshot export. Flow:
|
||||
// 1. WAL checkpoint so the .seoproject file on disk is consistent.
|
||||
// 2. VACUUM INTO a tmp .seoproject to capture an atomic snapshot of
|
||||
// the live DB without racing the writer worker.
|
||||
// 3. Ask the user for a destination .seoproject.enc path.
|
||||
// 4. AES-256-GCM encrypt the snapshot, write to dst, delete the tmp.
|
||||
ipcMain.handle(
|
||||
IPC.projectSaveEncrypted,
|
||||
async (
|
||||
_e,
|
||||
password: string,
|
||||
): Promise<{ filePath: string; bytesWritten: number } | { error: string } | null> => {
|
||||
const win = mainWindow;
|
||||
if (!win) return null;
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
return { error: 'Password is required.' };
|
||||
}
|
||||
loadPrefs();
|
||||
const baseDir = (() => {
|
||||
const raw = prefsCache['projectSaveDir'];
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) return raw;
|
||||
return app.getPath('documents');
|
||||
})();
|
||||
const res = await dialog.showSaveDialog(win, {
|
||||
title: 'Save Encrypted Snapshot…',
|
||||
defaultPath: join(baseDir, 'crawl.seoproject.enc'),
|
||||
filters: [
|
||||
{ name: 'FreeCrawl Encrypted Project', extensions: ['enc', 'seoproject.enc'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (res.canceled || !res.filePath) return null;
|
||||
const target = res.filePath;
|
||||
|
||||
const tmpPath = join(app.getPath('temp'), `freecrawl-enc-${Date.now()}.seoproject`);
|
||||
try {
|
||||
const database = getDb();
|
||||
try {
|
||||
database.walCheckpoint();
|
||||
} catch {
|
||||
/* checkpoint is best-effort; VACUUM INTO still snapshots consistently */
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawDb = (database as any).db as { exec: (sql: string) => void };
|
||||
const escaped = tmpPath.replace(/'/g, "''");
|
||||
rawDb.exec(`VACUUM INTO '${escaped}'`);
|
||||
|
||||
const { encryptFile } = await import('./project-encryption.js');
|
||||
const result = encryptFile(tmpPath, target, password);
|
||||
|
||||
await dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Encrypted Snapshot Saved',
|
||||
message: `Encrypted snapshot written: ${(result.bytesWritten / (1024 * 1024)).toFixed(1)} MB.`,
|
||||
detail:
|
||||
`${target}\n\n` +
|
||||
'Keep the password safe — it cannot be recovered. Without it, the file is unreadable.',
|
||||
buttons: ['OK'],
|
||||
noLink: true,
|
||||
});
|
||||
return { filePath: target, bytesWritten: result.bytesWritten };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.log('warn', 'main', `projectSaveEncrypted failed: ${message}`);
|
||||
return { error: message };
|
||||
} finally {
|
||||
try {
|
||||
const { unlinkSync } = await import('node:fs');
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
/* best-effort tmp cleanup */
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.projectOpenEncrypted,
|
||||
async (
|
||||
_e,
|
||||
password: string,
|
||||
): Promise<{ filePath: string } | { error: string } | null> => {
|
||||
const win = mainWindow;
|
||||
if (!win) return null;
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
return { error: 'Password is required.' };
|
||||
}
|
||||
const open = await dialog.showOpenDialog(win, {
|
||||
title: 'Open Encrypted Project…',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'FreeCrawl Encrypted Project', extensions: ['enc', 'seoproject.enc'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (open.canceled || open.filePaths.length === 0) return null;
|
||||
const srcPath = open.filePaths[0]!;
|
||||
|
||||
loadPrefs();
|
||||
const baseDir = (() => {
|
||||
const raw = prefsCache['projectSaveDir'];
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) return raw;
|
||||
return app.getPath('documents');
|
||||
})();
|
||||
const save = await dialog.showSaveDialog(win, {
|
||||
title: 'Save Decrypted Project As…',
|
||||
defaultPath: join(baseDir, 'recovered.seoproject'),
|
||||
filters: [
|
||||
{ name: 'FreeCrawl Project', extensions: ['seoproject'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (save.canceled || !save.filePath) return null;
|
||||
const dstPath = save.filePath;
|
||||
|
||||
try {
|
||||
const { decryptFile } = await import('./project-encryption.js');
|
||||
decryptFile(srcPath, dstPath, password);
|
||||
openProjectAtPath(dstPath);
|
||||
return { filePath: dstPath };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.log('warn', 'main', `projectOpenEncrypted failed: ${message}`);
|
||||
return { error: message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.confirmClear, async (): Promise<ConfirmClearResult> => {
|
||||
const win = mainWindow;
|
||||
if (!win) return { confirmed: false, skipNext: false };
|
||||
@@ -2734,6 +3552,31 @@ function registerIpc(): void {
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.exportImages,
|
||||
async (
|
||||
_e,
|
||||
input: ExportImagesInput,
|
||||
): Promise<ExportImagesResult> => {
|
||||
let filePath = input.filePath;
|
||||
if (!filePath) {
|
||||
const res = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: 'freecrawl-images.csv',
|
||||
filters: [{ name: 'CSV', extensions: ['csv'] }],
|
||||
});
|
||||
if (res.canceled || !res.filePath) {
|
||||
return { filePath: '', rowsWritten: 0 };
|
||||
}
|
||||
filePath = res.filePath;
|
||||
}
|
||||
const { rowsWritten } = await exportImagesToCsv(getDb(), filePath, {
|
||||
missingAltOnly: input.missingAltOnly,
|
||||
search: input.search,
|
||||
});
|
||||
return { filePath, rowsWritten };
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.exportTabular,
|
||||
async (_e, input: ExportTabularInput): Promise<ExportTabularResult> => {
|
||||
|
||||
@@ -29,10 +29,16 @@ export interface MenuLabels {
|
||||
generateSitemap: string;
|
||||
exportHtmlReport: string;
|
||||
bulkExport: string;
|
||||
exportSheets: string;
|
||||
exportBigquery: string;
|
||||
compareWith: string;
|
||||
scheduledCrawl: string;
|
||||
scheduledCrawlTooltip: string;
|
||||
saveProjectAs: string;
|
||||
saveProjectEncrypted: string;
|
||||
saveProjectEncryptedTooltip: string;
|
||||
openProjectEncrypted: string;
|
||||
openProjectEncryptedTooltip: string;
|
||||
settings: string;
|
||||
view: string;
|
||||
overviewSidebar: string;
|
||||
@@ -73,11 +79,19 @@ const MENU_EN: MenuLabels = {
|
||||
generateSitemap: 'Generate XML Sitemap…',
|
||||
exportHtmlReport: 'Export HTML Report…',
|
||||
bulkExport: 'Bulk Export…',
|
||||
exportSheets: 'Export to Google Sheets…',
|
||||
exportBigquery: 'Export to BigQuery…',
|
||||
compareWith: 'Compare With Project…',
|
||||
scheduledCrawl: 'Scheduled Crawl…',
|
||||
scheduledCrawlTooltip:
|
||||
'Set up an in-app recurring crawl for the currently-open project. Fires only while FreeCrawl is open; use the CLI + OS scheduler for triggers that survive restarts.',
|
||||
saveProjectAs: 'Save Project As…',
|
||||
saveProjectEncrypted: 'Save Encrypted Snapshot…',
|
||||
saveProjectEncryptedTooltip:
|
||||
'Export the active project to an AES-256-GCM-encrypted .seoproject.enc file protected by a password.',
|
||||
openProjectEncrypted: 'Open Encrypted Project…',
|
||||
openProjectEncryptedTooltip:
|
||||
'Decrypt a .seoproject.enc snapshot with its password and open the recovered project.',
|
||||
settings: 'Settings…',
|
||||
view: 'View',
|
||||
overviewSidebar: 'Overview Sidebar',
|
||||
@@ -123,11 +137,19 @@ const MENU_TR: MenuLabels = {
|
||||
generateSitemap: 'XML Sitemap Oluştur…',
|
||||
exportHtmlReport: 'HTML Rapor Dışa Aktar…',
|
||||
bulkExport: 'Toplu Dışa Aktarım…',
|
||||
exportSheets: 'Google Sheets\'e Aktar…',
|
||||
exportBigquery: 'BigQuery\'ye Aktar…',
|
||||
compareWith: 'Projeyle Karşılaştır…',
|
||||
scheduledCrawl: 'Zamanlanmış Crawl…',
|
||||
scheduledCrawlTooltip:
|
||||
'Şu an açık olan proje için uygulama içi tekrarlayan crawl kur. Yalnızca FreeCrawl açıkken çalışır; yeniden başlatmaya dayanan tetikler için CLI + OS zamanlayıcısını kullan.',
|
||||
saveProjectAs: 'Projeyi Farklı Kaydet…',
|
||||
saveProjectEncrypted: 'Şifreli Snapshot Kaydet…',
|
||||
saveProjectEncryptedTooltip:
|
||||
'Aktif projeyi parolayla korunan AES-256-GCM şifreli .seoproject.enc dosyasına dışa aktar.',
|
||||
openProjectEncrypted: 'Şifreli Proje Aç…',
|
||||
openProjectEncryptedTooltip:
|
||||
'Bir .seoproject.enc snapshot\'ını parolasıyla çöz ve kurtarılan projeyi aç.',
|
||||
settings: 'Ayarlar…',
|
||||
view: 'Görünüm',
|
||||
overviewSidebar: 'Genel Bakış Kenar Çubuğu',
|
||||
|
||||
@@ -104,6 +104,14 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
label: L.bulkExport,
|
||||
click: () => send('export-bulk'),
|
||||
},
|
||||
{
|
||||
label: L.exportSheets,
|
||||
click: () => send('export-sheets'),
|
||||
},
|
||||
{
|
||||
label: L.exportBigquery,
|
||||
click: () => send('export-bigquery'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: L.compareWith,
|
||||
@@ -119,6 +127,16 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
accelerator: 'CmdOrCtrl+Shift+S',
|
||||
click: () => send('save-project-as'),
|
||||
},
|
||||
{
|
||||
label: L.saveProjectEncrypted,
|
||||
toolTip: L.saveProjectEncryptedTooltip,
|
||||
click: () => send('save-project-encrypted'),
|
||||
},
|
||||
{
|
||||
label: L.openProjectEncrypted,
|
||||
toolTip: L.openProjectEncryptedTooltip,
|
||||
click: () => send('open-project-encrypted'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: L.settings,
|
||||
|
||||
@@ -178,6 +178,28 @@ export async function runPagespeedBatch(
|
||||
let failed = 0;
|
||||
let cursor = 0;
|
||||
|
||||
// Per-error dedupe so a batch of 2000 URLs that all hit the same
|
||||
// Google quota cap doesn't spam the log with 2000 identical warn
|
||||
// lines. First N occurrences of each distinct error are logged with
|
||||
// the offending URL; further occurrences are silently counted and
|
||||
// summarised in the final batch line.
|
||||
const ERROR_LOG_LIMIT_PER_KIND = 3;
|
||||
const errorCounts = new Map<string, number>();
|
||||
function recordFailure(url: string, message: string): void {
|
||||
const prev = errorCounts.get(message) ?? 0;
|
||||
errorCounts.set(message, prev + 1);
|
||||
if (prev < ERROR_LOG_LIMIT_PER_KIND) {
|
||||
logger.log('warn', 'pagespeed', `${url} — ${message}`);
|
||||
if (prev + 1 === ERROR_LOG_LIMIT_PER_KIND) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'pagespeed',
|
||||
`(further "${message.slice(0, 80)}${message.length > 80 ? '…' : ''}" errors will be summarised at end)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(0, total, null);
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
@@ -205,8 +227,12 @@ export async function runPagespeedBatch(
|
||||
);
|
||||
}
|
||||
done++;
|
||||
if (metrics.status === 'ok') completed++;
|
||||
else failed++;
|
||||
if (metrics.status === 'ok') {
|
||||
completed++;
|
||||
} else {
|
||||
failed++;
|
||||
recordFailure(item.url, metrics.error ?? 'Unknown error');
|
||||
}
|
||||
onProgress(done, total, null);
|
||||
}
|
||||
};
|
||||
@@ -225,5 +251,30 @@ export async function runPagespeedBatch(
|
||||
cancelled ? ', cancelled early' : ''
|
||||
} (${done}/${total})`,
|
||||
);
|
||||
if (errorCounts.size > 0) {
|
||||
const sorted = [...errorCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [message, count] of sorted) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'pagespeed',
|
||||
`failure summary — ${count}× "${message}"`,
|
||||
);
|
||||
}
|
||||
// Specific advisory when EVERY failure is the keyless quota hit —
|
||||
// tells the user exactly what to do instead of leaving them to
|
||||
// decode Google's error string. Google's keyless shared project
|
||||
// currently has a hard 0/day quota for PSI v5 (see Settings →
|
||||
// Integrations → PageSpeed Insights for a free key).
|
||||
const allQuota =
|
||||
failed > 0 &&
|
||||
sorted.every(([m]) => /quota|rate.?limit|RESOURCE_EXHAUSTED/i.test(m));
|
||||
if (allQuota && !apiKey) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'pagespeed',
|
||||
"Google's keyless PageSpeed quota is currently 0/day — add a free API key under Settings → Integrations → PageSpeed Insights to run audits.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { completed, failed, cancelled };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* V1 #4 — Encrypted project snapshot.
|
||||
*
|
||||
* Container format (binary):
|
||||
* ┌────────────┬───────────────┬───────────────┬──────────────────┬──────────────────┐
|
||||
* │ 8 B magic │ 1 B version │ 16 B salt │ 12 B IV (nonce) │ ciphertext + GCM │
|
||||
* │ "FCRYPT01" │ 0x01 │ random │ random │ 16 B auth tag │
|
||||
* └────────────┴───────────────┴───────────────┴──────────────────┴──────────────────┘
|
||||
*
|
||||
* - PBKDF2-SHA256, 200 000 iterations, 32-byte derived key.
|
||||
* - AES-256-GCM with a per-file random IV — never reuse an IV with the
|
||||
* same key (the salt is also fresh per-file so the key is always
|
||||
* fresh; doubly safe).
|
||||
* - The GCM auth tag covers everything the cipher saw (the ciphertext);
|
||||
* the header bytes are also fed in as AAD so any header tampering
|
||||
* surfaces as a decrypt failure instead of a silent re-keying.
|
||||
*
|
||||
* Why a snapshot and not at-rest encryption?
|
||||
* `node:sqlite` has no built-in encryption (SEE / SQLCipher would need
|
||||
* a native module). Encrypting the whole `.seoproject` file at save
|
||||
* time + decrypting to a working copy at open is the pragmatic V1
|
||||
* path: simple, well-trodden cryptography, no native deps. The cost
|
||||
* is that the *running* working copy is unencrypted on disk while
|
||||
* the project is open — which matches the trust model (only the
|
||||
* user who can log into their account can read it).
|
||||
*/
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
pbkdf2Sync,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const MAGIC = Buffer.from('FCRYPT01', 'utf8'); // 8 bytes
|
||||
const VERSION = 0x01;
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const KEY_LEN = 32;
|
||||
const TAG_LEN = 16;
|
||||
const PBKDF2_ITERATIONS = 200_000;
|
||||
const HEADER_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN; // magic + version + salt + iv
|
||||
|
||||
/**
|
||||
* Encrypt the file at `srcPath` and write the encrypted container to
|
||||
* `dstPath`. The source file is read into memory in one shot — fine for
|
||||
* `.seoproject` snapshots which are tens of MB; for hundreds of MB we'd
|
||||
* want a streaming variant. Throws if the password is empty or the
|
||||
* source file can't be read.
|
||||
*/
|
||||
export function encryptFile(
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
password: string,
|
||||
): { bytesWritten: number } {
|
||||
if (!password || password.length === 0) {
|
||||
throw new Error('Password is required.');
|
||||
}
|
||||
const plaintext = readFileSync(srcPath);
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
||||
|
||||
const header = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv]);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
cipher.setAAD(header);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const out = Buffer.concat([header, ciphertext, tag]);
|
||||
writeFileSync(dstPath, out);
|
||||
return { bytesWritten: out.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the encrypted container at `srcPath` and write the recovered
|
||||
* `.seoproject` bytes to `dstPath`. Throws on bad magic, unsupported
|
||||
* version, or an authentication-tag mismatch (wrong password / file
|
||||
* tampered with).
|
||||
*/
|
||||
export function decryptFile(
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
password: string,
|
||||
): { bytesWritten: number } {
|
||||
if (!password || password.length === 0) {
|
||||
throw new Error('Password is required.');
|
||||
}
|
||||
const buf = readFileSync(srcPath);
|
||||
if (buf.length < HEADER_LEN + TAG_LEN) {
|
||||
throw new Error('Encrypted file is truncated or not a FreeCrawl encrypted project.');
|
||||
}
|
||||
if (!buf.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||||
throw new Error('File is not a FreeCrawl encrypted project (bad magic).');
|
||||
}
|
||||
const version = buf[MAGIC.length];
|
||||
if (version !== VERSION) {
|
||||
throw new Error(`Unsupported encrypted-project version: 0x${version?.toString(16) ?? '??'}.`);
|
||||
}
|
||||
const salt = buf.subarray(MAGIC.length + 1, MAGIC.length + 1 + SALT_LEN);
|
||||
const iv = buf.subarray(
|
||||
MAGIC.length + 1 + SALT_LEN,
|
||||
MAGIC.length + 1 + SALT_LEN + IV_LEN,
|
||||
);
|
||||
const header = buf.subarray(0, HEADER_LEN);
|
||||
const tag = buf.subarray(buf.length - TAG_LEN);
|
||||
const ciphertext = buf.subarray(HEADER_LEN, buf.length - TAG_LEN);
|
||||
|
||||
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAAD(header);
|
||||
decipher.setAuthTag(tag);
|
||||
let plaintext: Buffer;
|
||||
try {
|
||||
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
} catch {
|
||||
// GCM auth failure — wrong password or corrupted ciphertext. Don't
|
||||
// leak which one; surface a single user-friendly error.
|
||||
throw new Error(
|
||||
'Could not decrypt — the password is wrong, or the file has been altered.',
|
||||
);
|
||||
}
|
||||
writeFileSync(dstPath, plaintext);
|
||||
return { bytesWritten: plaintext.length };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Faz 7 — SEO authority batch runner.
|
||||
*
|
||||
* Same shape as the AI / PageSpeed batches: a small concurrency pool
|
||||
* fans `(url)` items through the chosen provider, reports each result
|
||||
* via `onResult`, and streams progress through `onProgress`. The
|
||||
* orchestrating IPC handler owns DB writes.
|
||||
*/
|
||||
import type { SeoMetrics, SeoProvider } from '@freecrawl/shared-types';
|
||||
import { fetchSeoMetrics, SeoProviderError } from './seo-providers.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const DEFAULT_CONCURRENCY = 2;
|
||||
|
||||
export interface SeoBatchOutcome {
|
||||
url: string;
|
||||
ok: boolean;
|
||||
metrics: SeoMetrics | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SeoBatchOptions {
|
||||
provider: SeoProvider;
|
||||
urls: string[];
|
||||
isCancelled: () => boolean;
|
||||
onResult: (outcome: SeoBatchOutcome) => void;
|
||||
onProgress: (done: number, total: number, currentUrl: string | null) => void;
|
||||
}
|
||||
|
||||
export interface SeoBatchResult {
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export async function runSeoBatch(opts: SeoBatchOptions): Promise<SeoBatchResult> {
|
||||
const { provider, urls, isCancelled, onResult, onProgress } = opts;
|
||||
const total = urls.length;
|
||||
let done = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let cursor = 0;
|
||||
onProgress(0, total, null);
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (isCancelled()) return;
|
||||
const index = cursor++;
|
||||
if (index >= total) return;
|
||||
const url = urls[index]!;
|
||||
onProgress(done, total, url);
|
||||
let outcome: SeoBatchOutcome;
|
||||
try {
|
||||
const metrics = await fetchSeoMetrics(provider, url);
|
||||
outcome = { url, ok: true, metrics, error: null };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof SeoProviderError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
outcome = { url, ok: false, metrics: null, error: message };
|
||||
}
|
||||
try {
|
||||
onResult(outcome);
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'error',
|
||||
'seo',
|
||||
`failed to persist SEO result for ${url}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
done++;
|
||||
if (outcome.ok) completed++;
|
||||
else failed++;
|
||||
onProgress(done, total, null);
|
||||
}
|
||||
};
|
||||
|
||||
const pool = Math.min(Math.max(1, DEFAULT_CONCURRENCY), Math.max(1, total));
|
||||
await Promise.all(Array.from({ length: pool }, () => worker()));
|
||||
|
||||
const cancelled = isCancelled() && done < total;
|
||||
logger.log(
|
||||
'info',
|
||||
'seo',
|
||||
`${provider} batch finished — ${completed} ok, ${failed} failed${
|
||||
cancelled ? ', cancelled early' : ''
|
||||
} (${done}/${total})`,
|
||||
);
|
||||
return { completed, failed, cancelled };
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Faz 7 — Third-party SEO authority providers.
|
||||
*
|
||||
* One thin abstraction (`fetchSeoMetrics`) so the SEO tab + batch
|
||||
* runner don't have to know which provider they're driving. Each
|
||||
* client returns a strongly-typed metrics object matching the
|
||||
* `SeoMetrics` union in `@freecrawl/shared-types`.
|
||||
*
|
||||
* Auth varies wildly between vendors — Ahrefs uses a Bearer API token
|
||||
* (v3), Majestic a query-string key, Moz Basic auth with Access ID +
|
||||
* Secret Key, Semrush a query-string key. All credentials live in the
|
||||
* encrypted credential store; nothing leaks to the renderer.
|
||||
*
|
||||
* The API endpoints / response shapes shift periodically per vendor;
|
||||
* the implementations here target the documented v1-current shapes and
|
||||
* parse defensively. If a metric field renames upstream the
|
||||
* corresponding value falls to `null` rather than throwing.
|
||||
*/
|
||||
import type {
|
||||
AhrefsMetrics,
|
||||
MajesticMetrics,
|
||||
MozMetrics,
|
||||
SemrushMetrics,
|
||||
SeoMetrics,
|
||||
SeoProvider,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { resolveCredentials } from './credentials.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class SeoProviderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SeoProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
function numOrNull(v: unknown): number | null {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchSeoMetrics(
|
||||
provider: SeoProvider,
|
||||
url: string,
|
||||
): Promise<SeoMetrics> {
|
||||
switch (provider) {
|
||||
case 'ahrefs':
|
||||
return fetchAhrefs(url);
|
||||
case 'majestic':
|
||||
return fetchMajestic(url);
|
||||
case 'moz':
|
||||
return fetchMoz(url);
|
||||
case 'semrush':
|
||||
return fetchSemrush(url);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ahrefs ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ahrefs v3 removed the combined `/site-explorer/overview` endpoint
|
||||
* in 2024 — every metric now has its own route. We fan out to four
|
||||
* per-metric endpoints in parallel and stitch the result back into the
|
||||
* `AhrefsMetrics` shape the rest of the app expects.
|
||||
*
|
||||
* Per-metric endpoints + response field used:
|
||||
* - domain-rating → `domain_rating`
|
||||
* - url-rating → `url_rating`
|
||||
* - backlinks-stats → `live` (count of live backlinks)
|
||||
* - metrics → `refdomains` (referring domains)
|
||||
*
|
||||
* Each Ahrefs endpoint may wrap its payload under a key matching the
|
||||
* metric name (e.g. `{ "domain_rating": { "domain_rating": 73, … } }`)
|
||||
* or return fields at the top level. We probe both shapes so a small
|
||||
* API tweak doesn't break us again.
|
||||
*/
|
||||
interface AhrefsCall {
|
||||
path: string;
|
||||
extract: (json: Record<string, unknown>) => number | null;
|
||||
}
|
||||
|
||||
async function ahrefsFetch(
|
||||
call: AhrefsCall,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
): Promise<number | null> {
|
||||
const params = new URLSearchParams({
|
||||
target: url,
|
||||
mode: 'exact',
|
||||
protocol: 'both',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`https://api.ahrefs.com/v3/site-explorer/${call.path}?${params.toString()}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
throw new SeoProviderError(
|
||||
`Ahrefs ${call.path}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
const err = (json?.['error'] as string | undefined) ?? `HTTP ${res.status}`;
|
||||
throw new SeoProviderError(`Ahrefs ${call.path}: ${err}`);
|
||||
}
|
||||
return call.extract(json);
|
||||
}
|
||||
|
||||
/** Read a numeric field from either top-level or one level of nesting
|
||||
* (Ahrefs sometimes wraps payloads under a key matching the metric). */
|
||||
function readNested(
|
||||
json: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null {
|
||||
for (const k of keys) {
|
||||
const direct = numOrNull(json[k]);
|
||||
if (direct !== null) return direct;
|
||||
}
|
||||
for (const v of Object.values(json)) {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const inner = v as Record<string, unknown>;
|
||||
for (const k of keys) {
|
||||
const nested = numOrNull(inner[k]);
|
||||
if (nested !== null) return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchAhrefs(url: string): Promise<AhrefsMetrics> {
|
||||
const apiKey = (resolveCredentials('ahrefs')['apiKey'] ?? '').trim();
|
||||
if (!apiKey) {
|
||||
throw new SeoProviderError(
|
||||
'No Ahrefs API token — paste yours in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
const calls: { name: keyof AhrefsMetrics; call: AhrefsCall }[] = [
|
||||
{
|
||||
name: 'domainRating',
|
||||
call: {
|
||||
path: 'domain-rating',
|
||||
extract: (j) => readNested(j, ['domain_rating']),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'urlRating',
|
||||
call: {
|
||||
path: 'url-rating',
|
||||
extract: (j) => readNested(j, ['url_rating']),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backlinks',
|
||||
call: {
|
||||
path: 'backlinks-stats',
|
||||
extract: (j) => readNested(j, ['live', 'backlinks', 'all_time']),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refDomains',
|
||||
call: {
|
||||
path: 'metrics',
|
||||
extract: (j) => readNested(j, ['refdomains', 'ref_domains']),
|
||||
},
|
||||
},
|
||||
];
|
||||
// Run all four in parallel — Ahrefs rate-limits per-token, not
|
||||
// per-endpoint, so 4 concurrent calls = 4 tokens consumed instantly.
|
||||
// We tolerate partial success: if backlinks-stats fails but the other
|
||||
// three succeed, the user still sees DR/UR/refdomains rather than a
|
||||
// total ERR row. `Promise.allSettled` makes that explicit.
|
||||
const settled = await Promise.allSettled(
|
||||
calls.map((c) => ahrefsFetch(c.call, url, apiKey)),
|
||||
);
|
||||
const result: AhrefsMetrics = {
|
||||
domainRating: null,
|
||||
urlRating: null,
|
||||
backlinks: null,
|
||||
refDomains: null,
|
||||
};
|
||||
const errors: string[] = [];
|
||||
settled.forEach((r, i) => {
|
||||
const entry = calls[i]!;
|
||||
if (r.status === 'fulfilled') {
|
||||
result[entry.name] = r.value;
|
||||
} else {
|
||||
errors.push(r.reason instanceof Error ? r.reason.message : String(r.reason));
|
||||
}
|
||||
});
|
||||
// If every endpoint failed, surface a single error rather than silently
|
||||
// returning a row full of nulls.
|
||||
if (errors.length === calls.length) {
|
||||
throw new SeoProviderError(errors[0] ?? 'Ahrefs: all metric endpoints failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Majestic ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchMajestic(url: string): Promise<MajesticMetrics> {
|
||||
const apiKey = (resolveCredentials('majestic')['apiKey'] ?? '').trim();
|
||||
if (!apiKey) {
|
||||
throw new SeoProviderError(
|
||||
'No Majestic API key — paste yours in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
cmd: 'GetIndexItemInfo',
|
||||
items: '1',
|
||||
item0: url,
|
||||
app_api_key: apiKey,
|
||||
});
|
||||
const res = await fetch(`https://api.majestic.com/api/json?${params.toString()}`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
throw new SeoProviderError(`Majestic: HTTP ${res.status}`);
|
||||
}
|
||||
if (json['Code'] && json['Code'] !== 'OK') {
|
||||
throw new SeoProviderError(
|
||||
`Majestic: ${(json['ErrorMessage'] as string | undefined) ?? json['Code']}`,
|
||||
);
|
||||
}
|
||||
// Drill into the documented `DataTables.Results.Data[0]` shape.
|
||||
const tables = json['DataTables'] as Record<string, unknown> | undefined;
|
||||
const results = tables?.['Results'] as Record<string, unknown> | undefined;
|
||||
const data = results?.['Data'] as Record<string, unknown>[] | undefined;
|
||||
const row = (Array.isArray(data) && data[0]) || {};
|
||||
return {
|
||||
trustFlow: numOrNull(row['TrustFlow']),
|
||||
citationFlow: numOrNull(row['CitationFlow']),
|
||||
externalBacklinks: numOrNull(row['ExtBackLinks']),
|
||||
refDomains: numOrNull(row['RefDomains']),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Moz ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchMoz(url: string): Promise<MozMetrics> {
|
||||
const creds = resolveCredentials('moz');
|
||||
const accessId = (creds['accessId'] ?? '').trim();
|
||||
const secretKey = (creds['secretKey'] ?? '').trim();
|
||||
if (!accessId || !secretKey) {
|
||||
throw new SeoProviderError(
|
||||
'Missing Moz credentials — paste your Access ID and Secret Key in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
const basic = Buffer.from(`${accessId}:${secretKey}`).toString('base64');
|
||||
const res = await fetch('https://lsapi.seomoz.com/v2/url_metrics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ targets: [url] }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!res.ok || !json) {
|
||||
const err = (json?.['message'] as string | undefined) ?? `HTTP ${res.status}`;
|
||||
throw new SeoProviderError(`Moz: ${err}`);
|
||||
}
|
||||
const results = json['results'] as Record<string, unknown>[] | undefined;
|
||||
const row = (Array.isArray(results) && results[0]) || {};
|
||||
return {
|
||||
domainAuthority: numOrNull(row['domain_authority']),
|
||||
pageAuthority: numOrNull(row['page_authority']),
|
||||
spamScore: numOrNull(row['spam_score']),
|
||||
linkingDomains: numOrNull(row['root_domains_to_root_domain'] ?? row['linking_root_domains']),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Semrush ──────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchSemrush(url: string): Promise<SemrushMetrics> {
|
||||
const apiKey = (resolveCredentials('semrush')['apiKey'] ?? '').trim();
|
||||
if (!apiKey) {
|
||||
throw new SeoProviderError(
|
||||
'No Semrush API key — paste yours in Settings → Integrations.',
|
||||
);
|
||||
}
|
||||
// Semrush API v3 renamed `url_overview` → `url_rank` and made
|
||||
// `export_columns` mandatory (it used to default to a useful subset).
|
||||
// Column codes match the response field names, so the row extractor
|
||||
// below still keys off `Or` / `Ot` / `Oc` / `Ad` unchanged.
|
||||
const params = new URLSearchParams({
|
||||
type: 'url_rank',
|
||||
key: apiKey,
|
||||
url,
|
||||
database: 'us',
|
||||
display_format: 'json',
|
||||
export_columns: 'Or,Ot,Oc,Ad',
|
||||
});
|
||||
const res = await fetch(`https://api.semrush.com/?${params.toString()}`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new SeoProviderError(`Semrush: ${text.trim() || `HTTP ${res.status}`}`);
|
||||
}
|
||||
// Semrush returns a typo-laden plain error string for some failures,
|
||||
// and a JSON array for success.
|
||||
let row: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>[] | unknown;
|
||||
if (Array.isArray(parsed) && parsed[0]) row = parsed[0];
|
||||
} catch {
|
||||
throw new SeoProviderError(`Semrush: ${text.trim().slice(0, 200)}`);
|
||||
}
|
||||
return {
|
||||
organicKeywords: numOrNull(row['Or']),
|
||||
organicTraffic: numOrNull(row['Ot']),
|
||||
organicCost: numOrNull(row['Oc']),
|
||||
adwordsKeywords: numOrNull(row['Ad']),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Faz 7 — Google Sheets export.
|
||||
*
|
||||
* Creates a new spreadsheet on the connected Google account's Drive,
|
||||
* writes a key set of columns for the chosen URL category, and returns
|
||||
* the spreadsheet URL. Auth runs through the shared OAuth keystone
|
||||
* (`getAccessToken('sheets')`).
|
||||
*
|
||||
* One sheet per export — the user can switch categories and export
|
||||
* again to create another. For very large crawls (>50 K rows) the
|
||||
* batched `values.update` would exceed Sheets' per-request size cap;
|
||||
* V1 caps the export at 50 K rows and tells the caller how many were
|
||||
* skipped.
|
||||
*/
|
||||
import type { ProjectDb } from '@freecrawl/db';
|
||||
import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
|
||||
import { getAccessToken } from './google-oauth.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const MAX_ROWS = 50_000;
|
||||
|
||||
/** The columns we write — a sensible "key SEO snapshot" set. */
|
||||
const HEADERS = [
|
||||
'URL',
|
||||
'Status',
|
||||
'Title',
|
||||
'Meta description',
|
||||
'H1',
|
||||
'Indexability',
|
||||
'Response time (ms)',
|
||||
'Depth',
|
||||
'Inlinks',
|
||||
'Outlinks',
|
||||
'Word count',
|
||||
] as const;
|
||||
|
||||
function rowFromUrl(r: CrawlUrlRow): (string | number | null)[] {
|
||||
return [
|
||||
r.url,
|
||||
r.statusCode ?? null,
|
||||
r.title ?? '',
|
||||
r.metaDescription ?? '',
|
||||
r.h1 ?? '',
|
||||
r.indexability ?? '',
|
||||
r.responseTimeMs ?? null,
|
||||
r.depth ?? null,
|
||||
r.inlinks ?? null,
|
||||
r.outlinks ?? null,
|
||||
r.wordCount ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
export interface SheetsExportOutput {
|
||||
spreadsheetUrl: string;
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
export async function exportCategoryToSheets(
|
||||
db: ProjectDb,
|
||||
category: UrlCategory,
|
||||
title: string,
|
||||
): Promise<SheetsExportOutput> {
|
||||
const token = await getAccessToken('sheets');
|
||||
|
||||
// 1. Create the spreadsheet.
|
||||
const createRes = await fetch(
|
||||
'https://sheets.googleapis.com/v4/spreadsheets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ properties: { title } }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
const createJson = (await createRes.json().catch(() => null)) as {
|
||||
spreadsheetId?: unknown;
|
||||
spreadsheetUrl?: unknown;
|
||||
error?: { message?: unknown };
|
||||
} | null;
|
||||
if (!createRes.ok || !createJson || typeof createJson.spreadsheetId !== 'string') {
|
||||
const err =
|
||||
(createJson?.error?.message as string | undefined) ??
|
||||
`HTTP ${createRes.status}`;
|
||||
throw new Error(`Sheets create failed: ${err}`);
|
||||
}
|
||||
const spreadsheetId = createJson.spreadsheetId;
|
||||
const spreadsheetUrl =
|
||||
typeof createJson.spreadsheetUrl === 'string'
|
||||
? createJson.spreadsheetUrl
|
||||
: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`;
|
||||
|
||||
// 2. Collect rows from the project DB.
|
||||
const values: (string | number | null)[][] = [[...HEADERS]];
|
||||
let count = 0;
|
||||
for (const row of db.iterateUrlsByCategory(category)) {
|
||||
if (count >= MAX_ROWS) break;
|
||||
values.push(rowFromUrl(row));
|
||||
count++;
|
||||
}
|
||||
|
||||
// 3. Write the values.
|
||||
if (count > 0) {
|
||||
const writeRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/A1?valueInputOption=RAW`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ values }),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
},
|
||||
);
|
||||
if (!writeRes.ok) {
|
||||
const writeJson = (await writeRes.json().catch(() => null)) as {
|
||||
error?: { message?: unknown };
|
||||
} | null;
|
||||
const err =
|
||||
(writeJson?.error?.message as string | undefined) ??
|
||||
`HTTP ${writeRes.status}`;
|
||||
throw new Error(`Sheets write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
logger.log('info', 'sheets', `exported ${count} row(s) to ${spreadsheetUrl}`);
|
||||
return { spreadsheetUrl, rowsWritten: count };
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type ExportXmlResult,
|
||||
type ExportBrokenLinksInput,
|
||||
type ExportBrokenLinksResult,
|
||||
type ExportImagesInput,
|
||||
type ExportImagesResult,
|
||||
type ExportTabularInput,
|
||||
type ExportTabularResult,
|
||||
type DataDeleteByDomainInput,
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type WordCountPerDirectoryInput,
|
||||
type WordCountPerDirectoryRow,
|
||||
type SitemapOrphanRow,
|
||||
type OrphanCrossSourceRow,
|
||||
type SettingsExportInput,
|
||||
type SettingsExportResult,
|
||||
type SettingsImportResult,
|
||||
@@ -88,6 +91,36 @@ import {
|
||||
type PagespeedRunInput,
|
||||
type PagespeedRunResult,
|
||||
type PagespeedProgress,
|
||||
type GoogleAuthState,
|
||||
type GoogleAuthResult,
|
||||
type GscListSitesResult,
|
||||
type GscFetchInput,
|
||||
type GscFetchResult,
|
||||
type GscQueryInput,
|
||||
type GscQueryResult,
|
||||
type Ga4ListPropertiesResult,
|
||||
type Ga4FetchInput,
|
||||
type Ga4FetchResult,
|
||||
type Ga4QueryInput,
|
||||
type Ga4QueryResult,
|
||||
type AiRunInput,
|
||||
type AiRunResult,
|
||||
type AiQueryInput,
|
||||
type AiQueryResult,
|
||||
type AiProgress,
|
||||
type SeoRunInput,
|
||||
type SeoRunResult,
|
||||
type SeoQueryInput,
|
||||
type SeoQueryResult,
|
||||
type SeoProgress,
|
||||
type GscInspectRunInput,
|
||||
type GscInspectRunResult,
|
||||
type GscInspectProgress,
|
||||
type ExportSheetsInput,
|
||||
type ExportSheetsResult,
|
||||
type ExportBigqueryInput,
|
||||
type ExportBigqueryResult,
|
||||
type UrlAnalyticsDetail,
|
||||
} from '@freecrawl/shared-types';
|
||||
|
||||
function subscribe<T>(
|
||||
@@ -118,6 +151,14 @@ const api: FreeCrawlApi = {
|
||||
ipcRenderer.invoke(IPC.projectOpen, filePath),
|
||||
projectCurrentPath: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC.projectCurrentPath),
|
||||
projectSaveEncrypted: (
|
||||
password: string,
|
||||
): Promise<{ filePath: string; bytesWritten: number } | { error: string } | null> =>
|
||||
ipcRenderer.invoke(IPC.projectSaveEncrypted, password),
|
||||
projectOpenEncrypted: (
|
||||
password: string,
|
||||
): Promise<{ filePath: string } | { error: string } | null> =>
|
||||
ipcRenderer.invoke(IPC.projectOpenEncrypted, password),
|
||||
urlsQuery: (input: UrlsQueryInput): Promise<UrlsQueryResult> =>
|
||||
ipcRenderer.invoke(IPC.urlsQuery, input),
|
||||
urlDetailGet: (input: UrlDetailInput): Promise<UrlDetail | null> =>
|
||||
@@ -148,6 +189,8 @@ const api: FreeCrawlApi = {
|
||||
input: ExportBrokenLinksInput,
|
||||
): Promise<ExportBrokenLinksResult> =>
|
||||
ipcRenderer.invoke(IPC.exportBrokenLinks, input),
|
||||
exportImages: (input: ExportImagesInput): Promise<ExportImagesResult> =>
|
||||
ipcRenderer.invoke(IPC.exportImages, input),
|
||||
exportTabular: (input: ExportTabularInput): Promise<ExportTabularResult> =>
|
||||
ipcRenderer.invoke(IPC.exportTabular, input),
|
||||
dataDeleteByDomain: (
|
||||
@@ -235,6 +278,8 @@ const api: FreeCrawlApi = {
|
||||
ipcRenderer.invoke(IPC.reportsWordCountPerDirectory, input),
|
||||
reportsSitemapOrphans: (limit?: number): Promise<SitemapOrphanRow[]> =>
|
||||
ipcRenderer.invoke(IPC.reportsSitemapOrphans, limit),
|
||||
reportsOrphanCrossSource: (limit?: number): Promise<OrphanCrossSourceRow[]> =>
|
||||
ipcRenderer.invoke(IPC.reportsOrphanCrossSource, limit),
|
||||
// Fire-and-forget — `send` not `invoke` because we don't need a
|
||||
// response and we want this to be cheap (≤ 1 ms per call, no wait).
|
||||
reportRendererLag: (lagMs: number) => {
|
||||
@@ -269,6 +314,47 @@ const api: FreeCrawlApi = {
|
||||
pagespeedCancel: (): Promise<void> => ipcRenderer.invoke(IPC.pagespeedCancel),
|
||||
onPagespeedProgress: (cb) =>
|
||||
subscribe<PagespeedProgress>(IPC.pagespeedProgress, cb),
|
||||
googleAuthStart: (id: string): Promise<GoogleAuthResult> =>
|
||||
ipcRenderer.invoke(IPC.googleAuthStart, id),
|
||||
googleAuthStatus: (id: string): Promise<GoogleAuthState> =>
|
||||
ipcRenderer.invoke(IPC.googleAuthStatus, id),
|
||||
googleAuthRevoke: (id: string): Promise<GoogleAuthState> =>
|
||||
ipcRenderer.invoke(IPC.googleAuthRevoke, id),
|
||||
gscListSites: (): Promise<GscListSitesResult> =>
|
||||
ipcRenderer.invoke(IPC.gscListSites),
|
||||
gscFetch: (input: GscFetchInput): Promise<GscFetchResult> =>
|
||||
ipcRenderer.invoke(IPC.gscFetch, input),
|
||||
gscQuery: (input: GscQueryInput): Promise<GscQueryResult> =>
|
||||
ipcRenderer.invoke(IPC.gscQuery, input),
|
||||
ga4ListProperties: (): Promise<Ga4ListPropertiesResult> =>
|
||||
ipcRenderer.invoke(IPC.ga4ListProperties),
|
||||
ga4Fetch: (input: Ga4FetchInput): Promise<Ga4FetchResult> =>
|
||||
ipcRenderer.invoke(IPC.ga4Fetch, input),
|
||||
ga4Query: (input: Ga4QueryInput): Promise<Ga4QueryResult> =>
|
||||
ipcRenderer.invoke(IPC.ga4Query, input),
|
||||
aiRun: (input: AiRunInput): Promise<AiRunResult> =>
|
||||
ipcRenderer.invoke(IPC.aiRun, input),
|
||||
aiCancel: (): Promise<void> => ipcRenderer.invoke(IPC.aiCancel),
|
||||
aiQuery: (input: AiQueryInput): Promise<AiQueryResult> =>
|
||||
ipcRenderer.invoke(IPC.aiQuery, input),
|
||||
onAiProgress: (cb) => subscribe<AiProgress>(IPC.aiProgress, cb),
|
||||
seoRun: (input: SeoRunInput): Promise<SeoRunResult> =>
|
||||
ipcRenderer.invoke(IPC.seoRun, input),
|
||||
seoCancel: (): Promise<void> => ipcRenderer.invoke(IPC.seoCancel),
|
||||
seoQuery: (input: SeoQueryInput): Promise<SeoQueryResult> =>
|
||||
ipcRenderer.invoke(IPC.seoQuery, input),
|
||||
onSeoProgress: (cb) => subscribe<SeoProgress>(IPC.seoProgress, cb),
|
||||
gscInspectRun: (input: GscInspectRunInput): Promise<GscInspectRunResult> =>
|
||||
ipcRenderer.invoke(IPC.gscInspectRun, input),
|
||||
gscInspectCancel: (): Promise<void> => ipcRenderer.invoke(IPC.gscInspectCancel),
|
||||
onGscInspectProgress: (cb) =>
|
||||
subscribe<GscInspectProgress>(IPC.gscInspectProgress, cb),
|
||||
exportSheets: (input: ExportSheetsInput): Promise<ExportSheetsResult> =>
|
||||
ipcRenderer.invoke(IPC.exportSheets, input),
|
||||
exportBigquery: (input: ExportBigqueryInput): Promise<ExportBigqueryResult> =>
|
||||
ipcRenderer.invoke(IPC.exportBigquery, input),
|
||||
urlAnalyticsGet: (url: string): Promise<UrlAnalyticsDetail | null> =>
|
||||
ipcRenderer.invoke(IPC.urlAnalyticsGet, url),
|
||||
onLogEntry: (cb) => subscribe<LogEntry>(IPC.logsEntry, cb),
|
||||
onLogsBatch: (cb) => subscribe<LogEntry[]>(IPC.logsBatch, cb),
|
||||
onLogsBusy: (cb) => subscribe<boolean>(IPC.logsBusy, cb),
|
||||
|
||||
@@ -12,11 +12,16 @@ import { ReportsDialog } from './components/ReportsDialog.js';
|
||||
import { SettingsDialog } from './components/SettingsDialog.js';
|
||||
import { CompareDialog } from './components/CompareDialog.js';
|
||||
import { ScheduledCrawlDialog } from './components/ScheduledCrawlDialog.js';
|
||||
import { PasswordPromptDialog } from './components/PasswordPromptDialog.js';
|
||||
import { UrlsTab } from './tabs/UrlsTab.js';
|
||||
import { ImagesTab } from './tabs/ImagesTab.js';
|
||||
import { BrokenLinksTab } from './tabs/BrokenLinksTab.js';
|
||||
import { SerpTab } from './tabs/SerpTab.js';
|
||||
import { PageSpeedTab } from './tabs/PageSpeedTab.js';
|
||||
import { SearchConsoleTab } from './tabs/SearchConsoleTab.js';
|
||||
import { AnalyticsTab } from './tabs/AnalyticsTab.js';
|
||||
import { AiTab } from './tabs/AiTab.js';
|
||||
import { SeoTab } from './tabs/SeoTab.js';
|
||||
import { VisualizationTab } from './tabs/VisualizationTab.js';
|
||||
import { useAppStore } from './store.js';
|
||||
import type { MenuEvent } from '@freecrawl/shared-types';
|
||||
@@ -44,6 +49,12 @@ export function App() {
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [dropFlash, setDropFlash] = useState<string | null>(null);
|
||||
const [passwordPrompt, setPasswordPrompt] = useState<
|
||||
| {
|
||||
mode: 'save' | 'open';
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Drag & drop URL list — drop a `.txt` / `.csv` of URLs anywhere on the
|
||||
// window to populate List mode + open Settings so the user can review
|
||||
@@ -284,6 +295,63 @@ export function App() {
|
||||
case 'export-bulk':
|
||||
void window.freecrawl.exportBulk();
|
||||
break;
|
||||
case 'export-sheets': {
|
||||
const cat = useAppStore.getState().activeCategory;
|
||||
void window.freecrawl
|
||||
.exportSheets({ category: cat })
|
||||
.then((res) => {
|
||||
if (res.ok && res.url) {
|
||||
window.alert(
|
||||
t('app.sheetsExportOk', {
|
||||
defaultValue:
|
||||
'Exported {{rows}} row(s) to Google Sheets:\n\n{{url}}\n\nThe link is in your clipboard.',
|
||||
rows: res.rowsWritten.toLocaleString(),
|
||||
url: res.url,
|
||||
}),
|
||||
);
|
||||
void navigator.clipboard.writeText(res.url).catch(() => undefined);
|
||||
} else {
|
||||
window.alert(
|
||||
t('app.sheetsExportFail', {
|
||||
defaultValue:
|
||||
'Google Sheets export failed: {{error}}\n\nMake sure you have connected your Google account in Settings → Integrations (Google Sheets).',
|
||||
error: res.error ?? 'unknown error',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'export-bigquery': {
|
||||
const cat = useAppStore.getState().activeCategory;
|
||||
void window.freecrawl
|
||||
.exportBigquery({ category: cat })
|
||||
.then((res) => {
|
||||
if (res.ok && res.consoleUrl && res.tableRef) {
|
||||
window.alert(
|
||||
t('app.bigqueryExportOk', {
|
||||
defaultValue:
|
||||
'Exported {{rows}} row(s) to BigQuery:\n\n{{ref}}\n\nOpen in console:\n{{url}}\n\nThe link is in your clipboard.',
|
||||
rows: res.rowsWritten.toLocaleString(),
|
||||
ref: res.tableRef,
|
||||
url: res.consoleUrl,
|
||||
}),
|
||||
);
|
||||
void navigator.clipboard
|
||||
.writeText(res.consoleUrl)
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
window.alert(
|
||||
t('app.bigqueryExportFail', {
|
||||
defaultValue:
|
||||
'BigQuery export failed: {{error}}\n\nMake sure you have pasted a service-account JSON and GCP project ID in Settings → Integrations (Google BigQuery), and that the service account has BigQuery Data Editor on the project.',
|
||||
error: res.error ?? 'unknown error',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'open-reports':
|
||||
setReportsOpen(true);
|
||||
break;
|
||||
@@ -302,6 +370,12 @@ export function App() {
|
||||
case 'save-project-as':
|
||||
void window.freecrawl.projectSaveAs();
|
||||
break;
|
||||
case 'save-project-encrypted':
|
||||
setPasswordPrompt({ mode: 'save' });
|
||||
break;
|
||||
case 'open-project-encrypted':
|
||||
setPasswordPrompt({ mode: 'open' });
|
||||
break;
|
||||
case 'open-visualization':
|
||||
setActiveTab('visualization');
|
||||
break;
|
||||
@@ -366,6 +440,14 @@ export function App() {
|
||||
<SerpTab />
|
||||
) : activeTab === 'pagespeed' ? (
|
||||
<PageSpeedTab />
|
||||
) : activeTab === 'search-console' ? (
|
||||
<SearchConsoleTab />
|
||||
) : activeTab === 'analytics' ? (
|
||||
<AnalyticsTab />
|
||||
) : activeTab === 'ai' ? (
|
||||
<AiTab />
|
||||
) : activeTab === 'seo' ? (
|
||||
<SeoTab />
|
||||
) : activeTab === 'visualization' ? (
|
||||
<VisualizationTab />
|
||||
) : (
|
||||
@@ -409,6 +491,59 @@ export function App() {
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CompareDialog open={compareOpen} onClose={() => setCompareOpen(false)} />
|
||||
<ScheduledCrawlDialog open={scheduleOpen} onClose={() => setScheduleOpen(false)} />
|
||||
<PasswordPromptDialog
|
||||
open={passwordPrompt !== null}
|
||||
title={
|
||||
passwordPrompt?.mode === 'save'
|
||||
? t('encryptedSnapshot.saveTitle', { defaultValue: 'Save Encrypted Snapshot' })
|
||||
: t('encryptedSnapshot.openTitle', { defaultValue: 'Open Encrypted Project' })
|
||||
}
|
||||
message={
|
||||
passwordPrompt?.mode === 'save'
|
||||
? t('encryptedSnapshot.saveMessage', {
|
||||
defaultValue:
|
||||
'Choose a password to encrypt the snapshot. Keep it safe — without the password the file cannot be recovered.',
|
||||
})
|
||||
: t('encryptedSnapshot.openMessage', {
|
||||
defaultValue:
|
||||
'Enter the password used to encrypt this snapshot.',
|
||||
})
|
||||
}
|
||||
confirm={passwordPrompt?.mode === 'save'}
|
||||
submitLabel={
|
||||
passwordPrompt?.mode === 'save'
|
||||
? t('encryptedSnapshot.saveSubmit', { defaultValue: 'Encrypt & Save…' })
|
||||
: t('encryptedSnapshot.openSubmit', { defaultValue: 'Decrypt & Open…' })
|
||||
}
|
||||
onCancel={() => setPasswordPrompt(null)}
|
||||
onSubmit={(password) => {
|
||||
const mode = passwordPrompt?.mode;
|
||||
setPasswordPrompt(null);
|
||||
if (mode === 'save') {
|
||||
void window.freecrawl.projectSaveEncrypted(password).then((res) => {
|
||||
if (res && 'error' in res) {
|
||||
window.alert(
|
||||
t('encryptedSnapshot.saveFailed', {
|
||||
defaultValue: 'Encrypted save failed: {{error}}',
|
||||
error: res.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (mode === 'open') {
|
||||
void window.freecrawl.projectOpenEncrypted(password).then((res) => {
|
||||
if (res && 'error' in res) {
|
||||
window.alert(
|
||||
t('encryptedSnapshot.openFailed', {
|
||||
defaultValue: 'Open encrypted project failed: {{error}}',
|
||||
error: res.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
UrlDetail,
|
||||
UrlPageImageRow,
|
||||
UrlSourceResult,
|
||||
UrlAnalyticsDetail,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
import { diagnoseStatus, type StatusDiagnosis } from '../utils/statusDiagnosis.js';
|
||||
@@ -31,7 +32,8 @@ type SubTab =
|
||||
| 'cookies'
|
||||
| 'structured-data'
|
||||
| 'view-source'
|
||||
| 'duplicates';
|
||||
| 'duplicates'
|
||||
| 'analytics';
|
||||
|
||||
const SUB_TABS: { key: SubTab; label: string; disabled?: boolean }[] = [
|
||||
{ key: 'url-details', label: 'URL Details' },
|
||||
@@ -47,6 +49,7 @@ const SUB_TABS: { key: SubTab; label: string; disabled?: boolean }[] = [
|
||||
{ key: 'structured-data', label: 'Structured Data' },
|
||||
{ key: 'view-source', label: 'View Source' },
|
||||
{ key: 'duplicates', label: 'Duplicates' },
|
||||
{ key: 'analytics', label: 'Analytics' },
|
||||
];
|
||||
|
||||
// Maximum number of URLs we aggregate over in one go. Anything larger is
|
||||
@@ -67,6 +70,7 @@ const SINGLE_URL_ONLY_TABS: ReadonlySet<SubTab> = new Set([
|
||||
'structured-data',
|
||||
'view-source',
|
||||
'duplicates',
|
||||
'analytics',
|
||||
]);
|
||||
|
||||
export function BottomDetailPanel() {
|
||||
@@ -404,6 +408,153 @@ export function BottomDetailPanel() {
|
||||
{detail && subTab === 'duplicates' && (
|
||||
<DuplicatesView urlId={detail.row.id} row={detail.row} />
|
||||
)}
|
||||
{detail && subTab === 'analytics' && (
|
||||
<AnalyticsView pageUrl={detail.row.url} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsView({ pageUrl }: { pageUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<UrlAnalyticsDetail | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
void window.freecrawl.urlAnalyticsGet(pageUrl).then((res) => {
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pageUrl, dataVersion]);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[12px] text-surface-500">
|
||||
{t('analyticsView.loading', { defaultValue: 'Loading…' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (!data.gsc && !data.ga4 && !data.inspection)) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{t('analyticsView.empty', {
|
||||
defaultValue:
|
||||
'No Search Console / Analytics 4 / URL Inspection data for this URL yet. Fetch it from the GA4 / Search Console tabs.',
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { gsc, ga4, inspection } = data;
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-3">
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.gscTitle', { defaultValue: 'Search Console' })}
|
||||
empty={t('analyticsView.gscEmpty', { defaultValue: 'No clicks / impressions data yet.' })}
|
||||
present={!!gsc}
|
||||
>
|
||||
{gsc && (
|
||||
<>
|
||||
<AnalyticsRow label="Clicks" value={gsc.clicks.toLocaleString()} />
|
||||
<AnalyticsRow label="Impressions" value={gsc.impressions.toLocaleString()} />
|
||||
<AnalyticsRow label="CTR" value={`${(gsc.ctr * 100).toFixed(1)}%`} />
|
||||
<AnalyticsRow label="Avg position" value={gsc.position.toFixed(1)} />
|
||||
</>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.ga4Title', { defaultValue: 'Analytics 4' })}
|
||||
empty={t('analyticsView.ga4Empty', { defaultValue: 'No GA4 traffic data yet.' })}
|
||||
present={!!ga4}
|
||||
>
|
||||
{ga4 && (
|
||||
<>
|
||||
<AnalyticsRow label="Sessions" value={ga4.sessions.toLocaleString()} />
|
||||
<AnalyticsRow label="Users" value={ga4.users.toLocaleString()} />
|
||||
<AnalyticsRow label="Pageviews" value={ga4.pageviews.toLocaleString()} />
|
||||
<AnalyticsRow
|
||||
label="Engagement rate"
|
||||
value={`${(ga4.engagementRate * 100).toFixed(1)}%`}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="Avg session duration"
|
||||
value={`${Math.round(ga4.avgSessionDuration)} s`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.inspectionTitle', { defaultValue: 'URL Inspection' })}
|
||||
empty={t('analyticsView.inspectionEmpty', {
|
||||
defaultValue: 'No URL Inspection result yet — run it from the Search Console tab.',
|
||||
})}
|
||||
present={!!inspection}
|
||||
>
|
||||
{inspection && (
|
||||
<>
|
||||
<AnalyticsRow label="Verdict" value={inspection.verdict ?? '—'} />
|
||||
<AnalyticsRow label="Coverage" value={inspection.coverageState ?? '—'} />
|
||||
<AnalyticsRow label="Indexing" value={inspection.indexingState ?? '—'} />
|
||||
<AnalyticsRow label="robots.txt" value={inspection.robotsTxtState ?? '—'} />
|
||||
<AnalyticsRow
|
||||
label="Last crawl"
|
||||
value={inspection.lastCrawlTime ?? '—'}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="Google canonical"
|
||||
value={inspection.googleCanonical ?? '—'}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="User canonical"
|
||||
value={inspection.userCanonical ?? '—'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsCard({
|
||||
title,
|
||||
present,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
present: boolean;
|
||||
empty: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded border border-surface-800 bg-surface-900/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-surface-300">
|
||||
{title}
|
||||
</div>
|
||||
{present ? (
|
||||
<div className="space-y-1">{children}</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-surface-500">{empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 text-[11px]">
|
||||
<div className="w-32 shrink-0 text-surface-500">{label}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-surface-100" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,10 @@ const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
|
||||
'broken-links': 'all',
|
||||
serp: 'tab:serp',
|
||||
pagespeed: 'all',
|
||||
'search-console': 'all',
|
||||
analytics: 'all',
|
||||
ai: 'all',
|
||||
seo: 'all',
|
||||
visualization: 'all',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PasswordPromptDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
/** When true, requires the user to type the password twice so a typo
|
||||
* doesn't lock them out of an encrypted snapshot they're creating. */
|
||||
confirm: boolean;
|
||||
submitLabel: string;
|
||||
onSubmit: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 #4 — Tiny password-entry modal used by the encrypted-snapshot
|
||||
* Save / Open flows. Electron's `window.prompt` returns null without
|
||||
* showing UI (security default), and `dialog.showMessageBox` has no
|
||||
* input field — so we render a small in-renderer overlay.
|
||||
*
|
||||
* Auto-focuses the first input on open, traps Esc → Cancel and
|
||||
* Enter → Submit, and (in `confirm` mode) validates that both fields
|
||||
* match before calling `onSubmit`.
|
||||
*/
|
||||
export function PasswordPromptDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirm,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: PasswordPromptDialogProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const [pw1, setPw1] = useState('');
|
||||
const [pw2, setPw2] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const firstInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPw1('');
|
||||
setPw2('');
|
||||
setError(null);
|
||||
// Focus after the modal mounts.
|
||||
setTimeout(() => firstInputRef.current?.focus(), 30);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function submit(): void {
|
||||
if (pw1.length === 0) {
|
||||
setError(
|
||||
t('passwordPrompt.errorEmpty', { defaultValue: 'Password cannot be empty.' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (confirm && pw1 !== pw2) {
|
||||
setError(
|
||||
t('passwordPrompt.errorMismatch', { defaultValue: 'Passwords do not match.' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (confirm && pw1.length < 8) {
|
||||
setError(
|
||||
t('passwordPrompt.errorTooShort', {
|
||||
defaultValue: 'Use at least 8 characters.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSubmit(pw1);
|
||||
}
|
||||
|
||||
function onKey(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[420px] rounded-md border border-surface-700 bg-surface-900 p-5 text-surface-100 shadow-xl"
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<h2 className="mb-2 text-[15px] font-semibold">{title}</h2>
|
||||
<p className="mb-4 text-[12px] text-surface-300">{message}</p>
|
||||
|
||||
<label className="mb-1 block text-[11px] font-medium text-surface-300">
|
||||
{t('passwordPrompt.passwordLabel', { defaultValue: 'Password' })}
|
||||
</label>
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
type="password"
|
||||
className="mb-3 w-full rounded border border-surface-700 bg-surface-950 px-2 py-1.5 text-[12px] outline-none focus:border-blue-500"
|
||||
value={pw1}
|
||||
onChange={(e) => setPw1(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
{confirm && (
|
||||
<>
|
||||
<label className="mb-1 block text-[11px] font-medium text-surface-300">
|
||||
{t('passwordPrompt.confirmLabel', { defaultValue: 'Confirm password' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="mb-3 w-full rounded border border-surface-700 bg-surface-950 px-2 py-1.5 text-[12px] outline-none focus:border-blue-500"
|
||||
value={pw2}
|
||||
onChange={(e) => setPw2(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded border border-red-700/60 bg-red-900/30 px-2 py-1.5 text-[11px] text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-surface-700 bg-surface-800 px-3 py-1.5 text-[12px] hover:bg-surface-700"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-blue-500"
|
||||
onClick={submit}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
TopWordsRow,
|
||||
WordCountPerDirectoryRow,
|
||||
SitemapOrphanRow,
|
||||
OrphanCrossSourceRow,
|
||||
} from '@freecrawl/shared-types';
|
||||
|
||||
interface Props {
|
||||
@@ -48,6 +49,7 @@ type ReportKind =
|
||||
| 'url-length-histogram'
|
||||
| 'word-count-per-dir'
|
||||
| 'sitemap-orphans'
|
||||
| 'orphan-cross-source'
|
||||
| 'server-headers'
|
||||
| 'top-words';
|
||||
|
||||
@@ -87,6 +89,7 @@ const REPORT_LABELS: Record<ReportKind, string> = {
|
||||
'url-length-histogram': 'URL Length Histogram',
|
||||
'word-count-per-dir': 'Word Count per Directory',
|
||||
'sitemap-orphans': 'Sitemap Orphans (Top 1000)',
|
||||
'orphan-cross-source': 'Orphan Pages — Sitemap + GSC + GA4 (Top 1000)',
|
||||
'server-headers': 'Server Stack (Server Header)',
|
||||
'top-words': 'Top Words (Title + Meta + H1, Top 100)',
|
||||
};
|
||||
@@ -113,6 +116,7 @@ const KEY_LABELS: Record<ReportKind, string> = {
|
||||
'url-length-histogram': 'Bucket',
|
||||
'word-count-per-dir': 'Directory',
|
||||
'sitemap-orphans': 'URL',
|
||||
'orphan-cross-source': 'URL',
|
||||
'server-headers': 'Server',
|
||||
'top-words': 'Word',
|
||||
};
|
||||
@@ -139,6 +143,7 @@ const TOP_URL_METRIC: Record<ReportKind, TopUrlMetric | null> = {
|
||||
'url-length-histogram': null,
|
||||
'word-count-per-dir': null,
|
||||
'sitemap-orphans': null,
|
||||
'orphan-cross-source': null,
|
||||
'server-headers': null,
|
||||
'top-words': null,
|
||||
};
|
||||
@@ -170,6 +175,7 @@ const VALUE_FORMAT: Record<ReportKind, (v: number | null) => string> = {
|
||||
'url-length-histogram': (v) => (v ?? 0).toLocaleString(),
|
||||
'word-count-per-dir': (v) => (v ?? 0).toLocaleString(),
|
||||
'sitemap-orphans': (v) => (v ?? 0).toLocaleString(),
|
||||
'orphan-cross-source': (v) => (v ?? 0).toLocaleString(),
|
||||
'server-headers': (v) => (v ?? 0).toLocaleString(),
|
||||
'top-words': (v) => (v ?? 0).toLocaleString(),
|
||||
};
|
||||
@@ -205,6 +211,7 @@ export function ReportsDialog({ open, onClose }: Props) {
|
||||
'url-length-histogram': t('reports.report.urlLengthHistogram', { defaultValue: REPORT_LABELS['url-length-histogram'] }),
|
||||
'word-count-per-dir': t('reports.report.wordCountPerDir', { defaultValue: REPORT_LABELS['word-count-per-dir'] }),
|
||||
'sitemap-orphans': t('reports.report.sitemapOrphans', { defaultValue: REPORT_LABELS['sitemap-orphans'] }),
|
||||
'orphan-cross-source': t('reports.report.orphanCrossSource', { defaultValue: REPORT_LABELS['orphan-cross-source'] }),
|
||||
'server-headers': t('reports.report.serverHeaders', { defaultValue: REPORT_LABELS['server-headers'] }),
|
||||
'top-words': t('reports.report.topWords', { defaultValue: REPORT_LABELS['top-words'] }),
|
||||
}), [t]);
|
||||
@@ -230,6 +237,7 @@ export function ReportsDialog({ open, onClose }: Props) {
|
||||
'url-length-histogram': t('reports.keyBucket', { defaultValue: 'Bucket' }),
|
||||
'word-count-per-dir': t('reports.keyDirectory', { defaultValue: 'Directory' }),
|
||||
'sitemap-orphans': 'URL',
|
||||
'orphan-cross-source': 'URL',
|
||||
'server-headers': t('reports.keyServer', { defaultValue: 'Server' }),
|
||||
'top-words': t('reports.keyWord', { defaultValue: 'Word' }),
|
||||
}), [t]);
|
||||
@@ -389,6 +397,21 @@ export function ReportsDialog({ open, onClose }: Props) {
|
||||
.join(' · ') || '—',
|
||||
})),
|
||||
);
|
||||
} else if (kind === 'orphan-cross-source') {
|
||||
const r = await window.freecrawl.reportsOrphanCrossSource(1000);
|
||||
if (!cancelled)
|
||||
setRows(
|
||||
r.map((x: OrphanCrossSourceRow) => ({
|
||||
key: x.url,
|
||||
count: 1,
|
||||
// `sources` (sitemap / gsc / ga4) goes in the secondary
|
||||
// value column so the user can triage why each URL was
|
||||
// missed by the crawl — sitemap-only often means a
|
||||
// broken internal-link path; GSC + GA4 means real
|
||||
// user-visited pages no crawl path reaches.
|
||||
valueLabel: x.sources.join(' + '),
|
||||
})),
|
||||
);
|
||||
} else if (kind === 'server-headers') {
|
||||
const r = await window.freecrawl.reportsServerHeaders();
|
||||
if (!cancelled)
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
"compareWith": "Compare With Project…",
|
||||
"scheduledCrawl": "Scheduled Crawl…",
|
||||
"saveProjectAs": "Save Project As…",
|
||||
"saveProjectEncrypted": "Save Encrypted Snapshot…",
|
||||
"openProjectEncrypted": "Open Encrypted Project…",
|
||||
"settings": "Settings…",
|
||||
"view": "View",
|
||||
"overviewSidebar": "Overview Sidebar",
|
||||
@@ -117,6 +119,23 @@
|
||||
"checkForUpdates": "Check for Updates…",
|
||||
"about": "About FreeCrawl SEO"
|
||||
},
|
||||
"passwordPrompt": {
|
||||
"passwordLabel": "Password",
|
||||
"confirmLabel": "Confirm password",
|
||||
"errorEmpty": "Password cannot be empty.",
|
||||
"errorMismatch": "Passwords do not match.",
|
||||
"errorTooShort": "Use at least 8 characters."
|
||||
},
|
||||
"encryptedSnapshot": {
|
||||
"saveTitle": "Save Encrypted Snapshot",
|
||||
"saveMessage": "Choose a password to encrypt the snapshot. Keep it safe — without the password the file cannot be recovered.",
|
||||
"saveSubmit": "Encrypt & Save…",
|
||||
"saveFailed": "Encrypted save failed: {{error}}",
|
||||
"openTitle": "Open Encrypted Project",
|
||||
"openMessage": "Enter the password used to encrypt this snapshot.",
|
||||
"openSubmit": "Decrypt & Open…",
|
||||
"openFailed": "Open encrypted project failed: {{error}}"
|
||||
},
|
||||
"tabs": {
|
||||
"internal": "Internal",
|
||||
"external": "External",
|
||||
@@ -144,6 +163,10 @@
|
||||
"brokenLinks": "Broken Links",
|
||||
"serp": "SERP",
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"ai": "AI",
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"export": {
|
||||
@@ -222,6 +245,7 @@
|
||||
"urlLengthHistogram": "URL Length Histogram",
|
||||
"wordCountPerDir": "Word Count per Directory",
|
||||
"sitemapOrphans": "Sitemap Orphans (Top 1000)",
|
||||
"orphanCrossSource": "Orphan Pages — Sitemap + GSC + GA4 (Top 1000)",
|
||||
"serverHeaders": "Server Stack (Server Header)",
|
||||
"topWords": "Top Words (Title + Meta + H1, Top 100)"
|
||||
},
|
||||
@@ -634,7 +658,9 @@
|
||||
"cancel": "Cancel",
|
||||
"confirmRun": "This will request {{count}} PageSpeed audits. Each audit takes ~10–30s and{{keyless}} may take a while. Continue?",
|
||||
"confirmKeyless": " — without an API key (low rate limit) —",
|
||||
"noKeyBanner": "No PageSpeed API key — audits run keyless at a low rate limit. Add a free key in Settings → Integrations for faster runs.",
|
||||
"noKeyGate": "PageSpeed Insights needs a free Google API key — keyless audits no longer work (Google quota: 0/day).",
|
||||
"addKeyCta": "Add API Key…",
|
||||
"runDisabledNoKey": "Add a free PageSpeed Insights API key in Settings → Integrations to enable audits.",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colPerf": "Perf",
|
||||
@@ -649,6 +675,138 @@
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then select pages to audit.",
|
||||
"emptyNoMatch": "No pages match the current filter."
|
||||
},
|
||||
"gscTab": {
|
||||
"title": "Search Console",
|
||||
"loading": "Loading…",
|
||||
"unconfiguredTitle": "Google Search Console is not set up",
|
||||
"unconfiguredBody": "Open Settings → Integrations → Google Search Console and paste your own Google Cloud OAuth client ID + secret (Desktop app type). Then come back here to connect.",
|
||||
"disconnectedTitle": "Connect your Search Console account",
|
||||
"disconnectedBody": "Sign in with the Google account that owns the Search Console property. A browser window opens for consent — nothing leaves your machine except the standard Google OAuth exchange.",
|
||||
"connect": "Connect with Google",
|
||||
"connectedAs": "Connected as {{email}}",
|
||||
"disconnect": "Disconnect",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
"filterAll": "All pages",
|
||||
"filterWith": "With GSC data",
|
||||
"filterWithout": "No GSC data",
|
||||
"noProperties": "No properties",
|
||||
"days7": "Last 7 days",
|
||||
"days28": "Last 28 days",
|
||||
"days90": "Last 90 days",
|
||||
"fetch": "Fetch",
|
||||
"fetching": "Fetching from Search Console…",
|
||||
"metaLine": "{{property}} · {{start}} → {{end}} · {{rows}} pages · fetched {{ago}}",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colVerdict": "Index",
|
||||
"colClicks": "Clicks",
|
||||
"colImpr": "Impressions",
|
||||
"colCtr": "CTR",
|
||||
"colPos": "Position",
|
||||
"colFetched": "Fetched",
|
||||
"inspect": "Inspect (top {{n}})",
|
||||
"inspectTooltip": "Run URL Inspection on the top {{n}} loaded pages (quota: 2 000/day per property)",
|
||||
"confirmInspect": "This uses {{count}} of the {{property}} daily 2 000-URL Inspection quota. Continue?",
|
||||
"inspecting": "Inspecting {{done}} / {{total}} — {{url}}",
|
||||
"summary": "{{shown}} pages",
|
||||
"firstN": "(first {{n}})",
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then fetch Search Console data.",
|
||||
"emptyNoMatch": "No pages match the current filter."
|
||||
},
|
||||
"ga4Tab": {
|
||||
"title": "Google Analytics 4",
|
||||
"loading": "Loading…",
|
||||
"unconfiguredTitle": "Google Analytics 4 is not set up",
|
||||
"unconfiguredBody": "Open Settings → Integrations → Google Analytics 4 and paste your own Google Cloud OAuth client ID + secret (Desktop app type). Then come back here to connect.",
|
||||
"disconnectedTitle": "Connect your Analytics 4 account",
|
||||
"disconnectedBody": "Sign in with the Google account that owns the GA4 property. A browser window opens for consent — nothing leaves your machine except the standard Google OAuth exchange.",
|
||||
"connect": "Connect with Google",
|
||||
"connectedAs": "Connected as {{email}}",
|
||||
"disconnect": "Disconnect",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
"filterAll": "All pages",
|
||||
"filterWith": "With GA4 data",
|
||||
"filterWithout": "No GA4 data",
|
||||
"noProperties": "No properties",
|
||||
"days7": "Last 7 days",
|
||||
"days28": "Last 28 days",
|
||||
"days90": "Last 90 days",
|
||||
"fetch": "Fetch",
|
||||
"fetching": "Fetching from GA4…",
|
||||
"metaLine": "{{property}} · {{start}} → {{end}} · {{rows}} pages · fetched {{ago}}",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colSessions": "Sessions",
|
||||
"colUsers": "Users",
|
||||
"colViews": "Pageviews",
|
||||
"colEng": "Engagement",
|
||||
"colDuration": "Avg duration",
|
||||
"colFetched": "Fetched",
|
||||
"summary": "{{shown}} pages",
|
||||
"firstN": "(first {{n}})",
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then fetch GA4 data.",
|
||||
"emptyNoMatch": "No pages match the current filter."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
"filterAll": "All pages",
|
||||
"filterWith": "With response",
|
||||
"filterWithout": "No response",
|
||||
"filterError": "Errors only",
|
||||
"provider": "Provider:",
|
||||
"modelPlaceholder": "Model (optional)",
|
||||
"selectUntested": "Select untested",
|
||||
"run": "Run ({{count}})",
|
||||
"runProgress": "{{done}} / {{total}}",
|
||||
"cancel": "Cancel",
|
||||
"confirmRun": "This will send {{count}} prompts to {{provider}}. Continue?",
|
||||
"notConfigured": "{{provider}} is not configured — set its credentials in Settings → Integrations to run prompts.",
|
||||
"promptLabel": "Prompt template",
|
||||
"promptVars": "variables: {url} · {title} · {description} · {h1} · {body} (truncated to 2 KB)",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colResponse": "Response",
|
||||
"colModel": "Model",
|
||||
"colTokens": "Tokens (in/out)",
|
||||
"colFetched": "Fetched",
|
||||
"summary": "{{shown}} pages · {{tested}} with response · {{errors}} errors · {{selected}} selected",
|
||||
"running": "Running: {{url}}",
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then select pages and Run.",
|
||||
"emptyNoMatch": "No pages match the current filter."
|
||||
},
|
||||
"seoTab": {
|
||||
"title": "SEO Authority",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
"filterAll": "All pages",
|
||||
"filterWith": "With data",
|
||||
"filterWithout": "No data",
|
||||
"filterError": "Errors only",
|
||||
"provider": "Provider:",
|
||||
"selectUntested": "Select untested",
|
||||
"run": "Fetch ({{count}})",
|
||||
"runProgress": "{{done}} / {{total}}",
|
||||
"cancel": "Cancel",
|
||||
"confirmRun": "This will fetch {{count}} URLs from {{provider}}. Continue?",
|
||||
"notConfigured": "{{provider}} is not configured — set its credentials in Settings → Integrations to fetch data.",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colFetched": "Fetched",
|
||||
"summary": "{{shown}} pages · {{tested}} with data · {{errors}} errors · {{selected}} selected",
|
||||
"running": "Fetching: {{url}}",
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then select pages and Fetch.",
|
||||
"emptyNoMatch": "No pages match the current filter."
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Loading…",
|
||||
"empty": "No Search Console / Analytics 4 / URL Inspection data for this URL yet. Fetch it from the GA4 / Search Console tabs.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "No clicks / impressions data yet.",
|
||||
"ga4Title": "Analytics 4",
|
||||
"ga4Empty": "No GA4 traffic data yet.",
|
||||
"inspectionTitle": "URL Inspection",
|
||||
"inspectionEmpty": "No URL Inspection result yet — run it from the Search Console tab."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Search broken target URLs…",
|
||||
"scopeAll": "All",
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
"compareWith": "Projeyle Karşılaştır…",
|
||||
"scheduledCrawl": "Zamanlanmış Crawl…",
|
||||
"saveProjectAs": "Projeyi Farklı Kaydet…",
|
||||
"saveProjectEncrypted": "Şifreli Snapshot Kaydet…",
|
||||
"openProjectEncrypted": "Şifreli Proje Aç…",
|
||||
"settings": "Ayarlar…",
|
||||
"view": "Görünüm",
|
||||
"overviewSidebar": "Genel Bakış Kenar Çubuğu",
|
||||
@@ -117,6 +119,23 @@
|
||||
"checkForUpdates": "Güncellemeleri Kontrol Et…",
|
||||
"about": "FreeCrawl SEO Hakkında"
|
||||
},
|
||||
"passwordPrompt": {
|
||||
"passwordLabel": "Parola",
|
||||
"confirmLabel": "Parolayı doğrula",
|
||||
"errorEmpty": "Parola boş olamaz.",
|
||||
"errorMismatch": "Parolalar eşleşmiyor.",
|
||||
"errorTooShort": "En az 8 karakter kullanın."
|
||||
},
|
||||
"encryptedSnapshot": {
|
||||
"saveTitle": "Şifreli Snapshot Kaydet",
|
||||
"saveMessage": "Snapshot'ı şifrelemek için bir parola seçin. Güvende tutun — parola olmadan dosya kurtarılamaz.",
|
||||
"saveSubmit": "Şifrele ve Kaydet…",
|
||||
"saveFailed": "Şifreli kayıt başarısız: {{error}}",
|
||||
"openTitle": "Şifreli Proje Aç",
|
||||
"openMessage": "Bu snapshot'ın şifrelenmesinde kullanılan parolayı girin.",
|
||||
"openSubmit": "Çöz ve Aç…",
|
||||
"openFailed": "Şifreli proje açılamadı: {{error}}"
|
||||
},
|
||||
"tabs": {
|
||||
"internal": "Dahili",
|
||||
"external": "Harici",
|
||||
@@ -144,6 +163,10 @@
|
||||
"brokenLinks": "Kırık Linkler",
|
||||
"serp": "SERP",
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"ai": "AI",
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Görselleştirme"
|
||||
},
|
||||
"export": {
|
||||
@@ -221,6 +244,7 @@
|
||||
"urlLengthHistogram": "URL Uzunluğu Histogramı",
|
||||
"wordCountPerDir": "Dizin Başına Kelime Sayısı",
|
||||
"sitemapOrphans": "Sitemap Yetimleri (Top 1000)",
|
||||
"orphanCrossSource": "Yetim Sayfalar — Sitemap + GSC + GA4 (Top 1000)",
|
||||
"serverHeaders": "Sunucu Stack (Server Header)",
|
||||
"topWords": "En Çok Kelime (Başlık + Meta + H1, Top 100)"
|
||||
},
|
||||
@@ -633,7 +657,9 @@
|
||||
"cancel": "İptal",
|
||||
"confirmRun": "Bu işlem {{count}} PageSpeed denetimi isteyecek. Her denetim ~10–30sn sürer ve{{keyless}} biraz zaman alabilir. Devam edilsin mi?",
|
||||
"confirmKeyless": " — API anahtarı olmadan (düşük hız sınırı) —",
|
||||
"noKeyBanner": "PageSpeed API anahtarı yok — denetimler düşük hız sınırıyla anahtarsız çalışır. Daha hızlı çalıştırmak için Ayarlar → Entegrasyonlar'dan ücretsiz bir anahtar ekleyin.",
|
||||
"noKeyGate": "PageSpeed Insights için ücretsiz Google API anahtarı gerekir — anahtarsız denetimler artık çalışmıyor (Google kotası: 0/gün).",
|
||||
"addKeyCta": "API Anahtarı Ekle…",
|
||||
"runDisabledNoKey": "Denetimleri etkinleştirmek için Ayarlar → Entegrasyonlar'dan ücretsiz bir PageSpeed Insights API anahtarı ekleyin.",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colPerf": "Perf",
|
||||
@@ -648,6 +674,138 @@
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra denetlenecek sayfaları seç.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
|
||||
},
|
||||
"gscTab": {
|
||||
"title": "Search Console",
|
||||
"loading": "Yükleniyor…",
|
||||
"unconfiguredTitle": "Google Search Console kurulu değil",
|
||||
"unconfiguredBody": "Ayarlar → Entegrasyonlar → Google Search Console'u açıp kendi Google Cloud OAuth client ID + secret'ını yapıştır (Desktop app tipi). Sonra buraya dönüp bağlan.",
|
||||
"disconnectedTitle": "Search Console hesabını bağla",
|
||||
"disconnectedBody": "Search Console mülküne sahip Google hesabıyla oturum aç. Onay için bir tarayıcı penceresi açılır — standart Google OAuth alışverişi dışında makinenden hiçbir şey çıkmaz.",
|
||||
"connect": "Google ile Bağlan",
|
||||
"connectedAs": "Bağlı hesap: {{email}}",
|
||||
"disconnect": "Bağlantıyı kes",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
"filterAll": "Tüm sayfalar",
|
||||
"filterWith": "GSC verisi olan",
|
||||
"filterWithout": "GSC verisi olmayan",
|
||||
"noProperties": "Mülk yok",
|
||||
"days7": "Son 7 gün",
|
||||
"days28": "Son 28 gün",
|
||||
"days90": "Son 90 gün",
|
||||
"fetch": "Getir",
|
||||
"fetching": "Search Console'dan getiriliyor…",
|
||||
"metaLine": "{{property}} · {{start}} → {{end}} · {{rows}} sayfa · getirildi {{ago}}",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colVerdict": "İndeks",
|
||||
"colClicks": "Tıklama",
|
||||
"colImpr": "Gösterim",
|
||||
"colCtr": "TO",
|
||||
"colPos": "Konum",
|
||||
"colFetched": "Getirilme",
|
||||
"inspect": "Denetle (ilk {{n}})",
|
||||
"inspectTooltip": "Yüklü ilk {{n}} sayfada URL Inspection çalıştır (kota: gün başına property başı 2 000)",
|
||||
"confirmInspect": "Bu işlem {{property}} günlük 2 000 URL Inspection kotanızdan {{count}} kullanır. Devam edilsin mi?",
|
||||
"inspecting": "Denetleniyor {{done}} / {{total}} — {{url}}",
|
||||
"summary": "{{shown}} sayfa",
|
||||
"firstN": "(ilk {{n}})",
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra Search Console verisini getir.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
|
||||
},
|
||||
"ga4Tab": {
|
||||
"title": "Google Analytics 4",
|
||||
"loading": "Yükleniyor…",
|
||||
"unconfiguredTitle": "Google Analytics 4 kurulu değil",
|
||||
"unconfiguredBody": "Ayarlar → Entegrasyonlar → Google Analytics 4'ü açıp kendi Google Cloud OAuth client ID + secret'ını yapıştır (Desktop app tipi). Sonra buraya dönüp bağlan.",
|
||||
"disconnectedTitle": "Analytics 4 hesabını bağla",
|
||||
"disconnectedBody": "GA4 mülküne sahip Google hesabıyla oturum aç. Onay için bir tarayıcı penceresi açılır — standart Google OAuth alışverişi dışında makinenden hiçbir şey çıkmaz.",
|
||||
"connect": "Google ile Bağlan",
|
||||
"connectedAs": "Bağlı hesap: {{email}}",
|
||||
"disconnect": "Bağlantıyı kes",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
"filterAll": "Tüm sayfalar",
|
||||
"filterWith": "GA4 verisi olan",
|
||||
"filterWithout": "GA4 verisi olmayan",
|
||||
"noProperties": "Mülk yok",
|
||||
"days7": "Son 7 gün",
|
||||
"days28": "Son 28 gün",
|
||||
"days90": "Son 90 gün",
|
||||
"fetch": "Getir",
|
||||
"fetching": "GA4'ten getiriliyor…",
|
||||
"metaLine": "{{property}} · {{start}} → {{end}} · {{rows}} sayfa · getirildi {{ago}}",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colSessions": "Oturum",
|
||||
"colUsers": "Kullanıcı",
|
||||
"colViews": "Görüntüleme",
|
||||
"colEng": "Etkileşim",
|
||||
"colDuration": "Ort. süre",
|
||||
"colFetched": "Getirilme",
|
||||
"summary": "{{shown}} sayfa",
|
||||
"firstN": "(ilk {{n}})",
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra GA4 verisini getir.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
"filterAll": "Tüm sayfalar",
|
||||
"filterWith": "Yanıt olan",
|
||||
"filterWithout": "Yanıt olmayan",
|
||||
"filterError": "Yalnızca hatalar",
|
||||
"provider": "Sağlayıcı:",
|
||||
"modelPlaceholder": "Model (opsiyonel)",
|
||||
"selectUntested": "Yanıtsızları seç",
|
||||
"run": "Çalıştır ({{count}})",
|
||||
"runProgress": "{{done}} / {{total}}",
|
||||
"cancel": "İptal",
|
||||
"confirmRun": "Bu işlem {{provider}}'a {{count}} prompt gönderecek. Devam edilsin mi?",
|
||||
"notConfigured": "{{provider}} ayarlı değil — prompt çalıştırmak için Ayarlar → Entegrasyonlar'dan kimlik bilgilerini gir.",
|
||||
"promptLabel": "Prompt şablonu",
|
||||
"promptVars": "değişkenler: {url} · {title} · {description} · {h1} · {body} (2 KB'a kısaltılır)",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colResponse": "Yanıt",
|
||||
"colModel": "Model",
|
||||
"colTokens": "Token (in/out)",
|
||||
"colFetched": "Getirilme",
|
||||
"summary": "{{shown}} sayfa · {{tested}} yanıtlı · {{errors}} hata · {{selected}} seçili",
|
||||
"running": "Çalışıyor: {{url}}",
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra sayfaları seç ve çalıştır.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
|
||||
},
|
||||
"seoTab": {
|
||||
"title": "SEO Authority",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
"filterAll": "Tüm sayfalar",
|
||||
"filterWith": "Verisi olan",
|
||||
"filterWithout": "Verisi olmayan",
|
||||
"filterError": "Yalnızca hatalar",
|
||||
"provider": "Sağlayıcı:",
|
||||
"selectUntested": "Verisizleri seç",
|
||||
"run": "Getir ({{count}})",
|
||||
"runProgress": "{{done}} / {{total}}",
|
||||
"cancel": "İptal",
|
||||
"confirmRun": "Bu işlem {{provider}}'dan {{count}} URL getirir. Devam edilsin mi?",
|
||||
"notConfigured": "{{provider}} ayarlı değil — veri çekmek için Ayarlar → Entegrasyonlar'dan kimlik bilgilerini gir.",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colFetched": "Getirilme",
|
||||
"summary": "{{shown}} sayfa · {{tested}} verisi olan · {{errors}} hata · {{selected}} seçili",
|
||||
"running": "Getiriliyor: {{url}}",
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir crawl çalıştır, sonra sayfaları seç ve getir.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Yükleniyor…",
|
||||
"empty": "Bu URL için henüz Search Console / Analytics 4 / URL Inspection verisi yok. GA4 / Search Console sekmelerinden getir.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Henüz tıklama / gösterim verisi yok.",
|
||||
"ga4Title": "Analytics 4",
|
||||
"ga4Empty": "Henüz GA4 trafik verisi yok.",
|
||||
"inspectionTitle": "URL Inspection",
|
||||
"inspectionEmpty": "URL Inspection sonucu yok — Search Console sekmesinden çalıştır."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Kırık hedef URL ara…",
|
||||
"scopeAll": "Tümü",
|
||||
|
||||
@@ -35,6 +35,10 @@ export type TabKey =
|
||||
| 'broken-links'
|
||||
| 'serp'
|
||||
| 'pagespeed'
|
||||
| 'search-console'
|
||||
| 'analytics'
|
||||
| 'ai'
|
||||
| 'seo'
|
||||
| 'visualization';
|
||||
|
||||
export const TAB_ORDER: { key: TabKey; label: string }[] = [
|
||||
@@ -64,6 +68,10 @@ export const TAB_ORDER: { key: TabKey; label: string }[] = [
|
||||
{ key: 'broken-links', label: 'Broken Links' },
|
||||
{ key: 'serp', label: 'SERP' },
|
||||
{ key: 'pagespeed', label: 'PageSpeed' },
|
||||
{ key: 'search-console', label: 'Search Console' },
|
||||
{ key: 'analytics', label: 'GA4' },
|
||||
{ key: 'ai', label: 'AI' },
|
||||
{ key: 'seo', label: 'SEO Authority' },
|
||||
{ key: 'visualization', label: 'Visualization' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Search, Play, AlertTriangle } from 'lucide-react';
|
||||
import type {
|
||||
AiProgress,
|
||||
AiProvider,
|
||||
AiResult,
|
||||
AiRow,
|
||||
IntegrationsState,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const PROMPT_PANEL_HEIGHT = 100;
|
||||
const HEADER_HEIGHT = 26;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const ROW_HEIGHT = 32;
|
||||
const PAGE_SIZE = 2000;
|
||||
const POLL_MS_RUNNING = 5000;
|
||||
const CONFIRM_THRESHOLD = 30;
|
||||
|
||||
const DEFAULT_PROMPT =
|
||||
'Summarize the page below in 2-3 sentences for an SEO audit.\n\n' +
|
||||
'URL: {url}\nTitle: {title}\nMeta description: {description}\nH1: {h1}\n\n' +
|
||||
'Body:\n{body}';
|
||||
|
||||
const PROVIDER_LABEL: Record<AiProvider, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
ollama: 'Ollama (local)',
|
||||
};
|
||||
|
||||
type FilterMode = 'all' | 'with-data' | 'without-data' | 'error';
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const min = Math.floor((Date.now() - then) / 60_000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
function fmtTokens(a: AiResult): string {
|
||||
const i = a.tokensIn ?? 0;
|
||||
const o = a.tokensOut ?? 0;
|
||||
if (!i && !o) return '—';
|
||||
return `${i.toLocaleString()} / ${o.toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function AiTab() {
|
||||
const { t } = useTranslation();
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const crawlProgress = useAppStore((s) => s.progress);
|
||||
|
||||
const [provider, setProvider] = useState<AiProvider>('openai');
|
||||
const [model, setModel] = useState('');
|
||||
const [promptTemplate, setPromptTemplate] = useState<string>(() => {
|
||||
const saved = window.freecrawl?.prefsGet('ai-tab:prompt');
|
||||
return typeof saved === 'string' && saved.trim() ? saved : DEFAULT_PROMPT;
|
||||
});
|
||||
const [rows, setRows] = useState<AiRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<AiProgress | null>(null);
|
||||
const [integrations, setIntegrations] = useState<IntegrationsState | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await window.freecrawl.aiQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
search: search || undefined,
|
||||
provider,
|
||||
filter,
|
||||
});
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
}, [search, filter, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void reload();
|
||||
if (!crawlProgress?.running) return;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) void reload();
|
||||
}, POLL_MS_RUNNING);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [reload, dataVersion, crawlProgress?.running]);
|
||||
|
||||
useEffect(() => {
|
||||
void window.freecrawl.integrationsGetAll().then(setIntegrations);
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.freecrawl.onAiProgress((p) => {
|
||||
setProgress(p.running ? p : null);
|
||||
if (!p.currentUrl) void reload();
|
||||
});
|
||||
return off;
|
||||
}, [reload]);
|
||||
|
||||
// Persist the prompt — it's expensive to craft, cheap to remember.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.freecrawl?.prefsSet('ai-tab:prompt', promptTemplate);
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, [promptTemplate]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 12,
|
||||
getItemKey: (i) => rows[i]?.url ?? `idx-${i}`,
|
||||
});
|
||||
|
||||
const allLoadedSelected =
|
||||
rows.length > 0 && rows.every((r) => selected.has(r.url));
|
||||
|
||||
const toggleRow = useCallback((url: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) next.delete(url);
|
||||
else next.add(url);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (rows.length > 0 && rows.every((r) => prev.has(r.url))) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(rows.map((r) => r.url));
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
const selectUntested = useCallback(() => {
|
||||
setSelected(new Set(rows.filter((r) => !r.ai).map((r) => r.url)));
|
||||
}, [rows]);
|
||||
|
||||
const providerConfigured =
|
||||
integrations?.[provider]?.configured ?? false;
|
||||
|
||||
const runAi = useCallback(async () => {
|
||||
const urls = [...selected];
|
||||
if (urls.length === 0 || running) return;
|
||||
if (!promptTemplate.trim()) return;
|
||||
if (!providerConfigured) return;
|
||||
if (
|
||||
urls.length > CONFIRM_THRESHOLD &&
|
||||
!window.confirm(
|
||||
t('aiTab.confirmRun', {
|
||||
defaultValue:
|
||||
'This will send {{count}} prompts to {{provider}}. Continue?',
|
||||
count: urls.length,
|
||||
provider: PROVIDER_LABEL[provider],
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setProgress({ done: 0, total: urls.length, currentUrl: null, running: true });
|
||||
try {
|
||||
await window.freecrawl.aiRun({
|
||||
provider,
|
||||
model: model.trim() || undefined,
|
||||
prompt: promptTemplate,
|
||||
urls,
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
void reload();
|
||||
}
|
||||
}, [
|
||||
selected,
|
||||
running,
|
||||
promptTemplate,
|
||||
providerConfigured,
|
||||
provider,
|
||||
model,
|
||||
t,
|
||||
reload,
|
||||
]);
|
||||
|
||||
const cancelRun = useCallback(() => {
|
||||
void window.freecrawl.aiCancel();
|
||||
}, []);
|
||||
|
||||
const testedCount = useMemo(() => rows.filter((r) => r.ai).length, [rows]);
|
||||
const errorCount = useMemo(
|
||||
() => rows.filter((r) => r.ai?.status === 'error').length,
|
||||
[rows],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-950">
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
|
||||
style={{ height: TOOLBAR_HEIGHT }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
|
||||
{t('aiTab.title', { defaultValue: 'AI' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="pointer-events-none absolute left-1.5 top-1/2 -translate-y-1/2 text-surface-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('aiTab.filterPlaceholder', { defaultValue: 'Filter by URL…' })}
|
||||
className="h-6 w-44 rounded border border-surface-700 bg-surface-950 pl-6 pr-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterMode)}
|
||||
>
|
||||
<option value="all">{t('aiTab.filterAll', { defaultValue: 'All pages' })}</option>
|
||||
<option value="with-data">{t('aiTab.filterWith', { defaultValue: 'With response' })}</option>
|
||||
<option value="without-data">{t('aiTab.filterWithout', { defaultValue: 'No response' })}</option>
|
||||
<option value="error">{t('aiTab.filterError', { defaultValue: 'Errors only' })}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-[11px] text-surface-400">
|
||||
{t('aiTab.provider', { defaultValue: 'Provider:' })}
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as AiProvider)}
|
||||
disabled={running}
|
||||
>
|
||||
<option value="openai">{PROVIDER_LABEL.openai}</option>
|
||||
<option value="anthropic">{PROVIDER_LABEL.anthropic}</option>
|
||||
<option value="ollama">{PROVIDER_LABEL.ollama}</option>
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={t('aiTab.modelPlaceholder', { defaultValue: 'Model (optional)' })}
|
||||
disabled={running}
|
||||
className="h-6 w-36 rounded border border-surface-700 bg-surface-950 px-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectUntested}
|
||||
disabled={running || rows.length === 0}
|
||||
className="h-6 rounded border border-surface-700 bg-surface-800 px-2 text-[11px] text-surface-200 hover:bg-surface-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t('aiTab.selectUntested', { defaultValue: 'Select untested' })}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{running && progress ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-surface-300">
|
||||
<Loader2 size={12} className="animate-spin text-blue-400" />
|
||||
{t('aiTab.runProgress', {
|
||||
defaultValue: '{{done}} / {{total}}',
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRun}
|
||||
className="h-6 rounded border border-red-700 bg-red-900/40 px-2.5 text-[11px] font-medium text-red-200 hover:bg-red-900/70"
|
||||
>
|
||||
{t('aiTab.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runAi()}
|
||||
disabled={
|
||||
selected.size === 0 ||
|
||||
running ||
|
||||
!providerConfigured ||
|
||||
!promptTemplate.trim()
|
||||
}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded bg-blue-600 px-3 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
<Play size={11} />
|
||||
{t('aiTab.run', {
|
||||
defaultValue: 'Run ({{count}})',
|
||||
count: selected.size,
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration warning */}
|
||||
{!providerConfigured && (
|
||||
<div className="flex items-center gap-2 border-b border-amber-700/40 bg-amber-900/20 px-3 py-1 text-[10px] text-amber-200">
|
||||
<AlertTriangle size={11} />
|
||||
{t('aiTab.notConfigured', {
|
||||
defaultValue:
|
||||
'{{provider}} is not configured — set its credentials in Settings → Integrations to run prompts.',
|
||||
provider: PROVIDER_LABEL[provider],
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt template panel */}
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-b border-surface-800 bg-surface-900/20"
|
||||
style={{ height: PROMPT_PANEL_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-1 text-[10px] text-surface-500">
|
||||
<span className="font-semibold uppercase tracking-wide">
|
||||
{t('aiTab.promptLabel', { defaultValue: 'Prompt template' })}
|
||||
</span>
|
||||
<span>
|
||||
{t('aiTab.promptVars', {
|
||||
defaultValue:
|
||||
'variables: {url} · {title} · {description} · {h1} · {body} (truncated to 2 KB)',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={promptTemplate}
|
||||
onChange={(e) => setPromptTemplate(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="flex-1 resize-none border-0 bg-transparent px-3 pb-2 font-mono text-[11px] leading-snug text-surface-100 outline-none placeholder-surface-600"
|
||||
placeholder={DEFAULT_PROMPT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex shrink-0 select-none items-center border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex w-[30px] items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allLoadedSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selected.size > 0 && !allLoadedSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
disabled={rows.length === 0}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 px-2">{t('aiTab.colUrl', { defaultValue: 'URL' })}</div>
|
||||
<div className="w-[54px] shrink-0 text-center">{t('aiTab.colStatus', { defaultValue: 'Status' })}</div>
|
||||
<div className="w-[380px] shrink-0 px-2">{t('aiTab.colResponse', { defaultValue: 'Response' })}</div>
|
||||
<div className="w-[100px] shrink-0 px-2">{t('aiTab.colModel', { defaultValue: 'Model' })}</div>
|
||||
<div className="w-[88px] shrink-0 text-right px-2">{t('aiTab.colTokens', { defaultValue: 'Tokens' })}</div>
|
||||
<div className="w-[80px] shrink-0 px-2 text-right">{t('aiTab.colFetched', { defaultValue: 'Fetched' })}</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{total === 0
|
||||
? t('aiTab.emptyNoData', {
|
||||
defaultValue:
|
||||
'No internal HTML pages crawled yet — run a crawl, then select pages and Run.',
|
||||
})
|
||||
: t('aiTab.emptyNoMatch', { defaultValue: 'No pages match the current filter.' })}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const row = rows[vRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<AiDataRow
|
||||
row={row}
|
||||
selected={selected.has(row.url)}
|
||||
onToggle={() => toggleRow(row.url)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
>
|
||||
<span>
|
||||
{t('aiTab.summary', {
|
||||
defaultValue:
|
||||
'{{shown}} pages · {{tested}} with response · {{errors}} errors · {{selected}} selected',
|
||||
shown: rows.length.toLocaleString(),
|
||||
tested: testedCount.toLocaleString(),
|
||||
errors: errorCount.toLocaleString(),
|
||||
selected: selected.size.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{running && progress?.currentUrl && (
|
||||
<span className="truncate text-surface-500">
|
||||
{t('aiTab.running', { defaultValue: 'Running: {{url}}', url: progress.currentUrl })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiDataRow({
|
||||
row,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
row: AiRow;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const a = row.ai;
|
||||
const status = row.statusCode;
|
||||
const statusCls =
|
||||
status === null
|
||||
? 'text-surface-600'
|
||||
: status >= 200 && status < 300
|
||||
? 'text-emerald-400'
|
||||
: status >= 300 && status < 400
|
||||
? 'text-blue-400'
|
||||
: 'text-red-400';
|
||||
|
||||
let responseCell: ReactNode;
|
||||
if (!a) {
|
||||
responseCell = <span className="text-surface-600">—</span>;
|
||||
} else if (a.status === 'error') {
|
||||
responseCell = (
|
||||
<span className="text-red-400" title={a.error ?? 'Error'}>
|
||||
{a.error ?? 'Error'}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
responseCell = (
|
||||
<span className="text-surface-200" title={a.response}>
|
||||
{a.response}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className={`flex h-full cursor-pointer items-center text-[11px] ${
|
||||
selected
|
||||
? 'bg-blue-900/30'
|
||||
: 'odd:bg-surface-900/20 hover:bg-surface-800/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-[30px] items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||
{row.url}
|
||||
</div>
|
||||
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
|
||||
{status ?? '—'}
|
||||
</div>
|
||||
<div className="w-[380px] shrink-0 truncate px-2">{responseCell}</div>
|
||||
<div
|
||||
className="w-[100px] shrink-0 truncate px-2 text-surface-400"
|
||||
title={a?.model ?? ''}
|
||||
>
|
||||
{a?.model ?? '—'}
|
||||
</div>
|
||||
<div className="w-[88px] shrink-0 text-right px-2 tabular-nums text-surface-400">
|
||||
{a ? fmtTokens(a) : '—'}
|
||||
</div>
|
||||
<div className="w-[80px] shrink-0 truncate px-2 text-right text-surface-500">
|
||||
{a ? relativeTime(a.fetchedAt) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Search, RefreshCw } from 'lucide-react';
|
||||
import type {
|
||||
Ga4FetchMeta,
|
||||
Ga4Property,
|
||||
Ga4Row,
|
||||
GoogleAuthState,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 26;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const ROW_HEIGHT = 30;
|
||||
const PAGE_SIZE = 2000;
|
||||
const POLL_MS_RUNNING = 5000;
|
||||
|
||||
type FilterMode = 'all' | 'with-data' | 'without-data';
|
||||
type RangeDays = 7 | 28 | 90;
|
||||
type Stage = 'loading' | 'unconfigured' | 'disconnected' | 'ready';
|
||||
|
||||
/** Engagement-rate bands → cell colour. Higher is better. */
|
||||
function engagementClass(v: number): string {
|
||||
if (v <= 0) return 'text-surface-600';
|
||||
if (v >= 0.6) return 'text-emerald-400';
|
||||
if (v >= 0.3) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number): string {
|
||||
if (!seconds || seconds < 0) return '—';
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = Math.round(seconds % 60);
|
||||
if (min === 0) return `${sec}s`;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const min = Math.floor((Date.now() - then) / 60_000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function AnalyticsTab() {
|
||||
const { t } = useTranslation();
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const crawlProgress = useAppStore((s) => s.progress);
|
||||
|
||||
const [stage, setStage] = useState<Stage>('loading');
|
||||
const [authState, setAuthState] = useState<GoogleAuthState | null>(null);
|
||||
const [properties, setProperties] = useState<Ga4Property[]>([]);
|
||||
const [propertyId, setPropertyId] = useState('');
|
||||
const [days, setDays] = useState<RangeDays>(28);
|
||||
const [rows, setRows] = useState<Ga4Row[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [meta, setMeta] = useState<Ga4FetchMeta | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadProperties = useCallback(async () => {
|
||||
const res = await window.freecrawl.ga4ListProperties();
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setProperties(res.properties);
|
||||
setPropertyId((prev) => prev || res.properties[0]?.propertyId || '');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [integrations, auth] = await Promise.all([
|
||||
window.freecrawl.integrationsGetAll(),
|
||||
window.freecrawl.googleAuthStatus('ga4'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAuthState(auth);
|
||||
const configured = integrations['ga4']?.configured ?? false;
|
||||
if (!configured) {
|
||||
setStage('unconfigured');
|
||||
} else if (!auth.connected) {
|
||||
setStage('disconnected');
|
||||
} else {
|
||||
setStage('ready');
|
||||
void loadProperties();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadProperties]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await window.freecrawl.ga4Query({
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
search: search || undefined,
|
||||
filter,
|
||||
});
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
setMeta(res.meta);
|
||||
}, [search, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'ready') return;
|
||||
let cancelled = false;
|
||||
void reload();
|
||||
if (!crawlProgress?.running) return;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) void reload();
|
||||
}, POLL_MS_RUNNING);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [stage, reload, dataVersion, crawlProgress?.running]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 16,
|
||||
getItemKey: (index) => rows[index]?.url ?? `idx-${index}`,
|
||||
});
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setConnecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await window.freecrawl.googleAuthStart('ga4');
|
||||
setAuthState(res.state);
|
||||
if (res.ok && res.state.connected) {
|
||||
setStage('ready');
|
||||
void loadProperties();
|
||||
} else if (res.error) {
|
||||
setError(res.error);
|
||||
}
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [loadProperties]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
const next = await window.freecrawl.googleAuthRevoke('ga4');
|
||||
setAuthState(next);
|
||||
setProperties([]);
|
||||
setPropertyId('');
|
||||
setStage('disconnected');
|
||||
}, []);
|
||||
|
||||
const runFetch = useCallback(async () => {
|
||||
if (!propertyId || fetching) return;
|
||||
const selected = properties.find((p) => p.propertyId === propertyId);
|
||||
setFetching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await window.freecrawl.ga4Fetch({
|
||||
property: propertyId,
|
||||
propertyName: selected?.displayName || propertyId,
|
||||
days,
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
setMeta(res.meta);
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [propertyId, properties, days, fetching, reload]);
|
||||
|
||||
// ── Gate states ──────────────────────────────────────────────────────
|
||||
if (stage === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 text-[12px] text-surface-500">
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
{t('ga4Tab.loading', { defaultValue: 'Loading…' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'unconfigured') {
|
||||
return (
|
||||
<GateMessage
|
||||
title={t('ga4Tab.unconfiguredTitle', {
|
||||
defaultValue: 'Google Analytics 4 is not set up',
|
||||
})}
|
||||
body={t('ga4Tab.unconfiguredBody', {
|
||||
defaultValue:
|
||||
'Open Settings → Integrations → Google Analytics 4 and paste your own Google Cloud OAuth client ID + secret (Desktop app type). Then come back here to connect.',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'disconnected') {
|
||||
return (
|
||||
<GateMessage
|
||||
title={t('ga4Tab.disconnectedTitle', {
|
||||
defaultValue: 'Connect your Analytics 4 account',
|
||||
})}
|
||||
body={t('ga4Tab.disconnectedBody', {
|
||||
defaultValue:
|
||||
'Sign in with the Google account that owns the GA4 property. A browser window opens for consent — nothing leaves your machine except the standard Google OAuth exchange.',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void connect()}
|
||||
disabled={connecting}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded bg-blue-600 px-4 py-1.5 text-[12px] font-medium text-white hover:bg-blue-500 disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
{connecting && <Loader2 size={13} className="animate-spin" />}
|
||||
{t('ga4Tab.connect', { defaultValue: 'Connect with Google' })}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-3 max-w-md text-[11px] text-red-400">{error}</div>
|
||||
)}
|
||||
</GateMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-950">
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
|
||||
style={{ height: TOOLBAR_HEIGHT }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
|
||||
{t('ga4Tab.title', { defaultValue: 'Google Analytics 4' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="pointer-events-none absolute left-1.5 top-1/2 -translate-y-1/2 text-surface-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('ga4Tab.filterPlaceholder', {
|
||||
defaultValue: 'Filter by URL…',
|
||||
})}
|
||||
className="h-6 w-48 rounded border border-surface-700 bg-surface-950 pl-6 pr-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterMode)}
|
||||
>
|
||||
<option value="all">
|
||||
{t('ga4Tab.filterAll', { defaultValue: 'All pages' })}
|
||||
</option>
|
||||
<option value="with-data">
|
||||
{t('ga4Tab.filterWith', { defaultValue: 'With GA4 data' })}
|
||||
</option>
|
||||
<option value="without-data">
|
||||
{t('ga4Tab.filterWithout', { defaultValue: 'No GA4 data' })}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<select
|
||||
className="h-6 max-w-[260px] rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={propertyId}
|
||||
onChange={(e) => setPropertyId(e.target.value)}
|
||||
disabled={fetching || properties.length === 0}
|
||||
title={propertyId}
|
||||
>
|
||||
{properties.length === 0 && (
|
||||
<option value="">
|
||||
{t('ga4Tab.noProperties', { defaultValue: 'No properties' })}
|
||||
</option>
|
||||
)}
|
||||
{properties.map((p) => (
|
||||
<option key={p.propertyId} value={p.propertyId}>
|
||||
{p.displayName}
|
||||
{p.accountName ? ` — ${p.accountName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value) as RangeDays)}
|
||||
disabled={fetching}
|
||||
>
|
||||
<option value={7}>{t('ga4Tab.days7', { defaultValue: 'Last 7 days' })}</option>
|
||||
<option value={28}>{t('ga4Tab.days28', { defaultValue: 'Last 28 days' })}</option>
|
||||
<option value={90}>{t('ga4Tab.days90', { defaultValue: 'Last 90 days' })}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runFetch()}
|
||||
disabled={fetching || !propertyId}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded bg-blue-600 px-2.5 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
{fetching ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
{t('ga4Tab.fetch', { defaultValue: 'Fetch' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context line — connection + last pull */}
|
||||
<div className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/20 px-3 py-1 text-[10px] text-surface-500">
|
||||
<span>
|
||||
{t('ga4Tab.connectedAs', {
|
||||
defaultValue: 'Connected as {{email}}',
|
||||
email: authState?.email ?? 'Google account',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void disconnect()}
|
||||
className="text-surface-500 underline decoration-dotted hover:text-surface-300"
|
||||
>
|
||||
{t('ga4Tab.disconnect', { defaultValue: 'Disconnect' })}
|
||||
</button>
|
||||
{meta && (
|
||||
<span className="ml-auto">
|
||||
{t('ga4Tab.metaLine', {
|
||||
defaultValue:
|
||||
'{{property}} · {{start}} → {{end}} · {{rows}} pages · fetched {{ago}}',
|
||||
property: meta.propertyName,
|
||||
start: meta.startDate,
|
||||
end: meta.endDate,
|
||||
rows: meta.rowCount.toLocaleString(),
|
||||
ago: relativeTime(meta.fetchedAt),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-700/40 bg-red-900/20 px-3 py-1 text-[10px] text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex shrink-0 select-none items-center border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex-1 px-2">{t('ga4Tab.colUrl', { defaultValue: 'URL' })}</div>
|
||||
<div className="w-[54px] shrink-0 text-center">{t('ga4Tab.colStatus', { defaultValue: 'Status' })}</div>
|
||||
<div className="w-[72px] shrink-0 text-right">{t('ga4Tab.colSessions', { defaultValue: 'Sessions' })}</div>
|
||||
<div className="w-[72px] shrink-0 text-right">{t('ga4Tab.colUsers', { defaultValue: 'Users' })}</div>
|
||||
<div className="w-[84px] shrink-0 text-right">{t('ga4Tab.colViews', { defaultValue: 'Pageviews' })}</div>
|
||||
<div className="w-[80px] shrink-0 text-right">{t('ga4Tab.colEng', { defaultValue: 'Engagement' })}</div>
|
||||
<div className="w-[80px] shrink-0 text-right">{t('ga4Tab.colDuration', { defaultValue: 'Avg duration' })}</div>
|
||||
<div className="w-[88px] shrink-0 px-2 text-right">{t('ga4Tab.colFetched', { defaultValue: 'Fetched' })}</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{total === 0
|
||||
? t('ga4Tab.emptyNoData', {
|
||||
defaultValue:
|
||||
'No internal HTML pages crawled yet — run a crawl, then fetch GA4 data.',
|
||||
})
|
||||
: t('ga4Tab.emptyNoMatch', {
|
||||
defaultValue: 'No pages match the current filter.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const row = rows[vRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Ga4DataRow row={row} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
>
|
||||
<span>
|
||||
{t('ga4Tab.summary', {
|
||||
defaultValue: '{{shown}} pages',
|
||||
shown: rows.length.toLocaleString(),
|
||||
})}
|
||||
{total > rows.length &&
|
||||
' ' +
|
||||
t('ga4Tab.firstN', {
|
||||
defaultValue: '(first {{n}})',
|
||||
n: PAGE_SIZE.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{fetching && (
|
||||
<span className="flex items-center gap-1.5 text-surface-500">
|
||||
<Loader2 size={11} className="animate-spin text-blue-400" />
|
||||
{t('ga4Tab.fetching', { defaultValue: 'Fetching from GA4…' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ga4DataRow({ row }: { row: Ga4Row }) {
|
||||
const a = row.ga4;
|
||||
const status = row.statusCode;
|
||||
const statusCls =
|
||||
status === null
|
||||
? 'text-surface-600'
|
||||
: status >= 200 && status < 300
|
||||
? 'text-emerald-400'
|
||||
: status >= 300 && status < 400
|
||||
? 'text-blue-400'
|
||||
: 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center text-[11px] odd:bg-surface-900/20 hover:bg-surface-800/40">
|
||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||
{row.url}
|
||||
</div>
|
||||
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
|
||||
{status ?? '—'}
|
||||
</div>
|
||||
<div className="w-[72px] shrink-0 text-right tabular-nums text-surface-200">
|
||||
{a ? a.sessions.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[72px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{a ? a.users.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[84px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{a ? a.pageviews.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div
|
||||
className={`w-[80px] shrink-0 text-right tabular-nums ${
|
||||
a ? engagementClass(a.engagementRate) : 'text-surface-600'
|
||||
}`}
|
||||
>
|
||||
{a ? `${(a.engagementRate * 100).toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className="w-[80px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{a ? fmtDuration(a.avgSessionDuration) : '—'}
|
||||
</div>
|
||||
<div className="w-[88px] shrink-0 truncate px-2 text-right text-surface-500">
|
||||
{a ? relativeTime(a.fetchedAt) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GateMessage({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-1.5 text-[13px] font-semibold text-surface-200">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[11px] leading-relaxed text-surface-500">{body}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ImageRow } from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
@@ -29,6 +30,7 @@ export function ImagesTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortKey>('occurrences');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Missing-alt filter is driven by the Overview sidebar's Issues section —
|
||||
@@ -81,6 +83,22 @@ export function ImagesTab() {
|
||||
getItemKey: (index) => sorted[index]?.id ?? index,
|
||||
});
|
||||
|
||||
// Export every image row (not just the loaded page) to CSV. The
|
||||
// export honours the current search box + missing-alt sidebar filter
|
||||
// so what the user sees is what they get.
|
||||
const handleExport = async () => {
|
||||
if (total === 0 || exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await window.freecrawl.exportImages({
|
||||
missingAltOnly,
|
||||
search: search || undefined,
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortBy === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||
else {
|
||||
@@ -158,6 +176,25 @@ export function ImagesTab() {
|
||||
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('imagesTab.imagesUnit', { defaultValue: 'images' })}
|
||||
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: sorted.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">
|
||||
|
||||
@@ -76,6 +76,7 @@ export function PageSpeedTab() {
|
||||
const { t } = useTranslation();
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const crawlProgress = useAppStore((s) => s.progress);
|
||||
const setSettingsOpen = useAppStore((s) => s.setSettingsOpen);
|
||||
|
||||
const [rows, setRows] = useState<PagespeedRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -175,7 +176,7 @@ export function PageSpeedTab() {
|
||||
|
||||
const runAudit = useCallback(async () => {
|
||||
const urls = [...selected];
|
||||
if (urls.length === 0 || running) return;
|
||||
if (urls.length === 0 || running || !hasApiKey) return;
|
||||
if (
|
||||
auditCount > CONFIRM_THRESHOLD &&
|
||||
!window.confirm(
|
||||
@@ -300,7 +301,15 @@ export function PageSpeedTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runAudit()}
|
||||
disabled={selected.size === 0 || running}
|
||||
disabled={selected.size === 0 || running || !hasApiKey}
|
||||
title={
|
||||
!hasApiKey
|
||||
? t('pagespeedTab.runDisabledNoKey', {
|
||||
defaultValue:
|
||||
'Add a free PageSpeed Insights API key in Settings → Integrations to enable audits.',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className="h-6 rounded bg-blue-600 px-3 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
{t('pagespeedTab.run', {
|
||||
@@ -312,13 +321,26 @@ export function PageSpeedTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyless-mode banner */}
|
||||
{/* No-key gate. PSI v5's keyless shared quota is 0/day (Google
|
||||
decision, summer 2025) so audits CANNOT run without a key.
|
||||
Banner doubles as a CTA — clicking opens Settings to the
|
||||
Integrations panel pre-focused on PageSpeed Insights. */}
|
||||
{!hasApiKey && (
|
||||
<div className="border-b border-amber-700/40 bg-amber-900/20 px-3 py-1 text-[10px] text-amber-200">
|
||||
{t('pagespeedTab.noKeyBanner', {
|
||||
defaultValue:
|
||||
'No PageSpeed API key — audits run keyless at a low rate limit. Add a free key in Settings → Integrations for faster runs.',
|
||||
})}
|
||||
<div className="flex items-center gap-2 border-b border-amber-700/50 bg-amber-900/25 px-3 py-1.5 text-[11px] text-amber-100">
|
||||
<span className="font-semibold">⚠</span>
|
||||
<span className="flex-1">
|
||||
{t('pagespeedTab.noKeyGate', {
|
||||
defaultValue:
|
||||
'PageSpeed Insights needs a free Google API key — keyless audits no longer work (Google quota: 0/day).',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="rounded border border-amber-500/60 bg-amber-700/40 px-2 py-0.5 text-[10px] font-medium text-amber-50 hover:bg-amber-700/70"
|
||||
>
|
||||
{t('pagespeedTab.addKeyCta', { defaultValue: 'Add API Key…' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -479,6 +501,24 @@ function MetricCell({
|
||||
);
|
||||
}
|
||||
|
||||
/** Short, scannable label derived from a PSI failure string. Keeps the
|
||||
* on-screen real estate small while letting the user spot the failure
|
||||
* class at a glance. The full message stays in the cell's `title`. */
|
||||
function classifyError(message: string | null | undefined): string {
|
||||
if (!message) return 'ERR';
|
||||
const m = message.toLowerCase();
|
||||
if (/quota|resource_exhausted/.test(m)) return 'QUOTA';
|
||||
if (/rate.?limit|too many requests|\b429\b/.test(m)) return 'RATE';
|
||||
if (/timed?\s*out|timeout/.test(m)) return 'TIMEOUT';
|
||||
if (/abort/.test(m)) return 'ABORT';
|
||||
if (/lighthouse/.test(m)) return 'LH';
|
||||
if (/network|fetch failed|enotfound|econnrefused|econnreset/.test(m))
|
||||
return 'NET';
|
||||
if (/\b4\d\d\b/.test(m)) return 'HTTP-4xx';
|
||||
if (/\b5\d\d\b/.test(m)) return 'HTTP-5xx';
|
||||
return 'ERR';
|
||||
}
|
||||
|
||||
function StrategyCells({ m }: { m: PagespeedMetrics | null }) {
|
||||
if (!m) {
|
||||
return (
|
||||
@@ -490,12 +530,14 @@ function StrategyCells({ m }: { m: PagespeedMetrics | null }) {
|
||||
);
|
||||
}
|
||||
if (m.status === 'error') {
|
||||
const label = classifyError(m.error);
|
||||
return (
|
||||
<div
|
||||
className="flex w-[162px] shrink-0 items-center justify-center text-red-400"
|
||||
className="flex w-[162px] shrink-0 cursor-help items-center justify-center gap-1 text-red-400"
|
||||
title={m.error ?? 'Audit failed'}
|
||||
>
|
||||
ERR
|
||||
<span className="font-semibold">{label}</span>
|
||||
<span className="text-[9px] text-red-300/70">ⓘ</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Search, RefreshCw } from 'lucide-react';
|
||||
import type {
|
||||
GoogleAuthState,
|
||||
GscFetchMeta,
|
||||
GscInspectProgress,
|
||||
GscRow,
|
||||
GscSite,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 26;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const ROW_HEIGHT = 30;
|
||||
const PAGE_SIZE = 2000;
|
||||
const POLL_MS_RUNNING = 5000;
|
||||
/** GSC URL Inspection daily quota is 2 000 per property — batch in
|
||||
* small enough chunks that one click is reversible. */
|
||||
const INSPECT_BATCH = 100;
|
||||
|
||||
type FilterMode = 'all' | 'with-data' | 'without-data';
|
||||
type RangeDays = 7 | 28 | 90;
|
||||
/** What the tab is currently showing — drives the three-way render. */
|
||||
type Stage = 'loading' | 'unconfigured' | 'disconnected' | 'ready';
|
||||
|
||||
/** Average-position bands → cell colour (lower is better). */
|
||||
function positionClass(v: number): string {
|
||||
if (v <= 0) return 'text-surface-600';
|
||||
if (v <= 10) return 'text-emerald-400';
|
||||
if (v <= 20) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const min = Math.floor((Date.now() - then) / 60_000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function SearchConsoleTab() {
|
||||
const { t } = useTranslation();
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const crawlProgress = useAppStore((s) => s.progress);
|
||||
|
||||
const [stage, setStage] = useState<Stage>('loading');
|
||||
const [authState, setAuthState] = useState<GoogleAuthState | null>(null);
|
||||
const [sites, setSites] = useState<GscSite[]>([]);
|
||||
const [property, setProperty] = useState('');
|
||||
const [days, setDays] = useState<RangeDays>(28);
|
||||
const [rows, setRows] = useState<GscRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [meta, setMeta] = useState<GscFetchMeta | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
const [inspectProgress, setInspectProgress] = useState<GscInspectProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadSites = useCallback(async () => {
|
||||
const res = await window.freecrawl.gscListSites();
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSites(res.sites);
|
||||
setProperty((prev) => prev || res.sites[0]?.siteUrl || '');
|
||||
}, []);
|
||||
|
||||
// Resolve the tab's stage on mount: needs OAuth credentials, then a
|
||||
// connected account, before the data view is meaningful.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [integrations, auth] = await Promise.all([
|
||||
window.freecrawl.integrationsGetAll(),
|
||||
window.freecrawl.googleAuthStatus('gsc'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAuthState(auth);
|
||||
const configured = integrations['gsc']?.configured ?? false;
|
||||
if (!configured) {
|
||||
setStage('unconfigured');
|
||||
} else if (!auth.connected) {
|
||||
setStage('disconnected');
|
||||
} else {
|
||||
setStage('ready');
|
||||
void loadSites();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadSites]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await window.freecrawl.gscQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
search: search || undefined,
|
||||
filter,
|
||||
});
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
setMeta(res.meta);
|
||||
}, [search, filter]);
|
||||
|
||||
// Candidate list — only while the data view is showing.
|
||||
useEffect(() => {
|
||||
if (stage !== 'ready') return;
|
||||
let cancelled = false;
|
||||
void reload();
|
||||
if (!crawlProgress?.running) return;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) void reload();
|
||||
}, POLL_MS_RUNNING);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [stage, reload, dataVersion, crawlProgress?.running]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 16,
|
||||
getItemKey: (index) => rows[index]?.url ?? `idx-${index}`,
|
||||
});
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setConnecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await window.freecrawl.googleAuthStart('gsc');
|
||||
setAuthState(res.state);
|
||||
if (res.ok && res.state.connected) {
|
||||
setStage('ready');
|
||||
void loadSites();
|
||||
} else if (res.error) {
|
||||
setError(res.error);
|
||||
}
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [loadSites]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
const next = await window.freecrawl.googleAuthRevoke('gsc');
|
||||
setAuthState(next);
|
||||
setSites([]);
|
||||
setProperty('');
|
||||
setStage('disconnected');
|
||||
}, []);
|
||||
|
||||
const runFetch = useCallback(async () => {
|
||||
if (!property || fetching) return;
|
||||
setFetching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await window.freecrawl.gscFetch({ property, days });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
setMeta(res.meta);
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [property, days, fetching, reload]);
|
||||
|
||||
const runInspect = useCallback(async () => {
|
||||
if (!property || inspecting) return;
|
||||
const urls = rows.slice(0, INSPECT_BATCH).map((r) => r.url);
|
||||
if (urls.length === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
t('gscTab.confirmInspect', {
|
||||
defaultValue:
|
||||
'This uses {{count}} of the {{property}} daily 2 000-URL Inspection quota. Continue?',
|
||||
count: urls.length,
|
||||
property,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setInspecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await window.freecrawl.gscInspectRun({ property, urls });
|
||||
} finally {
|
||||
setInspecting(false);
|
||||
setInspectProgress(null);
|
||||
void reload();
|
||||
}
|
||||
}, [property, inspecting, rows, t, reload]);
|
||||
|
||||
// Live URL Inspection progress.
|
||||
useEffect(() => {
|
||||
const off = window.freecrawl.onGscInspectProgress((p) => {
|
||||
setInspectProgress(p.running ? p : null);
|
||||
if (!p.currentUrl) void reload();
|
||||
});
|
||||
return off;
|
||||
}, [reload]);
|
||||
|
||||
// ── Gate states ──────────────────────────────────────────────────────
|
||||
if (stage === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 text-[12px] text-surface-500">
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
{t('gscTab.loading', { defaultValue: 'Loading…' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'unconfigured') {
|
||||
return (
|
||||
<GateMessage
|
||||
title={t('gscTab.unconfiguredTitle', {
|
||||
defaultValue: 'Google Search Console is not set up',
|
||||
})}
|
||||
body={t('gscTab.unconfiguredBody', {
|
||||
defaultValue:
|
||||
'Open Settings → Integrations → Google Search Console and paste your own Google Cloud OAuth client ID + secret (Desktop app type). Then come back here to connect.',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'disconnected') {
|
||||
return (
|
||||
<GateMessage
|
||||
title={t('gscTab.disconnectedTitle', {
|
||||
defaultValue: 'Connect your Search Console account',
|
||||
})}
|
||||
body={t('gscTab.disconnectedBody', {
|
||||
defaultValue:
|
||||
'Sign in with the Google account that owns the Search Console property. A browser window opens for consent — nothing leaves your machine except the standard Google OAuth exchange.',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void connect()}
|
||||
disabled={connecting}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded bg-blue-600 px-4 py-1.5 text-[12px] font-medium text-white hover:bg-blue-500 disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
{connecting && <Loader2 size={13} className="animate-spin" />}
|
||||
{t('gscTab.connect', { defaultValue: 'Connect with Google' })}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-3 max-w-md text-[11px] text-red-400">{error}</div>
|
||||
)}
|
||||
</GateMessage>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Data view ────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-950">
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
|
||||
style={{ height: TOOLBAR_HEIGHT }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
|
||||
{t('gscTab.title', { defaultValue: 'Search Console' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="pointer-events-none absolute left-1.5 top-1/2 -translate-y-1/2 text-surface-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('gscTab.filterPlaceholder', {
|
||||
defaultValue: 'Filter by URL…',
|
||||
})}
|
||||
className="h-6 w-48 rounded border border-surface-700 bg-surface-950 pl-6 pr-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterMode)}
|
||||
>
|
||||
<option value="all">
|
||||
{t('gscTab.filterAll', { defaultValue: 'All pages' })}
|
||||
</option>
|
||||
<option value="with-data">
|
||||
{t('gscTab.filterWith', { defaultValue: 'With GSC data' })}
|
||||
</option>
|
||||
<option value="without-data">
|
||||
{t('gscTab.filterWithout', { defaultValue: 'No GSC data' })}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<select
|
||||
className="h-6 max-w-[200px] rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={property}
|
||||
onChange={(e) => setProperty(e.target.value)}
|
||||
disabled={fetching || sites.length === 0}
|
||||
title={property}
|
||||
>
|
||||
{sites.length === 0 && (
|
||||
<option value="">
|
||||
{t('gscTab.noProperties', { defaultValue: 'No properties' })}
|
||||
</option>
|
||||
)}
|
||||
{sites.map((s) => (
|
||||
<option key={s.siteUrl} value={s.siteUrl}>
|
||||
{s.siteUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value) as RangeDays)}
|
||||
disabled={fetching}
|
||||
>
|
||||
<option value={7}>{t('gscTab.days7', { defaultValue: 'Last 7 days' })}</option>
|
||||
<option value={28}>{t('gscTab.days28', { defaultValue: 'Last 28 days' })}</option>
|
||||
<option value={90}>{t('gscTab.days90', { defaultValue: 'Last 90 days' })}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runFetch()}
|
||||
disabled={fetching || inspecting || !property}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded bg-blue-600 px-2.5 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
{fetching ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
{t('gscTab.fetch', { defaultValue: 'Fetch' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runInspect()}
|
||||
disabled={inspecting || fetching || !property || rows.length === 0}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded border border-surface-700 bg-surface-800 px-2.5 text-[11px] font-medium text-surface-200 hover:bg-surface-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={t('gscTab.inspectTooltip', {
|
||||
defaultValue:
|
||||
'Run URL Inspection on the top {{n}} loaded pages (quota: 2 000/day per property)',
|
||||
n: INSPECT_BATCH,
|
||||
})}
|
||||
>
|
||||
{inspecting && <Loader2 size={12} className="animate-spin" />}
|
||||
{t('gscTab.inspect', {
|
||||
defaultValue: 'Inspect (top {{n}})',
|
||||
n: INSPECT_BATCH,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inspecting && inspectProgress && (
|
||||
<div className="flex items-center gap-2 border-b border-blue-700/40 bg-blue-900/20 px-3 py-1 text-[10px] text-blue-200">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('gscTab.inspecting', {
|
||||
defaultValue: 'Inspecting {{done}} / {{total}} — {{url}}',
|
||||
done: inspectProgress.done,
|
||||
total: inspectProgress.total,
|
||||
url: inspectProgress.currentUrl ?? '…',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context line — connection + last pull */}
|
||||
<div className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/20 px-3 py-1 text-[10px] text-surface-500">
|
||||
<span>
|
||||
{t('gscTab.connectedAs', {
|
||||
defaultValue: 'Connected as {{email}}',
|
||||
email: authState?.email ?? 'Google account',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void disconnect()}
|
||||
className="text-surface-500 underline decoration-dotted hover:text-surface-300"
|
||||
>
|
||||
{t('gscTab.disconnect', { defaultValue: 'Disconnect' })}
|
||||
</button>
|
||||
{meta && (
|
||||
<span className="ml-auto">
|
||||
{t('gscTab.metaLine', {
|
||||
defaultValue:
|
||||
'{{property}} · {{start}} → {{end}} · {{rows}} pages · fetched {{ago}}',
|
||||
property: meta.property,
|
||||
start: meta.startDate,
|
||||
end: meta.endDate,
|
||||
rows: meta.rowCount.toLocaleString(),
|
||||
ago: relativeTime(meta.fetchedAt),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-700/40 bg-red-900/20 px-3 py-1 text-[10px] text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex shrink-0 select-none items-center border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex-1 px-2">{t('gscTab.colUrl', { defaultValue: 'URL' })}</div>
|
||||
<div className="w-[54px] shrink-0 text-center">{t('gscTab.colStatus', { defaultValue: 'Status' })}</div>
|
||||
<div className="w-[64px] shrink-0 text-center">{t('gscTab.colVerdict', { defaultValue: 'Index' })}</div>
|
||||
<div className="w-[72px] shrink-0 text-right">{t('gscTab.colClicks', { defaultValue: 'Clicks' })}</div>
|
||||
<div className="w-[92px] shrink-0 text-right">{t('gscTab.colImpr', { defaultValue: 'Impressions' })}</div>
|
||||
<div className="w-[64px] shrink-0 text-right">{t('gscTab.colCtr', { defaultValue: 'CTR' })}</div>
|
||||
<div className="w-[72px] shrink-0 text-right">{t('gscTab.colPos', { defaultValue: 'Position' })}</div>
|
||||
<div className="w-[88px] shrink-0 px-2 text-right">{t('gscTab.colFetched', { defaultValue: 'Fetched' })}</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{total === 0
|
||||
? t('gscTab.emptyNoData', {
|
||||
defaultValue:
|
||||
'No internal HTML pages crawled yet — run a crawl, then fetch Search Console data.',
|
||||
})
|
||||
: t('gscTab.emptyNoMatch', {
|
||||
defaultValue: 'No pages match the current filter.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const row = rows[vRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<GscDataRow row={row} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
>
|
||||
<span>
|
||||
{t('gscTab.summary', {
|
||||
defaultValue: '{{shown}} pages',
|
||||
shown: rows.length.toLocaleString(),
|
||||
})}
|
||||
{total > rows.length &&
|
||||
' ' +
|
||||
t('gscTab.firstN', {
|
||||
defaultValue: '(first {{n}})',
|
||||
n: PAGE_SIZE.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{fetching && (
|
||||
<span className="flex items-center gap-1.5 text-surface-500">
|
||||
<Loader2 size={11} className="animate-spin text-blue-400" />
|
||||
{t('gscTab.fetching', { defaultValue: 'Fetching from Search Console…' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GscDataRow({ row }: { row: GscRow }) {
|
||||
const g = row.gsc;
|
||||
const status = row.statusCode;
|
||||
const statusCls =
|
||||
status === null
|
||||
? 'text-surface-600'
|
||||
: status >= 200 && status < 300
|
||||
? 'text-emerald-400'
|
||||
: status >= 300 && status < 400
|
||||
? 'text-blue-400'
|
||||
: 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center text-[11px] odd:bg-surface-900/20 hover:bg-surface-800/40">
|
||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||
{row.url}
|
||||
</div>
|
||||
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
|
||||
{status ?? '—'}
|
||||
</div>
|
||||
<div className="w-[64px] shrink-0 text-center">
|
||||
<VerdictBadge verdict={row.inspection?.verdict ?? null} title={row.inspection?.coverageState ?? null} />
|
||||
</div>
|
||||
<div className="w-[72px] shrink-0 text-right tabular-nums text-surface-200">
|
||||
{g ? g.clicks.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[92px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{g ? g.impressions.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[64px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{g ? `${(g.ctr * 100).toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div
|
||||
className={`w-[72px] shrink-0 text-right tabular-nums ${
|
||||
g ? positionClass(g.position) : 'text-surface-600'
|
||||
}`}
|
||||
>
|
||||
{g ? g.position.toFixed(1) : '—'}
|
||||
</div>
|
||||
<div className="w-[88px] shrink-0 truncate px-2 text-right text-surface-500">
|
||||
{g ? relativeTime(g.fetchedAt) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictBadge({
|
||||
verdict,
|
||||
title,
|
||||
}: {
|
||||
verdict: string | null;
|
||||
title: string | null;
|
||||
}) {
|
||||
if (!verdict) return <span className="text-surface-700">—</span>;
|
||||
const cls =
|
||||
verdict === 'PASS'
|
||||
? 'bg-emerald-900/40 text-emerald-300'
|
||||
: verdict === 'PARTIAL' || verdict === 'NEUTRAL'
|
||||
? 'bg-amber-900/40 text-amber-300'
|
||||
: 'bg-red-900/40 text-red-300';
|
||||
const short =
|
||||
verdict === 'PASS'
|
||||
? 'PASS'
|
||||
: verdict === 'PARTIAL'
|
||||
? 'PART'
|
||||
: verdict === 'NEUTRAL'
|
||||
? 'NEU'
|
||||
: verdict === 'FAIL'
|
||||
? 'FAIL'
|
||||
: verdict.slice(0, 4);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded px-1.5 text-[10px] font-semibold ${cls}`}
|
||||
title={title ?? verdict}
|
||||
>
|
||||
{short}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GateMessage({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-1.5 text-[13px] font-semibold text-surface-200">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[11px] leading-relaxed text-surface-500">{body}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Search, Play, AlertTriangle } from 'lucide-react';
|
||||
import type {
|
||||
AhrefsMetrics,
|
||||
IntegrationsState,
|
||||
MajesticMetrics,
|
||||
MozMetrics,
|
||||
SemrushMetrics,
|
||||
SeoMetrics,
|
||||
SeoProgress,
|
||||
SeoProvider,
|
||||
SeoRow,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 26;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const ROW_HEIGHT = 30;
|
||||
const PAGE_SIZE = 2000;
|
||||
const POLL_MS_RUNNING = 5000;
|
||||
const CONFIRM_THRESHOLD = 30;
|
||||
|
||||
const PROVIDER_LABEL: Record<SeoProvider, string> = {
|
||||
ahrefs: 'Ahrefs',
|
||||
majestic: 'Majestic',
|
||||
moz: 'Moz',
|
||||
semrush: 'Semrush',
|
||||
};
|
||||
|
||||
type FilterMode = 'all' | 'with-data' | 'without-data' | 'error';
|
||||
|
||||
/** Per-provider column headers + a value extractor for each metric. */
|
||||
const PROVIDER_COLUMNS: Record<
|
||||
SeoProvider,
|
||||
{ label: string; key: string }[]
|
||||
> = {
|
||||
ahrefs: [
|
||||
{ label: 'DR', key: 'domainRating' },
|
||||
{ label: 'UR', key: 'urlRating' },
|
||||
{ label: 'Backlinks', key: 'backlinks' },
|
||||
{ label: 'Ref Domains', key: 'refDomains' },
|
||||
],
|
||||
majestic: [
|
||||
{ label: 'TF', key: 'trustFlow' },
|
||||
{ label: 'CF', key: 'citationFlow' },
|
||||
{ label: 'Ext Backlinks', key: 'externalBacklinks' },
|
||||
{ label: 'Ref Domains', key: 'refDomains' },
|
||||
],
|
||||
moz: [
|
||||
{ label: 'DA', key: 'domainAuthority' },
|
||||
{ label: 'PA', key: 'pageAuthority' },
|
||||
{ label: 'Spam', key: 'spamScore' },
|
||||
{ label: 'Linking Domains', key: 'linkingDomains' },
|
||||
],
|
||||
semrush: [
|
||||
{ label: 'Org KW', key: 'organicKeywords' },
|
||||
{ label: 'Org Traffic', key: 'organicTraffic' },
|
||||
{ label: 'Org Cost ($)', key: 'organicCost' },
|
||||
{ label: 'Paid KW', key: 'adwordsKeywords' },
|
||||
],
|
||||
};
|
||||
|
||||
function fmtNum(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '—';
|
||||
if (Math.abs(v) >= 1000) return v.toLocaleString();
|
||||
return String(Math.round(v * 100) / 100);
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const min = Math.floor((Date.now() - then) / 60_000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
function metricValue(provider: SeoProvider, metrics: SeoMetrics | null, key: string): number | null {
|
||||
if (!metrics) return null;
|
||||
// Each provider's metrics object only carries its own keys — narrow by
|
||||
// provider so TS knows the shape.
|
||||
switch (provider) {
|
||||
case 'ahrefs':
|
||||
return (metrics as AhrefsMetrics)[key as keyof AhrefsMetrics] ?? null;
|
||||
case 'majestic':
|
||||
return (metrics as MajesticMetrics)[key as keyof MajesticMetrics] ?? null;
|
||||
case 'moz':
|
||||
return (metrics as MozMetrics)[key as keyof MozMetrics] ?? null;
|
||||
case 'semrush':
|
||||
return (metrics as SemrushMetrics)[key as keyof SemrushMetrics] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SeoTab() {
|
||||
const { t } = useTranslation();
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
const crawlProgress = useAppStore((s) => s.progress);
|
||||
|
||||
const [provider, setProvider] = useState<SeoProvider>('ahrefs');
|
||||
const [rows, setRows] = useState<SeoRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<SeoProgress | null>(null);
|
||||
const [integrations, setIntegrations] = useState<IntegrationsState | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await window.freecrawl.seoQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
search: search || undefined,
|
||||
provider,
|
||||
filter,
|
||||
});
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
}, [search, filter, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void reload();
|
||||
if (!crawlProgress?.running) return;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) void reload();
|
||||
}, POLL_MS_RUNNING);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [reload, dataVersion, crawlProgress?.running]);
|
||||
|
||||
useEffect(() => {
|
||||
void window.freecrawl.integrationsGetAll().then(setIntegrations);
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.freecrawl.onSeoProgress((p) => {
|
||||
setProgress(p.running ? p : null);
|
||||
if (!p.currentUrl) void reload();
|
||||
});
|
||||
return off;
|
||||
}, [reload]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 16,
|
||||
getItemKey: (i) => rows[i]?.url ?? `idx-${i}`,
|
||||
});
|
||||
|
||||
const allLoadedSelected =
|
||||
rows.length > 0 && rows.every((r) => selected.has(r.url));
|
||||
|
||||
const toggleRow = useCallback((url: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) next.delete(url);
|
||||
else next.add(url);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (rows.length > 0 && rows.every((r) => prev.has(r.url))) return new Set();
|
||||
return new Set(rows.map((r) => r.url));
|
||||
});
|
||||
}, [rows]);
|
||||
const selectUntested = useCallback(() => {
|
||||
setSelected(new Set(rows.filter((r) => !r.seo).map((r) => r.url)));
|
||||
}, [rows]);
|
||||
|
||||
const providerConfigured = integrations?.[provider]?.configured ?? false;
|
||||
|
||||
const runSeo = useCallback(async () => {
|
||||
const urls = [...selected];
|
||||
if (urls.length === 0 || running) return;
|
||||
if (!providerConfigured) return;
|
||||
if (
|
||||
urls.length > CONFIRM_THRESHOLD &&
|
||||
!window.confirm(
|
||||
t('seoTab.confirmRun', {
|
||||
defaultValue:
|
||||
'This will fetch {{count}} URLs from {{provider}}. Continue?',
|
||||
count: urls.length,
|
||||
provider: PROVIDER_LABEL[provider],
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setRunning(true);
|
||||
setProgress({ done: 0, total: urls.length, currentUrl: null, running: true });
|
||||
try {
|
||||
await window.freecrawl.seoRun({ provider, urls });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(null);
|
||||
void reload();
|
||||
}
|
||||
}, [selected, running, providerConfigured, provider, t, reload]);
|
||||
|
||||
const cancelRun = useCallback(() => {
|
||||
void window.freecrawl.seoCancel();
|
||||
}, []);
|
||||
|
||||
const cols = PROVIDER_COLUMNS[provider];
|
||||
const testedCount = useMemo(() => rows.filter((r) => r.seo).length, [rows]);
|
||||
const errorCount = useMemo(
|
||||
() => rows.filter((r) => r.seo?.status === 'error').length,
|
||||
[rows],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-950">
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
|
||||
style={{ height: TOOLBAR_HEIGHT }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
|
||||
{t('seoTab.title', { defaultValue: 'SEO Authority' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="pointer-events-none absolute left-1.5 top-1/2 -translate-y-1/2 text-surface-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('seoTab.filterPlaceholder', { defaultValue: 'Filter by URL…' })}
|
||||
className="h-6 w-48 rounded border border-surface-700 bg-surface-950 pl-6 pr-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterMode)}
|
||||
>
|
||||
<option value="all">{t('seoTab.filterAll', { defaultValue: 'All pages' })}</option>
|
||||
<option value="with-data">{t('seoTab.filterWith', { defaultValue: 'With data' })}</option>
|
||||
<option value="without-data">{t('seoTab.filterWithout', { defaultValue: 'No data' })}</option>
|
||||
<option value="error">{t('seoTab.filterError', { defaultValue: 'Errors only' })}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-[11px] text-surface-400">
|
||||
{t('seoTab.provider', { defaultValue: 'Provider:' })}
|
||||
<select
|
||||
className="h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as SeoProvider)}
|
||||
disabled={running}
|
||||
>
|
||||
<option value="ahrefs">{PROVIDER_LABEL.ahrefs}</option>
|
||||
<option value="majestic">{PROVIDER_LABEL.majestic}</option>
|
||||
<option value="moz">{PROVIDER_LABEL.moz}</option>
|
||||
<option value="semrush">{PROVIDER_LABEL.semrush}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectUntested}
|
||||
disabled={running || rows.length === 0}
|
||||
className="h-6 rounded border border-surface-700 bg-surface-800 px-2 text-[11px] text-surface-200 hover:bg-surface-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t('seoTab.selectUntested', { defaultValue: 'Select untested' })}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{running && progress ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-surface-300">
|
||||
<Loader2 size={12} className="animate-spin text-blue-400" />
|
||||
{t('seoTab.runProgress', {
|
||||
defaultValue: '{{done}} / {{total}}',
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRun}
|
||||
className="h-6 rounded border border-red-700 bg-red-900/40 px-2.5 text-[11px] font-medium text-red-200 hover:bg-red-900/70"
|
||||
>
|
||||
{t('seoTab.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runSeo()}
|
||||
disabled={selected.size === 0 || running || !providerConfigured}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded bg-blue-600 px-3 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
<Play size={11} />
|
||||
{t('seoTab.run', {
|
||||
defaultValue: 'Fetch ({{count}})',
|
||||
count: selected.size,
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!providerConfigured && (
|
||||
<div className="flex items-center gap-2 border-b border-amber-700/40 bg-amber-900/20 px-3 py-1 text-[10px] text-amber-200">
|
||||
<AlertTriangle size={11} />
|
||||
{t('seoTab.notConfigured', {
|
||||
defaultValue:
|
||||
'{{provider}} is not configured — set its credentials in Settings → Integrations to fetch data.',
|
||||
provider: PROVIDER_LABEL[provider],
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex shrink-0 select-none items-center border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex w-[30px] items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allLoadedSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selected.size > 0 && !allLoadedSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
disabled={rows.length === 0}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 px-2">{t('seoTab.colUrl', { defaultValue: 'URL' })}</div>
|
||||
<div className="w-[54px] shrink-0 text-center">{t('seoTab.colStatus', { defaultValue: 'Status' })}</div>
|
||||
{cols.map((c) => (
|
||||
<div key={c.key} className="w-[110px] shrink-0 text-right px-2">
|
||||
{c.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="w-[88px] shrink-0 px-2 text-right">
|
||||
{t('seoTab.colFetched', { defaultValue: 'Fetched' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{total === 0
|
||||
? t('seoTab.emptyNoData', {
|
||||
defaultValue:
|
||||
'No internal HTML pages crawled yet — run a crawl, then select pages and Fetch.',
|
||||
})
|
||||
: t('seoTab.emptyNoMatch', { defaultValue: 'No pages match the current filter.' })}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const row = rows[vRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SeoDataRow
|
||||
row={row}
|
||||
provider={provider}
|
||||
cols={cols}
|
||||
selected={selected.has(row.url)}
|
||||
onToggle={() => toggleRow(row.url)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
>
|
||||
<span>
|
||||
{t('seoTab.summary', {
|
||||
defaultValue:
|
||||
'{{shown}} pages · {{tested}} with data · {{errors}} errors · {{selected}} selected',
|
||||
shown: rows.length.toLocaleString(),
|
||||
tested: testedCount.toLocaleString(),
|
||||
errors: errorCount.toLocaleString(),
|
||||
selected: selected.size.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{running && progress?.currentUrl && (
|
||||
<span className="truncate text-surface-500">
|
||||
{t('seoTab.running', { defaultValue: 'Fetching: {{url}}', url: progress.currentUrl })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeoDataRow({
|
||||
row,
|
||||
provider,
|
||||
cols,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
row: SeoRow;
|
||||
provider: SeoProvider;
|
||||
cols: { label: string; key: string }[];
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const s = row.seo;
|
||||
const status = row.statusCode;
|
||||
const statusCls =
|
||||
status === null
|
||||
? 'text-surface-600'
|
||||
: status >= 200 && status < 300
|
||||
? 'text-emerald-400'
|
||||
: status >= 300 && status < 400
|
||||
? 'text-blue-400'
|
||||
: 'text-red-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className={`flex h-full cursor-pointer items-center text-[11px] ${
|
||||
selected ? 'bg-blue-900/30' : 'odd:bg-surface-900/20 hover:bg-surface-800/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-[30px] items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||
{row.url}
|
||||
</div>
|
||||
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
|
||||
{status ?? '—'}
|
||||
</div>
|
||||
{s?.status === 'error' ? (
|
||||
<div
|
||||
className="w-[440px] shrink-0 truncate px-2 text-red-400"
|
||||
title={s.error ?? 'Error'}
|
||||
style={{ width: cols.length * 110 }}
|
||||
>
|
||||
{s.error ?? 'Error'}
|
||||
</div>
|
||||
) : (
|
||||
cols.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="w-[110px] shrink-0 text-right px-2 tabular-nums text-surface-200"
|
||||
>
|
||||
{fmtNum(metricValue(provider, s?.metrics ?? null, c.key))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="w-[88px] shrink-0 truncate px-2 text-right text-surface-500">
|
||||
{s ? relativeTime(s.fetchedAt) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -606,6 +606,10 @@ export const COLUMN_SPECS: Record<TabKey, ColumnSpec[]> = {
|
||||
'broken-links': [],
|
||||
serp: [],
|
||||
pagespeed: [],
|
||||
'search-console': [],
|
||||
analytics: [],
|
||||
ai: [],
|
||||
seo: [],
|
||||
visualization: [],
|
||||
canonicals: [
|
||||
COL.url,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# FreeCrawl SEO — example GitHub Actions workflow
|
||||
#
|
||||
# Runs the headless CLI against your site on every push and on a daily
|
||||
# schedule, fails the build if more than N broken links are discovered,
|
||||
# and uploads the JSON / CSV crawl as a build artifact. Adapt
|
||||
# `TARGET_URL` and `BROKEN_LINK_THRESHOLD` to your site.
|
||||
#
|
||||
# Save this file as `.github/workflows/freecrawl-audit.yml` in your
|
||||
# repo. No FreeCrawl install required on the runner — `npx` pulls the
|
||||
# CLI from the source clone.
|
||||
|
||||
name: FreeCrawl SEO Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # 04:00 UTC daily
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: https://example.com
|
||||
MAX_URLS: "5000"
|
||||
BROKEN_LINK_THRESHOLD: "10"
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: kemalai/FreeCrawl-SEO-Tool
|
||||
path: freecrawl
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: freecrawl/package-lock.json
|
||||
|
||||
- name: Install + build CLI
|
||||
working-directory: freecrawl
|
||||
run: |
|
||||
npm ci
|
||||
npm run build:cli
|
||||
|
||||
- name: Crawl ${{ env.TARGET_URL }}
|
||||
working-directory: freecrawl
|
||||
run: |
|
||||
node apps/cli/dist/index.js "$TARGET_URL" \
|
||||
--max "$MAX_URLS" \
|
||||
--out "$GITHUB_WORKSPACE/crawl.json"
|
||||
|
||||
- name: Fail on too many broken links
|
||||
run: |
|
||||
BROKEN=$(node -e "
|
||||
const r = require('$GITHUB_WORKSPACE/crawl.json');
|
||||
const n = r.filter(u => u.statusCode >= 400 && u.statusCode < 600).length;
|
||||
console.log(n);
|
||||
")
|
||||
echo "Broken-URL count: $BROKEN (threshold: $BROKEN_LINK_THRESHOLD)"
|
||||
if [ "$BROKEN" -gt "$BROKEN_LINK_THRESHOLD" ]; then
|
||||
echo "::error::Broken-URL count $BROKEN exceeds threshold $BROKEN_LINK_THRESHOLD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: freecrawl-audit-${{ github.run_number }}
|
||||
path: crawl.json
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,49 @@
|
||||
# FreeCrawl SEO — example GitLab CI/CD pipeline
|
||||
#
|
||||
# Runs the headless FreeCrawl CLI against your site, fails the
|
||||
# pipeline if more than `$BROKEN_LINK_THRESHOLD` URLs are broken, and
|
||||
# stores the JSON crawl as a job artifact. Adapt `TARGET_URL` and the
|
||||
# threshold to your project.
|
||||
#
|
||||
# Save this file as `.gitlab-ci.yml` (or include it as a template).
|
||||
# Schedule daily runs via GitLab CI/CD → Schedules.
|
||||
|
||||
variables:
|
||||
TARGET_URL: "https://example.com"
|
||||
MAX_URLS: "5000"
|
||||
BROKEN_LINK_THRESHOLD: "10"
|
||||
|
||||
stages:
|
||||
- audit
|
||||
|
||||
freecrawl-audit:
|
||||
stage: audit
|
||||
image: node:22
|
||||
timeout: 30 minutes
|
||||
before_script:
|
||||
- apt-get update -qq && apt-get install -y -qq git ca-certificates
|
||||
- git clone --depth 1 https://github.com/kemalai/FreeCrawl-SEO-Tool.git freecrawl
|
||||
- cd freecrawl
|
||||
- npm ci
|
||||
- npm run build:cli
|
||||
script:
|
||||
- node apps/cli/dist/index.js "$TARGET_URL"
|
||||
--max "$MAX_URLS"
|
||||
--out "$CI_PROJECT_DIR/crawl.json"
|
||||
- >-
|
||||
BROKEN=$(node -e "
|
||||
const r = require('$CI_PROJECT_DIR/crawl.json');
|
||||
const n = r.filter(u => u.statusCode >= 400 && u.statusCode < 600).length;
|
||||
console.log(n);
|
||||
")
|
||||
- echo "Broken-URL count $BROKEN (threshold $BROKEN_LINK_THRESHOLD)"
|
||||
- >-
|
||||
if [ "$BROKEN" -gt "$BROKEN_LINK_THRESHOLD" ]; then
|
||||
echo "Broken-URL count $BROKEN exceeds threshold $BROKEN_LINK_THRESHOLD" >&2
|
||||
exit 1
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- crawl.json
|
||||
expire_in: 30 days
|
||||
when: always
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -42,7 +42,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -86,7 +86,7 @@
|
||||
},
|
||||
"apps/mcp-server": {
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*"
|
||||
@@ -8826,7 +8826,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*",
|
||||
@@ -8852,7 +8852,7 @@
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@freecrawl/shared-types": "*"
|
||||
},
|
||||
@@ -8864,7 +8864,7 @@
|
||||
},
|
||||
"packages/shared-types": {
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"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.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -102,6 +102,7 @@ export class Crawler extends EventEmitter {
|
||||
private inFlightFetchControllers = new Set<AbortController>();
|
||||
private robots: RobotsChecker | null = null;
|
||||
private progressTimer: NodeJS.Timeout | null = null;
|
||||
private memoryWatchdogTimer: NodeJS.Timeout | null = null;
|
||||
/** Periodic post-crawl-style recompute of expensive issue counters
|
||||
* while the crawl is still running, so the sidebar number is < 30 s
|
||||
* stale instead of "0 until crawl ends". The recompute is dispatched
|
||||
@@ -514,6 +515,39 @@ export class Crawler extends EventEmitter {
|
||||
|
||||
this.progressTimer = setInterval(() => this.emitProgress(), 500);
|
||||
|
||||
// Memory-limit watchdog. When `memoryLimitMb > 0`, sample RSS every
|
||||
// 2 s; if the threshold is crossed, pause the crawler so pending
|
||||
// writes drain (PQueue.pause() lets in-flight tasks finish) and
|
||||
// surface a clear `warn`. Auto-resume once RSS drops back below 80 %
|
||||
// of the cap so a transient spike doesn't permanently halt the
|
||||
// crawl — matches the Settings → Memory limit field's documented
|
||||
// behaviour.
|
||||
if (this.config.memoryLimitMb > 0) {
|
||||
let autoPaused = false;
|
||||
const limit = this.config.memoryLimitMb;
|
||||
const resumeAt = Math.floor(limit * 0.8);
|
||||
this.memoryWatchdogTimer = setInterval(() => {
|
||||
if (this.stopped) return;
|
||||
const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
|
||||
if (!autoPaused && !this.paused && rssMb >= limit) {
|
||||
this.emit(
|
||||
'warn',
|
||||
`Memory limit reached: RSS ${rssMb} MB ≥ ${limit} MB — crawler paused. ` +
|
||||
`Will resume automatically once RSS drops below ${resumeAt} MB.`,
|
||||
);
|
||||
autoPaused = true;
|
||||
this.pause();
|
||||
} else if (autoPaused && this.paused && rssMb < resumeAt) {
|
||||
this.emit(
|
||||
'info',
|
||||
`Memory dropped to ${rssMb} MB (< ${resumeAt} MB) — resuming crawler.`,
|
||||
);
|
||||
autoPaused = false;
|
||||
this.resume();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// I-3 — Periodic materialised issue recompute while the crawl is
|
||||
// running. The post-crawl pass at the end will run a final clean
|
||||
// recompute; this keeps the sidebar's expensive issue counters
|
||||
@@ -590,6 +624,8 @@ export class Crawler extends EventEmitter {
|
||||
} finally {
|
||||
if (this.progressTimer) clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
if (this.memoryWatchdogTimer) clearInterval(this.memoryWatchdogTimer);
|
||||
this.memoryWatchdogTimer = null;
|
||||
this.stopIssueRecomputeTimer();
|
||||
this.stopQueueCheckpointTimer();
|
||||
}
|
||||
@@ -1079,6 +1115,8 @@ export class Crawler extends EventEmitter {
|
||||
} finally {
|
||||
if (this.progressTimer) clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
if (this.memoryWatchdogTimer) clearInterval(this.memoryWatchdogTimer);
|
||||
this.memoryWatchdogTimer = null;
|
||||
this.stopIssueRecomputeTimer();
|
||||
this.stopQueueCheckpointTimer();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,55 @@ export async function exportUrlsToCsv(
|
||||
return { rowsWritten };
|
||||
}
|
||||
|
||||
/** Fixed column order for the Images CSV export. Matches the columns
|
||||
* shown in the Images tab so the file is a 1:1 export of the grid. */
|
||||
const IMAGES_CSV_HEADER = [
|
||||
'Image URL',
|
||||
'Alt',
|
||||
'Width',
|
||||
'Height',
|
||||
'Internal',
|
||||
'Occurrences',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Stream every image row to a CSV file. Honours the Images tab's
|
||||
* `missingAltOnly` + `search` filters so the export matches what the
|
||||
* user is currently looking at. Streamed row-by-row so a 100K-image
|
||||
* crawl never builds a giant string in memory.
|
||||
*/
|
||||
export async function exportImagesToCsv(
|
||||
db: ProjectDb,
|
||||
filePath: string,
|
||||
options: { missingAltOnly?: boolean; search?: string } = {},
|
||||
): Promise<{ rowsWritten: number }> {
|
||||
let rowsWritten = 0;
|
||||
const header = IMAGES_CSV_HEADER.join(',') + '\n';
|
||||
|
||||
const generator = async function* (): AsyncGenerator<string> {
|
||||
yield '' + header;
|
||||
for (const row of db.iterateImages({
|
||||
missingAltOnly: options.missingAltOnly,
|
||||
search: options.search,
|
||||
})) {
|
||||
const cells = [
|
||||
row.src,
|
||||
row.alt,
|
||||
row.width,
|
||||
row.height,
|
||||
row.isInternal ? 'internal' : 'external',
|
||||
row.occurrences,
|
||||
];
|
||||
rowsWritten++;
|
||||
yield cells.map(escapeCsv).join(',') + '\n';
|
||||
}
|
||||
};
|
||||
|
||||
await pipeline(Readable.from(generator()), createWriteStream(filePath, { encoding: 'utf8' }));
|
||||
|
||||
return { rowsWritten };
|
||||
}
|
||||
|
||||
/** Fixed column order for the Broken Links CSV export. */
|
||||
const BROKEN_LINK_CSV_HEADER = [
|
||||
'Source URL',
|
||||
|
||||
@@ -18,7 +18,7 @@ export {
|
||||
type AnalyticsTracker,
|
||||
type HreflangEntry,
|
||||
} from './html-parser.js';
|
||||
export { exportUrlsToCsv, exportBrokenLinksToCsv } from './csv-export.js';
|
||||
export { exportUrlsToCsv, exportBrokenLinksToCsv, exportImagesToCsv } from './csv-export.js';
|
||||
export { exportUrlsToJson, type JsonExportOptions } from './json-export.js';
|
||||
export { exportUrlsToXml } from './xml-export.js';
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1292,6 +1292,123 @@ const MIGRATIONS: Migration[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 57,
|
||||
name: 'add_gsc_results_table',
|
||||
// V1 Faz 7 — Google Search Console integration. Per-page clicks /
|
||||
// impressions / CTR / position pulled from the Search Console API,
|
||||
// keyed by the page URL so the Search Console tab can LEFT JOIN it
|
||||
// onto the crawled `urls`. Like `pagespeed_results` this lives in
|
||||
// its own table — the data is fetched on demand for a date range,
|
||||
// wholesale-replaced on each pull, and not part of the crawl.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gsc_results (
|
||||
url TEXT PRIMARY KEY,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
impressions INTEGER NOT NULL DEFAULT 0,
|
||||
ctr REAL NOT NULL DEFAULT 0,
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 58,
|
||||
name: 'add_ga4_results_table',
|
||||
// V1 Faz 7 — Google Analytics 4 integration. Per-page sessions /
|
||||
// users / pageviews / engagement-rate / avg-session-duration pulled
|
||||
// from the GA4 Data API on demand for a date range. Lives in its
|
||||
// own table for the same reasons as `pagespeed_results` /
|
||||
// `gsc_results` — wholesale-replaced per pull, not part of the
|
||||
// crawl, joined onto the crawled `urls` via the page URL.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ga4_results (
|
||||
url TEXT PRIMARY KEY,
|
||||
sessions INTEGER NOT NULL DEFAULT 0,
|
||||
users INTEGER NOT NULL DEFAULT 0,
|
||||
pageviews INTEGER NOT NULL DEFAULT 0,
|
||||
engagement_rate REAL NOT NULL DEFAULT 0,
|
||||
avg_session_duration REAL NOT NULL DEFAULT 0,
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 59,
|
||||
name: 'add_ai_results_table',
|
||||
// V1 Faz 7 — AI integrations (OpenAI / Anthropic / Ollama). One row
|
||||
// per (url, provider) — re-running a prompt overwrites the prior
|
||||
// response for that URL on that provider. Status/error live in the
|
||||
// same row so an AI tab cell can render either a successful
|
||||
// response or the failure reason without joining a second table.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_results (
|
||||
url TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
fetched_at TEXT NOT NULL,
|
||||
PRIMARY KEY (url, provider)
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 60,
|
||||
name: 'add_seo_results_table',
|
||||
// V1 Faz 7 — third-party SEO authority providers (Ahrefs / Majestic /
|
||||
// Moz / Semrush). Per-URL metrics stored as a JSON blob since each
|
||||
// provider returns a different metric set; the SEO tab parses
|
||||
// shape based on the `provider` column.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS seo_results (
|
||||
url TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
metrics TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
fetched_at TEXT NOT NULL,
|
||||
PRIMARY KEY (url, provider)
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 61,
|
||||
name: 'add_gsc_inspection_table',
|
||||
// V1 Faz 7 — Google Search Console URL Inspection API results.
|
||||
// Separate from `gsc_results` (Search Analytics) because inspection
|
||||
// is per-URL with a hard 2 K/day quota and its own response shape:
|
||||
// verdict / coverageState / lastCrawlTime / robotsTxtState /
|
||||
// indexingState / canonical info.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gsc_inspection_results (
|
||||
url TEXT PRIMARY KEY,
|
||||
verdict TEXT,
|
||||
coverage_state TEXT,
|
||||
robots_txt_state TEXT,
|
||||
indexing_state TEXT,
|
||||
last_crawl_time TEXT,
|
||||
google_canonical TEXT,
|
||||
user_canonical TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
+785
-21
@@ -18,6 +18,17 @@ import type {
|
||||
PagespeedMetrics,
|
||||
PagespeedRow,
|
||||
PagespeedStrategy,
|
||||
GscRow,
|
||||
Ga4Row,
|
||||
AiProvider,
|
||||
AiResult,
|
||||
AiRow,
|
||||
SeoMetrics,
|
||||
SeoProvider,
|
||||
SeoResult,
|
||||
SeoRow,
|
||||
GscInspectionResult,
|
||||
UrlAnalyticsDetail,
|
||||
UrlCategory,
|
||||
UrlDetail,
|
||||
} from '@freecrawl/shared-types';
|
||||
@@ -675,7 +686,12 @@ export class ProjectDb {
|
||||
DELETE FROM urls_issues;
|
||||
DELETE FROM crawl_queue;
|
||||
DELETE FROM host_certs;
|
||||
DELETE FROM pagespeed_results;`,
|
||||
DELETE FROM pagespeed_results;
|
||||
DELETE FROM gsc_results;
|
||||
DELETE FROM ga4_results;
|
||||
DELETE FROM ai_results;
|
||||
DELETE FROM seo_results;
|
||||
DELETE FROM gsc_inspection_results;`,
|
||||
);
|
||||
});
|
||||
// Reclaim freed pages back to the OS so the project file size
|
||||
@@ -796,26 +812,29 @@ export class ProjectDb {
|
||||
WHERE id NOT IN (SELECT DISTINCT image_id FROM image_usages)`,
|
||||
);
|
||||
|
||||
// PageSpeed audit rows are keyed by URL string (no FK to `urls`),
|
||||
// so wipe them by the same host match — otherwise a GDPR delete
|
||||
// leaves stale PSI data that would re-attach if the URL is
|
||||
// re-crawled later.
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM pagespeed_results
|
||||
WHERE LOWER(
|
||||
SUBSTR(
|
||||
url,
|
||||
INSTR(url, '://') + 3,
|
||||
CASE
|
||||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||||
ELSE LENGTH(url)
|
||||
END
|
||||
)
|
||||
) = ?`,
|
||||
)
|
||||
.run(target);
|
||||
// PageSpeed + Search Console rows are keyed by URL string (no FK
|
||||
// to `urls`), so wipe them by the same host match — otherwise a
|
||||
// GDPR delete leaves stale data that would re-attach if the URL
|
||||
// is re-crawled later.
|
||||
const hostMatchSql = (table: string): string =>
|
||||
`DELETE FROM ${table}
|
||||
WHERE LOWER(
|
||||
SUBSTR(
|
||||
url,
|
||||
INSTR(url, '://') + 3,
|
||||
CASE
|
||||
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0
|
||||
THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1
|
||||
ELSE LENGTH(url)
|
||||
END
|
||||
)
|
||||
) = ?`;
|
||||
this.db.prepare(hostMatchSql('pagespeed_results')).run(target);
|
||||
this.db.prepare(hostMatchSql('gsc_results')).run(target);
|
||||
this.db.prepare(hostMatchSql('ga4_results')).run(target);
|
||||
this.db.prepare(hostMatchSql('ai_results')).run(target);
|
||||
this.db.prepare(hostMatchSql('seo_results')).run(target);
|
||||
this.db.prepare(hostMatchSql('gsc_inspection_results')).run(target);
|
||||
|
||||
return { urlsDeleted, linksDeleted };
|
||||
});
|
||||
@@ -880,6 +899,17 @@ export class ProjectDb {
|
||||
return this.txDepth > 0;
|
||||
}
|
||||
|
||||
/** Force a passive WAL → main-DB checkpoint so the on-disk
|
||||
* `.seoproject` file is fully self-contained. Called by the
|
||||
* encrypted-snapshot exporter before it reads the file bytes. */
|
||||
walCheckpoint(): void {
|
||||
try {
|
||||
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||
} catch {
|
||||
/* checkpoint can fail if a reader holds the lock — harmless */
|
||||
}
|
||||
}
|
||||
|
||||
getMeta(key: string): string | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT value FROM project_meta WHERE key = ?')
|
||||
@@ -2719,6 +2749,666 @@ export class ProjectDb {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — wholesale-replace the stored Google Search Console data.
|
||||
* Each GSC pull is a fresh snapshot for a date range, so the old rows
|
||||
* are dropped and the new set inserted in one transaction.
|
||||
*/
|
||||
replaceGscResults(
|
||||
rows: {
|
||||
url: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}[],
|
||||
fetchedAt: string,
|
||||
): void {
|
||||
this.runInTransaction(() => {
|
||||
this.db.exec('DELETE FROM gsc_results');
|
||||
const CHUNK = 400;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const slice = rows.slice(i, i + CHUNK);
|
||||
const placeholders = slice.map(() => '(?, ?, ?, ?, ?, ?)').join(',');
|
||||
const args: (string | number)[] = [];
|
||||
for (const r of slice) {
|
||||
args.push(r.url, r.clicks, r.impressions, r.ctr, r.position, fetchedAt);
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO gsc_results
|
||||
(url, clicks, impressions, ctr, position, fetched_at)
|
||||
VALUES ${placeholders}`,
|
||||
)
|
||||
.run(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — candidate query for the Search Console tab. Crawled internal
|
||||
* HTML 2xx pages left-joined with their stored GSC metrics, ordered
|
||||
* impressions-first so the pages Google shows most surface at the top.
|
||||
*/
|
||||
queryGsc(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
filter?: 'all' | 'with-data' | 'without-data';
|
||||
}): { rows: GscRow[]; total: number } {
|
||||
const where: string[] = [
|
||||
"u.is_external = 0 AND u.content_kind = 'html'",
|
||||
'u.status_code >= 200 AND u.status_code < 300',
|
||||
];
|
||||
const args: (string | number)[] = [];
|
||||
if (params.search) {
|
||||
where.push('u.url LIKE ?');
|
||||
args.push(`%${params.search}%`);
|
||||
}
|
||||
const filter = params.filter ?? 'all';
|
||||
if (filter === 'with-data') where.push('g.url IS NOT NULL');
|
||||
else if (filter === 'without-data') where.push('g.url IS NULL');
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const joinSql =
|
||||
`FROM urls u
|
||||
LEFT JOIN gsc_results g ON g.url = u.url
|
||||
LEFT JOIN gsc_inspection_results i ON i.url = u.url`;
|
||||
|
||||
const totalRow = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
|
||||
.get(...args) as { c: number };
|
||||
|
||||
const rowsDb = this.db
|
||||
.prepare(
|
||||
`SELECT u.url AS url, u.status_code AS status_code,
|
||||
g.clicks AS clicks, g.impressions AS impressions,
|
||||
g.ctr AS ctr, g.position AS position,
|
||||
g.fetched_at AS g_fetched,
|
||||
i.verdict AS i_verdict,
|
||||
i.coverage_state AS i_coverage_state,
|
||||
i.robots_txt_state AS i_robots_txt_state,
|
||||
i.indexing_state AS i_indexing_state,
|
||||
i.last_crawl_time AS i_last_crawl_time,
|
||||
i.google_canonical AS i_google_canonical,
|
||||
i.user_canonical AS i_user_canonical,
|
||||
i.status AS i_status,
|
||||
i.error AS i_error,
|
||||
i.fetched_at AS i_fetched
|
||||
${joinSql}
|
||||
${whereSql}
|
||||
ORDER BY g.impressions DESC, u.url ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...args, params.limit, params.offset) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
return {
|
||||
total: totalRow.c,
|
||||
rows: rowsDb.map((r) => {
|
||||
const gFetched = r['g_fetched'];
|
||||
const iFetched = r['i_fetched'];
|
||||
const iStatus = r['i_status'];
|
||||
return {
|
||||
url: r['url'] as string,
|
||||
statusCode: (r['status_code'] as number | null) ?? null,
|
||||
gsc:
|
||||
typeof gFetched === 'string'
|
||||
? {
|
||||
clicks: (r['clicks'] as number | null) ?? 0,
|
||||
impressions: (r['impressions'] as number | null) ?? 0,
|
||||
ctr: (r['ctr'] as number | null) ?? 0,
|
||||
position: (r['position'] as number | null) ?? 0,
|
||||
fetchedAt: gFetched,
|
||||
}
|
||||
: null,
|
||||
inspection:
|
||||
typeof iFetched === 'string' && typeof iStatus === 'string'
|
||||
? {
|
||||
verdict: (r['i_verdict'] as string | null) ?? null,
|
||||
coverageState: (r['i_coverage_state'] as string | null) ?? null,
|
||||
robotsTxtState: (r['i_robots_txt_state'] as string | null) ?? null,
|
||||
indexingState: (r['i_indexing_state'] as string | null) ?? null,
|
||||
lastCrawlTime: (r['i_last_crawl_time'] as string | null) ?? null,
|
||||
googleCanonical: (r['i_google_canonical'] as string | null) ?? null,
|
||||
userCanonical: (r['i_user_canonical'] as string | null) ?? null,
|
||||
status: iStatus === 'error' ? 'error' : 'ok',
|
||||
error: (r['i_error'] as string | null) ?? null,
|
||||
fetchedAt: iFetched,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Combined per-URL Analytics view for the detail panel. Three small
|
||||
* lookups (GSC Search Analytics + GA4 + GSC URL Inspection) keyed by
|
||||
* the URL string. Returns null when none of the three is present. */
|
||||
getUrlAnalytics(url: string): UrlAnalyticsDetail | null {
|
||||
const g = this.db
|
||||
.prepare(
|
||||
`SELECT clicks, impressions, ctr, position, fetched_at
|
||||
FROM gsc_results WHERE url = ?`,
|
||||
)
|
||||
.get(url) as
|
||||
| {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
const a = this.db
|
||||
.prepare(
|
||||
`SELECT sessions, users, pageviews, engagement_rate,
|
||||
avg_session_duration, fetched_at
|
||||
FROM ga4_results WHERE url = ?`,
|
||||
)
|
||||
.get(url) as
|
||||
| {
|
||||
sessions: number;
|
||||
users: number;
|
||||
pageviews: number;
|
||||
engagement_rate: number;
|
||||
avg_session_duration: number;
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
const i = this.db
|
||||
.prepare(
|
||||
`SELECT verdict, coverage_state, robots_txt_state, indexing_state,
|
||||
last_crawl_time, google_canonical, user_canonical,
|
||||
status, error, fetched_at
|
||||
FROM gsc_inspection_results WHERE url = ?`,
|
||||
)
|
||||
.get(url) as
|
||||
| {
|
||||
verdict: string | null;
|
||||
coverage_state: string | null;
|
||||
robots_txt_state: string | null;
|
||||
indexing_state: string | null;
|
||||
last_crawl_time: string | null;
|
||||
google_canonical: string | null;
|
||||
user_canonical: string | null;
|
||||
status: string;
|
||||
error: string | null;
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!g && !a && !i) return null;
|
||||
return {
|
||||
url,
|
||||
gsc: g
|
||||
? {
|
||||
clicks: g.clicks ?? 0,
|
||||
impressions: g.impressions ?? 0,
|
||||
ctr: g.ctr ?? 0,
|
||||
position: g.position ?? 0,
|
||||
fetchedAt: g.fetched_at,
|
||||
}
|
||||
: null,
|
||||
ga4: a
|
||||
? {
|
||||
sessions: a.sessions ?? 0,
|
||||
users: a.users ?? 0,
|
||||
pageviews: a.pageviews ?? 0,
|
||||
engagementRate: a.engagement_rate ?? 0,
|
||||
avgSessionDuration: a.avg_session_duration ?? 0,
|
||||
fetchedAt: a.fetched_at,
|
||||
}
|
||||
: null,
|
||||
inspection: i
|
||||
? {
|
||||
verdict: i.verdict,
|
||||
coverageState: i.coverage_state,
|
||||
robotsTxtState: i.robots_txt_state,
|
||||
indexingState: i.indexing_state,
|
||||
lastCrawlTime: i.last_crawl_time,
|
||||
googleCanonical: i.google_canonical,
|
||||
userCanonical: i.user_canonical,
|
||||
status: i.status === 'error' ? 'error' : 'ok',
|
||||
error: i.error,
|
||||
fetchedAt: i.fetched_at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Faz 7 — store one Search Console URL Inspection result. */
|
||||
upsertGscInspection(url: string, result: GscInspectionResult): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO gsc_inspection_results
|
||||
(url, verdict, coverage_state, robots_txt_state, indexing_state,
|
||||
last_crawl_time, google_canonical, user_canonical,
|
||||
status, error, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
verdict = excluded.verdict,
|
||||
coverage_state = excluded.coverage_state,
|
||||
robots_txt_state = excluded.robots_txt_state,
|
||||
indexing_state = excluded.indexing_state,
|
||||
last_crawl_time = excluded.last_crawl_time,
|
||||
google_canonical = excluded.google_canonical,
|
||||
user_canonical = excluded.user_canonical,
|
||||
status = excluded.status,
|
||||
error = excluded.error,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
)
|
||||
.run(
|
||||
url,
|
||||
result.verdict,
|
||||
result.coverageState,
|
||||
result.robotsTxtState,
|
||||
result.indexingState,
|
||||
result.lastCrawlTime,
|
||||
result.googleCanonical,
|
||||
result.userCanonical,
|
||||
result.status,
|
||||
result.error,
|
||||
result.fetchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/** Faz 7 — store one SEO authority provider result. The metrics blob
|
||||
* is serialised as JSON since each provider's shape differs. */
|
||||
upsertSeoResult(
|
||||
url: string,
|
||||
provider: SeoProvider,
|
||||
metrics: SeoMetrics | null,
|
||||
status: 'ok' | 'error',
|
||||
error: string | null,
|
||||
fetchedAt: string,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO seo_results
|
||||
(url, provider, metrics, status, error, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url, provider) DO UPDATE SET
|
||||
metrics = excluded.metrics,
|
||||
status = excluded.status,
|
||||
error = excluded.error,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
)
|
||||
.run(
|
||||
url,
|
||||
provider,
|
||||
metrics === null ? null : JSON.stringify(metrics),
|
||||
status,
|
||||
error,
|
||||
fetchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/** Faz 7 — candidate query for the SEO tab. Same shape as `queryAi`. */
|
||||
querySeo(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
provider: SeoProvider;
|
||||
filter?: 'all' | 'with-data' | 'without-data' | 'error';
|
||||
}): { rows: SeoRow[]; total: number } {
|
||||
const where: string[] = [
|
||||
"u.is_external = 0 AND u.content_kind = 'html'",
|
||||
'u.status_code >= 200 AND u.status_code < 300',
|
||||
];
|
||||
const args: (string | number)[] = [params.provider];
|
||||
if (params.search) {
|
||||
where.push('u.url LIKE ?');
|
||||
args.push(`%${params.search}%`);
|
||||
}
|
||||
const filter = params.filter ?? 'all';
|
||||
if (filter === 'with-data') {
|
||||
where.push("s.url IS NOT NULL AND s.status = 'ok'");
|
||||
} else if (filter === 'without-data') {
|
||||
where.push('s.url IS NULL');
|
||||
} else if (filter === 'error') {
|
||||
where.push("s.status = 'error'");
|
||||
}
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const joinSql =
|
||||
'FROM urls u LEFT JOIN seo_results s ON s.url = u.url AND s.provider = ?';
|
||||
|
||||
const totalRow = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
|
||||
.get(...args) as { c: number };
|
||||
|
||||
const rowsDb = this.db
|
||||
.prepare(
|
||||
`SELECT u.url AS url, u.status_code AS status_code,
|
||||
s.provider AS provider, s.metrics AS metrics,
|
||||
s.status AS status, s.error AS error,
|
||||
s.fetched_at AS fetched_at
|
||||
${joinSql}
|
||||
${whereSql}
|
||||
ORDER BY s.fetched_at DESC, u.url ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...args, params.limit, params.offset) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
return {
|
||||
total: totalRow.c,
|
||||
rows: rowsDb.map((r) => {
|
||||
const fetchedAt = r['fetched_at'];
|
||||
const status = r['status'];
|
||||
let metrics: SeoMetrics | null = null;
|
||||
if (typeof r['metrics'] === 'string') {
|
||||
try {
|
||||
metrics = JSON.parse(r['metrics']) as SeoMetrics;
|
||||
} catch {
|
||||
metrics = null;
|
||||
}
|
||||
}
|
||||
const seo: SeoResult | null =
|
||||
typeof fetchedAt === 'string' && typeof status === 'string'
|
||||
? {
|
||||
provider: r['provider'] as SeoProvider,
|
||||
metrics,
|
||||
status: status === 'error' ? 'error' : 'ok',
|
||||
error: (r['error'] as string | null) ?? null,
|
||||
fetchedAt,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
url: r['url'] as string,
|
||||
statusCode: (r['status_code'] as number | null) ?? null,
|
||||
seo,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — wholesale-replace the stored GA4 data. Like the GSC pull,
|
||||
* each refresh is a snapshot for a date range so the old rows are
|
||||
* dropped and the new set inserted in one transaction.
|
||||
*/
|
||||
replaceGa4Results(
|
||||
rows: {
|
||||
url: string;
|
||||
sessions: number;
|
||||
users: number;
|
||||
pageviews: number;
|
||||
engagementRate: number;
|
||||
avgSessionDuration: number;
|
||||
}[],
|
||||
fetchedAt: string,
|
||||
): void {
|
||||
this.runInTransaction(() => {
|
||||
this.db.exec('DELETE FROM ga4_results');
|
||||
const CHUNK = 400;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const slice = rows.slice(i, i + CHUNK);
|
||||
const placeholders = slice.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(',');
|
||||
const args: (string | number)[] = [];
|
||||
for (const r of slice) {
|
||||
args.push(
|
||||
r.url,
|
||||
r.sessions,
|
||||
r.users,
|
||||
r.pageviews,
|
||||
r.engagementRate,
|
||||
r.avgSessionDuration,
|
||||
fetchedAt,
|
||||
);
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ga4_results
|
||||
(url, sessions, users, pageviews, engagement_rate,
|
||||
avg_session_duration, fetched_at)
|
||||
VALUES ${placeholders}`,
|
||||
)
|
||||
.run(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — candidate query for the GA4 tab. Crawled internal HTML 2xx
|
||||
* pages left-joined with their stored GA4 metrics, ordered by
|
||||
* pageviews desc so the highest-traffic pages surface first.
|
||||
*/
|
||||
queryGa4(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
filter?: 'all' | 'with-data' | 'without-data';
|
||||
}): { rows: Ga4Row[]; total: number } {
|
||||
const where: string[] = [
|
||||
"u.is_external = 0 AND u.content_kind = 'html'",
|
||||
'u.status_code >= 200 AND u.status_code < 300',
|
||||
];
|
||||
const args: (string | number)[] = [];
|
||||
if (params.search) {
|
||||
where.push('u.url LIKE ?');
|
||||
args.push(`%${params.search}%`);
|
||||
}
|
||||
const filter = params.filter ?? 'all';
|
||||
if (filter === 'with-data') where.push('a.url IS NOT NULL');
|
||||
else if (filter === 'without-data') where.push('a.url IS NULL');
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const joinSql = 'FROM urls u LEFT JOIN ga4_results a ON a.url = u.url';
|
||||
|
||||
const totalRow = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
|
||||
.get(...args) as { c: number };
|
||||
|
||||
const rowsDb = this.db
|
||||
.prepare(
|
||||
`SELECT u.url AS url, u.status_code AS status_code,
|
||||
a.sessions AS sessions, a.users AS users,
|
||||
a.pageviews AS pageviews,
|
||||
a.engagement_rate AS engagement_rate,
|
||||
a.avg_session_duration AS avg_session_duration,
|
||||
a.fetched_at AS fetched_at
|
||||
${joinSql}
|
||||
${whereSql}
|
||||
ORDER BY a.pageviews DESC, u.url ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...args, params.limit, params.offset) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
return {
|
||||
total: totalRow.c,
|
||||
rows: rowsDb.map((r) => {
|
||||
const fetchedAt = r['fetched_at'];
|
||||
return {
|
||||
url: r['url'] as string,
|
||||
statusCode: (r['status_code'] as number | null) ?? null,
|
||||
ga4:
|
||||
typeof fetchedAt === 'string'
|
||||
? {
|
||||
sessions: (r['sessions'] as number | null) ?? 0,
|
||||
users: (r['users'] as number | null) ?? 0,
|
||||
pageviews: (r['pageviews'] as number | null) ?? 0,
|
||||
engagementRate: (r['engagement_rate'] as number | null) ?? 0,
|
||||
avgSessionDuration:
|
||||
(r['avg_session_duration'] as number | null) ?? 0,
|
||||
fetchedAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — store one AI run result. Keyed by (url, provider); re-running
|
||||
* a prompt for the same page on the same provider overwrites the prior
|
||||
* row so the user always sees the latest answer.
|
||||
*/
|
||||
upsertAiResult(
|
||||
url: string,
|
||||
provider: AiProvider,
|
||||
model: string,
|
||||
response: string,
|
||||
tokensIn: number | null,
|
||||
tokensOut: number | null,
|
||||
status: 'ok' | 'error',
|
||||
error: string | null,
|
||||
fetchedAt: string,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO ai_results
|
||||
(url, provider, model, response, tokens_in, tokens_out,
|
||||
status, error, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url, provider) DO UPDATE SET
|
||||
model = excluded.model,
|
||||
response = excluded.response,
|
||||
tokens_in = excluded.tokens_in,
|
||||
tokens_out = excluded.tokens_out,
|
||||
status = excluded.status,
|
||||
error = excluded.error,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
)
|
||||
.run(
|
||||
url,
|
||||
provider,
|
||||
model,
|
||||
response,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
status,
|
||||
error,
|
||||
fetchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — context fed into AI prompt substitution. The `body` column
|
||||
* is the (optional) full-text snapshot stored when `bodySnapshot` is
|
||||
* enabled — null when crawling skipped the body capture for this page.
|
||||
*/
|
||||
getAiContext(url: string): {
|
||||
url: string;
|
||||
title: string | null;
|
||||
metaDescription: string | null;
|
||||
h1: string | null;
|
||||
body: string | null;
|
||||
} | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT u.url AS url, u.title AS title,
|
||||
u.meta_description AS meta_description,
|
||||
u.h1 AS h1, s.body AS body
|
||||
FROM urls u
|
||||
LEFT JOIN url_sources s ON s.url_id = u.id
|
||||
WHERE u.url = ?`,
|
||||
)
|
||||
.get(url) as
|
||||
| {
|
||||
url: string;
|
||||
title: string | null;
|
||||
meta_description: string | null;
|
||||
h1: string | null;
|
||||
body: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
metaDescription: row.meta_description,
|
||||
h1: row.h1,
|
||||
body: row.body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — candidate query for the AI tab. Crawled internal HTML 2xx
|
||||
* pages left-joined with the latest AI result for the requested
|
||||
* provider; the filter narrows to with-data / without-data / error.
|
||||
*/
|
||||
queryAi(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
provider: AiProvider;
|
||||
filter?: 'all' | 'with-data' | 'without-data' | 'error';
|
||||
}): { rows: AiRow[]; total: number } {
|
||||
const where: string[] = [
|
||||
"u.is_external = 0 AND u.content_kind = 'html'",
|
||||
'u.status_code >= 200 AND u.status_code < 300',
|
||||
];
|
||||
const args: (string | number)[] = [params.provider];
|
||||
if (params.search) {
|
||||
where.push('u.url LIKE ?');
|
||||
args.push(`%${params.search}%`);
|
||||
}
|
||||
const filter = params.filter ?? 'all';
|
||||
if (filter === 'with-data') {
|
||||
where.push("a.url IS NOT NULL AND a.status = 'ok'");
|
||||
} else if (filter === 'without-data') {
|
||||
where.push('a.url IS NULL');
|
||||
} else if (filter === 'error') {
|
||||
where.push("a.status = 'error'");
|
||||
}
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const joinSql =
|
||||
'FROM urls u LEFT JOIN ai_results a ON a.url = u.url AND a.provider = ?';
|
||||
|
||||
const totalRow = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
|
||||
.get(...args) as { c: number };
|
||||
|
||||
const rowsDb = this.db
|
||||
.prepare(
|
||||
`SELECT u.url AS url, u.status_code AS status_code,
|
||||
a.provider AS provider, a.model AS model,
|
||||
a.response AS response,
|
||||
a.tokens_in AS tokens_in, a.tokens_out AS tokens_out,
|
||||
a.status AS status, a.error AS error,
|
||||
a.fetched_at AS fetched_at
|
||||
${joinSql}
|
||||
${whereSql}
|
||||
ORDER BY a.fetched_at DESC, u.url ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...args, params.limit, params.offset) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
return {
|
||||
total: totalRow.c,
|
||||
rows: rowsDb.map((r) => {
|
||||
const fetchedAt = r['fetched_at'];
|
||||
const status = r['status'];
|
||||
const ai: AiResult | null =
|
||||
typeof fetchedAt === 'string' && typeof status === 'string'
|
||||
? {
|
||||
provider: r['provider'] as AiProvider,
|
||||
model: (r['model'] as string | null) ?? '',
|
||||
response: (r['response'] as string | null) ?? '',
|
||||
tokensIn: (r['tokens_in'] as number | null) ?? null,
|
||||
tokensOut: (r['tokens_out'] as number | null) ?? null,
|
||||
status: status === 'error' ? 'error' : 'ok',
|
||||
error: (r['error'] as string | null) ?? null,
|
||||
fetchedAt,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
url: r['url'] as string,
|
||||
statusCode: (r['status_code'] as number | null) ?? null,
|
||||
ai,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
queryImages(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
@@ -3834,6 +4524,43 @@ export class ProjectDb {
|
||||
* targets; `all` walks everything. Same JOIN + ordering as the table
|
||||
* so the export matches what the user sees.
|
||||
*/
|
||||
/**
|
||||
* Yield every image row for CSV export — the unbounded counterpart of
|
||||
* `queryImages` (which paginates for the table view). Applies the same
|
||||
* `missingAltOnly` + search filters so the export mirrors what the
|
||||
* user sees in the Images tab.
|
||||
*/
|
||||
*iterateImages(options: {
|
||||
missingAltOnly?: boolean;
|
||||
search?: string;
|
||||
} = {}): IterableIterator<ImageRow> {
|
||||
const where: string[] = [];
|
||||
const args: (string | number)[] = [];
|
||||
if (options.missingAltOnly) where.push('alt IS NULL');
|
||||
if (options.search) {
|
||||
where.push('(src LIKE ? OR alt LIKE ?)');
|
||||
const like = `%${options.search}%`;
|
||||
args.push(like, like);
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM images ${whereSql} ORDER BY occurrences DESC, id`,
|
||||
)
|
||||
.all(...args) as unknown as ImageRowDb[];
|
||||
for (const r of rows) {
|
||||
yield {
|
||||
id: r.id,
|
||||
src: r.src,
|
||||
alt: r.alt,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
isInternal: r.is_internal === 1,
|
||||
occurrences: r.occurrences,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
*iterateBrokenLinks(
|
||||
internal: 'all' | 'internal' | 'external' = 'all',
|
||||
): IterableIterator<BrokenLinkRow> {
|
||||
@@ -4652,6 +5379,43 @@ export class ProjectDb {
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-source orphan URLs — pages that appear in any external truth
|
||||
* source (XML sitemap, Search Console, or Analytics 4) but are *not*
|
||||
* in the crawled `urls` table, or are stubs with no fetched status.
|
||||
* Each row carries which sources mentioned the URL so the user can
|
||||
* triage why it was missed (sitemap-only = drop or fix internal
|
||||
* linking; GSC + GA4 = real but unreachable from the crawl seed).
|
||||
*/
|
||||
orphanPagesCrossSource(
|
||||
limit = 1000,
|
||||
): { url: string; sources: string[] }[] {
|
||||
const cap = Math.max(1, Math.min(10_000, limit));
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH src AS (
|
||||
SELECT url, 'sitemap' AS source FROM sitemap_urls
|
||||
UNION ALL
|
||||
SELECT url, 'gsc' AS source FROM gsc_results
|
||||
UNION ALL
|
||||
SELECT url, 'ga4' AS source FROM ga4_results
|
||||
)
|
||||
SELECT s.url AS url,
|
||||
GROUP_CONCAT(DISTINCT s.source) AS sources
|
||||
FROM src s
|
||||
LEFT JOIN urls u ON u.url = s.url
|
||||
WHERE u.url IS NULL OR u.status_code IS NULL
|
||||
GROUP BY s.url
|
||||
ORDER BY s.url
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(cap) as { url: string; sources: string }[];
|
||||
return rows.map((r) => ({
|
||||
url: r.url,
|
||||
sources: r.sources ? r.sources.split(',').sort() : [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-position counts across all internal links — drives the Link
|
||||
* Position report. Uses the `link_position` column populated by the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.5.6",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Faz 7 — AI integrations (OpenAI / Anthropic / Ollama).
|
||||
*
|
||||
* The three LLM providers all share the same "run a custom prompt
|
||||
* against each crawled page" UX, so the renderer drives them through
|
||||
* a single `AiTab` and a single set of IPC channels — the only thing
|
||||
* that changes per provider is the API client wiring in the main
|
||||
* process. Credentials live in the existing encrypted credential
|
||||
* store (`openai`, `anthropic`, `ollama` integration ids).
|
||||
*/
|
||||
|
||||
export type AiProvider = 'openai' | 'anthropic' | 'ollama';
|
||||
|
||||
/** Per-URL AI result stored in `ai_results`. */
|
||||
export interface AiResult {
|
||||
provider: AiProvider;
|
||||
/** Model the response was generated with — for traceability. */
|
||||
model: string;
|
||||
/** The LLM's response text (or error message when `status === 'error'`). */
|
||||
response: string;
|
||||
tokensIn: number | null;
|
||||
tokensOut: number | null;
|
||||
status: 'ok' | 'error';
|
||||
error: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** One AI tab row — a crawled page joined with its latest AI result. */
|
||||
export interface AiRow {
|
||||
url: string;
|
||||
statusCode: number | null;
|
||||
/** Null when this page has no result for the queried provider. */
|
||||
ai: AiResult | null;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Faz 7 — Google OAuth + Search Console shared types.
|
||||
*
|
||||
* The Google integrations (Search Console, Analytics 4, Sheets) all
|
||||
* authenticate through the same OAuth "loopback" keystone: the user
|
||||
* brings their own Google Cloud OAuth Desktop client (BYOC — id +
|
||||
* secret entered in Settings → Integrations), the main process opens
|
||||
* the consent screen in the system browser, and a short-lived
|
||||
* localhost HTTP server catches the redirect. Refresh tokens are
|
||||
* encrypted at rest with `safeStorage`, exactly like API keys.
|
||||
*/
|
||||
|
||||
/** Connection state of one OAuth-BYOC Google integration. */
|
||||
export interface GoogleAuthState {
|
||||
/** True once a refresh token is stored — the integration is connected. */
|
||||
connected: boolean;
|
||||
/** The Google account email the tokens belong to, when known. */
|
||||
email: string | null;
|
||||
/** ISO timestamp the connection was established. */
|
||||
connectedAt: string | null;
|
||||
}
|
||||
|
||||
/** Result of an interactive `googleAuthStart` consent flow. */
|
||||
export interface GoogleAuthResult {
|
||||
ok: boolean;
|
||||
/** Human-readable reason when `ok` is false (cancelled, denied, …). */
|
||||
error: string | null;
|
||||
state: GoogleAuthState;
|
||||
}
|
||||
|
||||
/** One verified property from the user's Search Console account. */
|
||||
export interface GscSite {
|
||||
/** Property URL — `https://example.com/` or `sc-domain:example.com`. */
|
||||
siteUrl: string;
|
||||
/** `siteOwner` / `siteFullUser` / `siteRestrictedUser` / `siteUnverifiedUser`. */
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
/** Per-URL Search Console metrics for a date range. */
|
||||
export interface GscMetrics {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
/** Click-through rate, 0–1. */
|
||||
ctr: number;
|
||||
/** Average position (1 = top of the results). */
|
||||
position: number;
|
||||
/** ISO timestamp of the pull this row came from. */
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** One Search Console tab row — a crawled page joined with its GSC data. */
|
||||
export interface GscRow {
|
||||
url: string;
|
||||
statusCode: number | null;
|
||||
/** Null when this page has no Search Console impressions in the range. */
|
||||
gsc: GscMetrics | null;
|
||||
/** Null when URL Inspection has not been run for this page. */
|
||||
inspection: GscInspectionResult | null;
|
||||
}
|
||||
|
||||
/** Result of a GSC URL Inspection API call for one URL. */
|
||||
export interface GscInspectionResult {
|
||||
/** Overall verdict — `PASS` / `NEUTRAL` / `PARTIAL` / `FAIL` /
|
||||
* `VERDICT_UNSPECIFIED`. */
|
||||
verdict: string | null;
|
||||
/** Human-readable coverage state, e.g. "Submitted and indexed". */
|
||||
coverageState: string | null;
|
||||
robotsTxtState: string | null;
|
||||
indexingState: string | null;
|
||||
/** ISO timestamp of Google's last crawl. */
|
||||
lastCrawlTime: string | null;
|
||||
/** The canonical Google picked for this page. */
|
||||
googleCanonical: string | null;
|
||||
/** The canonical the page declared. */
|
||||
userCanonical: string | null;
|
||||
status: 'ok' | 'error';
|
||||
error: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** Metadata describing the most recent GSC pull for the active project. */
|
||||
export interface GscFetchMeta {
|
||||
/** The Search Console property the data was pulled from. */
|
||||
property: string;
|
||||
/** Date range (inclusive), `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
fetchedAt: string;
|
||||
/** Number of page rows Search Console returned. */
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/** One Google Analytics 4 property the connected account can read. */
|
||||
export interface Ga4Property {
|
||||
/** API resource name, e.g. `properties/123456789`. */
|
||||
propertyId: string;
|
||||
/** Friendly label, e.g. `"Acme – Marketing Site"`. */
|
||||
displayName: string;
|
||||
/** Display name of the parent account, for disambiguation. */
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
/** Per-page Google Analytics 4 metrics for a date range. */
|
||||
export interface Ga4Metrics {
|
||||
sessions: number;
|
||||
users: number;
|
||||
pageviews: number;
|
||||
/** Engagement rate, 0–1 (the share of engaged sessions). */
|
||||
engagementRate: number;
|
||||
/** Average session duration in seconds. */
|
||||
avgSessionDuration: number;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** One Analytics tab row — a crawled page joined with its GA4 data. */
|
||||
export interface Ga4Row {
|
||||
url: string;
|
||||
statusCode: number | null;
|
||||
/** Null when the page had no GA4 traffic in the range. */
|
||||
ga4: Ga4Metrics | null;
|
||||
}
|
||||
|
||||
/** Combined per-URL Analytics view — GSC Search Analytics + GA4 +
|
||||
* GSC URL Inspection, returned by `urlAnalyticsGet` for the detail
|
||||
* panel's Analytics sub-tab. Any field is null when no data exists. */
|
||||
export interface UrlAnalyticsDetail {
|
||||
url: string;
|
||||
gsc: GscMetrics | null;
|
||||
ga4: Ga4Metrics | null;
|
||||
inspection: GscInspectionResult | null;
|
||||
}
|
||||
|
||||
/** Metadata describing the most recent GA4 pull for the active project. */
|
||||
export interface Ga4FetchMeta {
|
||||
/** API resource name (`properties/<id>`) the data was pulled from. */
|
||||
property: string;
|
||||
/** Friendly label for the property — what the UI shows. */
|
||||
propertyName: string;
|
||||
/** Inclusive date range, `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
fetchedAt: string;
|
||||
/** Number of page rows GA4 returned. */
|
||||
rowCount: number;
|
||||
}
|
||||
@@ -3,3 +3,6 @@ export * from './ipc.js';
|
||||
export * from './project.js';
|
||||
export * from './integrations.js';
|
||||
export * from './pagespeed.js';
|
||||
export * from './google.js';
|
||||
export * from './ai.js';
|
||||
export * from './seo.js';
|
||||
@@ -156,9 +156,10 @@ export const INTEGRATIONS: IntegrationDef[] = [
|
||||
category: 'performance',
|
||||
authType: 'api-key',
|
||||
description: 'Lighthouse audit scores per URL (mobile + desktop).',
|
||||
fields: [{ ...KEY_FIELD, optional: true, label: 'API Key (optional)' }],
|
||||
fields: [KEY_FIELD],
|
||||
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.',
|
||||
setupHint:
|
||||
"A free API key is required — Google's keyless shared quota is now 0/day, so anonymous audits no longer work. Create a key in Google Cloud Console (no billing required), enable the PageSpeed Insights API, then paste the key here. Free tier: 25 000 queries/day, 240/min.",
|
||||
},
|
||||
|
||||
// ---- SEO data ----
|
||||
|
||||
@@ -16,6 +16,19 @@ import type {
|
||||
PagespeedRow,
|
||||
PagespeedRunStrategy,
|
||||
} from './pagespeed.js';
|
||||
import type {
|
||||
GoogleAuthState,
|
||||
GoogleAuthResult,
|
||||
GscSite,
|
||||
GscRow,
|
||||
GscFetchMeta,
|
||||
Ga4Property,
|
||||
Ga4Row,
|
||||
Ga4FetchMeta,
|
||||
UrlAnalyticsDetail,
|
||||
} from './google.js';
|
||||
import type { AiProvider, AiRow } from './ai.js';
|
||||
import type { SeoProvider, SeoRow } from './seo.js';
|
||||
|
||||
export const IPC = {
|
||||
crawlStart: 'crawl:start',
|
||||
@@ -27,6 +40,14 @@ export const IPC = {
|
||||
projectSaveAs: 'project:save-as',
|
||||
projectOpen: 'project:open',
|
||||
projectCurrentPath: 'project:current-path',
|
||||
/** V1 #4 — Save an AES-256-GCM-encrypted snapshot of the active
|
||||
* `.seoproject` file. Renderer provides the password; main shows a
|
||||
* save dialog for the destination `.seoproject.enc` path. */
|
||||
projectSaveEncrypted: 'project:save-encrypted',
|
||||
/** V1 #4 — Open an encrypted snapshot. Renderer provides the password;
|
||||
* main shows an open dialog for the source `.seoproject.enc`, asks
|
||||
* for a destination `.seoproject` path, decrypts, and opens it. */
|
||||
projectOpenEncrypted: 'project:open-encrypted',
|
||||
crawlProgress: 'crawl:progress',
|
||||
crawlDone: 'crawl:done',
|
||||
crawlError: 'crawl:error',
|
||||
@@ -47,6 +68,9 @@ export const IPC = {
|
||||
/** Export the Broken Links tab to a CSV file. Honours the tab's
|
||||
* internal/external scope filter. */
|
||||
exportBrokenLinks: 'export:broken-links',
|
||||
/** Export the Images tab to CSV. Honours the missing-alt scope
|
||||
* filter so what the user sees is what they get. */
|
||||
exportImages: 'export:images',
|
||||
/** New unified tabular export with column selection + multi-tab support
|
||||
* (CSV / XLSX). Backs the in-table "Export" button. */
|
||||
exportTabular: 'export:tabular',
|
||||
@@ -157,6 +181,62 @@ export const IPC = {
|
||||
pagespeedCancel: 'pagespeed:cancel',
|
||||
/** main → renderer: live progress of an in-flight PageSpeed run. */
|
||||
pagespeedProgress: 'pagespeed:progress',
|
||||
/** Faz 7 — Google OAuth keystone (shared by Search Console, GA4,
|
||||
* Sheets). `googleAuthStart` opens the consent screen in the browser
|
||||
* and catches the loopback redirect; `googleAuthStatus` reports the
|
||||
* stored connection; `googleAuthRevoke` deletes the tokens. */
|
||||
googleAuthStart: 'google:auth-start',
|
||||
googleAuthStatus: 'google:auth-status',
|
||||
googleAuthRevoke: 'google:auth-revoke',
|
||||
/** Faz 7 — Google Search Console. `gscListSites` lists the connected
|
||||
* account's verified properties; `gscFetch` pulls per-page clicks /
|
||||
* impressions / CTR / position for a date range; `gscQuery` lists
|
||||
* crawled pages joined with that stored data. */
|
||||
gscListSites: 'gsc:list-sites',
|
||||
gscFetch: 'gsc:fetch',
|
||||
gscQuery: 'gsc:query',
|
||||
/** Faz 7 — Google Analytics 4. `ga4ListProperties` lists the connected
|
||||
* account's GA4 properties; `ga4Fetch` pulls per-page sessions /
|
||||
* users / pageviews / engagement / avg-duration; `ga4Query` lists
|
||||
* crawled pages joined with that stored data. */
|
||||
ga4ListProperties: 'ga4:list-properties',
|
||||
ga4Fetch: 'ga4:fetch',
|
||||
ga4Query: 'ga4:query',
|
||||
/** Faz 7 — AI integrations (OpenAI / Anthropic / Ollama). `aiRun`
|
||||
* applies a prompt template to each selected URL and stores the
|
||||
* response; `aiQuery` lists crawled pages joined with the latest
|
||||
* result for the selected provider; `aiCancel` aborts an in-flight
|
||||
* run; `aiProgress` streams live progress. */
|
||||
aiRun: 'ai:run',
|
||||
aiCancel: 'ai:cancel',
|
||||
aiQuery: 'ai:query',
|
||||
aiProgress: 'ai:progress',
|
||||
/** Faz 7 — third-party SEO authority providers (Ahrefs / Majestic /
|
||||
* Moz / Semrush). Same shape as the AI run: pick a provider, fetch
|
||||
* per-URL metrics, store them, list joined with crawled pages. */
|
||||
seoRun: 'seo:run',
|
||||
seoCancel: 'seo:cancel',
|
||||
seoQuery: 'seo:query',
|
||||
seoProgress: 'seo:progress',
|
||||
/** Faz 7 — GSC URL Inspection API. `gscInspectRun` inspects the
|
||||
* selected URLs against the chosen Search Console property and
|
||||
* stores the index/coverage verdict per URL. */
|
||||
gscInspectRun: 'gsc:inspect-run',
|
||||
gscInspectCancel: 'gsc:inspect-cancel',
|
||||
gscInspectProgress: 'gsc:inspect-progress',
|
||||
/** Faz 7 — Google Sheets export. Writes a category's rows to a new
|
||||
* spreadsheet on the connected account's Drive and returns the URL. */
|
||||
exportSheets: 'export:sheets',
|
||||
/** Faz 7 — Google BigQuery export. Service-account auth (no OAuth);
|
||||
* creates a timestamped table in the user's project and streams the
|
||||
* category's rows in via `tabledata.insertAll`. */
|
||||
exportBigquery: 'export:bigquery',
|
||||
/** Detail Panel — combined per-URL Analytics view (GSC + GA4 + GSC
|
||||
* URL Inspection) for the selected URL. */
|
||||
urlAnalyticsGet: 'urls:analytics',
|
||||
/** Cross-source orphan-pages report — URLs seen in sitemap / GSC /
|
||||
* GA4 but not in the crawled `urls` table. */
|
||||
reportsOrphanCrossSource: 'reports:orphan-cross-source',
|
||||
} as const;
|
||||
|
||||
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
|
||||
@@ -260,10 +340,14 @@ export type MenuEvent =
|
||||
| 'export-xml'
|
||||
| 'export-html-report'
|
||||
| 'export-bulk'
|
||||
| 'export-sheets'
|
||||
| 'export-bigquery'
|
||||
| 'delete-domain-data'
|
||||
| 'clear-all-data'
|
||||
| 'compare-with-project'
|
||||
| 'save-project-as'
|
||||
| 'save-project-encrypted'
|
||||
| 'open-project-encrypted'
|
||||
| 'open-visualization'
|
||||
| 'generate-sitemap'
|
||||
| 'open-robots-tester'
|
||||
@@ -315,6 +399,23 @@ export interface ExportBrokenLinksResult {
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
export interface ExportImagesInput {
|
||||
/** Optional pre-resolved output path (skips the save dialog). */
|
||||
filePath?: string;
|
||||
/** When true, export only images with no `alt` attribute. Mirrors
|
||||
* the Images tab's "Missing Alt only" sidebar filter. */
|
||||
missingAltOnly?: boolean;
|
||||
/** Free-text URL/alt filter (substring match, case-insensitive).
|
||||
* Mirrors the tab's search box. */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ExportImagesResult {
|
||||
/** 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 {
|
||||
@@ -758,6 +859,13 @@ export interface SitemapOrphanRow {
|
||||
sourceSitemap: string | null;
|
||||
}
|
||||
|
||||
/** One row of the cross-source orphan-pages report. */
|
||||
export interface OrphanCrossSourceRow {
|
||||
url: string;
|
||||
/** Which truth sources mentioned this URL — `sitemap`, `gsc`, `ga4`. */
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
/** One row in the Server Stack report — `Server` response-header rollup. */
|
||||
export interface ServerHeaderRow {
|
||||
server: string;
|
||||
@@ -955,6 +1063,200 @@ export interface PagespeedProgress {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** Result of `gscListSites` — the connected account's GSC properties. */
|
||||
export interface GscListSitesResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
sites: GscSite[];
|
||||
}
|
||||
|
||||
export interface GscFetchInput {
|
||||
/** Search Console property to pull from (`siteUrl` from `GscSite`). */
|
||||
property: string;
|
||||
/** Trailing window in days (Search Console data lags ~2-3 days). */
|
||||
days: 7 | 28 | 90;
|
||||
}
|
||||
|
||||
export interface GscFetchResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
/** Page rows stored from this pull. */
|
||||
rowCount: number;
|
||||
meta: GscFetchMeta | null;
|
||||
}
|
||||
|
||||
export interface GscQueryInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
/** `all` (default), `with-data` (has GSC impressions), `without-data`. */
|
||||
filter?: 'all' | 'with-data' | 'without-data';
|
||||
}
|
||||
|
||||
export interface GscQueryResult {
|
||||
rows: GscRow[];
|
||||
/** Total candidate rows matching the filter (before pagination). */
|
||||
total: number;
|
||||
/** Metadata about the stored pull, or null when none has run. */
|
||||
meta: GscFetchMeta | null;
|
||||
}
|
||||
|
||||
export interface Ga4ListPropertiesResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
properties: Ga4Property[];
|
||||
}
|
||||
|
||||
export interface Ga4FetchInput {
|
||||
/** Property resource name (`properties/<id>`). */
|
||||
property: string;
|
||||
/** Friendly label persisted into the fetch meta for the UI. */
|
||||
propertyName: string;
|
||||
days: 7 | 28 | 90;
|
||||
}
|
||||
|
||||
export interface Ga4FetchResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
rowCount: number;
|
||||
meta: Ga4FetchMeta | null;
|
||||
}
|
||||
|
||||
export interface Ga4QueryInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
filter?: 'all' | 'with-data' | 'without-data';
|
||||
}
|
||||
|
||||
export interface Ga4QueryResult {
|
||||
rows: Ga4Row[];
|
||||
total: number;
|
||||
meta: Ga4FetchMeta | null;
|
||||
}
|
||||
|
||||
export interface AiRunInput {
|
||||
/** Provider to send the prompt to. */
|
||||
provider: AiProvider;
|
||||
/** Optional model override; blank uses the provider's default. */
|
||||
model?: string;
|
||||
/** Prompt template — `{url}` / `{title}` / `{description}` / `{h1}` /
|
||||
* `{body}` are substituted per-URL at run time. */
|
||||
prompt: string;
|
||||
/** URLs to run against (taken from the candidate rows in the tab). */
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface AiRunResult {
|
||||
/** URL × prompts that completed successfully. */
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/** main → renderer live progress while an AI run is in flight. */
|
||||
export interface AiProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
currentUrl: string | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface AiQueryInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
/** Which provider's results to surface in the table. */
|
||||
provider: AiProvider;
|
||||
/** `all` (default), `with-data`, `without-data`, `error`. */
|
||||
filter?: 'all' | 'with-data' | 'without-data' | 'error';
|
||||
}
|
||||
|
||||
export interface AiQueryResult {
|
||||
rows: AiRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SeoRunInput {
|
||||
provider: SeoProvider;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface SeoRunResult {
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface SeoProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
currentUrl: string | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface SeoQueryInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
provider: SeoProvider;
|
||||
filter?: 'all' | 'with-data' | 'without-data' | 'error';
|
||||
}
|
||||
|
||||
export interface SeoQueryResult {
|
||||
rows: SeoRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GscInspectRunInput {
|
||||
/** Search Console property the URLs belong to. */
|
||||
property: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface GscInspectRunResult {
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface GscInspectProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
currentUrl: string | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ExportSheetsInput {
|
||||
/** Category to export — same enum the Bulk Export uses. */
|
||||
category: UrlCategory;
|
||||
/** Optional title for the new spreadsheet. Falls back to the
|
||||
* category label + current date. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ExportSheetsResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
/** Browser URL of the new spreadsheet on success. */
|
||||
url: string | null;
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
export interface ExportBigqueryInput {
|
||||
category: UrlCategory;
|
||||
}
|
||||
|
||||
export interface ExportBigqueryResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
/** GCP console URL pointing at the new table on success. */
|
||||
consoleUrl: string | null;
|
||||
/** Fully-qualified table reference, e.g. `myproj.freecrawl_seo.urls_1716391000`. */
|
||||
tableRef: string | null;
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
/** Monotonic sequence id, increments on every log call this session. */
|
||||
id: number;
|
||||
@@ -976,6 +1278,12 @@ export interface FreeCrawlApi {
|
||||
projectSaveAs(): Promise<{ filePath: string; bytesWritten: number } | null>;
|
||||
projectOpen(filePath?: string): Promise<{ filePath: string } | null>;
|
||||
projectCurrentPath(): Promise<string | null>;
|
||||
projectSaveEncrypted(
|
||||
password: string,
|
||||
): Promise<{ filePath: string; bytesWritten: number } | { error: string } | null>;
|
||||
projectOpenEncrypted(
|
||||
password: string,
|
||||
): Promise<{ filePath: string } | { error: string } | null>;
|
||||
urlsQuery(input: UrlsQueryInput): Promise<UrlsQueryResult>;
|
||||
urlDetailGet(input: UrlDetailInput): Promise<UrlDetail | null>;
|
||||
urlSourceGet(input: UrlSourceInput): Promise<UrlSourceResult>;
|
||||
@@ -991,6 +1299,7 @@ export interface FreeCrawlApi {
|
||||
exportJson(input: ExportJsonInput): Promise<ExportJsonResult>;
|
||||
exportXml(input: ExportXmlInput): Promise<ExportXmlResult>;
|
||||
exportBrokenLinks(input: ExportBrokenLinksInput): Promise<ExportBrokenLinksResult>;
|
||||
exportImages(input: ExportImagesInput): Promise<ExportImagesResult>;
|
||||
exportTabular(input: ExportTabularInput): Promise<ExportTabularResult>;
|
||||
dataDeleteByDomain(
|
||||
input: DataDeleteByDomainInput,
|
||||
@@ -1037,6 +1346,8 @@ export interface FreeCrawlApi {
|
||||
input: WordCountPerDirectoryInput,
|
||||
): Promise<WordCountPerDirectoryRow[]>;
|
||||
reportsSitemapOrphans(limit?: number): Promise<SitemapOrphanRow[]>;
|
||||
/** Cross-source orphan URLs — sitemap ∪ GSC ∪ GA4 minus crawled. */
|
||||
reportsOrphanCrossSource(limit?: number): Promise<OrphanCrossSourceRow[]>;
|
||||
reportsServerHeaders(): Promise<ServerHeaderRow[]>;
|
||||
reportsTopWords(input: TopWordsInput): Promise<TopWordsRow[]>;
|
||||
/** Heartbeat: renderer reports its latest input-lag sample (ms). */
|
||||
@@ -1072,6 +1383,56 @@ export interface FreeCrawlApi {
|
||||
pagespeedCancel(): Promise<void>;
|
||||
/** Live progress of an in-flight PageSpeed run. */
|
||||
onPagespeedProgress(cb: (p: PagespeedProgress) => void): () => void;
|
||||
/** Faz 7 — start the interactive Google OAuth consent flow for one
|
||||
* integration. Resolves once the user finishes (or cancels) in the
|
||||
* browser. */
|
||||
googleAuthStart(integrationId: string): Promise<GoogleAuthResult>;
|
||||
/** Read the stored OAuth connection state of one Google integration. */
|
||||
googleAuthStatus(integrationId: string): Promise<GoogleAuthState>;
|
||||
/** Disconnect — wipe the stored OAuth tokens for one integration. */
|
||||
googleAuthRevoke(integrationId: string): Promise<GoogleAuthState>;
|
||||
/** List the connected account's Search Console properties. */
|
||||
gscListSites(): Promise<GscListSitesResult>;
|
||||
/** Pull per-page Search Console metrics for the given property + range. */
|
||||
gscFetch(input: GscFetchInput): Promise<GscFetchResult>;
|
||||
/** List crawled pages joined with their stored Search Console metrics. */
|
||||
gscQuery(input: GscQueryInput): Promise<GscQueryResult>;
|
||||
/** List the connected account's Google Analytics 4 properties. */
|
||||
ga4ListProperties(): Promise<Ga4ListPropertiesResult>;
|
||||
/** Pull per-page GA4 metrics for the given property + range. */
|
||||
ga4Fetch(input: Ga4FetchInput): Promise<Ga4FetchResult>;
|
||||
/** List crawled pages joined with their stored GA4 metrics. */
|
||||
ga4Query(input: Ga4QueryInput): Promise<Ga4QueryResult>;
|
||||
/** Run an AI prompt against the given URLs through the chosen
|
||||
* provider. Resolves when the run finishes; subscribe to
|
||||
* `onAiProgress` for live updates. */
|
||||
aiRun(input: AiRunInput): Promise<AiRunResult>;
|
||||
/** Request cancellation of an in-flight AI run. */
|
||||
aiCancel(): Promise<void>;
|
||||
/** List crawled pages joined with the latest AI result for the
|
||||
* selected provider. */
|
||||
aiQuery(input: AiQueryInput): Promise<AiQueryResult>;
|
||||
/** Live progress of an in-flight AI run. */
|
||||
onAiProgress(cb: (p: AiProgress) => void): () => void;
|
||||
/** Faz 7 — run an SEO authority provider against the given URLs. */
|
||||
seoRun(input: SeoRunInput): Promise<SeoRunResult>;
|
||||
seoCancel(): Promise<void>;
|
||||
/** List crawled pages joined with their stored SEO provider metrics. */
|
||||
seoQuery(input: SeoQueryInput): Promise<SeoQueryResult>;
|
||||
onSeoProgress(cb: (p: SeoProgress) => void): () => void;
|
||||
/** Run GSC URL Inspection on the given URLs (rate-limited 2K/day). */
|
||||
gscInspectRun(input: GscInspectRunInput): Promise<GscInspectRunResult>;
|
||||
gscInspectCancel(): Promise<void>;
|
||||
onGscInspectProgress(cb: (p: GscInspectProgress) => void): () => void;
|
||||
/** Export a category's rows to a new Google Sheet. Requires the
|
||||
* `sheets` Google integration to be connected. */
|
||||
exportSheets(input: ExportSheetsInput): Promise<ExportSheetsResult>;
|
||||
/** Export a category's rows to a new BigQuery table in the configured
|
||||
* GCP project. Uses the `bigquery` service-account credentials. */
|
||||
exportBigquery(input: ExportBigqueryInput): Promise<ExportBigqueryResult>;
|
||||
/** Read the combined Analytics detail (GSC + GA4 + URL Inspection)
|
||||
* for one URL. Returns null when the URL hasn't been crawled. */
|
||||
urlAnalyticsGet(url: string): Promise<UrlAnalyticsDetail | null>;
|
||||
onLogEntry(cb: (entry: LogEntry) => void): () => void;
|
||||
onLogsBatch(cb: (entries: LogEntry[]) => void): () => void;
|
||||
onLogsBusy(cb: (busy: boolean) => void): () => void;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Faz 7 — Third-party SEO authority data providers.
|
||||
*
|
||||
* Ahrefs, Majestic, Moz, and Semrush all answer the same question with
|
||||
* a different number set: "what does the wider web think of this URL?"
|
||||
* One unified tab + one storage table keep the UI and DB simple — each
|
||||
* provider's specific metrics are serialised into a `metrics` JSON
|
||||
* value, and the UI switches the visible columns per provider.
|
||||
*/
|
||||
|
||||
export type SeoProvider = 'ahrefs' | 'majestic' | 'moz' | 'semrush';
|
||||
|
||||
/** Ahrefs Site Explorer URL metrics. */
|
||||
export interface AhrefsMetrics {
|
||||
domainRating: number | null;
|
||||
urlRating: number | null;
|
||||
backlinks: number | null;
|
||||
refDomains: number | null;
|
||||
}
|
||||
|
||||
/** Majestic GetIndexItemInfo URL metrics. */
|
||||
export interface MajesticMetrics {
|
||||
trustFlow: number | null;
|
||||
citationFlow: number | null;
|
||||
externalBacklinks: number | null;
|
||||
refDomains: number | null;
|
||||
}
|
||||
|
||||
/** Moz Link Explorer / Mozscape URL metrics. */
|
||||
export interface MozMetrics {
|
||||
domainAuthority: number | null;
|
||||
pageAuthority: number | null;
|
||||
spamScore: number | null;
|
||||
linkingDomains: number | null;
|
||||
}
|
||||
|
||||
/** Semrush URL Overview metrics (organic side). */
|
||||
export interface SemrushMetrics {
|
||||
organicKeywords: number | null;
|
||||
organicTraffic: number | null;
|
||||
/** Estimated equivalent paid-ads cost (USD). */
|
||||
organicCost: number | null;
|
||||
adwordsKeywords: number | null;
|
||||
}
|
||||
|
||||
export type SeoMetrics =
|
||||
| AhrefsMetrics
|
||||
| MajesticMetrics
|
||||
| MozMetrics
|
||||
| SemrushMetrics;
|
||||
|
||||
/** Per-URL SEO authority result stored in `seo_results`. */
|
||||
export interface SeoResult {
|
||||
provider: SeoProvider;
|
||||
/** Provider-specific metric object — type-narrowed at the call site. */
|
||||
metrics: SeoMetrics | null;
|
||||
status: 'ok' | 'error';
|
||||
error: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** One SEO tab row — a crawled page joined with its provider data. */
|
||||
export interface SeoRow {
|
||||
url: string;
|
||||
statusCode: number | null;
|
||||
/** Null when this page has no result for the currently-queried provider. */
|
||||
seo: SeoResult | null;
|
||||
}
|
||||
Reference in new issue
Block a user