seo-intel 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -91,6 +91,38 @@ async function checkOllamaAvailability() {
91
91
  // ── EXTRACTION PROGRESS TRACKER ──────────────────────────────────────────
92
92
  const PROGRESS_FILE = join(__dirname, '.extraction-progress.json');
93
93
 
94
+ // ── Graceful shutdown support ──
95
+ // Cleanup callbacks registered by crawl/extract commands (e.g. close browser)
96
+ const _shutdownCallbacks = [];
97
+ let _shuttingDown = false;
98
+
99
+ function onShutdown(fn) { _shutdownCallbacks.push(fn); }
100
+ function clearShutdownCallbacks() { _shutdownCallbacks.length = 0; }
101
+
102
+ async function _gracefulExit(signal) {
103
+ if (_shuttingDown) return;
104
+ _shuttingDown = true;
105
+ console.log(chalk.yellow(`\n⏹ Received ${signal} — stopping gracefully…`));
106
+
107
+ // Update progress file
108
+ try {
109
+ const progress = readProgress();
110
+ if (progress && progress.status === 'running' && progress.pid === process.pid) {
111
+ writeProgress({ ...progress, status: 'stopped', stopped_at: Date.now() });
112
+ }
113
+ } catch { /* best-effort */ }
114
+
115
+ // Run cleanup callbacks (close browsers, etc.)
116
+ for (const fn of _shutdownCallbacks) {
117
+ try { await Promise.resolve(fn()); } catch { /* best-effort */ }
118
+ }
119
+
120
+ process.exit(0);
121
+ }
122
+
123
+ process.on('SIGTERM', () => _gracefulExit('SIGTERM'));
124
+ process.on('SIGINT', () => _gracefulExit('SIGINT'));
125
+
94
126
  function writeProgress(data) {
95
127
  try {
96
128
  writeFileSync(PROGRESS_FILE, JSON.stringify({
@@ -481,6 +513,7 @@ program
481
513
  page_index: totalExtracted + 1,
482
514
  started_at: crawlStart,
483
515
  failed: totalFailed,
516
+ stealth: !!crawlOpts.stealth,
484
517
  });
485
518
  upsertTechnical(db, { pageId, hasCanonical: page.hasCanonical, hasOgTags: page.hasOgTags, hasSchema: page.hasSchema, hasRobots: page.hasRobots });
486
519
  try {
@@ -1559,6 +1592,14 @@ program
1559
1592
  console.log(chalk.magenta(' 🥷 Advanced mode — full browser rendering, persistent sessions\n'));
1560
1593
  }
1561
1594
 
1595
+ // Register cleanup so SIGTERM closes the browser gracefully
1596
+ onShutdown(async () => {
1597
+ if (stealthSession) {
1598
+ await stealthSession.close();
1599
+ console.log(chalk.magenta(' 🥷 Stealth session closed'));
1600
+ }
1601
+ });
1602
+
1562
1603
  try {
1563
1604
  for (const row of pendingPages) {
1564
1605
  process.stdout.write(chalk.gray(` [${done + failed + 1}/${pendingPages.length}] ${row.url.slice(0, 65)} → `));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -418,17 +418,21 @@ function buildHtmlTemplate(data, opts = {}) {
418
418
  border-radius: var(--radius);
419
419
  padding: 14px 20px;
420
420
  display: flex;
421
- align-items: center;
422
- gap: 20px;
421
+ flex-direction: column;
422
+ gap: 12px;
423
423
  font-size: 0.78rem;
424
424
  }
425
425
  .extraction-status.is-running {
426
426
  border-color: rgba(232,213,163,0.3);
427
427
  }
428
+ .es-top-row {
429
+ display: flex; align-items: center; gap: 16px; width: 100%;
430
+ }
428
431
  .es-indicator {
429
432
  display: flex; align-items: center; gap: 8px;
430
433
  font-family: var(--font-display); font-weight: 700;
431
434
  font-size: 0.8rem; white-space: nowrap;
435
+ flex-shrink: 0;
432
436
  }
433
437
  .es-dot {
434
438
  width: 8px; height: 8px; border-radius: 50%;
@@ -444,21 +448,27 @@ function buildHtmlTemplate(data, opts = {}) {
444
448
  50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(232,213,163,0); }
445
449
  }
446
450
  .es-domains {
447
- display: flex; gap: 12px; flex-wrap: wrap; flex: 1;
451
+ display: grid;
452
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
453
+ gap: 6px 20px;
454
+ flex: 1;
448
455
  }
449
456
  .es-domain {
450
457
  display: flex; align-items: center; gap: 6px;
451
458
  }
452
459
  .es-domain-name {
453
460
  color: var(--text-secondary); font-size: 0.72rem;
461
+ white-space: nowrap;
462
+ width: 68px; min-width: 68px; flex-shrink: 0;
463
+ overflow: hidden; text-overflow: ellipsis;
454
464
  }
455
465
  .es-domain-name.is-target { color: var(--accent-gold); }
456
466
  .es-bar-wrap {
457
- width: 60px; height: 4px; background: var(--border-subtle);
458
- border-radius: 2px; overflow: hidden;
467
+ flex: 1; height: 5px; background: var(--border-subtle);
468
+ border-radius: 2.5px; overflow: hidden;
459
469
  }
460
470
  .es-bar-fill {
461
- height: 100%; border-radius: 2px;
471
+ height: 100%; border-radius: 2.5px;
462
472
  background: var(--color-success);
463
473
  transition: width 0.3s;
464
474
  }
@@ -466,7 +476,7 @@ function buildHtmlTemplate(data, opts = {}) {
466
476
  .es-bar-fill.low { background: var(--color-danger); }
467
477
  .es-pct {
468
478
  font-size: 0.68rem; color: var(--text-muted);
469
- min-width: 28px; text-align: right;
479
+ width: 32px; min-width: 32px; text-align: right; flex-shrink: 0;
470
480
  }
471
481
  .es-live {
472
482
  font-size: 0.7rem; color: var(--accent-gold);
@@ -479,9 +489,14 @@ function buildHtmlTemplate(data, opts = {}) {
479
489
  background: var(--color-danger);
480
490
  animation: pulse-dot 2s ease-in-out infinite;
481
491
  }
492
+ .es-bottom-row {
493
+ display: flex; align-items: center; gap: 12px; width: 100%;
494
+ padding-top: 10px;
495
+ border-top: 1px solid var(--border-subtle);
496
+ }
482
497
  .es-meta {
483
498
  display: flex; gap: 12px; align-items: center;
484
- margin-left: auto; white-space: nowrap; font-size: 0.7rem;
499
+ white-space: nowrap; font-size: 0.7rem;
485
500
  }
486
501
  .es-meta-item { color: var(--text-muted); }
487
502
  .es-meta-item i { margin-right: 3px; font-size: 0.62rem; }
@@ -493,8 +508,7 @@ function buildHtmlTemplate(data, opts = {}) {
493
508
  /* ─── Extraction Controls (server mode) ──────────────────────────────── */
494
509
  .es-controls {
495
510
  display: flex; align-items: center; gap: 10px;
496
- margin-left: 16px; padding-left: 16px;
497
- border-left: 1px solid var(--border-subtle);
511
+ margin-left: auto;
498
512
  }
499
513
  .es-controls.hidden { display: none; }
500
514
  .es-btn {
@@ -1866,69 +1880,85 @@ function buildHtmlTemplate(data, opts = {}) {
1866
1880
  extractionStatus.liveProgress?.status === 'running' ? 'is-running' :
1867
1881
  extractionStatus.liveProgress?.status === 'crashed' ? 'is-crashed' : ''
1868
1882
  }">
1869
- <div class="es-indicator">
1870
- <span class="es-dot ${
1871
- extractionStatus.liveProgress?.status === 'running' ? 'running' :
1872
- extractionStatus.liveProgress?.status === 'crashed' ? 'crashed' : ''
1873
- }"></span>
1874
- ${extractionStatus.liveProgress?.status === 'running'
1875
- ? `<span style="color:var(--accent-gold);">Extracting</span>`
1876
- : extractionStatus.liveProgress?.status === 'crashed'
1877
- ? `<span style="color:var(--color-danger);">Crashed</span>`
1878
- : `<span style="color:var(--text-muted);">${extractionStatus.overallPct === 100 ? 'Fully Extracted' : extractionStatus.overallPct + '% Extracted'}</span>`
1879
- }
1880
- </div>
1881
- <div class="es-domains">
1882
- ${extractionStatus.coverage.map(c => {
1883
- const pct = c.total_pages > 0 ? Math.round((c.extracted_pages / c.total_pages) * 100) : 0;
1884
- const barClass = pct === 100 ? '' : pct > 50 ? 'partial' : 'low';
1885
- return `
1886
- <div class="es-domain">
1887
- <span class="es-domain-name ${c.role === 'target' ? 'is-target' : ''}">${getDomainShortName(c.domain)}</span>
1888
- <div class="es-bar-wrap"><div class="es-bar-fill ${barClass}" style="width:${pct}%;"></div></div>
1889
- <span class="es-pct">${pct}%</span>
1890
- </div>`;
1891
- }).join('')}
1892
- </div>
1893
- <div class="es-meta">
1894
- ${extractionStatus.liveProgress?.status === 'running' ? `
1895
- <span class="es-meta-item" style="color:var(--accent-gold);">
1896
- <i class="fa-solid fa-spinner fa-spin"></i>
1897
- ${extractionStatus.liveProgress.current_url ? extractionStatus.liveProgress.current_url.replace(/https?:\/\/[^/]+/, '').slice(0, 30) : ''}
1898
- ${extractionStatus.liveProgress.total ? ` · ${extractionStatus.liveProgress.page_index}/${extractionStatus.liveProgress.total}` : ''}
1899
- </span>
1900
- ` : ''}
1901
- ${extractionStatus.liveProgress?.status === 'crashed' ? `
1902
- <span class="es-meta-item blocked">
1903
- <i class="fa-solid fa-skull"></i> PID ${extractionStatus.liveProgress.pid} dead
1904
- </span>
1905
- ` : ''}
1906
- ${extractionStatus.liveProgress?.skipped > 0 ? `
1907
- <span class="es-meta-item skipped">
1908
- <i class="fa-solid fa-forward"></i> ${extractionStatus.liveProgress.skipped} skipped
1909
- </span>
1910
- ` : ''}
1911
- ${extractionStatus.hashedPages > 0 ? `
1912
- <span class="es-meta-item">
1913
- <i class="fa-solid fa-fingerprint"></i> ${extractionStatus.hashedPages} hashed
1914
- </span>
1915
- ` : ''}
1883
+ <!-- Row 1: Status indicator + domain coverage bars (full width) -->
1884
+ <div class="es-top-row">
1885
+ <div class="es-indicator">
1886
+ <span class="es-dot ${
1887
+ extractionStatus.liveProgress?.status === 'running' ? 'running' :
1888
+ extractionStatus.liveProgress?.status === 'crashed' ? 'crashed' : ''
1889
+ }"></span>
1890
+ ${extractionStatus.liveProgress?.status === 'running'
1891
+ ? `<span style="color:var(--accent-gold);">Extracting</span>`
1892
+ : extractionStatus.liveProgress?.status === 'crashed'
1893
+ ? `<span style="color:var(--color-danger);">Crashed</span>`
1894
+ : `<span style="color:var(--text-muted);">${extractionStatus.overallPct === 100 ? 'Fully Extracted' : extractionStatus.overallPct + '% Extracted'}</span>`
1895
+ }
1896
+ </div>
1897
+ <div class="es-domains">
1898
+ ${extractionStatus.coverage.map(c => {
1899
+ const pct = c.total_pages > 0 ? Math.round((c.extracted_pages / c.total_pages) * 100) : 0;
1900
+ const barClass = pct === 100 ? '' : pct > 50 ? 'partial' : 'low';
1901
+ return `
1902
+ <div class="es-domain">
1903
+ <span class="es-domain-name ${c.role === 'target' ? 'is-target' : ''}">${getDomainShortName(c.domain)}</span>
1904
+ <div class="es-bar-wrap"><div class="es-bar-fill ${barClass}" style="width:${pct}%;"></div></div>
1905
+ <span class="es-pct">${pct}%</span>
1906
+ </div>`;
1907
+ }).join('')}
1908
+ </div>
1916
1909
  </div>
1917
- <div class="es-controls" id="esControls${suffix}">
1918
- <button class="es-btn" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')">
1919
- <i class="fa-solid fa-spider"></i> Crawl
1920
- </button>
1921
- <button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')">
1922
- <i class="fa-solid fa-brain"></i> Extract
1923
- </button>
1924
- <button class="es-btn es-btn-stop" id="btnStop${suffix}" onclick="stopJob()" style="display:none;">
1910
+ <!-- Row 2: Meta info + controls -->
1911
+ <div class="es-bottom-row">
1912
+ <div class="es-meta">
1913
+ ${extractionStatus.liveProgress?.status === 'running' ? `
1914
+ <span class="es-meta-item" style="color:var(--accent-gold);">
1915
+ <i class="fa-solid fa-spinner fa-spin"></i>
1916
+ ${extractionStatus.liveProgress.current_url ? extractionStatus.liveProgress.current_url.replace(/https?:\/\/[^/]+/, '').slice(0, 30) : ''}
1917
+ ${extractionStatus.liveProgress.total ? ` · ${extractionStatus.liveProgress.page_index}/${extractionStatus.liveProgress.total}` : ''}
1918
+ </span>
1919
+ ` : ''}
1920
+ ${extractionStatus.liveProgress?.status === 'crashed' ? `
1921
+ <span class="es-meta-item blocked">
1922
+ <i class="fa-solid fa-skull"></i> PID ${extractionStatus.liveProgress.pid} dead
1923
+ </span>
1924
+ ` : ''}
1925
+ ${extractionStatus.liveProgress?.skipped > 0 ? `
1926
+ <span class="es-meta-item skipped">
1927
+ <i class="fa-solid fa-forward"></i> ${extractionStatus.liveProgress.skipped} skipped
1928
+ </span>
1929
+ ` : ''}
1930
+ ${extractionStatus.hashedPages > 0 ? `
1931
+ <span class="es-meta-item">
1932
+ <i class="fa-solid fa-fingerprint"></i> ${extractionStatus.hashedPages} hashed
1933
+ </span>
1934
+ ` : ''}
1935
+ </div>
1936
+ <div class="es-controls" id="esControls${suffix}">
1937
+ ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'crawl'
1938
+ ? `<button class="es-btn running" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')" disabled>
1939
+ <i class="fa-solid fa-spinner fa-spin"></i> Crawling\u2026
1940
+ </button>`
1941
+ : `<button class="es-btn" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1942
+ <i class="fa-solid fa-spider"></i> Crawl
1943
+ </button>`
1944
+ }
1945
+ ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'extract'
1946
+ ? `<button class="es-btn running" id="btnExtract${suffix}" onclick="startJob('extract','${project}')" disabled>
1947
+ <i class="fa-solid fa-spinner fa-spin"></i> Extracting\u2026
1948
+ </button>`
1949
+ : `<button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1950
+ <i class="fa-solid fa-brain"></i> Extract
1951
+ </button>`
1952
+ }
1953
+ <button class="es-btn es-btn-stop" id="btnStop${suffix}" onclick="stopJob()" style="display:${extractionStatus.liveProgress?.status === 'running' ? 'inline-flex' : 'none'};">
1925
1954
  <i class="fa-solid fa-stop"></i> Stop
1926
1955
  </button>
1927
1956
  <label class="es-stealth-toggle">
1928
- <input type="checkbox" id="stealthToggle${suffix}">
1957
+ <input type="checkbox" id="stealthToggle${suffix}"${extractionStatus.liveProgress?.stealth ? ' checked' : ''}>
1929
1958
  <i class="fa-solid fa-user-ninja"></i> Stealth
1930
1959
  </label>
1931
1960
  </div>
1961
+ </div>
1932
1962
  </div>
1933
1963
 
1934
1964
  <!-- ═══ INTEGRATED TERMINAL + EXPORT SIDEBAR ═══ -->
@@ -3646,6 +3676,11 @@ function buildHtmlTemplate(data, opts = {}) {
3646
3676
  fetch('/api/progress')
3647
3677
  .then(function(r) { return r.json(); })
3648
3678
  .then(function(data) {
3679
+ // Sync stealth toggle with progress state
3680
+ var stealthEl = document.getElementById('stealthToggle' + sfx);
3681
+ if (stealthEl && data.stealth !== undefined) {
3682
+ stealthEl.checked = !!data.stealth;
3683
+ }
3649
3684
  if (data.status === 'running') {
3650
3685
  setButtonsState(true, data.command);
3651
3686
  startPolling();
package/server.js CHANGED
@@ -345,16 +345,18 @@ async function handleRequest(req, res) {
345
345
  return;
346
346
  }
347
347
  try {
348
+ // Graceful: SIGTERM lets the CLI close browsers / write progress
348
349
  process.kill(progress.pid, 'SIGTERM');
349
- // Give it a moment, then force kill if still alive
350
+ // Escalate: SIGKILL after 5s if still alive (stealth browser cleanup needs time)
350
351
  setTimeout(() => {
352
+ try { process.kill(progress.pid, 0); } catch { return; } // already dead
351
353
  try { process.kill(progress.pid, 'SIGKILL'); } catch {}
352
- }, 3000);
354
+ }, 5000);
353
355
  } catch (e) {
354
356
  if (e.code !== 'ESRCH') throw e;
355
357
  // Already dead
356
358
  }
357
- // Update progress file to reflect stopped state
359
+ // Update progress file (CLI also writes this on SIGTERM, but server does it too as safety net)
358
360
  try {
359
361
  writeFileSync(PROGRESS_FILE, JSON.stringify({
360
362
  ...progress,
@@ -628,6 +630,18 @@ const server = createServer((req, res) => {
628
630
  // Start background update check
629
631
  checkForUpdates();
630
632
 
633
+ server.on('error', (err) => {
634
+ if (err.code === 'EADDRINUSE') {
635
+ console.log(`\n ⚠️ Port ${PORT} is already in use — opening existing dashboard…\n`);
636
+ const url = `http://localhost:${PORT}`;
637
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
638
+ import('child_process').then(({ exec }) => exec(`${cmd} "${url}"`));
639
+ setTimeout(() => process.exit(0), 500);
640
+ } else {
641
+ throw err;
642
+ }
643
+ });
644
+
631
645
  server.listen(PORT, '127.0.0.1', () => {
632
646
  console.log(`\n SEO Intel Dashboard Server`);
633
647
  console.log(` http://localhost:${PORT}\n`);