mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
fix: anchor checks, URL details in grids, MCP and OAuth refresh errors
This commit is contained in:
1 parent
1a62e1e9af
commit
44bb3d09c6
45 files changed
+480
-94
No files matched your search
@@ -32,6 +32,7 @@ import type { GoogleAccount, GoogleAuthState } from '@freecrawl/shared-types';
|
|||||||
import { apiFetch } from './api-fetch.js';
|
import { apiFetch } from './api-fetch.js';
|
||||||
import { resolveCredentials } from './credentials.js';
|
import { resolveCredentials } from './credentials.js';
|
||||||
import * as logger from './logger.js';
|
import * as logger from './logger.js';
|
||||||
|
import { L } from './menu-i18n.js';
|
||||||
|
|
||||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||||
@@ -568,10 +569,20 @@ export async function getAccessToken(
|
|||||||
});
|
});
|
||||||
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
if (!res.ok || !json || typeof json['access_token'] !== 'string') {
|
if (!res.ok || !json || typeof json['access_token'] !== 'string') {
|
||||||
|
const code = json?.['error'] as string | undefined;
|
||||||
const msg =
|
const msg =
|
||||||
(json?.['error_description'] as string | undefined) ??
|
(json?.['error_description'] as string | undefined) ??
|
||||||
(json?.['error'] as string | undefined) ??
|
code ??
|
||||||
`token refresh failed (HTTP ${res.status})`;
|
`token refresh failed (HTTP ${res.status})`;
|
||||||
|
// `invalid_grant` (Google's description is just "Bad Request") is the
|
||||||
|
// refresh token itself being dead. With a BYOC client that is almost
|
||||||
|
// always the "Testing" publishing status, where Google expires refresh
|
||||||
|
// tokens after 7 days — tell the user the one-time fix instead of the
|
||||||
|
// generic "reconnect", which they'd otherwise repeat every week.
|
||||||
|
if (code === 'invalid_grant') {
|
||||||
|
logger.log('warn', 'main', `google-oauth: refresh rejected for "${integrationId}": ${msg}`);
|
||||||
|
throw new Error(L().oauthRefreshExpired.replace('{reason}', msg));
|
||||||
|
}
|
||||||
throw new Error(`${msg} — you may need to reconnect this integration.`);
|
throw new Error(`${msg} — you may need to reconnect this integration.`);
|
||||||
}
|
}
|
||||||
const token = json['access_token'] as string;
|
const token = json['access_token'] as string;
|
||||||
|
|||||||
@@ -335,6 +335,10 @@ export interface MenuLabels {
|
|||||||
dlgConfirmClearMsg: string;
|
dlgConfirmClearMsg: string;
|
||||||
dlgConfirmClearDetail: string;
|
dlgConfirmClearDetail: string;
|
||||||
dlgDontAskAgain: string;
|
dlgDontAskAgain: string;
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
/** `{reason}` = Google's error code / description (e.g. `invalid_grant`). */
|
||||||
|
oauthRefreshExpired: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENU_LABELS: Record<UiLanguage, MenuLabels> = {
|
const MENU_LABELS: Record<UiLanguage, MenuLabels> = {
|
||||||
|
|||||||
@@ -309,4 +309,8 @@ export const MENU_AZ: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Bu, aktiv layihədəki hər taranmış URL-i, keçidi, şəkli, başlığı və mənbə görüntüsünü həmişəlik silir. Geri qaytarıla bilməz.',
|
'Bu, aktiv layihədəki hər taranmış URL-i, keçidi, şəkli, başlığı və mənbə görüntüsünü həmişəlik silir. Geri qaytarıla bilməz.',
|
||||||
dlgDontAskAgain: 'Bir daha soruşma',
|
dlgDontAskAgain: 'Bir daha soruşma',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google yadda saxlanılan girişi rədd etdi ({reason}). Bu adətən Google Cloud OAuth tətbiqinizin "Testing" statusunda olduğunu bildirir; bu halda Google bağlantını hər 7 gündə bir bitirir. Bir dəfə düzəldin: Google Cloud Console → OAuth consent screen → "Publish app" (təsdiq yoxlaması tələb olunmur), sonra bu inteqrasiyanı ayırıb yenidən qoşun.',
|
||||||
};
|
};
|
||||||
@@ -302,4 +302,8 @@ export const MENU_EN: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'This permanently deletes every crawled URL, link, image, header and source snapshot in the active project. This cannot be undone.',
|
'This permanently deletes every crawled URL, link, image, header and source snapshot in the active project. This cannot be undone.',
|
||||||
dlgDontAskAgain: "Don't ask me again",
|
dlgDontAskAgain: "Don't ask me again",
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google rejected the saved sign-in ({reason}). This usually means your Google Cloud OAuth app is in "Testing" status, where Google expires the connection every 7 days. Fix it once: Google Cloud Console → OAuth consent screen → "Publish app" (no verification review needed), then Disconnect and Connect this integration again.',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_ES: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Esto elimina de forma permanente cada URL rastreada, enlace, imagen, cabecera e instantánea del código fuente del proyecto activo. No se puede deshacer.',
|
'Esto elimina de forma permanente cada URL rastreada, enlace, imagen, cabecera e instantánea del código fuente del proyecto activo. No se puede deshacer.',
|
||||||
dlgDontAskAgain: 'No volver a preguntar',
|
dlgDontAskAgain: 'No volver a preguntar',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google rechazó el inicio de sesión guardado ({reason}). Normalmente significa que tu app OAuth de Google Cloud está en estado "Testing", donde Google caduca la conexión cada 7 días. Arréglalo una vez: Google Cloud Console → OAuth consent screen → "Publish app" (sin revisión de verificación) y luego Desconecta y Conecta esta integración de nuevo.',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_FR: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Cette action supprime définitivement chaque URL explorée, lien, image, en-tête et instantané du code source du projet actif. Elle est irréversible.',
|
'Cette action supprime définitivement chaque URL explorée, lien, image, en-tête et instantané du code source du projet actif. Elle est irréversible.',
|
||||||
dlgDontAskAgain: 'Ne plus me demander',
|
dlgDontAskAgain: 'Ne plus me demander',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google a rejeté la connexion enregistrée ({reason}). Cela signifie généralement que votre application OAuth Google Cloud est en statut "Testing", où Google fait expirer la connexion tous les 7 jours. Corrigez-le une fois : Google Cloud Console → OAuth consent screen → "Publish app" (aucune vérification requise), puis Déconnectez et Connectez à nouveau cette intégration.',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_HI: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'यह सक्रिय प्रोजेक्ट के हर क्रॉल किए गए URL, लिंक, छवि, हेडर और स्रोत स्नैपशॉट को स्थायी रूप से हटा देता है। इसे पूर्ववत नहीं किया जा सकता।',
|
'यह सक्रिय प्रोजेक्ट के हर क्रॉल किए गए URL, लिंक, छवि, हेडर और स्रोत स्नैपशॉट को स्थायी रूप से हटा देता है। इसे पूर्ववत नहीं किया जा सकता।',
|
||||||
dlgDontAskAgain: 'मुझसे फिर न पूछें',
|
dlgDontAskAgain: 'मुझसे फिर न पूछें',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google ने सहेजा हुआ साइन-इन अस्वीकार कर दिया ({reason})। इसका आमतौर पर मतलब है कि आपका Google Cloud OAuth ऐप "Testing" स्थिति में है, जहाँ Google हर 7 दिन में कनेक्शन समाप्त कर देता है। एक बार ठीक करें: Google Cloud Console → OAuth consent screen → "Publish app" (सत्यापन समीक्षा की ज़रूरत नहीं), फिर इस इंटीग्रेशन को डिस्कनेक्ट करके दोबारा कनेक्ट करें।',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_IT: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Questa operazione elimina in modo permanente ogni URL scansionato, link, immagine, header e snapshot del codice sorgente nel progetto attivo. Non è reversibile.',
|
'Questa operazione elimina in modo permanente ogni URL scansionato, link, immagine, header e snapshot del codice sorgente nel progetto attivo. Non è reversibile.',
|
||||||
dlgDontAskAgain: 'Non chiedermelo più',
|
dlgDontAskAgain: 'Non chiedermelo più',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google ha rifiutato l\'accesso salvato ({reason}). Di solito significa che la tua app OAuth di Google Cloud è nello stato "Testing", in cui Google fa scadere la connessione ogni 7 giorni. Risolvi una volta per tutte: Google Cloud Console → OAuth consent screen → "Publish app" (nessuna revisione di verifica), poi Disconnetti e Connetti di nuovo questa integrazione.',
|
||||||
};
|
};
|
||||||
@@ -301,4 +301,8 @@ export const MENU_KO: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'현재 프로젝트에서 크롤된 모든 URL, 링크, 이미지, 헤더, 소스 스냅샷이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
'현재 프로젝트에서 크롤된 모든 URL, 링크, 이미지, 헤더, 소스 스냅샷이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||||
dlgDontAskAgain: '다시 묻지 않기',
|
dlgDontAskAgain: '다시 묻지 않기',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google이 저장된 로그인을 거부했습니다({reason}). 보통 Google Cloud OAuth 앱이 "Testing" 상태라는 뜻이며, 이 상태에서는 Google이 7일마다 연결을 만료시킵니다. 한 번만 고치세요: Google Cloud Console → OAuth consent screen → "Publish app"(검증 심사 불필요), 그다음 이 통합을 연결 해제 후 다시 연결하세요.',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_PT_BR: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Isso exclui permanentemente cada URL rastreada, link, imagem, cabeçalho e snapshot do código-fonte do projeto ativo. Não pode ser desfeito.',
|
'Isso exclui permanentemente cada URL rastreada, link, imagem, cabeçalho e snapshot do código-fonte do projeto ativo. Não pode ser desfeito.',
|
||||||
dlgDontAskAgain: 'Não perguntar novamente',
|
dlgDontAskAgain: 'Não perguntar novamente',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'O Google rejeitou o login salvo ({reason}). Isso normalmente significa que seu app OAuth do Google Cloud está no status "Testing", em que o Google expira a conexão a cada 7 dias. Corrija uma vez: Google Cloud Console → OAuth consent screen → "Publish app" (sem revisão de verificação) e depois Desconecte e Conecte esta integração de novo.',
|
||||||
};
|
};
|
||||||
@@ -304,4 +304,8 @@ export const MENU_RU: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Это безвозвратно удалит каждый обойдённый URL, ссылку, изображение, заголовок и снимок исходного кода в активном проекте. Отменить нельзя.',
|
'Это безвозвратно удалит каждый обойдённый URL, ссылку, изображение, заголовок и снимок исходного кода в активном проекте. Отменить нельзя.',
|
||||||
dlgDontAskAgain: 'Больше не спрашивать',
|
dlgDontAskAgain: 'Больше не спрашивать',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google отклонил сохранённый вход ({reason}). Обычно это означает, что ваше OAuth-приложение в Google Cloud находится в статусе "Testing", при котором Google сбрасывает подключение каждые 7 дней. Исправьте один раз: Google Cloud Console → OAuth consent screen → "Publish app" (проверка не требуется), затем отключите и снова подключите эту интеграцию.',
|
||||||
};
|
};
|
||||||
@@ -302,4 +302,8 @@ export const MENU_TR: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'Bu işlem, aktif projedeki taranmış her URL, link, görsel, başlık ve kaynak snapshot\'ını kalıcı olarak siler. Geri alınamaz.',
|
'Bu işlem, aktif projedeki taranmış her URL, link, görsel, başlık ve kaynak snapshot\'ını kalıcı olarak siler. Geri alınamaz.',
|
||||||
dlgDontAskAgain: 'Bir daha sorma',
|
dlgDontAskAgain: 'Bir daha sorma',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google kayıtlı oturumu reddetti ({reason}). Bu genellikle Google Cloud OAuth uygulamanızın "Testing" durumunda olduğunu gösterir; Google bu durumda bağlantıyı her 7 günde bir düşürür. Bir kez düzeltin: Google Cloud Console → OAuth consent screen → "Publish app" (verification incelemesi gerekmez), sonra bu entegrasyonu Bağlantıyı Kes ve tekrar Bağla.',
|
||||||
};
|
};
|
||||||
@@ -299,4 +299,8 @@ export const MENU_ZH_CN: MenuLabels = {
|
|||||||
dlgConfirmClearDetail:
|
dlgConfirmClearDetail:
|
||||||
'此操作将永久删除当前项目中每个已抓取的 URL、链接、图片、响应头和源码快照,且无法撤销。',
|
'此操作将永久删除当前项目中每个已抓取的 URL、链接、图片、响应头和源码快照,且无法撤销。',
|
||||||
dlgDontAskAgain: '不再询问',
|
dlgDontAskAgain: '不再询问',
|
||||||
|
|
||||||
|
// ── Google OAuth refresh failure ──
|
||||||
|
oauthRefreshExpired:
|
||||||
|
'Google 拒绝了已保存的登录({reason})。这通常表示你的 Google Cloud OAuth 应用处于 "Testing" 状态,Google 会每 7 天使连接失效。一次性修复:Google Cloud Console → OAuth consent screen → "Publish app"(无需验证审核),然后断开并重新连接此集成。',
|
||||||
};
|
};
|
||||||
@@ -544,7 +544,7 @@ export const GUIDES_AZ: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'Test rejimində OAuth yeniləmə tokenləri hər 7 gündə bitir. Parametrlər → İnteqrasiyalar → Search Console → "Bağlantını kəs" → "Bağlan" ilə yenidən autentifikasiya edin. Bitmədən qurtulmaq üçün tətbiqi Google-un təsdiq prosesindən (1-4 həftə) keçirməlisiniz.',
|
'Razılıq ekranı "Testing" nəşr statusunda olduğu müddətcə Google OAuth yeniləmə tokenlərini 7 gündən sonra etibarsız edir. Daimi həll: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (status "In production" olur). Təsdiq yoxlaması tələb olunmur — "unverified app" xəbərdarlığı qalır, amma tokenlər artıq bitmir. Sonra Parametrlər → İnteqrasiyalar → Search Console → "Bağlantını kəs" → "Bağlan" ilə son dəfə yenidən daxil olun.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ export const GUIDES_EN: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'OAuth refresh tokens expire every 7 days in testing mode. Settings → Integrations → Search Console → "Disconnect" → "Connect" to re-auth. To get rid of the expiry you need to push the app through Google\'s verification process (1-4 weeks).',
|
'Google expires OAuth refresh tokens after 7 days while the consent screen is in "Testing" publishing status. Permanent fix: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (status becomes "In production"). No verification review is needed — the "unverified app" warning stays, but tokens stop expiring. Then Settings → Integrations → Search Console → "Disconnect" → "Connect" one last time.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ export const GUIDES_ES: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'Los refresh tokens de OAuth caducan cada 7 días en modo de pruebas. Ajustes → Integraciones → Search Console → "Desconectar" → "Conectar" para volver a autenticarte. Para librarte de la caducidad tienes que pasar la app por el proceso de verificación de Google (1-4 semanas).',
|
'Google caduca los refresh tokens de OAuth a los 7 días mientras la pantalla de consentimiento está en estado de publicación "Testing". Solución definitiva: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (el estado pasa a "In production"). No hace falta la revisión de verificación: el aviso de "unverified app" se mantiene, pero los tokens dejan de caducar. Después, Ajustes → Integraciones → Search Console → "Desconectar" → "Conectar" por última vez.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ export const GUIDES_FR: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'Les jetons de rafraîchissement OAuth expirent tous les 7 jours en mode test. Paramètres → Intégrations → Search Console → "Déconnecter" → "Connecter" pour vous réauthentifier. Pour supprimer cette expiration, il faut faire passer l\'application par le processus de validation de Google (1-4 semaines).',
|
'Google fait expirer les jetons de rafraîchissement OAuth après 7 jours tant que l\'écran de consentement est en statut de publication "Testing". Correctif définitif : Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (le statut passe à "In production"). Aucune vérification n\'est requise : l\'avertissement "unverified app" reste, mais les jetons n\'expirent plus. Ensuite, Paramètres → Intégrations → Search Console → "Déconnecter" → "Connecter" une dernière fois.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ export const GUIDES_HI: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'टेस्टिंग मोड में OAuth रिफ़्रेश टोकन हर 7 दिन में समाप्त होते हैं। सेटिंग्स → इंटीग्रेशन → Search Console → "डिस्कनेक्ट" → "कनेक्ट" से पुनः प्रमाणित करें। समाप्ति से छुटकारा पाने के लिए ऐप को Google की सत्यापन प्रक्रिया (1-4 सप्ताह) से गुज़ारना होगा।',
|
'जब तक consent screen "Testing" प्रकाशन स्थिति में है, Google OAuth रिफ़्रेश टोकन को 7 दिन बाद समाप्त कर देता है। स्थायी समाधान: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (स्थिति "In production" हो जाती है)। सत्यापन समीक्षा की ज़रूरत नहीं है — "unverified app" चेतावनी बनी रहती है, पर टोकन समाप्त होना बंद हो जाते हैं। फिर सेटिंग्स → इंटीग्रेशन → Search Console → "डिस्कनेक्ट" → "कनेक्ट" आख़िरी बार करें।',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export const GUIDES_IT: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'I refresh token OAuth scadono ogni 7 giorni in modalità test. Impostazioni → Integrazioni → Search Console → "Disconnetti" → "Connetti" per riautenticarti. Per eliminare la scadenza devi far passare l\'app per il processo di verifica di Google (1-4 settimane).',
|
'Google fa scadere i refresh token OAuth dopo 7 giorni finché la schermata di consenso è nello stato di pubblicazione "Testing". Soluzione definitiva: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (lo stato diventa "In production"). Non serve la revisione di verifica: l\'avviso "unverified app" resta, ma i token smettono di scadere. Poi Impostazioni → Integrazioni → Search Console → "Disconnetti" → "Connetti" un\'ultima volta.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ export const GUIDES_KO: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'테스트 모드에서는 OAuth 리프레시 토큰이 7일마다 만료됩니다. 설정 → 통합 → Search Console → "연결 해제" → "연결"로 다시 인증하세요. 만료를 없애려면 앱을 Google 검증 절차(1-4주)에 통과시켜야 합니다.',
|
'동의 화면이 "Testing" 게시 상태인 동안 Google은 OAuth 리프레시 토큰을 7일 후 만료시킵니다. 영구 해결: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app"(상태가 "In production"으로 바뀜). 검증 심사는 필요 없습니다. "unverified app" 경고는 남지만 토큰은 더 이상 만료되지 않습니다. 그다음 설정 → 연동 → Search Console → "연결 해제" → "연결"로 마지막 한 번만 다시 인증하세요.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ export const GUIDES_PT_BR: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'Refresh tokens do OAuth expiram a cada 7 dias no modo de teste. Configurações → Integrações → Search Console → "Desconectar" → "Conectar" para autenticar de novo. Para se livrar da expiração é preciso passar o app pelo processo de verificação do Google (1-4 semanas).',
|
'O Google expira os refresh tokens do OAuth após 7 dias enquanto a tela de consentimento está no status de publicação "Testing". Correção definitiva: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (o status passa a "In production"). Não é preciso passar pela revisão de verificação: o aviso "unverified app" continua, mas os tokens deixam de expirar. Depois, Configurações → Integrações → Search Console → "Desconectar" → "Conectar" uma última vez.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ export const GUIDES_RU: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'В режиме тестирования refresh-токены OAuth истекают каждые 7 дней. Настройки → Интеграции → Search Console → «Отключить» → «Подключить» для повторной авторизации. Чтобы избавиться от истечения, нужно провести приложение через проверку Google (1-4 недели).',
|
'Google аннулирует refresh-токены OAuth через 7 дней, пока экран согласия находится в статусе публикации "Testing". Постоянное решение: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (статус станет "In production"). Проверка приложения не требуется: предупреждение "unverified app" останется, но токены перестанут истекать. Затем Настройки → Интеграции → Search Console → «Отключить» → «Подключить» в последний раз.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ export const GUIDES_TR: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Bağlantı 7 gün sonra patladı',
|
problem: 'Bağlantı 7 gün sonra patladı',
|
||||||
solution:
|
solution:
|
||||||
'Testing modunda OAuth refresh token\'lar 7 günde bir expire olur. Settings → Integrations → Search Console → "Disconnect" → "Connect" ile yeniden bağlan. Production\'a geçmek için Google verification süreci gerekir (1-4 hafta).',
|
'Google, OAuth consent screen "Testing" yayın durumundayken refresh token\'ları 7 gün sonra geçersiz kılar. Kalıcı çözüm: Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app" (durum "In production" olur). Verification incelemesi gerekmez — "unverified app" uyarısı kalır ama token\'lar artık expire olmaz. Ardından Settings → Integrations → Search Console → "Disconnect" → "Connect" ile son bir kez bağlan.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ export const GUIDES_ZH_CN: Record<string, Guide> = {
|
|||||||
{
|
{
|
||||||
problem: 'Connection broke after 7 days',
|
problem: 'Connection broke after 7 days',
|
||||||
solution:
|
solution:
|
||||||
'测试模式下 OAuth 刷新令牌每 7 天过期。设置 → 集成 → Search Console → “断开” → “连接”重新授权。要消除过期限制,需要让应用通过 Google 的验证流程(1-4 周)。',
|
'同意屏幕处于 "Testing" 发布状态时,Google 会在 7 天后使 OAuth 刷新令牌失效。永久解决:Google Cloud Console → APIs & Services → OAuth consent screen → "Publish app"(状态变为 "In production")。无需通过验证审核:"unverified app" 警告会保留,但令牌不再过期。然后设置 → 集成 → Search Console → “断开” → “连接”,最后再授权一次。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.addEventListener('beforeunload', flushConfigPersist);
|
window.addEventListener('beforeunload', flushConfigPersist);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
config: loadInitialConfig(),
|
config: loadInitialConfig(),
|
||||||
progress: null,
|
progress: null,
|
||||||
summary: null,
|
summary: null,
|
||||||
@@ -584,7 +584,13 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
navigateToCategory: (c) => {
|
navigateToCategory: (c) => {
|
||||||
// Some categories are only meaningful on specific tabs — switch the
|
// Some categories are only meaningful on specific tabs — switch the
|
||||||
// active tab along with the category so the correct view renders.
|
// active tab along with the category so the correct view renders.
|
||||||
const tab = tabForCategory(c);
|
// A category with no dedicated home stays on the current tab — but
|
||||||
|
// only when that tab is a URL table that honours `activeCategory`.
|
||||||
|
// Dedicated grids (Broken Links, Images, SERP, GA4, …) ignore it, so
|
||||||
|
// leaving the user there would show the old table under the new
|
||||||
|
// filter's label; land on Internal instead.
|
||||||
|
const tab =
|
||||||
|
tabForCategory(c) ?? (tabRendersUrlTable(get().activeTab) ? null : 'internal');
|
||||||
set(tab ? { activeCategory: c, activeTab: tab } : { activeCategory: c });
|
set(tab ? { activeCategory: c, activeTab: tab } : { activeCategory: c });
|
||||||
},
|
},
|
||||||
setError: (e) => set({ error: e }),
|
setError: (e) => set({ error: e }),
|
||||||
@@ -717,6 +723,28 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Tabs whose grid is the category-filtered URL table (`UrlsTab`). The rest
|
||||||
|
// mount a dedicated component that never reads `activeCategory` — keep in
|
||||||
|
// step with the tab switch in App.tsx.
|
||||||
|
const DEDICATED_GRID_TABS: ReadonlySet<TabKey> = new Set<TabKey>([
|
||||||
|
'images',
|
||||||
|
'broken-links',
|
||||||
|
'serp',
|
||||||
|
'pagespeed',
|
||||||
|
'crux',
|
||||||
|
'spelling',
|
||||||
|
'search-console',
|
||||||
|
'analytics',
|
||||||
|
'metrica',
|
||||||
|
'ai',
|
||||||
|
'seo',
|
||||||
|
'assistant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function tabRendersUrlTable(tab: TabKey): boolean {
|
||||||
|
return !DEDICATED_GRID_TABS.has(tab);
|
||||||
|
}
|
||||||
|
|
||||||
function tabForCategory(cat: UrlCategory): TabKey | null {
|
function tabForCategory(cat: UrlCategory): TabKey | null {
|
||||||
// V2 — Comprehensive sidebar-click → top-level tab routing. Lets the
|
// V2 — Comprehensive sidebar-click → top-level tab routing. Lets the
|
||||||
// user click any OverviewSidebar entry and land on the tab whose
|
// user click any OverviewSidebar entry and land on the tab whose
|
||||||
@@ -849,7 +877,10 @@ function tabForCategory(cat: UrlCategory): TabKey | null {
|
|||||||
) {
|
) {
|
||||||
return 'response-codes';
|
return 'response-codes';
|
||||||
}
|
}
|
||||||
if (cat.startsWith('issues:analytics-')) return 'analytics';
|
// Page-level tag checks (no GA4 snippet, legacy UA, …) filter the URL
|
||||||
|
// table; the 'analytics' tab is the GA4 traffic grid, which ignores the
|
||||||
|
// category entirely and would show unrelated data (or a connect screen).
|
||||||
|
if (cat.startsWith('issues:analytics-')) return 'internal';
|
||||||
if (
|
if (
|
||||||
cat === 'issues:anchor-text-too-long' ||
|
cat === 'issues:anchor-text-too-long' ||
|
||||||
cat === 'issues:anchor-text-generic' ||
|
cat === 'issues:anchor-text-generic' ||
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ function fmtTokens(a: AiResult): string {
|
|||||||
export function AiTab() {
|
export function AiTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
|
|
||||||
const [provider, setProvider] = useState<AiProvider>('openai');
|
const [provider, setProvider] = useState<AiProvider>('openai');
|
||||||
@@ -413,7 +415,13 @@ export function AiTab() {
|
|||||||
<AiDataRow
|
<AiDataRow
|
||||||
row={row}
|
row={row}
|
||||||
selected={selected.has(row.url)}
|
selected={selected.has(row.url)}
|
||||||
onToggle={() => toggleRow(row.url)}
|
onToggle={() => {
|
||||||
|
toggleRow(row.url);
|
||||||
|
// The click still picks the row for the next run; it
|
||||||
|
// also opens the page in the Detail panel below.
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ function relativeTime(iso: string): string {
|
|||||||
export function AnalyticsTab() {
|
export function AnalyticsTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
// Clicking a row opens its page in the Detail panel below, as in the URL
|
||||||
|
// tables — without this the panel kept whatever another tab had picked.
|
||||||
|
const selectedUrlId = useAppStore((s) => s.selectedUrlId);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
|
|
||||||
const [stage, setStage] = useState<Stage>('loading');
|
const [stage, setStage] = useState<Stage>('loading');
|
||||||
@@ -522,7 +527,14 @@ export function AnalyticsTab() {
|
|||||||
transform: `translateY(${vRow.start}px)`,
|
transform: `translateY(${vRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ga4DataRow row={row} />
|
<Ga4DataRow
|
||||||
|
row={row}
|
||||||
|
active={selectedUrlId === row.id}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -558,7 +570,16 @@ export function AnalyticsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Ga4DataRow({ row }: { row: Ga4Row }) {
|
function Ga4DataRow({
|
||||||
|
row,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
row: Ga4Row;
|
||||||
|
/** The page the Detail panel is showing. */
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
const a = row.ga4;
|
const a = row.ga4;
|
||||||
const status = row.statusCode;
|
const status = row.statusCode;
|
||||||
const statusCls =
|
const statusCls =
|
||||||
@@ -571,7 +592,12 @@ function Ga4DataRow({ row }: { row: Ga4Row }) {
|
|||||||
: 'text-red-400';
|
: 'text-red-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center text-[11px] odd:bg-surface-900/20 hover:bg-surface-800/40">
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`flex h-full items-center text-[11px] cursor-pointer ${
|
||||||
|
active ? 'bg-accent-500/20' : 'odd:bg-surface-900/20 hover:bg-surface-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export function BrokenLinksTab() {
|
|||||||
const activeCategory = useAppStore((s) => s.activeCategory);
|
const activeCategory = useAppStore((s) => s.activeCategory);
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
const progress = useAppStore((s) => s.progress);
|
const progress = useAppStore((s) => s.progress);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const [rows, setRows] = useState<BrokenLinkRow[]>([]);
|
const [rows, setRows] = useState<BrokenLinkRow[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
// Distinct broken target URLs — stable across crawls of different
|
// Distinct broken target URLs — stable across crawls of different
|
||||||
@@ -227,6 +229,44 @@ export function BrokenLinksTab() {
|
|||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
}, [search, internal]);
|
}, [search, internal]);
|
||||||
|
|
||||||
|
// Mirror the cell selection into the global URL selection so the
|
||||||
|
// Detail panel below follows the clicked row's *source* page — the
|
||||||
|
// crawled URL that carries the broken link. Without this the panel
|
||||||
|
// kept showing whatever was picked on the previous tab. Multi-row
|
||||||
|
// picks aggregate (distinct pages, table order); the anchor cell's
|
||||||
|
// row is the primary one. An empty local selection is left alone:
|
||||||
|
// filter changes reset it, and that must not blank the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
const ids: number[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const rowIdxs = [...selected]
|
||||||
|
.map((k) => Number(k.split(':')[0]))
|
||||||
|
.filter((n) => Number.isFinite(n))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
for (const ri of rowIdxs) {
|
||||||
|
const id = rows[ri]?.fromUrlId;
|
||||||
|
if (id === undefined || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const anchorId = anchorRef.current ? rows[anchorRef.current.r]?.fromUrlId : undefined;
|
||||||
|
const primary = anchorId !== undefined && seen.has(anchorId) ? anchorId : ids[0]!;
|
||||||
|
// A poll refresh replaces `rows` with equal content; skip the write
|
||||||
|
// when nothing changed so the Detail panel isn't re-fetched every tick.
|
||||||
|
const cur = useAppStore.getState();
|
||||||
|
if (
|
||||||
|
cur.selectedUrlId === primary &&
|
||||||
|
cur.selectedUrlIds.length === ids.length &&
|
||||||
|
cur.selectedUrlIds.every((id, i) => id === ids[i])
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedUrlId(primary);
|
||||||
|
setSelectedUrlIds(ids);
|
||||||
|
}, [selected, rows, setSelectedUrlId, setSelectedUrlIds]);
|
||||||
|
|
||||||
// Any mouseup ends a drag — even one released outside the table.
|
// Any mouseup ends a drag — even one released outside the table.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ function relativeTime(iso: string): string {
|
|||||||
export function CruxTab() {
|
export function CruxTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
const setSettingsOpen = useAppStore((s) => s.setSettingsOpen);
|
const setSettingsOpen = useAppStore((s) => s.setSettingsOpen);
|
||||||
|
|
||||||
@@ -409,7 +411,13 @@ export function CruxTab() {
|
|||||||
<CruxTableRow
|
<CruxTableRow
|
||||||
row={row}
|
row={row}
|
||||||
selected={selected.has(row.url)}
|
selected={selected.has(row.url)}
|
||||||
onToggle={() => toggleRow(row.url)}
|
onToggle={() => {
|
||||||
|
toggleRow(row.url);
|
||||||
|
// The click still picks the row for the next run; it
|
||||||
|
// also opens the page in the Detail panel below.
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ function relativeTime(iso: string): string {
|
|||||||
export function PageSpeedTab() {
|
export function PageSpeedTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
const setSettingsOpen = useAppStore((s) => s.setSettingsOpen);
|
const setSettingsOpen = useAppStore((s) => s.setSettingsOpen);
|
||||||
|
|
||||||
@@ -444,7 +446,13 @@ export function PageSpeedTab() {
|
|||||||
<PageSpeedRow
|
<PageSpeedRow
|
||||||
row={row}
|
row={row}
|
||||||
selected={selected.has(row.url)}
|
selected={selected.has(row.url)}
|
||||||
onToggle={() => toggleRow(row.url)}
|
onToggle={() => {
|
||||||
|
toggleRow(row.url);
|
||||||
|
// The click still picks the row for the next run; it
|
||||||
|
// also opens the page in the Detail panel below.
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ function relativeTime(iso: string): string {
|
|||||||
export function SearchConsoleTab() {
|
export function SearchConsoleTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
// Clicking a row opens its page in the Detail panel below, as in the URL
|
||||||
|
// tables — without this the panel kept whatever another tab had picked.
|
||||||
|
const selectedUrlId = useAppStore((s) => s.selectedUrlId);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
|
|
||||||
const [stage, setStage] = useState<Stage>('loading');
|
const [stage, setStage] = useState<Stage>('loading');
|
||||||
@@ -692,7 +697,16 @@ export function SearchConsoleTab() {
|
|||||||
transform: `translateY(${vRow.start}px)`,
|
transform: `translateY(${vRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GscDataRow row={row} />
|
<GscDataRow
|
||||||
|
row={row}
|
||||||
|
active={row.id !== null && selectedUrlId === row.id}
|
||||||
|
onSelect={() => {
|
||||||
|
// A GSC URL the crawl never reached has no page to open.
|
||||||
|
if (row.id === null) return;
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -728,7 +742,16 @@ export function SearchConsoleTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GscDataRow({ row }: { row: GscRow }) {
|
function GscDataRow({
|
||||||
|
row,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
row: GscRow;
|
||||||
|
/** The page the Detail panel is showing. */
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
const g = row.gsc;
|
const g = row.gsc;
|
||||||
const status = row.statusCode;
|
const status = row.statusCode;
|
||||||
const statusCls =
|
const statusCls =
|
||||||
@@ -741,7 +764,12 @@ function GscDataRow({ row }: { row: GscRow }) {
|
|||||||
: 'text-red-400';
|
: 'text-red-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center text-[11px] odd:bg-surface-900/20 hover:bg-surface-800/40">
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`flex h-full items-center text-[11px] ${row.id !== null ? 'cursor-pointer' : ''} ${
|
||||||
|
active ? 'bg-accent-500/20' : 'odd:bg-surface-900/20 hover:bg-surface-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
<div className="flex-1 truncate px-2 text-surface-200" title={row.url}>
|
||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ function metricValue(provider: SeoProvider, metrics: SeoMetrics | null, key: str
|
|||||||
export function SeoTab() {
|
export function SeoTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||||
|
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
|
||||||
|
const setSelectedUrlIds = useAppStore((s) => s.setSelectedUrlIds);
|
||||||
const crawlProgress = useAppStore((s) => s.progress);
|
const crawlProgress = useAppStore((s) => s.progress);
|
||||||
|
|
||||||
const [provider, setProvider] = useState<SeoProvider>('ahrefs');
|
const [provider, setProvider] = useState<SeoProvider>('ahrefs');
|
||||||
@@ -388,7 +390,13 @@ export function SeoTab() {
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
cols={cols}
|
cols={cols}
|
||||||
selected={selected.has(row.url)}
|
selected={selected.has(row.url)}
|
||||||
onToggle={() => toggleRow(row.url)}
|
onToggle={() => {
|
||||||
|
toggleRow(row.url);
|
||||||
|
// The click still picks the row for the next run; it
|
||||||
|
// also opens the page in the Detail panel below.
|
||||||
|
setSelectedUrlId(row.id);
|
||||||
|
setSelectedUrlIds([row.id]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,17 +104,38 @@ function resolveUrlId(
|
|||||||
throw new Error('Tool requires either `id` (number) or `url` (string).');
|
throw new Error('Tool requires either `id` (number) or `url` (string).');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One integration's per-project settings blob, as the desktop saves it
|
||||||
|
* (`integrationSettings:<id>`), or `{}` when missing or corrupt. */
|
||||||
|
function integrationSettings(db: ProjectDb, id: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const raw = db.getMeta(`integrationSettings:${id}`);
|
||||||
|
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Google account whose GSC / GA4 rows this project reads — the one
|
||||||
|
* the desktop saved for it (account picker, `google_account_set`, every
|
||||||
|
* fetch). Rows are keyed by account since multi-account, so querying
|
||||||
|
* without it finds nothing. The linked-account list lives in the
|
||||||
|
* desktop's credential store, out of this process's reach, so a project
|
||||||
|
* with no saved choice falls back to the account of its latest pull.
|
||||||
|
*/
|
||||||
|
function savedGoogleAccount(db: ProjectDb, id: 'gsc' | 'ga4'): string {
|
||||||
|
const saved = integrationSettings(db, id)['accountId'];
|
||||||
|
if (typeof saved === 'string' && saved) return saved;
|
||||||
|
return db.latestGoogleAccountId(id === 'gsc' ? 'gsc_results' : 'ga4_results');
|
||||||
|
}
|
||||||
|
|
||||||
/** The Metrica counter last picked or fetched for this project (the desktop
|
/** The Metrica counter last picked or fetched for this project (the desktop
|
||||||
* saves it on a pick in the Metrica tab and after a successful pull), or ''
|
* saves it on a pick in the Metrica tab and after a successful pull), or ''
|
||||||
* when there is none. */
|
* when there is none. */
|
||||||
function savedMetricaCounter(db: ProjectDb): string {
|
function savedMetricaCounter(db: ProjectDb): string {
|
||||||
try {
|
const saved = integrationSettings(db, 'metrica')['counterId'];
|
||||||
const raw = db.getMeta('integrationSettings:metrica');
|
return typeof saved === 'string' ? saved : '';
|
||||||
const saved = raw ? (JSON.parse(raw) as { counterId?: unknown }) : null;
|
|
||||||
return typeof saved?.counterId === 'string' ? saved.counterId : '';
|
|
||||||
} catch {
|
|
||||||
return ''; // corrupt settings — no counter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sanity-cap a numeric param without falling back to undefined. */
|
/** Sanity-cap a numeric param without falling back to undefined. */
|
||||||
@@ -611,7 +632,12 @@ export function buildTools(): Tool[] {
|
|||||||
const id = resolveUrlId(args, db);
|
const id = resolveUrlId(args, db);
|
||||||
const row = db.getUrlRowById(id);
|
const row = db.getUrlRowById(id);
|
||||||
if (!row) throw new Error(`URL id ${id} not found.`);
|
if (!row) throw new Error(`URL id ${id} not found.`);
|
||||||
return db.getUrlAnalytics(row.url, '', '', savedMetricaCounter(db));
|
return db.getUrlAnalytics(
|
||||||
|
row.url,
|
||||||
|
savedGoogleAccount(db, 'gsc'),
|
||||||
|
savedGoogleAccount(db, 'ga4'),
|
||||||
|
savedMetricaCounter(db),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -642,7 +668,7 @@ export function buildTools(): Tool[] {
|
|||||||
{
|
{
|
||||||
name: 'query_gsc',
|
name: 'query_gsc',
|
||||||
description:
|
description:
|
||||||
'Search Console rows: clicks, impressions, CTR, average position per crawled page. Returns null per-URL when the URL has not been pulled from GSC yet (configure the integration in Settings → Integrations → Search Console).',
|
'Search Console rows: clicks, impressions, CTR, average position per crawled page, from the Google account the project uses (switch with `google_account_set`). Returns null per-URL when the URL has not been pulled from GSC yet (configure the integration in Settings → Integrations → Search Console).',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -656,19 +682,25 @@ export function buildTools(): Tool[] {
|
|||||||
offset: { type: 'integer', minimum: 0 },
|
offset: { type: 'integer', minimum: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: (args, db) =>
|
handler: (args, db) => {
|
||||||
db.queryGsc({
|
// Same URL-matching options the desktop's Search Console tab joins with.
|
||||||
|
const settings = integrationSettings(db, 'gsc');
|
||||||
|
return db.queryGsc({
|
||||||
limit: clamp(args.limit, 1, 1000, 50),
|
limit: clamp(args.limit, 1, 1000, 50),
|
||||||
offset: clamp(args.offset, 0, 10_000_000, 0),
|
offset: clamp(args.offset, 0, 10_000_000, 0),
|
||||||
search: typeof args.search === 'string' ? args.search : undefined,
|
search: typeof args.search === 'string' ? args.search : undefined,
|
||||||
filter: args.filter as 'all' | 'with-data' | 'without-data' | undefined,
|
filter: args.filter as 'all' | 'with-data' | 'without-data' | undefined,
|
||||||
}),
|
accountId: savedGoogleAccount(db, 'gsc'),
|
||||||
|
matchSlash: typeof settings['matchSlash'] === 'boolean' ? settings['matchSlash'] : undefined,
|
||||||
|
matchCase: typeof settings['matchCase'] === 'boolean' ? settings['matchCase'] : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'query_ga4',
|
name: 'query_ga4',
|
||||||
description:
|
description:
|
||||||
'GA4 rows: sessions, users, bounceRate, engagementRate per crawled page. Configure the integration in Settings → Integrations → GA4 to populate.',
|
'GA4 rows: sessions, users, bounceRate, engagementRate per crawled page, from the Google account the project uses (switch with `google_account_set`). Configure the integration in Settings → Integrations → GA4 to populate.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -687,6 +719,7 @@ export function buildTools(): Tool[] {
|
|||||||
offset: clamp(args.offset, 0, 10_000_000, 0),
|
offset: clamp(args.offset, 0, 10_000_000, 0),
|
||||||
search: typeof args.search === 'string' ? args.search : undefined,
|
search: typeof args.search === 'string' ? args.search : undefined,
|
||||||
filter: args.filter as 'all' | 'with-data' | 'without-data' | undefined,
|
filter: args.filter as 'all' | 'with-data' | 'without-data' | undefined,
|
||||||
|
accountId: savedGoogleAccount(db, 'ga4'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1173,8 +1206,8 @@ export function buildTools(): Tool[] {
|
|||||||
handler: (args, db) => ({
|
handler: (args, db) => ({
|
||||||
rows: db.orphanPagesCrossSource(
|
rows: db.orphanPagesCrossSource(
|
||||||
clamp(args.limit, 1, 10_000, 1000),
|
clamp(args.limit, 1, 10_000, 1000),
|
||||||
'',
|
savedGoogleAccount(db, 'gsc'),
|
||||||
'',
|
savedGoogleAccount(db, 'ga4'),
|
||||||
savedMetricaCounter(db),
|
savedMetricaCounter(db),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -96,8 +96,11 @@ noun and the second the verb.
|
|||||||
|
|
||||||
- **es / pt-BR / fr / it** — use the infinitive for button labels and menu
|
- **es / pt-BR / fr / it** — use the infinitive for button labels and menu
|
||||||
actions (`Exportar`, `Exporter`, `Esportare` → but prefer the established
|
actions (`Exportar`, `Exporter`, `Esportare` → but prefer the established
|
||||||
UI convention: `Exportar` / `Exporter` / `Esporta`). Address the user with
|
UI convention: `Exportar` / `Exporter` / `Esporta`). Address the user in
|
||||||
the formal/impersonal register; avoid `tú` / `tu` imperatives in help text.
|
the register each locale already ships: `es` and `it` use `tú` / `tu`
|
||||||
|
imperatives (`Abre…`, `Apri…`), `fr` uses `vous` (`Ouvrez…`) and `pt-BR`
|
||||||
|
uses `você` (`Abra…`). Keep new strings in that register — switching
|
||||||
|
register inside one language reads as machine translation.
|
||||||
- **ru** — button labels are imperative verbs or verbal nouns
|
- **ru** — button labels are imperative verbs or verbal nouns
|
||||||
(`Экспорт`, `Остановить`). Respect the three plural forms
|
(`Экспорт`, `Остановить`). Respect the three plural forms
|
||||||
(`_one` / `_few` / `_many` / `_other`).
|
(`_one` / `_few` / `_many` / `_other`).
|
||||||
|
|||||||
@@ -4377,6 +4377,9 @@ export class Crawler extends EventEmitter {
|
|||||||
titleCount: parsed.titleCount,
|
titleCount: parsed.titleCount,
|
||||||
imagesEmptyAlt: parsed.imagesEmptyAlt,
|
imagesEmptyAlt: parsed.imagesEmptyAlt,
|
||||||
emptyAnchorCount: parsed.emptyAnchorCount,
|
emptyAnchorCount: parsed.emptyAnchorCount,
|
||||||
|
anchorTooLongCount: parsed.anchorTooLongCount,
|
||||||
|
genericAnchorCount: parsed.genericAnchorCount,
|
||||||
|
targetBlankNoNoopenerCount: parsed.targetBlankNoNoopenerCount,
|
||||||
microdataCount: parsed.microdataCount,
|
microdataCount: parsed.microdataCount,
|
||||||
rdfaCount: parsed.rdfaCount,
|
rdfaCount: parsed.rdfaCount,
|
||||||
insecureFormActionCount: parsed.insecureFormActionCount,
|
insecureFormActionCount: parsed.insecureFormActionCount,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
LinkPosition,
|
LinkPosition,
|
||||||
SchemaFinding,
|
SchemaFinding,
|
||||||
} from '@freecrawl/shared-types';
|
} from '@freecrawl/shared-types';
|
||||||
|
import { GENERIC_ANCHOR_TEXTS } from '@freecrawl/shared-types';
|
||||||
import {
|
import {
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
isSameHost,
|
isSameHost,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
import { computeContentFingerprint } from './simhash.js';
|
import { computeContentFingerprint } from './simhash.js';
|
||||||
import { runExtractionRules } from './extraction.js';
|
import { runExtractionRules } from './extraction.js';
|
||||||
|
|
||||||
|
const GENERIC_ANCHORS: ReadonlySet<string> = new Set(GENERIC_ANCHOR_TEXTS);
|
||||||
|
|
||||||
export interface HreflangEntry {
|
export interface HreflangEntry {
|
||||||
/** Language tag from `hreflang` attribute (e.g. "tr", "en-US", "x-default"). */
|
/** Language tag from `hreflang` attribute (e.g. "tr", "en-US", "x-default"). */
|
||||||
lang: string;
|
lang: string;
|
||||||
@@ -201,6 +204,12 @@ export interface ParsedPage {
|
|||||||
feedUrl: string | null;
|
feedUrl: string | null;
|
||||||
/** Number of internal hyperlinks with no usable anchor text or image alt. */
|
/** Number of internal hyperlinks with no usable anchor text or image alt. */
|
||||||
emptyAnchorCount: number;
|
emptyAnchorCount: number;
|
||||||
|
/** `<a href>` elements whose anchor text is longer than 100 characters. */
|
||||||
|
anchorTooLongCount: number;
|
||||||
|
/** `<a href>` elements whose anchor is a generic phrase (`GENERIC_ANCHOR_TEXTS`). */
|
||||||
|
genericAnchorCount: number;
|
||||||
|
/** `<a target="_blank">` elements without `rel="noopener"` / `noreferrer`. */
|
||||||
|
targetBlankNoNoopenerCount: number;
|
||||||
/**
|
/**
|
||||||
* Number of `<a>` elements that look clickable but are NOT crawlable —
|
* Number of `<a>` elements that look clickable but are NOT crawlable —
|
||||||
* either no `href` at all (with `onclick`), `href="javascript:…"`, or
|
* either no `href` at all (with `onclick`), `href="javascript:…"`, or
|
||||||
@@ -1289,6 +1298,9 @@ export function parseHtml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let emptyAnchorCount = 0;
|
let emptyAnchorCount = 0;
|
||||||
|
let anchorTooLongCount = 0;
|
||||||
|
let genericAnchorCount = 0;
|
||||||
|
let targetBlankNoNoopenerCount = 0;
|
||||||
// JS-only / non-crawlable anchors. Three patterns count toward this
|
// JS-only / non-crawlable anchors. Three patterns count toward this
|
||||||
// number — see the field doc on `parseHtml` return for the rationale:
|
// number — see the field doc on `parseHtml` return for the rationale:
|
||||||
// 1. `<a onclick="…">` (no href at all)
|
// 1. `<a onclick="…">` (no href at all)
|
||||||
@@ -1345,6 +1357,38 @@ export function parseHtml(
|
|||||||
}
|
}
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const rel = ($el.attr('rel') ?? '').trim().toLowerCase() || null;
|
const rel = ($el.attr('rel') ?? '').trim().toLowerCase() || null;
|
||||||
|
const target = ($el.attr('target') ?? '').trim() || null;
|
||||||
|
const rawAnchor = $el.text().replace(/\s+/g, ' ').trim().slice(0, 200) || null;
|
||||||
|
const anchor = rawAnchor ? decodeEntities(rawAnchor) : null;
|
||||||
|
|
||||||
|
// Image inside <a>: capture its alt attribute so the detail table can
|
||||||
|
// show it alongside the plain anchor text.
|
||||||
|
let altText: string | null = null;
|
||||||
|
const imgAlt = $el.find('img[alt]').first().attr('alt');
|
||||||
|
if (imgAlt !== undefined) {
|
||||||
|
altText = decodeEntities(imgAlt.trim());
|
||||||
|
if (altText === '') altText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInternal = isSameHost(pageUrl, normalized, opts);
|
||||||
|
|
||||||
|
// Link-level checks count every <a>, before the per-target dedupe
|
||||||
|
// below: the link graph keeps one record per target, so an icon-only,
|
||||||
|
// generic or `target=_blank` link to a page the nav already links to
|
||||||
|
// never reached the `links` table and the page went unflagged.
|
||||||
|
// Empty anchor: no usable text inside the <a> AND no alt on a nested
|
||||||
|
// image. Common accessibility/SEO failure on icon-only or image-only
|
||||||
|
// links. Only count internal links — externals aren't our problem.
|
||||||
|
if (isInternal && !anchor && !altText) emptyAnchorCount++;
|
||||||
|
if (anchor && anchor.length > 100) anchorTooLongCount++;
|
||||||
|
if (anchor && GENERIC_ANCHORS.has(anchor.trim().toLowerCase())) genericAnchorCount++;
|
||||||
|
if (
|
||||||
|
target?.toLowerCase() === '_blank' &&
|
||||||
|
!rel?.includes('noopener') &&
|
||||||
|
!rel?.includes('noreferrer')
|
||||||
|
) {
|
||||||
|
targetBlankNoNoopenerCount++;
|
||||||
|
}
|
||||||
|
|
||||||
// Same target linked more than once (a nav/footer link that also
|
// Same target linked more than once (a nav/footer link that also
|
||||||
// appears in the body). Keep the MOST permissive occurrence rather
|
// appears in the body). Keep the MOST permissive occurrence rather
|
||||||
@@ -1360,37 +1404,15 @@ export function parseHtml(
|
|||||||
existing.rel = rel;
|
existing.rel = rel;
|
||||||
// A followable content link also carries the more useful anchor
|
// A followable content link also carries the more useful anchor
|
||||||
// text than an icon-only nav link, so take it when we had none.
|
// text than an icon-only nav link, so take it when we had none.
|
||||||
if (!existing.anchor) {
|
if (!existing.anchor && anchor) existing.anchor = anchor;
|
||||||
const upgradedAnchor =
|
|
||||||
$el.text().replace(/\s+/g, ' ').trim().slice(0, 200) || null;
|
|
||||||
if (upgradedAnchor) existing.anchor = decodeEntities(upgradedAnchor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = ($el.attr('target') ?? '').trim() || null;
|
|
||||||
const rawAnchor = $el.text().replace(/\s+/g, ' ').trim().slice(0, 200) || null;
|
|
||||||
const anchor = rawAnchor ? decodeEntities(rawAnchor) : null;
|
|
||||||
|
|
||||||
// Image inside <a>: capture its alt attribute so the detail table can
|
|
||||||
// show it alongside the plain anchor text.
|
|
||||||
let altText: string | null = null;
|
|
||||||
const imgAlt = $el.find('img[alt]').first().attr('alt');
|
|
||||||
if (imgAlt !== undefined) {
|
|
||||||
altText = decodeEntities(imgAlt.trim());
|
|
||||||
if (altText === '') altText = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInternal = isSameHost(pageUrl, normalized, opts);
|
|
||||||
const pathType = detectPathType(href);
|
const pathType = detectPathType(href);
|
||||||
const linkPath = buildLinkPath(el);
|
const linkPath = buildLinkPath(el);
|
||||||
const linkPosition = detectLinkPosition(el);
|
const linkPosition = detectLinkPosition(el);
|
||||||
|
|
||||||
// Empty anchor: no usable text inside the <a> AND no alt on a nested
|
|
||||||
// image. Common accessibility/SEO failure on icon-only or image-only
|
|
||||||
// links. Only count internal links — externals aren't our problem.
|
|
||||||
if (isInternal && !anchor && !altText) emptyAnchorCount++;
|
|
||||||
|
|
||||||
linkMap.set(normalized, {
|
linkMap.set(normalized, {
|
||||||
fromUrl: pageUrl,
|
fromUrl: pageUrl,
|
||||||
toUrl: normalized,
|
toUrl: normalized,
|
||||||
@@ -1739,6 +1761,9 @@ export function parseHtml(
|
|||||||
manifestUrl,
|
manifestUrl,
|
||||||
feedUrl,
|
feedUrl,
|
||||||
emptyAnchorCount,
|
emptyAnchorCount,
|
||||||
|
anchorTooLongCount,
|
||||||
|
genericAnchorCount,
|
||||||
|
targetBlankNoNoopenerCount,
|
||||||
jsOnlyLinksCount,
|
jsOnlyLinksCount,
|
||||||
textCodeRatio,
|
textCodeRatio,
|
||||||
fleschReadingEase: readability.fleschReadingEase,
|
fleschReadingEase: readability.fleschReadingEase,
|
||||||
|
|||||||
@@ -2343,6 +2343,29 @@ const MIGRATIONS: Migration[] = [
|
|||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 94,
|
||||||
|
name: 'add_link_level_anchor_counters',
|
||||||
|
// Per-page counters the HTML parser takes over every `<a>`. The link
|
||||||
|
// graph keeps one record per target, so a long, generic or
|
||||||
|
// `target=_blank` link to a page the nav already links to never
|
||||||
|
// reached `links` and the anchor checks missed the page.
|
||||||
|
up: (db) => {
|
||||||
|
const cols = db
|
||||||
|
.prepare('PRAGMA table_info(urls)')
|
||||||
|
.all() as unknown as { name: string }[];
|
||||||
|
const names = new Set(cols.map((c) => c.name));
|
||||||
|
for (const col of [
|
||||||
|
'anchor_too_long_count',
|
||||||
|
'generic_anchor_count',
|
||||||
|
'target_blank_no_noopener_count',
|
||||||
|
]) {
|
||||||
|
if (!names.has(col)) {
|
||||||
|
db.exec(`ALTER TABLE urls ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import type {
|
|||||||
LogThreatSummary,
|
LogThreatSummary,
|
||||||
} from '@freecrawl/shared-types';
|
} from '@freecrawl/shared-types';
|
||||||
import {
|
import {
|
||||||
|
GENERIC_ANCHOR_TEXTS,
|
||||||
ISSUE_COUNT_KEY,
|
ISSUE_COUNT_KEY,
|
||||||
issueCategoriesBySeverity,
|
issueCategoriesBySeverity,
|
||||||
normalizeDisabledIssues,
|
normalizeDisabledIssues,
|
||||||
@@ -492,6 +493,12 @@ export interface UpsertUrlInput {
|
|||||||
titleCount?: number;
|
titleCount?: number;
|
||||||
imagesEmptyAlt?: number;
|
imagesEmptyAlt?: number;
|
||||||
emptyAnchorCount?: number;
|
emptyAnchorCount?: number;
|
||||||
|
/** Per-page link-level counters over every `<a>` (not just the deduped
|
||||||
|
* link records): anchors > 100 chars, generic anchors, and
|
||||||
|
* `target=_blank` without noopener / noreferrer. */
|
||||||
|
anchorTooLongCount?: number;
|
||||||
|
genericAnchorCount?: number;
|
||||||
|
targetBlankNoNoopenerCount?: number;
|
||||||
appleTouchIcon?: string | null;
|
appleTouchIcon?: string | null;
|
||||||
manifestUrl?: string | null;
|
manifestUrl?: string | null;
|
||||||
feedUrl?: string | null;
|
feedUrl?: string | null;
|
||||||
@@ -632,6 +639,7 @@ const UPSERT_URL_SQL = `
|
|||||||
extraction_results,
|
extraction_results,
|
||||||
simhash, content_hash,
|
simhash, content_hash,
|
||||||
title_count, images_empty_alt, empty_anchor_count,
|
title_count, images_empty_alt, empty_anchor_count,
|
||||||
|
anchor_too_long_count, generic_anchor_count, target_blank_no_noopener_count,
|
||||||
apple_touch_icon, manifest_url, feed_url,
|
apple_touch_icon, manifest_url, feed_url,
|
||||||
microdata_count, rdfa_count, insecure_form_action_count, missing_sri_count,
|
microdata_count, rdfa_count, insecure_form_action_count, missing_sri_count,
|
||||||
title_pixel_width, meta_pixel_width,
|
title_pixel_width, meta_pixel_width,
|
||||||
@@ -674,6 +682,7 @@ const UPSERT_URL_SQL = `
|
|||||||
:extraction_results,
|
:extraction_results,
|
||||||
:simhash, :content_hash,
|
:simhash, :content_hash,
|
||||||
:title_count, :images_empty_alt, :empty_anchor_count,
|
:title_count, :images_empty_alt, :empty_anchor_count,
|
||||||
|
:anchor_too_long_count, :generic_anchor_count, :target_blank_no_noopener_count,
|
||||||
:apple_touch_icon, :manifest_url, :feed_url,
|
:apple_touch_icon, :manifest_url, :feed_url,
|
||||||
:microdata_count, :rdfa_count, :insecure_form_action_count, :missing_sri_count,
|
:microdata_count, :rdfa_count, :insecure_form_action_count, :missing_sri_count,
|
||||||
:title_pixel_width, :meta_pixel_width,
|
:title_pixel_width, :meta_pixel_width,
|
||||||
@@ -776,6 +785,9 @@ const UPSERT_URL_SQL = `
|
|||||||
title_count = excluded.title_count,
|
title_count = excluded.title_count,
|
||||||
images_empty_alt = excluded.images_empty_alt,
|
images_empty_alt = excluded.images_empty_alt,
|
||||||
empty_anchor_count = excluded.empty_anchor_count,
|
empty_anchor_count = excluded.empty_anchor_count,
|
||||||
|
anchor_too_long_count = excluded.anchor_too_long_count,
|
||||||
|
generic_anchor_count = excluded.generic_anchor_count,
|
||||||
|
target_blank_no_noopener_count = excluded.target_blank_no_noopener_count,
|
||||||
apple_touch_icon = excluded.apple_touch_icon,
|
apple_touch_icon = excluded.apple_touch_icon,
|
||||||
manifest_url = excluded.manifest_url,
|
manifest_url = excluded.manifest_url,
|
||||||
feed_url = excluded.feed_url,
|
feed_url = excluded.feed_url,
|
||||||
@@ -2161,6 +2173,9 @@ export class ProjectDb {
|
|||||||
title_count: input.titleCount ?? 0,
|
title_count: input.titleCount ?? 0,
|
||||||
images_empty_alt: input.imagesEmptyAlt ?? 0,
|
images_empty_alt: input.imagesEmptyAlt ?? 0,
|
||||||
empty_anchor_count: input.emptyAnchorCount ?? 0,
|
empty_anchor_count: input.emptyAnchorCount ?? 0,
|
||||||
|
anchor_too_long_count: input.anchorTooLongCount ?? 0,
|
||||||
|
generic_anchor_count: input.genericAnchorCount ?? 0,
|
||||||
|
target_blank_no_noopener_count: input.targetBlankNoNoopenerCount ?? 0,
|
||||||
apple_touch_icon: input.appleTouchIcon ?? null,
|
apple_touch_icon: input.appleTouchIcon ?? null,
|
||||||
manifest_url: input.manifestUrl ?? null,
|
manifest_url: input.manifestUrl ?? null,
|
||||||
feed_url: input.feedUrl ?? null,
|
feed_url: input.feedUrl ?? null,
|
||||||
@@ -4234,7 +4249,7 @@ export class ProjectDb {
|
|||||||
.get(...args) as { c: number; u: number };
|
.get(...args) as { c: number; u: number };
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT f.url AS from_url, f.status_code AS from_status,
|
`SELECT f.id AS from_url_id, f.url AS from_url, f.status_code AS from_status,
|
||||||
l.to_url AS to_url, t.status_code AS to_status,
|
l.to_url AS to_url, t.status_code AS to_status,
|
||||||
l.anchor, l.rel, l.is_internal
|
l.anchor, l.rel, l.is_internal
|
||||||
FROM links l
|
FROM links l
|
||||||
@@ -4245,6 +4260,7 @@ export class ProjectDb {
|
|||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
)
|
)
|
||||||
.all(...args, params.limit, params.offset) as unknown as {
|
.all(...args, params.limit, params.offset) as unknown as {
|
||||||
|
from_url_id: number;
|
||||||
from_url: string;
|
from_url: string;
|
||||||
from_status: number | null;
|
from_status: number | null;
|
||||||
to_url: string;
|
to_url: string;
|
||||||
@@ -4257,6 +4273,7 @@ export class ProjectDb {
|
|||||||
total: totalRow.c,
|
total: totalRow.c,
|
||||||
uniqueTargets: totalRow.u,
|
uniqueTargets: totalRow.u,
|
||||||
rows: rowsDb.map((r) => ({
|
rows: rowsDb.map((r) => ({
|
||||||
|
fromUrlId: r.from_url_id,
|
||||||
fromUrl: r.from_url,
|
fromUrl: r.from_url,
|
||||||
fromStatusCode: r.from_status,
|
fromStatusCode: r.from_status,
|
||||||
toUrl: r.to_url,
|
toUrl: r.to_url,
|
||||||
@@ -4354,7 +4371,7 @@ export class ProjectDb {
|
|||||||
|
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.url AS url, u.status_code AS status_code,
|
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||||
pm.performance AS m_perf, pm.lcp AS m_lcp, pm.cls AS m_cls,
|
pm.performance AS m_perf, pm.lcp AS m_lcp, pm.cls AS m_cls,
|
||||||
pm.fcp AS m_fcp, pm.tbt AS m_tbt, pm.speed_index AS m_si,
|
pm.fcp AS m_fcp, pm.tbt AS m_tbt, pm.speed_index AS m_si,
|
||||||
pm.tti AS m_tti, pm.max_potential_fid AS m_mpfid, pm.inp AS m_inp,
|
pm.tti AS m_tti, pm.max_potential_fid AS m_mpfid, pm.inp AS m_inp,
|
||||||
@@ -4398,6 +4415,7 @@ export class ProjectDb {
|
|||||||
return {
|
return {
|
||||||
total: totalRow.c,
|
total: totalRow.c,
|
||||||
rows: rowsDb.map((r) => ({
|
rows: rowsDb.map((r) => ({
|
||||||
|
id: r['id'] as number,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
mobile: metrics(r, 'm'),
|
mobile: metrics(r, 'm'),
|
||||||
@@ -4484,7 +4502,7 @@ export class ProjectDb {
|
|||||||
|
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.url AS url, u.status_code AS status_code,
|
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||||
cp.lcp AS p_lcp, cp.cls AS p_cls, cp.inp AS p_inp,
|
cp.lcp AS p_lcp, cp.cls AS p_cls, cp.inp AS p_inp,
|
||||||
cp.fcp AS p_fcp, cp.ttfb AS p_ttfb, cp.status AS p_status,
|
cp.fcp AS p_fcp, cp.ttfb AS p_ttfb, cp.status AS p_status,
|
||||||
cp.error AS p_error, cp.collection_period AS p_period, cp.fetched_at AS p_fetched,
|
cp.error AS p_error, cp.collection_period AS p_period, cp.fetched_at AS p_fetched,
|
||||||
@@ -4524,6 +4542,7 @@ export class ProjectDb {
|
|||||||
return {
|
return {
|
||||||
total: totalRow.c,
|
total: totalRow.c,
|
||||||
rows: rowsDb.map((r) => ({
|
rows: rowsDb.map((r) => ({
|
||||||
|
id: r['id'] as number,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
phone: metrics(r, 'p'),
|
phone: metrics(r, 'p'),
|
||||||
@@ -4785,6 +4804,23 @@ export class ProjectDb {
|
|||||||
return moved;
|
return moved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account id of the most recent pull stored in a GSC / GA4 results
|
||||||
|
* table, or '' when the table is empty (or holds only pre-multi-account
|
||||||
|
* rows). For readers that can't see the desktop's linked-account list —
|
||||||
|
* the MCP server — when the project has no saved account choice: the
|
||||||
|
* desktop adopts legacy rows into its first linked account without
|
||||||
|
* saving one.
|
||||||
|
*/
|
||||||
|
latestGoogleAccountId(table: 'gsc_results' | 'ga4_results'): string {
|
||||||
|
const allowed = ['gsc_results', 'ga4_results'];
|
||||||
|
if (!allowed.includes(table)) return '';
|
||||||
|
const row = this.db
|
||||||
|
.prepare(`SELECT account_id FROM ${table} ORDER BY fetched_at DESC LIMIT 1`)
|
||||||
|
.get() as { account_id: string } | undefined;
|
||||||
|
return row?.account_id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Faz 7 — candidate query for the Search Console tab. Crawled internal
|
* Faz 7 — candidate query for the Search Console tab. Crawled internal
|
||||||
* HTML 2xx pages left-joined with their stored GSC metrics, ordered
|
* HTML 2xx pages left-joined with their stored GSC metrics, ordered
|
||||||
@@ -4827,7 +4863,7 @@ export class ProjectDb {
|
|||||||
const accountId = params.accountId ?? '';
|
const accountId = params.accountId ?? '';
|
||||||
// The full column projection + row mapper is shared by both the
|
// The full column projection + row mapper is shared by both the
|
||||||
// crawled-pages branch and the orphan branch below.
|
// crawled-pages branch and the orphan branch below.
|
||||||
const selectCols = `g.clicks AS clicks, g.impressions AS impressions,
|
const selectCols = `u.id AS id, g.clicks AS clicks, g.impressions AS impressions,
|
||||||
g.ctr AS ctr, g.position AS position,
|
g.ctr AS ctr, g.position AS position,
|
||||||
g.fetched_at AS g_fetched,
|
g.fetched_at AS g_fetched,
|
||||||
i.verdict AS i_verdict,
|
i.verdict AS i_verdict,
|
||||||
@@ -4848,6 +4884,7 @@ export class ProjectDb {
|
|||||||
const iFetched = r['i_fetched'];
|
const iFetched = r['i_fetched'];
|
||||||
const iStatus = r['i_status'];
|
const iStatus = r['i_status'];
|
||||||
return {
|
return {
|
||||||
|
id: (r['id'] as number | null) ?? null,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
gsc:
|
gsc:
|
||||||
@@ -5285,7 +5322,7 @@ export class ProjectDb {
|
|||||||
|
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.url AS url, u.status_code AS status_code,
|
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||||
s.provider AS provider, s.metrics AS metrics,
|
s.provider AS provider, s.metrics AS metrics,
|
||||||
s.status AS status, s.error AS error,
|
s.status AS status, s.error AS error,
|
||||||
s.fetched_at AS fetched_at
|
s.fetched_at AS fetched_at
|
||||||
@@ -5323,6 +5360,7 @@ export class ProjectDb {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
|
id: r['id'] as number,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
seo,
|
seo,
|
||||||
@@ -5580,7 +5618,7 @@ export class ProjectDb {
|
|||||||
|
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.url AS url, u.status_code AS status_code,
|
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||||
a.sessions AS sessions, a.users AS users,
|
a.sessions AS sessions, a.users AS users,
|
||||||
a.pageviews AS pageviews,
|
a.pageviews AS pageviews,
|
||||||
a.engagement_rate AS engagement_rate,
|
a.engagement_rate AS engagement_rate,
|
||||||
@@ -5601,6 +5639,7 @@ export class ProjectDb {
|
|||||||
rows: rowsDb.map((r) => {
|
rows: rowsDb.map((r) => {
|
||||||
const fetchedAt = r['fetched_at'];
|
const fetchedAt = r['fetched_at'];
|
||||||
return {
|
return {
|
||||||
|
id: r['id'] as number,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
ga4:
|
ga4:
|
||||||
@@ -5743,7 +5782,7 @@ export class ProjectDb {
|
|||||||
|
|
||||||
const rowsDb = this.db
|
const rowsDb = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.url AS url, u.status_code AS status_code,
|
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||||
a.provider AS provider, a.model AS model,
|
a.provider AS provider, a.model AS model,
|
||||||
a.response AS response,
|
a.response AS response,
|
||||||
a.tokens_in AS tokens_in, a.tokens_out AS tokens_out,
|
a.tokens_in AS tokens_in, a.tokens_out AS tokens_out,
|
||||||
@@ -5778,6 +5817,7 @@ export class ProjectDb {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
|
id: r['id'] as number,
|
||||||
url: r['url'] as string,
|
url: r['url'] as string,
|
||||||
statusCode: (r['status_code'] as number | null) ?? null,
|
statusCode: (r['status_code'] as number | null) ?? null,
|
||||||
ai,
|
ai,
|
||||||
@@ -7543,7 +7583,7 @@ export class ProjectDb {
|
|||||||
}
|
}
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT f.url AS from_url, f.status_code AS from_status,
|
`SELECT f.id AS from_url_id, f.url AS from_url, f.status_code AS from_status,
|
||||||
l.to_url AS to_url, t.status_code AS to_status,
|
l.to_url AS to_url, t.status_code AS to_status,
|
||||||
l.anchor, l.rel, l.is_internal
|
l.anchor, l.rel, l.is_internal
|
||||||
FROM links l
|
FROM links l
|
||||||
@@ -7558,6 +7598,7 @@ export class ProjectDb {
|
|||||||
// footer produces one row per page, so a large site turns one broken
|
// footer produces one row per page, so a large site turns one broken
|
||||||
// URL into hundreds of thousands of rows held in memory at once.
|
// URL into hundreds of thousands of rows held in memory at once.
|
||||||
.iterate(...args) as unknown as Iterable<{
|
.iterate(...args) as unknown as Iterable<{
|
||||||
|
from_url_id: number;
|
||||||
from_url: string;
|
from_url: string;
|
||||||
from_status: number | null;
|
from_status: number | null;
|
||||||
to_url: string;
|
to_url: string;
|
||||||
@@ -7568,6 +7609,7 @@ export class ProjectDb {
|
|||||||
}>;
|
}>;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
yield {
|
yield {
|
||||||
|
fromUrlId: r.from_url_id,
|
||||||
fromUrl: r.from_url,
|
fromUrl: r.from_url,
|
||||||
fromStatusCode: r.from_status,
|
fromStatusCode: r.from_status,
|
||||||
toUrl: r.to_url,
|
toUrl: r.to_url,
|
||||||
@@ -9990,6 +10032,11 @@ function urlPostNormSql(col: string): string {
|
|||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A JS string as a single-quoted SQL literal. */
|
||||||
|
function sqlString(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Rows per write transaction in the yielding issue recompute. */
|
/** Rows per write transaction in the yielding issue recompute. */
|
||||||
const ISSUE_WRITE_BATCH = 2_000;
|
const ISSUE_WRITE_BATCH = 2_000;
|
||||||
|
|
||||||
@@ -10063,39 +10110,48 @@ export const EXPENSIVE_ISSUE_DEFINITIONS: ReadonlyArray<readonly [string, string
|
|||||||
// aggregate ran past the 60 s reader budget on every tick.
|
// aggregate ran past the 60 s reader budget on every tick.
|
||||||
[
|
[
|
||||||
'issues:anchor-text-too-long',
|
'issues:anchor-text-too-long',
|
||||||
|
// The column counts every <a> on the page (the link graph keeps one
|
||||||
|
// record per target); the links sub-select keeps projects crawled
|
||||||
|
// before the column existed flagged until their next crawl.
|
||||||
`is_external = 0 AND content_kind = 'html'
|
`is_external = 0 AND content_kind = 'html'
|
||||||
AND id IN (
|
AND (
|
||||||
SELECT from_url_id FROM links
|
anchor_too_long_count > 0
|
||||||
WHERE anchor IS NOT NULL AND LENGTH(anchor) > 100
|
OR id IN (
|
||||||
|
SELECT from_url_id FROM links
|
||||||
|
WHERE anchor IS NOT NULL AND LENGTH(anchor) > 100
|
||||||
|
)
|
||||||
)`,
|
)`,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'issues:anchor-text-generic',
|
'issues:anchor-text-generic',
|
||||||
|
// Same column + legacy fallback split as anchor-text-too-long.
|
||||||
`is_external = 0 AND content_kind = 'html'
|
`is_external = 0 AND content_kind = 'html'
|
||||||
AND id IN (
|
AND (
|
||||||
SELECT from_url_id FROM links
|
generic_anchor_count > 0
|
||||||
WHERE anchor IS NOT NULL
|
OR id IN (
|
||||||
AND LOWER(TRIM(anchor)) IN (
|
SELECT from_url_id FROM links
|
||||||
'click here', 'click', 'here', 'read more', 'more',
|
WHERE anchor IS NOT NULL
|
||||||
'learn more', 'see more', 'continue reading', 'continue',
|
AND LOWER(TRIM(anchor)) IN (${GENERIC_ANCHOR_TEXTS.map(sqlString).join(', ')})
|
||||||
'this link', 'link', 'go', 'buraya', 'tıkla', 'devamı',
|
)
|
||||||
'devamını oku', 'daha fazla'
|
|
||||||
)
|
|
||||||
)`,
|
)`,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'issues:target-blank-no-noopener',
|
'issues:target-blank-no-noopener',
|
||||||
|
// Same column + legacy fallback split as anchor-text-too-long.
|
||||||
`is_external = 0 AND content_kind = 'html'
|
`is_external = 0 AND content_kind = 'html'
|
||||||
AND id IN (
|
AND (
|
||||||
SELECT from_url_id FROM links
|
target_blank_no_noopener_count > 0
|
||||||
WHERE LOWER(COALESCE(target, '')) = '_blank'
|
OR id IN (
|
||||||
AND (
|
SELECT from_url_id FROM links
|
||||||
rel IS NULL
|
WHERE LOWER(COALESCE(target, '')) = '_blank'
|
||||||
OR (
|
AND (
|
||||||
LOWER(rel) NOT LIKE '%noopener%'
|
rel IS NULL
|
||||||
AND LOWER(rel) NOT LIKE '%noreferrer%'
|
OR (
|
||||||
|
LOWER(rel) NOT LIKE '%noopener%'
|
||||||
|
AND LOWER(rel) NOT LIKE '%noreferrer%'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)`,
|
)`,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface AiResult {
|
|||||||
|
|
||||||
/** One AI tab row — a crawled page joined with its latest AI result. */
|
/** One AI tab row — a crawled page joined with its latest AI result. */
|
||||||
export interface AiRow {
|
export interface AiRow {
|
||||||
|
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
/** Null when this page has no result for the queried provider. */
|
/** Null when this page has no result for the queried provider. */
|
||||||
|
|||||||
@@ -2329,6 +2329,8 @@ export interface AdvancedFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokenLinkRow {
|
export interface BrokenLinkRow {
|
||||||
|
/** Crawled id of the linking page — lets the Broken Links grid open it in the Detail panel. */
|
||||||
|
fromUrlId: number;
|
||||||
fromUrl: string;
|
fromUrl: string;
|
||||||
fromStatusCode: number | null;
|
fromStatusCode: number | null;
|
||||||
toUrl: string;
|
toUrl: string;
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface CruxMetrics {
|
|||||||
* has been fetched for the URL.
|
* has been fetched for the URL.
|
||||||
*/
|
*/
|
||||||
export interface CruxRow {
|
export interface CruxRow {
|
||||||
|
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
phone: CruxMetrics | null;
|
phone: CruxMetrics | null;
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface GscMetrics {
|
|||||||
|
|
||||||
/** One Search Console tab row — a crawled page joined with its GSC data. */
|
/** One Search Console tab row — a crawled page joined with its GSC data. */
|
||||||
export interface GscRow {
|
export interface GscRow {
|
||||||
|
/** `urls.id` the Detail panel opens on a row click; null for a GSC URL
|
||||||
|
* the crawl never reached (the orphan presets). */
|
||||||
|
id: number | null;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
/** Null when this page has no Search Console impressions in the range. */
|
/** Null when this page has no Search Console impressions in the range. */
|
||||||
@@ -249,6 +252,8 @@ export interface Ga4Metrics {
|
|||||||
|
|
||||||
/** One Analytics tab row — a crawled page joined with its GA4 data. */
|
/** One Analytics tab row — a crawled page joined with its GA4 data. */
|
||||||
export interface Ga4Row {
|
export interface Ga4Row {
|
||||||
|
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
/** Null when the page had no GA4 traffic in the range. */
|
/** Null when the page had no GA4 traffic in the range. */
|
||||||
|
|||||||
@@ -385,6 +385,18 @@ export const ISSUE_CATEGORIES: readonly UrlCategory[] = ISSUE_GROUPS.flatMap((g)
|
|||||||
g.items.map((i) => i.category),
|
g.items.map((i) => i.category),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor texts the "Generic Anchor Text" check flags, lower-cased. One list
|
||||||
|
* for both sides: the HTML parser counts them over every `<a>` on a page,
|
||||||
|
* and the issue SQL matches them against stored link anchors.
|
||||||
|
*/
|
||||||
|
export const GENERIC_ANCHOR_TEXTS: readonly string[] = [
|
||||||
|
'click here', 'click', 'here', 'read more', 'more',
|
||||||
|
'learn more', 'see more', 'continue reading', 'continue',
|
||||||
|
'this link', 'link', 'go', 'buraya', 'tıkla', 'devamı',
|
||||||
|
'devamını oku', 'daha fazla',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalises a saved `disabledIssues` list: drops unknown ids (a check
|
* Normalises a saved `disabledIssues` list: drops unknown ids (a check
|
||||||
* renamed since the project was saved) and duplicates, keeps sidebar order.
|
* renamed since the project was saved) and duplicates, keeps sidebar order.
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface PagespeedMetrics {
|
|||||||
* strategy has been run for the URL.
|
* strategy has been run for the URL.
|
||||||
*/
|
*/
|
||||||
export interface PagespeedRow {
|
export interface PagespeedRow {
|
||||||
|
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
mobile: PagespeedMetrics | null;
|
mobile: PagespeedMetrics | null;
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface SeoResult {
|
|||||||
|
|
||||||
/** One SEO tab row — a crawled page joined with its provider data. */
|
/** One SEO tab row — a crawled page joined with its provider data. */
|
||||||
export interface SeoRow {
|
export interface SeoRow {
|
||||||
|
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
statusCode: number | null;
|
statusCode: number | null;
|
||||||
/** Null when this page has no result for the currently-queried provider. */
|
/** Null when this page has no result for the currently-queried provider. */
|
||||||
|
|||||||
Reference in new issue
Block a user