fix: anchor checks, URL details in grids, MCP and OAuth refresh errors

This commit is contained in:
kemalai committed 2026-10-13 19:33:48 +03:00
1 parent 1a62e1e9af
commit 44bb3d09c6
45 files changed
+480 -94

No files matched your search

+12 -1
View File
@@ -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;
+4
View File
@@ -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> = {
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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" (सत्यापन समीक्षा की ज़रूरत नहीं), फिर इस इंटीग्रेशन को डिस्कनेक्ट करके दोबारा कनेक्ट करें।',
}; };
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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.',
}; };
+4
View File
@@ -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" (проверка не требуется), затем отключите и снова подключите эту интеграцию.',
}; };
+4
View File
@@ -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: [
+34 -3
View File
@@ -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' ||
+9 -1
View File
@@ -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>
); );
+48 -15
View File
@@ -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),
), ),
}), }),
+5 -2
View File
@@ -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`).
+3
View File
@@ -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,
+48 -23
View File
@@ -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,
+23
View File
@@ -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 {
+85 -29
View File
@@ -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%'
)
) )
) )
)`, )`,
], ],
[ [
+2
View File
@@ -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. */
+2
View File
@@ -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;
+2
View File
@@ -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;
+5
View File
@@ -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.
+2
View File
@@ -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;
+2
View File
@@ -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. */