mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: JS rendering + Export Crawl Data dialog + tree view + standalone Visualization
This commit is contained in:
1 parent
cac81ec93c
commit
7f42901d38
40 files changed
+5210
-521
No files matched your search
@@ -59,7 +59,15 @@ jobs:
|
||||
- name: Build shared packages
|
||||
run: npx tsc -b
|
||||
|
||||
- name: Build installer
|
||||
- name: Ensure Playwright browsers downloaded
|
||||
# `npm ci` runs `postinstall` which already pulls Chromium +
|
||||
# chrome-headless-shell, but we re-invoke explicitly to make
|
||||
# the dependency obvious in the workflow logs and to force a
|
||||
# re-check if Playwright was upgraded since the lockfile was
|
||||
# written.
|
||||
run: npm run playwright:install
|
||||
|
||||
- name: Build installer (with bundled Playwright browsers)
|
||||
run: npm --workspace apps/desktop run ${{ matrix.build_script }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -67,6 +75,13 @@ jobs:
|
||||
# cert in CI yet. Users will see a Gatekeeper warning on first
|
||||
# launch and must right-click → Open the .dmg's app once.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
# V2 Faz 1 — Bundle the Playwright Chromium browser into the
|
||||
# installer so JS rendering works offline / on first launch
|
||||
# with zero extra download. The prepare-playwright-bundle.mjs
|
||||
# script reads this env var and copies the OS cache into
|
||||
# apps/desktop/resources/playwright-browsers/ before
|
||||
# electron-builder packs the installer.
|
||||
BUNDLE_PLAYWRIGHT: '1'
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -38,6 +38,7 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
|
||||
- robots.txt, sitemaps, redirect chains
|
||||
- HTTP Basic / Bearer auth, proxy override
|
||||
- Pause / Resume / manual URL injection
|
||||
- **JavaScript rendering** via headless Chromium (Playwright) — post-JS DOM, screenshot capture (full / above-fold / mobile), LCP candidate, Mobile Usability audit
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -67,10 +68,13 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
|
||||
|
||||
### 🖥 Desktop UI
|
||||
- Dense dark theme, virtualized 1M+ row tables
|
||||
- **Multi-language UI** (English + Turkish) — switch instantly from Settings → Language
|
||||
- **Multi-language UI** (English + Turkish) — full coverage including all Settings panels
|
||||
- Live-streaming rows during crawl
|
||||
- Advanced AND/OR search (24 fields × 12 ops)
|
||||
- Detail panel with 13 sub-tabs — **HTTP Headers** has Request/Response toggle + **status-code diagnosis banner** (decodes Cloudflare 52x/530 and 4xx/5xx with header signals)
|
||||
- **Per-tab quick-filter dropdown** — instant filtering by Overview sub-categories without leaving the tab
|
||||
- **List ↔ Tree view toggle** — switch between flat virtual table and URL-path hierarchy with collapsible folders
|
||||
- Detail panel with 15 sub-tabs — adds **View Rendered** (post-JS DOM) + **Screenshot** (full/fold/mobile preview) when JS rendering is on
|
||||
- **Visualization in its own native window** — open from menu, park on a second monitor while data tables stay free
|
||||
- Per-URL **Duplicates** view (cluster siblings)
|
||||
- **Live memory monitor** in status bar (RSS / Sys Free / per-URL cost / capacity projection)
|
||||
- **Robots.txt syntax validator** in the Robots Tester (typo suggestions, orphan rules, sitemap URL check)
|
||||
@@ -86,8 +90,8 @@ A high-performance website crawler built for serious SEO audits. Targets <kbd>1M
|
||||
|
||||
### 📤 Export & Reports
|
||||
- **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) + dedicated **Images CSV export** (honours missing-alt + search)
|
||||
- **Export Crawl Data dialog** — single menu entry with format picker (**Excel `.xlsx`** / **CSV UTF-8** / **JSON** / **XML**) + hierarchical tree picker (top tables + structural sub-categories) + nested folder output
|
||||
- Per-tab in-context export button (current category) + dedicated **Images CSV export** (honours missing-alt + search)
|
||||
- Standalone HTML audit report
|
||||
- Sitemap generator (image / hreflang / sharded / gz)
|
||||
- Project-vs-project compare diff
|
||||
@@ -449,9 +453,9 @@ graph LR
|
||||
## 📈 Status
|
||||
|
||||
> [!NOTE]
|
||||
> **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**.
|
||||
> **Active development.** All 29 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**) plus standalone Visualization window, advanced search, **per-tab quick-filter dropdown** + **List/Tree view toggle**, 150+ issue categories, sitemap export variants, **Export Crawl Data dialog (XLSX / CSV-UTF-8 / JSON / XML with hierarchical tree picker + nested folder output)**, 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**, **JavaScript rendering with Playwright (post-JS DOM, screenshot capture, LCP candidate, Mobile Usability audit)**, **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) with full Settings coverage**, **`.seoproject` file association**, in-app logs, and diagnostic popups are working. Cross-platform installers (Windows `.exe` + portable, macOS `.dmg`, Linux `.AppImage` / `.deb` / `.rpm`) — **release builds ship Playwright Chromium offline so JS rendering works on first launch**. Live-streaming UX with **first row in ~1 s**.
|
||||
>
|
||||
> **Upcoming (V2):** JavaScript rendering (Chromium pool), Screenshot capture, Log file analyzer, Plugin system, Light theme, Multi-window, Code-signing + auto-update.
|
||||
> **Upcoming (V2):** Log file analyzer, Plugin system, Light theme, Multi-window, Code-signing + auto-update.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -17,6 +17,24 @@ files:
|
||||
asar: true
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
# V2 Faz 1 — JS rendering. By default the Playwright browser cache is
|
||||
# NOT bundled into the installer (saves ~250 MB). The main process
|
||||
# prompts the user to download the browser on first JS-render crawl.
|
||||
#
|
||||
# To opt into bundling (offline installer / enterprise distribution):
|
||||
# 1. Run `npm run playwright:install` so the cache exists locally.
|
||||
# 2. Build with `BUNDLE_PLAYWRIGHT=1`:
|
||||
# $env:BUNDLE_PLAYWRIGHT='1'; npm --workspace apps/desktop run build:win
|
||||
# The build script copies `~/AppData/Local/ms-playwright` into
|
||||
# `apps/desktop/resources/playwright-browsers/` BEFORE
|
||||
# electron-builder runs, and the `extraResources` glob below picks
|
||||
# it up. When the env var is unset the resources folder is empty
|
||||
# and the glob no-ops.
|
||||
extraResources:
|
||||
- from: "resources/playwright-browsers"
|
||||
to: "playwright-browsers"
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target:
|
||||
- target: nsis
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"description": "FreeCrawl SEO — desktop application",
|
||||
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
|
||||
@@ -17,9 +17,10 @@
|
||||
"dev": "electron-vite dev",
|
||||
"start": "electron-vite preview",
|
||||
"build": "electron-vite build",
|
||||
"build:win": "electron-vite build && electron-builder --win --publish never",
|
||||
"build:mac": "electron-vite build && electron-builder --mac --publish never",
|
||||
"build:linux": "electron-vite build && electron-builder --linux --publish never",
|
||||
"prepare:playwright-bundle": "node scripts/prepare-playwright-bundle.mjs",
|
||||
"build:win": "npm run prepare:playwright-bundle && electron-vite build && electron-builder --win --publish never",
|
||||
"build:mac": "npm run prepare:playwright-bundle && electron-vite build && electron-builder --mac --publish never",
|
||||
"build:linux": "npm run prepare:playwright-bundle && electron-vite build && electron-builder --linux --publish never",
|
||||
"typecheck:node": "tsc -p tsconfig.node.json --noEmit",
|
||||
"typecheck:web": "tsc -p tsconfig.web.json --noEmit",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* V2 Faz 1 — Optional bundling of Playwright browsers into the
|
||||
* installer. Reads `BUNDLE_PLAYWRIGHT=1` from the environment; when
|
||||
* set, copies the user's local Playwright cache into
|
||||
* `apps/desktop/resources/playwright-browsers/` so electron-builder's
|
||||
* `extraResources` rule packs it into the .exe / .dmg / .AppImage.
|
||||
*
|
||||
* When the env var is NOT set, the resources directory is wiped (or
|
||||
* left empty) so the installer stays small (~150 MB instead of ~400
|
||||
* MB) and users download the browser on demand via the in-app prompt.
|
||||
*
|
||||
* Invoke from the desktop workspace BEFORE `electron-builder`:
|
||||
* $env:BUNDLE_PLAYWRIGHT='1'; node scripts/prepare-playwright-bundle.mjs
|
||||
*
|
||||
* Idempotent: safe to run multiple times.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const desktopDir = join(__filename, '..', '..');
|
||||
const targetDir = join(desktopDir, 'resources', 'playwright-browsers');
|
||||
|
||||
function sourceCacheDir() {
|
||||
if (platform() === 'win32') {
|
||||
return join(
|
||||
process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
|
||||
'ms-playwright',
|
||||
);
|
||||
}
|
||||
if (platform() === 'darwin') {
|
||||
return join(homedir(), 'Library', 'Caches', 'ms-playwright');
|
||||
}
|
||||
return join(homedir(), '.cache', 'ms-playwright');
|
||||
}
|
||||
|
||||
if (process.env.BUNDLE_PLAYWRIGHT !== '1') {
|
||||
// Bundling disabled — wipe any leftover from a previous build so the
|
||||
// small-installer default produces a small file.
|
||||
if (existsSync(targetDir)) {
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
// Create empty dir so electron-builder's `extraResources` glob doesn't
|
||||
// error on missing source.
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
console.log(
|
||||
'[prepare-playwright-bundle] BUNDLE_PLAYWRIGHT not set — skipping (installer will prompt for download).',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const src = sourceCacheDir();
|
||||
if (!existsSync(src)) {
|
||||
console.error(
|
||||
`[prepare-playwright-bundle] BUNDLE_PLAYWRIGHT=1 but cache directory not found at ${src}.\n` +
|
||||
'Run "npm run playwright:install" from the monorepo root first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[prepare-playwright-bundle] Copying ${src} → ${targetDir} …`);
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
// `verbatimSymlinks: true` preserves the symlinks Playwright uses
|
||||
// inside the macOS .app bundle structure. Without this they'd be
|
||||
// dereferenced, doubling the on-disk size and breaking the bundle's
|
||||
// Mach-O loader paths.
|
||||
cpSync(src, targetDir, { recursive: true, verbatimSymlinks: true });
|
||||
console.log('[prepare-playwright-bundle] Done.');
|
||||
@@ -70,6 +70,10 @@ const ALLOWED_METHODS = new Set<string>([
|
||||
'insertImages',
|
||||
'setUrlHeaders',
|
||||
'setUrlSource',
|
||||
'setUrlRenderedBody',
|
||||
'setUrlScreenshotPaths',
|
||||
'setUrlLcpCandidate',
|
||||
'setUrlMobileUsability',
|
||||
'setSitemapUrls',
|
||||
'setHostCert',
|
||||
'setImageSize',
|
||||
|
||||
+572
-14
@@ -12,8 +12,9 @@ import {
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { basename, dirname, join, resolve as resolvePath, sep as pathSep } from 'node:path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { totalmem, freemem } from 'node:os';
|
||||
import {
|
||||
DEFAULT_CRAWL_CONFIG,
|
||||
@@ -128,7 +129,11 @@ import {
|
||||
type UrlAnalyticsDetail,
|
||||
} from '@freecrawl/shared-types';
|
||||
import {
|
||||
BrowserPool,
|
||||
PlaywrightBrowserMissingError,
|
||||
Crawler,
|
||||
renderUrl,
|
||||
auditMobileUsability,
|
||||
exportUrlsToCsv,
|
||||
exportBrokenLinksToCsv,
|
||||
exportImagesToCsv,
|
||||
@@ -188,8 +193,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let logsWindow: BrowserWindow | null = null;
|
||||
/** Standalone Visualization popup — single-instance, native window. */
|
||||
let visualizationWindow: BrowserWindow | null = null;
|
||||
let db: ProjectDb | null = null;
|
||||
let activeCrawler: Crawler | null = null;
|
||||
/** V2 Faz 1 — Playwright browser pool, lazily started when a JS-mode
|
||||
* crawl launches. Disposed on crawl stop/clear/app quit. */
|
||||
let activeBrowserPool: BrowserPool | null = null;
|
||||
/** Faz 7 — PageSpeed run guard. Only one PSI batch may run at a time;
|
||||
* `pagespeedCancelRequested` is the cooperative cancel flag. */
|
||||
let pagespeedRunActive = false;
|
||||
@@ -219,7 +229,7 @@ let latestProgress: CrawlProgress | null = null;
|
||||
* having to know about the closure-scoped helpers. Null before
|
||||
* `registerIpc()` runs. */
|
||||
let crawlController: {
|
||||
launchCrawl: (config: CrawlConfig) => void;
|
||||
launchCrawl: (config: CrawlConfig) => Promise<void>;
|
||||
stopCrawl: () => void;
|
||||
pauseCrawl: () => void;
|
||||
resumeCrawl: () => void;
|
||||
@@ -289,6 +299,11 @@ function flushPrefs(): void {
|
||||
|
||||
function fireDataChanged(): void {
|
||||
mainWindow?.webContents.send(IPC.dataChanged);
|
||||
// Mirror the event to the standalone Visualization window so its
|
||||
// Cytoscape graph repaints when a crawl finishes or rows change.
|
||||
if (visualizationWindow && !visualizationWindow.isDestroyed()) {
|
||||
visualizationWindow.webContents.send(IPC.dataChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,7 +371,7 @@ function schedulerTick(): void {
|
||||
`Scheduled crawl firing: ${lastCrawlConfig.startUrl} (cadence=${entry.spec.cadence})`,
|
||||
);
|
||||
crawlerStartedByScheduler = true;
|
||||
crawlController.launchCrawl(lastCrawlConfig);
|
||||
void crawlController.launchCrawl(lastCrawlConfig);
|
||||
}
|
||||
|
||||
function startScheduler(): void {
|
||||
@@ -682,7 +697,33 @@ function getDb(): ProjectDb {
|
||||
// until the user accepts/discards the recovery prompt.
|
||||
try {
|
||||
const raw = db.getMeta('lastCrawlConfig');
|
||||
if (raw) lastCrawlConfig = JSON.parse(raw) as CrawlConfig;
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<CrawlConfig>;
|
||||
lastCrawlConfig = {
|
||||
...DEFAULT_CRAWL_CONFIG,
|
||||
...parsed,
|
||||
jsRender: {
|
||||
...DEFAULT_CRAWL_CONFIG.jsRender,
|
||||
...(parsed.jsRender ?? {}),
|
||||
blockResources: {
|
||||
...DEFAULT_CRAWL_CONFIG.jsRender.blockResources,
|
||||
...(parsed.jsRender?.blockResources ?? {}),
|
||||
},
|
||||
screenshotMode:
|
||||
parsed.jsRender?.screenshotMode ??
|
||||
DEFAULT_CRAWL_CONFIG.jsRender.screenshotMode,
|
||||
mobileScreenshot:
|
||||
parsed.jsRender?.mobileScreenshot ??
|
||||
DEFAULT_CRAWL_CONFIG.jsRender.mobileScreenshot,
|
||||
mobileUsability:
|
||||
parsed.jsRender?.mobileUsability ??
|
||||
DEFAULT_CRAWL_CONFIG.jsRender.mobileUsability,
|
||||
lcpCandidate:
|
||||
parsed.jsRender?.lcpCandidate ??
|
||||
DEFAULT_CRAWL_CONFIG.jsRender.lcpCandidate,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* malformed JSON or missing key — recovery just won't fire */
|
||||
}
|
||||
@@ -1536,6 +1577,69 @@ function openLogsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 — Standalone Visualization window. Single-instance; the same
|
||||
* renderer bundle loads with `?visualization=1` so React mounts only
|
||||
* `VisualizationView` (the Cytoscape graph) instead of the full App.
|
||||
* Keeps the visualization off the main tab strip and gives it its own
|
||||
* OS window the user can move to a second monitor.
|
||||
*/
|
||||
function openVisualizationWindow(): void {
|
||||
if (visualizationWindow && !visualizationWindow.isDestroyed()) {
|
||||
visualizationWindow.show();
|
||||
visualizationWindow.focus();
|
||||
return;
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
backgroundColor: '#0a0a0a',
|
||||
title: 'FreeCrawl — Visualization',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
win.setMenu(null);
|
||||
win.on('ready-to-show', () => win.show());
|
||||
win.on('page-title-updated', (e) => e.preventDefault());
|
||||
win.on('closed', () => {
|
||||
if (visualizationWindow === win) visualizationWindow = null;
|
||||
});
|
||||
// Mirror the main window's dev-tools lockdown.
|
||||
win.webContents.on('before-input-event', (e, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
const key = input.key.toLowerCase();
|
||||
const mod = input.control || input.meta;
|
||||
const isF12 = key === 'f12';
|
||||
const isCtrlShiftI = mod && input.shift && key === 'i';
|
||||
const isCtrlAltI = mod && input.alt && key === 'i';
|
||||
const isCtrlShiftJ = mod && input.shift && key === 'j';
|
||||
const isCtrlShiftC = mod && input.shift && key === 'c';
|
||||
if (isF12 || isCtrlShiftI || isCtrlAltI || isCtrlShiftJ || isCtrlShiftC) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
win.webContents.on('devtools-opened', () => {
|
||||
win.webContents.closeDevTools();
|
||||
});
|
||||
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||
void win.loadURL(process.env['ELECTRON_RENDERER_URL'] + '?visualization=1');
|
||||
} else {
|
||||
void win.loadFile(join(__dirname, '../renderer/index.html'), {
|
||||
search: 'visualization=1',
|
||||
});
|
||||
}
|
||||
visualizationWindow = win;
|
||||
logger.log('info', 'main', 'Visualization window opened');
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle(IPC.appVersion, () => app.getVersion());
|
||||
|
||||
@@ -1585,6 +1689,36 @@ function registerIpc(): void {
|
||||
logger.log('info', 'main', 'Log buffer cleared');
|
||||
});
|
||||
ipcMain.handle(IPC.logsOpenWindow, () => openLogsWindow());
|
||||
ipcMain.handle(IPC.visualizationOpenWindow, () => openVisualizationWindow());
|
||||
ipcMain.handle(
|
||||
IPC.screenshotRead,
|
||||
async (_e, absolutePath: string): Promise<string | null> => {
|
||||
if (typeof absolutePath !== 'string' || absolutePath.length === 0) return null;
|
||||
// Resolve both the requested file AND the allowed root so `..`
|
||||
// traversal can't escape the sidecar. Without `path.resolve` the
|
||||
// naive startsWith() lets `<root>/../../../etc/passwd` pass.
|
||||
const resolved = resolvePath(absolutePath);
|
||||
const allowedRoots: string[] = [];
|
||||
if (currentProjectPath) {
|
||||
const dir = resolveScreenshotDir(currentProjectPath);
|
||||
if (dir) allowedRoots.push(resolvePath(dir));
|
||||
}
|
||||
// Append the separator so that `<root>` doesn't match `<root>2`.
|
||||
const safe = allowedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + pathSep),
|
||||
);
|
||||
if (!safe) return null;
|
||||
// Only allow PNGs — extra defence in depth against the guard
|
||||
// returning unintended files if the roots set ever broadens.
|
||||
if (!resolved.toLowerCase().endsWith('.png')) return null;
|
||||
try {
|
||||
const buf = readFileSync(resolved);
|
||||
return `data:image/png;base64,${buf.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC.robotsValidate, (_e, text: string) =>
|
||||
validateRobotsTxt(typeof text === 'string' ? text : ''),
|
||||
@@ -2814,7 +2948,7 @@ function registerIpc(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function launchCrawl(config: CrawlConfig): void {
|
||||
async function launchCrawl(config: CrawlConfig): Promise<void> {
|
||||
if (activeCrawler) {
|
||||
activeCrawler.stop();
|
||||
logger.log('info', 'crawler', 'Stopped previous crawl before starting a new one');
|
||||
@@ -2838,6 +2972,235 @@ function registerIpc(): void {
|
||||
/* never fatal */
|
||||
}
|
||||
lastCrawlConfig = config;
|
||||
|
||||
// V2 Faz 1 — Spin up the Playwright browser pool when JS rendering
|
||||
// is enabled. Pool start is lazy (Chromium launches on first
|
||||
// acquire) so HTTP-only crawls pay nothing. Dispose any previous
|
||||
// pool first; pool reuse across runs would carry over routes /
|
||||
// viewport from a config that may have changed.
|
||||
void disposeBrowserPool();
|
||||
let renderHook:
|
||||
| ((
|
||||
url: string,
|
||||
urlId: number | null,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
html: string;
|
||||
timingMs: number;
|
||||
error?: string;
|
||||
screenshots?: { fullpage?: string; fold?: string; mobile?: string };
|
||||
lcp?: {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
coverage: number;
|
||||
resourceUrl: string | null;
|
||||
} | null;
|
||||
mobileUsability?: {
|
||||
ok: boolean;
|
||||
overflowPx: number;
|
||||
hasViewportMeta: boolean;
|
||||
} | null;
|
||||
}>)
|
||||
| undefined;
|
||||
if (config.renderingMode === 'js') {
|
||||
const blocked = new Set<string>();
|
||||
if (config.jsRender.blockResources.image) blocked.add('image');
|
||||
if (config.jsRender.blockResources.font) blocked.add('font');
|
||||
if (config.jsRender.blockResources.media) blocked.add('media');
|
||||
if (config.jsRender.blockResources.stylesheet) blocked.add('stylesheet');
|
||||
if (config.jsRender.blockResources.script) blocked.add('script');
|
||||
activeBrowserPool = new BrowserPool({
|
||||
maxPages:
|
||||
config.jsRender.maxPages > 0
|
||||
? config.jsRender.maxPages
|
||||
: Math.max(1, Math.min(8, config.maxConcurrency)),
|
||||
headless: config.jsRender.headless,
|
||||
viewport: {
|
||||
width: config.jsRender.viewportWidth,
|
||||
height: config.jsRender.viewportHeight,
|
||||
},
|
||||
userAgent: config.userAgent || undefined,
|
||||
acceptLanguage: config.acceptLanguage || undefined,
|
||||
channel: config.jsRender.browserChannel || undefined,
|
||||
blockResourceTypes: blocked.size > 0 ? blocked : undefined,
|
||||
// Bug #2 — register once at context level. Setting it on
|
||||
// each page accumulated across reused slots; a long crawl
|
||||
// ran the snippet N times per navigation.
|
||||
prerenderJs: config.jsRender.prerenderJs || undefined,
|
||||
});
|
||||
const pool = activeBrowserPool;
|
||||
|
||||
// V2 Faz 1 — Eagerly start the pool so we can detect a missing
|
||||
// Playwright browser BEFORE any URL hits the render hook.
|
||||
// Without this, the binary-missing error fires once per URL as
|
||||
// a "JS render threw" warning, spamming logs and never giving
|
||||
// the user a chance to install. Now: one prompt, install (if
|
||||
// accepted), retry once. If still missing, drop JS render for
|
||||
// this crawl and continue with text-only mode.
|
||||
try {
|
||||
await pool.start();
|
||||
} catch (err) {
|
||||
if (err instanceof PlaywrightBrowserMissingError) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'crawler',
|
||||
'JS render disabled — Playwright browser binary missing. Prompting user to install.',
|
||||
);
|
||||
const installed = await offerPlaywrightInstall();
|
||||
if (installed) {
|
||||
try {
|
||||
// Pool's `closed` was never set; just retry start.
|
||||
await pool.start();
|
||||
} catch (retryErr) {
|
||||
logger.log(
|
||||
'error',
|
||||
'crawler',
|
||||
`JS render pool still failed after install: ${retryErr instanceof Error ? retryErr.message : String(retryErr)} — falling back to text mode for this crawl.`,
|
||||
);
|
||||
await disposeBrowserPool();
|
||||
config = { ...config, renderingMode: 'text' };
|
||||
renderHook = undefined;
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
'warn',
|
||||
'crawler',
|
||||
'User declined Playwright install — falling back to text mode for this crawl.',
|
||||
);
|
||||
await disposeBrowserPool();
|
||||
config = { ...config, renderingMode: 'text' };
|
||||
renderHook = undefined;
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
'error',
|
||||
'crawler',
|
||||
`Browser pool start failed: ${err instanceof Error ? err.message : String(err)} — falling back to text mode.`,
|
||||
);
|
||||
await disposeBrowserPool();
|
||||
config = { ...config, renderingMode: 'text' };
|
||||
renderHook = undefined;
|
||||
}
|
||||
}
|
||||
const screenshotDir = resolveScreenshotDir(currentProjectPath);
|
||||
const wantsScreenshot = config.jsRender.screenshotMode !== 'none';
|
||||
const wantsMobile =
|
||||
config.jsRender.mobileScreenshot || config.jsRender.mobileUsability;
|
||||
renderHook = async (url: string, _urlId: number | null, signal: AbortSignal) => {
|
||||
if (!pool || pool.isClosed()) {
|
||||
return { ok: false, html: '', timingMs: 0, error: 'pool closed' };
|
||||
}
|
||||
let screenshotPaths: { fullpage?: string; fold?: string } | undefined;
|
||||
if (wantsScreenshot && screenshotDir) {
|
||||
try {
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
} catch {
|
||||
/* failure here just disables screenshot for this URL */
|
||||
}
|
||||
const stem = hashUrlStem(url);
|
||||
const mode = config.jsRender.screenshotMode;
|
||||
screenshotPaths = {};
|
||||
if (mode === 'fullpage' || mode === 'both') {
|
||||
screenshotPaths.fullpage = join(screenshotDir, `${stem}-fullpage.png`);
|
||||
}
|
||||
if (mode === 'fold' || mode === 'both') {
|
||||
screenshotPaths.fold = join(screenshotDir, `${stem}-fold.png`);
|
||||
}
|
||||
}
|
||||
const res = await renderUrl(
|
||||
url,
|
||||
pool,
|
||||
{
|
||||
timeoutMs: Math.max(5000, config.requestTimeoutMs),
|
||||
ajaxTimeoutMs: config.jsRender.ajaxTimeoutMs,
|
||||
waitSelector: config.jsRender.waitSelector || undefined,
|
||||
waitUntil: config.jsRender.waitUntil,
|
||||
screenshotMode: config.jsRender.screenshotMode,
|
||||
detectLcp: config.jsRender.lcpCandidate,
|
||||
screenshotPaths,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
// Mobile pass — runs only when screenshot or usability is opted-in.
|
||||
// Reuses the same pool but flips viewport temporarily; the
|
||||
// function restores the previous size before releasing the page.
|
||||
let mobileScreenshotPath: string | undefined;
|
||||
let mobileUsability:
|
||||
| { ok: boolean; overflowPx: number; hasViewportMeta: boolean }
|
||||
| null = null;
|
||||
if (wantsMobile && pool && !pool.isClosed() && res.ok) {
|
||||
try {
|
||||
if (config.jsRender.mobileScreenshot && screenshotDir) {
|
||||
const stem = hashUrlStem(url);
|
||||
const path = join(screenshotDir, `${stem}-mobile.png`);
|
||||
const mobileRes = await renderUrl(
|
||||
url,
|
||||
pool,
|
||||
{
|
||||
timeoutMs: Math.max(5000, config.requestTimeoutMs),
|
||||
ajaxTimeoutMs: Math.min(config.jsRender.ajaxTimeoutMs, 1000),
|
||||
waitUntil: config.jsRender.waitUntil,
|
||||
screenshotMode: 'fold',
|
||||
screenshotPaths: { fold: path },
|
||||
// Bug #1 — actually use a mobile viewport so the PNG
|
||||
// matches the column name. Without this, the file
|
||||
// was a duplicate of the desktop above-the-fold.
|
||||
viewport: { width: 375, height: 667 },
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (mobileRes.ok && mobileRes.screenshots?.fold) {
|
||||
mobileScreenshotPath = mobileRes.screenshots.fold;
|
||||
}
|
||||
}
|
||||
if (config.jsRender.mobileUsability) {
|
||||
const audit = await auditMobileUsability(
|
||||
url,
|
||||
pool,
|
||||
{
|
||||
timeoutMs: Math.max(5000, config.requestTimeoutMs),
|
||||
waitUntil: config.jsRender.waitUntil,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (audit) {
|
||||
mobileUsability = {
|
||||
ok: audit.ok,
|
||||
overflowPx: audit.overflowPx,
|
||||
hasViewportMeta: audit.hasViewportMeta,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'debug',
|
||||
'crawler',
|
||||
`mobile pass failed for ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const screenshots =
|
||||
res.screenshots || mobileScreenshotPath
|
||||
? {
|
||||
...(res.screenshots ?? {}),
|
||||
...(mobileScreenshotPath ? { mobile: mobileScreenshotPath } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
ok: res.ok,
|
||||
html: res.html,
|
||||
timingMs: res.timingMs,
|
||||
error: res.error,
|
||||
screenshots,
|
||||
lcp: res.lcp ?? null,
|
||||
mobileUsability,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const crawler = new Crawler(config, database, {
|
||||
setOp: (op: string) => freezeWatchdog.setMainOp(op),
|
||||
parseHtml: parseHtmlViaPool,
|
||||
@@ -2845,18 +3208,139 @@ function registerIpc(): void {
|
||||
recomputeIssues: recomputeIssuesViaPool,
|
||||
runDbPass: runDbPassViaPool,
|
||||
dbCall: dbCallViaPool,
|
||||
renderUrl: renderHook,
|
||||
});
|
||||
activeCrawler = crawler;
|
||||
attachCrawlerListeners(crawler);
|
||||
crawler.on('done', () => {
|
||||
// Best-effort dispose — keeps Chromium from lingering after a
|
||||
// crawl ends. Pool is recreated on next JS-mode launch.
|
||||
void disposeBrowserPool();
|
||||
});
|
||||
crawler.on('error', () => {
|
||||
void disposeBrowserPool();
|
||||
});
|
||||
void crawler.start();
|
||||
}
|
||||
|
||||
ipcMain.handle(IPC.crawlStart, (_e, config: CrawlConfig) => {
|
||||
launchCrawl(config);
|
||||
/**
|
||||
* V2 Faz 1 — Resolve the per-project screenshots sidecar directory.
|
||||
* Lives alongside the `.seoproject` file as `<project>.screenshots/`
|
||||
* so it tracks with Save As / move operations.
|
||||
*/
|
||||
/**
|
||||
* V2 Faz 1 — Once-per-session prompt asking the user to install the
|
||||
* Playwright browser binaries when JS rendering is enabled but the
|
||||
* binaries are missing on disk. Spawned via `npx playwright install`
|
||||
* inheriting the user-data env so the cache lands in the standard
|
||||
* location. Returns true when the install command finished cleanly
|
||||
* (the caller can then retry the BrowserPool start).
|
||||
*/
|
||||
let playwrightInstallPromise: Promise<boolean> | null = null;
|
||||
async function offerPlaywrightInstall(): Promise<boolean> {
|
||||
if (playwrightInstallPromise) return playwrightInstallPromise;
|
||||
if (!mainWindow) return false;
|
||||
const res = await dialog.showMessageBox(mainWindow, {
|
||||
type: 'question',
|
||||
title: 'JavaScript Rendering — Browser Missing',
|
||||
message:
|
||||
'Playwright needs to download a Chromium browser before JavaScript rendering can run.',
|
||||
detail:
|
||||
'This is a one-time ~250 MB download. The browser is stored in your user cache; FreeCrawl never sends any data to Playwright servers — only the binary is downloaded from cdn.playwright.dev.\n\nDownload now?',
|
||||
buttons: ['Download now', 'Skip — disable JS render for this run'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (res.response !== 0) return false;
|
||||
playwrightInstallPromise = new Promise<boolean>((resolve) => {
|
||||
const { spawn } = require('node:child_process') as typeof import('node:child_process');
|
||||
const proc = spawn(
|
||||
'npx',
|
||||
['playwright', 'install', 'chromium', 'chromium-headless-shell'],
|
||||
{
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let stderr = '';
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
const line = chunk.toString().trim();
|
||||
if (line) logger.log('info', 'playwright-install', line);
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
playwrightInstallPromise = null;
|
||||
if (code === 0) {
|
||||
logger.log('info', 'playwright-install', 'Browser install complete.');
|
||||
resolve(true);
|
||||
} else {
|
||||
logger.log(
|
||||
'error',
|
||||
'playwright-install',
|
||||
`Browser install failed (exit ${code}). stderr: ${stderr.slice(0, 500)}`,
|
||||
);
|
||||
dialog.showErrorBox(
|
||||
'Browser Install Failed',
|
||||
`Playwright install exited with code ${code}.\n\n` +
|
||||
`Try running "npx playwright install chromium chromium-headless-shell" manually from a terminal in the FreeCrawl source folder.\n\n${stderr.slice(0, 400)}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
playwrightInstallPromise = null;
|
||||
logger.log('error', 'playwright-install', `spawn failed: ${err.message}`);
|
||||
dialog.showErrorBox(
|
||||
'Browser Install Failed',
|
||||
`Could not run "npx playwright install": ${err.message}\n\nPlease install Playwright browsers manually from a terminal.`,
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
return playwrightInstallPromise;
|
||||
}
|
||||
|
||||
function resolveScreenshotDir(projectPath: string): string | null {
|
||||
if (!projectPath) return null;
|
||||
const dir = dirname(projectPath);
|
||||
const base = basename(projectPath);
|
||||
return join(dir, `${base}.screenshots`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable 16-char hex stem derived from the URL. Used as the screenshot
|
||||
* filename prefix so re-crawls overwrite the previous capture instead
|
||||
* of accumulating files.
|
||||
*/
|
||||
function hashUrlStem(url: string): string {
|
||||
return createHash('sha1').update(url).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
async function disposeBrowserPool(): Promise<void> {
|
||||
if (!activeBrowserPool) return;
|
||||
const p = activeBrowserPool;
|
||||
activeBrowserPool = null;
|
||||
try {
|
||||
await p.close();
|
||||
} catch (err) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'crawler',
|
||||
`Browser pool close failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(IPC.crawlStart, async (_e, config: CrawlConfig) => {
|
||||
await launchCrawl(config);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.crawlStop, () => {
|
||||
activeCrawler?.stop();
|
||||
void disposeBrowserPool();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.crawlPause, () => {
|
||||
@@ -2881,6 +3365,7 @@ function registerIpc(): void {
|
||||
const t0 = Date.now();
|
||||
activeCrawler?.stop();
|
||||
activeCrawler = null;
|
||||
await disposeBrowserPool();
|
||||
const database = getDb();
|
||||
// Route reset() through the writer pool so it sits behind any
|
||||
// in-flight writeFetchedUrl RPCs in the worker's FIFO. Without
|
||||
@@ -3408,13 +3893,30 @@ function registerIpc(): void {
|
||||
(_e, input: UrlSourceInput): UrlSourceResult => {
|
||||
const r = getDb().getUrlSource(input.id);
|
||||
if (!r) {
|
||||
return { body: null, bodyLength: 0, truncated: false, capturedAt: null };
|
||||
return {
|
||||
body: null,
|
||||
bodyLength: 0,
|
||||
truncated: false,
|
||||
capturedAt: null,
|
||||
renderedBody: null,
|
||||
renderedBodyLength: null,
|
||||
renderMs: null,
|
||||
screenshotFullpagePath: null,
|
||||
screenshotFoldPath: null,
|
||||
screenshotMobilePath: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
body: r.body,
|
||||
bodyLength: r.bodyLength,
|
||||
truncated: r.truncated,
|
||||
capturedAt: r.capturedAt,
|
||||
renderedBody: r.renderedBody,
|
||||
renderedBodyLength: r.renderedBodyLength,
|
||||
renderMs: r.renderMs,
|
||||
screenshotFullpagePath: r.screenshotFullpagePath,
|
||||
screenshotFoldPath: r.screenshotFoldPath,
|
||||
screenshotMobilePath: r.screenshotMobilePath,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -3580,23 +4082,34 @@ function registerIpc(): void {
|
||||
ipcMain.handle(
|
||||
IPC.exportTabular,
|
||||
async (_e, input: ExportTabularInput): Promise<ExportTabularResult> => {
|
||||
const { format, sections, columns, selectedIds } = input;
|
||||
const { format, sections, columns, selectedIds, csvBom } = input;
|
||||
let outputPath = input.filePath ?? '';
|
||||
const isSelection = (selectedIds?.length ?? 0) > 0;
|
||||
const multiSection = sections.length > 1;
|
||||
// Hierarchical export (any section with a subdir) always lands in
|
||||
// a folder root so the tree can be reconstructed under it.
|
||||
const hasSubdirs = sections.some((s) => !!s.subdir);
|
||||
const needsFolder = format !== 'xlsx' && (multiSection || hasSubdirs);
|
||||
if (!outputPath) {
|
||||
if (format === 'csv' && multiSection) {
|
||||
if (needsFolder) {
|
||||
const res = await dialog.showOpenDialog(mainWindow!, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: 'Choose folder for CSV export',
|
||||
title: `Choose folder for ${format.toUpperCase()} export`,
|
||||
});
|
||||
if (res.canceled || res.filePaths.length === 0) {
|
||||
return { filePath: '', files: [], rowsWritten: 0 };
|
||||
}
|
||||
outputPath = res.filePaths[0]!;
|
||||
} else {
|
||||
const ext = format === 'xlsx' ? 'xlsx' : 'csv';
|
||||
const filterName = format === 'xlsx' ? 'Excel Workbook' : 'CSV';
|
||||
const ext = format;
|
||||
const filterName =
|
||||
format === 'xlsx'
|
||||
? 'Excel Workbook'
|
||||
: format === 'csv'
|
||||
? 'CSV (UTF-8)'
|
||||
: format === 'json'
|
||||
? 'JSON'
|
||||
: 'XML';
|
||||
const baseName = isSelection ? 'freecrawl-selected' : 'freecrawl-export';
|
||||
const res = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: `${baseName}.${ext}`,
|
||||
@@ -3613,6 +4126,7 @@ function registerIpc(): void {
|
||||
sections,
|
||||
columns,
|
||||
selectedIds,
|
||||
csvBom,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
@@ -4249,6 +4763,32 @@ function pickProjectPathFromArgv(argv: readonly string[]): string | null {
|
||||
app.commandLine.appendSwitch('disable-gpu-disk-cache');
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
|
||||
|
||||
// V2 Faz 1 — Production builds may ship the Playwright browser cache
|
||||
// inside the installer (when `BUNDLE_PLAYWRIGHT=1` is set at build
|
||||
// time, see apps/desktop/scripts/prepare-playwright-bundle.mjs).
|
||||
// Playwright reads PLAYWRIGHT_BROWSERS_PATH at module load, so we
|
||||
// must set it BEFORE the BrowserPool import gets touched. Setting it
|
||||
// to a path that doesn't exist is harmless — Playwright simply falls
|
||||
// back to its default cache lookup.
|
||||
//
|
||||
// Must run before `app.whenReady()` so subsequent `import('playwright')`
|
||||
// calls inherit the env. `process.resourcesPath` is undefined in dev
|
||||
// (`electron-vite dev`) so the bundled-browser branch is production-only.
|
||||
try {
|
||||
if (
|
||||
app.isPackaged &&
|
||||
typeof process.resourcesPath === 'string' &&
|
||||
process.resourcesPath
|
||||
) {
|
||||
const bundled = join(process.resourcesPath, 'playwright-browsers');
|
||||
if (existsSync(bundled)) {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = bundled;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to default Playwright lookup */
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// Disk logging is bootstrapped after `app.whenReady()` — `getPath('userData')`
|
||||
// is only valid post-ready. Prior log lines are still in the ring buffer
|
||||
@@ -4320,7 +4860,7 @@ void app.whenReady().then(async () => {
|
||||
error: 'startUrl is empty — pass it in the request or run a crawl from the desktop UI at least once so the bridge has a saved default.',
|
||||
};
|
||||
}
|
||||
crawlController.launchCrawl(resolved);
|
||||
await crawlController.launchCrawl(resolved);
|
||||
return { ok: true as const, config: resolved };
|
||||
},
|
||||
stopCrawl: () => crawlController?.stopCrawl(),
|
||||
@@ -4400,7 +4940,25 @@ let shutdownComplete = false;
|
||||
function performShutdown(): void {
|
||||
if (shutdownComplete) return;
|
||||
shutdownComplete = true;
|
||||
// Close ancillary popup windows first so their renderers can flush
|
||||
// any in-flight IPC before the worker pools shut down.
|
||||
if (visualizationWindow && !visualizationWindow.isDestroyed()) {
|
||||
try {
|
||||
visualizationWindow.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
visualizationWindow = null;
|
||||
}
|
||||
activeCrawler?.stop();
|
||||
// V2 Faz 1 — Tear down Playwright before worker pools so a hung
|
||||
// Chromium process can't keep the app alive after the renderer
|
||||
// window closes.
|
||||
if (activeBrowserPool) {
|
||||
const pool = activeBrowserPool;
|
||||
activeBrowserPool = null;
|
||||
void pool.close().catch(() => {});
|
||||
}
|
||||
stopScheduler();
|
||||
// Tear down the MCP bridge first so any in-flight HTTP calls fail
|
||||
// fast rather than racing with the DB shutdown that follows.
|
||||
|
||||
@@ -23,9 +23,7 @@ export interface MenuLabels {
|
||||
clearRecent: string;
|
||||
emptyRecent: string;
|
||||
clearCrawlData: string;
|
||||
exportCsv: string;
|
||||
exportJson: string;
|
||||
exportXml: string;
|
||||
exportAs: string;
|
||||
generateSitemap: string;
|
||||
exportHtmlReport: string;
|
||||
bulkExport: string;
|
||||
@@ -43,8 +41,9 @@ export interface MenuLabels {
|
||||
view: string;
|
||||
overviewSidebar: string;
|
||||
detailPanel: string;
|
||||
showVisualization: string;
|
||||
fullscreen: string;
|
||||
visualization: string;
|
||||
openVisualizationWindow: string;
|
||||
reports: string;
|
||||
reportsItem: string;
|
||||
help: string;
|
||||
@@ -73,9 +72,7 @@ const MENU_EN: MenuLabels = {
|
||||
clearRecent: 'Clear Recent',
|
||||
emptyRecent: '(empty)',
|
||||
clearCrawlData: 'Clear Crawl Data',
|
||||
exportCsv: 'Export Current View as CSV',
|
||||
exportJson: 'Export Current View as JSON…',
|
||||
exportXml: 'Export Current View as XML…',
|
||||
exportAs: 'Export Crawl Data…',
|
||||
generateSitemap: 'Generate XML Sitemap…',
|
||||
exportHtmlReport: 'Export HTML Report…',
|
||||
bulkExport: 'Bulk Export…',
|
||||
@@ -96,8 +93,9 @@ const MENU_EN: MenuLabels = {
|
||||
view: 'View',
|
||||
overviewSidebar: 'Overview Sidebar',
|
||||
detailPanel: 'Detail Panel',
|
||||
showVisualization: 'Show Visualization Tab',
|
||||
fullscreen: 'Fullscreen',
|
||||
visualization: 'Visualization',
|
||||
openVisualizationWindow: 'Open Visualization Window…',
|
||||
reports: 'Reports',
|
||||
reportsItem: 'Reports…',
|
||||
help: 'Help',
|
||||
@@ -131,9 +129,7 @@ const MENU_TR: MenuLabels = {
|
||||
clearRecent: 'Son Açılanları Temizle',
|
||||
emptyRecent: '(boş)',
|
||||
clearCrawlData: 'Crawl Verilerini Temizle',
|
||||
exportCsv: 'Mevcut Görünümü CSV Olarak Dışa Aktar',
|
||||
exportJson: 'Mevcut Görünümü JSON Olarak Dışa Aktar…',
|
||||
exportXml: 'Mevcut Görünümü XML Olarak Dışa Aktar…',
|
||||
exportAs: 'Crawl Verilerini Dışa Aktar…',
|
||||
generateSitemap: 'XML Sitemap Oluştur…',
|
||||
exportHtmlReport: 'HTML Rapor Dışa Aktar…',
|
||||
bulkExport: 'Toplu Dışa Aktarım…',
|
||||
@@ -154,8 +150,9 @@ const MENU_TR: MenuLabels = {
|
||||
view: 'Görünüm',
|
||||
overviewSidebar: 'Genel Bakış Kenar Çubuğu',
|
||||
detailPanel: 'Detay Paneli',
|
||||
showVisualization: 'Görselleştirme Sekmesini Göster',
|
||||
fullscreen: 'Tam Ekran',
|
||||
visualization: 'Görselleştirme',
|
||||
openVisualizationWindow: 'Görselleştirme Penceresini Aç…',
|
||||
reports: 'Raporlar',
|
||||
reportsItem: 'Raporlar…',
|
||||
help: 'Yardım',
|
||||
|
||||
@@ -79,18 +79,9 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
{ label: L.clearCrawlData, click: () => send('clear-crawl') },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: L.exportCsv,
|
||||
label: L.exportAs,
|
||||
accelerator: 'CmdOrCtrl+E',
|
||||
click: () => send('export-csv'),
|
||||
},
|
||||
{
|
||||
label: L.exportJson,
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => send('export-json'),
|
||||
},
|
||||
{
|
||||
label: L.exportXml,
|
||||
click: () => send('export-xml'),
|
||||
click: () => send('export-as'),
|
||||
},
|
||||
{
|
||||
label: L.generateSitemap,
|
||||
@@ -161,12 +152,6 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
click: () => send('toggle-detail-panel'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: L.showVisualization,
|
||||
accelerator: 'CmdOrCtrl+G',
|
||||
click: () => send('open-visualization'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'reload' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
@@ -176,6 +161,16 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
{ role: 'togglefullscreen', label: L.fullscreen },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: L.visualization,
|
||||
submenu: [
|
||||
{
|
||||
label: L.openVisualizationWindow,
|
||||
accelerator: 'CmdOrCtrl+G',
|
||||
click: () => send('open-visualization'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: L.reports,
|
||||
submenu: [
|
||||
@@ -236,7 +231,10 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
||||
click: () => handlers.onCheckForUpdates(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: L.about, click: () => send('about') },
|
||||
{
|
||||
label: L.about,
|
||||
click: () => void shell.openExternal('https://freecrawl.net/'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -230,6 +230,10 @@ const api: FreeCrawlApi = {
|
||||
logsGetAll: (): Promise<LogEntry[]> => ipcRenderer.invoke(IPC.logsGetAll),
|
||||
logsClear: (): Promise<void> => ipcRenderer.invoke(IPC.logsClear),
|
||||
logsOpenWindow: (): Promise<void> => ipcRenderer.invoke(IPC.logsOpenWindow),
|
||||
openVisualizationWindow: (): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.visualizationOpenWindow),
|
||||
readScreenshot: (absolutePath: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC.screenshotRead, absolutePath),
|
||||
robotsTest: (input: RobotsTestInput): Promise<RobotsTestResult> =>
|
||||
ipcRenderer.invoke(IPC.robotsTest, input),
|
||||
robotsValidate: (text: string): Promise<RobotsValidationIssue[]> =>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 { ExportDialog } from './components/ExportDialog.js';
|
||||
import { UrlsTab } from './tabs/UrlsTab.js';
|
||||
import { ImagesTab } from './tabs/ImagesTab.js';
|
||||
import { BrokenLinksTab } from './tabs/BrokenLinksTab.js';
|
||||
@@ -22,7 +23,6 @@ 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';
|
||||
import { clearCrawlWithConfirm } from './utils/clearCrawl.js';
|
||||
@@ -45,6 +45,7 @@ export function App() {
|
||||
const setActiveTab = useAppStore((s) => s.setActiveTab);
|
||||
const [robotsTesterOpen, setRobotsTesterOpen] = useState(false);
|
||||
const [sitemapValidatorOpen, setSitemapValidatorOpen] = useState(false);
|
||||
const [exportAsOpen, setExportAsOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
@@ -229,14 +230,8 @@ export function App() {
|
||||
if (didClear) reset();
|
||||
});
|
||||
break;
|
||||
case 'export-csv':
|
||||
void window.freecrawl.exportCsv({ filePath: '' });
|
||||
break;
|
||||
case 'export-json':
|
||||
void window.freecrawl.exportJson({ filePath: '', pretty: true });
|
||||
break;
|
||||
case 'export-xml':
|
||||
void window.freecrawl.exportXml({ filePath: '' });
|
||||
case 'export-as':
|
||||
setExportAsOpen(true);
|
||||
break;
|
||||
case 'delete-domain-data': {
|
||||
const domain = window.prompt(
|
||||
@@ -377,7 +372,7 @@ export function App() {
|
||||
setPasswordPrompt({ mode: 'open' });
|
||||
break;
|
||||
case 'open-visualization':
|
||||
setActiveTab('visualization');
|
||||
void window.freecrawl.openVisualizationWindow();
|
||||
break;
|
||||
case 'open-scheduled-crawl':
|
||||
setScheduleOpen(true);
|
||||
@@ -448,13 +443,11 @@ export function App() {
|
||||
<AiTab />
|
||||
) : activeTab === 'seo' ? (
|
||||
<SeoTab />
|
||||
) : activeTab === 'visualization' ? (
|
||||
<VisualizationTab />
|
||||
) : (
|
||||
<UrlsTab />
|
||||
)}
|
||||
</Panel>
|
||||
{detailPanelOpen && activeTab !== 'visualization' && (
|
||||
{detailPanelOpen && (
|
||||
<>
|
||||
<PanelResizeHandle className="group relative h-1.5 bg-surface-800 transition-colors hover:bg-accent-500/60 data-[resize-handle-state=drag]:bg-accent-500">
|
||||
<div className="absolute inset-x-0 -top-1 -bottom-1" />
|
||||
@@ -491,6 +484,11 @@ export function App() {
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CompareDialog open={compareOpen} onClose={() => setCompareOpen(false)} />
|
||||
<ScheduledCrawlDialog open={scheduleOpen} onClose={() => setScheduleOpen(false)} />
|
||||
<ExportDialog
|
||||
open={exportAsOpen}
|
||||
onClose={() => setExportAsOpen(false)}
|
||||
defaultTab={activeTab}
|
||||
/>
|
||||
<PasswordPromptDialog
|
||||
open={passwordPrompt !== null}
|
||||
title={
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from './store.js';
|
||||
import { VisualizationTab } from './tabs/VisualizationTab.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Standalone Visualization window — mounted only when the renderer
|
||||
* boots with `?visualization=1` (i.e. via the native popup window
|
||||
* spawned from File menu → Visualization). The View renders the same
|
||||
* Cytoscape graph as the embedded tab used to, but in its own native
|
||||
* window with no main-app chrome (sidebar, top tabs, status bar).
|
||||
*
|
||||
* Wiring: subscribes to `dataChanged` so the graph refreshes when the
|
||||
* main process broadcasts a new crawl event. Drops the heavy main-App
|
||||
* IPC subscriptions (logs batch, progress, etc.) — the popup is
|
||||
* read-only and doesn't need them.
|
||||
*/
|
||||
export function VisualizationView() {
|
||||
const { t } = useTranslation();
|
||||
const bumpDataVersion = useAppStore((s) => s.bumpDataVersion);
|
||||
|
||||
useEffect(() => {
|
||||
const offData = window.freecrawl.onDataChanged(() => bumpDataVersion());
|
||||
return () => {
|
||||
offData();
|
||||
};
|
||||
}, [bumpDataVersion]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-surface-950 text-surface-100">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-surface-800 bg-surface-900 px-3 py-1.5 text-[12px]">
|
||||
<span className="font-semibold tracking-wide text-surface-200">
|
||||
{t('visualization.windowTitle', { defaultValue: 'Crawl Visualization' })}
|
||||
</span>
|
||||
<span className="text-surface-500">·</span>
|
||||
<span className="text-surface-500">
|
||||
{t('visualization.windowHint', {
|
||||
defaultValue: 'Standalone window — close to detach from the main app',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<VisualizationTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,8 @@ type SubTab =
|
||||
| 'cookies'
|
||||
| 'structured-data'
|
||||
| 'view-source'
|
||||
| 'view-rendered'
|
||||
| 'screenshot'
|
||||
| 'duplicates'
|
||||
| 'analytics';
|
||||
|
||||
@@ -48,6 +50,8 @@ const SUB_TABS: { key: SubTab; label: string; disabled?: boolean }[] = [
|
||||
{ key: 'cookies', label: 'Cookies' },
|
||||
{ key: 'structured-data', label: 'Structured Data' },
|
||||
{ key: 'view-source', label: 'View Source' },
|
||||
{ key: 'view-rendered', label: 'View Rendered' },
|
||||
{ key: 'screenshot', label: 'Screenshot' },
|
||||
{ key: 'duplicates', label: 'Duplicates' },
|
||||
{ key: 'analytics', label: 'Analytics' },
|
||||
];
|
||||
@@ -69,6 +73,8 @@ const SINGLE_URL_ONLY_TABS: ReadonlySet<SubTab> = new Set([
|
||||
'cookies',
|
||||
'structured-data',
|
||||
'view-source',
|
||||
'view-rendered',
|
||||
'screenshot',
|
||||
'duplicates',
|
||||
'analytics',
|
||||
]);
|
||||
@@ -403,7 +409,13 @@ export function BottomDetailPanel() {
|
||||
<StructuredDataView urlId={detail.row.id} row={detail.row} />
|
||||
)}
|
||||
{detail && subTab === 'view-source' && (
|
||||
<ViewSourceView urlId={detail.row.id} pageUrl={detail.row.url} />
|
||||
<ViewSourceView urlId={detail.row.id} pageUrl={detail.row.url} kind="raw" />
|
||||
)}
|
||||
{detail && subTab === 'view-rendered' && (
|
||||
<ViewSourceView urlId={detail.row.id} pageUrl={detail.row.url} kind="rendered" />
|
||||
)}
|
||||
{detail && subTab === 'screenshot' && (
|
||||
<ScreenshotView urlId={detail.row.id} />
|
||||
)}
|
||||
{detail && subTab === 'duplicates' && (
|
||||
<DuplicatesView urlId={detail.row.id} row={detail.row} />
|
||||
@@ -560,12 +572,198 @@ function AnalyticsRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 Faz 1 Increment 3 — Screenshot sub-tab. Reads the captured PNG
|
||||
* paths from `url_sources` and serves the bytes via `readScreenshot`
|
||||
* IPC (returns a data: URL). Toggle row picks between Full-Page /
|
||||
* Above-The-Fold / Mobile variants.
|
||||
*/
|
||||
function ScreenshotView({ urlId }: { urlId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const [src, setSrc] = useState<UrlSourceResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variant, setVariant] = useState<'fullpage' | 'fold' | 'mobile'>('fullpage');
|
||||
const [imgData, setImgData] = useState<string | null>(null);
|
||||
const [imgError, setImgError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlId === null) {
|
||||
setSrc(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void window.freecrawl
|
||||
.urlSourceGet({ id: urlId })
|
||||
.then((r) => {
|
||||
if (!cancelled) setSrc(r);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [urlId]);
|
||||
|
||||
// Pick the best default variant for this URL — falls back to whichever
|
||||
// file was actually captured so the user sees something on first open.
|
||||
useEffect(() => {
|
||||
if (!src) return;
|
||||
if (variant === 'fullpage' && !src.screenshotFullpagePath) {
|
||||
if (src.screenshotFoldPath) setVariant('fold');
|
||||
else if (src.screenshotMobilePath) setVariant('mobile');
|
||||
}
|
||||
}, [src, variant]);
|
||||
|
||||
const activePath =
|
||||
variant === 'fullpage'
|
||||
? src?.screenshotFullpagePath
|
||||
: variant === 'fold'
|
||||
? src?.screenshotFoldPath
|
||||
: src?.screenshotMobilePath;
|
||||
|
||||
useEffect(() => {
|
||||
setImgData(null);
|
||||
setImgError(null);
|
||||
if (!activePath) return;
|
||||
let cancelled = false;
|
||||
void window.freecrawl
|
||||
.readScreenshot(activePath)
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) return;
|
||||
if (dataUrl) setImgData(dataUrl);
|
||||
else
|
||||
setImgError(
|
||||
t('screenshot.readFailed', {
|
||||
defaultValue: 'Screenshot file could not be read from disk.',
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setImgError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activePath, t]);
|
||||
|
||||
const noneCaptured =
|
||||
!!src &&
|
||||
!src.screenshotFullpagePath &&
|
||||
!src.screenshotFoldPath &&
|
||||
!src.screenshotMobilePath;
|
||||
|
||||
if (loading && !src) {
|
||||
return (
|
||||
<div className="p-4 text-[11px] text-surface-500">
|
||||
{t('screenshot.loading', { defaultValue: 'Loading screenshot…' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (noneCaptured) {
|
||||
return (
|
||||
<div className="p-4 text-[11px] text-surface-500">
|
||||
{t('screenshot.empty', {
|
||||
defaultValue: 'No screenshots stored for this URL.',
|
||||
})}
|
||||
<div className="mt-1 text-[10px] text-surface-600">
|
||||
{t('screenshot.emptyHint', {
|
||||
defaultValue:
|
||||
'Screenshots are only captured when Rendering Mode = JavaScript Rendering and Screenshot Capture is enabled in Settings → Rendering. Re-crawl the URL with those options on.',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-1 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px]">
|
||||
<span className="text-surface-500">
|
||||
{t('screenshot.variant', { defaultValue: 'Variant' })}:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!src?.screenshotFullpagePath}
|
||||
onClick={() => setVariant('fullpage')}
|
||||
className={clsx(
|
||||
'rounded border px-2 py-0.5 text-[10px] transition',
|
||||
!src?.screenshotFullpagePath
|
||||
? 'cursor-not-allowed border-surface-800 text-surface-600'
|
||||
: variant === 'fullpage'
|
||||
? 'border-accent-500/60 bg-accent-500/15 text-accent-300'
|
||||
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
|
||||
)}
|
||||
>
|
||||
{t('screenshot.fullpage', { defaultValue: 'Full page' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!src?.screenshotFoldPath}
|
||||
onClick={() => setVariant('fold')}
|
||||
className={clsx(
|
||||
'rounded border px-2 py-0.5 text-[10px] transition',
|
||||
!src?.screenshotFoldPath
|
||||
? 'cursor-not-allowed border-surface-800 text-surface-600'
|
||||
: variant === 'fold'
|
||||
? 'border-accent-500/60 bg-accent-500/15 text-accent-300'
|
||||
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
|
||||
)}
|
||||
>
|
||||
{t('screenshot.fold', { defaultValue: 'Above the fold' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!src?.screenshotMobilePath}
|
||||
onClick={() => setVariant('mobile')}
|
||||
className={clsx(
|
||||
'rounded border px-2 py-0.5 text-[10px] transition',
|
||||
!src?.screenshotMobilePath
|
||||
? 'cursor-not-allowed border-surface-800 text-surface-600'
|
||||
: variant === 'mobile'
|
||||
? 'border-accent-500/60 bg-accent-500/15 text-accent-300'
|
||||
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
|
||||
)}
|
||||
>
|
||||
{t('screenshot.mobile', { defaultValue: 'Mobile' })}
|
||||
</button>
|
||||
{activePath && (
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-surface-500" title={activePath}>
|
||||
{activePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-surface-950 p-3">
|
||||
{imgError ? (
|
||||
<div className="rounded border border-red-800/60 bg-red-950/30 px-3 py-2 text-[11px] text-red-200">
|
||||
{imgError}
|
||||
</div>
|
||||
) : imgData ? (
|
||||
<img
|
||||
src={imgData}
|
||||
alt="Captured screenshot"
|
||||
className="max-w-full rounded border border-surface-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-[11px] text-surface-500">
|
||||
{t('screenshot.loading', { defaultValue: 'Loading screenshot…' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSourceView({
|
||||
urlId,
|
||||
pageUrl,
|
||||
kind = 'raw',
|
||||
}: {
|
||||
urlId: number | null;
|
||||
pageUrl: string;
|
||||
/** 'raw' = HTTP response body, 'rendered' = post-JS DOM dump. */
|
||||
kind?: 'raw' | 'rendered';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [src, setSrc] = useState<UrlSourceResult | null>(null);
|
||||
@@ -608,7 +806,23 @@ function ViewSourceView({
|
||||
if (loading && !src) {
|
||||
return <div className="p-4 text-[11px] text-surface-500">{t('viewSource.loadingSource', { defaultValue: 'Loading source…' })}</div>;
|
||||
}
|
||||
if (!src || src.body === null) {
|
||||
const activeBody = kind === 'rendered' ? src?.renderedBody ?? null : src?.body ?? null;
|
||||
if (!src || activeBody === null) {
|
||||
if (kind === 'rendered') {
|
||||
return (
|
||||
<div className="p-4 text-[11px] text-surface-500">
|
||||
{t('viewRendered.noBody', {
|
||||
defaultValue: 'No rendered DOM stored for this URL.',
|
||||
})}
|
||||
<div className="mt-1 text-[10px] text-surface-600">
|
||||
{t('viewRendered.noBodyHint', {
|
||||
defaultValue:
|
||||
'View Rendered is only populated when the page was crawled with Rendering Mode = JavaScript Rendering. Switch to JS mode in Settings → Crawler and re-crawl the URL to capture the post-JS DOM.',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="p-4 text-[11px] text-surface-500">
|
||||
{t('viewSource.noBody', { defaultValue: 'No HTML body stored for this URL.' })}
|
||||
@@ -621,7 +835,9 @@ function ViewSourceView({
|
||||
);
|
||||
}
|
||||
|
||||
const body = src.body;
|
||||
const body = activeBody;
|
||||
const activeLength =
|
||||
kind === 'rendered' ? src.renderedBodyLength ?? body.length : src.bodyLength;
|
||||
const matches = search ? countMatches(body, search) : 0;
|
||||
// Clamp active match into the current range so a shrinking match
|
||||
// count (after the user typed an extra character) doesn't leave us
|
||||
@@ -661,12 +877,17 @@ function ViewSourceView({
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px]">
|
||||
<span className="text-surface-500">
|
||||
{(src.bodyLength / 1024).toFixed(1)} KB
|
||||
{src.truncated && (
|
||||
{(activeLength / 1024).toFixed(1)} KB
|
||||
{src.truncated && kind === 'raw' && (
|
||||
<span className="ml-1 rounded bg-amber-900/40 px-1.5 py-0.5 text-[9px] uppercase text-amber-300">
|
||||
truncated
|
||||
</span>
|
||||
)}
|
||||
{kind === 'rendered' && src.renderMs !== null && (
|
||||
<span className="ml-2 rounded bg-blue-900/40 px-1.5 py-0.5 text-[9px] uppercase text-blue-300">
|
||||
{t('viewRendered.renderMs', { defaultValue: 'render {{ms}}ms', ms: src.renderMs })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{src.capturedAt && (
|
||||
<span className="text-surface-600">· captured {src.capturedAt}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { X, Loader2, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CrawlUrlRow, ExportTabularSection, UrlCategory } from '@freecrawl/shared-types';
|
||||
@@ -7,28 +7,104 @@ import { TAB_ORDER, type TabKey } from '../store.js';
|
||||
import { COLUMN_SPECS, columnId } from '../tabs/columns.js';
|
||||
import { translateLabel } from '../i18n/labels.js';
|
||||
|
||||
type Format = 'csv' | 'xlsx';
|
||||
type Format = 'csv' | 'xlsx' | 'json' | 'xml';
|
||||
|
||||
interface ExportTab {
|
||||
key: TabKey;
|
||||
interface SubCategory {
|
||||
/** Child label shown beneath the parent (translated via labels.ts). */
|
||||
label: string;
|
||||
category: UrlCategory;
|
||||
columnKeys: (keyof CrawlUrlRow)[];
|
||||
/** Filename stem written under the parent's subdir. */
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
|
||||
internal: 'internal:html',
|
||||
external: 'external:all',
|
||||
'response-codes': 'all',
|
||||
interface ExportNode {
|
||||
/** Unique key within the tree. */
|
||||
key: string;
|
||||
/** TabKey for column-spec lookup. */
|
||||
tabKey: TabKey;
|
||||
/** Top-level label. */
|
||||
label: string;
|
||||
/** Folder name when this node has children; '' when no nesting. */
|
||||
subdir: string;
|
||||
/** Default category when the parent itself is exported (no children selected). */
|
||||
category: UrlCategory;
|
||||
/** Optional structural sub-categories. Empty array = leaf node. */
|
||||
children: SubCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural sub-categories per tab. These mirror the OverviewSidebar's
|
||||
* Crawl Data + Response Codes + Security + Indexability groupings — the
|
||||
* "issue filter" pages under Overview are intentionally excluded.
|
||||
*/
|
||||
const TAB_CHILDREN: Partial<Record<TabKey, { subdir: string; children: SubCategory[] }>> = {
|
||||
internal: {
|
||||
subdir: 'internal',
|
||||
children: [
|
||||
{ label: 'All', category: 'internal:all', filename: 'all' },
|
||||
{ label: 'HTML', category: 'internal:html', filename: 'html' },
|
||||
{ label: 'JavaScript', category: 'internal:js', filename: 'javascript' },
|
||||
{ label: 'CSS', category: 'internal:css', filename: 'css' },
|
||||
{ label: 'Images', category: 'internal:image', filename: 'images' },
|
||||
{ label: 'PDF', category: 'internal:pdf', filename: 'pdf' },
|
||||
{ label: 'Fonts', category: 'internal:font', filename: 'fonts' },
|
||||
{ label: 'Other', category: 'internal:other', filename: 'other' },
|
||||
],
|
||||
},
|
||||
external: {
|
||||
subdir: 'external',
|
||||
children: [
|
||||
{ label: 'All', category: 'external:all', filename: 'all' },
|
||||
{ label: 'HTML', category: 'external:html', filename: 'html' },
|
||||
{ label: 'Other', category: 'external:other', filename: 'other' },
|
||||
],
|
||||
},
|
||||
'response-codes': {
|
||||
subdir: 'response-codes',
|
||||
children: [
|
||||
{ label: 'All', category: 'all', filename: 'all' },
|
||||
{ label: 'Blocked by Robots', category: 'status:blocked-robots', filename: 'blocked-robots' },
|
||||
{ label: 'No Response', category: 'status:no-response', filename: 'no-response' },
|
||||
{ label: 'Success (2xx)', category: 'status:2xx', filename: '2xx-success' },
|
||||
{ label: 'Redirection (3xx)', category: 'status:3xx', filename: '3xx-redirect' },
|
||||
{ label: 'Client Error (4xx)', category: 'status:4xx', filename: '4xx-client-error' },
|
||||
{ label: 'Server Error (5xx)', category: 'status:5xx', filename: '5xx-server-error' },
|
||||
],
|
||||
},
|
||||
security: {
|
||||
subdir: 'security',
|
||||
children: [
|
||||
{ label: 'HTTPS URLs', category: 'security:https', filename: 'https' },
|
||||
{ label: 'HTTP URLs', category: 'security:http', filename: 'http' },
|
||||
],
|
||||
},
|
||||
directives: {
|
||||
subdir: 'indexability',
|
||||
children: [
|
||||
{ label: 'Indexable', category: 'indexability:indexable', filename: 'indexable' },
|
||||
{ label: 'Non-Indexable', category: 'indexability:non-indexable', filename: 'non-indexable' },
|
||||
{ label: 'Noindex', category: 'indexability:noindex', filename: 'noindex' },
|
||||
{ label: 'Canonicalised', category: 'indexability:canonicalised', filename: 'canonicalised' },
|
||||
{ label: 'Blocked by Robots', category: 'indexability:blocked-robots', filename: 'blocked-robots' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Tabs that aren't exportable via the URL-row schema (Images / Broken
|
||||
* Links use their own per-tab CSV exporter; Visualization is a graph). */
|
||||
const SKIPPED_TABS = new Set<TabKey>(['images', 'broken-links']);
|
||||
|
||||
/** Default category for top-level tabs that don't have structural
|
||||
* sub-categories. Issues / filter content is intentionally NOT broken
|
||||
* out — the user explicitly asked to exclude Overview issue filters. */
|
||||
const TAB_CATEGORY: Partial<Record<TabKey, UrlCategory>> = {
|
||||
url: 'internal:html',
|
||||
'page-titles': 'internal:html',
|
||||
'meta-description': 'internal:html',
|
||||
h1: 'internal:html',
|
||||
h2: 'internal:html',
|
||||
content: 'internal:html',
|
||||
images: 'internal:html',
|
||||
canonicals: 'internal:html',
|
||||
directives: 'internal:html',
|
||||
redirects: 'all',
|
||||
pagination: 'tab:pagination',
|
||||
hreflang: 'tab:hreflang',
|
||||
@@ -37,41 +113,48 @@ const TAB_CATEGORY: Record<TabKey, UrlCategory> = {
|
||||
'meta-refresh': 'tab:meta-refresh',
|
||||
'custom-extraction': 'tab:custom-extraction',
|
||||
'custom-search': 'tab:custom-search',
|
||||
security: 'tab:security',
|
||||
duplicates: 'tab:duplicates',
|
||||
links: 'all',
|
||||
'broken-links': 'all',
|
||||
serp: 'tab:serp',
|
||||
pagespeed: 'all',
|
||||
'search-console': 'all',
|
||||
analytics: 'all',
|
||||
ai: 'all',
|
||||
seo: 'all',
|
||||
visualization: 'all',
|
||||
};
|
||||
|
||||
function buildExportableTabs(): ExportTab[] {
|
||||
const out: ExportTab[] = [];
|
||||
function buildTree(): ExportNode[] {
|
||||
const out: ExportNode[] = [];
|
||||
for (const t of TAB_ORDER) {
|
||||
if (SKIPPED_TABS.has(t.key)) continue;
|
||||
const cols = COLUMN_SPECS[t.key];
|
||||
// Tabs with their own non-URL-row layouts (Images / Broken Links)
|
||||
// have empty COLUMN_SPECS — skip them so the export grid only shows
|
||||
// categories that share the URL-row schema.
|
||||
if (!cols || cols.length === 0) continue;
|
||||
const sub = TAB_CHILDREN[t.key];
|
||||
out.push({
|
||||
key: t.key,
|
||||
tabKey: t.key,
|
||||
label: t.label,
|
||||
category: TAB_CATEGORY[t.key],
|
||||
columnKeys: cols.map((c) => c.key),
|
||||
subdir: sub ? sub.subdir : '',
|
||||
category: sub ? sub.children[0]!.category : TAB_CATEGORY[t.key] ?? 'internal:html',
|
||||
children: sub ? sub.children : [],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Stable leaf identifier for the "what's checked" set — parents with
|
||||
* children are not selectable directly; their `<tabKey>:<filename>`
|
||||
* child IDs are. Childless tabs use `<tabKey>` as their own leaf id. */
|
||||
function leafId(node: ExportNode, child?: SubCategory): string {
|
||||
return child ? `${node.key}:${child.filename}` : node.key;
|
||||
}
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The tab the user clicked Export from — drives the default selection. */
|
||||
/** The tab the user clicked Export from — drives the default selection
|
||||
* when invoked from a per-tab button. When opened from the File menu
|
||||
* this is just used to bias the "Active" tag and starting checkbox. */
|
||||
defaultTab: TabKey;
|
||||
/** Row ids selected in the current table; falsy → export the whole filter set. */
|
||||
selectedIds?: number[];
|
||||
@@ -85,26 +168,43 @@ export function ExportDialog({
|
||||
}: ExportDialogProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tabs = useMemo(() => buildExportableTabs(), []);
|
||||
const tree = useMemo(() => buildTree(), []);
|
||||
const initialTabKey: TabKey = useMemo(() => {
|
||||
return tabs.find((t) => t.key === defaultTab)?.key ?? tabs[0]?.key ?? 'internal';
|
||||
}, [defaultTab, tabs]);
|
||||
return tree.find((n) => n.tabKey === defaultTab)?.tabKey ?? tree[0]?.tabKey ?? 'internal';
|
||||
}, [defaultTab, tree]);
|
||||
|
||||
const selectionCount = selectedIds?.length ?? 0;
|
||||
|
||||
const [format, setFormat] = useState<Format>('csv');
|
||||
const [pickedTabs, setPickedTabs] = useState<Set<TabKey>>(() => new Set([initialTabKey]));
|
||||
const [format, setFormat] = useState<Format>('xlsx');
|
||||
const [csvBom, setCsvBom] = useState<boolean>(true);
|
||||
|
||||
// Picked leaves — childless tabs use the tab key, child rows use
|
||||
// `<tabKey>:<filename>`. Defaults to the active tab's first leaf so
|
||||
// the dialog always has something to export.
|
||||
const [pickedLeaves, setPickedLeaves] = useState<Set<string>>(() => {
|
||||
const node = tree.find((n) => n.tabKey === initialTabKey);
|
||||
if (!node) return new Set();
|
||||
if (node.children.length === 0) return new Set([leafId(node)]);
|
||||
return new Set([leafId(node, node.children[0])]);
|
||||
});
|
||||
|
||||
// Expanded parent rows in the tree — closed nodes still let their
|
||||
// leaves be exported (the user just doesn't see them). Defaults to
|
||||
// the active tab being open.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
const node = tree.find((n) => n.tabKey === initialTabKey);
|
||||
return new Set(node && node.children.length > 0 ? [node.key] : []);
|
||||
});
|
||||
|
||||
const [pickedColumns, setPickedColumns] = useState<Set<string>>(() => {
|
||||
const t = tabs.find((x) => x.key === initialTabKey);
|
||||
return new Set((t?.columnKeys ?? []) as string[]);
|
||||
const node = tree.find((n) => n.tabKey === initialTabKey);
|
||||
const specs = node ? COLUMN_SPECS[node.tabKey] : [];
|
||||
return new Set(specs.map((s) => s.key as string));
|
||||
});
|
||||
const [useSelection, setUseSelection] = useState<boolean>(selectionCount > 0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
|
||||
// The dialog is conditionally mounted by the parent (only when open=true),
|
||||
// so useState initializers above already populate the right defaults on
|
||||
// each open. Escape key dismissal is the only ambient behaviour we need.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -114,23 +214,40 @@ export function ExportDialog({
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Union of column keys exposed by every picked tab — these are the
|
||||
// columns the dialog offers; checked state lives in `pickedColumns`.
|
||||
/** Tabs whose leaves currently have at least one pick — drives the
|
||||
* column picker (union of selected tabs' columns). */
|
||||
const pickedTabKeys = useMemo(() => {
|
||||
const set = new Set<TabKey>();
|
||||
for (const node of tree) {
|
||||
if (node.children.length === 0) {
|
||||
if (pickedLeaves.has(leafId(node))) set.add(node.tabKey);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
if (pickedLeaves.has(leafId(node, child))) {
|
||||
set.add(node.tabKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [pickedLeaves, tree]);
|
||||
|
||||
const offeredColumns = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: { key: string; header: string; tab: TabKey }[] = [];
|
||||
for (const t of tabs) {
|
||||
if (!pickedTabs.has(t.key)) continue;
|
||||
const specs = COLUMN_SPECS[t.key];
|
||||
for (const node of tree) {
|
||||
if (!pickedTabKeys.has(node.tabKey)) continue;
|
||||
const specs = COLUMN_SPECS[node.tabKey];
|
||||
for (const spec of specs) {
|
||||
const id = columnId(spec);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push({ key: spec.key as string, header: spec.header, tab: t.key });
|
||||
out.push({ key: spec.key as string, header: spec.header, tab: node.tabKey });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [pickedTabs, tabs]);
|
||||
}, [pickedTabKeys, tree]);
|
||||
|
||||
const orderedColumnList = useMemo(
|
||||
() => offeredColumns.filter((c) => pickedColumns.has(c.key)).map((c) => c.key),
|
||||
@@ -139,35 +256,100 @@ export function ExportDialog({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleTab = (key: TabKey) => {
|
||||
setPickedTabs((prev) => {
|
||||
const totalLeafSelections = pickedLeaves.size;
|
||||
|
||||
function parentState(node: ExportNode): 'all' | 'some' | 'none' {
|
||||
if (node.children.length === 0) {
|
||||
return pickedLeaves.has(leafId(node)) ? 'all' : 'none';
|
||||
}
|
||||
let picked = 0;
|
||||
for (const child of node.children) {
|
||||
if (pickedLeaves.has(leafId(node, child))) picked++;
|
||||
}
|
||||
if (picked === 0) return 'none';
|
||||
if (picked === node.children.length) return 'all';
|
||||
return 'some';
|
||||
}
|
||||
|
||||
const toggleParent = (node: ExportNode) => {
|
||||
const state = parentState(node);
|
||||
setPickedLeaves((prev) => {
|
||||
const next = new Set(prev);
|
||||
let willBeRemoved = false;
|
||||
if (next.has(key)) {
|
||||
if (next.size === 1) return prev; // keep at least one
|
||||
next.delete(key);
|
||||
willBeRemoved = true;
|
||||
} else {
|
||||
next.add(key);
|
||||
if (node.children.length === 0) {
|
||||
if (state === 'all') next.delete(leafId(node));
|
||||
else next.add(leafId(node));
|
||||
return next;
|
||||
}
|
||||
// Auto-tick the new tab's columns when it joins the picked set, so
|
||||
// the user doesn't have to re-check them. When a tab leaves we keep
|
||||
// the user's column picks alone — orderedColumnList already drops
|
||||
// any column not offered by remaining tabs.
|
||||
if (!willBeRemoved) {
|
||||
const t = tabs.find((x) => x.key === key);
|
||||
if (t) {
|
||||
setPickedColumns((prevCols) => {
|
||||
const nextCols = new Set(prevCols);
|
||||
for (const k of t.columnKeys) nextCols.add(k as string);
|
||||
return nextCols;
|
||||
});
|
||||
if (state === 'all') {
|
||||
for (const child of node.children) next.delete(leafId(node, child));
|
||||
} else {
|
||||
for (const child of node.children) next.add(leafId(node, child));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Bug #12 — only auto-add the tab's columns the FIRST time it gains
|
||||
// any leaf. If the user previously hand-toggled columns off for this
|
||||
// tab, respect that decision across tab on/off cycles. We detect
|
||||
// "first time" by whether the user has any of this tab's columns
|
||||
// currently in pickedColumns.
|
||||
if (state !== 'all') {
|
||||
setPickedColumns((prev) => {
|
||||
const specs = COLUMN_SPECS[node.tabKey] ?? [];
|
||||
const hasAny = specs.some((spec) => prev.has(spec.key as string));
|
||||
if (hasAny) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const spec of specs) next.add(spec.key as string);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLeaf = (node: ExportNode, child: SubCategory) => {
|
||||
setPickedLeaves((prev) => {
|
||||
const next = new Set(prev);
|
||||
const id = leafId(node, child);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
// Bug #12 — same conditional auto-add as toggleParent.
|
||||
setPickedColumns((prev) => {
|
||||
const specs = COLUMN_SPECS[node.tabKey] ?? [];
|
||||
const hasAny = specs.some((spec) => prev.has(spec.key as string));
|
||||
if (hasAny) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const spec of specs) next.add(spec.key as string);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const checkAllParents = () => {
|
||||
setPickedLeaves((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const node of tree) {
|
||||
if (node.children.length === 0) {
|
||||
next.add(leafId(node));
|
||||
} else {
|
||||
for (const child of node.children) next.add(leafId(node, child));
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uncheckAllParents = () => {
|
||||
setPickedLeaves(new Set());
|
||||
};
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
setPickedColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -190,27 +372,42 @@ export function ExportDialog({
|
||||
setStatus(t('export.errorNoColumn', { defaultValue: 'Pick at least one column.' }));
|
||||
return;
|
||||
}
|
||||
if (pickedTabs.size === 0) {
|
||||
if (totalLeafSelections === 0) {
|
||||
setStatus(t('export.errorNoTable', { defaultValue: 'Pick at least one table.' }));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
try {
|
||||
const sections: ExportTabularSection[] = tabs
|
||||
.filter((tab) => pickedTabs.has(tab.key))
|
||||
.map((tab) => ({ label: tab.label, category: tab.category }));
|
||||
const sections: ExportTabularSection[] = [];
|
||||
for (const node of tree) {
|
||||
if (node.children.length === 0) {
|
||||
if (pickedLeaves.has(leafId(node))) {
|
||||
sections.push({ label: node.label, category: node.category });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
if (!pickedLeaves.has(leafId(node, child))) continue;
|
||||
sections.push({
|
||||
label: `${node.label} — ${child.label}`,
|
||||
category: child.category,
|
||||
subdir: node.subdir,
|
||||
filename: child.filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await window.freecrawl.exportTabular({
|
||||
format,
|
||||
sections,
|
||||
columns: orderedColumnList,
|
||||
csvBom: format === 'csv' ? csvBom : undefined,
|
||||
selectedIds:
|
||||
useSelection && selectedIds && selectedIds.length > 0
|
||||
? selectedIds
|
||||
: undefined,
|
||||
});
|
||||
if (!result.filePath) {
|
||||
// User cancelled save / folder dialog.
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
@@ -222,24 +419,32 @@ export function ExportDialog({
|
||||
}),
|
||||
);
|
||||
setBusy(false);
|
||||
setTimeout(() => onClose(), 700);
|
||||
setTimeout(() => onClose(), 900);
|
||||
} catch (err) {
|
||||
setBusy(false);
|
||||
setStatus(t('export.failedPrefix', { defaultValue: 'Export failed' }) + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
setStatus(
|
||||
t('export.failedPrefix', { defaultValue: 'Export failed' }) +
|
||||
': ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showFolderHint = format !== 'xlsx' && (totalLeafSelections > 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[85vh] w-[780px] max-w-[95vw] flex-col rounded-md border border-surface-700 bg-surface-900 shadow-2xl"
|
||||
className="flex max-h-[90vh] w-[860px] max-w-[95vw] flex-col rounded-md border border-surface-700 bg-surface-900 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-surface-800 px-4 py-2.5">
|
||||
<div className="text-sm font-semibold text-surface-100">{t('export.title', { defaultValue: 'Export' })}</div>
|
||||
<div className="text-sm font-semibold text-surface-100">
|
||||
{t('export.exportAsTitle', { defaultValue: 'Export As…' })}
|
||||
</div>
|
||||
<button
|
||||
className="rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
|
||||
onClick={onClose}
|
||||
@@ -251,38 +456,113 @@ export function ExportDialog({
|
||||
|
||||
<div className="flex-1 overflow-auto px-4 py-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<section>
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-surface-400">
|
||||
{t('export.tables', { defaultValue: 'Tables' })}
|
||||
<section className="flex min-h-0 flex-col">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
|
||||
{t('export.tablesTree', { defaultValue: 'Tables (hierarchical)' })}
|
||||
</div>
|
||||
<div className="flex gap-1 text-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
|
||||
onClick={checkAllParents}
|
||||
>
|
||||
{t('export.all', { defaultValue: 'All' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
|
||||
onClick={uncheckAllParents}
|
||||
>
|
||||
{t('export.none', { defaultValue: 'None' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border border-surface-800 bg-surface-950/50 p-2">
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const checked = pickedTabs.has(tab.key);
|
||||
const isDefault = tab.key === initialTabKey;
|
||||
<div className="max-h-[420px] overflow-auto rounded border border-surface-800 bg-surface-950/50 p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tree.map((node) => {
|
||||
const state = parentState(node);
|
||||
const isOpen = expanded.has(node.key);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isActive = node.tabKey === initialTabKey;
|
||||
return (
|
||||
<label
|
||||
key={tab.key}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
|
||||
checked
|
||||
? 'bg-accent-500/10 text-surface-100'
|
||||
: 'text-surface-300 hover:bg-surface-800/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={checked}
|
||||
onChange={() => toggleTab(tab.key)}
|
||||
/>
|
||||
<span className="truncate">{translateLabel(tab.label, lang)}</span>
|
||||
{isDefault && (
|
||||
<span className="ml-auto rounded bg-surface-800 px-1 text-[9px] uppercase tracking-wide text-surface-400">
|
||||
{t('export.active', { defaultValue: 'Active' })}
|
||||
<div key={node.key}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-1.5 py-1 text-[12px]',
|
||||
state !== 'none'
|
||||
? 'bg-accent-500/10 text-surface-100'
|
||||
: 'text-surface-300 hover:bg-surface-800/60',
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-surface-500 hover:bg-surface-800 hover:text-surface-200"
|
||||
onClick={() => toggleExpanded(node.key)}
|
||||
title={isOpen ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block h-3.5 w-3.5" />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={state === 'all'}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = state === 'some';
|
||||
}}
|
||||
onChange={() => toggleParent(node)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{translateLabel(node.label, lang)}
|
||||
</span>
|
||||
{hasChildren && (
|
||||
<span className="ml-1 rounded bg-surface-800 px-1 text-[9px] uppercase tracking-wide text-surface-500">
|
||||
{node.children.length}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="ml-auto rounded bg-surface-800 px-1 text-[9px] uppercase tracking-wide text-surface-400">
|
||||
{t('export.active', { defaultValue: 'Active' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && isOpen && (
|
||||
<div className="ml-7 flex flex-col gap-0.5 border-l border-surface-800 pl-2">
|
||||
{node.children.map((child) => {
|
||||
const checked = pickedLeaves.has(leafId(node, child));
|
||||
return (
|
||||
<label
|
||||
key={child.filename}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-0.5 text-[11.5px]',
|
||||
checked
|
||||
? 'bg-accent-500/10 text-surface-100'
|
||||
: 'text-surface-300 hover:bg-surface-800/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={checked}
|
||||
onChange={() => toggleLeaf(node, child)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{translateLabel(child.label, lang)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -311,33 +591,41 @@ export function ExportDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-auto rounded border border-surface-800 bg-surface-950/50 p-2">
|
||||
<div className="max-h-[420px] overflow-auto rounded border border-surface-800 bg-surface-950/50 p-2">
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{offeredColumns.map((c) => {
|
||||
const checked = pickedColumns.has(c.key);
|
||||
return (
|
||||
<label
|
||||
key={c.key}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
|
||||
checked
|
||||
? 'bg-accent-500/10 text-surface-100'
|
||||
: 'text-surface-300 hover:bg-surface-800/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={checked}
|
||||
onChange={() => toggleColumn(c.key)}
|
||||
/>
|
||||
<span className="truncate">{translateLabel(c.header, lang)}</span>
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-surface-500">
|
||||
{c.key}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{offeredColumns.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-[11px] text-surface-500">
|
||||
{t('export.emptyCols', {
|
||||
defaultValue: 'Pick a table on the left to load its columns.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
offeredColumns.map((c) => {
|
||||
const checked = pickedColumns.has(c.key);
|
||||
return (
|
||||
<label
|
||||
key={c.key}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
|
||||
checked
|
||||
? 'bg-accent-500/10 text-surface-100'
|
||||
: 'text-surface-300 hover:bg-surface-800/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={checked}
|
||||
onChange={() => toggleColumn(c.key)}
|
||||
/>
|
||||
<span className="truncate">{translateLabel(c.header, lang)}</span>
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-surface-500">
|
||||
{c.key}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -348,15 +636,6 @@ export function ExportDialog({
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
|
||||
{t('export.format', { defaultValue: 'Format' })}
|
||||
</span>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-format"
|
||||
checked={format === 'csv'}
|
||||
onChange={() => setFormat('csv')}
|
||||
/>
|
||||
CSV
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -366,8 +645,48 @@ export function ExportDialog({
|
||||
/>
|
||||
Excel (.xlsx)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-format"
|
||||
checked={format === 'csv'}
|
||||
onChange={() => setFormat('csv')}
|
||||
/>
|
||||
CSV (UTF-8)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-format"
|
||||
checked={format === 'json'}
|
||||
onChange={() => setFormat('json')}
|
||||
/>
|
||||
JSON
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-format"
|
||||
checked={format === 'xml'}
|
||||
onChange={() => setFormat('xml')}
|
||||
/>
|
||||
XML
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{format === 'csv' && (
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[11.5px] text-surface-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={csvBom}
|
||||
onChange={(e) => setCsvBom(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('export.csvBom', { defaultValue: 'UTF-8 BOM (Excel-friendly)' })}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectionCount > 0 && (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-[12px] text-surface-200">
|
||||
<input
|
||||
@@ -385,9 +704,22 @@ export function ExportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{format === 'csv' && pickedTabs.size > 1 && (
|
||||
{showFolderHint && (
|
||||
<div className="mt-2 rounded border border-amber-700/40 bg-amber-900/20 px-2 py-1 text-[11px] text-amber-200">
|
||||
{t('export.multiCsvNote', { defaultValue: 'Multiple tables + CSV → you\'ll be asked to pick a folder; one file is written per table.' })}
|
||||
{t('export.folderNote', {
|
||||
defaultValue:
|
||||
'Multiple sections + {{fmt}} → you\'ll be asked to pick a folder; one file is written per section, nested under sub-folders where applicable.',
|
||||
fmt: format.toUpperCase(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{format === 'xlsx' && totalLeafSelections > 1 && (
|
||||
<div className="mt-2 rounded border border-blue-700/40 bg-blue-900/20 px-2 py-1 text-[11px] text-blue-200">
|
||||
{t('export.xlsxNote', {
|
||||
defaultValue:
|
||||
'Excel output → single .xlsx workbook with one sheet per section.',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -398,28 +730,37 @@ export function ExportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-surface-800 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-300 hover:bg-surface-800"
|
||||
disabled={busy}
|
||||
>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || pickedColumns.size === 0 || pickedTabs.size === 0}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-200 hover:bg-surface-800',
|
||||
(busy || pickedColumns.size === 0 || pickedTabs.size === 0) &&
|
||||
'cursor-not-allowed opacity-50 hover:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t('export.title', { defaultValue: 'Export' })}
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-surface-800 px-4 py-2.5">
|
||||
<div className="text-[11px] text-surface-500">
|
||||
{t('export.summary', {
|
||||
defaultValue: '{{n}} section(s) · {{c}} column(s)',
|
||||
n: totalLeafSelections,
|
||||
c: orderedColumnList.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-300 hover:bg-surface-800"
|
||||
disabled={busy}
|
||||
>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || pickedColumns.size === 0 || totalLeafSelections === 0}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded border border-blue-600 bg-blue-600/20 px-3 py-1 text-[12px] text-blue-100 hover:bg-blue-600/30',
|
||||
(busy || pickedColumns.size === 0 || totalLeafSelections === 0) &&
|
||||
'cursor-not-allowed opacity-50 hover:bg-blue-600/20',
|
||||
)}
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t('export.exportButton', { defaultValue: 'Export' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,412 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronRight, ChevronDown, FolderTree, Loader2 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CrawlUrlRow, UrlCategory, AdvancedFilter } from '@freecrawl/shared-types';
|
||||
|
||||
const ROW_HEIGHT = 24;
|
||||
const TREE_LIMIT = 5000;
|
||||
const INDENT_PX = 14;
|
||||
|
||||
/** One node in the URL tree — either a synthesized folder (path
|
||||
* segment with one or more descendants) or a leaf row from the DB. */
|
||||
type TreeNode =
|
||||
| { kind: 'folder'; key: string; label: string; depth: number; count: number; children: TreeNode[] }
|
||||
| { kind: 'leaf'; key: string; depth: number; row: CrawlUrlRow };
|
||||
|
||||
interface FlatRow {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function splitPathSegments(rawUrl: string): { origin: string; segments: string[] } {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
const origin = `${u.protocol}//${u.host}`;
|
||||
const path = u.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
const segments = path ? path.split('/') : [];
|
||||
const suffix = (u.search ?? '') + (u.hash ?? '');
|
||||
if (suffix) {
|
||||
// Append the query/hash to the last path segment so `?a=1` and
|
||||
// `?a=2` show up as distinct leaves. For root URLs (no path
|
||||
// segments yet) synthesize the suffix itself as the leaf — `/?a=1`.
|
||||
if (segments.length > 0) {
|
||||
segments[segments.length - 1] = segments[segments.length - 1] + suffix;
|
||||
} else {
|
||||
segments.push(suffix);
|
||||
}
|
||||
}
|
||||
return { origin, segments };
|
||||
} catch {
|
||||
return { origin: rawUrl, segments: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Group URLs into a hierarchy keyed by origin → path segments. The
|
||||
* resulting tree is sorted alphabetically at every level so siblings
|
||||
* appear in a natural file-tree order. */
|
||||
function buildTree(rows: CrawlUrlRow[]): TreeNode[] {
|
||||
interface Builder {
|
||||
folders: Map<string, Builder>;
|
||||
leaves: { row: CrawlUrlRow; key: string }[];
|
||||
}
|
||||
const roots = new Map<string, Builder>();
|
||||
for (const row of rows) {
|
||||
const { origin, segments } = splitPathSegments(row.url);
|
||||
let bucket = roots.get(origin);
|
||||
if (!bucket) {
|
||||
bucket = { folders: new Map(), leaves: [] };
|
||||
roots.set(origin, bucket);
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
bucket.leaves.push({ row, key: `${origin}|root|${row.id}` });
|
||||
continue;
|
||||
}
|
||||
let cur = bucket;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i]!;
|
||||
let child = cur.folders.get(seg);
|
||||
if (!child) {
|
||||
child = { folders: new Map(), leaves: [] };
|
||||
cur.folders.set(seg, child);
|
||||
}
|
||||
cur = child;
|
||||
}
|
||||
cur.leaves.push({
|
||||
row,
|
||||
key: `${origin}|${segments.join('/')}|${row.id}`,
|
||||
});
|
||||
}
|
||||
// Walk the Builders into TreeNode objects with stable keys.
|
||||
function materialise(
|
||||
builder: Builder,
|
||||
pathPrefix: string,
|
||||
depth: number,
|
||||
): { children: TreeNode[]; count: number } {
|
||||
let total = 0;
|
||||
const out: TreeNode[] = [];
|
||||
const folderEntries = [...builder.folders.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
for (const [name, sub] of folderEntries) {
|
||||
const subPath = pathPrefix ? `${pathPrefix}/${name}` : name;
|
||||
const inner = materialise(sub, subPath, depth + 1);
|
||||
total += inner.count;
|
||||
out.push({
|
||||
kind: 'folder',
|
||||
key: `f:${subPath}`,
|
||||
label: name,
|
||||
depth,
|
||||
count: inner.count,
|
||||
children: inner.children,
|
||||
});
|
||||
}
|
||||
const leaves = [...builder.leaves].sort((a, b) => a.row.url.localeCompare(b.row.url));
|
||||
for (const leaf of leaves) {
|
||||
out.push({ kind: 'leaf', key: `l:${leaf.row.id}`, depth, row: leaf.row });
|
||||
total += 1;
|
||||
}
|
||||
return { children: out, count: total };
|
||||
}
|
||||
const result: TreeNode[] = [];
|
||||
const originEntries = [...roots.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
for (const [origin, bucket] of originEntries) {
|
||||
const inner = materialise(bucket, '', 1);
|
||||
result.push({
|
||||
kind: 'folder',
|
||||
key: `o:${origin}`,
|
||||
label: origin,
|
||||
depth: 0,
|
||||
count: inner.count,
|
||||
children: inner.children,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(nodes: TreeNode[], expanded: Set<string>, acc: FlatRow[]): void {
|
||||
for (const node of nodes) {
|
||||
acc.push({ node, depth: node.depth });
|
||||
if (node.kind === 'folder' && expanded.has(node.key)) {
|
||||
flatten(node.children, expanded, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UrlTreeViewProps {
|
||||
category: UrlCategory;
|
||||
search: string;
|
||||
filter: AdvancedFilter | null;
|
||||
refreshKey: number;
|
||||
/** Currently selected URL id — drives highlighting. */
|
||||
selectedUrlId: number | null;
|
||||
onSelect: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function UrlTreeView({
|
||||
category,
|
||||
search,
|
||||
filter,
|
||||
refreshKey,
|
||||
selectedUrlId,
|
||||
onSelect,
|
||||
}: UrlTreeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<CrawlUrlRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void window.freecrawl
|
||||
.urlsQuery({
|
||||
limit: TREE_LIMIT,
|
||||
offset: 0,
|
||||
category,
|
||||
search: search || undefined,
|
||||
filter: filter ?? undefined,
|
||||
sortBy: 'url',
|
||||
sortDir: 'asc',
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
// Default-expand the origin folders so the user sees something
|
||||
// immediately; deeper folders stay collapsed until clicked.
|
||||
const origins = new Set<string>();
|
||||
for (const r of res.rows) {
|
||||
try {
|
||||
const u = new URL(r.url);
|
||||
origins.add(`o:${u.protocol}//${u.host}`);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
setExpanded(origins);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [category, search, filter, refreshKey]);
|
||||
|
||||
const tree = useMemo(() => buildTree(rows), [rows]);
|
||||
const flat = useMemo(() => {
|
||||
const out: FlatRow[] = [];
|
||||
flatten(tree, expanded, out);
|
||||
return out;
|
||||
}, [tree, expanded]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flat.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 30,
|
||||
getItemKey: (i) => flat[i]?.node.key ?? `idx-${i}`,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const truncated = total > TREE_LIMIT;
|
||||
|
||||
function toggleFolder(key: string): void {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function expandAll(): void {
|
||||
const all = new Set<string>();
|
||||
function walk(nodes: TreeNode[]): void {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'folder') {
|
||||
all.add(n.key);
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(tree);
|
||||
setExpanded(all);
|
||||
}
|
||||
|
||||
function collapseAll(): void {
|
||||
setExpanded(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-surface-800 bg-surface-900/30 px-3 py-1 text-[11px] text-surface-400">
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{t('urlsTab.treeStatus', {
|
||||
defaultValue: 'Tree view · {{rows}} URL · {{folders}} folder',
|
||||
rows: rows.length.toLocaleString(),
|
||||
folders: countFolders(tree).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{truncated && (
|
||||
<span className="rounded bg-amber-900/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-amber-300">
|
||||
{t('urlsTab.treeTruncated', {
|
||||
defaultValue: 'truncated to first {{cap}}',
|
||||
cap: TREE_LIMIT.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandAll}
|
||||
className="rounded border border-surface-700 px-2 py-0.5 text-[10px] text-surface-300 hover:bg-surface-800"
|
||||
>
|
||||
{t('urlsTab.expandAll', { defaultValue: 'Expand all' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={collapseAll}
|
||||
className="rounded border border-surface-700 px-2 py-0.5 text-[10px] text-surface-300 hover:bg-surface-800"
|
||||
>
|
||||
{t('urlsTab.collapseAll', { defaultValue: 'Collapse all' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto bg-surface-950 font-mono">
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-[12px] text-surface-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('urlsTab.treeLoading', { defaultValue: 'Loading URL tree…' })}
|
||||
</div>
|
||||
) : flat.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-[12px] text-surface-500">
|
||||
{t('urlsTab.treeEmpty', {
|
||||
defaultValue: 'No URLs to display — crawl a site or change the filter.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualRows.map((vi) => {
|
||||
const item = flat[vi.index]!;
|
||||
const isSelected =
|
||||
item.node.kind === 'leaf' && item.node.row.id === selectedUrlId;
|
||||
const isFolder = item.node.kind === 'folder';
|
||||
const expandedNow = isFolder && expanded.has(item.node.key);
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
className={clsx(
|
||||
'absolute left-0 top-0 flex w-full items-center gap-1 border-b border-surface-900/60 pr-3 text-[11px] transition',
|
||||
isSelected
|
||||
? 'bg-accent-500/15 text-surface-100'
|
||||
: isFolder
|
||||
? 'text-surface-200 hover:bg-surface-900/40'
|
||||
: 'text-surface-300 hover:bg-surface-900/40',
|
||||
)}
|
||||
style={{
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
height: ROW_HEIGHT,
|
||||
paddingLeft: 8 + item.depth * INDENT_PX,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (item.node.kind === 'folder') {
|
||||
toggleFolder(item.node.key);
|
||||
} else {
|
||||
onSelect(item.node.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFolder ? (
|
||||
<span className="text-surface-500">
|
||||
{expandedNow ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block h-3.5 w-3.5" />
|
||||
)}
|
||||
{isFolder ? (
|
||||
<>
|
||||
<span className="truncate font-semibold">
|
||||
{(item.node as { label: string }).label || '/'}
|
||||
</span>
|
||||
<span className="text-surface-500">
|
||||
({(item.node as { count: number }).count.toLocaleString()})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<LeafRow row={(item.node as { row: CrawlUrlRow }).row} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countFolders(nodes: TreeNode[]): number {
|
||||
let n = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'folder') {
|
||||
n++;
|
||||
n += countFolders(node.children);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function LeafRow({ row }: { row: CrawlUrlRow }) {
|
||||
const status = row.statusCode ?? 0;
|
||||
const statusClass =
|
||||
status >= 500
|
||||
? 'bg-red-900/50 text-red-200'
|
||||
: status >= 400
|
||||
? 'bg-orange-900/50 text-orange-200'
|
||||
: status >= 300
|
||||
? 'bg-amber-900/40 text-amber-200'
|
||||
: status >= 200
|
||||
? 'bg-emerald-900/40 text-emerald-200'
|
||||
: 'bg-surface-800 text-surface-400';
|
||||
const title = row.title ?? '';
|
||||
const { segments } = splitPathSegments(row.url);
|
||||
const last = segments[segments.length - 1] ?? '/';
|
||||
return (
|
||||
<>
|
||||
<span className="truncate" title={row.url}>
|
||||
{last}
|
||||
</span>
|
||||
{status > 0 && (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-2 rounded px-1.5 py-0.5 text-[9px] font-mono uppercase',
|
||||
statusClass,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<span className="ml-2 truncate text-surface-500" title={title}>
|
||||
· {title}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,7 @@ const TR_LABELS: Record<string, string> = {
|
||||
Noindex: 'Noindex',
|
||||
Canonicalised: 'Canonicalised',
|
||||
'Canonicalised (→ other)': 'Canonicalised (→ başka)',
|
||||
'Non-Indexable': 'İndekslenemez',
|
||||
|
||||
// Issues parent
|
||||
'Page Titles': 'Sayfa Başlıkları',
|
||||
@@ -360,6 +361,8 @@ const TR_LABELS: Record<string, string> = {
|
||||
'SERP Snippet': 'SERP Snippet',
|
||||
'HTTP Headers': 'HTTP Başlıkları',
|
||||
'View Source': 'Kaynağı Göster',
|
||||
'View Rendered': 'Render Edilmiş DOM',
|
||||
Screenshot: 'Ekran Görüntüsü',
|
||||
|
||||
// Detail panel resource categories
|
||||
Scripts: 'Scriptler',
|
||||
@@ -547,6 +550,30 @@ const TR_LABELS: Record<string, string> = {
|
||||
'Link Position': 'Link Konumu',
|
||||
'Link Origin': 'Link Kaynağı',
|
||||
Transferred: 'Aktarılan',
|
||||
|
||||
// Settings dialog sidebar sections
|
||||
Presets: 'Hazır Ayarlar',
|
||||
Mode: 'Mod',
|
||||
Crawler: 'Crawler',
|
||||
Speed: 'Hız',
|
||||
Requests: 'İstekler',
|
||||
'Include/Exclude': 'Dahil Et/Hariç Tut',
|
||||
'Custom Search': 'Özel Arama',
|
||||
'Custom Extraction': 'Özel Veri Çekme',
|
||||
'URL Rewriting': 'URL Yeniden Yazma',
|
||||
Authentication: 'Kimlik Doğrulama',
|
||||
Network: 'Ağ',
|
||||
Duplicates: 'Yinelenenler',
|
||||
Hardware: 'Donanım',
|
||||
Webhook: 'Webhook',
|
||||
'Crawl Analysis': 'Crawl Analizi',
|
||||
Advanced: 'Gelişmiş',
|
||||
'Per-Host UA': 'Host Bazlı UA',
|
||||
Integrations: 'Entegrasyonlar',
|
||||
Rendering: 'Render',
|
||||
Storage: 'Depolama',
|
||||
Privacy: 'Gizlilik',
|
||||
Language: 'Dil',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,9 +88,7 @@
|
||||
"clearRecent": "Clear Recent",
|
||||
"emptyRecent": "(empty)",
|
||||
"clearCrawlData": "Clear Crawl Data",
|
||||
"exportCsv": "Export Current View as CSV",
|
||||
"exportJson": "Export Current View as JSON…",
|
||||
"exportXml": "Export Current View as XML…",
|
||||
"exportAs": "Export Crawl Data…",
|
||||
"generateSitemap": "Generate XML Sitemap…",
|
||||
"exportHtmlReport": "Export HTML Report…",
|
||||
"bulkExport": "Bulk Export…",
|
||||
@@ -103,8 +101,9 @@
|
||||
"view": "View",
|
||||
"overviewSidebar": "Overview Sidebar",
|
||||
"detailPanel": "Detail Panel",
|
||||
"showVisualization": "Show Visualization Tab",
|
||||
"fullscreen": "Fullscreen",
|
||||
"visualization": "Visualization",
|
||||
"openVisualizationWindow": "Open Visualization Window…",
|
||||
"reports": "Reports",
|
||||
"reportsItem": "Reports…",
|
||||
"help": "Help",
|
||||
@@ -169,15 +168,27 @@
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"visualization": {
|
||||
"windowTitle": "Crawl Visualization",
|
||||
"windowHint": "Standalone window — close to detach from the main app"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export",
|
||||
"exportAsTitle": "Export Crawl Data",
|
||||
"exportButton": "Export",
|
||||
"tables": "Tables",
|
||||
"tablesTree": "Tables (hierarchical)",
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"format": "Format",
|
||||
"csvBom": "UTF-8 BOM (Excel-friendly)",
|
||||
"selectedOnly": "Selected rows only",
|
||||
"multiCsvNote": "Multiple tables + CSV → you'll be asked to pick a folder; one file is written per table.",
|
||||
"folderNote": "Multiple sections + {{fmt}} → you'll be asked to pick a folder; one file is written per section, nested under sub-folders where applicable.",
|
||||
"xlsxNote": "Excel output → single .xlsx workbook with one sheet per section.",
|
||||
"summary": "{{n}} section(s) · {{c}} column(s)",
|
||||
"emptyCols": "Pick a table on the left to load its columns.",
|
||||
"errorNoColumn": "Pick at least one column.",
|
||||
"errorNoTable": "Pick at least one table.",
|
||||
"successMsg": "Exported {{count}} row(s) to {{path}}",
|
||||
@@ -334,28 +345,58 @@
|
||||
"importFailed": "Import failed: {{msg}}",
|
||||
"outro": "After clicking Apply or Import, review each panel to verify the values, then press Save at the bottom to persist."
|
||||
},
|
||||
"mode": { "intro": "Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set." },
|
||||
"mode": {
|
||||
"intro": "Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set.",
|
||||
"crawlMode": "Crawl Mode",
|
||||
"spiderOption": "Spider — start URL + follow links",
|
||||
"listOption": "List — fetch a fixed URL list, no link follow",
|
||||
"urlList": "URL List (one URL per line)"
|
||||
},
|
||||
"crawler": {
|
||||
"intro": "Traversal limits and crawl-scope toggles. Screaming Frog equivalents shown in parentheses.",
|
||||
"speedHintPrefix": "Looking for crawl speed?",
|
||||
"speedHintBody": "Thread count, requests per second, per-request delay, and retries live in the",
|
||||
"speedHintLink": "Speed",
|
||||
"speedHintSuffix": "section — that's where you cap how fast the crawler hits a server.",
|
||||
"maxDepth": "Crawl Depth Limit (Max Depth)",
|
||||
"maxUrls": "Crawl Total Limit (Max URLs)",
|
||||
"requestTimeoutMs": "Response Timeout (ms)",
|
||||
"followRedirects": "Always Follow Redirects",
|
||||
"respectRobotsTxt": "Respect robots.txt",
|
||||
"crawlExternal": "Check External Links",
|
||||
"storeNofollow": "Follow / Store Nofollow Links",
|
||||
"storeNofollowHint": "Default off — Screaming Frog 'Respect Nofollow' behaviour",
|
||||
"discoverSitemaps": "Auto-Discover XML Sitemaps",
|
||||
"discoverSitemapsHint": "Reads sitemap.xml from robots.txt + default paths at crawl start",
|
||||
"renderingMode": "Rendering Mode",
|
||||
"renderTextOnly": "Text Only (default)",
|
||||
"renderAjax": "Old AJAX Crawling Scheme (_escaped_fragment_)",
|
||||
"renderJs": "JavaScript Rendering (coming in V2)"
|
||||
"renderJs": "JavaScript Rendering (Playwright)",
|
||||
"renderJsHint": "JS rendering uses a headless Chromium per page — significantly slower than text mode. Configure viewport, wait conditions, and resource blocking in the Rendering tab."
|
||||
},
|
||||
"speed": {
|
||||
"intro": "Control crawl throughput. Increasing parallelism speeds the crawl, but every extra request adds load on the target server — pick numbers a host that you don't own can absorb without rate-limiting / 429s.",
|
||||
"effectiveCeiling": "Effective ceiling"
|
||||
"effectiveCeiling": "Effective ceiling",
|
||||
"maxConcurrency": "Max Concurrency (parallel workers)",
|
||||
"maxRps": "Max URL/s (rate limit)",
|
||||
"crawlDelayMs": "Per-Worker Delay (ms after each request)",
|
||||
"retriesHeading": "Retries",
|
||||
"retryAttempts": "Retry Attempts (per URL on transient errors)",
|
||||
"retryInitialDelayMs": "Initial Retry Delay (ms — exponential backoff)"
|
||||
},
|
||||
"requests": {
|
||||
"intro": "HTTP headers sent with every request.",
|
||||
"uaPreset": "Quick User-Agent preset",
|
||||
"uaCustom": "Custom / pick a preset…"
|
||||
"uaCustom": "Custom / pick a preset…",
|
||||
"userAgent": "User-Agent",
|
||||
"acceptLanguage": "Accept-Language",
|
||||
"customHeaders": "Custom Headers (one per line, \"Key: Value\")"
|
||||
},
|
||||
"filters": {
|
||||
"intro": "URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL.",
|
||||
"includePatterns": "Include Patterns (regex, one per line — empty = all allowed)",
|
||||
"excludePatterns": "Exclude Patterns (regex, one per line)"
|
||||
},
|
||||
"filters": { "intro": "URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL." },
|
||||
"customSearch": {
|
||||
"intro1": "Flag pages whose body contains any of these terms. Two modes per line:",
|
||||
"intro2": "for regex,",
|
||||
@@ -365,30 +406,178 @@
|
||||
"customExtraction": {
|
||||
"intro1": "Up to 10 custom extraction rules. Each runs against every crawled HTML page; results are stored on the URL row and visible in the URL Details panel under",
|
||||
"extractionLabel": "Extraction",
|
||||
"empty": "No rules — click \"Add Rule\" to start."
|
||||
"empty": "No rules — click \"Add Rule\" to start.",
|
||||
"removeRule": "Remove rule",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"cssSelector": "CSS Selector",
|
||||
"regex": "Regex",
|
||||
"regexPattern": "Regex Pattern",
|
||||
"output": "Output",
|
||||
"outputText": "Text",
|
||||
"outputAttribute": "Attribute",
|
||||
"outputInnerHtml": "Inner HTML",
|
||||
"outputOuterHtml": "Outer HTML",
|
||||
"outputCount": "Count",
|
||||
"outputCaptureGroup": "Capture group 1",
|
||||
"outputWholeMatch": "Whole match",
|
||||
"attribute": "Attribute",
|
||||
"multiMatch": "Multi-Match",
|
||||
"multiFirst": "First",
|
||||
"multiLast": "Last",
|
||||
"multiAll": "All (array)",
|
||||
"multiConcat": "Concat (` | `)",
|
||||
"multiCount": "Count",
|
||||
"addRule": "Add Rule",
|
||||
"limitReached": "Limit reached (10 rules)."
|
||||
},
|
||||
"urlRewriting": {
|
||||
"intro": "Normalisation applied before URLs are deduplicated and queued.",
|
||||
"stripWww": "Strip www.",
|
||||
"stripWwwHint": "Treat www.x.com and x.com as the same URL",
|
||||
"forceHttps": "Force HTTPS",
|
||||
"forceHttpsHint": "Upgrade http:// → https:// before fetching",
|
||||
"lowercasePath": "Lowercase path",
|
||||
"lowercasePathHint": "Treat /Foo and /foo as the same URL",
|
||||
"trailingSlashPolicy": "Trailing slash policy",
|
||||
"trailingSlashLeave": "Leave as-is",
|
||||
"trailingSlashStrip": "Strip (/foo/ → /foo)",
|
||||
"trailingSlashAdd": "Add (/foo → /foo/)",
|
||||
"keepQueryParams": "Keep query parameters (whitelist; one per line)",
|
||||
"regexRewrites": "Regex rewrites (applied to the final URL string in order)",
|
||||
"addRule": "Add rule",
|
||||
"noRegexRules": "No regex rules. Click \"Add rule\" to define one.",
|
||||
"removeRule": "Remove rule",
|
||||
"previewLabel": "Preview — test a URL against current settings",
|
||||
"running": "Running…",
|
||||
"preview": "Preview",
|
||||
"cannotNormalise": "URL could not be normalised.",
|
||||
"enterUrlAbove": "Enter a URL above to preview"
|
||||
},
|
||||
"urlRewriting": { "intro": "Normalisation applied before URLs are deduplicated and queued." },
|
||||
"webhook": {
|
||||
"intro1": "Webhook fired once when each crawl finishes. Single",
|
||||
"intro2": "with a JSON summary (start URL, duration, total URLs, status mix, every non-zero issue count). Empty disables.",
|
||||
"outro": "Compatible with Slack incoming webhooks (the JSON shape is rich enough for Slack to render plain text), Zapier \"Catch Hook\" triggers, Discord webhooks, and custom HTTP endpoints."
|
||||
"outro": "Compatible with Slack incoming webhooks (the JSON shape is rich enough for Slack to render plain text), Zapier \"Catch Hook\" triggers, Discord webhooks, and custom HTTP endpoints.",
|
||||
"url": "Webhook URL"
|
||||
},
|
||||
"auth": {
|
||||
"intro": "HTTP authentication applied on every request. Useful for staging environments behind Basic auth, or APIs that require a Bearer token. Digest is not supported (challenge-response state machine).",
|
||||
"scheme": "Auth scheme",
|
||||
"schemeNone": "None",
|
||||
"schemeBasic": "Basic (username + password)",
|
||||
"schemeBearer": "Bearer (token)",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"token": "Token"
|
||||
},
|
||||
"network": {
|
||||
"intro": "Network-level controls: proxy override, file-extension exclusion, redirect hop cap.",
|
||||
"proxyUrl": "Proxy URL (overrides HTTPS_PROXY)",
|
||||
"savedProfilesDesc": "Save multiple `(name, URL)` pairs and switch between them via the dropdown below. The active profile overrides the Proxy URL field and the HTTPS_PROXY env var. Empty selection falls back to the Proxy URL above.",
|
||||
"activeProfile": "Active profile",
|
||||
"noneUseProxyAbove": "— none (use Proxy URL above) —",
|
||||
"noSavedProfiles": "No saved profiles — add one below.",
|
||||
"removeProfile": "Remove profile",
|
||||
"addProxyProfile": "Add proxy profile",
|
||||
"excludeExtensions": "Exclude extensions (comma-separated)",
|
||||
"maxRedirects": "Max redirect hops"
|
||||
},
|
||||
"auth": { "intro": "HTTP authentication applied on every request. Useful for staging environments behind Basic auth, or APIs that require a Bearer token. Digest is not supported (challenge-response state machine)." },
|
||||
"network": { "intro": "Network-level controls: proxy override, file-extension exclusion, redirect hop cap." },
|
||||
"duplicates": {
|
||||
"intro": "Near-duplicate detection. After every crawl, body text is hashed with a 64-bit SimHash, and pages whose hashes lie within the configured Hamming distance of each other are clustered as near-duplicates. Surfaced under",
|
||||
"path": "Issues → Content → Near-Duplicate"
|
||||
"path": "Issues → Content → Near-Duplicate",
|
||||
"maxHamming": "Max Hamming distance (0 = exact only, 12 = very loose, 0 disables)",
|
||||
"thresholdHint": "Lower = stricter. 3 is the SF-equivalent default. Pages with too little body content (<50 characters) are excluded from clustering regardless of threshold.",
|
||||
"onlyIndexable": "Only cluster indexable pages",
|
||||
"preNormalize": "Normalise URLs before duplicate-URL check",
|
||||
"costLabel": "Cost:",
|
||||
"costBody": "SimHash adds ~5-10 ms per page during crawl; clustering itself runs after the last URL completes (~3-10 s at 1M URLs, <500 ms at 100K)."
|
||||
},
|
||||
"hardware": {
|
||||
"intro": "Resource caps for the crawler process. Useful for keeping the machine usable while crawling large sites (1M+ URLs).",
|
||||
"memorySoftLimit": "Memory soft limit (MB) — 0 = unlimited",
|
||||
"memoryHint": "When the crawler's RSS exceeds this, the queue auto-pauses and resumes once memory drops below 80% of the cap. Soft cap — does not enforce a hard heap limit.",
|
||||
"maxQueueSize": "Max in-memory queue size — 0 = unlimited",
|
||||
"queueHint": "Hard cap on pending URLs held in memory. Excess discoveries are dropped silently — bounds peak heap during fan-out bursts (large sitemaps, dense link graphs). Set conservatively if memory is tight.",
|
||||
"processPriority": "Process priority",
|
||||
"priorityNormal": "Normal",
|
||||
"priorityBelowNormal": "Below Normal",
|
||||
"priorityIdle": "Idle (lowest)",
|
||||
"priorityHint1": "OS scheduler hint. Lowering priority lets the rest of the machine stay responsive during heavy crawls. Effective on next crawl start; may require elevated privileges on some platforms. For raw CPU concurrency, see",
|
||||
"maxConcurrencyRef": "Max Concurrency",
|
||||
"priorityHint2": "in the Crawler section."
|
||||
},
|
||||
"content": {
|
||||
"intro": "How crawled HTML is stored on disk. Disable body snapshots to keep the project file small when you don't need the View Source detail tab; tighten the cap for sites with adversarially-large pages.",
|
||||
"storeBody": "Store raw HTML body per page",
|
||||
"bodyCap": "Body cap per page (bytes) — 0 = unbounded"
|
||||
},
|
||||
"crawlAnalysis": {
|
||||
"intro": "Per-pass post-crawl analysis toggles. Each pass runs after the HTTP fetch phase finishes and feeds different issue filters. Skip passes you don't need to shave wall-clock on large crawls; the related issue counters quietly read as 0 until the pass runs.",
|
||||
"recomputeInlinks": "Recompute inlinks",
|
||||
"recomputeRedirectChains": "Recompute redirect chains",
|
||||
"hreflang": "Hreflang reciprocity + inconsistent lang",
|
||||
"nearDuplicate": "Near-duplicate clustering",
|
||||
"paginationGap": "Pagination ordinal-gap detection",
|
||||
"materialiseIssues": "Materialise heavy issue counters"
|
||||
},
|
||||
"hardware": { "intro": "Resource caps for the crawler process. Useful for keeping the machine usable while crawling large sites (1M+ URLs)." },
|
||||
"content": { "intro": "How crawled HTML is stored on disk. Disable body snapshots to keep the project file small when you don't need the View Source detail tab; tighten the cap for sites with adversarially-large pages." },
|
||||
"crawlAnalysis": { "intro": "Per-pass post-crawl analysis toggles. Each pass runs after the HTTP fetch phase finishes and feeds different issue filters. Skip passes you don't need to shave wall-clock on large crawls; the related issue counters quietly read as 0 until the pass runs." },
|
||||
"issues": {
|
||||
"intro": "Per-issue check on/off toggles.",
|
||||
"v2Prefix": "Coming in V2.",
|
||||
"v2Body": "Today every issue check runs unconditionally and surfaces in the sidebar. The plan is to let you silence specific checks per-project (e.g. disable \"Description = Title\" on a CMS that's known to do it intentionally). Until that ships, hide rows you don't care about by collapsing the sidebar group, or filter them out via the Advanced filter on each tab."
|
||||
},
|
||||
"advanced": { "intro": "Lower-level caps and link-follow toggles. Defaults are tuned for typical SEO audits — only touch these if you know why." },
|
||||
"cookies": { "intro": "Cookie policy applied to every fetch. The crawler is otherwise stateless across requests; this setting controls whether Set-Cookie response headers are recorded for the cookie-flag issue checks (Missing Secure / HttpOnly / SameSite)." },
|
||||
"advanced": {
|
||||
"intro": "Lower-level caps and link-follow toggles. Defaults are tuned for typical SEO audits — only touch these if you know why.",
|
||||
"maxLinksPerPage": "Max links per page (issue threshold)",
|
||||
"maxResponseTime": "Max response time (ms) — 0 = disabled",
|
||||
"maxFileSize": "Max file size (bytes) — 0 = disabled",
|
||||
"maxUrlLength": "Max URL length (chars)",
|
||||
"maxQueryStringLength": "Max query string length (chars) — 0 = disabled",
|
||||
"maxFolderDepth": "Max folder depth — 0 = disabled",
|
||||
"followCanonical": "Follow canonical targets",
|
||||
"followNextPrev": "Follow rel=next / rel=prev",
|
||||
"followNofollow": "Follow nofollow links (override 'respect nofollow')",
|
||||
"followJsMetaRefresh": "Follow JS / meta-refresh redirects"
|
||||
},
|
||||
"cookies": {
|
||||
"intro": "Cookie policy applied to every fetch. The crawler is otherwise stateless across requests; this setting controls whether Set-Cookie response headers are recorded for the cookie-flag issue checks (Missing Secure / HttpOnly / SameSite).",
|
||||
"policy": "Cookie policy",
|
||||
"rejectAll": "Reject all (default — stateless)",
|
||||
"acceptAll": "Accept all",
|
||||
"blockThirdParty": "Block third-party",
|
||||
"valuesNeverStored1": "Cookie values themselves are",
|
||||
"never": "never",
|
||||
"valuesNeverStored2": "stored in the project file regardless of this setting — only the security flag (Secure / HttpOnly / SameSite) counts are kept."
|
||||
},
|
||||
"rendering": {
|
||||
"intro": "JavaScript rendering uses headless Chromium (Playwright) to fetch the post-load DOM after scripts execute. Required for SPA / hydration-only content. Significantly slower than text mode — every URL pays a browser-launch and wait cost. Enable in the Crawler panel under Rendering Mode.",
|
||||
"disabledHint": "Rendering Mode is currently set to Text or Old AJAX. Switch to JavaScript Rendering in the Crawler panel to apply the settings below.",
|
||||
"viewport": "Viewport",
|
||||
"viewportWidth": "Width (px)",
|
||||
"viewportHeight": "Height (px)",
|
||||
"waitConditions": "Wait Conditions",
|
||||
"waitUntil": "Wait until",
|
||||
"ajaxTimeoutMs": "Extra wait after load (ms)",
|
||||
"waitSelector": "Wait for CSS selector (optional)",
|
||||
"resourceBlocking": "Resource Blocking",
|
||||
"resourceBlockingHint": "Block resource types before they hit the network — speeds up rendering at the cost of fidelity. Images and fonts are typically safe to block for SEO crawls; blocking scripts defeats the point of JS rendering.",
|
||||
"blockImages": "Block images",
|
||||
"blockFonts": "Block fonts",
|
||||
"blockMedia": "Block media (video/audio)",
|
||||
"blockStylesheets": "Block stylesheets",
|
||||
"blockScripts": "Block scripts (defeats JS render!)",
|
||||
"browserConfig": "Browser",
|
||||
"headless": "Headless (no visible window)",
|
||||
"browserChannel": "Chromium channel",
|
||||
"maxPages": "Parallel render pages",
|
||||
"prerenderJs": "Pre-navigation JS snippet (optional)",
|
||||
"screenshot": "Screenshot Capture",
|
||||
"screenshotHint": "PNG screenshots written to a sidecar folder next to the .seoproject file. Adds ~150–500ms per URL — leave off unless you need them for the visual audit.",
|
||||
"screenshotMode": "Capture mode",
|
||||
"mobileScreenshot": "Also capture mobile viewport (375×667)",
|
||||
"advancedAudits": "Advanced Audits",
|
||||
"mobileUsability": "Mobile Usability audit",
|
||||
"lcpCandidate": "LCP candidate detection"
|
||||
},
|
||||
"perHostUa": {
|
||||
"intro": "Override the User-Agent on a per-host basis. Useful when crawling a mobile subdomain with the mobile-Googlebot UA in the same run as the desktop site, or when a CDN serves a different page based on the requester's UA. The first matching pattern wins; the global User-Agent (Requests tab) is the fallback.",
|
||||
"syntax1": "Pattern syntax: exact host",
|
||||
@@ -396,7 +585,9 @@
|
||||
"syntax3": "— the wildcard form matches any subdomain but",
|
||||
"notApex": "not",
|
||||
"theApex": "the apex.",
|
||||
"empty": "No per-host overrides yet — add one below."
|
||||
"empty": "No per-host overrides yet — add one below.",
|
||||
"addRule": "Add per-host UA rule",
|
||||
"removeRule": "Remove rule"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
@@ -567,6 +758,21 @@
|
||||
"noBodyHint1": "View Source is only captured for HTML pages crawled with the",
|
||||
"noBodyHint2": "setting enabled."
|
||||
},
|
||||
"viewRendered": {
|
||||
"noBody": "No rendered DOM stored for this URL.",
|
||||
"noBodyHint": "View Rendered is only populated when the page was crawled with Rendering Mode = JavaScript Rendering. Switch to JS mode in Settings → Crawler and re-crawl the URL to capture the post-JS DOM.",
|
||||
"renderMs": "render {{ms}}ms"
|
||||
},
|
||||
"screenshot": {
|
||||
"loading": "Loading screenshot…",
|
||||
"empty": "No screenshots stored for this URL.",
|
||||
"emptyHint": "Screenshots are only captured when Rendering Mode = JavaScript Rendering and Screenshot Capture is enabled in Settings → Rendering. Re-crawl the URL with those options on.",
|
||||
"variant": "Variant",
|
||||
"fullpage": "Full page",
|
||||
"fold": "Above the fold",
|
||||
"mobile": "Mobile",
|
||||
"readFailed": "Screenshot file could not be read from disk."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
"level": "Level",
|
||||
@@ -838,11 +1044,25 @@
|
||||
"hideAllExceptRow": "Hide every column except Row #",
|
||||
"showAll": "Show all",
|
||||
"hideAll": "Hide all",
|
||||
"columnHeaderHint": "(click to select · drag to multi-select · right-click to hide)"
|
||||
"columnHeaderHint": "(click to select · drag to multi-select · right-click to hide)",
|
||||
"quickFilterTitle": "Quick filter for this tab",
|
||||
"listView": "List view",
|
||||
"treeView": "Tree view (URL hierarchy)",
|
||||
"treeStatus": "Tree view · {{rows}} URL · {{folders}} folder",
|
||||
"treeTruncated": "truncated to first {{cap}}",
|
||||
"treeLoading": "Loading URL tree…",
|
||||
"treeEmpty": "No URLs to display — crawl a site or change the filter.",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"closeTooltip": "Close (Esc)",
|
||||
"searchPlaceholder": "Search…",
|
||||
"noMatches": "No matches",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"resetTooltip": "Reset every crawl setting to its factory default (you still have to Save)",
|
||||
"resetConfirm": "Reset ALL crawl settings to their defaults?\n\nThis clears include/exclude patterns, custom search & extraction rules, URL rewrites, auth, proxy profiles, per-host user-agents, and webhook URL. Nothing is saved until you click Save — Cancel still discards.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"language": {
|
||||
|
||||
@@ -88,9 +88,7 @@
|
||||
"clearRecent": "Son Açılanları Temizle",
|
||||
"emptyRecent": "(boş)",
|
||||
"clearCrawlData": "Crawl Verilerini Temizle",
|
||||
"exportCsv": "Mevcut Görünümü CSV Olarak Dışa Aktar",
|
||||
"exportJson": "Mevcut Görünümü JSON Olarak Dışa Aktar…",
|
||||
"exportXml": "Mevcut Görünümü XML Olarak Dışa Aktar…",
|
||||
"exportAs": "Crawl Verilerini Dışa Aktar…",
|
||||
"generateSitemap": "XML Sitemap Oluştur…",
|
||||
"exportHtmlReport": "HTML Rapor Dışa Aktar…",
|
||||
"bulkExport": "Toplu Dışa Aktarım…",
|
||||
@@ -103,8 +101,9 @@
|
||||
"view": "Görünüm",
|
||||
"overviewSidebar": "Genel Bakış Kenar Çubuğu",
|
||||
"detailPanel": "Detay Paneli",
|
||||
"showVisualization": "Görselleştirme Sekmesini Göster",
|
||||
"fullscreen": "Tam Ekran",
|
||||
"visualization": "Görselleştirme",
|
||||
"openVisualizationWindow": "Görselleştirme Penceresini Aç…",
|
||||
"reports": "Raporlar",
|
||||
"reportsItem": "Raporlar…",
|
||||
"help": "Yardım",
|
||||
@@ -169,15 +168,27 @@
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Görselleştirme"
|
||||
},
|
||||
"visualization": {
|
||||
"windowTitle": "Crawl Görselleştirme",
|
||||
"windowHint": "Ayrı pencere — kapatınca ana uygulamadan ayrılır"
|
||||
},
|
||||
"export": {
|
||||
"title": "Dışa Aktar",
|
||||
"exportAsTitle": "Crawl Verilerini Dışa Aktar",
|
||||
"exportButton": "Dışa Aktar",
|
||||
"tables": "Tablolar",
|
||||
"tablesTree": "Tablolar (hiyerarşik)",
|
||||
"active": "Aktif",
|
||||
"all": "Tümü",
|
||||
"none": "Hiçbiri",
|
||||
"format": "Format",
|
||||
"csvBom": "UTF-8 BOM (Excel uyumlu)",
|
||||
"selectedOnly": "Yalnızca seçili satırlar",
|
||||
"multiCsvNote": "Birden çok tablo + CSV → bir klasör seçmen istenecek; tablo başına bir dosya yazılır.",
|
||||
"folderNote": "Birden çok bölüm + {{fmt}} → bir klasör seçmen istenecek; her bölüm için bir dosya, uygun yerlerde alt-klasörler altında yazılır.",
|
||||
"xlsxNote": "Excel çıktı → tek .xlsx çalışma kitabı, bölüm başına bir sayfa.",
|
||||
"summary": "{{n}} bölüm · {{c}} kolon",
|
||||
"emptyCols": "Solda bir tablo seç — kolonları buraya yüklensin.",
|
||||
"errorNoColumn": "En az bir kolon seç.",
|
||||
"errorNoTable": "En az bir tablo seç.",
|
||||
"successMsg": "{{count}} satır {{path}} konumuna aktarıldı",
|
||||
@@ -333,28 +344,58 @@
|
||||
"importFailed": "İçe aktarım başarısız: {{msg}}",
|
||||
"outro": "Uygula veya İçe Aktar'a tıkladıktan sonra her paneli değerleri doğrulamak için gözden geçir, sonra altta Kaydet'e bas."
|
||||
},
|
||||
"mode": { "intro": "Crawler'ın URL'leri nasıl keşfedeceğini seç. Spider başlangıç URL'inden linkleri takip eder; List sabit bir setten alır." },
|
||||
"mode": {
|
||||
"intro": "Crawler'ın URL'leri nasıl keşfedeceğini seç. Spider başlangıç URL'inden linkleri takip eder; List sabit bir setten alır.",
|
||||
"crawlMode": "Crawl Modu",
|
||||
"spiderOption": "Spider — başlangıç URL + linkleri takip et",
|
||||
"listOption": "List — sabit URL listesi getir, link takip etme",
|
||||
"urlList": "URL Listesi (her satıra bir URL)"
|
||||
},
|
||||
"crawler": {
|
||||
"intro": "Gezinme limitleri ve crawl-kapsam toggle'ları. Screaming Frog karşılıkları parantez içinde gösterildi.",
|
||||
"speedHintPrefix": "Crawl hızı mı arıyorsun?",
|
||||
"speedHintBody": "Thread sayısı, saniyedeki istek, istek-başına gecikme ve yeniden denemeler şurada yaşar:",
|
||||
"speedHintLink": "Hız",
|
||||
"speedHintSuffix": "bölümü — crawler'ın sunucuya ne hızda vurduğunu orada kıs.",
|
||||
"maxDepth": "Crawl Derinlik Limiti (Max Depth)",
|
||||
"maxUrls": "Toplam URL Limiti (Max URLs)",
|
||||
"requestTimeoutMs": "Yanıt Zaman Aşımı (ms)",
|
||||
"followRedirects": "Yönlendirmeleri Daima Takip Et",
|
||||
"respectRobotsTxt": "robots.txt'ye Uy",
|
||||
"crawlExternal": "Harici Linkleri Kontrol Et",
|
||||
"storeNofollow": "Nofollow Linkleri Takip Et / Sakla",
|
||||
"storeNofollowHint": "Varsayılan kapalı — Screaming Frog 'Respect Nofollow' davranışı",
|
||||
"discoverSitemaps": "XML Sitemap'leri Otomatik Keşfet",
|
||||
"discoverSitemapsHint": "Crawl başlangıcında robots.txt + default yollardan sitemap.xml okur",
|
||||
"renderingMode": "Render Modu",
|
||||
"renderTextOnly": "Yalnızca Metin (varsayılan)",
|
||||
"renderAjax": "Eski AJAX Crawl Şeması (_escaped_fragment_)",
|
||||
"renderJs": "JavaScript Render (V2'de gelecek)"
|
||||
"renderJs": "JavaScript Render (Playwright)",
|
||||
"renderJsHint": "JS render her sayfa için ayrı bir headless Chromium kullanır — metin modundan kayda değer ölçüde yavaştır. Viewport, bekleme koşulları ve kaynak engelleme ayarlarını Render sekmesinden yapılandırın."
|
||||
},
|
||||
"speed": {
|
||||
"intro": "Crawl throughput'unu kontrol et. Paralelliği artırmak crawl'u hızlandırır, ama her ek istek hedef sunucuya yük bindirir — sahip olmadığın bir host'un rate-limit / 429 olmadan emebileceği sayılar seç.",
|
||||
"effectiveCeiling": "Etkin tavan"
|
||||
"effectiveCeiling": "Etkin tavan",
|
||||
"maxConcurrency": "Maks Paralellik (paralel worker)",
|
||||
"maxRps": "Maks URL/sn (rate limit)",
|
||||
"crawlDelayMs": "Worker Başına Gecikme (her istekten sonra, ms)",
|
||||
"retriesHeading": "Yeniden Denemeler",
|
||||
"retryAttempts": "Yeniden Deneme Sayısı (geçici hatada URL başına)",
|
||||
"retryInitialDelayMs": "İlk Yeniden Deneme Gecikmesi (ms — üstel backoff)"
|
||||
},
|
||||
"requests": {
|
||||
"intro": "Her istekle gönderilen HTTP başlıkları.",
|
||||
"uaPreset": "Hızlı User-Agent ön ayarı",
|
||||
"uaCustom": "Özel / bir ön ayar seç…"
|
||||
"uaCustom": "Özel / bir ön ayar seç…",
|
||||
"userAgent": "User-Agent",
|
||||
"acceptLanguage": "Accept-Language",
|
||||
"customHeaders": "Özel Başlıklar (her satıra bir tane, \"Key: Value\")"
|
||||
},
|
||||
"filters": {
|
||||
"intro": "URL izin/yasak listesi. Kalıplar URL'nin tamamına karşı test edilen JavaScript regex'tir.",
|
||||
"includePatterns": "Dahil Etme Kalıpları (regex, her satıra bir tane — boş = hepsine izin)",
|
||||
"excludePatterns": "Hariç Tutma Kalıpları (regex, her satıra bir tane)"
|
||||
},
|
||||
"filters": { "intro": "URL izin/yasak listesi. Kalıplar URL'nin tamamına karşı test edilen JavaScript regex'tir." },
|
||||
"customSearch": {
|
||||
"intro1": "Gövdesinde bu terimlerden herhangi birini içeren sayfaları işaretle. Satır başına iki mod:",
|
||||
"intro2": "regex için,",
|
||||
@@ -364,30 +405,178 @@
|
||||
"customExtraction": {
|
||||
"intro1": "10'a kadar özel çıkarım kuralı. Her biri her crawl edilmiş HTML sayfasına karşı çalışır; sonuçlar URL satırında saklanır ve URL Detayları panelinde",
|
||||
"extractionLabel": "Extraction",
|
||||
"empty": "Kural yok — başlamak için \"Add Rule\" tıkla."
|
||||
"empty": "Kural yok — başlamak için \"Add Rule\" tıkla.",
|
||||
"removeRule": "Kuralı kaldır",
|
||||
"name": "İsim",
|
||||
"type": "Tür",
|
||||
"cssSelector": "CSS Seçici",
|
||||
"regex": "Regex",
|
||||
"regexPattern": "Regex Kalıbı",
|
||||
"output": "Çıktı",
|
||||
"outputText": "Metin",
|
||||
"outputAttribute": "Öznitelik",
|
||||
"outputInnerHtml": "Inner HTML",
|
||||
"outputOuterHtml": "Outer HTML",
|
||||
"outputCount": "Sayı",
|
||||
"outputCaptureGroup": "Capture grubu 1",
|
||||
"outputWholeMatch": "Tüm eşleşme",
|
||||
"attribute": "Öznitelik",
|
||||
"multiMatch": "Çoklu Eşleşme",
|
||||
"multiFirst": "İlk",
|
||||
"multiLast": "Son",
|
||||
"multiAll": "Tümü (dizi)",
|
||||
"multiConcat": "Birleştir (` | `)",
|
||||
"multiCount": "Sayı",
|
||||
"addRule": "Kural Ekle",
|
||||
"limitReached": "Limit aşıldı (10 kural)."
|
||||
},
|
||||
"urlRewriting": {
|
||||
"intro": "URL'ler yinelemeden ve kuyruğa alınmadan önce uygulanan normalleştirme.",
|
||||
"stripWww": "www. ön ekini kaldır",
|
||||
"stripWwwHint": "www.x.com ve x.com'u aynı URL olarak değerlendir",
|
||||
"forceHttps": "HTTPS'i zorla",
|
||||
"forceHttpsHint": "Fetch öncesi http:// → https:// yükselt",
|
||||
"lowercasePath": "Yolu küçük harfe çevir",
|
||||
"lowercasePathHint": "/Foo ve /foo'yu aynı URL olarak değerlendir",
|
||||
"trailingSlashPolicy": "Sondaki slash politikası",
|
||||
"trailingSlashLeave": "Olduğu gibi bırak",
|
||||
"trailingSlashStrip": "Kaldır (/foo/ → /foo)",
|
||||
"trailingSlashAdd": "Ekle (/foo → /foo/)",
|
||||
"keepQueryParams": "Korunan query parametreleri (whitelist; her satıra bir tane)",
|
||||
"regexRewrites": "Regex rewrites (sırayla nihai URL string'ine uygulanır)",
|
||||
"addRule": "Kural ekle",
|
||||
"noRegexRules": "Regex kuralı yok. Bir tane tanımlamak için \"Kural ekle\"ye tıkla.",
|
||||
"removeRule": "Kuralı kaldır",
|
||||
"previewLabel": "Önizleme — mevcut ayarlara karşı bir URL'i test et",
|
||||
"running": "Çalışıyor…",
|
||||
"preview": "Önizle",
|
||||
"cannotNormalise": "URL normalleştirilemedi.",
|
||||
"enterUrlAbove": "Önizlemek için yukarıya bir URL gir"
|
||||
},
|
||||
"urlRewriting": { "intro": "URL'ler yinelemeden ve kuyruğa alınmadan önce uygulanan normalleştirme." },
|
||||
"webhook": {
|
||||
"intro1": "Her crawl bittiğinde bir kez fırlatılan webhook. Tek bir",
|
||||
"intro2": "JSON özetle (başlangıç URL, süre, toplam URL, durum dağılımı, sıfır olmayan her issue sayacı). Boş bırakmak devre dışı bırakır.",
|
||||
"outro": "Slack incoming webhook'ları (JSON şekli Slack'in düz metin render etmesi için yeterince zengin), Zapier \"Catch Hook\" tetikleri, Discord webhook'ları ve özel HTTP uç noktalarıyla uyumlu."
|
||||
"outro": "Slack incoming webhook'ları (JSON şekli Slack'in düz metin render etmesi için yeterince zengin), Zapier \"Catch Hook\" tetikleri, Discord webhook'ları ve özel HTTP uç noktalarıyla uyumlu.",
|
||||
"url": "Webhook URL"
|
||||
},
|
||||
"auth": {
|
||||
"intro": "Her istekte uygulanan HTTP kimlik doğrulaması. Basic auth arkasındaki staging ortamları veya Bearer token gerektiren API'ler için faydalı. Digest desteklenmiyor (meydan okuma-yanıt durum makinesi).",
|
||||
"scheme": "Auth şeması",
|
||||
"schemeNone": "Yok",
|
||||
"schemeBasic": "Basic (kullanıcı adı + şifre)",
|
||||
"schemeBearer": "Bearer (token)",
|
||||
"username": "Kullanıcı adı",
|
||||
"password": "Şifre",
|
||||
"token": "Token"
|
||||
},
|
||||
"network": {
|
||||
"intro": "Ağ seviyesi kontroller: proxy override, dosya-uzantısı hariç tutma, yönlendirme hop limiti.",
|
||||
"proxyUrl": "Proxy URL (HTTPS_PROXY'yi geçersiz kılar)",
|
||||
"savedProfilesDesc": "Birden çok `(isim, URL)` çiftini kaydet ve aşağıdaki açılır menüden aralarında geçiş yap. Aktif profil hem Proxy URL alanını hem de HTTPS_PROXY env değişkenini geçersiz kılar. Boş seçim yukarıdaki Proxy URL'e döner.",
|
||||
"activeProfile": "Aktif profil",
|
||||
"noneUseProxyAbove": "— yok (yukarıdaki Proxy URL'i kullan) —",
|
||||
"noSavedProfiles": "Kayıtlı profil yok — aşağıdan ekle.",
|
||||
"removeProfile": "Profili kaldır",
|
||||
"addProxyProfile": "Proxy profili ekle",
|
||||
"excludeExtensions": "Hariç tutulan uzantılar (virgülle ayrılmış)",
|
||||
"maxRedirects": "Maks yönlendirme hop'u"
|
||||
},
|
||||
"auth": { "intro": "Her istekte uygulanan HTTP kimlik doğrulaması. Basic auth arkasındaki staging ortamları veya Bearer token gerektiren API'ler için faydalı. Digest desteklenmiyor (meydan okuma-yanıt durum makinesi)." },
|
||||
"network": { "intro": "Ağ seviyesi kontroller: proxy override, dosya-uzantısı hariç tutma, yönlendirme hop limiti." },
|
||||
"duplicates": {
|
||||
"intro": "Yakın-yinelenen tespit. Her crawl sonrası gövde metni 64-bit SimHash ile hash'lenir; hash'leri yapılandırılmış Hamming mesafesi içindeki sayfalar yakın-yinelenen olarak kümelenir. Şurada görünür:",
|
||||
"path": "Issues → Content → Near-Duplicate"
|
||||
"path": "Issues → Content → Near-Duplicate",
|
||||
"maxHamming": "Maks Hamming mesafesi (0 = sadece birebir, 12 = çok gevşek, 0 devre dışı bırakır)",
|
||||
"thresholdHint": "Düşük = daha katı. 3 SF eşdeğeri varsayılan. Çok az gövde içeriği (<50 karakter) olan sayfalar eşikten bağımsız olarak kümeleme dışı kalır.",
|
||||
"onlyIndexable": "Yalnızca indekslenebilir sayfaları kümele",
|
||||
"preNormalize": "Yinelenen URL kontrolünden önce URL'leri normalleştir",
|
||||
"costLabel": "Maliyet:",
|
||||
"costBody": "SimHash crawl sırasında sayfa başına ~5-10 ms ekler; kümelemenin kendisi son URL bittikten sonra çalışır (1M URL'de ~3-10 sn, 100K'da <500 ms)."
|
||||
},
|
||||
"hardware": {
|
||||
"intro": "Crawler süreci için kaynak limitleri. Büyük siteleri (1M+ URL) crawl ederken makineyi kullanılabilir tutmak için faydalı.",
|
||||
"memorySoftLimit": "Hafıza yumuşak limiti (MB) — 0 = sınırsız",
|
||||
"memoryHint": "Crawler'ın RSS değeri bunu aştığında kuyruk otomatik duraklar; hafıza limitin %80'ine düştüğünde devam eder. Yumuşak limit — sert heap sınırı uygulamaz.",
|
||||
"maxQueueSize": "Maks bellek-içi kuyruk boyutu — 0 = sınırsız",
|
||||
"queueHint": "Bellekte tutulan bekleyen URL'ler için sert limit. Fazla keşifler sessizce düşürülür — fan-out patlamalarında (büyük sitemap'ler, yoğun link grafikleri) tepe heap değerini sınırlar. Hafıza dar ise muhafazakar ayarla.",
|
||||
"processPriority": "Süreç önceliği",
|
||||
"priorityNormal": "Normal",
|
||||
"priorityBelowNormal": "Normalin Altı",
|
||||
"priorityIdle": "Idle (en düşük)",
|
||||
"priorityHint1": "OS scheduler ipucu. Önceliği düşürmek yoğun crawl sırasında makinenin geri kalanının duyarlı kalmasını sağlar. Sonraki crawl başlangıcında etkili olur; bazı platformlarda yetkili ayrıcalık gerekebilir. Ham CPU paralelliği için bakınız:",
|
||||
"maxConcurrencyRef": "Max Concurrency",
|
||||
"priorityHint2": "Crawler bölümü."
|
||||
},
|
||||
"content": {
|
||||
"intro": "Crawl edilmiş HTML'in diskte nasıl saklandığı. View Source detay sekmesine ihtiyacın yoksa proje dosyasını küçük tutmak için body snapshot'ları kapat; düşman büyüklükteki sayfalı siteler için sınırı sıkılaştır.",
|
||||
"storeBody": "Sayfa başına ham HTML gövdesini sakla",
|
||||
"bodyCap": "Sayfa başına gövde sınırı (byte) — 0 = sınırsız"
|
||||
},
|
||||
"crawlAnalysis": {
|
||||
"intro": "Pas-başına post-crawl analiz toggle'ları. Her pas HTTP fetch fazı bittikten sonra çalışır ve farklı issue filtrelerini besler. İhtiyacın olmayan pasları atla — ilgili issue sayaçları pas çalışana kadar 0 okur.",
|
||||
"recomputeInlinks": "Inlink'leri yeniden hesapla",
|
||||
"recomputeRedirectChains": "Yönlendirme zincirlerini yeniden hesapla",
|
||||
"hreflang": "Hreflang karşılıklılığı + tutarsız lang",
|
||||
"nearDuplicate": "Yakın-yinelenen kümeleme",
|
||||
"paginationGap": "Sayfalama ordinal-boşluk tespiti",
|
||||
"materialiseIssues": "Ağır issue sayaçlarını materyalize et"
|
||||
},
|
||||
"hardware": { "intro": "Crawler süreci için kaynak limitleri. Büyük siteleri (1M+ URL) crawl ederken makineyi kullanılabilir tutmak için faydalı." },
|
||||
"content": { "intro": "Crawl edilmiş HTML'in diskte nasıl saklandığı. View Source detay sekmesine ihtiyacın yoksa proje dosyasını küçük tutmak için body snapshot'ları kapat; düşman büyüklükteki sayfalı siteler için sınırı sıkılaştır." },
|
||||
"crawlAnalysis": { "intro": "Pas-başına post-crawl analiz toggle'ları. Her pas HTTP fetch fazı bittikten sonra çalışır ve farklı issue filtrelerini besler. İhtiyacın olmayan pasları atla — ilgili issue sayaçları pas çalışana kadar 0 okur." },
|
||||
"issues": {
|
||||
"intro": "Issue başına kontrol on/off toggle'ları.",
|
||||
"v2Prefix": "V2'de Gelecek.",
|
||||
"v2Body": "Bugün her issue kontrolü koşulsuz çalışır ve kenar çubuğunda görünür. Plan, proje başına belirli kontrolleri susturmana izin vermek (örn. kasıtlı olarak yapan bir CMS'te \"Description = Title\"ı kapat). O gelene kadar, umursamadığın satırları kenar çubuğu grubunu daraltarak gizle veya her sekmedeki Advanced filtreyle dışla."
|
||||
},
|
||||
"advanced": { "intro": "Daha düşük seviyeli limitler ve link-takip toggle'ları. Varsayılanlar tipik SEO denetimleri için ayarlandı — sebebini bilmiyorsan dokunma." },
|
||||
"cookies": { "intro": "Her fetch'e uygulanan çerez politikası. Crawler aksi halde istekler arası durumsuzdur; bu ayar cookie-flag issue kontrolleri (Missing Secure / HttpOnly / SameSite) için Set-Cookie yanıt başlıklarının kaydedilip kaydedilmeyeceğini kontrol eder." },
|
||||
"advanced": {
|
||||
"intro": "Daha düşük seviyeli limitler ve link-takip toggle'ları. Varsayılanlar tipik SEO denetimleri için ayarlandı — sebebini bilmiyorsan dokunma.",
|
||||
"maxLinksPerPage": "Sayfa başına maks link (issue eşiği)",
|
||||
"maxResponseTime": "Maks yanıt süresi (ms) — 0 = devre dışı",
|
||||
"maxFileSize": "Maks dosya boyutu (byte) — 0 = devre dışı",
|
||||
"maxUrlLength": "Maks URL uzunluğu (karakter)",
|
||||
"maxQueryStringLength": "Maks query string uzunluğu (karakter) — 0 = devre dışı",
|
||||
"maxFolderDepth": "Maks klasör derinliği — 0 = devre dışı",
|
||||
"followCanonical": "Canonical hedeflerini takip et",
|
||||
"followNextPrev": "rel=next / rel=prev'i takip et",
|
||||
"followNofollow": "Nofollow linkleri takip et ('respect nofollow'u geçersiz kıl)",
|
||||
"followJsMetaRefresh": "JS / meta-refresh yönlendirmelerini takip et"
|
||||
},
|
||||
"cookies": {
|
||||
"intro": "Her fetch'e uygulanan çerez politikası. Crawler aksi halde istekler arası durumsuzdur; bu ayar cookie-flag issue kontrolleri (Missing Secure / HttpOnly / SameSite) için Set-Cookie yanıt başlıklarının kaydedilip kaydedilmeyeceğini kontrol eder.",
|
||||
"policy": "Çerez politikası",
|
||||
"rejectAll": "Hepsini reddet (varsayılan — durumsuz)",
|
||||
"acceptAll": "Hepsini kabul et",
|
||||
"blockThirdParty": "Üçüncü tarafı engelle",
|
||||
"valuesNeverStored1": "Çerez değerlerinin kendileri bu ayar ne olursa olsun proje dosyasında",
|
||||
"never": "asla",
|
||||
"valuesNeverStored2": "saklanmaz — yalnızca güvenlik bayrakları (Secure / HttpOnly / SameSite) sayımları tutulur."
|
||||
},
|
||||
"rendering": {
|
||||
"intro": "JavaScript render, script'ler çalıştıktan sonraki DOM'u almak için headless Chromium (Playwright) kullanır. SPA / hidrasyon-yalnızca içerik için gereklidir. Metin modundan kayda değer ölçüde yavaştır — her URL bir browser-başlatma ve bekleme bedeli öder. Crawler panelindeki Render Modu altından etkinleştirin.",
|
||||
"disabledHint": "Render Modu şu anda Metin veya Eski AJAX olarak ayarlı. Aşağıdaki ayarları uygulamak için Crawler panelinden JavaScript Render'a geçin.",
|
||||
"viewport": "Viewport",
|
||||
"viewportWidth": "Genişlik (px)",
|
||||
"viewportHeight": "Yükseklik (px)",
|
||||
"waitConditions": "Bekleme Koşulları",
|
||||
"waitUntil": "Beklenecek olay",
|
||||
"ajaxTimeoutMs": "Yükleme sonrası ek bekleme (ms)",
|
||||
"waitSelector": "CSS seçicisi bekle (opsiyonel)",
|
||||
"resourceBlocking": "Kaynak Engelleme",
|
||||
"resourceBlockingHint": "Kaynak türlerini network'e ulaşmadan engelleyin — render hızlanır, fidelity azalır. Image ve font'lar SEO crawl'ları için genellikle güvenle engellenebilir; script engellemek JS render'ın anlamını ortadan kaldırır.",
|
||||
"blockImages": "Görselleri engelle",
|
||||
"blockFonts": "Font'ları engelle",
|
||||
"blockMedia": "Media engelle (video/audio)",
|
||||
"blockStylesheets": "Stylesheet'leri engelle",
|
||||
"blockScripts": "Script'leri engelle (JS render'ı bozar!)",
|
||||
"browserConfig": "Tarayıcı",
|
||||
"headless": "Headless (görünmez pencere)",
|
||||
"browserChannel": "Chromium kanalı",
|
||||
"maxPages": "Paralel render sayfası",
|
||||
"prerenderJs": "Navigasyon öncesi JS snippet (opsiyonel)",
|
||||
"screenshot": "Ekran Görüntüsü Yakalama",
|
||||
"screenshotHint": ".seoproject dosyasının yanındaki sidecar klasöre PNG yazılır. URL başına ~150–500ms ekler — görsel audit gerekmiyorsa kapalı bırakın.",
|
||||
"screenshotMode": "Yakalama modu",
|
||||
"mobileScreenshot": "Mobil viewport (375×667) için de yakala",
|
||||
"advancedAudits": "Gelişmiş Denetimler",
|
||||
"mobileUsability": "Mobil Kullanılabilirlik denetimi",
|
||||
"lcpCandidate": "LCP aday tespiti"
|
||||
},
|
||||
"perHostUa": {
|
||||
"intro": "User-Agent'ı host bazında geçersiz kıl. Mobil bir alt alan adını mobile-Googlebot UA ile masaüstü sitesiyle aynı koşuda crawl ederken veya bir CDN, isteyene göre farklı sayfa servis ettiğinde faydalı. İlk eşleşen kalıp kazanır; global User-Agent (Requests sekmesi) yedektir.",
|
||||
"syntax1": "Kalıp söz dizimi: tam host",
|
||||
@@ -395,7 +584,9 @@
|
||||
"syntax3": "— wildcard formu herhangi bir alt alanı eşler ama",
|
||||
"notApex": "değil",
|
||||
"theApex": "apex'i.",
|
||||
"empty": "Henüz host-bazlı override yok — aşağıdan ekle."
|
||||
"empty": "Henüz host-bazlı override yok — aşağıdan ekle.",
|
||||
"addRule": "Host bazlı UA kuralı ekle",
|
||||
"removeRule": "Kuralı kaldır"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
@@ -566,6 +757,21 @@
|
||||
"noBodyHint1": "View Source yalnızca şu ayarla crawl edilen HTML sayfaları için yakalanır:",
|
||||
"noBodyHint2": "etkin halde."
|
||||
},
|
||||
"viewRendered": {
|
||||
"noBody": "Bu URL için render edilmiş DOM kaydedilmemiş.",
|
||||
"noBodyHint": "View Rendered yalnızca Render Modu = JavaScript Render ile crawl edilmiş sayfalarda dolar. Settings → Crawler altından JS moduna geçip URL'i yeniden crawl ederek post-JS DOM'u yakalayın.",
|
||||
"renderMs": "render {{ms}}ms"
|
||||
},
|
||||
"screenshot": {
|
||||
"loading": "Ekran görüntüsü yükleniyor…",
|
||||
"empty": "Bu URL için ekran görüntüsü kaydedilmemiş.",
|
||||
"emptyHint": "Ekran görüntüleri yalnızca Render Modu = JavaScript Render ve Settings → Rendering altında Screenshot Capture açıkken alınır. URL'i bu seçeneklerle yeniden crawl edin.",
|
||||
"variant": "Variant",
|
||||
"fullpage": "Tam sayfa",
|
||||
"fold": "Fold üstü",
|
||||
"mobile": "Mobil",
|
||||
"readFailed": "Ekran görüntüsü diskten okunamadı."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loglar",
|
||||
"level": "Seviye",
|
||||
@@ -837,11 +1043,25 @@
|
||||
"hideAllExceptRow": "Satır # dışındaki her kolonu gizle",
|
||||
"showAll": "Tümünü göster",
|
||||
"hideAll": "Tümünü gizle",
|
||||
"columnHeaderHint": "(seçmek için tıkla · çoklu seçim için sürükle · gizlemek için sağ-tıkla)"
|
||||
"columnHeaderHint": "(seçmek için tıkla · çoklu seçim için sürükle · gizlemek için sağ-tıkla)",
|
||||
"quickFilterTitle": "Bu sekme için hızlı filtre",
|
||||
"listView": "Liste görünümü",
|
||||
"treeView": "Ağaç görünümü (URL hiyerarşisi)",
|
||||
"treeStatus": "Ağaç görünümü · {{rows}} URL · {{folders}} klasör",
|
||||
"treeTruncated": "ilk {{cap}} satırla sınırlı",
|
||||
"treeLoading": "URL ağacı yükleniyor…",
|
||||
"treeEmpty": "Gösterilecek URL yok — bir site tara veya filtreyi değiştir.",
|
||||
"expandAll": "Tümünü genişlet",
|
||||
"collapseAll": "Tümünü daralt"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"closeTooltip": "Kapat (Esc)",
|
||||
"searchPlaceholder": "Ara…",
|
||||
"noMatches": "Eşleşme yok",
|
||||
"resetToDefaults": "Varsayılanlara Sıfırla",
|
||||
"resetTooltip": "Her crawl ayarını fabrika varsayılanına sıfırla (yine de Kaydet'e basman gerekir)",
|
||||
"resetConfirm": "TÜM crawl ayarları varsayılanlara sıfırlansın mı?\n\nDahil/hariç desenleri, özel arama ve veri çekme kuralları, URL yeniden yazma, kimlik doğrulama, proxy profilleri, host bazlı UA'lar ve webhook URL'si temizlenir. Kaydet'e basana kadar hiçbir şey kalıcı olmaz — İptal yine de değişiklikleri atar.",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"language": {
|
||||
|
||||
@@ -2,18 +2,28 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.js';
|
||||
import { LogsView } from './LogsView.js';
|
||||
import { VisualizationView } from './VisualizationView.js';
|
||||
import './styles.css';
|
||||
import './i18n/index.js';
|
||||
|
||||
// The Logs popup window loads the same renderer bundle with `?logs=1` —
|
||||
// we branch at the entry point so only the LogsView component mounts
|
||||
// there (skipping the heavy main App, its stores, and IPC subscriptions).
|
||||
const isLogsWindow =
|
||||
typeof window !== 'undefined' && /[?&]logs=1\b/.test(window.location.search);
|
||||
// Popup windows load the same renderer bundle with a query flag so the
|
||||
// entry point branches on which top-level component to mount. Keeps the
|
||||
// heavy main App (its stores + IPC subscriptions) off the popups.
|
||||
const search = typeof window !== 'undefined' ? window.location.search : '';
|
||||
const isLogsWindow = /[?&]logs=1\b/.test(search);
|
||||
const isVisualizationWindow = /[?&]visualization=1\b/.test(search);
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>{isLogsWindow ? <LogsView /> : <App />}</React.StrictMode>,
|
||||
<React.StrictMode>
|
||||
{isLogsWindow ? (
|
||||
<LogsView />
|
||||
) : isVisualizationWindow ? (
|
||||
<VisualizationView />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,7 @@ export type TabKey =
|
||||
| 'search-console'
|
||||
| 'analytics'
|
||||
| 'ai'
|
||||
| 'seo'
|
||||
| 'visualization';
|
||||
| 'seo';
|
||||
|
||||
export const TAB_ORDER: { key: TabKey; label: string }[] = [
|
||||
{ key: 'internal', label: 'Internal' },
|
||||
@@ -72,9 +71,254 @@ export const TAB_ORDER: { key: TabKey; label: string }[] = [
|
||||
{ key: 'analytics', label: 'GA4' },
|
||||
{ key: 'ai', label: 'AI' },
|
||||
{ key: 'seo', label: 'SEO Authority' },
|
||||
{ key: 'visualization', label: 'Visualization' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-tab quick-filter dropdown options. Mirrors the OverviewSidebar's
|
||||
* structural sub-categories for tabs that have them; tabs without a
|
||||
* predefined sub-grouping (Page Titles, Meta Description, H1, …) get
|
||||
* just an `All` entry so the dropdown stays consistent.
|
||||
*
|
||||
* Labels here are the English source strings — they go through
|
||||
* `translateLabel(label, lang)` at render time, identical to how the
|
||||
* sidebar handles localisation.
|
||||
*/
|
||||
export interface TabQuickFilter {
|
||||
label: string;
|
||||
category: UrlCategory;
|
||||
}
|
||||
|
||||
export const TAB_QUICK_FILTERS: Partial<Record<TabKey, TabQuickFilter[]>> = {
|
||||
// ─── Structural content-kind / status / security / indexability splits ──
|
||||
internal: [
|
||||
{ label: 'All', category: 'internal:all' },
|
||||
{ label: 'HTML', category: 'internal:html' },
|
||||
{ label: 'JavaScript', category: 'internal:js' },
|
||||
{ label: 'CSS', category: 'internal:css' },
|
||||
{ label: 'Images', category: 'internal:image' },
|
||||
{ label: 'PDF', category: 'internal:pdf' },
|
||||
{ label: 'Fonts', category: 'internal:font' },
|
||||
{ label: 'Other', category: 'internal:other' },
|
||||
],
|
||||
external: [
|
||||
{ label: 'All', category: 'external:all' },
|
||||
{ label: 'HTML', category: 'external:html' },
|
||||
{ label: 'Other', category: 'external:other' },
|
||||
],
|
||||
'response-codes': [
|
||||
{ label: 'All', category: 'all' },
|
||||
{ label: 'Blocked by Robots', category: 'status:blocked-robots' },
|
||||
{ label: 'No Response', category: 'status:no-response' },
|
||||
{ label: 'Success (2xx)', category: 'status:2xx' },
|
||||
{ label: 'Redirection (3xx)', category: 'status:3xx' },
|
||||
{ label: 'Client Error (4xx)', category: 'status:4xx' },
|
||||
{ label: 'Server Error (5xx)', category: 'status:5xx' },
|
||||
{ label: 'Slow (>1s)', category: 'issues:response-slow' },
|
||||
{ label: 'Very Slow (>3s)', category: 'issues:response-very-slow' },
|
||||
{ label: 'TTFB Slow (>600ms)', category: 'issues:ttfb-slow' },
|
||||
{ label: 'TTFB Very Slow (>1.8s)', category: 'issues:ttfb-very-slow' },
|
||||
],
|
||||
security: [
|
||||
{ label: 'HTTPS URLs', category: 'security:https' },
|
||||
{ label: 'HTTP URLs', category: 'security:http' },
|
||||
// Cookies
|
||||
{ label: 'Missing Secure (HTTPS)', category: 'issues:cookie-no-secure' },
|
||||
{ label: 'Missing HttpOnly', category: 'issues:cookie-no-httponly' },
|
||||
{ label: 'Missing SameSite', category: 'issues:cookie-no-samesite' },
|
||||
// Security headers
|
||||
{ label: 'HSTS Missing', category: 'issues:hsts-missing' },
|
||||
{ label: 'HSTS Missing Preload', category: 'issues:hsts-no-preload' },
|
||||
{ label: 'HSTS Max-Age <1y', category: 'issues:hsts-max-age-short' },
|
||||
{ label: 'HSTS Missing includeSubDomains', category: 'issues:hsts-no-includesubdomains' },
|
||||
{ label: 'X-Frame-Options Missing', category: 'issues:x-frame-options-missing' },
|
||||
{ label: 'X-Content-Type-Options Missing', category: 'issues:x-content-type-options-missing' },
|
||||
{ label: 'CSP Missing', category: 'issues:csp-missing' },
|
||||
{ label: 'Mixed Content (any)', category: 'issues:mixed-content' },
|
||||
{ label: 'Mixed Content Active (Blocked)', category: 'issues:mixed-content-active' },
|
||||
{ label: 'Mixed Content Passive (Warning)', category: 'issues:mixed-content-passive' },
|
||||
{ label: 'Insecure Form Action', category: 'issues:insecure-form-action' },
|
||||
{ label: 'Missing SRI (3rd-party)', category: 'issues:missing-sri' },
|
||||
{ label: 'CORS Wildcard + Credentials', category: 'issues:cors-wildcard-with-credentials' },
|
||||
{ label: 'CORS Wildcard Origin', category: 'issues:cors-wildcard-origin' },
|
||||
{ label: 'HTTP (not HTTPS)', category: 'issues:http-not-https' },
|
||||
// SSL/TLS
|
||||
{ label: 'Certificate Expired', category: 'issues:ssl-cert-expired' },
|
||||
{ label: 'Certificate Expiring (≤30d)', category: 'issues:ssl-cert-expiring-soon' },
|
||||
{ label: 'Deprecated TLS Protocol', category: 'issues:ssl-protocol-old' },
|
||||
{ label: 'Weak Signature Algorithm', category: 'issues:ssl-signature-weak' },
|
||||
],
|
||||
directives: [
|
||||
{ label: 'Indexable', category: 'indexability:indexable' },
|
||||
{ label: 'Non-Indexable', category: 'indexability:non-indexable' },
|
||||
{ label: 'Noindex', category: 'indexability:noindex' },
|
||||
{ label: 'Canonicalised', category: 'indexability:canonicalised' },
|
||||
{ label: 'Blocked by Robots', category: 'indexability:blocked-robots' },
|
||||
],
|
||||
|
||||
// ─── URL tab ────────────────────────────────────────────────────────────
|
||||
url: [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Too Long (>2048 chars)', category: 'issues:url-too-long' },
|
||||
{ label: 'Contains Uppercase', category: 'issues:url-uppercase' },
|
||||
{ label: 'Contains Underscore', category: 'issues:url-underscore' },
|
||||
{ label: 'Multiple Slashes', category: 'issues:url-multiple-slashes' },
|
||||
{ label: 'Non-ASCII Characters', category: 'issues:url-non-ascii' },
|
||||
{ label: 'Many Query Params (>5)', category: 'issues:url-many-params' },
|
||||
{ label: 'Fragment (#) in URL', category: 'issues:url-fragment' },
|
||||
{ label: 'Spaces in URL', category: 'issues:url-spaces' },
|
||||
{ label: 'Malformed URL', category: 'issues:url-malformed' },
|
||||
{ label: 'Long Query String (>100 chars)', category: 'issues:query-string-too-long' },
|
||||
{ label: 'Folder Depth >4', category: 'issues:folder-depth-too-deep' },
|
||||
{ label: 'Duplicate URL (post-norm)', category: 'issues:duplicate-url-post-norm' },
|
||||
],
|
||||
|
||||
// ─── Page Titles tab ────────────────────────────────────────────────────
|
||||
'page-titles': [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Missing', category: 'issues:title-missing' },
|
||||
{ label: 'Over 60 Characters', category: 'issues:title-too-long' },
|
||||
{ label: 'Below 30 Characters', category: 'issues:title-too-short' },
|
||||
{ label: 'Duplicate', category: 'issues:title-duplicate' },
|
||||
{ label: 'Multiple <title> Tags', category: 'issues:title-multiple' },
|
||||
{ label: 'Pixel Width Truncated (>600px)', category: 'issues:title-pixel-width-too-long' },
|
||||
{ label: 'Placeholder Title', category: 'issues:title-placeholder' },
|
||||
{ label: 'Single-Word Title', category: 'issues:title-single-word' },
|
||||
],
|
||||
|
||||
// ─── Meta Description tab ───────────────────────────────────────────────
|
||||
'meta-description': [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Missing', category: 'issues:meta-missing' },
|
||||
{ label: 'Over 160 Characters', category: 'issues:meta-too-long' },
|
||||
{ label: 'Below 120 Characters', category: 'issues:meta-too-short' },
|
||||
{ label: 'Duplicate', category: 'issues:meta-duplicate' },
|
||||
{ label: 'Pixel Width Truncated (>990px)', category: 'issues:meta-pixel-width-too-long' },
|
||||
{ label: 'Description = Title', category: 'issues:description-equals-title' },
|
||||
{ label: 'Description = H1', category: 'issues:description-equals-h1' },
|
||||
],
|
||||
|
||||
// ─── H1 tab ─────────────────────────────────────────────────────────────
|
||||
h1: [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Missing', category: 'issues:h1-missing' },
|
||||
{ label: 'Duplicate', category: 'issues:h1-duplicate' },
|
||||
{ label: 'Multiple', category: 'issues:h1-multiple' },
|
||||
{ label: 'Empty', category: 'issues:h1-empty' },
|
||||
{ label: 'Over 70 Characters', category: 'issues:h1-too-long' },
|
||||
{ label: 'H1 = Title', category: 'issues:h1-equals-title' },
|
||||
],
|
||||
|
||||
// ─── H2 tab ─────────────────────────────────────────────────────────────
|
||||
h2: [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Skipped Heading Level', category: 'issues:heading-skipped-level' },
|
||||
],
|
||||
|
||||
// ─── Content tab ────────────────────────────────────────────────────────
|
||||
content: [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Thin Content (<300 words)', category: 'issues:content-thin' },
|
||||
{ label: 'Empty Page (<30 words)', category: 'issues:page-empty' },
|
||||
{ label: 'Hard to Read (Flesch <30)', category: 'issues:flesch-very-difficult' },
|
||||
{ label: 'Gunning Fog >17', category: 'issues:gunning-fog-very-high' },
|
||||
{ label: 'Large (>1MB)', category: 'issues:page-large' },
|
||||
{ label: 'Critical Size (>3MB)', category: 'issues:page-too-large-critical' },
|
||||
{ label: 'Charset Missing', category: 'issues:charset-missing' },
|
||||
],
|
||||
|
||||
// ─── Canonicals tab ─────────────────────────────────────────────────────
|
||||
canonicals: [
|
||||
{ label: 'All', category: 'internal:html' },
|
||||
{ label: 'Canonical Missing', category: 'issues:canonical-missing' },
|
||||
{ label: 'Self-Referencing', category: 'issues:canonical-self-referencing' },
|
||||
{ label: 'Canonicalised (→ other)', category: 'issues:canonical-non-self' },
|
||||
{ label: 'HTTP vs HTML Mismatch', category: 'issues:canonical-mismatch' },
|
||||
{ label: 'Multiple Canonicals', category: 'issues:multiple-canonicals' },
|
||||
{ label: 'Canonical → Non-200', category: 'issues:canonical-to-non-200' },
|
||||
{ label: 'Canonical → Redirect', category: 'issues:canonical-to-redirect' },
|
||||
{ label: 'Canonical → Noindex', category: 'issues:canonical-to-noindex' },
|
||||
{ label: 'Canonical Not Absolute', category: 'issues:canonical-not-absolute' },
|
||||
{ label: 'Canonical Chain (Multi-hop)', category: 'issues:canonical-chain-multi-hop' },
|
||||
],
|
||||
|
||||
// ─── Redirects tab ──────────────────────────────────────────────────────
|
||||
redirects: [
|
||||
{ label: 'All', category: 'tab:redirects' },
|
||||
{ label: 'Redirect Loop', category: 'issues:redirect-loop' },
|
||||
{ label: 'Long Chain (>3 hops)', category: 'issues:redirect-chain-long' },
|
||||
{ label: 'Self-Redirect', category: 'issues:redirect-self' },
|
||||
],
|
||||
|
||||
// ─── Pagination tab ─────────────────────────────────────────────────────
|
||||
pagination: [
|
||||
{ label: 'All', category: 'tab:pagination' },
|
||||
{ label: 'Broken Next/Prev Target', category: 'issues:pagination-broken' },
|
||||
{ label: 'Sequence Break', category: 'issues:pagination-sequence-break' },
|
||||
{ label: 'Canonical Conflict', category: 'issues:pagination-canonical-conflict' },
|
||||
],
|
||||
|
||||
// ─── Hreflang tab ───────────────────────────────────────────────────────
|
||||
hreflang: [
|
||||
{ label: 'All', category: 'tab:hreflang' },
|
||||
{ label: 'X-Default Missing', category: 'issues:hreflang-x-default-missing' },
|
||||
{ label: 'Invalid Code', category: 'issues:hreflang-invalid-code' },
|
||||
{ label: 'Self-Ref Missing', category: 'issues:hreflang-self-ref-missing' },
|
||||
{ label: 'Reciprocity Missing', category: 'issues:hreflang-reciprocity-missing' },
|
||||
{ label: 'Target Issues', category: 'issues:hreflang-target-issues' },
|
||||
{ label: 'Inconsistent Lang', category: 'issues:hreflang-inconsistent-lang' },
|
||||
],
|
||||
|
||||
// ─── Structured Data tab ────────────────────────────────────────────────
|
||||
'structured-data': [
|
||||
{ label: 'All', category: 'tab:structured-data' },
|
||||
{ label: 'No Structured Data', category: 'issues:structured-data-missing' },
|
||||
{ label: 'Invalid JSON-LD', category: 'issues:structured-data-invalid' },
|
||||
{ label: 'Duplicate @id', category: 'issues:schema-duplicate-id' },
|
||||
{ label: 'Malformed @type', category: 'issues:schema-unknown-type' },
|
||||
{ label: 'Missing Required Property', category: 'issues:schema-missing-required' },
|
||||
],
|
||||
|
||||
// ─── Meta Refresh tab ───────────────────────────────────────────────────
|
||||
'meta-refresh': [
|
||||
{ label: 'All', category: 'tab:meta-refresh' },
|
||||
{ label: 'Meta Refresh Used', category: 'issues:meta-refresh-used' },
|
||||
],
|
||||
|
||||
// ─── Duplicates tab ─────────────────────────────────────────────────────
|
||||
duplicates: [
|
||||
{ label: 'All', category: 'tab:duplicates' },
|
||||
{ label: 'Near-Duplicate Content', category: 'issues:near-duplicate' },
|
||||
{ label: 'Duplicate Content (exact)', category: 'issues:duplicate-content-exact' },
|
||||
],
|
||||
|
||||
// ─── Links tab ──────────────────────────────────────────────────────────
|
||||
links: [
|
||||
{ label: 'All', category: 'all' },
|
||||
{ label: 'Broken (All)', category: 'issues:broken-links-all' },
|
||||
{ label: 'Broken Internal', category: 'issues:broken-links-internal' },
|
||||
{ label: 'Broken External', category: 'issues:broken-links-external' },
|
||||
{ label: 'Empty Anchor Text', category: 'issues:link-empty-anchor' },
|
||||
{ label: 'Anchor Text Too Long (>100)', category: 'issues:anchor-text-too-long' },
|
||||
{ label: 'Generic Anchor Text', category: 'issues:anchor-text-generic' },
|
||||
{ label: 'Target=_blank No Noopener', category: 'issues:target-blank-no-noopener' },
|
||||
{ label: 'External Links > 100', category: 'issues:external-links-too-many' },
|
||||
{ label: 'Total Links per Page > 100', category: 'issues:links-per-page-too-many' },
|
||||
{ label: 'No Outlinks (Dead-End)', category: 'issues:outlinks-zero' },
|
||||
{ label: 'Internal Link → Redirect', category: 'issues:internal-link-to-redirect' },
|
||||
{ label: 'Dead External Domain', category: 'issues:dead-external-domain' },
|
||||
{ label: 'JS-Only Navigation', category: 'issues:js-only-navigation' },
|
||||
],
|
||||
|
||||
// ─── Analytics tab ──────────────────────────────────────────────────────
|
||||
analytics: [
|
||||
{ label: 'All', category: 'all' },
|
||||
{ label: 'No Analytics Detected', category: 'issues:analytics-missing' },
|
||||
{ label: 'Multiple GA4 IDs', category: 'issues:analytics-multiple-ga4' },
|
||||
{ label: 'Universal Analytics (Sunset)', category: 'issues:analytics-ua-legacy' },
|
||||
{ label: 'Pixel Without Permissions-Policy', category: 'issues:analytics-pixel-without-policy' },
|
||||
],
|
||||
};
|
||||
|
||||
interface AppState {
|
||||
config: CrawlConfig;
|
||||
progress: CrawlProgress | null;
|
||||
@@ -212,14 +456,145 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
}));
|
||||
|
||||
function tabForCategory(cat: UrlCategory): TabKey | null {
|
||||
if (cat === 'issues:image-missing-alt') return 'images';
|
||||
// V2 — Comprehensive sidebar-click → top-level tab routing. Lets the
|
||||
// user click any OverviewSidebar entry and land on the tab whose
|
||||
// table is best suited to render the filtered rows. Issue
|
||||
// sub-categories whose home tab isn't obvious stay on the current
|
||||
// tab (returning null leaves activeTab untouched).
|
||||
|
||||
// Crawl Data → Internal tab
|
||||
if (cat.startsWith('internal:')) return 'internal';
|
||||
// External
|
||||
if (cat.startsWith('external:')) return 'external';
|
||||
// Response Codes group — including the bare 'all' entry which the
|
||||
// sidebar uses as the Response-Codes parent.
|
||||
if (cat === 'all' || cat.startsWith('status:')) return 'response-codes';
|
||||
// Security
|
||||
if (cat.startsWith('security:')) return 'security';
|
||||
// Indexability (Directives tab)
|
||||
if (cat.startsWith('indexability:')) return 'directives';
|
||||
|
||||
// Tab-keyed categories — the OverviewSidebar uses these for the
|
||||
// dedicated tab landing entries.
|
||||
if (cat === 'tab:pagination') return 'pagination';
|
||||
if (cat === 'tab:hreflang') return 'hreflang';
|
||||
if (cat === 'tab:amp') return 'amp';
|
||||
if (cat === 'tab:structured-data') return 'structured-data';
|
||||
if (cat === 'tab:meta-refresh') return 'meta-refresh';
|
||||
if (cat === 'tab:custom-extraction') return 'custom-extraction';
|
||||
if (cat === 'tab:custom-search') return 'custom-search';
|
||||
if (cat === 'tab:security') return 'security';
|
||||
if (cat === 'tab:duplicates') return 'duplicates';
|
||||
if (cat === 'tab:serp') return 'serp';
|
||||
if (cat === 'tab:canonicals') return 'canonicals';
|
||||
if (cat === 'tab:directives') return 'directives';
|
||||
if (cat === 'tab:redirects') return 'redirects';
|
||||
|
||||
// Issue groupings whose home tab is obvious.
|
||||
if (cat === 'issues:image-missing-alt' || cat === 'issues:image-empty-alt') {
|
||||
return 'images';
|
||||
}
|
||||
if (
|
||||
cat === 'issues:broken-links-all' ||
|
||||
cat === 'issues:broken-links-internal' ||
|
||||
cat === 'issues:broken-links-external'
|
||||
cat === 'issues:broken-links-external' ||
|
||||
cat === 'issues:link-empty-anchor'
|
||||
) {
|
||||
return 'broken-links';
|
||||
}
|
||||
if (cat.startsWith('issues:title-') || cat === 'issues:title-pixel-width-too-long') {
|
||||
return 'page-titles';
|
||||
}
|
||||
if (
|
||||
cat.startsWith('issues:meta-') ||
|
||||
cat === 'issues:description-equals-title' ||
|
||||
cat === 'issues:description-equals-h1'
|
||||
) {
|
||||
// Note: meta-refresh-used is in the Document sidebar group — routed
|
||||
// to meta-refresh tab below to match the dedicated panel.
|
||||
if (cat === 'issues:meta-refresh-used') return 'meta-refresh';
|
||||
return 'meta-description';
|
||||
}
|
||||
if (cat.startsWith('issues:h1-')) return 'h1';
|
||||
if (cat === 'issues:heading-skipped-level') return 'h2';
|
||||
if (cat.startsWith('issues:canonical-')) return 'canonicals';
|
||||
if (cat === 'issues:multiple-canonicals') return 'canonicals';
|
||||
if (
|
||||
cat === 'issues:content-thin' ||
|
||||
cat === 'issues:page-empty' ||
|
||||
cat === 'issues:flesch-very-difficult' ||
|
||||
cat === 'issues:gunning-fog-very-high' ||
|
||||
cat === 'issues:page-large' ||
|
||||
cat === 'issues:page-too-large-critical' ||
|
||||
cat === 'issues:charset-missing'
|
||||
) {
|
||||
return 'content';
|
||||
}
|
||||
if (
|
||||
cat.startsWith('issues:url-') ||
|
||||
cat === 'issues:query-string-too-long' ||
|
||||
cat === 'issues:folder-depth-too-deep' ||
|
||||
cat === 'issues:duplicate-url-post-norm'
|
||||
) {
|
||||
return 'url';
|
||||
}
|
||||
if (cat.startsWith('issues:hreflang-')) return 'hreflang';
|
||||
if (cat.startsWith('issues:structured-data-') || cat.startsWith('issues:schema-')) {
|
||||
return 'structured-data';
|
||||
}
|
||||
if (cat.startsWith('issues:pagination-')) return 'pagination';
|
||||
if (
|
||||
cat === 'issues:near-duplicate' ||
|
||||
cat === 'issues:duplicate-content-exact'
|
||||
) {
|
||||
return 'duplicates';
|
||||
}
|
||||
if (cat.startsWith('issues:redirect-')) return 'redirects';
|
||||
if (
|
||||
cat === 'issues:hsts-missing' ||
|
||||
cat === 'issues:hsts-no-preload' ||
|
||||
cat === 'issues:hsts-max-age-short' ||
|
||||
cat === 'issues:hsts-no-includesubdomains' ||
|
||||
cat === 'issues:x-frame-options-missing' ||
|
||||
cat === 'issues:x-content-type-options-missing' ||
|
||||
cat === 'issues:csp-missing' ||
|
||||
cat === 'issues:mixed-content' ||
|
||||
cat === 'issues:mixed-content-active' ||
|
||||
cat === 'issues:mixed-content-passive' ||
|
||||
cat === 'issues:insecure-form-action' ||
|
||||
cat === 'issues:missing-sri' ||
|
||||
cat === 'issues:cors-wildcard-with-credentials' ||
|
||||
cat === 'issues:cors-wildcard-origin' ||
|
||||
cat === 'issues:http-not-https' ||
|
||||
cat === 'issues:cookie-no-secure' ||
|
||||
cat === 'issues:cookie-no-httponly' ||
|
||||
cat === 'issues:cookie-no-samesite' ||
|
||||
cat.startsWith('issues:ssl-')
|
||||
) {
|
||||
return 'security';
|
||||
}
|
||||
if (
|
||||
cat === 'issues:response-slow' ||
|
||||
cat === 'issues:response-very-slow' ||
|
||||
cat === 'issues:ttfb-slow' ||
|
||||
cat === 'issues:ttfb-very-slow'
|
||||
) {
|
||||
return 'response-codes';
|
||||
}
|
||||
if (cat.startsWith('issues:analytics-')) return 'analytics';
|
||||
if (
|
||||
cat === 'issues:anchor-text-too-long' ||
|
||||
cat === 'issues:anchor-text-generic' ||
|
||||
cat === 'issues:target-blank-no-noopener' ||
|
||||
cat === 'issues:external-links-too-many' ||
|
||||
cat === 'issues:links-per-page-too-many' ||
|
||||
cat === 'issues:outlinks-zero' ||
|
||||
cat === 'issues:internal-link-to-redirect' ||
|
||||
cat === 'issues:dead-external-domain' ||
|
||||
cat === 'issues:js-only-navigation'
|
||||
) {
|
||||
return 'links';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronsUpDown, Columns3, Download, Filter, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
Columns3,
|
||||
Download,
|
||||
Filter,
|
||||
List,
|
||||
Network,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AdvancedFilter, CrawlUrlRow } from '@freecrawl/shared-types';
|
||||
import { useAppStore, type TabKey } from '../store.js';
|
||||
import type {
|
||||
AdvancedFilter,
|
||||
CrawlUrlRow,
|
||||
UrlCategory,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore, TAB_QUICK_FILTERS, type TabKey } from '../store.js';
|
||||
import { COLUMN_SPECS, columnId, type ColumnSpec } from './columns.js';
|
||||
import { useLazyUrlRows } from '../hooks/useLazyUrlRows.js';
|
||||
import { AdvancedFilterDialog } from '../components/AdvancedFilterDialog.js';
|
||||
import { UrlTreeView } from '../components/UrlTreeView.js';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary.js';
|
||||
import { ExportDialog } from '../components/ExportDialog.js';
|
||||
import { InfoTip } from '../components/InfoTip.js';
|
||||
@@ -51,6 +64,7 @@ export function UrlsTab() {
|
||||
const lang = i18n.language;
|
||||
const activeTab = useAppStore((s) => s.activeTab);
|
||||
const activeCategory = useAppStore((s) => s.activeCategory);
|
||||
const setActiveCategory = useAppStore((s) => s.setActiveCategory);
|
||||
const selectedUrlId = useAppStore((s) => s.selectedUrlId);
|
||||
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||
@@ -60,6 +74,17 @@ export function UrlsTab() {
|
||||
const [filter, setFilter] = useState<AdvancedFilter | null>(null);
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
/** View mode toggle — list (default flat virtual table) or tree
|
||||
* (URL-path hierarchy). Per-tab + persisted across mounts via prefs. */
|
||||
const [viewMode, setViewMode] = useState<'list' | 'tree'>(() => {
|
||||
const stored = window.freecrawl.prefsGet(`view-mode:${activeTab}`);
|
||||
return stored === 'tree' ? 'tree' : 'list';
|
||||
});
|
||||
const quickFilters = TAB_QUICK_FILTERS[activeTab];
|
||||
const activeQuickFilter = useMemo(() => {
|
||||
if (!quickFilters) return null;
|
||||
return quickFilters.find((q) => q.category === activeCategory) ?? null;
|
||||
}, [quickFilters, activeCategory]);
|
||||
// Three orthogonal selection layers:
|
||||
// - selectedIds: row-level selection driven by the Row-number column.
|
||||
// Feeds the bulk context menu (Copy/Open/Re-Spider/Remove/Export).
|
||||
@@ -133,8 +158,20 @@ export function UrlsTab() {
|
||||
setColumnWidths(loadStoredWidths(activeTab));
|
||||
setHiddenColumnsState(loadHiddenColumns(activeTab));
|
||||
setColumnPickerOpen(false);
|
||||
// Pick up the persisted view-mode for the new tab.
|
||||
const stored = window.freecrawl.prefsGet(`view-mode:${activeTab}`);
|
||||
setViewMode(stored === 'tree' ? 'tree' : 'list');
|
||||
}, [activeTab]);
|
||||
|
||||
// Persist viewMode whenever the user toggles it.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.freecrawl.prefsSet(`view-mode:${activeTab}`, viewMode);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}, [activeTab, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
setSelectedCells(new Set());
|
||||
@@ -532,6 +569,34 @@ export function UrlsTab() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{quickFilters && quickFilters.length > 0 && (
|
||||
<select
|
||||
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-200 focus:border-blue-500 focus:outline-none"
|
||||
// Bug #6 — when activeCategory isn't one of the predefined
|
||||
// quick-filter options, fall back to an explicit placeholder
|
||||
// value so the dropdown doesn't silently mislabel itself as
|
||||
// the first option.
|
||||
value={activeQuickFilter?.category ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v) setActiveCategory(v as UrlCategory);
|
||||
}}
|
||||
title={t('urlsTab.quickFilterTitle', { defaultValue: 'Quick filter for this tab' })}
|
||||
>
|
||||
{!activeQuickFilter && (
|
||||
<option value="" disabled>
|
||||
{t('urlsTab.quickFilterCustom', {
|
||||
defaultValue: '— Custom filter —',
|
||||
})}
|
||||
</option>
|
||||
)}
|
||||
{quickFilters.map((q) => (
|
||||
<option key={q.category} value={q.category}>
|
||||
{translateLabel(q.label, lang)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterDialogOpen(true)}
|
||||
@@ -561,7 +626,35 @@ export function UrlsTab() {
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative ml-auto">
|
||||
<div className="ml-auto inline-flex overflow-hidden rounded border border-surface-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center px-2 py-1 text-[11px] transition',
|
||||
viewMode === 'list'
|
||||
? 'bg-accent-500/15 text-accent-300'
|
||||
: 'text-surface-300 hover:bg-surface-800',
|
||||
)}
|
||||
title={t('urlsTab.listView', { defaultValue: 'List view' })}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('tree')}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center border-l border-surface-700 px-2 py-1 text-[11px] transition',
|
||||
viewMode === 'tree'
|
||||
? 'bg-accent-500/15 text-accent-300'
|
||||
: 'text-surface-300 hover:bg-surface-800',
|
||||
)}
|
||||
title={t('urlsTab.treeView', { defaultValue: 'Tree view (URL hierarchy)' })}
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-columns-anchor="1"
|
||||
@@ -617,6 +710,25 @@ export function UrlsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'tree' ? (
|
||||
<UrlTreeView
|
||||
category={activeCategory}
|
||||
search={search}
|
||||
filter={filter}
|
||||
refreshKey={dataVersion}
|
||||
selectedUrlId={selectedUrlId}
|
||||
onSelect={(id) => {
|
||||
setSelectedUrlId(id);
|
||||
if (id !== null) {
|
||||
setSelectedIds(new Set([id]));
|
||||
setSelectedUrlIds([id]);
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
setSelectedUrlIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div ref={scrollRef} className="relative flex-1 select-none overflow-auto">
|
||||
<div style={{ minWidth: totalWidth, width: '100%' }}>
|
||||
{/* Header row */}
|
||||
@@ -876,6 +988,7 @@ export function UrlsTab() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end gap-4 border-t border-surface-800 bg-surface-900/60 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
|
||||
@@ -610,7 +610,6 @@ export const COLUMN_SPECS: Record<TabKey, ColumnSpec[]> = {
|
||||
analytics: [],
|
||||
ai: [],
|
||||
seo: [],
|
||||
visualization: [],
|
||||
canonicals: [
|
||||
COL.url,
|
||||
COL.canonical,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
Generated
+54
-8
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -25,7 +26,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -42,7 +43,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -86,7 +87,7 @@
|
||||
},
|
||||
"apps/mcp-server": {
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*"
|
||||
@@ -6605,6 +6606,50 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
@@ -8826,13 +8871,14 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"playwright": "^1.49.0",
|
||||
"robots-parser": "^3.0.1",
|
||||
"undici": "^8.0.0"
|
||||
},
|
||||
@@ -8852,7 +8898,7 @@
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@freecrawl/shared-types": "*"
|
||||
},
|
||||
@@ -8864,7 +8910,7 @@
|
||||
},
|
||||
"packages/shared-types": {
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0"
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,9 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md,css,html}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,json,md,css,html}\"",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"cli": "npm run start --workspace apps/cli --"
|
||||
"cli": "npm run start --workspace apps/cli --",
|
||||
"postinstall": "node scripts/install-playwright-browsers.mjs",
|
||||
"playwright:install": "node scripts/install-playwright-browsers.mjs --force"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -22,6 +22,7 @@
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"playwright": "^1.49.0",
|
||||
"robots-parser": "^3.0.1",
|
||||
"undici": "^8.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type LaunchOptions,
|
||||
type Page,
|
||||
} from 'playwright';
|
||||
|
||||
/**
|
||||
* Distinct error code so callers (main-process diagnostic dialog,
|
||||
* crawler error stream) can recognise the "browser not installed"
|
||||
* case and surface a one-time install prompt instead of one warning
|
||||
* per URL.
|
||||
*/
|
||||
export class PlaywrightBrowserMissingError extends Error {
|
||||
public readonly code = 'PLAYWRIGHT_BROWSER_MISSING' as const;
|
||||
constructor(
|
||||
public readonly executablePath: string,
|
||||
public readonly channel?: string,
|
||||
) {
|
||||
super(
|
||||
`Playwright browser executable not found at ${executablePath || '<unresolved>'}. ` +
|
||||
`Run "npx playwright install chromium chromium-headless-shell" to download it, ` +
|
||||
`or via the in-app prompt when JS rendering is enabled.`,
|
||||
);
|
||||
this.name = 'PlaywrightBrowserMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface BrowserPoolOptions {
|
||||
/** Number of pages kept warm in parallel. Matches crawl maxConcurrency. */
|
||||
maxPages: number;
|
||||
/** Show the browser window (debug). Default false (headless). */
|
||||
headless: boolean;
|
||||
/** Viewport dimensions for every page in the pool. */
|
||||
viewport: { width: number; height: number };
|
||||
/** UA string applied at context level. */
|
||||
userAgent?: string;
|
||||
/** Accept-Language applied at context level. */
|
||||
acceptLanguage?: string;
|
||||
/** Playwright Chromium channel (e.g. 'chrome', 'msedge'). Undefined = bundled. */
|
||||
channel?: string;
|
||||
/** Resource types Playwright should abort before they hit the network. */
|
||||
blockResourceTypes?: ReadonlySet<string>;
|
||||
/** Custom Chromium executable path (optional override). */
|
||||
executablePath?: string;
|
||||
/**
|
||||
* Optional init script — registered once at the BrowserContext level so
|
||||
* every page in the pool runs it on each navigation. Registering on
|
||||
* the page (via `page.addInitScript`) would accumulate per-render —
|
||||
* after N renders of the same slot the script ran N times. Pinning it
|
||||
* to the context at pool start sidesteps the leak entirely; the
|
||||
* trade-off is that the script cannot change mid-crawl, which matches
|
||||
* how `CrawlConfig.jsRender.prerenderJs` is read (once at launch).
|
||||
*/
|
||||
prerenderJs?: string;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
page: Page;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve: (slot: Slot) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Chromium browser + page pool for JS rendering.
|
||||
*
|
||||
* One Chromium process, one BrowserContext, and N persistent Pages — pages are
|
||||
* cheap to recycle (clear cookies between URLs via context.clearCookies, then
|
||||
* navigate). This keeps p95 render latency low because page warm-up (~300ms)
|
||||
* is paid once at pool init, not per URL.
|
||||
*
|
||||
* Concurrency: callers `acquire()` a Page, render, then `release()`. The pool
|
||||
* blocks acquire when all pages are busy. Pages that throw during a render
|
||||
* are dropped + replaced so a single hung page can't take a slot out of
|
||||
* rotation permanently.
|
||||
*/
|
||||
export class BrowserPool {
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private slots: Slot[] = [];
|
||||
private waiters: Waiter[] = [];
|
||||
private opts: BrowserPoolOptions;
|
||||
private starting: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(opts: BrowserPoolOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.starting) return this.starting;
|
||||
if (this.browser) return;
|
||||
this.starting = this.doStart();
|
||||
try {
|
||||
await this.starting;
|
||||
} finally {
|
||||
this.starting = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doStart(): Promise<void> {
|
||||
const launchOpts: LaunchOptions = {
|
||||
headless: this.opts.headless,
|
||||
};
|
||||
if (this.opts.channel) launchOpts.channel = this.opts.channel;
|
||||
if (this.opts.executablePath) launchOpts.executablePath = this.opts.executablePath;
|
||||
|
||||
// Pre-flight check — Playwright's own error is a multi-line ASCII
|
||||
// banner emitted PER navigation, which the crawler then logs once
|
||||
// per URL. Catching the missing binary up-front lets the host
|
||||
// surface a single install prompt and abort the JS render mode.
|
||||
//
|
||||
// When no explicit executable path or system channel is set,
|
||||
// Playwright resolves the bundled browser. `chromium.executablePath()`
|
||||
// throws when none is registered yet (very old install) — we treat
|
||||
// that as "missing" too. For `headless: true` (default) Playwright
|
||||
// 1.49+ also requires the chrome-headless-shell binary, which lives
|
||||
// in a sibling directory; checking the regular chromium path is a
|
||||
// good-enough proxy because both binaries are installed together
|
||||
// by `npx playwright install`.
|
||||
if (!this.opts.channel && !this.opts.executablePath) {
|
||||
let resolved = '';
|
||||
try {
|
||||
resolved = chromium.executablePath();
|
||||
} catch {
|
||||
/* unresolved → fall through to the missing check below */
|
||||
}
|
||||
if (!resolved || !existsSync(resolved)) {
|
||||
throw new PlaywrightBrowserMissingError(resolved, this.opts.channel);
|
||||
}
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOpts);
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: this.opts.viewport,
|
||||
userAgent: this.opts.userAgent,
|
||||
locale: this.opts.acceptLanguage,
|
||||
extraHTTPHeaders: this.opts.acceptLanguage
|
||||
? { 'Accept-Language': this.opts.acceptLanguage }
|
||||
: undefined,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
// Context-level prerender script — runs once per navigation on every
|
||||
// page, without per-page accumulation. Must be set BEFORE pages are
|
||||
// created so they inherit it.
|
||||
if (this.opts.prerenderJs && this.opts.prerenderJs.length > 0) {
|
||||
await this.context.addInitScript({ content: this.opts.prerenderJs });
|
||||
}
|
||||
|
||||
if (this.opts.blockResourceTypes && this.opts.blockResourceTypes.size > 0) {
|
||||
const blocked = this.opts.blockResourceTypes;
|
||||
await this.context.route('**/*', (route) => {
|
||||
if (blocked.has(route.request().resourceType())) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.opts.maxPages; i++) {
|
||||
const page = await this.context.newPage();
|
||||
this.slots.push({ page, busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
async acquire(): Promise<{ page: Page; release: (replace?: boolean) => void }> {
|
||||
if (this.closed) throw new Error('BrowserPool closed');
|
||||
if (!this.browser || !this.context) await this.start();
|
||||
if (this.closed) throw new Error('BrowserPool closed');
|
||||
|
||||
const slot = await this.takeSlot();
|
||||
return {
|
||||
page: slot.page,
|
||||
// Callers pass `release(true)` after an aborted render so the slot's
|
||||
// page is closed + replaced before the next acquire. Default is a
|
||||
// simple busy=false flip that keeps the page warm.
|
||||
release: (replace?: boolean) => this.releaseSlot(slot, replace === true),
|
||||
};
|
||||
}
|
||||
|
||||
private takeSlot(): Promise<Slot> {
|
||||
return new Promise<Slot>((resolve, reject) => {
|
||||
const free = this.slots.find((s) => !s.busy);
|
||||
if (free) {
|
||||
free.busy = true;
|
||||
resolve(free);
|
||||
return;
|
||||
}
|
||||
this.waiters.push({
|
||||
resolve: (s) => {
|
||||
s.busy = true;
|
||||
resolve(s);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private releaseSlot(slot: Slot, replace: boolean): void {
|
||||
if (replace && this.context && !this.closed) {
|
||||
// Drop the (possibly torn) page and create a fresh one in its
|
||||
// place. The replacement is awaited inline; new acquires queue
|
||||
// behind it. Cheap (~150-300ms) compared with letting a leaked
|
||||
// page consume a slot permanently.
|
||||
const oldPage = slot.page;
|
||||
void oldPage
|
||||
.close({ runBeforeUnload: false })
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
if (this.closed || !this.context) return;
|
||||
try {
|
||||
const fresh = await this.context.newPage();
|
||||
slot.page = fresh;
|
||||
} catch {
|
||||
// Couldn't make a fresh page — mark the slot stale by
|
||||
// leaving it busy. The pool degrades by one slot until
|
||||
// close().
|
||||
slot.busy = true;
|
||||
return;
|
||||
}
|
||||
slot.busy = false;
|
||||
const next = this.waiters.shift();
|
||||
if (next) next.resolve(slot);
|
||||
});
|
||||
return;
|
||||
}
|
||||
slot.busy = false;
|
||||
const next = this.waiters.shift();
|
||||
if (next) next.resolve(slot);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
// Reject queued waiters first so callers parked in acquire() exit
|
||||
// with a clean error instead of receiving a null page (which would
|
||||
// null-deref the next instant on page.goto()).
|
||||
const waiters = this.waiters.splice(0);
|
||||
waiters.forEach((w) => {
|
||||
try {
|
||||
w.reject(new Error('BrowserPool closed'));
|
||||
} catch {
|
||||
/* swallow — waiter cleanup */
|
||||
}
|
||||
});
|
||||
try {
|
||||
await this.context?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await this.browser?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.context = null;
|
||||
this.browser = null;
|
||||
this.slots = [];
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,36 @@ export class Crawler extends EventEmitter {
|
||||
*/
|
||||
private readonly dbCall: <T>(method: string, args: unknown[]) => Promise<T>;
|
||||
|
||||
/**
|
||||
* V2 — Optional Playwright renderer. Returns the post-JS DOM dump for
|
||||
* a URL when `renderingMode === 'js'`. Defaults to undefined so the
|
||||
* crawler silently skips the render pass when no pool is wired.
|
||||
*/
|
||||
private readonly renderUrlHook?: (
|
||||
url: string,
|
||||
urlId: number | null,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
html: string;
|
||||
timingMs: number;
|
||||
error?: string;
|
||||
screenshots?: { fullpage?: string; fold?: string; mobile?: string };
|
||||
lcp?: {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
coverage: number;
|
||||
resourceUrl: string | null;
|
||||
} | null;
|
||||
mobileUsability?: {
|
||||
ok: boolean;
|
||||
overflowPx: number;
|
||||
hasViewportMeta: boolean;
|
||||
} | null;
|
||||
}>;
|
||||
|
||||
constructor(
|
||||
config: CrawlConfig,
|
||||
db: ProjectDb,
|
||||
@@ -295,6 +325,42 @@ export class Crawler extends EventEmitter {
|
||||
) => Promise<void>;
|
||||
runDbPass?: (methodName: string) => Promise<void>;
|
||||
dbCall?: <T>(method: string, args: unknown[]) => Promise<T>;
|
||||
/**
|
||||
* Optional Playwright-based renderer. When `config.renderingMode ===
|
||||
* 'js'`, the crawler invokes this hook after the HTTP fetch and
|
||||
* uses the post-JS DOM for link extraction. The desktop host wires
|
||||
* a managed BrowserPool here; CLI runs render-less unless the
|
||||
* caller injects one explicitly.
|
||||
*
|
||||
* The result may include screenshot paths (written by Playwright
|
||||
* to disk before this promise resolves) and an LCP candidate —
|
||||
* the crawler stores these via dbCall hooks. The host decides
|
||||
* which extras to compute via its own config.
|
||||
*/
|
||||
renderUrl?: (
|
||||
url: string,
|
||||
urlId: number | null,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
html: string;
|
||||
timingMs: number;
|
||||
error?: string;
|
||||
screenshots?: { fullpage?: string; fold?: string; mobile?: string };
|
||||
lcp?: {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
coverage: number;
|
||||
resourceUrl: string | null;
|
||||
} | null;
|
||||
mobileUsability?: {
|
||||
ok: boolean;
|
||||
overflowPx: number;
|
||||
hasViewportMeta: boolean;
|
||||
} | null;
|
||||
}>;
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
@@ -322,6 +388,7 @@ export class Crawler extends EventEmitter {
|
||||
}
|
||||
return Promise.resolve((fn as (...a: unknown[]) => T).apply(this.db, args));
|
||||
});
|
||||
this.renderUrlHook = opts.renderUrl;
|
||||
// Wave 9 — Resolve the active proxy. If a named profile is selected
|
||||
// and present in `proxyProfiles`, its URL wins over the legacy
|
||||
// `proxyUrl` field; if the named profile doesn't resolve we fall
|
||||
@@ -2062,12 +2129,76 @@ export class Crawler extends EventEmitter {
|
||||
|
||||
const body = await res.text();
|
||||
const bodyLength = parseIntSafe(contentLengthHeader) ?? Buffer.byteLength(body, 'utf8');
|
||||
|
||||
// V2 Faz 1 — JavaScript rendering pass. When `renderingMode === 'js'`
|
||||
// and the host injected a Playwright renderer, fetch the post-JS DOM
|
||||
// and use it for link extraction + extraction rules. The raw HTTP body
|
||||
// stays in `url_sources.body`; the rendered DOM goes to
|
||||
// `url_sources.rendered_body`. Render failure is non-fatal — we fall
|
||||
// back to the static HTML so the crawl never stalls on a single page.
|
||||
let parseBody = body;
|
||||
let renderedHtml: string | null = null;
|
||||
let renderMs = 0;
|
||||
let renderScreenshots:
|
||||
| { fullpage?: string; fold?: string; mobile?: string }
|
||||
| null = null;
|
||||
let renderLcp:
|
||||
| {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
coverage: number;
|
||||
resourceUrl: string | null;
|
||||
}
|
||||
| null = null;
|
||||
let renderMobile:
|
||||
| { ok: boolean; overflowPx: number; hasViewportMeta: boolean }
|
||||
| null = null;
|
||||
if (
|
||||
this.config.renderingMode === 'js' &&
|
||||
this.renderUrlHook &&
|
||||
statusCode < 400
|
||||
) {
|
||||
this.setOp(`crawl:render:${item.url}`);
|
||||
try {
|
||||
// We pass `null` for urlId here — the writeFetchedUrl call
|
||||
// hasn't happened yet so we don't have one. The host can
|
||||
// either generate a temp id, or use the URL string as the
|
||||
// sidecar filename key. Screenshot paths are returned by
|
||||
// the host so the crawler can persist them after upsert.
|
||||
const renderRes = await this.renderUrlHook(
|
||||
item.url,
|
||||
null,
|
||||
controller.signal,
|
||||
);
|
||||
renderMs = renderRes.timingMs;
|
||||
if (renderRes.ok && renderRes.html) {
|
||||
parseBody = renderRes.html;
|
||||
renderedHtml = renderRes.html;
|
||||
} else if (renderRes.error) {
|
||||
this.emit(
|
||||
'warn',
|
||||
`JS render failed for ${item.url}: ${renderRes.error} (falling back to static HTML)`,
|
||||
);
|
||||
}
|
||||
if (renderRes.screenshots) renderScreenshots = renderRes.screenshots;
|
||||
if (renderRes.lcp) renderLcp = renderRes.lcp;
|
||||
if (renderRes.mobileUsability) renderMobile = renderRes.mobileUsability;
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'warn',
|
||||
`JS render threw for ${item.url}: ${err instanceof Error ? err.message : String(err)} (falling back to static HTML)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hand parsing to the worker pool when injected by the desktop
|
||||
// host; the CLI's default is the inline `parseHtml`. The
|
||||
// crawler doesn't care which one runs as long as the result
|
||||
// shape matches.
|
||||
this.setOp(`crawl:parse:${item.url}`);
|
||||
const parsed = await this.parsePage(body, item.url, {
|
||||
const parsed = await this.parsePage(parseBody, item.url, {
|
||||
includeSubdomains: this.config.scope === 'all-subdomains',
|
||||
cdnHosts: this.config.cdnHosts,
|
||||
customSearchTerms: this.config.customSearchTerms,
|
||||
@@ -2270,6 +2401,52 @@ export class Crawler extends EventEmitter {
|
||||
images: parsed.images,
|
||||
fromDepth: item.depth,
|
||||
});
|
||||
// V2 Faz 1 — Persist render artefacts inline so they finish before
|
||||
// the URL is considered done. Bug #8: previously these were
|
||||
// fire-and-forget, which let a Clear racing the crawler write
|
||||
// orphan rows into the freshly-reset DB. Awaiting here adds at
|
||||
// most 1-3 ms per URL but eliminates the race window.
|
||||
if (urlId) {
|
||||
const persistOps: Promise<unknown>[] = [];
|
||||
if (renderedHtml) {
|
||||
const cap =
|
||||
this.config.bodySnapshotMaxBytes > 0
|
||||
? this.config.bodySnapshotMaxBytes
|
||||
: 1_048_576;
|
||||
persistOps.push(
|
||||
this.dbCall<void>('setUrlRenderedBody', [
|
||||
urlId,
|
||||
renderedHtml,
|
||||
renderMs,
|
||||
cap,
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (renderScreenshots) {
|
||||
persistOps.push(
|
||||
this.dbCall<void>('setUrlScreenshotPaths', [urlId, renderScreenshots]),
|
||||
);
|
||||
}
|
||||
if (renderLcp) {
|
||||
persistOps.push(this.dbCall<void>('setUrlLcpCandidate', [urlId, renderLcp]));
|
||||
}
|
||||
if (renderMobile) {
|
||||
persistOps.push(
|
||||
this.dbCall<void>('setUrlMobileUsability', [urlId, renderMobile]),
|
||||
);
|
||||
}
|
||||
if (persistOps.length > 0) {
|
||||
const results = await Promise.allSettled(persistOps);
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
this.emit(
|
||||
'debug',
|
||||
`Render persistence failed for ${item.url}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const link of storableLinks) {
|
||||
if (!link.isInternal) this.enqueueExternal(link.toUrl);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
export { Crawler } from './crawler.js';
|
||||
export type { CrawlerEvents } from './crawler.js';
|
||||
export {
|
||||
BrowserPool,
|
||||
PlaywrightBrowserMissingError,
|
||||
type BrowserPoolOptions,
|
||||
} from './browser-pool.js';
|
||||
export {
|
||||
renderUrl,
|
||||
auditMobileUsability,
|
||||
type RenderOptions,
|
||||
type RenderResult,
|
||||
type LcpCandidate,
|
||||
type MobileUsabilityResult,
|
||||
} from './render-fetcher.js';
|
||||
export {
|
||||
normalizeUrl,
|
||||
isSameHost,
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { BrowserPool } from './browser-pool.js';
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Hard cap on navigation + post-load wait. */
|
||||
timeoutMs: number;
|
||||
/** Extra wait after `load` / `networkidle` for SPA hydration. */
|
||||
ajaxTimeoutMs: number;
|
||||
/** Optional selector — pool waits for it to appear before extracting HTML. */
|
||||
waitSelector?: string;
|
||||
/** Wait condition for page.goto. */
|
||||
waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
|
||||
/** Capture screenshots after render completes. */
|
||||
screenshotMode?: 'none' | 'fullpage' | 'fold' | 'both';
|
||||
/** Detect the LCP candidate element after render. */
|
||||
detectLcp?: boolean;
|
||||
/** Where to write screenshot PNGs — the caller resolves a per-URL path. */
|
||||
screenshotPaths?: {
|
||||
fullpage?: string;
|
||||
fold?: string;
|
||||
};
|
||||
/**
|
||||
* Override the viewport for this render only. Restores the previous
|
||||
* viewport in `finally`. Used by the mobile-screenshot pass to
|
||||
* capture a 375×667 variant against the same warm pool.
|
||||
*/
|
||||
viewport?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface LcpCandidate {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Approximate viewport coverage 0..1 — width*height / viewport area. */
|
||||
coverage: number;
|
||||
/** Loaded resource URL (img.src, background-image, video poster). */
|
||||
resourceUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
/** Final URL after redirects. */
|
||||
url: string;
|
||||
/** Post-JS rendered DOM (document.documentElement.outerHTML). */
|
||||
html: string;
|
||||
/** Total render time (navigation + wait + extract) in ms. */
|
||||
timingMs: number;
|
||||
/** True when navigation reached load/networkidle without error. */
|
||||
ok: boolean;
|
||||
/** Populated when render failed. */
|
||||
error?: string;
|
||||
/** Screenshot files actually written (when `screenshotMode` was set). */
|
||||
screenshots?: { fullpage?: string; fold?: string };
|
||||
/** LCP candidate from `detectLcp` — null when no eligible element found. */
|
||||
lcp?: LcpCandidate | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-side JS that walks the document for the largest above-the-fold
|
||||
* element. Stringified + injected via `page.evaluate()`. Eligible
|
||||
* elements: `<img>`, `<video>`, elements with computed `background-image`,
|
||||
* block text containers (when sufficiently large). Returns the element
|
||||
* with the largest area inside the initial viewport.
|
||||
*
|
||||
* Mirrors Chrome's LCP heuristic loosely (we don't have access to the
|
||||
* real PerformanceObserver from Playwright reliably across all sites,
|
||||
* so we re-compute statically — it lines up with the rendered LCP on
|
||||
* static layouts the vast majority of the time).
|
||||
*/
|
||||
const LCP_DETECT_FN = `(() => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (!vw || !vh) return null;
|
||||
const viewportArea = vw * vh;
|
||||
const candidates = document.querySelectorAll(
|
||||
'img, video, picture, svg, [style*="background-image"], h1, h2, p, section, article, header, main, div',
|
||||
);
|
||||
let best = null;
|
||||
let bestArea = 0;
|
||||
for (const el of candidates) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 50 || rect.height < 50) continue;
|
||||
if (rect.bottom < 0 || rect.top >= vh) continue;
|
||||
const visibleW = Math.max(0, Math.min(rect.right, vw) - Math.max(rect.left, 0));
|
||||
const visibleH = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0));
|
||||
const area = visibleW * visibleH;
|
||||
if (area <= bestArea) continue;
|
||||
// Skip elements that are mostly empty containers — only count text
|
||||
// containers that actually have visible text content.
|
||||
if (/^(SECTION|ARTICLE|HEADER|MAIN|DIV)$/.test(el.tagName)) {
|
||||
const text = el.textContent ? el.textContent.trim() : '';
|
||||
if (text.length < 30) continue;
|
||||
}
|
||||
best = el;
|
||||
bestArea = area;
|
||||
}
|
||||
if (!best) return null;
|
||||
// Stable-ish selector — prefer id, then a class chain, then tagName.
|
||||
const buildSelector = (node) => {
|
||||
if (node.id) return '#' + CSS.escape(node.id);
|
||||
if (node.classList && node.classList.length > 0) {
|
||||
const cls = [...node.classList].slice(0, 3).map((c) => '.' + CSS.escape(c)).join('');
|
||||
return node.tagName.toLowerCase() + cls;
|
||||
}
|
||||
return node.tagName.toLowerCase();
|
||||
};
|
||||
const rect = best.getBoundingClientRect();
|
||||
let resourceUrl = null;
|
||||
if (best.tagName === 'IMG') resourceUrl = best.currentSrc || best.src || null;
|
||||
else if (best.tagName === 'VIDEO') resourceUrl = best.poster || best.currentSrc || null;
|
||||
else {
|
||||
const bg = window.getComputedStyle(best).backgroundImage;
|
||||
if (bg && bg !== 'none') {
|
||||
const m = bg.match(/url\\(\\s*[\\"']?([^\\"')]+)/);
|
||||
if (m) resourceUrl = m[1];
|
||||
}
|
||||
}
|
||||
return {
|
||||
selector: buildSelector(best),
|
||||
tagName: best.tagName,
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
coverage: Math.round((bestArea / viewportArea) * 10000) / 10000,
|
||||
resourceUrl: resourceUrl,
|
||||
};
|
||||
})()`;
|
||||
|
||||
/**
|
||||
* Render a URL via Playwright and return a Response-like result. Cookies are
|
||||
* cleared from the BrowserContext between calls upstream — the pool keeps
|
||||
* pages warm but does not isolate state per-render. Callers that need state
|
||||
* isolation must drop and recreate the context.
|
||||
*/
|
||||
export async function renderUrl(
|
||||
url: string,
|
||||
pool: BrowserPool,
|
||||
opts: RenderOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RenderResult> {
|
||||
const t0 = Date.now();
|
||||
const { page, release } = await pool.acquire();
|
||||
|
||||
let result: RenderResult = {
|
||||
status: 0,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
url,
|
||||
html: '',
|
||||
timingMs: 0,
|
||||
ok: false,
|
||||
};
|
||||
|
||||
// On abort: close the page so the in-flight page.goto / waitForTimeout
|
||||
// bails out immediately (closing the page rejects all pending evaluate
|
||||
// calls with `Target closed`). The pool replaces the closed page when
|
||||
// we call `release(true)` in finally. Without this, Stop blocks for
|
||||
// the full `timeoutMs` waiting on a slow server.
|
||||
let aborted = false;
|
||||
const abortHandler = (): void => {
|
||||
aborted = true;
|
||||
page.close({ runBeforeUnload: false }).catch(() => {});
|
||||
};
|
||||
signal?.addEventListener('abort', abortHandler);
|
||||
|
||||
// Save the viewport so we can restore it on a per-render override.
|
||||
// Done before any navigation so even an early throw can roll back.
|
||||
const previousViewport = opts.viewport ? page.viewportSize() : null;
|
||||
|
||||
try {
|
||||
if (opts.viewport) {
|
||||
await page.setViewportSize(opts.viewport);
|
||||
}
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: opts.waitUntil,
|
||||
timeout: opts.timeoutMs,
|
||||
});
|
||||
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error('aborted');
|
||||
}
|
||||
|
||||
if (opts.waitSelector) {
|
||||
try {
|
||||
await page.waitForSelector(opts.waitSelector, {
|
||||
timeout: Math.max(1, opts.ajaxTimeoutMs),
|
||||
state: 'attached',
|
||||
});
|
||||
} catch {
|
||||
// Selector miss is non-fatal — fall through to whatever DOM exists.
|
||||
}
|
||||
} else if (opts.ajaxTimeoutMs > 0) {
|
||||
await page.waitForTimeout(opts.ajaxTimeoutMs);
|
||||
}
|
||||
|
||||
if (aborted || signal?.aborted) {
|
||||
throw new Error('aborted');
|
||||
}
|
||||
|
||||
const html = await page.content();
|
||||
const headers = response ? await response.allHeaders() : {};
|
||||
const finalUrl = response?.url() ?? page.url();
|
||||
|
||||
// LCP candidate detection — runs before screenshot so the in-page
|
||||
// layout pass it triggers is included in the captured viewport.
|
||||
let lcp: LcpCandidate | null = null;
|
||||
if (opts.detectLcp) {
|
||||
try {
|
||||
lcp = (await page.evaluate(LCP_DETECT_FN)) as LcpCandidate | null;
|
||||
} catch {
|
||||
lcp = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot capture — best-effort. Failures (e.g. detached page)
|
||||
// do not fail the render result.
|
||||
const screenshots: { fullpage?: string; fold?: string } = {};
|
||||
if (opts.screenshotMode && opts.screenshotMode !== 'none' && opts.screenshotPaths) {
|
||||
const mode = opts.screenshotMode;
|
||||
if ((mode === 'fullpage' || mode === 'both') && opts.screenshotPaths.fullpage) {
|
||||
try {
|
||||
await page.screenshot({
|
||||
path: opts.screenshotPaths.fullpage,
|
||||
fullPage: true,
|
||||
type: 'png',
|
||||
});
|
||||
screenshots.fullpage = opts.screenshotPaths.fullpage;
|
||||
} catch {
|
||||
/* ignore — fall through */
|
||||
}
|
||||
}
|
||||
if ((mode === 'fold' || mode === 'both') && opts.screenshotPaths.fold) {
|
||||
try {
|
||||
await page.screenshot({
|
||||
path: opts.screenshotPaths.fold,
|
||||
fullPage: false,
|
||||
type: 'png',
|
||||
});
|
||||
screenshots.fold = opts.screenshotPaths.fold;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
status: response?.status() ?? 0,
|
||||
statusText: response?.statusText() ?? '',
|
||||
headers,
|
||||
url: finalUrl,
|
||||
html,
|
||||
timingMs: Date.now() - t0,
|
||||
ok: true,
|
||||
screenshots: Object.keys(screenshots).length > 0 ? screenshots : undefined,
|
||||
lcp: opts.detectLcp ? lcp : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
result = {
|
||||
...result,
|
||||
timingMs: Date.now() - t0,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
if (!aborted) {
|
||||
// Best-effort cookie wipe between URLs so cross-URL state
|
||||
// doesn't leak. Aborted renders skip this — the page is being
|
||||
// closed + replaced anyway.
|
||||
try {
|
||||
await page.context().clearCookies();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Restore the viewport when a per-render override was applied.
|
||||
if (opts.viewport && previousViewport) {
|
||||
try {
|
||||
await page.setViewportSize(previousViewport);
|
||||
} catch {
|
||||
/* ignore — pool may be tearing down */
|
||||
}
|
||||
}
|
||||
}
|
||||
// On abort the page was closed; the pool replaces it before the
|
||||
// next acquire. On normal completion the page stays warm.
|
||||
release(aborted);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface MobileUsabilityResult {
|
||||
/** Overall pass: viewport meta present + content fits + readable text. */
|
||||
ok: boolean;
|
||||
/** True when the <meta name="viewport"> tag is present and parseable. */
|
||||
hasViewportMeta: boolean;
|
||||
/** True when the layout horizontally fits the mobile viewport. */
|
||||
fitsViewport: boolean;
|
||||
/** True when the smallest body-text font is ≥12px on the mobile viewport. */
|
||||
legibleText: boolean;
|
||||
/** True when tap targets (≥48 a/button) have adequate spacing. */
|
||||
tapTargetsOk: boolean;
|
||||
/** Total horizontal overflow in CSS pixels (0 = no overflow). */
|
||||
overflowPx: number;
|
||||
}
|
||||
|
||||
const MOBILE_AUDIT_FN = `(() => {
|
||||
const out = {
|
||||
hasViewportMeta: false,
|
||||
fitsViewport: false,
|
||||
legibleText: false,
|
||||
tapTargetsOk: false,
|
||||
overflowPx: 0,
|
||||
};
|
||||
const meta = document.querySelector('meta[name="viewport"]');
|
||||
if (meta) {
|
||||
const content = (meta.getAttribute('content') || '').toLowerCase();
|
||||
out.hasViewportMeta = content.includes('width=') || content.includes('initial-scale');
|
||||
}
|
||||
const viewport = window.innerWidth;
|
||||
const docWidth = Math.max(
|
||||
document.documentElement.scrollWidth,
|
||||
document.body ? document.body.scrollWidth : 0,
|
||||
);
|
||||
out.overflowPx = Math.max(0, docWidth - viewport);
|
||||
out.fitsViewport = out.overflowPx <= 4;
|
||||
// Text legibility — sample up to 30 visible <p>/<span>/<li> elements
|
||||
// and check that none use a computed font-size below 12px.
|
||||
const sample = Array.from(document.querySelectorAll('p, li, span, td, h3, h4, h5, h6'))
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
})
|
||||
.slice(0, 30);
|
||||
let tooSmall = 0;
|
||||
for (const el of sample) {
|
||||
const fs = parseFloat(window.getComputedStyle(el).fontSize || '16');
|
||||
if (fs && fs < 12) tooSmall++;
|
||||
}
|
||||
out.legibleText = sample.length === 0 || tooSmall / sample.length <= 0.1;
|
||||
// Tap targets — anchors/buttons with bounding box width AND height
|
||||
// below 32 px are considered fail. Empty / hidden ones are skipped.
|
||||
const tappable = Array.from(document.querySelectorAll('a, button, [role="button"]'))
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && el.offsetParent !== null;
|
||||
});
|
||||
let tooSmallTap = 0;
|
||||
for (const el of tappable) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 32 && r.height < 32) tooSmallTap++;
|
||||
}
|
||||
out.tapTargetsOk = tappable.length === 0 || tooSmallTap / tappable.length <= 0.2;
|
||||
return out;
|
||||
})()`;
|
||||
|
||||
/**
|
||||
* Re-render the URL on a mobile viewport and run the static
|
||||
* mobile-usability checks (viewport meta, layout overflow, font size,
|
||||
* tap target spacing). Independent from the main render — callers
|
||||
* decide when to invoke (typically only after the desktop pass
|
||||
* succeeds, to amortise the second navigation cost).
|
||||
*/
|
||||
export async function auditMobileUsability(
|
||||
url: string,
|
||||
pool: BrowserPool,
|
||||
opts: { timeoutMs: number; waitUntil: RenderOptions['waitUntil'] },
|
||||
signal?: AbortSignal,
|
||||
): Promise<MobileUsabilityResult | null> {
|
||||
const { page, release } = await pool.acquire();
|
||||
let aborted = false;
|
||||
const abortHandler = (): void => {
|
||||
aborted = true;
|
||||
page.close({ runBeforeUnload: false }).catch(() => {});
|
||||
};
|
||||
signal?.addEventListener('abort', abortHandler);
|
||||
try {
|
||||
// Switch the page to a mobile viewport for this audit only — the
|
||||
// previous size is restored in finally so the next render sees the
|
||||
// configured desktop viewport.
|
||||
const prev = page.viewportSize();
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
try {
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: opts.waitUntil,
|
||||
timeout: opts.timeoutMs,
|
||||
});
|
||||
if (!response || aborted || signal?.aborted) return null;
|
||||
const raw = (await page.evaluate(MOBILE_AUDIT_FN)) as Omit<
|
||||
MobileUsabilityResult,
|
||||
'ok'
|
||||
>;
|
||||
const ok =
|
||||
raw.hasViewportMeta &&
|
||||
raw.fitsViewport &&
|
||||
raw.legibleText &&
|
||||
raw.tapTargetsOk;
|
||||
return { ok, ...raw };
|
||||
} finally {
|
||||
if (prev && !aborted) {
|
||||
try {
|
||||
await page.setViewportSize(prev);
|
||||
} catch {
|
||||
/* ignore — pool may be tearing down */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
if (!aborted) {
|
||||
try {
|
||||
await page.context().clearCookies();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
release(aborted);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createWriteStream, writeFileSync } from 'node:fs';
|
||||
import { createWriteStream, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import path from 'node:path';
|
||||
@@ -9,15 +9,25 @@ import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
|
||||
export interface TabularSection {
|
||||
label: string;
|
||||
category: UrlCategory;
|
||||
/** Optional sub-directory under the export root (for hierarchical
|
||||
* tree exports — e.g. `Internal` → HTML / JS / CSS land under
|
||||
* `internal/`). Ignored for xlsx output. */
|
||||
subdir?: string;
|
||||
/** Optional filename stem (without extension). Defaults to a
|
||||
* sanitized version of `label`. */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface TabularExportOptions {
|
||||
format: 'csv' | 'xlsx';
|
||||
format: 'csv' | 'xlsx' | 'json' | 'xml';
|
||||
sections: TabularSection[];
|
||||
/** Keys of CrawlUrlRow to include, in order. */
|
||||
columns: string[];
|
||||
/** When provided AND non-empty, restrict every section to these row ids. */
|
||||
selectedIds?: number[];
|
||||
/** Prefix CSV files with a UTF-8 BOM so Excel-for-Windows opens them
|
||||
* in the correct charset. Ignored for non-CSV formats. Default true. */
|
||||
csvBom?: boolean;
|
||||
}
|
||||
|
||||
export interface TabularExportResult {
|
||||
@@ -27,9 +37,29 @@ export interface TabularExportResult {
|
||||
rowsWritten: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug #9 — Formula injection guard. Excel + LibreOffice + Google Sheets
|
||||
* interpret cells beginning with `=`, `+`, `-`, `@`, or a TAB/CR as
|
||||
* formulas (CVE-class). When the source data is crawled HTML (titles,
|
||||
* extraction values, anchor text), an attacker can craft a page whose
|
||||
* title is e.g. `=cmd|'/c calc'!A1` and have it execute when the user
|
||||
* opens the export. We neutralise by prepending a single quote — the
|
||||
* canonical CSV-injection mitigation per OWASP. The apostrophe is
|
||||
* visible in Excel only when the user is editing the cell.
|
||||
*/
|
||||
function sanitizeFormulaPrefix(str: string): string {
|
||||
if (!str) return str;
|
||||
const first = str.charCodeAt(0);
|
||||
// 0x09 TAB, 0x0D CR — both can start a formula in some parsers.
|
||||
if (first === 0x3d || first === 0x2b || first === 0x2d || first === 0x40 || first === 0x09 || first === 0x0d) {
|
||||
return `'${str}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function escapeCsv(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
const str = sanitizeFormulaPrefix(String(value));
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
@@ -61,12 +91,14 @@ async function writeCsvFile(
|
||||
columns: string[],
|
||||
category: UrlCategory,
|
||||
selectedIds: number[] | undefined,
|
||||
withBom: boolean,
|
||||
): Promise<number> {
|
||||
let rowsWritten = 0;
|
||||
const header = columns.join(',') + '\n';
|
||||
const source = rowSource(db, category, selectedIds);
|
||||
const generator = async function* (): AsyncGenerator<string> {
|
||||
yield '' + header;
|
||||
if (withBom) yield '' + header;
|
||||
else yield header;
|
||||
for (const row of source) {
|
||||
const line =
|
||||
columns
|
||||
@@ -83,6 +115,102 @@ async function writeCsvFile(
|
||||
return rowsWritten;
|
||||
}
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function xmlSafeTag(name: string): string {
|
||||
// Replace anything non-NCName-friendly with `_`. Numeric-leading keys
|
||||
// are prefixed with `n` so the doc stays well-formed.
|
||||
let safe = name.replace(/[^A-Za-z0-9_.-]/g, '_');
|
||||
if (/^[0-9]/.test(safe)) safe = `n${safe}`;
|
||||
return safe || '_';
|
||||
}
|
||||
|
||||
async function writeJsonFile(
|
||||
db: ProjectDb,
|
||||
filePath: string,
|
||||
columns: string[],
|
||||
category: UrlCategory,
|
||||
selectedIds: number[] | undefined,
|
||||
): Promise<number> {
|
||||
let rowsWritten = 0;
|
||||
const source = rowSource(db, category, selectedIds);
|
||||
const generator = async function* (): AsyncGenerator<string> {
|
||||
yield '[';
|
||||
let first = true;
|
||||
for (const row of source) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const c of columns) {
|
||||
obj[c] = (row as unknown as Record<string, unknown>)[c] ?? null;
|
||||
}
|
||||
yield (first ? '\n ' : ',\n ') + JSON.stringify(obj);
|
||||
first = false;
|
||||
rowsWritten++;
|
||||
}
|
||||
yield first ? ']\n' : '\n]\n';
|
||||
};
|
||||
await pipeline(
|
||||
Readable.from(generator()),
|
||||
createWriteStream(filePath, { encoding: 'utf8' }),
|
||||
);
|
||||
return rowsWritten;
|
||||
}
|
||||
|
||||
async function writeXmlFile(
|
||||
db: ProjectDb,
|
||||
filePath: string,
|
||||
columns: string[],
|
||||
category: UrlCategory,
|
||||
selectedIds: number[] | undefined,
|
||||
sectionLabel: string,
|
||||
): Promise<number> {
|
||||
let rowsWritten = 0;
|
||||
const source = rowSource(db, category, selectedIds);
|
||||
const rootTag = xmlSafeTag(sectionLabel) || 'export';
|
||||
const cols = columns.map((c) => ({ key: c, tag: xmlSafeTag(c) }));
|
||||
const generator = async function* (): AsyncGenerator<string> {
|
||||
yield `<?xml version="1.0" encoding="UTF-8"?>\n<${rootTag}>\n`;
|
||||
for (const row of source) {
|
||||
yield ' <row>\n';
|
||||
for (const { key, tag } of cols) {
|
||||
const v = (row as unknown as Record<string, unknown>)[key];
|
||||
if (v === null || v === undefined) continue;
|
||||
yield ` <${tag}>${escapeXml(v)}</${tag}>\n`;
|
||||
}
|
||||
yield ' </row>\n';
|
||||
rowsWritten++;
|
||||
}
|
||||
yield `</${rootTag}>\n`;
|
||||
};
|
||||
await pipeline(
|
||||
Readable.from(generator()),
|
||||
createWriteStream(filePath, { encoding: 'utf8' }),
|
||||
);
|
||||
return rowsWritten;
|
||||
}
|
||||
|
||||
function sanitizeFilename(label: string): string {
|
||||
return label.replace(/[\\/?*\[\]:<>|"]/g, '_').replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
function sanitizeSubdir(subdir: string): string {
|
||||
// Sanitize each path segment individually so a hierarchical export like
|
||||
// 'crawl-data/internal' stays nested rather than collapsing into one
|
||||
// filename component.
|
||||
return subdir
|
||||
.split(/[\\/]+/)
|
||||
.map((seg) => seg.replace(/[\\/?*\[\]:<>|"]/g, '_').replace(/\s+/g, '-').toLowerCase())
|
||||
.filter((seg) => seg.length > 0)
|
||||
.join(path.sep);
|
||||
}
|
||||
|
||||
// ── Minimal XLSX writer ─────────────────────────────────────────────
|
||||
//
|
||||
// Office Open XML SpreadsheetML, packaged as a ZIP with DEFLATE entries.
|
||||
@@ -164,8 +292,12 @@ function buildSheetXml(
|
||||
} else if (typeof v === 'boolean') {
|
||||
parts.push(`<c r="${cellRef}" t="b"><v>${v ? 1 : 0}</v></c>`);
|
||||
} else {
|
||||
// Bug #9 — neutralise leading formula triggers; Excel auto-runs
|
||||
// `=cmd|...` if the cell isn't sanitised. Apostrophe prefix is
|
||||
// the canonical guard (OWASP CSV-injection guidance).
|
||||
const safe = sanitizeFormulaPrefix(String(v));
|
||||
parts.push(
|
||||
`<c r="${cellRef}" t="inlineStr"><is><t>${xmlEscape(String(v))}</t></is></c>`,
|
||||
`<c r="${cellRef}" t="inlineStr"><is><t>${xmlEscape(safe)}</t></is></c>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -343,12 +475,27 @@ function buildXlsxBuffer(
|
||||
return buildZip(files);
|
||||
}
|
||||
|
||||
function resolveSectionPath(
|
||||
outputRoot: string,
|
||||
section: TabularSection,
|
||||
ext: string,
|
||||
): string {
|
||||
const stem = section.filename
|
||||
? sanitizeFilename(section.filename)
|
||||
: sanitizeFilename(section.label) || 'section';
|
||||
const subdir = section.subdir ? sanitizeSubdir(section.subdir) : '';
|
||||
const dir = subdir ? path.join(outputRoot, subdir) : outputRoot;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, `${stem}.${ext}`);
|
||||
}
|
||||
|
||||
export async function exportTabular(
|
||||
db: ProjectDb,
|
||||
outputPath: string,
|
||||
options: TabularExportOptions,
|
||||
): Promise<TabularExportResult> {
|
||||
const { format, sections, columns, selectedIds } = options;
|
||||
const csvBom = options.csvBom !== false;
|
||||
if (sections.length === 0) {
|
||||
throw new Error('exportTabular: at least one section is required');
|
||||
}
|
||||
@@ -356,43 +503,68 @@ export async function exportTabular(
|
||||
throw new Error('exportTabular: at least one column is required');
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
if (sections.length === 1) {
|
||||
const rows = await writeCsvFile(
|
||||
db,
|
||||
outputPath,
|
||||
columns,
|
||||
sections[0]!.category,
|
||||
selectedIds,
|
||||
);
|
||||
return { filePath: outputPath, files: [outputPath], rowsWritten: rows };
|
||||
}
|
||||
// Multi-section CSV → write one file per section into the chosen
|
||||
// folder. `outputPath` is treated as a folder.
|
||||
if (format === 'xlsx') {
|
||||
// xlsx — single workbook, one sheet per section. subdir/filename
|
||||
// are ignored; the workbook lives at `outputPath`.
|
||||
const usedNames = new Set<string>();
|
||||
let total = 0;
|
||||
const written: string[] = [];
|
||||
for (const section of sections) {
|
||||
const safe = section.label.replace(/[\\/?*\[\]:<>|"]/g, '_');
|
||||
const file = path.join(outputPath, `${safe}.csv`);
|
||||
total += await writeCsvFile(db, file, columns, section.category, selectedIds);
|
||||
written.push(file);
|
||||
}
|
||||
return { filePath: outputPath, files: written, rowsWritten: total };
|
||||
const sheets = sections.map((section) => {
|
||||
const rows: CrawlUrlRow[] = [];
|
||||
for (const row of rowSource(db, section.category, selectedIds)) {
|
||||
rows.push(row);
|
||||
}
|
||||
total += rows.length;
|
||||
return { name: sanitizeSheetName(section.label, usedNames), rows };
|
||||
});
|
||||
const buf = buildXlsxBuffer(sheets, columns);
|
||||
writeFileSync(outputPath, buf);
|
||||
return { filePath: outputPath, files: [outputPath], rowsWritten: total };
|
||||
}
|
||||
|
||||
// xlsx — collect rows per section into memory (bounded by typical
|
||||
// section size), build workbook, write once.
|
||||
const usedNames = new Set<string>();
|
||||
let total = 0;
|
||||
const sheets = sections.map((section) => {
|
||||
const rows: CrawlUrlRow[] = [];
|
||||
for (const row of rowSource(db, section.category, selectedIds)) {
|
||||
rows.push(row);
|
||||
// CSV / JSON / XML — file-per-section. When the export has only one
|
||||
// section with no subdir, `outputPath` is treated as the file path
|
||||
// directly (backwards compatible with the legacy single-CSV flow).
|
||||
const ext = format;
|
||||
const singleFlat =
|
||||
sections.length === 1 &&
|
||||
!sections[0]!.subdir &&
|
||||
!sections[0]!.filename &&
|
||||
/\.[a-z0-9]+$/i.test(outputPath);
|
||||
// Bug #11 — force the resolved path's extension to match `format` so a
|
||||
// user-typed "report.txt" with format=json doesn't silently write JSON
|
||||
// into the wrong extension. Only applies to the single-file branch
|
||||
// (folder mode always names its own files).
|
||||
const resolvedOutputPath = singleFlat
|
||||
? outputPath.replace(/\.[a-z0-9]+$/i, `.${ext}`)
|
||||
: outputPath;
|
||||
|
||||
const writeOne = async (filePath: string, section: TabularSection): Promise<number> => {
|
||||
if (format === 'csv') {
|
||||
return writeCsvFile(db, filePath, columns, section.category, selectedIds, csvBom);
|
||||
}
|
||||
total += rows.length;
|
||||
return { name: sanitizeSheetName(section.label, usedNames), rows };
|
||||
});
|
||||
const buf = buildXlsxBuffer(sheets, columns);
|
||||
writeFileSync(outputPath, buf);
|
||||
return { filePath: outputPath, files: [outputPath], rowsWritten: total };
|
||||
if (format === 'json') {
|
||||
return writeJsonFile(db, filePath, columns, section.category, selectedIds);
|
||||
}
|
||||
return writeXmlFile(db, filePath, columns, section.category, selectedIds, section.label);
|
||||
};
|
||||
|
||||
if (singleFlat) {
|
||||
const rows = await writeOne(resolvedOutputPath, sections[0]!);
|
||||
return {
|
||||
filePath: resolvedOutputPath,
|
||||
files: [resolvedOutputPath],
|
||||
rowsWritten: rows,
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-section (or hierarchical) — outputPath is a folder root.
|
||||
mkdirSync(outputPath, { recursive: true });
|
||||
let total = 0;
|
||||
const written: string[] = [];
|
||||
for (const section of sections) {
|
||||
const file = resolveSectionPath(outputPath, section, ext);
|
||||
total += await writeOne(file, section);
|
||||
written.push(file);
|
||||
}
|
||||
return { filePath: outputPath, files: written, rowsWritten: total };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1409,6 +1409,96 @@ const MIGRATIONS: Migration[] = [
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 62,
|
||||
name: 'add_rendered_body_column',
|
||||
// V2 Faz 1 — JavaScript rendering. Stores the post-JS DOM dump
|
||||
// captured by Playwright alongside the raw HTML in `url_sources`.
|
||||
// When `renderingMode === 'js'`, the crawler fills `rendered_body`
|
||||
// and re-uses `body` for the pre-JS HTML so users can diff the two
|
||||
// in the View Rendered HTML detail sub-tab.
|
||||
up: (db) => {
|
||||
const cols = db
|
||||
.prepare('PRAGMA table_info(url_sources)')
|
||||
.all() as unknown as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'rendered_body')) {
|
||||
db.exec('ALTER TABLE url_sources ADD COLUMN rendered_body TEXT');
|
||||
}
|
||||
if (!cols.some((c) => c.name === 'rendered_body_length')) {
|
||||
db.exec(
|
||||
'ALTER TABLE url_sources ADD COLUMN rendered_body_length INTEGER',
|
||||
);
|
||||
}
|
||||
if (!cols.some((c) => c.name === 'render_ms')) {
|
||||
db.exec('ALTER TABLE url_sources ADD COLUMN render_ms INTEGER');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 63,
|
||||
name: 'add_screenshot_lcp_mobile_columns',
|
||||
// V2 Faz 1 Increment 3+4 — Screenshot capture + LCP candidate +
|
||||
// Mobile Usability. Screenshot files live as PNGs on disk under
|
||||
// the project's `screenshots/` sidecar folder; the DB only stores
|
||||
// the file paths (relative-to-project-dir when possible). LCP +
|
||||
// mobile-usability fields live on the urls table because they're
|
||||
// queried alongside other per-URL fields in the main grid.
|
||||
up: (db) => {
|
||||
const srcCols = db
|
||||
.prepare('PRAGMA table_info(url_sources)')
|
||||
.all() as unknown as { name: string }[];
|
||||
if (!srcCols.some((c) => c.name === 'screenshot_fullpage_path')) {
|
||||
db.exec(
|
||||
'ALTER TABLE url_sources ADD COLUMN screenshot_fullpage_path TEXT',
|
||||
);
|
||||
}
|
||||
if (!srcCols.some((c) => c.name === 'screenshot_fold_path')) {
|
||||
db.exec('ALTER TABLE url_sources ADD COLUMN screenshot_fold_path TEXT');
|
||||
}
|
||||
if (!srcCols.some((c) => c.name === 'screenshot_mobile_path')) {
|
||||
db.exec(
|
||||
'ALTER TABLE url_sources ADD COLUMN screenshot_mobile_path TEXT',
|
||||
);
|
||||
}
|
||||
const urlCols = db
|
||||
.prepare('PRAGMA table_info(urls)')
|
||||
.all() as unknown as { name: string }[];
|
||||
if (!urlCols.some((c) => c.name === 'lcp_selector')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_selector TEXT');
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'lcp_tag')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_tag TEXT');
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'lcp_width')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_width INTEGER');
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'lcp_height')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_height INTEGER');
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'lcp_coverage')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_coverage REAL');
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'lcp_resource_url')) {
|
||||
db.exec('ALTER TABLE urls ADD COLUMN lcp_resource_url TEXT');
|
||||
}
|
||||
// mobile_usable: -1 = not audited, 0 = fails one or more checks, 1 = pass.
|
||||
if (!urlCols.some((c) => c.name === 'mobile_usable')) {
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN mobile_usable INTEGER NOT NULL DEFAULT -1',
|
||||
);
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'mobile_overflow_px')) {
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN mobile_overflow_px INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
if (!urlCols.some((c) => c.name === 'mobile_viewport_meta')) {
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN mobile_viewport_meta INTEGER NOT NULL DEFAULT -1',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
@@ -551,6 +551,29 @@ const UPSERT_URL_SQL = `
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* Truncate a UTF-8 string to at most `cap` bytes, rounding down at a
|
||||
* code-point boundary so the result is always valid UTF-8. `Buffer.slice`
|
||||
* can leave a partial sequence at the end (a continuation byte `10xxxxxx`
|
||||
* without its leader), which `.toString('utf8')` then renders as
|
||||
* `�`. We back up at most 3 bytes — the maximum continuation chain
|
||||
* length in 4-byte UTF-8 — until the next byte starts a new code point.
|
||||
*/
|
||||
function truncateUtf8(input: string, cap: number): string {
|
||||
if (cap <= 0) return '';
|
||||
const buf = Buffer.from(input, 'utf8');
|
||||
if (buf.length <= cap) return input;
|
||||
let end = cap;
|
||||
// Walk back past continuation bytes (10xxxxxx → 0x80..0xBF). The byte
|
||||
// we keep must NOT be a continuation byte; it's either ASCII (0xxxxxxx)
|
||||
// or a leader (11xxxxxx). If we're sitting on a continuation byte, the
|
||||
// previous leader's sequence is incomplete — drop the whole sequence.
|
||||
while (end > 0 && (buf[end] !== undefined) && (buf[end]! & 0xc0) === 0x80) {
|
||||
end--;
|
||||
}
|
||||
return buf.subarray(0, end).toString('utf8');
|
||||
}
|
||||
|
||||
export class ProjectDb {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly stmtUpsertUrl: StatementSync;
|
||||
@@ -4469,8 +4492,7 @@ export class ProjectDb {
|
||||
let stored = body;
|
||||
let truncated = 0;
|
||||
if (fullLength > cap) {
|
||||
// Slice on bytes, not chars — surrogate-pair safe via Buffer.
|
||||
stored = Buffer.from(body, 'utf8').slice(0, cap).toString('utf8');
|
||||
stored = truncateUtf8(body, cap);
|
||||
truncated = 1;
|
||||
}
|
||||
this.db
|
||||
@@ -4493,13 +4515,38 @@ export class ProjectDb {
|
||||
*/
|
||||
getUrlSource(
|
||||
urlId: number,
|
||||
): { body: string; bodyLength: number; truncated: boolean; capturedAt: string } | null {
|
||||
): {
|
||||
body: string;
|
||||
bodyLength: number;
|
||||
truncated: boolean;
|
||||
capturedAt: string;
|
||||
renderedBody: string | null;
|
||||
renderedBodyLength: number | null;
|
||||
renderMs: number | null;
|
||||
screenshotFullpagePath: string | null;
|
||||
screenshotFoldPath: string | null;
|
||||
screenshotMobilePath: string | null;
|
||||
} | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT body, body_length, truncated, captured_at FROM url_sources WHERE url_id = ?',
|
||||
`SELECT body, body_length, truncated, captured_at,
|
||||
rendered_body, rendered_body_length, render_ms,
|
||||
screenshot_fullpage_path, screenshot_fold_path, screenshot_mobile_path
|
||||
FROM url_sources WHERE url_id = ?`,
|
||||
)
|
||||
.get(urlId) as
|
||||
| { body: string; body_length: number; truncated: number; captured_at: string }
|
||||
| {
|
||||
body: string;
|
||||
body_length: number;
|
||||
truncated: number;
|
||||
captured_at: string;
|
||||
rendered_body: string | null;
|
||||
rendered_body_length: number | null;
|
||||
render_ms: number | null;
|
||||
screenshot_fullpage_path: string | null;
|
||||
screenshot_fold_path: string | null;
|
||||
screenshot_mobile_path: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
@@ -4507,9 +4554,155 @@ export class ProjectDb {
|
||||
bodyLength: row.body_length,
|
||||
truncated: row.truncated === 1,
|
||||
capturedAt: row.captured_at,
|
||||
renderedBody: row.rendered_body,
|
||||
renderedBodyLength: row.rendered_body_length,
|
||||
renderMs: row.render_ms,
|
||||
screenshotFullpagePath: row.screenshot_fullpage_path,
|
||||
screenshotFoldPath: row.screenshot_fold_path,
|
||||
screenshotMobilePath: row.screenshot_mobile_path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the screenshot file paths for a URL. Screenshots live
|
||||
* on disk under `<projectDir>/screenshots/` — the DB only stores
|
||||
* the paths so the row size stays small. Pass `null` to clear an
|
||||
* individual field.
|
||||
*/
|
||||
setUrlScreenshotPaths(
|
||||
urlId: number,
|
||||
paths: {
|
||||
fullpage?: string | null;
|
||||
fold?: string | null;
|
||||
mobile?: string | null;
|
||||
},
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO url_sources (
|
||||
url_id, body, body_length, truncated,
|
||||
screenshot_fullpage_path, screenshot_fold_path, screenshot_mobile_path
|
||||
) VALUES (?, '', 0, 0, ?, ?, ?)
|
||||
ON CONFLICT(url_id) DO UPDATE SET
|
||||
screenshot_fullpage_path =
|
||||
CASE WHEN excluded.screenshot_fullpage_path IS NULL
|
||||
THEN url_sources.screenshot_fullpage_path
|
||||
ELSE excluded.screenshot_fullpage_path END,
|
||||
screenshot_fold_path =
|
||||
CASE WHEN excluded.screenshot_fold_path IS NULL
|
||||
THEN url_sources.screenshot_fold_path
|
||||
ELSE excluded.screenshot_fold_path END,
|
||||
screenshot_mobile_path =
|
||||
CASE WHEN excluded.screenshot_mobile_path IS NULL
|
||||
THEN url_sources.screenshot_mobile_path
|
||||
ELSE excluded.screenshot_mobile_path END,
|
||||
captured_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.run(
|
||||
urlId,
|
||||
paths.fullpage ?? null,
|
||||
paths.fold ?? null,
|
||||
paths.mobile ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the LCP candidate (largest above-fold element) details on
|
||||
* the urls row. All fields are nullable — only updated when present.
|
||||
*/
|
||||
setUrlLcpCandidate(
|
||||
urlId: number,
|
||||
lcp: {
|
||||
selector: string;
|
||||
tagName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
coverage: number;
|
||||
resourceUrl: string | null;
|
||||
} | null,
|
||||
): void {
|
||||
if (!lcp) return;
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE urls
|
||||
SET lcp_selector = ?,
|
||||
lcp_tag = ?,
|
||||
lcp_width = ?,
|
||||
lcp_height = ?,
|
||||
lcp_coverage = ?,
|
||||
lcp_resource_url = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
lcp.selector,
|
||||
lcp.tagName,
|
||||
lcp.width,
|
||||
lcp.height,
|
||||
lcp.coverage,
|
||||
lcp.resourceUrl,
|
||||
urlId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist mobile-usability audit verdict + raw overflow / viewport-meta flag. */
|
||||
setUrlMobileUsability(
|
||||
urlId: number,
|
||||
audit: {
|
||||
ok: boolean;
|
||||
overflowPx: number;
|
||||
hasViewportMeta: boolean;
|
||||
},
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE urls
|
||||
SET mobile_usable = ?,
|
||||
mobile_overflow_px = ?,
|
||||
mobile_viewport_meta = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
audit.ok ? 1 : 0,
|
||||
audit.overflowPx,
|
||||
audit.hasViewportMeta ? 1 : 0,
|
||||
urlId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or overwrite) the post-JS rendered HTML for a URL.
|
||||
* Called when `renderingMode === 'js'` after Playwright finishes
|
||||
* navigating the page. The pre-JS body (if captured) stays in
|
||||
* `url_sources.body`; this column holds the DOM dump after
|
||||
* scripts execute and ajax/wait conditions settle.
|
||||
*/
|
||||
setUrlRenderedBody(
|
||||
urlId: number,
|
||||
renderedBody: string,
|
||||
renderMs: number,
|
||||
cap = 1_048_576,
|
||||
): void {
|
||||
const fullLength = Buffer.byteLength(renderedBody, 'utf8');
|
||||
let stored = renderedBody;
|
||||
if (fullLength > cap) {
|
||||
stored = truncateUtf8(renderedBody, cap);
|
||||
}
|
||||
// INSERT-OR-UPDATE — when no row exists yet (storeBodySnapshots was off
|
||||
// for the raw HTML), seed an empty `body` so the NOT NULL constraint
|
||||
// holds while still recording the rendered DOM.
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO url_sources (url_id, body, body_length, truncated, rendered_body, rendered_body_length, render_ms)
|
||||
VALUES (?, '', 0, 0, ?, ?, ?)
|
||||
ON CONFLICT(url_id) DO UPDATE SET
|
||||
rendered_body = excluded.rendered_body,
|
||||
rendered_body_length = excluded.rendered_body_length,
|
||||
render_ms = excluded.render_ms,
|
||||
captured_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.run(urlId, stored, fullLength, renderMs);
|
||||
}
|
||||
|
||||
*iterateAllUrls(): IterableIterator<CrawlUrlRow> {
|
||||
const rows = this.db.prepare('SELECT * FROM urls ORDER BY id').all() as unknown as UrlRowDb[];
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -898,10 +898,73 @@ export interface CrawlConfig {
|
||||
* are rewritten to Google's deprecated `?_escaped_fragment_=` form
|
||||
* before fetch, so a pre-rendering server returns the snapshot HTML.
|
||||
* No JS execution — just the URL transform.
|
||||
* Full JavaScript rendering (a headless-browser pass) is a V2 item and
|
||||
* intentionally absent from this union.
|
||||
* - `js` — full JavaScript rendering via Playwright/Chromium. Each URL
|
||||
* is navigated in a headless browser; post-load DOM is captured
|
||||
* after `ajaxTimeoutMs` / `waitSelector` settles. Slowest but
|
||||
* required for SPA / hydration-only content.
|
||||
*/
|
||||
renderingMode: 'text' | 'ajax';
|
||||
renderingMode: 'text' | 'ajax' | 'js';
|
||||
|
||||
/**
|
||||
* JS render configuration. Only consulted when `renderingMode === 'js'`.
|
||||
* Defaults are wired in `validateCrawlConfig` so unset fields don't
|
||||
* crash older projects that pre-date this block.
|
||||
*/
|
||||
jsRender: JsRenderConfig;
|
||||
}
|
||||
|
||||
/** Per-crawl Playwright settings — surfaces in Settings → Rendering. */
|
||||
export interface JsRenderConfig {
|
||||
/** Show the browser window for debugging. Default false. */
|
||||
headless: boolean;
|
||||
/** Viewport width in CSS pixels. Default 1366. */
|
||||
viewportWidth: number;
|
||||
/** Viewport height in CSS pixels. Default 768. */
|
||||
viewportHeight: number;
|
||||
/** Extra wait after navigation for SPA hydration, ms. Default 2000. */
|
||||
ajaxTimeoutMs: number;
|
||||
/** Selector to wait for before extracting DOM. Empty = skip. */
|
||||
waitSelector: string;
|
||||
/** `load` (default), `domcontentloaded`, `networkidle`, `commit`. */
|
||||
waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
|
||||
/** Resource types Playwright should block before they hit the network. */
|
||||
blockResources: {
|
||||
image: boolean;
|
||||
font: boolean;
|
||||
media: boolean;
|
||||
stylesheet: boolean;
|
||||
script: boolean;
|
||||
};
|
||||
/** Chromium channel — '' = bundled Playwright Chromium, or 'chrome'/'msedge'. */
|
||||
browserChannel: '' | 'chrome' | 'msedge' | 'chrome-beta' | 'msedge-beta';
|
||||
/** Inject this JS into the page context BEFORE navigation. */
|
||||
prerenderJs: string;
|
||||
/** Max parallel render tabs. Defaults to crawler maxConcurrency. */
|
||||
maxPages: number;
|
||||
/**
|
||||
* Screenshot capture during JS render:
|
||||
* - 'none' — skip (default for speed)
|
||||
* - 'fullpage' — `page.screenshot({fullPage:true})`
|
||||
* - 'fold' — above-the-fold (viewport-sized) capture
|
||||
* - 'both' — fullpage + fold (two files per URL)
|
||||
*/
|
||||
screenshotMode: 'none' | 'fullpage' | 'fold' | 'both';
|
||||
/** Additionally capture a mobile-viewport variant (375×667). */
|
||||
mobileScreenshot: boolean;
|
||||
/**
|
||||
* Run a second render pass at a mobile viewport to evaluate Google's
|
||||
* Mobile-Friendly checks (viewport meta, content fits viewport,
|
||||
* legible font size, tap-target spacing). Feeds the new
|
||||
* `mobile_usable` column on the urls table.
|
||||
*/
|
||||
mobileUsability: boolean;
|
||||
/**
|
||||
* Detect the largest above-the-fold element after JS render and
|
||||
* store its CSS selector + bounding-box dimensions as an LCP
|
||||
* candidate. Mirrors what PageSpeed Insights reports without
|
||||
* requiring a PSI API call.
|
||||
*/
|
||||
lcpCandidate: boolean;
|
||||
}
|
||||
|
||||
export interface HttpAuth {
|
||||
@@ -1540,4 +1603,26 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
|
||||
proxyProfiles: [],
|
||||
proxyProfileActive: '',
|
||||
renderingMode: 'text',
|
||||
jsRender: {
|
||||
headless: true,
|
||||
viewportWidth: 1366,
|
||||
viewportHeight: 768,
|
||||
ajaxTimeoutMs: 2000,
|
||||
waitSelector: '',
|
||||
waitUntil: 'load',
|
||||
blockResources: {
|
||||
image: false,
|
||||
font: false,
|
||||
media: true,
|
||||
stylesheet: false,
|
||||
script: false,
|
||||
},
|
||||
browserChannel: '',
|
||||
prerenderJs: '',
|
||||
maxPages: 0,
|
||||
screenshotMode: 'none',
|
||||
mobileScreenshot: false,
|
||||
mobileUsability: false,
|
||||
lcpCandidate: false,
|
||||
},
|
||||
};
|
||||
@@ -116,6 +116,15 @@ export const IPC = {
|
||||
* heavy crawls this drops IPC volume from ~200 msgs/s to ~10. */
|
||||
logsBatch: 'logs:batch',
|
||||
logsOpenWindow: 'logs:open-window',
|
||||
/** Renderer → main: open (or focus) the standalone Visualization
|
||||
* popup window. The graph runs in its own native window so the user
|
||||
* can park it on a second monitor while the main tab strip stays
|
||||
* free for data tables. */
|
||||
visualizationOpenWindow: 'visualization:open-window',
|
||||
/** Renderer → main: read a captured screenshot PNG and return a
|
||||
* data: URL the renderer can plug straight into `<img src>`. Avoids
|
||||
* file:// CSP / sandbox headaches. */
|
||||
screenshotRead: 'screenshot:read',
|
||||
/** main → logs renderer: pause / resume the live setState pump while
|
||||
* the user is dragging or resizing the Logs window. Prevents the
|
||||
* renderer's render loop from competing with the OS compositor for
|
||||
@@ -335,9 +344,7 @@ export type MenuEvent =
|
||||
| 'clear-crawl'
|
||||
| 'toggle-sidebar'
|
||||
| 'toggle-detail-panel'
|
||||
| 'export-csv'
|
||||
| 'export-json'
|
||||
| 'export-xml'
|
||||
| 'export-as'
|
||||
| 'export-html-report'
|
||||
| 'export-bulk'
|
||||
| 'export-sheets'
|
||||
@@ -417,19 +424,28 @@ export interface ExportImagesResult {
|
||||
}
|
||||
|
||||
/** One section / sheet of a tabular export — a single category fed into
|
||||
* the workbook (xlsx) or split into its own file (csv). */
|
||||
* the workbook (xlsx) or split into its own file (csv/json/xml). */
|
||||
export interface ExportTabularSection {
|
||||
/** Display label — becomes the xlsx sheet name and the csv file suffix. */
|
||||
/** Display label — becomes the xlsx sheet name and the per-file label. */
|
||||
label: string;
|
||||
category: UrlCategory;
|
||||
/** Optional subdirectory under the chosen folder. When set, the section
|
||||
* file lands at `<root>/<subdir>/<filename>.<ext>` instead of the flat
|
||||
* `<root>/<filename>.<ext>`. Lets a hierarchical tree pick (Internal →
|
||||
* HTML/JS/CSS) export under nested folders. Ignored for xlsx output
|
||||
* (everything stays in a single workbook). */
|
||||
subdir?: string;
|
||||
/** Optional filename (without extension) — defaults to a sanitized label. */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ExportTabularInput {
|
||||
format: 'csv' | 'xlsx';
|
||||
format: 'csv' | 'xlsx' | 'json' | 'xml';
|
||||
/**
|
||||
* One or more sections. With CSV + multiple sections, files are written
|
||||
* into a folder the user picks; xlsx always produces a single workbook
|
||||
* with one sheet per section.
|
||||
* One or more sections. With CSV/JSON/XML + multiple sections, files are
|
||||
* written into a folder the user picks (one file per section, honouring
|
||||
* the optional `subdir`); xlsx always produces a single workbook with
|
||||
* one sheet per section.
|
||||
*/
|
||||
sections: ExportTabularSection[];
|
||||
/** Keys of `CrawlUrlRow` to emit, in order. */
|
||||
@@ -438,6 +454,9 @@ export interface ExportTabularInput {
|
||||
filePath?: string;
|
||||
/** When set, restrict every section to these row ids. */
|
||||
selectedIds?: number[];
|
||||
/** When true, CSV files are written with a UTF-8 BOM so Excel for Windows
|
||||
* opens them in the correct charset. Ignored for non-CSV formats. */
|
||||
csvBom?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportTabularResult {
|
||||
@@ -624,6 +643,19 @@ export interface UrlSourceResult {
|
||||
truncated: boolean;
|
||||
/** ISO timestamp of when the snapshot was captured. */
|
||||
capturedAt: string | null;
|
||||
/** V2 Faz 1 — Post-JS rendered DOM dump. Null when JS render did not
|
||||
* run for this URL (text/ajax mode or page rendered before V2). */
|
||||
renderedBody: string | null;
|
||||
/** Byte length of the rendered DOM before truncation. */
|
||||
renderedBodyLength: number | null;
|
||||
/** Total Playwright render time in ms (nav + wait + extract). */
|
||||
renderMs: number | null;
|
||||
/** Absolute path to the full-page screenshot PNG, when captured. */
|
||||
screenshotFullpagePath: string | null;
|
||||
/** Absolute path to the above-the-fold screenshot PNG, when captured. */
|
||||
screenshotFoldPath: string | null;
|
||||
/** Absolute path to the mobile-viewport screenshot PNG, when captured. */
|
||||
screenshotMobilePath: string | null;
|
||||
}
|
||||
|
||||
export interface UrlPageImagesInput {
|
||||
@@ -1323,6 +1355,8 @@ export interface FreeCrawlApi {
|
||||
logsGetAll(): Promise<LogEntry[]>;
|
||||
logsClear(): Promise<void>;
|
||||
logsOpenWindow(): Promise<void>;
|
||||
openVisualizationWindow(): Promise<void>;
|
||||
readScreenshot(absolutePath: string): Promise<string | null>;
|
||||
robotsTest(input: RobotsTestInput): Promise<RobotsTestResult>;
|
||||
robotsValidate(text: string): Promise<RobotsValidationIssue[]>;
|
||||
sitemapValidate(input: SitemapValidateInput): Promise<SitemapValidateResult>;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* V2 Faz 1 — Ensures the Playwright browsers needed for JS rendering
|
||||
* are installed on this machine. Runs as `postinstall` so a fresh
|
||||
* `npm install` leaves the dev environment ready for JS-mode crawls.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Skips entirely when `PLAYWRIGHT_SKIP_BROWSER_INSTALL=1` (CI / Docker).
|
||||
* - Checks whether `chromium.executablePath()` resolves to a file on
|
||||
* disk; if so, exits with no work (idempotent).
|
||||
* - Otherwise runs `playwright install chromium chromium-headless-shell`
|
||||
* inheriting stdio so the user sees download progress.
|
||||
*
|
||||
* Pass `--force` to re-run the install even when the binaries are
|
||||
* already present (useful after upgrading Playwright).
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const force = process.argv.includes('--force');
|
||||
|
||||
if (process.env.PLAYWRIGHT_SKIP_BROWSER_INSTALL === '1') {
|
||||
console.log('[install-playwright] PLAYWRIGHT_SKIP_BROWSER_INSTALL=1 — skipping browser install.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let chromiumPath = '';
|
||||
try {
|
||||
const { chromium } = await import('playwright');
|
||||
chromiumPath = chromium.executablePath();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[install-playwright] Could not resolve Playwright entry — is the package installed yet?',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const fullBrowserOk = chromiumPath && existsSync(chromiumPath);
|
||||
// Headless-shell binary path — derive by replacing the chromium-NNNN
|
||||
// directory with chromium_headless_shell-NNNN. Playwright stores them
|
||||
// as siblings in the cache (same revision number, different folder
|
||||
// and exe name). Best-effort; if the regex fails to normalise we
|
||||
// assume the binary is missing and let `playwright install` redo
|
||||
// both — the command is idempotent.
|
||||
const headlessShellPath = (() => {
|
||||
if (!chromiumPath) return '';
|
||||
// Windows: ...\chromium-1223\chrome-win64\chrome.exe
|
||||
// → ...\chromium_headless_shell-1223\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
if (process.platform === 'win32') {
|
||||
return chromiumPath
|
||||
.replace(/chromium-(\d+)/, 'chromium_headless_shell-$1')
|
||||
.replace(/chrome-win64\\chrome\.exe$/, 'chrome-headless-shell-win64\\chrome-headless-shell.exe');
|
||||
}
|
||||
// macOS: .../chromium-1223/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
||||
// → .../chromium_headless_shell-1223/chrome-mac/headless_shell
|
||||
if (process.platform === 'darwin') {
|
||||
return chromiumPath
|
||||
.replace(/chromium-(\d+)/, 'chromium_headless_shell-$1')
|
||||
.replace(/chrome-mac\/Chromium\.app\/Contents\/MacOS\/Chromium$/, 'chrome-mac/headless_shell');
|
||||
}
|
||||
// Linux: .../chromium-1223/chrome-linux/chrome
|
||||
// → .../chromium_headless_shell-1223/chrome-linux/headless_shell
|
||||
return chromiumPath
|
||||
.replace(/chromium-(\d+)/, 'chromium_headless_shell-$1')
|
||||
.replace(/chrome-linux\/chrome$/, 'chrome-linux/headless_shell');
|
||||
})();
|
||||
const headlessShellOk = headlessShellPath && existsSync(headlessShellPath);
|
||||
|
||||
if (!force && fullBrowserOk && headlessShellOk) {
|
||||
console.log('[install-playwright] Browsers already present — nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
if (!fullBrowserOk) missing.push('chromium');
|
||||
if (!headlessShellOk) missing.push('chromium-headless-shell');
|
||||
console.log(
|
||||
`[install-playwright] Installing browsers — ${
|
||||
force ? 'forced re-install' : `missing: ${missing.join(', ')}`
|
||||
} …`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
['playwright', 'install', 'chromium', 'chromium-headless-shell'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.warn(
|
||||
`[install-playwright] Browser install exited with code ${result.status}. ` +
|
||||
'JS rendering will not work until this is resolved. Re-run `npm run playwright:install` once your network is back.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('[install-playwright] Done.');
|
||||
}
|
||||
|
||||
void main();
|
||||
Reference in new issue
Block a user