mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: 3D link visualiser + macOS performance and shortcuts
This commit is contained in:
1 parent
c802a8c2c3
commit
4492ce6f5a
31 files changed
+2293
-453
No files matched your search
@@ -400,12 +400,14 @@ graph LR
|
|||||||
## 📈 Status
|
## 📈 Status
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Active development.** All 31 analysis tabs are working — the core set (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) plus the integration tabs (**PageSpeed, CrUX, Spelling, Search Console, GA4, AI, SEO Authority**).
|
> **Active development.** All 32 analysis tabs are working — the core set (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) plus the integration tabs (**PageSpeed, CrUX, Spelling, Search Console, GA4, AI, SEO Authority**).
|
||||||
>
|
>
|
||||||
> Also shipping: 150+ issue checks, **JavaScript rendering** (Playwright — post-JS DOM, screenshots, LCP candidate, Mobile Usability audit), **multi-window projects** (independent per-window crawls), near/exact-duplicate detection, hreflang validation, **internal PageRank link scoring**, project compare, the **Cytoscape link graph** (force-directed / BFS / radial / Sugiyama / directory-tree layouts + LCP and link-score overlays + click-to-trace Crawl Path Report), **Basic / Digest / Bearer + form-based & browser-driven SPA login**, **HTTP / SOCKS5 proxy**, the **MCP server with live crawl control**, all **Google integrations** (Search Console / URL Inspection / GA4 / PageSpeed / CrUX), **LanguageTool spelling & grammar**, **AI per-URL prompts** (OpenAI / Anthropic / Ollama), **SEO Authority providers** (Ahrefs / Majestic / Moz / Semrush), **Google Sheets + BigQuery export**, **encrypted project snapshots** (AES-256-GCM), **RAM-only storage mode**, scheduled crawls, robots.txt validator, URL rewriting, EN + TR UI, and the **standalone Log File Analyzer** (Apache / Nginx / IIS access logs → bot hits, crawl budget, crawl × log orphans, 40+ bot detection, CSV / Excel export, and a headless `analyze-logs` CLI).
|
> Also shipping: 150+ issue checks, **JavaScript rendering** (Playwright — post-JS DOM, screenshots, LCP candidate, Mobile Usability audit), **multi-window projects** (independent per-window crawls), near/exact-duplicate detection, hreflang validation, **internal PageRank link scoring**, project compare, the **link visualiser** (WebGL 3D force-directed crawl tree and link graph, plus 2D Crawl Tree and Directory Tree with click-to-collapse branches, hover summary cards, LCP and link-score overlays, and a click-to-trace Crawl Path Report), **Basic / Digest / Bearer + form-based & browser-driven SPA login**, **HTTP / SOCKS5 proxy**, the **MCP server with live crawl control**, all **Google integrations** (Search Console / URL Inspection / GA4 / PageSpeed / CrUX), **LanguageTool spelling & grammar**, **AI per-URL prompts** (OpenAI / Anthropic / Ollama), **SEO Authority providers** (Ahrefs / Majestic / Moz / Semrush), **Google Sheets + BigQuery export**, **encrypted project snapshots** (AES-256-GCM), **RAM-only storage mode**, scheduled crawls, robots.txt validator, URL rewriting, EN + TR UI, and the **standalone Log File Analyzer** (Apache / Nginx / IIS access logs → bot hits, crawl budget, crawl × log orphans, 40+ bot detection, CSV / Excel export, and a headless `analyze-logs` CLI).
|
||||||
>
|
>
|
||||||
> Cross-platform installers ship for Windows (`.exe` + portable), macOS (`.dmg`), and Linux (`.AppImage` / `.deb` / `.rpm`), with Playwright Chromium bundled so JS rendering works on first launch. The virtualized 1M-row table streams the **first row in ~1 s**.
|
> Cross-platform installers ship for Windows (`.exe` + portable), macOS (`.dmg`), and Linux (`.AppImage` / `.deb` / `.rpm`), with Playwright Chromium bundled so JS rendering works on first launch. The virtualized 1M-row table streams the **first row in ~1 s**.
|
||||||
>
|
>
|
||||||
|
> **Apple Silicon tuning:** long crawls hold a power assertion so macOS App Nap can't throttle them in the background, concurrency scales down automatically as the thermal state rises (so a fanless MacBook Air degrades gracefully instead of stalling), heavy aggregates never run on the main thread, and every large read is batch-streamed to stay clear of Electron's hard per-process heap ceiling. macOS also gets the full Windows-style `Cmd`-key shortcut set.
|
||||||
|
>
|
||||||
> **Upcoming:** Plugin system, Light theme, Code-signing + auto-update.
|
> **Upcoming:** Plugin system, Light theme, Code-signing + auto-update.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
+26
-12
@@ -13,7 +13,7 @@ upgrading is the recommended remediation path.
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| Latest stable (currently `0.3.x`) | Yes |
|
| Latest stable (currently `0.9.x`) | Yes |
|
||||||
| Older `0.x` releases | No |
|
| Older `0.x` releases | No |
|
||||||
|
|
||||||
Release artifacts are published at:
|
Release artifacts are published at:
|
||||||
@@ -60,8 +60,8 @@ possible:
|
|||||||
layer (`@freecrawl/db`), desktop shell, MCP server, or CLI
|
layer (`@freecrawl/db`), desktop shell, MCP server, or CLI
|
||||||
- **Affected version(s)** — output of `Help → About`, or the `version`
|
- **Affected version(s)** — output of `Help → About`, or the `version`
|
||||||
field in `package.json`
|
field in `package.json`
|
||||||
- **Operating system + architecture** — Windows 11 / macOS 14 (Intel or
|
- **Operating system + architecture** — Windows 10/11, macOS 13 or newer
|
||||||
ARM64) / Linux distro
|
(Intel or Apple Silicon), or your Linux distribution
|
||||||
- **Reproduction steps** — minimal sequence that triggers the issue
|
- **Reproduction steps** — minimal sequence that triggers the issue
|
||||||
- **Proof-of-concept** — screenshot, video, or minimal code
|
- **Proof-of-concept** — screenshot, video, or minimal code
|
||||||
- **Impact assessment** — confidentiality / integrity / availability
|
- **Impact assessment** — confidentiality / integrity / availability
|
||||||
@@ -143,12 +143,16 @@ Releases. The artifacts are produced by an automated GitHub Actions
|
|||||||
workflow defined in `.github/workflows/release.yml`. SHA512 digests are
|
workflow defined in `.github/workflows/release.yml`. SHA512 digests are
|
||||||
published alongside each release in `latest.yml` / `latest-mac.yml`.
|
published alongside each release in `latest.yml` / `latest-mac.yml`.
|
||||||
|
|
||||||
Code-signing for Windows installers is being established through
|
**Release artifacts are currently unsigned on every platform.** Verify
|
||||||
[SignPath.io](https://signpath.io)'s open-source signing program. Once
|
the SHA512 digest of anything you download against the value published
|
||||||
active, all `.exe` artifacts will be signed and the application will
|
with the release before running it. Expect the usual first-launch
|
||||||
verify the publisher signature before applying any in-app update. macOS
|
warnings: SmartScreen on Windows, and Gatekeeper on macOS (documented
|
||||||
artifacts are currently unsigned; users should verify the SHA512 digest
|
in the README, along with the `xattr -cr` workaround).
|
||||||
manually until Apple Developer ID notarization is in place.
|
|
||||||
|
Code-signing for Windows installers is being pursued through
|
||||||
|
[SignPath.io](https://signpath.io)'s open-source signing program, and
|
||||||
|
macOS notarization requires an Apple Developer ID. Neither is active
|
||||||
|
yet; this section will be updated when that changes.
|
||||||
|
|
||||||
If you encounter an installer that fails signature verification or
|
If you encounter an installer that fails signature verification or
|
||||||
appears to come from any source other than the official GitHub Releases
|
appears to come from any source other than the official GitHub Releases
|
||||||
@@ -167,9 +171,19 @@ page above, **do not run it** — please report it via the channels above.
|
|||||||
the crawls the user explicitly initiates and (when enabled) the
|
the crawls the user explicitly initiates and (when enabled) the
|
||||||
on-demand "Check for Updates" call to the GitHub Releases API.
|
on-demand "Check for Updates" call to the GitHub Releases API.
|
||||||
- **Project files (`.seoproject`)** are SQLite databases containing
|
- **Project files (`.seoproject`)** are SQLite databases containing
|
||||||
crawled content. They are not encrypted at rest in the current
|
crawled content, and are **not** encrypted by default. Two options
|
||||||
release — users handling sensitive data should rely on full-disk
|
exist for sensitive crawls:
|
||||||
encryption at the OS level.
|
- **Save As Encrypted** writes a password-protected snapshot
|
||||||
|
(AES-256-GCM, per-file random salt and IV, PBKDF2-SHA256 key
|
||||||
|
derivation at 200 000 iterations, authenticated header). Opening it
|
||||||
|
decrypts to a working copy, so the *open* project is still plaintext
|
||||||
|
on disk for the duration of the session.
|
||||||
|
- **RAM-only storage mode** keeps the crawl in memory and never writes
|
||||||
|
a project file at all.
|
||||||
|
|
||||||
|
Users handling sensitive data should still enable full-disk encryption
|
||||||
|
at the OS level — it is the only thing that protects the working copy
|
||||||
|
and SQLite's temporary files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/cli",
|
"name": "@freecrawl/cli",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/desktop",
|
"name": "@freecrawl/desktop",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FreeCrawl SEO — desktop application",
|
"description": "FreeCrawl SEO — desktop application",
|
||||||
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
|
"homepage": "https://github.com/kemalai/FreeCrawl-SEO-Tool",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"@tanstack/react-virtual": "^3.10.0",
|
"@tanstack/react-virtual": "^3.10.0",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
|
"3d-force-graph": "^1.80.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
"cytoscape-dagre": "^2.5.0",
|
"cytoscape-dagre": "^2.5.0",
|
||||||
|
|||||||
@@ -323,6 +323,15 @@ export function setReaderPoolResolver(fn: () => DbReaderPool): void {
|
|||||||
* to the synchronous main-process DB on any worker error. The fallback
|
* to the synchronous main-process DB on any worker error. The fallback
|
||||||
* path keeps the UI working even if every worker is crashed/restarting,
|
* path keeps the UI working even if every worker is crashed/restarting,
|
||||||
* at the cost of running on the main thread for that one query.
|
* at the cost of running on the main thread for that one query.
|
||||||
|
*
|
||||||
|
* HEAVY methods never fall back. If a heavy aggregate crashed or timed
|
||||||
|
* out its reader worker, re-running the exact same query on the main
|
||||||
|
* thread escalates a contained worker failure into a main-process
|
||||||
|
* stall (multi-second aggregate blocking every IPC) or — for
|
||||||
|
* OOM-class failures — a process crash, since Electron's V8 heap is
|
||||||
|
* hard-capped at 4 GB per process. Callers get a rejection instead;
|
||||||
|
* the sidebar keeps its previous counts and the next poll tick retries
|
||||||
|
* against the (by then respawned) worker.
|
||||||
*/
|
*/
|
||||||
export async function callReaderOrFallback<T>(
|
export async function callReaderOrFallback<T>(
|
||||||
method: string,
|
method: string,
|
||||||
@@ -330,13 +339,25 @@ export async function callReaderOrFallback<T>(
|
|||||||
fallback: () => T | Promise<T>,
|
fallback: () => T | Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const pool = resolveReaderPool();
|
const pool = resolveReaderPool();
|
||||||
|
const heavy = HEAVY_METHODS.has(method);
|
||||||
if (!pool.isReady()) {
|
if (!pool.isReady()) {
|
||||||
|
if (heavy) {
|
||||||
|
throw new Error(`reader-unavailable: '${method}' skips main-thread fallback`);
|
||||||
|
}
|
||||||
return fallback();
|
return fallback();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await pool.call<T>(method, args);
|
return await pool.call<T>(method, args);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (heavy) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'db-reader',
|
||||||
|
`'${method}' failed on reader pool (${msg}) — heavy query, NOT retrying on main thread.`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
logger.log(
|
logger.log(
|
||||||
'warn',
|
'warn',
|
||||||
'db-reader',
|
'db-reader',
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ const ALLOWED_METHODS = new Set<string>([
|
|||||||
'recomputeHreflangAnalysis',
|
'recomputeHreflangAnalysis',
|
||||||
'recomputeHreflangInconsistent',
|
'recomputeHreflangInconsistent',
|
||||||
'recomputeDuplicateClusters',
|
'recomputeDuplicateClusters',
|
||||||
|
'recomputeBoilerplateCoverage',
|
||||||
'recomputePaginationSequence',
|
'recomputePaginationSequence',
|
||||||
'recomputeUrlsIssues',
|
'recomputeUrlsIssues',
|
||||||
'recomputeUrlsIssuesYielding',
|
'recomputeUrlsIssuesYielding',
|
||||||
'checkpointQueue',
|
'checkpointQueue',
|
||||||
'clearQueueCheckpoint',
|
'clearQueueCheckpoint',
|
||||||
|
'walCheckpoint',
|
||||||
'markUrlForRecrawl',
|
'markUrlForRecrawl',
|
||||||
'markUrlsForRecrawl',
|
'markUrlsForRecrawl',
|
||||||
'deleteUrl',
|
'deleteUrl',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
Menu,
|
Menu,
|
||||||
Notification,
|
Notification,
|
||||||
|
powerMonitor,
|
||||||
|
powerSaveBlocker,
|
||||||
session,
|
session,
|
||||||
shell,
|
shell,
|
||||||
Tray,
|
Tray,
|
||||||
@@ -24,6 +26,7 @@ import { totalmem, freemem } from 'node:os';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_CRAWL_CONFIG,
|
DEFAULT_CRAWL_CONFIG,
|
||||||
IPC,
|
IPC,
|
||||||
|
type MenuEvent,
|
||||||
type ConfirmClearResult,
|
type ConfirmClearResult,
|
||||||
type CrawlConfig,
|
type CrawlConfig,
|
||||||
type CrawlProgress,
|
type CrawlProgress,
|
||||||
@@ -184,6 +187,7 @@ import {
|
|||||||
exportTabular,
|
exportTabular,
|
||||||
exportSitemap,
|
exportSitemap,
|
||||||
exportHtmlReport,
|
exportHtmlReport,
|
||||||
|
ensureHeapHeadroom,
|
||||||
compareCrawls,
|
compareCrawls,
|
||||||
testUrlAgainstRobots,
|
testUrlAgainstRobots,
|
||||||
validateRobotsTxt,
|
validateRobotsTxt,
|
||||||
@@ -1035,6 +1039,92 @@ async function recomputeIssuesViaPool(
|
|||||||
* 5–15 s on a 5K-URL crawl — exactly the window where users hit
|
* 5–15 s on a 5K-URL crawl — exactly the window where users hit
|
||||||
* Stop/Pause and "nothing happens".
|
* Stop/Pause and "nothing happens".
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Ref-counted power-save blocker held while any window's crawl runs.
|
||||||
|
* First active crawl starts the blocker, last one to end releases it —
|
||||||
|
* keyed by Crawler instance so double-release (e.g. stop() during
|
||||||
|
* clearCrawlDb after the Stop IPC already fired) is a no-op. Without
|
||||||
|
* this, macOS App Nap throttles a backgrounded crawl's timers, CPU
|
||||||
|
* share and disk I/O, and laptop sleep kills it entirely.
|
||||||
|
*/
|
||||||
|
const crawlsHoldingPowerBlocker = new Set<Crawler>();
|
||||||
|
let crawlPowerBlockerId: number | null = null;
|
||||||
|
|
||||||
|
function acquireCrawlPowerBlocker(crawler: Crawler): void {
|
||||||
|
if (crawlsHoldingPowerBlocker.has(crawler)) return;
|
||||||
|
crawlsHoldingPowerBlocker.add(crawler);
|
||||||
|
if (crawlPowerBlockerId !== null) return;
|
||||||
|
try {
|
||||||
|
crawlPowerBlockerId = powerSaveBlocker.start('prevent-app-suspension');
|
||||||
|
logger.log('info', 'main', 'Power-save blocker acquired (crawl running)');
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — the crawl still runs, just without suspension immunity.
|
||||||
|
crawlPowerBlockerId = null;
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'main',
|
||||||
|
`powerSaveBlocker.start failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseCrawlPowerBlocker(crawler: Crawler): void {
|
||||||
|
if (!crawlsHoldingPowerBlocker.delete(crawler)) return;
|
||||||
|
if (crawlsHoldingPowerBlocker.size > 0 || crawlPowerBlockerId === null) return;
|
||||||
|
try {
|
||||||
|
powerSaveBlocker.stop(crawlPowerBlockerId);
|
||||||
|
} catch {
|
||||||
|
/* already stopped — ignore */
|
||||||
|
}
|
||||||
|
crawlPowerBlockerId = null;
|
||||||
|
logger.log('info', 'main', 'Power-save blocker released (no crawls running)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thermal-adaptive crawl throttling (macOS-only — Electron emits
|
||||||
|
* `thermal-state-change` only there). On fanless Apple Silicon
|
||||||
|
* machines (MacBook Air) a 40-minute crawl at full concurrency drives
|
||||||
|
* the SoC into sustained throttling; Apple's guidance is to shed CPU
|
||||||
|
* and I/O proactively from the `fair` state onward, before the OS
|
||||||
|
* starts enacting its own countermeasures at `serious`. The scale is
|
||||||
|
* applied as a concurrency *ceiling* on every live crawler — the
|
||||||
|
* crawler's lag-adaptive loop keeps operating underneath it.
|
||||||
|
* `nominal`/`unknown` map to 1 (unsupported systems always report
|
||||||
|
* `nominal`, so this path is a safe no-op for them).
|
||||||
|
*/
|
||||||
|
function thermalScaleFor(state: string): number {
|
||||||
|
switch (state) {
|
||||||
|
case 'critical':
|
||||||
|
return 0.25;
|
||||||
|
case 'serious':
|
||||||
|
return 0.5;
|
||||||
|
case 'fair':
|
||||||
|
return 0.75;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThermalStateToCrawlers(state: string): void {
|
||||||
|
const scale = thermalScaleFor(state);
|
||||||
|
for (const s of [defaultSession, ...sessionsByWc.values()]) {
|
||||||
|
s.activeCrawler?.setThrottleScale(scale, `thermal:${state}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed a freshly launched crawler with the current thermal state so a
|
||||||
|
* crawl started on an already-hot machine begins throttled instead of
|
||||||
|
* waiting for the next state *change*. */
|
||||||
|
function applyCurrentThermalState(crawler: Crawler): void {
|
||||||
|
if (process.platform !== 'darwin') return;
|
||||||
|
try {
|
||||||
|
const state = powerMonitor.getCurrentThermalState();
|
||||||
|
crawler.setThrottleScale(thermalScaleFor(state), `thermal:${state}`);
|
||||||
|
} catch {
|
||||||
|
/* powerMonitor not ready pre-app-ready — crawls can't start then */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runDbPassViaPool(
|
async function runDbPassViaPool(
|
||||||
session: ProjectSession,
|
session: ProjectSession,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -1352,6 +1442,22 @@ function rebuildMenu(): void {
|
|||||||
onOpenLogsFolder: () => openLogsFolder(),
|
onOpenLogsFolder: () => openLogsFolder(),
|
||||||
onCheckForUpdates: () => void checkForUpdates(),
|
onCheckForUpdates: () => void checkForUpdates(),
|
||||||
onOpenLogAnalyzer: () => openLogAnalyzerWindow(),
|
onOpenLogAnalyzer: () => openLogAnalyzerWindow(),
|
||||||
|
onEditCopy: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
// Project windows understand the 'edit-copy' menu event and run
|
||||||
|
// the custom-table-copy → native-copy round-trip. Auxiliary
|
||||||
|
// windows (Logs, Log Analyzer, viz popouts) don't subscribe to
|
||||||
|
// menu events — give them the stock native copy directly so
|
||||||
|
// Cmd+C on selected text keeps working there.
|
||||||
|
const isProjectWindow =
|
||||||
|
win === mainWindow || sessionsByWc.has(win.webContents.id);
|
||||||
|
if (isProjectWindow) {
|
||||||
|
win.webContents.send(IPC.menuEvent, 'edit-copy' satisfies MenuEvent);
|
||||||
|
} else {
|
||||||
|
win.webContents.copy();
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2466,6 +2572,13 @@ function seedDiscoveryUrls(limit: number): LogSeedDiscoveryResult {
|
|||||||
|
|
||||||
function registerIpc(): void {
|
function registerIpc(): void {
|
||||||
registerHandle(IPC.appVersion, () => app.getVersion());
|
registerHandle(IPC.appVersion, () => app.getVersion());
|
||||||
|
// Native-copy fallback leg of the macOS Edit▸Copy round-trip: the
|
||||||
|
// renderer calls this when no custom table selection consumed the
|
||||||
|
// copy, so the stock behaviour (copy the DOM text selection / the
|
||||||
|
// focused input's selection) still happens.
|
||||||
|
registerHandle(IPC.editCopyNative, (e): void => {
|
||||||
|
e.sender.copy();
|
||||||
|
});
|
||||||
|
|
||||||
// Live memory snapshot for the in-app monitor (status bar). Returns
|
// Live memory snapshot for the in-app monitor (status bar). Returns
|
||||||
// process RSS / heap + system total/free + the active project's URL
|
// process RSS / heap + system total/free + the active project's URL
|
||||||
@@ -2622,6 +2735,10 @@ function registerIpc(): void {
|
|||||||
filePath = res.filePath;
|
filePath = res.filePath;
|
||||||
}
|
}
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
// Bounded but chunky: up to 250K+ small rows plus the workbook
|
||||||
|
// buffer, all materialised at once. Verify the heap can absorb
|
||||||
|
// it before starting — a clear error beats a V8 OOM abort.
|
||||||
|
ensureHeapHeadroom('Log analysis export', 256 * 1024 * 1024);
|
||||||
const tables: LogExportTables = {
|
const tables: LogExportTables = {
|
||||||
overview: db.getLogOverview(),
|
overview: db.getLogOverview(),
|
||||||
urlStats: db.queryLogUrlStats({ limit: 100_000, offset: 0, sortBy: 'totalHits', filter: 'all' }).rows,
|
urlStats: db.queryLogUrlStats({ limit: 100_000, offset: 0, sortBy: 'totalHits', filter: 'all' }).rows,
|
||||||
@@ -4593,6 +4710,23 @@ function registerIpc(): void {
|
|||||||
* in the Start dialog again. Persisted across restarts via DB
|
* in the Start dialog again. Persisted across restarts via DB
|
||||||
* project_meta so it survives the very crash we're recovering from. */
|
* project_meta so it survives the very crash we're recovering from. */
|
||||||
function attachCrawlerListeners(crawler: Crawler, sess: ProjectSession): void {
|
function attachCrawlerListeners(crawler: Crawler, sess: ProjectSession): void {
|
||||||
|
// Keep the OS from suspending/App-Napping us while this crawl runs.
|
||||||
|
// On macOS a backgrounded crawl otherwise gets App Nap's priority
|
||||||
|
// reduction + timer throttling + disk-I/O throttling (and on Apple
|
||||||
|
// Silicon, background-QoS work is confined to E-cores); on laptops
|
||||||
|
// generally, system sleep would kill the crawl outright.
|
||||||
|
// `prevent-app-suspension` is the cheapest blocker level — the
|
||||||
|
// display may still turn off. Released on 'done' AND 'stopped'
|
||||||
|
// (done is suppressed after stop(), so both hooks are required),
|
||||||
|
// deliberately BEFORE the zombie-crawler guard in the handlers
|
||||||
|
// below: a superseded crawler must still drop its blocker hold.
|
||||||
|
acquireCrawlPowerBlocker(crawler);
|
||||||
|
crawler.on('done', () => releaseCrawlPowerBlocker(crawler));
|
||||||
|
crawler.on('stopped', () => releaseCrawlPowerBlocker(crawler));
|
||||||
|
// Start throttled if the machine is already hot (macOS-only no-op
|
||||||
|
// elsewhere) — the thermal-state-change listener only covers
|
||||||
|
// *changes* after launch.
|
||||||
|
applyCurrentThermalState(crawler);
|
||||||
// Bound to the launching session so progress/done/error reach THAT
|
// Bound to the launching session so progress/done/error reach THAT
|
||||||
// window (multi-window) — these callbacks run outside any IPC handler's
|
// window (multi-window) — these callbacks run outside any IPC handler's
|
||||||
// AsyncLocalStorage context, so they can't use `currentSession()`.
|
// AsyncLocalStorage context, so they can't use `currentSession()`.
|
||||||
@@ -6002,7 +6136,7 @@ function registerIpc(): void {
|
|||||||
const rawDb = (database as any).db as { exec: (sql: string) => void };
|
const rawDb = (database as any).db as { exec: (sql: string) => void };
|
||||||
rawDb.exec(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`);
|
rawDb.exec(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`);
|
||||||
const { encryptFile } = await import('./project-encryption.js');
|
const { encryptFile } = await import('./project-encryption.js');
|
||||||
const result = encryptFile(tmpPath, i.filePath, i.password);
|
const result = await encryptFile(tmpPath, i.filePath, i.password);
|
||||||
return { filePath: i.filePath, bytesWritten: result.bytesWritten };
|
return { filePath: i.filePath, bytesWritten: result.bytesWritten };
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -6022,7 +6156,7 @@ function registerIpc(): void {
|
|||||||
if (!i.password) throw new Error('password is required.');
|
if (!i.password) throw new Error('password is required.');
|
||||||
if (!i.destPath) throw new Error('destPath is required (output .seoproject path).');
|
if (!i.destPath) throw new Error('destPath is required (output .seoproject path).');
|
||||||
const { decryptFile } = await import('./project-encryption.js');
|
const { decryptFile } = await import('./project-encryption.js');
|
||||||
decryptFile(i.filePath, i.destPath, i.password);
|
await decryptFile(i.filePath, i.destPath, i.password);
|
||||||
openProjectAtPath(i.destPath);
|
openProjectAtPath(i.destPath);
|
||||||
return { filePath: i.destPath };
|
return { filePath: i.destPath };
|
||||||
},
|
},
|
||||||
@@ -6310,7 +6444,7 @@ function registerIpc(): void {
|
|||||||
rawDb.exec(`VACUUM INTO '${escaped}'`);
|
rawDb.exec(`VACUUM INTO '${escaped}'`);
|
||||||
|
|
||||||
const { encryptFile } = await import('./project-encryption.js');
|
const { encryptFile } = await import('./project-encryption.js');
|
||||||
const result = encryptFile(tmpPath, target, password);
|
const result = await encryptFile(tmpPath, target, password);
|
||||||
|
|
||||||
await dialog.showMessageBox(win, {
|
await dialog.showMessageBox(win, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@@ -6379,7 +6513,7 @@ function registerIpc(): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { decryptFile } = await import('./project-encryption.js');
|
const { decryptFile } = await import('./project-encryption.js');
|
||||||
decryptFile(srcPath, dstPath, password);
|
await decryptFile(srcPath, dstPath, password);
|
||||||
openProjectAtPath(dstPath);
|
openProjectAtPath(dstPath);
|
||||||
return { filePath: dstPath };
|
return { filePath: dstPath };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -7347,6 +7481,16 @@ void app.whenReady().then(async () => {
|
|||||||
logger.log('info', 'main', `App ready — version ${app.getVersion()}`);
|
logger.log('info', 'main', `App ready — version ${app.getVersion()}`);
|
||||||
freezeWatchdog.setMainOp('idle');
|
freezeWatchdog.setMainOp('idle');
|
||||||
|
|
||||||
|
// Thermal-adaptive crawl throttling — macOS-only (Electron emits
|
||||||
|
// `thermal-state-change` only on macOS; other platforms never fire it).
|
||||||
|
// See applyThermalStateToCrawlers for the state → concurrency mapping.
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
powerMonitor.on('thermal-state-change', (details) => {
|
||||||
|
logger.log('info', 'main', `Thermal state → ${details.state}`);
|
||||||
|
applyThermalStateToCrawlers(details.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Open the localhost MCP bridge so Claude Code / Claude Desktop can
|
// Open the localhost MCP bridge so Claude Code / Claude Desktop can
|
||||||
// drive crawls + poll progress against this running instance. Bind
|
// drive crawls + poll progress against this running instance. Bind
|
||||||
// failure is logged but non-fatal: the desktop app keeps working,
|
// failure is logged but non-fatal: the desktop app keeps working,
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface MenuLabels {
|
|||||||
openProjectEncrypted: string;
|
openProjectEncrypted: string;
|
||||||
openProjectEncryptedTooltip: string;
|
openProjectEncryptedTooltip: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
edit: string;
|
||||||
|
copy: string;
|
||||||
view: string;
|
view: string;
|
||||||
overviewSidebar: string;
|
overviewSidebar: string;
|
||||||
detailPanel: string;
|
detailPanel: string;
|
||||||
@@ -178,6 +180,8 @@ const MENU_EN: MenuLabels = {
|
|||||||
openProjectEncryptedTooltip:
|
openProjectEncryptedTooltip:
|
||||||
'Decrypt a .seoproject.enc snapshot with its password and open the recovered project.',
|
'Decrypt a .seoproject.enc snapshot with its password and open the recovered project.',
|
||||||
settings: 'Settings…',
|
settings: 'Settings…',
|
||||||
|
edit: 'Edit',
|
||||||
|
copy: 'Copy',
|
||||||
view: 'View',
|
view: 'View',
|
||||||
overviewSidebar: 'Overview Sidebar',
|
overviewSidebar: 'Overview Sidebar',
|
||||||
detailPanel: 'Detail Panel',
|
detailPanel: 'Detail Panel',
|
||||||
@@ -315,6 +319,8 @@ const MENU_TR: MenuLabels = {
|
|||||||
openProjectEncryptedTooltip:
|
openProjectEncryptedTooltip:
|
||||||
'Bir .seoproject.enc snapshot\'ını parolasıyla çöz ve kurtarılan projeyi aç.',
|
'Bir .seoproject.enc snapshot\'ını parolasıyla çöz ve kurtarılan projeyi aç.',
|
||||||
settings: 'Ayarlar…',
|
settings: 'Ayarlar…',
|
||||||
|
edit: 'Düzen',
|
||||||
|
copy: 'Kopyala',
|
||||||
view: 'Görünüm',
|
view: 'Görünüm',
|
||||||
overviewSidebar: 'Genel Bakış Kenar Çubuğu',
|
overviewSidebar: 'Genel Bakış Kenar Çubuğu',
|
||||||
detailPanel: 'Detay Paneli',
|
detailPanel: 'Detay Paneli',
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ export interface AppMenuHandlers {
|
|||||||
onCheckForUpdates: () => void;
|
onCheckForUpdates: () => void;
|
||||||
/** V2 Faz 2 — open the standalone Log File Analyzer window. */
|
/** V2 Faz 2 — open the standalone Log File Analyzer window. */
|
||||||
onOpenLogAnalyzer: () => void;
|
onOpenLogAnalyzer: () => void;
|
||||||
|
/**
|
||||||
|
* macOS Edit▸Copy dispatcher. On macOS the native menu's key
|
||||||
|
* equivalent swallows Cmd+C before the renderer ever sees a keydown
|
||||||
|
* — the exact opposite of Windows/Linux, where the page gets the key
|
||||||
|
* first. So on macOS the Copy item is a custom click that lets the
|
||||||
|
* host decide: project windows get a 'edit-copy' menu event (their
|
||||||
|
* renderer tries the custom table-selection copy, then falls back to
|
||||||
|
* native), auxiliary windows (Logs, Log Analyzer) get a plain
|
||||||
|
* `webContents.copy()`.
|
||||||
|
*/
|
||||||
|
onEditCopy: () => void;
|
||||||
/** UI language for menu labels. Falls back to `en` when missing. */
|
/** UI language for menu labels. Falls back to `en` when missing. */
|
||||||
lang?: MenuLang;
|
lang?: MenuLang;
|
||||||
}
|
}
|
||||||
@@ -154,7 +165,33 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
|
|||||||
isMac ? { role: 'close' } : { role: 'quit' },
|
isMac ? { role: 'close' } : { role: 'quit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: 'editMenu' as const },
|
// Windows/Linux keep the stock role — there the renderer receives
|
||||||
|
// Ctrl+C keydown BEFORE any menu accelerator, so the app's custom
|
||||||
|
// table-copy handlers work with zero interception. On macOS the
|
||||||
|
// native menu's key equivalent fires FIRST and the page never sees
|
||||||
|
// Cmd+C, so Copy must be a custom item that routes through
|
||||||
|
// `onEditCopy` (custom table copy → native copy fallback). Every
|
||||||
|
// other edit command keeps its native role.
|
||||||
|
isMac
|
||||||
|
? {
|
||||||
|
label: L.edit,
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' as const },
|
||||||
|
{ role: 'redo' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'cut' as const },
|
||||||
|
{
|
||||||
|
label: L.copy,
|
||||||
|
accelerator: 'CmdOrCtrl+C',
|
||||||
|
click: () => handlers.onEditCopy(),
|
||||||
|
},
|
||||||
|
{ role: 'paste' as const },
|
||||||
|
{ role: 'pasteAndMatchStyle' as const },
|
||||||
|
{ role: 'delete' as const },
|
||||||
|
{ role: 'selectAll' as const },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { role: 'editMenu' as const },
|
||||||
{
|
{
|
||||||
label: L.view,
|
label: L.view,
|
||||||
submenu: [
|
submenu: [
|
||||||
|
|||||||
@@ -31,7 +31,18 @@ import {
|
|||||||
pbkdf2Sync,
|
pbkdf2Sync,
|
||||||
randomBytes,
|
randomBytes,
|
||||||
} from 'node:crypto';
|
} from 'node:crypto';
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
closeSync,
|
||||||
|
createReadStream,
|
||||||
|
createWriteStream,
|
||||||
|
openSync,
|
||||||
|
readSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { finished, pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
const MAGIC = Buffer.from('FCRYPT01', 'utf8'); // 8 bytes
|
const MAGIC = Buffer.from('FCRYPT01', 'utf8'); // 8 bytes
|
||||||
const VERSION = 0x01;
|
const VERSION = 0x01;
|
||||||
@@ -42,22 +53,29 @@ const TAG_LEN = 16;
|
|||||||
const PBKDF2_ITERATIONS = 200_000;
|
const PBKDF2_ITERATIONS = 200_000;
|
||||||
const HEADER_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN; // magic + version + salt + iv
|
const HEADER_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN; // magic + version + salt + iv
|
||||||
|
|
||||||
|
/** Streaming chunk size — large enough that syscall overhead is noise,
|
||||||
|
* small enough that peak memory stays flat for any project size. */
|
||||||
|
const STREAM_HIGH_WATER_MARK = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt the file at `srcPath` and write the encrypted container to
|
* Encrypt the file at `srcPath` and write the encrypted container to
|
||||||
* `dstPath`. The source file is read into memory in one shot — fine for
|
* `dstPath`. Fully streaming: read → AES-GCM transform → write in 8 MB
|
||||||
* `.seoproject` snapshots which are tens of MB; for hundreds of MB we'd
|
* chunks, so peak memory is O(chunk) no matter how large the project
|
||||||
* want a streaming variant. Throws if the password is empty or the
|
* file is. The previous whole-file-in-a-Buffer version needed
|
||||||
* source file can't be read.
|
* plaintext + ciphertext resident at once — on a multi-GB project
|
||||||
|
* that alone could blow Electron's 4 GB heap cap (Buffers count
|
||||||
|
* against it, unlike plain Node). Throws if the password is empty or
|
||||||
|
* the source file can't be read.
|
||||||
*/
|
*/
|
||||||
export function encryptFile(
|
export async function encryptFile(
|
||||||
srcPath: string,
|
srcPath: string,
|
||||||
dstPath: string,
|
dstPath: string,
|
||||||
password: string,
|
password: string,
|
||||||
): { bytesWritten: number } {
|
): Promise<{ bytesWritten: number }> {
|
||||||
if (!password || password.length === 0) {
|
if (!password || password.length === 0) {
|
||||||
throw new Error('Password is required.');
|
throw new Error('Password is required.');
|
||||||
}
|
}
|
||||||
const plaintext = readFileSync(srcPath);
|
const srcSize = statSync(srcPath).size;
|
||||||
const salt = randomBytes(SALT_LEN);
|
const salt = randomBytes(SALT_LEN);
|
||||||
const iv = randomBytes(IV_LEN);
|
const iv = randomBytes(IV_LEN);
|
||||||
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
||||||
@@ -65,12 +83,21 @@ export function encryptFile(
|
|||||||
const header = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv]);
|
const header = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv]);
|
||||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
cipher.setAAD(header);
|
cipher.setAAD(header);
|
||||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
const out = Buffer.concat([header, ciphertext, tag]);
|
const out = createWriteStream(dstPath);
|
||||||
writeFileSync(dstPath, out);
|
out.write(header);
|
||||||
return { bytesWritten: out.length };
|
// `end: false` keeps the sink open — the GCM auth tag only exists
|
||||||
|
// after the cipher flushes, and it goes at the end of the container.
|
||||||
|
await pipeline(
|
||||||
|
createReadStream(srcPath, { highWaterMark: STREAM_HIGH_WATER_MARK }),
|
||||||
|
cipher,
|
||||||
|
out,
|
||||||
|
{ end: false },
|
||||||
|
);
|
||||||
|
out.end(cipher.getAuthTag());
|
||||||
|
await finished(out);
|
||||||
|
// GCM ciphertext length equals plaintext length.
|
||||||
|
return { bytesWritten: HEADER_LEN + srcSize + TAG_LEN };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,49 +105,91 @@ export function encryptFile(
|
|||||||
* `.seoproject` bytes to `dstPath`. Throws on bad magic, unsupported
|
* `.seoproject` bytes to `dstPath`. Throws on bad magic, unsupported
|
||||||
* version, or an authentication-tag mismatch (wrong password / file
|
* version, or an authentication-tag mismatch (wrong password / file
|
||||||
* tampered with).
|
* tampered with).
|
||||||
|
*
|
||||||
|
* Streaming, with the same all-or-nothing contract as the old
|
||||||
|
* in-memory version: plaintext is streamed into a sibling temp file
|
||||||
|
* and only renamed onto `dstPath` after `decipher.final()` verifies
|
||||||
|
* the GCM tag — so `dstPath` never contains unauthenticated bytes.
|
||||||
*/
|
*/
|
||||||
export function decryptFile(
|
export async function decryptFile(
|
||||||
srcPath: string,
|
srcPath: string,
|
||||||
dstPath: string,
|
dstPath: string,
|
||||||
password: string,
|
password: string,
|
||||||
): { bytesWritten: number } {
|
): Promise<{ bytesWritten: number }> {
|
||||||
if (!password || password.length === 0) {
|
if (!password || password.length === 0) {
|
||||||
throw new Error('Password is required.');
|
throw new Error('Password is required.');
|
||||||
}
|
}
|
||||||
const buf = readFileSync(srcPath);
|
const srcSize = statSync(srcPath).size;
|
||||||
if (buf.length < HEADER_LEN + TAG_LEN) {
|
if (srcSize < HEADER_LEN + TAG_LEN) {
|
||||||
throw new Error('Encrypted file is truncated or not a FreeCrawl encrypted project.');
|
throw new Error('Encrypted file is truncated or not a FreeCrawl encrypted project.');
|
||||||
}
|
}
|
||||||
if (!buf.subarray(0, MAGIC.length).equals(MAGIC)) {
|
// Positional reads for the fixed-size header and the trailing tag —
|
||||||
|
// the ciphertext between them is never held in memory.
|
||||||
|
const header = Buffer.alloc(HEADER_LEN);
|
||||||
|
const tag = Buffer.alloc(TAG_LEN);
|
||||||
|
const fd = openSync(srcPath, 'r');
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
readSync(fd, header, 0, HEADER_LEN, 0) !== HEADER_LEN ||
|
||||||
|
readSync(fd, tag, 0, TAG_LEN, srcSize - TAG_LEN) !== TAG_LEN
|
||||||
|
) {
|
||||||
|
throw new Error('Encrypted file is truncated or not a FreeCrawl encrypted project.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
if (!header.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||||||
throw new Error('File is not a FreeCrawl encrypted project (bad magic).');
|
throw new Error('File is not a FreeCrawl encrypted project (bad magic).');
|
||||||
}
|
}
|
||||||
const version = buf[MAGIC.length];
|
const version = header[MAGIC.length];
|
||||||
if (version !== VERSION) {
|
if (version !== VERSION) {
|
||||||
throw new Error(`Unsupported encrypted-project version: 0x${version?.toString(16) ?? '??'}.`);
|
throw new Error(`Unsupported encrypted-project version: 0x${version?.toString(16) ?? '??'}.`);
|
||||||
}
|
}
|
||||||
const salt = buf.subarray(MAGIC.length + 1, MAGIC.length + 1 + SALT_LEN);
|
const salt = header.subarray(MAGIC.length + 1, MAGIC.length + 1 + SALT_LEN);
|
||||||
const iv = buf.subarray(
|
const iv = header.subarray(
|
||||||
MAGIC.length + 1 + SALT_LEN,
|
MAGIC.length + 1 + SALT_LEN,
|
||||||
MAGIC.length + 1 + SALT_LEN + IV_LEN,
|
MAGIC.length + 1 + SALT_LEN + IV_LEN,
|
||||||
);
|
);
|
||||||
const header = buf.subarray(0, HEADER_LEN);
|
|
||||||
const tag = buf.subarray(buf.length - TAG_LEN);
|
|
||||||
const ciphertext = buf.subarray(HEADER_LEN, buf.length - TAG_LEN);
|
|
||||||
|
|
||||||
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256');
|
||||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||||
decipher.setAAD(header);
|
decipher.setAAD(header);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
let plaintext: Buffer;
|
|
||||||
|
const ciphertextLen = srcSize - HEADER_LEN - TAG_LEN;
|
||||||
|
if (ciphertextLen === 0) {
|
||||||
|
// Degenerate empty-payload container — still verify the tag.
|
||||||
|
try {
|
||||||
|
decipher.final();
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'Could not decrypt — the password is wrong, or the file has been altered.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(dstPath, Buffer.alloc(0));
|
||||||
|
return { bytesWritten: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${dstPath}.decrypt-tmp`;
|
||||||
try {
|
try {
|
||||||
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
await pipeline(
|
||||||
|
createReadStream(srcPath, {
|
||||||
|
start: HEADER_LEN,
|
||||||
|
end: srcSize - TAG_LEN - 1, // `end` is inclusive
|
||||||
|
highWaterMark: STREAM_HIGH_WATER_MARK,
|
||||||
|
}),
|
||||||
|
decipher,
|
||||||
|
createWriteStream(tmpPath),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// GCM auth failure — wrong password or corrupted ciphertext. Don't
|
// GCM auth failure (decipher.final threw inside the pipeline) or
|
||||||
// leak which one; surface a single user-friendly error.
|
// an I/O error — either way, drop the partial output. Don't leak
|
||||||
|
// which failure it was; surface a single user-friendly error.
|
||||||
|
rmSync(tmpPath, { force: true });
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Could not decrypt — the password is wrong, or the file has been altered.',
|
'Could not decrypt — the password is wrong, or the file has been altered.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
writeFileSync(dstPath, plaintext);
|
renameSync(tmpPath, dstPath);
|
||||||
return { bytesWritten: plaintext.length };
|
return { bytesWritten: ciphertextLen };
|
||||||
}
|
}
|
||||||
@@ -471,6 +471,7 @@ const api: FreeCrawlApi = {
|
|||||||
onDone: (cb) => subscribe<CrawlSummary>(IPC.crawlDone, cb),
|
onDone: (cb) => subscribe<CrawlSummary>(IPC.crawlDone, cb),
|
||||||
onError: (cb) => subscribe<string>(IPC.crawlError, cb),
|
onError: (cb) => subscribe<string>(IPC.crawlError, cb),
|
||||||
onMenuEvent: (cb) => subscribe<MenuEvent>(IPC.menuEvent, cb),
|
onMenuEvent: (cb) => subscribe<MenuEvent>(IPC.menuEvent, cb),
|
||||||
|
editCopyNative: (): Promise<void> => ipcRenderer.invoke(IPC.editCopyNative),
|
||||||
onDataChanged: (cb) => {
|
onDataChanged: (cb) => {
|
||||||
const listener = (): void => cb();
|
const listener = (): void => cb();
|
||||||
ipcRenderer.on(IPC.dataChanged, listener);
|
ipcRenderer.on(IPC.dataChanged, listener);
|
||||||
|
|||||||
@@ -228,6 +228,31 @@ export function App() {
|
|||||||
});
|
});
|
||||||
const off4 = window.freecrawl.onMenuEvent((event: MenuEvent) => {
|
const off4 = window.freecrawl.onMenuEvent((event: MenuEvent) => {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
|
case 'edit-copy': {
|
||||||
|
// macOS-only round-trip (see menu.ts): the native menu eats
|
||||||
|
// Cmd+C before the page sees a keydown, so the Copy item
|
||||||
|
// forwards here instead. Re-dispatch a synthetic keydown so
|
||||||
|
// the existing document-level Ctrl/Cmd+C table-copy handlers
|
||||||
|
// (detail panel cells, broken-links cells) run unchanged; if
|
||||||
|
// none of them preventDefault'ed — no table selection — fall
|
||||||
|
// back to the stock native copy for text selections/inputs.
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
const editable =
|
||||||
|
ae !== null &&
|
||||||
|
(ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable);
|
||||||
|
if (!editable) {
|
||||||
|
const synthetic = new KeyboardEvent('keydown', {
|
||||||
|
key: 'c',
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(synthetic);
|
||||||
|
if (synthetic.defaultPrevented) break;
|
||||||
|
}
|
||||||
|
void window.freecrawl.editCopyNative();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'toggle-sidebar':
|
case 'toggle-sidebar':
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export function OverviewSidebar() {
|
|||||||
overviewAppliedRef.current = seq;
|
overviewAppliedRef.current = seq;
|
||||||
setOverview(o);
|
setOverview(o);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Heavy aggregates no longer fall back to the main thread when
|
||||||
|
// the reader worker is down — the call rejects instead. Keep
|
||||||
|
// the previous counts; the next poll tick retries against the
|
||||||
|
// respawned worker.
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
}
|
}
|
||||||
@@ -129,12 +134,17 @@ export function OverviewSidebar() {
|
|||||||
if (bucket === lastRefetchAtRef.current) return;
|
if (bucket === lastRefetchAtRef.current) return;
|
||||||
lastRefetchAtRef.current = bucket;
|
lastRefetchAtRef.current = bucket;
|
||||||
const seq = ++overviewSeqRef.current;
|
const seq = ++overviewSeqRef.current;
|
||||||
void window.freecrawl.overviewGet().then((o) => {
|
void window.freecrawl
|
||||||
if (seq > overviewAppliedRef.current) {
|
.overviewGet()
|
||||||
overviewAppliedRef.current = seq;
|
.then((o) => {
|
||||||
setOverview(o);
|
if (seq > overviewAppliedRef.current) {
|
||||||
}
|
overviewAppliedRef.current = seq;
|
||||||
});
|
setOverview(o);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Reader worker down — keep previous counts, retry next bucket.
|
||||||
|
});
|
||||||
}, [progressCrawled, progressRunning, setOverview]);
|
}, [progressCrawled, progressRunning, setOverview]);
|
||||||
|
|
||||||
const toggle = (k: string) => {
|
const toggle = (k: string) => {
|
||||||
|
|||||||
@@ -452,20 +452,15 @@ const TR_LABELS: Record<string, string> = {
|
|||||||
Pages: 'Sayfalar',
|
Pages: 'Sayfalar',
|
||||||
|
|
||||||
// VisualizationTab layout names + hints
|
// VisualizationTab layout names + hints
|
||||||
'Force-Directed': 'Force-Directed',
|
'3D Force-Directed Crawl Tree': '3D Force-Directed Tarama Ağacı',
|
||||||
'Compound spring embedder': 'Bileşik yay yerleştirici',
|
'Discovery tree in 3D — drag to rotate, scroll to zoom':
|
||||||
'Tree (BFS)': 'Ağaç (BFS)',
|
'3B keşif ağacı — döndürmek için sürükle, yakınlaştırmak için kaydır',
|
||||||
'Roots-to-leaves layered': 'Kök-yaprak katmanlı',
|
'3D Force-Directed Link Graph': '3D Force-Directed Bağlantı Grafiği',
|
||||||
'Force-Directed Tree': 'Force-Directed Ağaç',
|
'Full internal link mesh in 3D': 'Tam iç bağlantı ağı, 3B',
|
||||||
'Sugiyama layered DAG (dagre)': 'Sugiyama katmanlı DAG (dagre)',
|
'Crawl Tree': 'Tarama Ağacı',
|
||||||
'Radial Tree': 'Radyal Ağaç',
|
'Left-to-right labelled discovery tree (2D)': 'Soldan sağa etiketli keşif ağacı (2D)',
|
||||||
'Roots at centre, leaves outward': 'Kök merkezde, yapraklar dışa',
|
|
||||||
'Directory Tree': 'Dizin Ağacı',
|
'Directory Tree': 'Dizin Ağacı',
|
||||||
'Grouped by URL path segments': 'URL yol segmentlerine göre gruplu',
|
'Grouped by URL path segments (2D)': 'URL yol segmentlerine göre gruplu (2D)',
|
||||||
Circle: 'Çember',
|
|
||||||
'Equal radial spacing': 'Eşit radyal boşluk',
|
|
||||||
Concentric: 'Konsantrik',
|
|
||||||
'By inlinks (centre = most-linked)': 'Inlinks\'e göre (merkez = en çok linklenen)',
|
|
||||||
|
|
||||||
// BrokenLinksTab column headers
|
// BrokenLinksTab column headers
|
||||||
Source: 'Kaynak',
|
Source: 'Kaynak',
|
||||||
|
|||||||
@@ -944,6 +944,7 @@
|
|||||||
"color": "Color:",
|
"color": "Color:",
|
||||||
"nodes": "Nodes:",
|
"nodes": "Nodes:",
|
||||||
"labels": "Labels:",
|
"labels": "Labels:",
|
||||||
|
"byCrawl": "Crawl Diagram (Depth × Indexability)",
|
||||||
"byStatus": "By Status",
|
"byStatus": "By Status",
|
||||||
"byDepth": "By Depth",
|
"byDepth": "By Depth",
|
||||||
"byIndexability": "By Indexability",
|
"byIndexability": "By Indexability",
|
||||||
@@ -969,7 +970,8 @@
|
|||||||
"exportSvg": "SVG (vector — Illustrator/Figma)",
|
"exportSvg": "SVG (vector — Illustrator/Figma)",
|
||||||
"exportHtml": "Standalone HTML (shareable)",
|
"exportHtml": "Standalone HTML (shareable)",
|
||||||
"nodesEdgesCount": "{{nodes}} nodes · {{edges}} edges",
|
"nodesEdgesCount": "{{nodes}} nodes · {{edges}} edges",
|
||||||
"interactionHint": "Hover = neighbours · Click = select · Empty click = clear · Double-click node = open · Double-click canvas = fit",
|
"interactionHint": "Hover = summary · Click branch = collapse/expand · Double-click node = open · Double-click canvas = fit",
|
||||||
|
"interactionHint3d": "Drag = rotate · Scroll = zoom · Right-drag = pan · Click = fly to node · Right-click = open",
|
||||||
"loadingGraph": "Loading graph…",
|
"loadingGraph": "Loading graph…",
|
||||||
"emptyGraph": "No URLs crawled yet — start a crawl to populate the graph.",
|
"emptyGraph": "No URLs crawled yet — start a crawl to populate the graph.",
|
||||||
"topAnchorTexts": "Top Anchor Texts",
|
"topAnchorTexts": "Top Anchor Texts",
|
||||||
@@ -988,7 +990,18 @@
|
|||||||
"changesNeedRerun": "Some changes need a layout re-run.",
|
"changesNeedRerun": "Some changes need a layout re-run.",
|
||||||
"rerunLayout": "Re-run layout",
|
"rerunLayout": "Re-run layout",
|
||||||
"title": "Visualization",
|
"title": "Visualization",
|
||||||
"occurrences": "{{count}} occurrences"
|
"occurrences": "{{count}} occurrences",
|
||||||
|
"cardTitle": "Page Title",
|
||||||
|
"cardResponse": "Response Code",
|
||||||
|
"cardIndexability": "Indexability",
|
||||||
|
"cardH1": "H1",
|
||||||
|
"cardH2Count": "H2 Count",
|
||||||
|
"cardDepth": "Crawl Depth",
|
||||||
|
"cardInlinks": "Unique Inlinks",
|
||||||
|
"cardOutlinks": "Unique Outlinks",
|
||||||
|
"cardFollowed": "Followed Outlinks",
|
||||||
|
"cardPctTotal": "% of Total",
|
||||||
|
"cardWordCount": "Word Count"
|
||||||
},
|
},
|
||||||
"imagesTab": {
|
"imagesTab": {
|
||||||
"searchPlaceholder": "Search image URLs / alt…",
|
"searchPlaceholder": "Search image URLs / alt…",
|
||||||
|
|||||||
@@ -944,6 +944,7 @@
|
|||||||
"color": "Renk:",
|
"color": "Renk:",
|
||||||
"nodes": "Düğüm:",
|
"nodes": "Düğüm:",
|
||||||
"labels": "Etiket:",
|
"labels": "Etiket:",
|
||||||
|
"byCrawl": "Tarama Diyagramı (Derinlik × İndekslenebilirlik)",
|
||||||
"byStatus": "Duruma Göre",
|
"byStatus": "Duruma Göre",
|
||||||
"byDepth": "Derinliğe Göre",
|
"byDepth": "Derinliğe Göre",
|
||||||
"byIndexability": "İndekslenebilirliğe Göre",
|
"byIndexability": "İndekslenebilirliğe Göre",
|
||||||
@@ -969,7 +970,8 @@
|
|||||||
"exportSvg": "SVG (vektör — Illustrator/Figma)",
|
"exportSvg": "SVG (vektör — Illustrator/Figma)",
|
||||||
"exportHtml": "Standalone HTML (paylaşılabilir)",
|
"exportHtml": "Standalone HTML (paylaşılabilir)",
|
||||||
"nodesEdgesCount": "{{nodes}} düğüm · {{edges}} kenar",
|
"nodesEdgesCount": "{{nodes}} düğüm · {{edges}} kenar",
|
||||||
"interactionHint": "Hover = komşular · Tıkla = seç · Boş tıklama = temizle · Çift tık düğüm = aç · Çift tık tuval = sığdır",
|
"interactionHint": "Üzerine gel = özet · Dala tıkla = kapat/aç · Çift tık düğüm = aç · Çift tık tuval = sığdır",
|
||||||
|
"interactionHint3d": "Sürükle = döndür · Kaydır = yakınlaştır · Sağ-sürükle = kaydır · Tıkla = düğüme uç · Sağ tık = aç",
|
||||||
"loadingGraph": "Grafik yükleniyor…",
|
"loadingGraph": "Grafik yükleniyor…",
|
||||||
"emptyGraph": "Henüz crawl edilmiş URL yok — grafiği doldurmak için bir crawl başlat.",
|
"emptyGraph": "Henüz crawl edilmiş URL yok — grafiği doldurmak için bir crawl başlat.",
|
||||||
"topAnchorTexts": "En Çok Anchor Metinleri",
|
"topAnchorTexts": "En Çok Anchor Metinleri",
|
||||||
@@ -988,7 +990,18 @@
|
|||||||
"changesNeedRerun": "Bazı değişiklikler layout yeniden çalıştırması gerektirir.",
|
"changesNeedRerun": "Bazı değişiklikler layout yeniden çalıştırması gerektirir.",
|
||||||
"rerunLayout": "Layout'u yeniden çalıştır",
|
"rerunLayout": "Layout'u yeniden çalıştır",
|
||||||
"title": "Görselleştirme",
|
"title": "Görselleştirme",
|
||||||
"occurrences": "{{count}} kez geçiyor"
|
"occurrences": "{{count}} kez geçiyor",
|
||||||
|
"cardTitle": "Sayfa Başlığı",
|
||||||
|
"cardResponse": "Yanıt Kodu",
|
||||||
|
"cardIndexability": "İndekslenebilirlik",
|
||||||
|
"cardH1": "H1",
|
||||||
|
"cardH2Count": "H2 Sayısı",
|
||||||
|
"cardDepth": "Tarama Derinliği",
|
||||||
|
"cardInlinks": "Benzersiz İç Bağlantı",
|
||||||
|
"cardOutlinks": "Benzersiz Dış Bağlantı",
|
||||||
|
"cardFollowed": "Takip Edilen Bağlantı",
|
||||||
|
"cardPctTotal": "Toplamın %'si",
|
||||||
|
"cardWordCount": "Kelime Sayısı"
|
||||||
},
|
},
|
||||||
"imagesTab": {
|
"imagesTab": {
|
||||||
"searchPlaceholder": "Görsel URL / alt ara…",
|
"searchPlaceholder": "Görsel URL / alt ara…",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/mcp-server",
|
"name": "@freecrawl/mcp-server",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# macOS / Apple Silicon Performans Optimizasyonu — Araştırma Raporu
|
||||||
|
|
||||||
|
> **Tarih:** 2026-07-21
|
||||||
|
> **Tetikleyici:** MacBook Air M2 (Mac14,2), macOS 26.1 (25B78) üzerinde 40 dakikalık crawl sonrası
|
||||||
|
> post-crawl fazında main process V8 heap OOM crash'i (`hata.txt` — EXC_BREAKPOINT / brk 0,
|
||||||
|
> `LowMemoryNotification` → `FatalProcessOutOfMemory`, `node::sqlite::StatementSync::All` içinde).
|
||||||
|
> **Kapsam:** FreeCrawl SEO'nun düşük donanımlı Apple Silicon Mac'lerde (8–16 GB unified memory,
|
||||||
|
> fansız MacBook Air) darboğazsız çalışması.
|
||||||
|
>
|
||||||
|
> **Kanıt durumu:** 26 kaynak tarandı, 125 iddia çıkarıldı. Doğrulama fazının bir kısmı oturum
|
||||||
|
> limitine takıldığından 25 iddianın 6'sı 3-0 çapraz doğrulamadan geçti, 3'ü çürütüldü (rapora
|
||||||
|
> alınmadı), kalanlar kaynak alıntılı ancak tek kaynaklı — ⚠️ ile işaretli.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. En kritik bulgu: Crash'in tavanı RAM değil, Electron'un 4 GB V8 kafesi ✅
|
||||||
|
|
||||||
|
**hata.txt'deki çökme 8 GB'lık Air'de değil, 64 GB'lık Mac Studio'da da aynen yaşanırdı.**
|
||||||
|
|
||||||
|
- Electron 14'ten beri **pointer compression** etkin: V8 heap'i process başına **maksimum 4 GB**
|
||||||
|
ile sınırlı — makinedeki fiziksel RAM'den bağımsız.
|
||||||
|
([electronjs.org/blog/v8-memory-cage](https://www.electronjs.org/blog/v8-memory-cage)) ✅ 3-0
|
||||||
|
- Electron 21'den beri **sandboxed pointers** de etkin; crash raporundaki
|
||||||
|
`Memory Tag 255 = 1.3 TB` bu sandbox'ın **sanal adres rezervasyonu** — gerçek RAM tüketimi
|
||||||
|
değil, zararsız. ([v8.dev/blog/sandbox](https://v8.dev/blog/sandbox)) ✅ 3-0 (rezervasyon boyutu ⚠️)
|
||||||
|
- ⚠️ `--max-old-space-size` **işe yaramaz**: pointer-compression kafesi flag'i ezer;
|
||||||
|
`app.commandLine.appendSwitch('js-flags', ...)` main process için no-op (isolate zaten
|
||||||
|
yaratılmış). Maintainer'lara göre runtime workaround yok; tek çıkış özel Electron derlemesi.
|
||||||
|
([electron#31330](https://github.com/electron/electron/issues/31330))
|
||||||
|
- ⚠️ **En sinsi detay:** Electron'da ArrayBuffer/Buffer verisi de Node.js'in aksine kafes
|
||||||
|
**içinde** tutuluyor (maintainer nornagon'un tespiti). "Büyük veriyi Buffer'a al, heap
|
||||||
|
rahatlasın" taktiği Electron'da **çalışmaz**.
|
||||||
|
- ⚠️ **Mimari sonuç:** `worker_threads` aynı process'te yaşadığından main isolate + reader
|
||||||
|
worker'lar + writer worker'lar **tek 4 GB kafesi paylaşıyor**. Worker havuzları CPU
|
||||||
|
paralelliği sağlıyor ama bellek tavanını genişletmiyor; worker→main `postMessage` structured
|
||||||
|
clone anlık olarak veriyi ikiye katlıyor.
|
||||||
|
|
||||||
|
**Windows/Intel kafasından fark:** "Page file büyür, idare eder" varsayımı Electron'da her
|
||||||
|
platformda geçersiz — kafes 4 GB. Düşük RAM'li Mac bunu sadece daha erken hissettirir
|
||||||
|
(compression/swap baskısı kafes OOM'undan önce başlar).
|
||||||
|
|
||||||
|
**Gerçek çözüm — veri hacmini heap'e hiç sokmamak:**
|
||||||
|
- `StatementSync.prototype.iterate()` — tam bu amaçla eklendi, Node v22.13.0'a backport edildi,
|
||||||
|
Electron 41.2.2'nin Node 22.x'inde mevcut.
|
||||||
|
([nodejs.org/api/sqlite.html](https://nodejs.org/api/sqlite.html),
|
||||||
|
[nodejs/node#54213](https://github.com/nodejs/node/pull/54213))
|
||||||
|
- Gerçekten büyük heap gerektiren işler için maintainer önerisi: işi Electron dışına taşımak.
|
||||||
|
Pratik karşılığı **`utilityProcess`** — ayrı OS process = ayrı 4 GB kafes + ayrı crash domain'i.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `procRole: Background` — App Nap + E-core hapsi ✅
|
||||||
|
|
||||||
|
Crash raporundaki `Role: Background` tesadüf değil; crawl arka planda çalışıyordu:
|
||||||
|
|
||||||
|
- **App Nap** koşulları: ön planda değil + görünür içerik güncellenmiyor + ses yok + assertion
|
||||||
|
alınmamış → aday. ✅ 3-0
|
||||||
|
- App Nap üç mekanizmayla vurur: ✅ 3-0
|
||||||
|
1. **Priority reduction** → daha az CPU payı
|
||||||
|
2. **Timer throttling** → `setInterval`/`setTimeout` tabanlı PQueue iş dağıtımı seyrekleşir
|
||||||
|
3. **I/O throttling** → SQLite disk yazışları kısılır
|
||||||
|
- Resmi çıkış: NSProcessInfo activity assertion → sistem App Nap'e sokmaz. ✅ 3-0
|
||||||
|
Electron karşılığı: `powerSaveBlocker.start('prevent-app-suspension')`.
|
||||||
|
([Apple App Nap docs](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/AppNap.html))
|
||||||
|
- ⚠️ Ödünleşim: `prevent-app-suspension` sistem uykusunu da engeller; "sadece App Nap'i engelle"
|
||||||
|
granülerliği Electron'da yok ('not planned' kapatıldı). Crawl aktifken aç, bitince `stop()`.
|
||||||
|
- ⚠️ **E-core hapsi** (Apple Silicon'a özgü): QoS `background` (9) thread'ler **yalnızca
|
||||||
|
E-core'larda** çalışır; P-core'lar boşta olsa bile terfi yok, dışarıdan terfi de mümkün değil.
|
||||||
|
([eclecticlight.co](https://eclecticlight.co/2024/12/17/tune-for-performance-core-types/))
|
||||||
|
- ⚠️ App Nap'e girmiş uygulamalar bellek baskısında **compression/swap için öncelikli hedef**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fansız Air'de sustained load — termal adaptasyon ⚠️
|
||||||
|
|
||||||
|
- macOS termal durum API'si: `nominal / fair / serious / critical`. Apple önerisi: `fair`'de
|
||||||
|
proaktif azalt, `serious`'ta CPU+I/O düşür, `critical`'de minimuma in.
|
||||||
|
([Apple thermal docs](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/RespondToThermalStateChanges.html))
|
||||||
|
- **Native modül gerekmez:** Electron 41.2.2'de `powerMonitor.getCurrentThermalState()` ve
|
||||||
|
`'thermal-state-change'` event'i mevcut (electron.d.ts'ten doğrulandı).
|
||||||
|
- Uyarı: desteklenmeyen sistemler her zaman `nominal` döndürür → tek sinyal olarak kullanma;
|
||||||
|
mevcut adaptive-throttle (yanıt süresi bazlı) ile birleştir.
|
||||||
|
- Önerilen merdiven: `fair` → concurrency %75, `serious` → %50, `critical` → minimum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SQLite × APFS — Windows'tan gerçekten farklı davranan katman ⚠️
|
||||||
|
|
||||||
|
- **macOS'ta `fsync()` dayanıklılık garantisi vermez** — güç kesintisi garantisi için
|
||||||
|
`F_FULLFSYNC` gerekir; o da çok pahalı. Apple'ın sistem SQLite'ı bile `fullfsync=on`'u sessizce
|
||||||
|
`F_BARRIERFSYNC`'e çevirir.
|
||||||
|
([mjtsai.com](https://mjtsai.com/blog/2025/09/05/sqlite-on-macos-not-acid/),
|
||||||
|
[avi.im/blag/2025/sqlite-fsync](https://avi.im/blag/2025/sqlite-fsync/))
|
||||||
|
- Crawl projesi için doğru nokta: **`WAL` + `synchronous=NORMAL`** — corruption-safe, fsync
|
||||||
|
yalnızca checkpoint'te; kaybedilebilecek şey son commit'ler (yeniden fetch edilebilir veri).
|
||||||
|
`fullfsync` açma.
|
||||||
|
- **WAL sınırsız büyüyebilir:** 40+ dk sürekli yazan crawl'da `wal_autocheckpoint` +
|
||||||
|
faz aralarında `PRAGMA wal_checkpoint(TRUNCATE)`.
|
||||||
|
([phiresky](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/))
|
||||||
|
- `mmap_size` (256 MB–1 GB): read syscall'larını page-cache mapping'e çevirir; yalnızca sanal
|
||||||
|
bellek rezerve eder.
|
||||||
|
- **Apple Silicon 16 KB VM sayfası kullanır** — 1 MB'lık TEXT blob'lar için `page_size=8192/16384`
|
||||||
|
düşünülebilir (yeni DB'lerde; mevcutlara VACUUM gerekir).
|
||||||
|
- Sorgu hijyeni: body kolonunu list/aggregate sorgularından dışla; Google eşiği "4 KB'ın birkaç
|
||||||
|
katı üstü dosyaya" — 1 MB gövdeler için uzun vadede dosya + path kolonu meşru seçenek.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. macOS bellek yönetimi — düşük RAM'li Mac'te ne olur? ⚠️
|
||||||
|
|
||||||
|
- Kademeli davranış: önce **memory compression** (10.9+), sonra **swap**; process kill son çare
|
||||||
|
ve önce düşük öncelik bandı. Jetsam'in masaüstü agresifliği konusunda kaynaklar çelişkili
|
||||||
|
(iki karşıt iddia da çürütüldü) — net olan: **bizim crash sistem kill'i değil, V8'in 4 GB
|
||||||
|
tavanına çarpması** (`LowMemoryNotification` → abort zinciri kanıtı).
|
||||||
|
- `DISPATCH_SOURCE_TYPE_MEMORYPRESSURE` (10.9+) native baskı bildirimi verir; native köprüsüz
|
||||||
|
pratik alternatif: büyük materialization öncesi `v8.getHeapStatistics()` headroom kontrolü.
|
||||||
|
- Unified memory'de RAM aynı zamanda VRAM: 8 GB Air'de efektif bellek nominalden az.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. macOS 26 (Tahoe) — bilinen Electron sorunları ⚠️
|
||||||
|
|
||||||
|
| Sorun | Durum | FreeCrawl'a etkisi |
|
||||||
|
|---|---|---|
|
||||||
|
| `_cornerMask` private-API override → WindowServer GPU spike, sistem geneli kasma | Electron 36+'ya backport (PR #48376, 2025-09-26) ile düzeltildi | **Yok** — 41.2.2 düzeltmeyi içerir ([theregister](https://www.theregister.com/2025/10/02/macos_26_electron_slowdown/), [#48311](https://github.com/electron/electron/issues/48311)) |
|
||||||
|
| MAS/App Sandbox + arm64 + macOS 26'da başlangıçta `EXC_BREAKPOINT` (MAP_JIT sıkılaşması hipotezi) | Açık; Apple DTS "JIT rehberi değişmedi" diyor, maintainer onayı yok | **App Store dağıtımı planlanırsa kritik**; sandbox'sız DMG'de üremiyor ([#51351](https://github.com/electron/electron/issues/51351), [Apple forums](https://developer.apple.com/forums/thread/821584)) |
|
||||||
|
| 26.2'de Electron uygulamalarının crash log'suz SIGKILL'lenmesi | Tek kullanıcı raporu, 'not planned', doğrulanmamış | İzlenmeli ([#49261](https://github.com/electron/electron/issues/49261)) |
|
||||||
|
| AutoFill (`NSAutoFillHeuristicController`) scroll yavaşlığı | 26.0.1'de kısmen düzeltildi | Düşük |
|
||||||
|
|
||||||
|
**Teşhis ipucu:** Bu regresyon sınıfının istisna imzası bizimkiyle aynı (`EXC_BREAKPOINT` /
|
||||||
|
brk 0, V8 içinde). Ayrım: OOM'da stack'te `LowMemoryNotification` + `node::sqlite` frame'leri
|
||||||
|
var ve çökme dakikalar sonra; JIT regresyonunda çökme başlangıçta ve JIT/compiler thread'inde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Eylem planı ve durum
|
||||||
|
|
||||||
|
| # | Öncelik | İş | Durum |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | P0 | `recomputeBoilerplateCoverage`: `.all(sampleSize)` → 64'lük keyset batch + `SUBSTR(body,1,200000)`; `dbCall` ile writer worker'a taşındı (tepe bellek ~2–4 GB → ~25 MB) | ✅ 2026-07-21 |
|
||||||
|
| 2 | P0 | Diğer toplu `url_sources.body` okuyucuları tarandı — `seoTextCorpus` body'yi zaten dışlıyor, duplicate clustering yalnızca `simhash` okuyor; başka toplu okuyucu yok | ✅ 2026-07-21 |
|
||||||
|
| 3 | P0 | Crawl sırasında `powerSaveBlocker('prevent-app-suspension')` — ref-count'lu, `done` + yeni `stopped` event'iyle bırakılıyor | ✅ 2026-07-21 |
|
||||||
|
| 4 | P1 | `callReaderOrFallback`: ağır metotlarda (HEAVY_METHODS) main-thread fallback kaldırıldı — rejection döner, sidebar eski sayıları korur ve sonraki tick'te yeniden dener | ✅ 2026-07-21 |
|
||||||
|
| 5 | P1 | Termal-adaptif concurrency: `Crawler.setThrottleScale` (dış tavan, lag-adaptasyonuyla kompoze) + macOS'ta `powerMonitor` `thermal-state-change` → nominal 1 / fair 0.75 / serious 0.5 / critical 0.25; crawl başlangıcında mevcut durum da uygulanır | ✅ 2026-07-21 |
|
||||||
|
| 6 | P1 | SQLite pragma seti — inceleme sonucu **zaten mevcuttu** (`WAL`, `synchronous=NORMAL`, `mmap_size=30GB`, `wal_autocheckpoint=2000`, `busy_timeout`, `temp_store=MEMORY`); eksik olan tek parça eklendi: post-crawl `wal_checkpoint(TRUNCATE)` (writer worker üzerinden) | ✅ 2026-07-21 |
|
||||||
|
| 7 | P2 | DB worker'ları `worker_threads` → `utilityProcess` (ayrı 4 GB kafes + crash izolasyonu) — **bilinçli ertelendi** (2026-07-21): crawl yazma yolunu değiştiren en riskli iş, gerçek crawl smoke-testleriyle doğrulanabileceği ayrı bir oturuma bırakıldı; watchdog SAB'ı cross-process çalışmadığından heartbeat'lerin mesaj tabanlıya çevrilmesi de gerekecek | ⏸ |
|
||||||
|
| 8 | P2 | Heap headroom guard: `ensureHeapHeadroom` (core, `v8.getHeapStatistics` tabanlı, 256 MB rezerv) — XLSX export satır biriktirme döngüsü (50K'da bir) + workbook build öncesi + Log Analyzer export'una uygulandı; OOM abort yerine anlaşılır "use CSV" hatası | ✅ 2026-07-21 |
|
||||||
|
| 9 | P2 | Sorgu/export hijyeni: (a) `iterateUrlsByCategory` + `iterateIndexableUrls` **sahte streamer'dı** (içeride `.all()`) — keyset-paginated gerçek streaming'e çevrildi (1M-URL CSV export tepe belleği ~yüzlerce MB → sabit); (b) `queryUrls`'a server-side limit clamp (≤10K); (c) `SELECT *` bilinçli korundu — `urls`'ta sınırsız kolon yok, mapping tüm kolonları tüketiyor, 187 kolonluk el listesi drift riski; (d) bonus: proje şifreleme/çözme tam-dosya-Buffer'dan 8 MB streaming'e çevrildi (format aynı; 9 senaryoluk round-trip testiyle doğrulandı) | ✅ 2026-07-21 |
|
||||||
|
| 10 | P2 | Uzun vade: `url_sources.body`'yi DB dışına (dosya + path) taşıma — şema migrasyonu + tüm okuma/yazma yollarını değiştiren büyük iş; P0–P2 sonrası acil bellek baskısı kalmadığından ihtiyaç doğana kadar ertelendi | ⏸ |
|
||||||
Generated
+376
-8
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "freecrawl-seo",
|
"name": "freecrawl-seo",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "freecrawl-seo",
|
"name": "freecrawl-seo",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"apps/cli": {
|
"apps/cli": {
|
||||||
"name": "@freecrawl/cli",
|
"name": "@freecrawl/cli",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@freecrawl/core": "*",
|
"@freecrawl/core": "*",
|
||||||
"@freecrawl/db": "*",
|
"@freecrawl/db": "*",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@freecrawl/desktop",
|
"name": "@freecrawl/desktop",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"@tanstack/react-virtual": "^3.10.0",
|
"@tanstack/react-virtual": "^3.10.0",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
|
"3d-force-graph": "^1.80.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
"cytoscape-dagre": "^2.5.0",
|
"cytoscape-dagre": "^2.5.0",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"apps/mcp-server": {
|
"apps/mcp-server": {
|
||||||
"name": "@freecrawl/mcp-server",
|
"name": "@freecrawl/mcp-server",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@freecrawl/db": "*",
|
"@freecrawl/db": "*",
|
||||||
"@freecrawl/shared-types": "*"
|
"@freecrawl/shared-types": "*"
|
||||||
@@ -2218,6 +2219,12 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "25.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||||
|
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2743,6 +2750,22 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/3d-force-graph": {
|
||||||
|
"version": "1.80.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz",
|
||||||
|
"integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accessor-fn": "1",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"three": ">=0.179 <1",
|
||||||
|
"three-forcegraph": "1",
|
||||||
|
"three-render-objects": "^1.41"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/7zip-bin": {
|
"node_modules/7zip-bin": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
@@ -2760,6 +2783,15 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/accessor-fn": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3929,6 +3961,165 @@
|
|||||||
"cytoscape": "^3.2.22"
|
"cytoscape": "^3.2.22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-binarytree": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-force-3d": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-binarytree": "1",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-octree": "1",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-octree": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dagre": {
|
"node_modules/dagre": {
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
@@ -3939,6 +4130,18 @@
|
|||||||
"lodash": "^4.17.15"
|
"lodash": "^4.17.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-bind-mapper": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accessor-fn": "1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -5110,6 +5313,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/float-tooltip": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"preact": "10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
@@ -5745,6 +5962,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
@@ -5959,6 +6185,18 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kapsule": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash-es": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6032,6 +6270,12 @@
|
|||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -6289,6 +6533,44 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ngraph.events": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/ngraph.forcelayout": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"ngraph.events": "^1.0.0",
|
||||||
|
"ngraph.merge": "^1.0.0",
|
||||||
|
"ngraph.random": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngraph.graph": {
|
||||||
|
"version": "20.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
|
||||||
|
"integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"ngraph.events": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngraph.merge": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ngraph.random": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-abi": {
|
||||||
"version": "4.28.0",
|
"version": "4.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
|
||||||
@@ -6822,6 +7104,18 @@
|
|||||||
"node": ">=10.4.0"
|
"node": ">=10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/polished": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.17.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
@@ -7014,6 +7308,24 @@
|
|||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||||
|
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -8015,6 +8327,56 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.185.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||||
|
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/three-forcegraph": {
|
||||||
|
"version": "1.43.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.4.tgz",
|
||||||
|
"integrity": "sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accessor-fn": "1",
|
||||||
|
"d3-array": "1 - 3",
|
||||||
|
"d3-force-3d": "2 - 3",
|
||||||
|
"d3-scale": "1 - 4",
|
||||||
|
"d3-scale-chromatic": "1 - 3",
|
||||||
|
"data-bind-mapper": "1",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"ngraph.forcelayout": "3",
|
||||||
|
"ngraph.graph": "20",
|
||||||
|
"tinycolor2": "1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.118.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/three-render-objects": {
|
||||||
|
"version": "1.42.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz",
|
||||||
|
"integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tweenjs/tween.js": "18 - 25",
|
||||||
|
"accessor-fn": "1",
|
||||||
|
"float-tooltip": "^1.7",
|
||||||
|
"kapsule": "^1.16",
|
||||||
|
"polished": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.179"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-async-pool": {
|
"node_modules/tiny-async-pool": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||||
@@ -8035,6 +8397,12 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinycolor2": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -9070,7 +9438,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@freecrawl/core",
|
"name": "@freecrawl/core",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@freecrawl/db": "*",
|
"@freecrawl/db": "*",
|
||||||
"@freecrawl/shared-types": "*",
|
"@freecrawl/shared-types": "*",
|
||||||
@@ -9099,7 +9467,7 @@
|
|||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@freecrawl/db",
|
"name": "@freecrawl/db",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@freecrawl/shared-types": "*"
|
"@freecrawl/shared-types": "*"
|
||||||
},
|
},
|
||||||
@@ -9111,7 +9479,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shared-types": {
|
"packages/shared-types": {
|
||||||
"name": "@freecrawl/shared-types",
|
"name": "@freecrawl/shared-types",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "freecrawl-seo",
|
"name": "freecrawl-seo",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
|
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/core",
|
"name": "@freecrawl/core",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ import { runBrowserLogin } from './browser-login.js';
|
|||||||
export interface CrawlerEvents {
|
export interface CrawlerEvents {
|
||||||
progress: (p: CrawlProgress) => void;
|
progress: (p: CrawlProgress) => void;
|
||||||
done: (summary: CrawlSummary) => void;
|
done: (summary: CrawlSummary) => void;
|
||||||
|
/**
|
||||||
|
* Fired exactly once when `stop()` transitions the crawler into the
|
||||||
|
* stopped state. `done` is deliberately suppressed after a stop (a
|
||||||
|
* zombie crawler's done-event would clobber the next crawl's UI
|
||||||
|
* state), so hosts that need a "this crawl is over, whatever the
|
||||||
|
* reason" signal must listen to BOTH `done` and `stopped` — e.g. the
|
||||||
|
* desktop app's power-save blocker release.
|
||||||
|
*/
|
||||||
|
stopped: () => void;
|
||||||
error: (message: string) => void;
|
error: (message: string) => void;
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
@@ -875,11 +884,20 @@ export class Crawler extends EventEmitter {
|
|||||||
// clustering because both are content-similarity passes; surfaces the
|
// clustering because both are content-similarity passes; surfaces the
|
||||||
// repeated chrome (nav, footer, sidebar) that dilutes thin-content
|
// repeated chrome (nav, footer, sidebar) that dilutes thin-content
|
||||||
// detection. Memory-bounded, skipped when no body snapshots are stored.
|
// detection. Memory-bounded, skipped when no body snapshots are stored.
|
||||||
|
// Routed through dbCall (writer worker on desktop) rather than the
|
||||||
|
// main-thread ProjectDb: the pass reads thousands of body snapshots
|
||||||
|
// and its JS-string churn belongs off the UI-serving thread — a
|
||||||
|
// main-thread run of this exact pass was the post-crawl V8 OOM
|
||||||
|
// crash on macOS (Electron's 4 GB pointer-compression heap cap).
|
||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
this.emit('info', 'Detecting boilerplate coverage…');
|
this.emit('info', 'Detecting boilerplate coverage…');
|
||||||
this.setOp('post-crawl:boilerplate-coverage');
|
this.setOp('post-crawl:boilerplate-coverage');
|
||||||
try {
|
try {
|
||||||
const result = this.db.recomputeBoilerplateCoverage();
|
const result = await this.dbCall<{
|
||||||
|
sampled: number;
|
||||||
|
boilerplateShingles: number;
|
||||||
|
pagesAboveHighThreshold: number;
|
||||||
|
}>('recomputeBoilerplateCoverage', []);
|
||||||
if (result.sampled > 0) {
|
if (result.sampled > 0) {
|
||||||
this.emit(
|
this.emit(
|
||||||
'info',
|
'info',
|
||||||
@@ -929,6 +947,20 @@ export class Crawler extends EventEmitter {
|
|||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
this.setOp('post-crawl:performance-budget');
|
this.setOp('post-crawl:performance-budget');
|
||||||
this.runBudgetPass();
|
this.runBudgetPass();
|
||||||
|
// Fold the WAL back into the main DB file now that the write burst
|
||||||
|
// is over. wal_autocheckpoint bounds WAL growth *during* the crawl;
|
||||||
|
// this TRUNCATE pass reclaims the file afterwards so the project
|
||||||
|
// on disk is compact and the first post-crawl reader queries don't
|
||||||
|
// pay WAL-frame lookup overhead. Matters double on macOS/APFS
|
||||||
|
// where checkpoint fsyncs are the expensive operation — better one
|
||||||
|
// deliberate pass here than implicit ones during the next crawl.
|
||||||
|
await yieldToEventLoop();
|
||||||
|
this.setOp('post-crawl:wal-checkpoint');
|
||||||
|
try {
|
||||||
|
await this.dbCall<void>('walCheckpoint', []);
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — a reader mid-query can block the checkpoint */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1944,6 +1976,9 @@ export class Crawler extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
// Emit 'stopped' only on the first transition — clearCrawlDb and
|
||||||
|
// the Stop IPC handler can both call stop() on the same instance.
|
||||||
|
const wasStopped = this.stopped;
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
@@ -1985,6 +2020,7 @@ export class Crawler extends EventEmitter {
|
|||||||
this.externalQueue.clear();
|
this.externalQueue.clear();
|
||||||
this.queue.start();
|
this.queue.start();
|
||||||
this.externalQueue.start();
|
this.externalQueue.start();
|
||||||
|
if (!wasStopped) this.emit('stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
pause(): void {
|
pause(): void {
|
||||||
@@ -3736,12 +3772,53 @@ export class Crawler extends EventEmitter {
|
|||||||
private currentConcurrency = 0;
|
private currentConcurrency = 0;
|
||||||
private lastAdaptTs = 0;
|
private lastAdaptTs = 0;
|
||||||
private static readonly ADAPT_COOLDOWN_MS = 2_000;
|
private static readonly ADAPT_COOLDOWN_MS = 2_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Externally imposed concurrency scale, 0.1–1. Fed by the desktop
|
||||||
|
* host from OS pressure signals — today macOS thermal state
|
||||||
|
* (nominal 1 / fair 0.75 / serious 0.5 / critical 0.25), by design
|
||||||
|
* open to other sources (memory pressure) later. Scales the ceiling
|
||||||
|
* the lag-adaptive loop is allowed to reach, so the two mechanisms
|
||||||
|
* compose instead of fighting: shrink is applied immediately
|
||||||
|
* (Apple's guidance — react to `serious` at once, the OS is already
|
||||||
|
* enacting countermeasures), recovery is gradual via
|
||||||
|
* `reportRendererLag`'s +1-per-cooldown growth once the ceiling
|
||||||
|
* lifts.
|
||||||
|
*/
|
||||||
|
private throttleScale = 1;
|
||||||
|
|
||||||
|
/** Configured ceiling with the external throttle scale applied. */
|
||||||
|
private effectiveCeiling(): number {
|
||||||
|
const base = Math.max(1, Math.min(200, this.config.maxConcurrency));
|
||||||
|
return Math.max(1, Math.round(base * this.throttleScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public — the desktop main process maps thermal state changes here. */
|
||||||
|
setThrottleScale(scale: number, reason: string): void {
|
||||||
|
const s = Math.min(1, Math.max(0.1, scale));
|
||||||
|
if (s === this.throttleScale) return;
|
||||||
|
this.throttleScale = s;
|
||||||
|
const ceiling = this.effectiveCeiling();
|
||||||
|
if (this.currentConcurrency === 0 || this.currentConcurrency > ceiling) {
|
||||||
|
this.currentConcurrency = ceiling;
|
||||||
|
this.queue.concurrency = ceiling;
|
||||||
|
}
|
||||||
|
// External probes get the same cut. Constructor band is 2..10;
|
||||||
|
// under throttle we additionally allow dropping to 1 so a
|
||||||
|
// `critical` state really does quiesce the network.
|
||||||
|
this.externalQueue.concurrency = Math.max(1, Math.min(10, ceiling));
|
||||||
|
this.emit(
|
||||||
|
'info',
|
||||||
|
`Throttle: concurrency ceiling → ${ceiling}/${Math.max(1, Math.min(200, this.config.maxConcurrency))} (${reason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Public so the desktop main process can pipe in renderer Lag reports. */
|
/** Public so the desktop main process can pipe in renderer Lag reports. */
|
||||||
reportRendererLag(lagMs: number): void {
|
reportRendererLag(lagMs: number): void {
|
||||||
if (!this.running || this.paused) return;
|
if (!this.running || this.paused) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastAdaptTs < Crawler.ADAPT_COOLDOWN_MS) return;
|
if (now - this.lastAdaptTs < Crawler.ADAPT_COOLDOWN_MS) return;
|
||||||
const ceiling = Math.max(1, Math.min(200, this.config.maxConcurrency));
|
const ceiling = this.effectiveCeiling();
|
||||||
if (this.currentConcurrency === 0) this.currentConcurrency = ceiling;
|
if (this.currentConcurrency === 0) this.currentConcurrency = ceiling;
|
||||||
let next = this.currentConcurrency;
|
let next = this.currentConcurrency;
|
||||||
// Floor 5 (was 1). The renderer's lag probe occasionally spikes
|
// Floor 5 (was 1). The renderer's lag probe occasionally spikes
|
||||||
@@ -3750,8 +3827,10 @@ export class Crawler extends EventEmitter {
|
|||||||
// single-task dispatch — which on a slow remote server (avg resp
|
// single-task dispatch — which on a slow remote server (avg resp
|
||||||
// 1.5-2 s) cuts throughput from 10 URL/s to 0.5 URL/s and the
|
// 1.5-2 s) cuts throughput from 10 URL/s to 0.5 URL/s and the
|
||||||
// queue never recovers because the user never produces zero lag.
|
// queue never recovers because the user never produces zero lag.
|
||||||
|
// The floor never exceeds the throttled ceiling — under thermal
|
||||||
|
// pressure the external cap wins.
|
||||||
if (lagMs > 200) {
|
if (lagMs > 200) {
|
||||||
next = Math.max(5, this.currentConcurrency - 1);
|
next = Math.max(Math.min(5, ceiling), this.currentConcurrency - 1);
|
||||||
} else if (lagMs < 30) {
|
} else if (lagMs < 30) {
|
||||||
next = Math.min(ceiling, this.currentConcurrency + 1);
|
next = Math.min(ceiling, this.currentConcurrency + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* JS-heap headroom guard for large single-shot materialisations.
|
||||||
|
*
|
||||||
|
* Electron enables V8 pointer compression, which hard-caps every
|
||||||
|
* process's JS heap at 4 GB regardless of machine RAM — and unlike
|
||||||
|
* plain Node, Buffer/ArrayBuffer data counts against the same cage.
|
||||||
|
* When V8 can't allocate it doesn't throw a catchable error; it aborts
|
||||||
|
* the whole process (`FatalProcessOutOfMemory`). So any code path that
|
||||||
|
* is about to build a large in-memory artefact (an XLSX workbook, a
|
||||||
|
* whole-file encryption buffer) must check headroom BEFORE allocating
|
||||||
|
* and fail with a normal, user-visible error instead of a crash.
|
||||||
|
*
|
||||||
|
* The check is advisory — `estimatedBytes` is a rough upper bound, and
|
||||||
|
* a healthy RESERVE is kept free for GC churn and concurrent
|
||||||
|
* allocations. Callers on genuinely unbounded paths should prefer
|
||||||
|
* streaming (see tabular-export's CSV writer) and use this guard only
|
||||||
|
* where the format itself forces full materialisation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import v8 from 'node:v8';
|
||||||
|
|
||||||
|
/** Slack kept free below the heap limit — GC needs breathing room, and
|
||||||
|
* other subsystems (IPC buffers, SQLite rows in flight) allocate
|
||||||
|
* concurrently with the guarded operation. */
|
||||||
|
const RESERVE_BYTES = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Bytes currently available before the hard heap ceiling. */
|
||||||
|
export function heapHeadroomBytes(): number {
|
||||||
|
const s = v8.getHeapStatistics();
|
||||||
|
return Math.max(0, s.heap_size_limit - s.used_heap_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw a descriptive Error when `estimatedBytes` more allocation
|
||||||
|
* would push the heap within RESERVE of its hard limit. No-op when
|
||||||
|
* there is room. `label` names the operation in the error message the
|
||||||
|
* user sees (e.g. "XLSX export").
|
||||||
|
*/
|
||||||
|
export function ensureHeapHeadroom(label: string, estimatedBytes: number): void {
|
||||||
|
const available = heapHeadroomBytes();
|
||||||
|
if (estimatedBytes + RESERVE_BYTES <= available) return;
|
||||||
|
throw new Error(
|
||||||
|
`${label}: not enough memory headroom — needs ~${Math.ceil(estimatedBytes / MB)} MB ` +
|
||||||
|
`but only ${Math.floor(available / MB)} MB of JS heap remains ` +
|
||||||
|
`(hard limit ${Math.round(v8.getHeapStatistics().heap_size_limit / MB)} MB). ` +
|
||||||
|
'Try a streaming format (CSV/JSON) or export a smaller selection.',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -922,7 +922,18 @@ export function parseHtml(
|
|||||||
}
|
}
|
||||||
const mixedContentCount = mixedContentActive + mixedContentPassive;
|
const mixedContentCount = mixedContentActive + mixedContentPassive;
|
||||||
|
|
||||||
const text = $('body').text().replace(/\s+/g, ' ').trim();
|
// Visible body prose. `$('body').text()` alone also returns the text
|
||||||
|
// nodes *inside* `<script>` / `<style>` / `<noscript>`, so on a
|
||||||
|
// JS-heavy page the minified bundle is counted as content: a real
|
||||||
|
// 400-word product page measured 12,900 "words", and every readability
|
||||||
|
// score was computed over JavaScript source. Strip those subtrees
|
||||||
|
// first — the same thing `textCodeRatio` does below, for the same
|
||||||
|
// reason. Note this deliberately keeps nav / header / footer copy
|
||||||
|
// (unlike `extractMainContentText`) so thin-content thresholds keep
|
||||||
|
// measuring the whole rendered page.
|
||||||
|
const $visibleBody = $('body').clone();
|
||||||
|
$visibleBody.find('script, style, noscript').remove();
|
||||||
|
const text = $visibleBody.text().replace(/\s+/g, ' ').trim();
|
||||||
const wordCount = text.length > 0 ? text.split(' ').filter(Boolean).length : 0;
|
const wordCount = text.length > 0 ? text.split(' ').filter(Boolean).length : 0;
|
||||||
const readability = computeReadability(text, wordCount);
|
const readability = computeReadability(text, wordCount);
|
||||||
|
|
||||||
@@ -1008,19 +1019,14 @@ export function parseHtml(
|
|||||||
const hasNofollow = metaRobots !== null && metaRobots.includes('nofollow');
|
const hasNofollow = metaRobots !== null && metaRobots.includes('nofollow');
|
||||||
|
|
||||||
// Text/code ratio — visible body text bytes divided by raw HTML bytes,
|
// Text/code ratio — visible body text bytes divided by raw HTML bytes,
|
||||||
// rounded to integer percent. Cheap to compute right next to the body
|
// rounded to integer percent. Reuses the script/style/noscript-stripped
|
||||||
// text-length pass below. We strip <script>/<style>/<noscript> contents
|
// `text` computed above: their inner text isn't user-facing content and
|
||||||
// out of the visible-text count because their inner text isn't user-
|
// would inflate the ratio on JS-heavy SPAs that ship a 200 KB script tag
|
||||||
// facing content (and would inflate the ratio on JS-heavy SPAs that
|
// with no markup. Null when the HTML body byte count is zero.
|
||||||
// ship a 200 KB script tag with no markup). Null when the body is
|
|
||||||
// empty or the HTML body byte count is zero.
|
|
||||||
const totalHtmlBytes = Buffer.byteLength(html, 'utf8');
|
const totalHtmlBytes = Buffer.byteLength(html, 'utf8');
|
||||||
let textCodeRatio: number | null = null;
|
let textCodeRatio: number | null = null;
|
||||||
if (totalHtmlBytes > 0) {
|
if (totalHtmlBytes > 0) {
|
||||||
const $body = $('body').clone();
|
const visibleBytes = Buffer.byteLength(text, 'utf8');
|
||||||
$body.find('script, style, noscript').remove();
|
|
||||||
const visibleText = $body.text().replace(/\s+/g, ' ').trim();
|
|
||||||
const visibleBytes = Buffer.byteLength(visibleText, 'utf8');
|
|
||||||
textCodeRatio = Math.min(100, Math.round((visibleBytes / totalHtmlBytes) * 100));
|
textCodeRatio = Math.min(100, Math.round((visibleBytes / totalHtmlBytes) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export {
|
|||||||
type TabularExportResult,
|
type TabularExportResult,
|
||||||
type TabularSection,
|
type TabularSection,
|
||||||
} from './tabular-export.js';
|
} from './tabular-export.js';
|
||||||
|
export { ensureHeapHeadroom, heapHeadroomBytes } from './heap-guard.js';
|
||||||
export {
|
export {
|
||||||
testUrlAgainstRobots,
|
testUrlAgainstRobots,
|
||||||
validateRobotsTxt,
|
validateRobotsTxt,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
import type { ProjectDb } from '@freecrawl/db';
|
import type { ProjectDb } from '@freecrawl/db';
|
||||||
import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
|
import type { CrawlUrlRow, UrlCategory } from '@freecrawl/shared-types';
|
||||||
|
import { ensureHeapHeadroom } from './heap-guard.js';
|
||||||
|
|
||||||
export interface TabularSection {
|
export interface TabularSection {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -506,17 +507,26 @@ export async function exportTabular(
|
|||||||
|
|
||||||
if (format === 'xlsx') {
|
if (format === 'xlsx') {
|
||||||
// xlsx — single workbook, one sheet per section. subdir/filename
|
// xlsx — single workbook, one sheet per section. subdir/filename
|
||||||
// are ignored; the workbook lives at `outputPath`.
|
// are ignored; the workbook lives at `outputPath`. Unlike the
|
||||||
|
// CSV/JSON/XML writers below (which stream row-by-row), the xlsx
|
||||||
|
// container format forces every sheet into memory at once — so
|
||||||
|
// guard the heap while accumulating: better a clear "use CSV for
|
||||||
|
// datasets this big" error than a V8 OOM abort of the whole app.
|
||||||
const usedNames = new Set<string>();
|
const usedNames = new Set<string>();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const sheets = sections.map((section) => {
|
const sheets = sections.map((section) => {
|
||||||
const rows: CrawlUrlRow[] = [];
|
const rows: CrawlUrlRow[] = [];
|
||||||
for (const row of rowSource(db, section.category, selectedIds)) {
|
for (const row of rowSource(db, section.category, selectedIds)) {
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
|
if (rows.length % 50_000 === 0) {
|
||||||
|
ensureHeapHeadroom('XLSX export', 128 * 1024 * 1024);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total += rows.length;
|
total += rows.length;
|
||||||
return { name: sanitizeSheetName(section.label, usedNames), rows };
|
return { name: sanitizeSheetName(section.label, usedNames), rows };
|
||||||
});
|
});
|
||||||
|
// Workbook buffer is roughly rows × columns × ~24 B of XML.
|
||||||
|
ensureHeapHeadroom('XLSX export', total * columns.length * 24);
|
||||||
const buf = buildXlsxBuffer(sheets, columns);
|
const buf = buildXlsxBuffer(sheets, columns);
|
||||||
writeFileSync(outputPath, buf);
|
writeFileSync(outputPath, buf);
|
||||||
return { filePath: outputPath, files: [outputPath], rowsWritten: total };
|
return { filePath: outputPath, files: [outputPath], rowsWritten: total };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/db",
|
"name": "@freecrawl/db",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
+216
-74
@@ -2304,8 +2304,11 @@ export class ProjectDb {
|
|||||||
* Why sampling instead of full-crawl pass: at 100K URLs × 50KB body
|
* Why sampling instead of full-crawl pass: at 100K URLs × 50KB body
|
||||||
* × 100 unique 5-word shingles, a full pass needs ~5GB of working
|
* × 100 unique 5-word shingles, a full pass needs ~5GB of working
|
||||||
* memory for the shingle→pageSet inverted index. Sampling 2K bodies
|
* memory for the shingle→pageSet inverted index. Sampling 2K bodies
|
||||||
* keeps it under 200MB while still surfacing the same boilerplate
|
* keeps the shingle index under ~60MB while still surfacing the same
|
||||||
* (templated chrome doesn't change between page 2000 and page 100000).
|
* boilerplate (templated chrome doesn't change between page 2000 and
|
||||||
|
* page 100000). Bodies themselves are streamed in small batches with
|
||||||
|
* a per-body SUBSTR cap (see Phase 1) so raw HTML never accumulates
|
||||||
|
* in the JS heap — the sampled-index budget above is the whole cost.
|
||||||
*
|
*
|
||||||
* Pages with < shingleSize words contribute nothing (no shingles
|
* Pages with < shingleSize words contribute nothing (no shingles
|
||||||
* generated) and get coverage = 0.
|
* generated) and get coverage = 0.
|
||||||
@@ -2350,6 +2353,15 @@ export class ProjectDb {
|
|||||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||||
|
// Unterminated trailing block. Bodies arrive truncated (the
|
||||||
|
// crawler's 1 MB snapshot cap, plus the SUBSTR cap below), so the
|
||||||
|
// cut routinely lands inside a <script>. The paired patterns above
|
||||||
|
// need a closing tag to match, so without this the whole minified
|
||||||
|
// bundle survives tag-stripping and becomes "text" — on a real
|
||||||
|
// crawl that turned 400 words of prose into 8,300 JS tokens, and
|
||||||
|
// since those tokens are near-identical across pages every shingle
|
||||||
|
// looked like boilerplate and coverage pinned at 100% site-wide.
|
||||||
|
.replace(/<(?:script|style|noscript)\b[\s\S]*$/i, ' ')
|
||||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||||
.replace(/<[^>]+>/g, ' ')
|
.replace(/<[^>]+>/g, ' ')
|
||||||
.replace(/&[#a-z0-9]+;/gi, ' ')
|
.replace(/&[#a-z0-9]+;/gi, ' ')
|
||||||
@@ -2374,26 +2386,55 @@ export class ProjectDb {
|
|||||||
const pageShingles = new Map<number, Set<number>>();
|
const pageShingles = new Map<number, Set<number>>();
|
||||||
const shingleCounts = new Map<number, number>();
|
const shingleCounts = new Map<number, number>();
|
||||||
|
|
||||||
const rows = this.db
|
// Bodies are read in small keyset-paginated batches, each body
|
||||||
.prepare(
|
// capped via SUBSTR, then shingled and dropped before the next
|
||||||
`SELECT u.id AS id, us.body AS body
|
// batch loads. The previous single `.all(sampleSize)` materialised
|
||||||
FROM urls u
|
// every sampled body at once — with 1 MB snapshot caps that's up
|
||||||
JOIN url_sources us ON us.url_id = u.id
|
// to 2 GB of JS strings (4 GB the moment a body contains one
|
||||||
WHERE u.is_external = 0
|
// non-Latin-1 char and V8 switches to two-byte strings), which
|
||||||
AND u.content_kind = 'html'
|
// blew Electron's hard 4 GB pointer-compression heap ceiling and
|
||||||
AND us.body IS NOT NULL
|
// crashed the app post-crawl. Peak is now ~BATCH × cap ≈ 25 MB.
|
||||||
AND LENGTH(us.body) > 0
|
//
|
||||||
ORDER BY u.id
|
// The cap matches the crawler's 1 MB body-snapshot ceiling, so a
|
||||||
LIMIT ?`,
|
// stored body is read whole and the shingle set covers the real
|
||||||
)
|
// page rather than its first third. An earlier 200K cap looked
|
||||||
.all(sampleSize) as { id: number; body: string }[];
|
// safer but skewed the result badly: it cut deep inside the inline
|
||||||
|
// JS most pages carry, and everything after that cut is unique
|
||||||
for (const row of rows) {
|
// *content*, so pages lost their distinguishing shingles while
|
||||||
const shingles = shingleHashes(stripHtml(row.body));
|
// keeping the shared chrome — coverage collapsed to 100% for every
|
||||||
pageShingles.set(row.id, shingles);
|
// sampled page. Peak heap stays bounded by BATCH × cap ≈ 64 MB,
|
||||||
for (const h of shingles) {
|
// versus the ~2 GB the pre-batching `.all(sampleSize)` reached.
|
||||||
shingleCounts.set(h, (shingleCounts.get(h) ?? 0) + 1);
|
const BATCH = 64;
|
||||||
|
const BODY_CHAR_CAP = 1_048_576;
|
||||||
|
const pickBatch = this.db.prepare(
|
||||||
|
`SELECT u.id AS id, SUBSTR(us.body, 1, ${BODY_CHAR_CAP}) AS body
|
||||||
|
FROM urls u
|
||||||
|
JOIN url_sources us ON us.url_id = u.id
|
||||||
|
WHERE u.id > ?
|
||||||
|
AND u.is_external = 0
|
||||||
|
AND u.content_kind = 'html'
|
||||||
|
AND us.body IS NOT NULL
|
||||||
|
AND LENGTH(us.body) > 0
|
||||||
|
ORDER BY u.id
|
||||||
|
LIMIT ?`,
|
||||||
|
);
|
||||||
|
let lastId = 0;
|
||||||
|
let remaining = sampleSize;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const batch = pickBatch.all(lastId, Math.min(BATCH, remaining)) as {
|
||||||
|
id: number;
|
||||||
|
body: string;
|
||||||
|
}[];
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
for (const row of batch) {
|
||||||
|
const shingles = shingleHashes(stripHtml(row.body));
|
||||||
|
pageShingles.set(row.id, shingles);
|
||||||
|
for (const h of shingles) {
|
||||||
|
shingleCounts.set(h, (shingleCounts.get(h) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
lastId = row.id;
|
||||||
}
|
}
|
||||||
|
remaining -= batch.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2 — identify boilerplate shingles (appear on more than
|
// Phase 2 — identify boilerplate shingles (appear on more than
|
||||||
@@ -4510,6 +4551,15 @@ export class ProjectDb {
|
|||||||
});
|
});
|
||||||
const sortCol = validSortColumn(params.sortBy);
|
const sortCol = validSortColumn(params.sortBy);
|
||||||
const sortDir = params.sortDir === 'desc' ? 'DESC' : 'ASC';
|
const sortDir = params.sortDir === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
// Server-side clamp — this method is reachable from the renderer
|
||||||
|
// and the MCP bridge, so the limit can't be trusted to be sane. A
|
||||||
|
// runaway limit here materialises the whole table in one `.all()`;
|
||||||
|
// 10K rows is comfortably above every legitimate caller (virtual
|
||||||
|
// table chunks are ≤2K, MCP selection resolution caps at 5K).
|
||||||
|
// Callers that genuinely need the full table stream it through
|
||||||
|
// `iterateUrlsByCategory` instead.
|
||||||
|
const limit = Math.max(1, Math.min(10_000, Math.floor(params.limit) || 1));
|
||||||
|
const offset = Math.max(0, Math.floor(params.offset) || 0);
|
||||||
|
|
||||||
const totalRow = this.db
|
const totalRow = this.db
|
||||||
.prepare(`SELECT COUNT(*) AS c FROM urls ${whereSql}`)
|
.prepare(`SELECT COUNT(*) AS c FROM urls ${whereSql}`)
|
||||||
@@ -4521,7 +4571,7 @@ export class ProjectDb {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM urls ${whereSql} ORDER BY ${sortCol} ${sortDir}${tieBreak} LIMIT ? OFFSET ?`,
|
`SELECT * FROM urls ${whereSql} ORDER BY ${sortCol} ${sortDir}${tieBreak} LIMIT ? OFFSET ?`,
|
||||||
)
|
)
|
||||||
.all(...args, params.limit, params.offset) as unknown as UrlRowDb[];
|
.all(...args, limit, offset) as unknown as UrlRowDb[];
|
||||||
|
|
||||||
return { rows: rowsDb.map(this.rowFromDb), total: totalRow.c };
|
return { rows: rowsDb.map(this.rowFromDb), total: totalRow.c };
|
||||||
}
|
}
|
||||||
@@ -4564,6 +4614,17 @@ export class ProjectDb {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
// Count a sidebar node through the *same* predicate its click applies.
|
||||||
|
// These counters used to carry their own hand-written WHERE clauses,
|
||||||
|
// which had silently drifted from `categoryWhereClause`: the sidebar
|
||||||
|
// scoped Response Codes / Security / Indexability to internal URLs
|
||||||
|
// while the filter didn't, so "Success (2xx) 7,327" opened a table of
|
||||||
|
// 7,329 rows. Deriving the count from the clause makes the number and
|
||||||
|
// the row set the same query by construction.
|
||||||
|
const countCategory = (cat: UrlCategory): number => {
|
||||||
|
const clause = categoryWhereClause(cat);
|
||||||
|
return countWhere(clause ?? '1 = 1');
|
||||||
|
};
|
||||||
|
|
||||||
const totalInternalUrls = countWhere('is_external = 0');
|
const totalInternalUrls = countWhere('is_external = 0');
|
||||||
const totalExternalUrls = countWhere('is_external = 1');
|
const totalExternalUrls = countWhere('is_external = 1');
|
||||||
@@ -4571,12 +4632,10 @@ export class ProjectDb {
|
|||||||
// Headline indexability reflects PAGES only (content_kind = 'html').
|
// Headline indexability reflects PAGES only (content_kind = 'html').
|
||||||
// Resources (image/css/js/font) are all `indexable` with a 2xx status,
|
// Resources (image/css/js/font) are all `indexable` with a 2xx status,
|
||||||
// so without this guard they would inflate the "Indexable URLs" stat.
|
// so without this guard they would inflate the "Indexable URLs" stat.
|
||||||
const totalIndexable = countWhere(
|
// The page-only scope lives in `categoryWhereClause` so the Directives
|
||||||
"is_external = 0 AND content_kind = 'html' AND indexability = 'indexable' AND status_code IS NOT NULL",
|
// tab shows exactly the rows this headline counts.
|
||||||
);
|
const totalIndexable = countCategory('indexability:indexable');
|
||||||
const totalNonIndexable = countWhere(
|
const totalNonIndexable = countCategory('indexability:non-indexable');
|
||||||
"is_external = 0 AND content_kind = 'html' AND indexability LIKE 'non-indexable%'",
|
|
||||||
);
|
|
||||||
const totalImages = (
|
const totalImages = (
|
||||||
this.db.prepare('SELECT COUNT(*) AS c FROM images').get() as { c: number }
|
this.db.prepare('SELECT COUNT(*) AS c FROM images').get() as { c: number }
|
||||||
).c;
|
).c;
|
||||||
@@ -4604,33 +4663,28 @@ export class ProjectDb {
|
|||||||
html: countWhere("is_external = 1 AND content_kind = 'html'"),
|
html: countWhere("is_external = 1 AND content_kind = 'html'"),
|
||||||
other: countWhere("is_external = 1 AND content_kind != 'html'"),
|
other: countWhere("is_external = 1 AND content_kind != 'html'"),
|
||||||
},
|
},
|
||||||
|
// Response Codes covers every URL the crawl touched, external
|
||||||
|
// included — that group is how a user finds a broken outbound link,
|
||||||
|
// and the External tab keeps its own separate view.
|
||||||
responseCodes: {
|
responseCodes: {
|
||||||
all: totalInternalUrls,
|
all: countCategory('all'),
|
||||||
blockedRobots: countWhere(
|
blockedRobots: countCategory('status:blocked-robots'),
|
||||||
"is_external = 0 AND indexability = 'non-indexable:robots-blocked'",
|
noResponse: countCategory('status:no-response'),
|
||||||
),
|
success2xx: countCategory('status:2xx'),
|
||||||
noResponse: countWhere('is_external = 0 AND status_code IS NULL'),
|
redirect3xx: countCategory('status:3xx'),
|
||||||
success2xx: countWhere('is_external = 0 AND status_code >= 200 AND status_code < 300'),
|
clientError4xx: countCategory('status:4xx'),
|
||||||
redirect3xx: countWhere('is_external = 0 AND status_code >= 300 AND status_code < 400'),
|
serverError5xx: countCategory('status:5xx'),
|
||||||
clientError4xx: countWhere(
|
|
||||||
'is_external = 0 AND status_code >= 400 AND status_code < 500',
|
|
||||||
),
|
|
||||||
serverError5xx: countWhere(
|
|
||||||
'is_external = 0 AND status_code >= 500 AND status_code < 600',
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
https: countWhere("is_external = 0 AND url LIKE 'https://%'"),
|
https: countCategory('security:https'),
|
||||||
http: countWhere("is_external = 0 AND url LIKE 'http://%'"),
|
http: countCategory('security:http'),
|
||||||
},
|
},
|
||||||
indexability: {
|
indexability: {
|
||||||
indexable: totalIndexable,
|
indexable: totalIndexable,
|
||||||
nonIndexable: totalNonIndexable,
|
nonIndexable: totalNonIndexable,
|
||||||
noindex: countWhere("is_external = 0 AND indexability = 'non-indexable:noindex'"),
|
noindex: countCategory('indexability:noindex'),
|
||||||
canonicalised: countWhere("is_external = 0 AND indexability = 'non-indexable:canonical'"),
|
canonicalised: countCategory('indexability:canonicalised'),
|
||||||
blockedRobots: countWhere(
|
blockedRobots: countCategory('indexability:blocked-robots'),
|
||||||
"is_external = 0 AND indexability = 'non-indexable:robots-blocked'",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
issues: this.getIssuesCounts(),
|
issues: this.getIssuesCounts(),
|
||||||
};
|
};
|
||||||
@@ -5968,13 +6022,28 @@ export class ProjectDb {
|
|||||||
* `category === 'all'` walks the full urls table.
|
* `category === 'all'` walks the full urls table.
|
||||||
*/
|
*/
|
||||||
*iterateUrlsByCategory(category: UrlCategory): IterableIterator<CrawlUrlRow> {
|
*iterateUrlsByCategory(category: UrlCategory): IterableIterator<CrawlUrlRow> {
|
||||||
|
// Keyset-paginated so this is a REAL streamer: the previous
|
||||||
|
// single `.all()` materialised every matching row up front, which
|
||||||
|
// on a 1M-URL export meant hundreds of MB of row objects pinned in
|
||||||
|
// the JS heap before the first CSV line was written — squarely
|
||||||
|
// inside Electron's 4 GB heap-cap budget. 2000-row batches keep
|
||||||
|
// peak memory flat regardless of table size while adding only
|
||||||
|
// ~500 statement executions per million rows.
|
||||||
const where = categoryWhereClause(category);
|
const where = categoryWhereClause(category);
|
||||||
|
const BATCH = 2000;
|
||||||
const sql = where
|
const sql = where
|
||||||
? `SELECT * FROM urls WHERE ${where} ORDER BY id`
|
? `SELECT * FROM urls WHERE (${where}) AND id > ? ORDER BY id LIMIT ?`
|
||||||
: 'SELECT * FROM urls ORDER BY id';
|
: 'SELECT * FROM urls WHERE id > ? ORDER BY id LIMIT ?';
|
||||||
const rows = this.db.prepare(sql).all() as unknown as UrlRowDb[];
|
const stmt = this.db.prepare(sql);
|
||||||
for (const row of rows) {
|
let lastId = 0;
|
||||||
yield this.rowFromDb(row);
|
for (;;) {
|
||||||
|
const rows = stmt.all(lastId, BATCH) as unknown as UrlRowDb[];
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
for (const row of rows) {
|
||||||
|
lastId = row.id;
|
||||||
|
yield this.rowFromDb(row);
|
||||||
|
}
|
||||||
|
if (rows.length < BATCH) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5984,18 +6053,32 @@ export class ProjectDb {
|
|||||||
* noindex, canonicalised, blocked, 4xx/5xx, and non-HTML resources.
|
* noindex, canonicalised, blocked, 4xx/5xx, and non-HTML resources.
|
||||||
*/
|
*/
|
||||||
*iterateIndexableUrls(): IterableIterator<CrawlUrlRow> {
|
*iterateIndexableUrls(): IterableIterator<CrawlUrlRow> {
|
||||||
const rows = this.db
|
// Composite (depth, id) keyset pagination — same real-streaming
|
||||||
.prepare(
|
// rationale as iterateUrlsByCategory, but this consumer (the XML
|
||||||
`SELECT * FROM urls
|
// sitemap generator) needs depth-then-id order, so the cursor
|
||||||
WHERE is_external = 0
|
// carries both columns.
|
||||||
AND content_kind = 'html'
|
const BATCH = 2000;
|
||||||
AND indexability = 'indexable'
|
const stmt = this.db.prepare(
|
||||||
AND status_code >= 200 AND status_code < 300
|
`SELECT * FROM urls
|
||||||
ORDER BY depth, id`,
|
WHERE is_external = 0
|
||||||
)
|
AND content_kind = 'html'
|
||||||
.all() as unknown as UrlRowDb[];
|
AND indexability = 'indexable'
|
||||||
for (const row of rows) {
|
AND status_code >= 200 AND status_code < 300
|
||||||
yield this.rowFromDb(row);
|
AND (depth > ? OR (depth = ? AND id > ?))
|
||||||
|
ORDER BY depth, id
|
||||||
|
LIMIT ?`,
|
||||||
|
);
|
||||||
|
let lastDepth = -1;
|
||||||
|
let lastId = 0;
|
||||||
|
for (;;) {
|
||||||
|
const rows = stmt.all(lastDepth, lastDepth, lastId, BATCH) as unknown as UrlRowDb[];
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
for (const row of rows) {
|
||||||
|
lastDepth = (row as unknown as { depth: number }).depth;
|
||||||
|
lastId = row.id;
|
||||||
|
yield this.rowFromDb(row);
|
||||||
|
}
|
||||||
|
if (rows.length < BATCH) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6021,13 +6104,24 @@ export class ProjectDb {
|
|||||||
lcpResourceUrl: string | null;
|
lcpResourceUrl: string | null;
|
||||||
lcpCoverage: number | null;
|
lcpCoverage: number | null;
|
||||||
linkScore: number | null;
|
linkScore: number | null;
|
||||||
|
title: string | null;
|
||||||
|
h1: string | null;
|
||||||
|
h2Count: number;
|
||||||
|
wordCount: number | null;
|
||||||
|
outlinks: number;
|
||||||
|
followedOutlinks: number;
|
||||||
}[];
|
}[];
|
||||||
edges: { source: number; target: number }[];
|
edges: { source: number; target: number }[];
|
||||||
|
/** Total internal HTML URLs in the project (NOT capped by
|
||||||
|
* `nodeLimit`) — the denominator for the visualization tooltip's
|
||||||
|
* "% of Total" inlink share. */
|
||||||
|
totalUrls: number;
|
||||||
} {
|
} {
|
||||||
const nodes = this.db
|
const nodes = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, url, status_code, depth, inlinks, indexability,
|
`SELECT id, url, status_code, depth, inlinks, indexability,
|
||||||
lcp_tag, lcp_resource_url, lcp_coverage, link_score
|
lcp_tag, lcp_resource_url, lcp_coverage, link_score,
|
||||||
|
title, h1, h2_count, word_count, outlinks
|
||||||
FROM urls
|
FROM urls
|
||||||
WHERE is_external = 0 AND content_kind = 'html'
|
WHERE is_external = 0 AND content_kind = 'html'
|
||||||
ORDER BY inlinks DESC, id ASC
|
ORDER BY inlinks DESC, id ASC
|
||||||
@@ -6044,9 +6138,23 @@ export class ProjectDb {
|
|||||||
lcp_resource_url: string | null;
|
lcp_resource_url: string | null;
|
||||||
lcp_coverage: number | null;
|
lcp_coverage: number | null;
|
||||||
link_score: number | null;
|
link_score: number | null;
|
||||||
|
title: string | null;
|
||||||
|
h1: string | null;
|
||||||
|
h2_count: number;
|
||||||
|
word_count: number | null;
|
||||||
|
outlinks: number;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
if (nodes.length === 0) return { nodes: [], edges: [] };
|
if (nodes.length === 0) return { nodes: [], edges: [], totalUrls: 0 };
|
||||||
|
|
||||||
|
const totalUrls = (
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS c FROM urls
|
||||||
|
WHERE is_external = 0 AND content_kind = 'html'`,
|
||||||
|
)
|
||||||
|
.get() as { c: number }
|
||||||
|
).c;
|
||||||
|
|
||||||
const idByUrl = new Map<string, number>();
|
const idByUrl = new Map<string, number>();
|
||||||
for (const n of nodes) idByUrl.set(n.url, n.id);
|
for (const n of nodes) idByUrl.set(n.url, n.id);
|
||||||
@@ -6058,12 +6166,27 @@ export class ProjectDb {
|
|||||||
const placeholders = fromIds.map(() => '?').join(',');
|
const placeholders = fromIds.map(() => '?').join(',');
|
||||||
const edgeRows = this.db
|
const edgeRows = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT from_url_id AS source, to_url FROM links
|
`SELECT from_url_id AS source, to_url, rel FROM links
|
||||||
WHERE is_internal = 1 AND from_url_id IN (${placeholders})`,
|
WHERE is_internal = 1 AND from_url_id IN (${placeholders})`,
|
||||||
)
|
)
|
||||||
.all(...fromIds) as { source: number; to_url: string }[];
|
.all(...fromIds) as { source: number; to_url: string; rel: string | null }[];
|
||||||
|
|
||||||
const edges: { source: number; target: number }[] = [];
|
const edges: { source: number; target: number }[] = [];
|
||||||
|
// Followed = distinct internal targets reached by a link whose rel
|
||||||
|
// doesn't carry nofollow — the outlinks that actually pass equity.
|
||||||
|
// Counted off the same rows we already fetched (no extra query) and
|
||||||
|
// BEFORE the node-cap filter, so the number reflects the real page
|
||||||
|
// rather than the visible slice of the graph.
|
||||||
|
const followedTargets = new Map<number, Set<string>>();
|
||||||
|
for (const e of edgeRows) {
|
||||||
|
if (e.rel && /\bnofollow\b/i.test(e.rel)) continue;
|
||||||
|
let set = followedTargets.get(e.source);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set<string>();
|
||||||
|
followedTargets.set(e.source, set);
|
||||||
|
}
|
||||||
|
set.add(e.to_url);
|
||||||
|
}
|
||||||
for (const e of edgeRows) {
|
for (const e of edgeRows) {
|
||||||
const target = idByUrl.get(e.to_url);
|
const target = idByUrl.get(e.to_url);
|
||||||
if (target !== undefined && target !== e.source) {
|
if (target !== undefined && target !== e.source) {
|
||||||
@@ -6083,8 +6206,15 @@ export class ProjectDb {
|
|||||||
lcpResourceUrl: n.lcp_resource_url,
|
lcpResourceUrl: n.lcp_resource_url,
|
||||||
lcpCoverage: n.lcp_coverage,
|
lcpCoverage: n.lcp_coverage,
|
||||||
linkScore: n.link_score,
|
linkScore: n.link_score,
|
||||||
|
title: n.title,
|
||||||
|
h1: n.h1,
|
||||||
|
h2Count: n.h2_count,
|
||||||
|
wordCount: n.word_count,
|
||||||
|
outlinks: n.outlinks,
|
||||||
|
followedOutlinks: followedTargets.get(n.id)?.size ?? 0,
|
||||||
})),
|
})),
|
||||||
edges,
|
edges,
|
||||||
|
totalUrls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8069,16 +8199,28 @@ function categoryWhereClause(cat: UrlCategory): string | null {
|
|||||||
return "url LIKE 'https://%'";
|
return "url LIKE 'https://%'";
|
||||||
case 'security:http':
|
case 'security:http':
|
||||||
return "url LIKE 'http://%'";
|
return "url LIKE 'http://%'";
|
||||||
|
// Indexability is a property of pages, not of the resources they
|
||||||
|
// pull in: a font or a stylesheet is stored as `indexable` with a
|
||||||
|
// 2xx status, and counting those as indexable URLs inflates the
|
||||||
|
// headline stat and fills the Directives tab with rows no directive
|
||||||
|
// can apply to. Every member of this group therefore carries the
|
||||||
|
// same internal-HTML scope. `indexable` additionally requires a
|
||||||
|
// response — a URL we never fetched isn't indexable.
|
||||||
case 'indexability:indexable':
|
case 'indexability:indexable':
|
||||||
return "indexability = 'indexable'";
|
return `is_external = 0 AND content_kind = 'html'
|
||||||
|
AND indexability = 'indexable' AND status_code IS NOT NULL`;
|
||||||
case 'indexability:non-indexable':
|
case 'indexability:non-indexable':
|
||||||
return "indexability LIKE 'non-indexable%'";
|
return `is_external = 0 AND content_kind = 'html'
|
||||||
|
AND indexability LIKE 'non-indexable%'`;
|
||||||
case 'indexability:noindex':
|
case 'indexability:noindex':
|
||||||
return "indexability = 'non-indexable:noindex'";
|
return `is_external = 0 AND content_kind = 'html'
|
||||||
|
AND indexability = 'non-indexable:noindex'`;
|
||||||
case 'indexability:canonicalised':
|
case 'indexability:canonicalised':
|
||||||
return "indexability = 'non-indexable:canonical'";
|
return `is_external = 0 AND content_kind = 'html'
|
||||||
|
AND indexability = 'non-indexable:canonical'`;
|
||||||
case 'indexability:blocked-robots':
|
case 'indexability:blocked-robots':
|
||||||
return "indexability = 'non-indexable:robots-blocked'";
|
return `is_external = 0 AND content_kind = 'html'
|
||||||
|
AND indexability = 'non-indexable:robots-blocked'`;
|
||||||
case 'issues:title-missing':
|
case 'issues:title-missing':
|
||||||
return "is_external = 0 AND content_kind = 'html' AND (title IS NULL OR title = '')";
|
return "is_external = 0 AND content_kind = 'html' AND (title IS NULL OR title = '')";
|
||||||
case 'issues:title-too-long':
|
case 'issues:title-too-long':
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@freecrawl/shared-types",
|
"name": "@freecrawl/shared-types",
|
||||||
"version": "0.8.9",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ export const IPC = {
|
|||||||
topAnchorTexts: 'graph:anchor-texts',
|
topAnchorTexts: 'graph:anchor-texts',
|
||||||
sitemapGenerate: 'sitemap:generate',
|
sitemapGenerate: 'sitemap:generate',
|
||||||
menuEvent: 'menu:event',
|
menuEvent: 'menu:event',
|
||||||
|
/** Renderer-requested native Copy (webContents.copy()). Used by the
|
||||||
|
* macOS Edit▸Copy round-trip: the native menu swallows Cmd+C before
|
||||||
|
* the page sees the keydown, so the menu item asks the renderer
|
||||||
|
* whether a custom table-selection copy applies — and when it
|
||||||
|
* doesn't, the renderer calls back here for the stock behaviour. */
|
||||||
|
editCopyNative: 'edit:copyNative',
|
||||||
dataChanged: 'data:changed',
|
dataChanged: 'data:changed',
|
||||||
appVersion: 'app:version',
|
appVersion: 'app:version',
|
||||||
/** Returns live process + system memory stats for the in-app
|
/** Returns live process + system memory stats for the in-app
|
||||||
@@ -477,6 +483,7 @@ export type MenuEvent =
|
|||||||
| 'open-log-analyzer'
|
| 'open-log-analyzer'
|
||||||
| 'open-settings'
|
| 'open-settings'
|
||||||
| 'open-scheduled-crawl'
|
| 'open-scheduled-crawl'
|
||||||
|
| 'edit-copy'
|
||||||
| 'about';
|
| 'about';
|
||||||
|
|
||||||
export interface ExportCsvResult {
|
export interface ExportCsvResult {
|
||||||
@@ -711,6 +718,18 @@ export interface GraphNode {
|
|||||||
* Null until the post-crawl link-score pass runs. Powers the
|
* Null until the post-crawl link-score pass runs. Powers the
|
||||||
* "By Link Score" visualization colour mode. */
|
* "By Link Score" visualization colour mode. */
|
||||||
linkScore: number | null;
|
linkScore: number | null;
|
||||||
|
// ── Fields below feed the visualization's hover summary card ──
|
||||||
|
title: string | null;
|
||||||
|
h1: string | null;
|
||||||
|
/** Number of `<h2>` elements on the page (the crawler stores a count,
|
||||||
|
* not the heading text). */
|
||||||
|
h2Count: number;
|
||||||
|
wordCount: number | null;
|
||||||
|
/** Unique outlinks recorded for the page (internal + external). */
|
||||||
|
outlinks: number;
|
||||||
|
/** Distinct internal outlink targets NOT marked rel="nofollow" — the
|
||||||
|
* links that actually pass equity. */
|
||||||
|
followedOutlinks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
@@ -725,6 +744,9 @@ export interface GraphSnapshotInput {
|
|||||||
export interface GraphSnapshotResult {
|
export interface GraphSnapshotResult {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
|
/** Total internal HTML URLs in the project, uncapped by `nodeLimit` —
|
||||||
|
* denominator for the hover card's "% of Total" inlink share. */
|
||||||
|
totalUrls: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrawlPathInput {
|
export interface CrawlPathInput {
|
||||||
@@ -1815,5 +1837,8 @@ export interface FreeCrawlApi {
|
|||||||
onDone(cb: (summary: CrawlSummary) => void): () => void;
|
onDone(cb: (summary: CrawlSummary) => void): () => void;
|
||||||
onError(cb: (message: string) => void): () => void;
|
onError(cb: (message: string) => void): () => void;
|
||||||
onMenuEvent(cb: (event: MenuEvent) => void): () => void;
|
onMenuEvent(cb: (event: MenuEvent) => void): () => void;
|
||||||
|
/** Run the stock Copy command (native text-selection copy) on this
|
||||||
|
* window — the fallback leg of the macOS Edit▸Copy round-trip. */
|
||||||
|
editCopyNative(): Promise<void>;
|
||||||
onDataChanged(cb: () => void): () => void;
|
onDataChanged(cb: () => void): () => void;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user