mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: Yandex Metrica integration (#29)
This commit is contained in:
1 parent
bad754574e
commit
1a62e1e9af
56 files changed
+4277
-93
No files matched your search
@@ -250,12 +250,12 @@ FreeCrawl ships an **MCP (Model Context Protocol) server** that exposes the acti
|
||||
| :--- | :--- |
|
||||
| 📊 **Top-level data** | `get_summary`, `get_overview_counts`, `top_issues`, `query_urls`, `get_url_detail` |
|
||||
| 🔬 **Per-URL detail sub-tabs** | `get_url_source` (raw + rendered + screenshot paths), `get_url_inlinks`, `get_url_outlinks`, `get_url_images`, `get_url_headers`, `get_url_duplicates`, `get_url_analytics`, `get_url_cert` |
|
||||
| 🧩 **Integration rows** | `query_gsc`, `query_ga4`, `query_pagespeed`, `query_crux`, `query_spelling`, `get_url_spelling`, `query_ai`, `query_seo` |
|
||||
| 🧩 **Integration rows** | `query_gsc`, `query_ga4`, `query_metrica`, `query_pagespeed`, `query_crux`, `query_spelling`, `get_url_spelling`, `query_ai`, `query_seo` |
|
||||
| 🔎 **Specialised queries** | `query_images`, `query_broken_links`, `list_duplicate_clusters` |
|
||||
| 📈 **Reports** | `report_status_code_histogram`, `report_indexability_distribution`, `report_content_kind_distribution`, `report_depth_histogram`, `report_response_time_histogram`, `report_inlinks_histogram`, `report_word_count_histogram`, `report_url_length_histogram`, `report_top_urls_by`, `report_top_anchor_texts`, `report_external_domain_health`, `report_image_weight_per_page`, `report_link_position_breakdown`, `report_pages_per_directory`, `report_word_count_per_directory`, `report_sitemap_orphans`, `report_orphan_cross_source`, `report_analytics_coverage`, `report_server_header_breakdown`, `report_query_string_variants`, `report_sitemap_priority_mismatch`, `report_top_words` |
|
||||
| 📤 **Exports & mutations** | `export_csv`, `export_json`, `export_xml`, `export_html_report`, `export_tabular`, `export_bulk`, `export_broken_links`, `export_images`, `sitemap_generate`, `sitemap_validate`, `robots_test`, `robots_validate`, `compare_load`, `crawl_add_url`, `respider_urls`, `remove_urls`, `data_delete_by_domain`, `graph_snapshot`, `get_crawl_path`, `url_rewrite_preview`, `extraction_preview` |
|
||||
| 🔐 **Project files** | `project_save_as`, `project_save_encrypted`, `project_open_encrypted` |
|
||||
| 🔌 **Integration fetch** | `google_auth_status`, `gsc_list_sites`, `gsc_fetch`, `ga4_list_properties`, `ga4_fetch`, `pagespeed_run`, `crux_run`, `spelling_run`, `ai_run`, `seo_run`, `get_fetch_progress`, `cancel_fetch` |
|
||||
| 🔌 **Integration fetch** | `google_auth_status`, `gsc_list_sites`, `gsc_fetch`, `ga4_list_properties`, `ga4_fetch`, `metrica_list_counters`, `metrica_fetch`, `pagespeed_run`, `crux_run`, `spelling_run`, `ai_run`, `seo_run`, `get_fetch_progress`, `cancel_fetch` |
|
||||
| ⚙ **Config & schedule** | `get_crawl_config`, `set_crawl_config`, `schedule_get`, `schedule_set` |
|
||||
| 📁 **Project management** | `list_projects`, `set_project`, `current_project` |
|
||||
| 🕷 **Crawl control** (desktop must be open) | `start_crawl`, `stop_crawl`, `pause_crawl`, `resume_crawl`, `clear_crawl`, `get_crawl_progress`, `get_desktop_project` |
|
||||
|
||||
@@ -75,6 +75,7 @@ const ALLOWED_METHODS = new Set<string>([
|
||||
'getSpellingMatches',
|
||||
'queryGsc',
|
||||
'queryGa4',
|
||||
'queryMetrica',
|
||||
'queryAi',
|
||||
'querySeo',
|
||||
'getUrlAnalytics',
|
||||
|
||||
@@ -140,6 +140,7 @@ const ALLOWED_METHODS = new Set<string>([
|
||||
'deleteUrls',
|
||||
'reset',
|
||||
'deleteByDomain',
|
||||
'replaceMetricaResults',
|
||||
]);
|
||||
|
||||
parentPort.on('message', async (msg: RequestMessage) => {
|
||||
|
||||
@@ -184,6 +184,17 @@ import {
|
||||
type Ga4FetchMeta,
|
||||
type Ga4QueryInput,
|
||||
type Ga4QueryResult,
|
||||
type MetricaIntegrationSettings,
|
||||
defaultMetricaSettings,
|
||||
type MetricaListCountersResult,
|
||||
type MetricaFetchInput,
|
||||
type MetricaFetchResult,
|
||||
type MetricaFetchMeta,
|
||||
type MetricaQueryInput,
|
||||
type MetricaQueryResult,
|
||||
type MetricaProgress,
|
||||
type MetricaNotice,
|
||||
type MetricaErrorCode,
|
||||
type AiRunInput,
|
||||
type AiRunResult,
|
||||
type AiQueryInput,
|
||||
@@ -344,6 +355,12 @@ import {
|
||||
import { exportCategoryToSheets } from './sheets-export.js';
|
||||
import { exportCategoryToBigQuery } from './bigquery-export.js';
|
||||
import { listProperties as ga4ListProperties, runReport as ga4RunReport } from './ga4.js';
|
||||
import {
|
||||
listCounters as metricaListCounters,
|
||||
fetchPageMetrics as metricaFetchPageMetrics,
|
||||
MetricaCancelledError,
|
||||
MetricaError,
|
||||
} from './metrica.js';
|
||||
import { runAiBatch, type AiBatchItem } from './ai-batch.js';
|
||||
import { resolveModel, defaultConcurrency } from './ai-providers.js';
|
||||
import { runSeoBatch } from './seo-batch.js';
|
||||
@@ -362,6 +379,7 @@ import {
|
||||
DbReaderPool,
|
||||
} from './db-reader-pool.js';
|
||||
import {
|
||||
callWriterOrFallback,
|
||||
dbWriterPool,
|
||||
DbWriterPool,
|
||||
setWriterPoolResolver,
|
||||
@@ -429,6 +447,9 @@ interface FetchRunSnapshot {
|
||||
provider: string | null;
|
||||
/** Set when the run threw before finishing. */
|
||||
error: string | null;
|
||||
/** Kind-specific progress note: for Metrica, the current report and the
|
||||
* raw rows it has returned so far. Null for the URL-batch kinds. */
|
||||
detail: string | null;
|
||||
}
|
||||
function idleFetchSnapshot(): FetchRunSnapshot {
|
||||
return {
|
||||
@@ -443,6 +464,7 @@ function idleFetchSnapshot(): FetchRunSnapshot {
|
||||
finishedAt: null,
|
||||
provider: null,
|
||||
error: null,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -571,6 +593,9 @@ class ProjectSession {
|
||||
spelling: newFetchRunSlot(),
|
||||
ai: newFetchRunSlot(),
|
||||
seo: newFetchRunSlot(),
|
||||
// A Metrica pull is three paged reports, not a URL batch: done/total
|
||||
// count reports, and `abort` cancels mid-request or mid-backoff.
|
||||
metrica: { ...newFetchRunSlot(), abort: null as AbortController | null },
|
||||
gscInspect: { active: false, cancelRequested: false },
|
||||
};
|
||||
|
||||
@@ -5473,11 +5498,13 @@ function registerIpc(): void {
|
||||
IPC.reportsOrphanCrossSource,
|
||||
(_e, limit?: number): Promise<OrphanCrossSourceRow[]> => {
|
||||
// Scoped to the project's selected accounts — the cross-source
|
||||
// joins are only 1:1 when pinned to one GSC + one GA4 account.
|
||||
const args: [number, string, string] = [
|
||||
// joins are only 1:1 when pinned to one GSC + one GA4 account and
|
||||
// one Metrica counter.
|
||||
const args: [number, string, string, string] = [
|
||||
limit ?? 1000,
|
||||
resolveGoogleAccount('gsc'),
|
||||
resolveGoogleAccount('ga4'),
|
||||
resolveMetricaSettings().counterId,
|
||||
];
|
||||
return callReaderOrFallback<OrphanCrossSourceRow[]>(
|
||||
'orphanPagesCrossSource',
|
||||
@@ -6538,6 +6565,257 @@ function registerIpc(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// Yandex Metrica. Same shape as GA4: list counters, fetch a date-range
|
||||
// snapshot, query crawled pages joined with the stored data. Auth is a
|
||||
// personal token from the credential store; rows are keyed by counter.
|
||||
const metricaToken = (): string => {
|
||||
const token = resolveCredentials('metrica')['token']?.trim() ?? '';
|
||||
if (!token) {
|
||||
throw new MetricaError(
|
||||
'Yandex Metrica is not connected — paste a token in Settings → Integrations → Yandex Metrica.',
|
||||
'not-connected',
|
||||
);
|
||||
}
|
||||
// Checked before any request: a pasted blob with spaces or line breaks
|
||||
// would otherwise reach undici, whose header error quotes the value.
|
||||
if (!/^[A-Za-z0-9._~+/=-]+$/.test(token)) {
|
||||
throw new MetricaError(
|
||||
'The saved Yandex Metrica token has an unexpected format — paste it again in Settings → Integrations → Yandex Metrica.',
|
||||
'token-format',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
};
|
||||
const resolveMetricaSettings = (): MetricaIntegrationSettings =>
|
||||
({
|
||||
...defaultMetricaSettings(),
|
||||
...(readIntegrationSettings('metrica') ?? {}),
|
||||
}) as MetricaIntegrationSettings;
|
||||
/** Normaliser that maps Metrica's raw page URLs onto `urls.url` with the
|
||||
* project's own URL rewrite options, so the join matches the crawl. */
|
||||
const crawlUrlNormalizer = (): ((url: string) => string | null) => {
|
||||
const sess = currentSession();
|
||||
let config = sess.lastCrawlConfig;
|
||||
if (!config) config = restoreProjectConfig(sess.getDb());
|
||||
const c = config ?? DEFAULT_CRAWL_CONFIG;
|
||||
const rewrites = {
|
||||
stripWww: c.stripWww,
|
||||
forceHttps: c.forceHttps,
|
||||
lowercasePath: c.lowercasePath,
|
||||
trailingSlash: c.trailingSlash,
|
||||
keepQueryParams: c.keepQueryParams,
|
||||
sortQueryParams: c.sortQueryParams,
|
||||
collapseDuplicateSlashes: c.collapseDuplicateSlashes,
|
||||
keepHashRoutes: c.renderingMode === 'js' && c.jsRender?.spaRouting === true,
|
||||
regexRewrites: compileUrlRegexRewrites(c.urlRegexRewrites),
|
||||
};
|
||||
return (url) => normalizeUrl(url, undefined, rewrites);
|
||||
};
|
||||
// Shared by the IPC handlers and the MCP bridge actions.
|
||||
const runMetricaListCounters = async (): Promise<MetricaListCountersResult> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
counters: await metricaListCounters(metricaToken()),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
errorCode: err instanceof MetricaError ? err.code : null,
|
||||
counters: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Pull one counter into the open project. Everything before the first
|
||||
* await is synchronous, so the run slot is claimed (or the input
|
||||
* rejected) by the time the call returns its promise; the MCP action
|
||||
* relies on that to answer at once. One pull per window at a time: two
|
||||
* would race on the replace, and the older one could win.
|
||||
*/
|
||||
const runMetricaFetch = (input: MetricaFetchInput): Promise<MetricaFetchResult> => {
|
||||
const fail = (
|
||||
error: string,
|
||||
errorCode: MetricaErrorCode | null,
|
||||
cancelled = false,
|
||||
): MetricaFetchResult => ({
|
||||
ok: false,
|
||||
error,
|
||||
errorCode,
|
||||
cancelled,
|
||||
rowCount: 0,
|
||||
meta: null,
|
||||
});
|
||||
const counterId = String(input.counterId ?? '').trim();
|
||||
if (!/^\d+$/.test(counterId)) {
|
||||
return Promise.resolve(fail('No Yandex Metrica counter selected.', 'no-counter'));
|
||||
}
|
||||
const days = input.days;
|
||||
if (days !== 7 && days !== 28 && days !== 90) {
|
||||
return Promise.resolve(fail('Unsupported Yandex Metrica date range.', 'bad-range'));
|
||||
}
|
||||
const runSession = currentSession();
|
||||
const run = runSession.fetchRuns.metrica;
|
||||
if (run.active) {
|
||||
return Promise.resolve(fail('A Yandex Metrica fetch is already running in this window.', 'already-running'));
|
||||
}
|
||||
let token: string;
|
||||
let normalize: (url: string) => string | null;
|
||||
try {
|
||||
token = metricaToken();
|
||||
normalize = crawlUrlNormalizer();
|
||||
} catch (err) {
|
||||
return Promise.resolve(
|
||||
fail(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
err instanceof MetricaError ? err.code : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Pin the pull to the project open now. Opening another project in
|
||||
// this window replaces `runSession.db`, so identity says whether the
|
||||
// result still belongs to the open project.
|
||||
const runDb = runSession.getDb();
|
||||
const sameProject = (): boolean => runSession.db === runDb;
|
||||
const switchedError = (): MetricaError =>
|
||||
new MetricaError(
|
||||
'Another project was opened during the Yandex Metrica fetch, so its result was discarded. Fetch again in this project.',
|
||||
'project-switched',
|
||||
);
|
||||
const counterName = input.counterName || counterId;
|
||||
const abort = new AbortController();
|
||||
run.active = true;
|
||||
run.cancelRequested = false;
|
||||
run.abort = abort;
|
||||
run.snapshot = {
|
||||
...idleFetchSnapshot(),
|
||||
running: true,
|
||||
total: 3,
|
||||
provider: counterId,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
const sendProgress = (p: MetricaProgress): void => runSession.send(IPC.metricaProgress, p);
|
||||
sendProgress({ running: true, report: 'pageviews', reportIndex: 1, reportCount: 3, rows: 0 });
|
||||
return (async (): Promise<MetricaFetchResult> => {
|
||||
try {
|
||||
// The window ends yesterday in the counter's time zone (see metrica.ts).
|
||||
const result = await metricaFetchPageMetrics(token, counterId, days, normalize, {
|
||||
signal: abort.signal,
|
||||
onProgress: (p) => {
|
||||
run.snapshot.done = p.reportIndex - 1;
|
||||
run.snapshot.detail = `${p.report} report ${p.reportIndex}/${p.reportCount}: ${p.rows} rows`;
|
||||
sendProgress({ running: true, ...p });
|
||||
},
|
||||
});
|
||||
if (!sameProject()) throw switchedError();
|
||||
const { startDate, endDate } = result;
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const meta: MetricaFetchMeta = {
|
||||
counterId,
|
||||
counterName,
|
||||
startDate,
|
||||
endDate,
|
||||
fetchedAt,
|
||||
rowCount: result.rows.length,
|
||||
truncated: result.truncated,
|
||||
sampled: result.sampled,
|
||||
totals: result.totals,
|
||||
};
|
||||
const writeArgs = [result.rows, fetchedAt, counterId, JSON.stringify(meta)] as const;
|
||||
// Off the main thread: a large counter is hundreds of thousands of
|
||||
// rows. The project check above and the worker post run in the same
|
||||
// tick, so the write can't land in a project opened in between.
|
||||
await callWriterOrFallback('replaceMetricaResults', [...writeArgs], () => {
|
||||
if (!sameProject()) throw switchedError();
|
||||
runDb.replaceMetricaResults(...writeArgs);
|
||||
});
|
||||
if (!sameProject()) throw switchedError();
|
||||
// Records the pulled counter and range; picking a counter in the tab
|
||||
// also stores it, so every Metrica view reads the counter shown.
|
||||
writeIntegrationSettings('metrica', { counterId, counterName, days });
|
||||
fireDataChanged(runSession);
|
||||
logger.log('info', 'metrica', `Metrica pull stored — ${result.rows.length} page row(s)`);
|
||||
run.snapshot = {
|
||||
...run.snapshot,
|
||||
running: false,
|
||||
done: 3,
|
||||
completed: result.rows.length,
|
||||
detail: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
cancelled: false,
|
||||
rowCount: result.rows.length,
|
||||
meta,
|
||||
};
|
||||
} catch (err) {
|
||||
const cancelled = err instanceof MetricaCancelledError;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
logger.log(cancelled ? 'info' : 'warn', 'metrica', `Metrica pull ${cancelled ? 'cancelled' : 'failed'}: ${error}`);
|
||||
run.snapshot = {
|
||||
...run.snapshot,
|
||||
running: false,
|
||||
cancelled,
|
||||
error: cancelled ? null : error,
|
||||
detail: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
return fail(error, err instanceof MetricaError ? err.code : null, cancelled);
|
||||
} finally {
|
||||
run.active = false;
|
||||
run.cancelRequested = false;
|
||||
run.abort = null;
|
||||
sendProgress({ running: false, report: null, reportIndex: 0, reportCount: 3, rows: 0 });
|
||||
}
|
||||
})();
|
||||
};
|
||||
/** Abort this window's Metrica pull, if one is running. */
|
||||
const cancelMetricaFetch = (): boolean => {
|
||||
const run = currentSession().fetchRuns.metrica;
|
||||
if (!run.active) return false;
|
||||
run.cancelRequested = true;
|
||||
run.abort?.abort();
|
||||
logger.log('info', 'metrica', 'Metrica pull cancellation requested');
|
||||
return true;
|
||||
};
|
||||
registerHandle(IPC.metricaListCounters, () => runMetricaListCounters());
|
||||
registerHandle(IPC.metricaFetch, (_e, input: MetricaFetchInput) => runMetricaFetch(input));
|
||||
registerHandle(IPC.metricaCancel, () => {
|
||||
cancelMetricaFetch();
|
||||
});
|
||||
registerHandle(
|
||||
IPC.metricaQuery,
|
||||
async (_e, input: MetricaQueryInput): Promise<MetricaQueryResult> => {
|
||||
const counterId = input.counterId || resolveMetricaSettings().counterId;
|
||||
const params = {
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
filter: input.filter,
|
||||
counterId,
|
||||
};
|
||||
const result = await callReaderOrFallback<{
|
||||
rows: MetricaQueryResult['rows'];
|
||||
total: number;
|
||||
notice: MetricaNotice | null;
|
||||
}>('queryMetrica', [params], () => getDb().queryMetrica(params));
|
||||
let meta: MetricaFetchMeta | null = null;
|
||||
try {
|
||||
const raw = counterId ? getDb().getMeta(`metricaFetchMeta:${counterId}`) : null;
|
||||
if (raw) meta = JSON.parse(raw) as MetricaFetchMeta;
|
||||
} catch {
|
||||
/* no / corrupt meta — render without the context line */
|
||||
}
|
||||
return { rows: result.rows, total: result.total, meta, notice: result.notice ?? null };
|
||||
},
|
||||
);
|
||||
|
||||
// V1 Faz 7 — AI integrations (OpenAI / Anthropic / Ollama). The
|
||||
// renderer hands over a prompt template + URL list; the handler
|
||||
// substitutes per-URL variables from the crawl DB, then drives a
|
||||
@@ -7108,11 +7386,12 @@ function registerIpc(): void {
|
||||
async (_e, url: string): Promise<UrlAnalyticsDetail | null> => {
|
||||
if (!url) return null;
|
||||
// Scoped to the project's selected accounts so the panel agrees
|
||||
// with what the Search Console / GA4 tabs are showing.
|
||||
const args: [string, string, string] = [
|
||||
// with what the Search Console / GA4 / Metrica tabs are showing.
|
||||
const args: [string, string, string, string] = [
|
||||
url,
|
||||
resolveGoogleAccount('gsc'),
|
||||
resolveGoogleAccount('ga4'),
|
||||
resolveMetricaSettings().counterId,
|
||||
];
|
||||
return callReaderOrFallback<UrlAnalyticsDetail | null>(
|
||||
'getUrlAnalytics',
|
||||
@@ -7921,13 +8200,14 @@ function registerIpc(): void {
|
||||
* upgraded project keeps the data it already pulled.
|
||||
*/
|
||||
/** Account + URL-matching context the dataset exports (Search Console,
|
||||
* GA4) read with — the same resolution the tabs' query handlers use,
|
||||
* GA4, Metrica) read with — the same resolution the tabs' query handlers use,
|
||||
* so an exported sheet matches what the tab shows. */
|
||||
function datasetExportContext(): DatasetExportContext {
|
||||
const gsc = resolveGscSettings();
|
||||
return {
|
||||
gscAccountId: resolveGoogleAccount('gsc'),
|
||||
ga4AccountId: resolveGoogleAccount('ga4'),
|
||||
metricaCounterId: resolveMetricaSettings().counterId,
|
||||
gscMatchSlash: gsc.matchSlash,
|
||||
gscMatchCase: gsc.matchCase,
|
||||
};
|
||||
@@ -8592,6 +8872,29 @@ function registerIpc(): void {
|
||||
}
|
||||
},
|
||||
|
||||
'metrica-list-counters': () => runMetricaListCounters(),
|
||||
|
||||
// Non-blocking like pagespeed-run: a pull with retries can outlast the
|
||||
// MCP server's 5-minute header wait. The agent polls fetch-progress
|
||||
// (kind "metrica") and may fetch-cancel.
|
||||
'metrica-fetch': async (input) => {
|
||||
const i = (input ?? {}) as Partial<MetricaFetchInput>;
|
||||
const run = currentSession().fetchRuns.metrica;
|
||||
const alreadyRunning = run.active;
|
||||
const pending = runMetricaFetch({
|
||||
counterId: String(i.counterId ?? ''),
|
||||
counterName: typeof i.counterName === 'string' ? i.counterName : '',
|
||||
days: (i.days ?? 28) as MetricaFetchInput['days'],
|
||||
});
|
||||
if (alreadyRunning || !run.active) {
|
||||
// Rejected before it started (bad input, no token, already running).
|
||||
const result = await pending;
|
||||
return { started: false, reason: result.error, state: run.snapshot };
|
||||
}
|
||||
void pending;
|
||||
return { started: true, state: run.snapshot };
|
||||
},
|
||||
|
||||
'ga4-list-properties': async (input) => {
|
||||
try {
|
||||
const account = resolveGoogleAccount(
|
||||
@@ -8991,13 +9294,15 @@ function registerIpc(): void {
|
||||
spelling: fr.spelling.snapshot,
|
||||
ai: fr.ai.snapshot,
|
||||
seo: fr.seo.snapshot,
|
||||
metrica: fr.metrica.snapshot,
|
||||
};
|
||||
if (
|
||||
kind === 'pagespeed' ||
|
||||
kind === 'crux' ||
|
||||
kind === 'spelling' ||
|
||||
kind === 'ai' ||
|
||||
kind === 'seo'
|
||||
kind === 'seo' ||
|
||||
kind === 'metrica'
|
||||
) {
|
||||
return { [kind]: all[kind] };
|
||||
}
|
||||
@@ -9033,17 +9338,22 @@ function registerIpc(): void {
|
||||
if (was) fr.spelling.cancelRequested = true;
|
||||
cancelled.spelling = { wasRunning: was };
|
||||
};
|
||||
const cancelMetrica = (): void => {
|
||||
cancelled.metrica = { wasRunning: cancelMetricaFetch() };
|
||||
};
|
||||
if (kind === 'pagespeed') cancelPagespeed();
|
||||
else if (kind === 'crux') cancelCrux();
|
||||
else if (kind === 'spelling') cancelSpelling();
|
||||
else if (kind === 'ai') cancelAi();
|
||||
else if (kind === 'seo') cancelSeo();
|
||||
else if (kind === 'metrica') cancelMetrica();
|
||||
else {
|
||||
cancelPagespeed();
|
||||
cancelCrux();
|
||||
cancelSpelling();
|
||||
cancelAi();
|
||||
cancelSeo();
|
||||
cancelMetrica();
|
||||
}
|
||||
return { ok: true, cancelled };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Yandex Metrica API client.
|
||||
*
|
||||
* - `listCounters()` — every counter the token can read (Management API).
|
||||
* - `fetchPageMetrics()` — per-page metrics for a date range from the
|
||||
* Reporting API, joined onto crawl URLs through the caller's
|
||||
* `normalize` function (the crawl's own `normalizeUrl` options).
|
||||
*
|
||||
* Three reports are pulled, all with `accuracy=full` (unsampled):
|
||||
*
|
||||
* 1. hit-level `ym:pv:URL` → pageviews
|
||||
* 2. session `ym:s:startURL` → visits, users (by landing page)
|
||||
* 3. session `ym:s:startURL` → visits filtered to
|
||||
* `ym:s:trafficSource=='organic'` (search-engine visits)
|
||||
*
|
||||
* Attribution is left at the API default (cross-device last significant
|
||||
* source), which is also the Metrica UI default, so the numbers match
|
||||
* what a user sees in the Metrica web interface.
|
||||
*
|
||||
* Dates: the window ends yesterday, because today's numbers keep moving
|
||||
* while pages are fetched and would shift rows across page boundaries.
|
||||
* The first request sends a relative range so Metrica resolves it in the
|
||||
* counter's time zone; every later page and report reuses the resolved
|
||||
* absolute dates, so all three reports cover the same days.
|
||||
*
|
||||
* Paging: `offset` is 1-based and `limit` tops out at 100 000. For big
|
||||
* reports `total_rows` is capped near `offset + limit` (and flagged
|
||||
* `total_rows_rounded`), so it can't be used as a stop condition — the
|
||||
* loop keeps going until a page comes back short.
|
||||
*
|
||||
* Auth is a personal OAuth token (`metrika:read`) from the credential
|
||||
* store; the caller resolves it so this module stays free of Electron.
|
||||
*/
|
||||
import type { MetricaCounter, MetricaErrorCode, MetricaFetchMeta } from '@freecrawl/shared-types';
|
||||
import { apiFetch } from './api-fetch.js';
|
||||
import * as logger from './logger.js';
|
||||
|
||||
const API_BASE = 'https://api-metrika.yandex.net';
|
||||
/** Reporting API maximum rows per request. */
|
||||
const PAGE_LIMIT = 100_000;
|
||||
/** Smallest page the "query is too complicated" fallback shrinks to. */
|
||||
const MIN_PAGE_LIMIT = 6_250;
|
||||
/** Per-report ceiling on raw rows, so a counter with endless query-string
|
||||
* variants can't keep the pull running for ever. Rows are folded into
|
||||
* per-URL totals page by page, so memory follows distinct normalised
|
||||
* Metrica URLs (crawled or not), not raw rows. */
|
||||
const MAX_ROWS = 1_000_000;
|
||||
/** Attempts per request on throttling (420 / 429), "too complicated",
|
||||
* 5xx and network errors. */
|
||||
const MAX_ATTEMPTS = 4;
|
||||
|
||||
type Totals = MetricaFetchMeta['totals'];
|
||||
|
||||
function apiError(json: unknown, status: number): string {
|
||||
const msg = (json as { message?: unknown } | null)?.message;
|
||||
if (typeof msg === 'string' && msg) return `Yandex Metrica: ${msg}`;
|
||||
if (status === 401) {
|
||||
return 'Yandex Metrica rejected the token — paste a fresh token with the metrika:read scope in Settings → Integrations.';
|
||||
}
|
||||
if (status === 403) {
|
||||
return 'Yandex Metrica denied access — the token’s account cannot read this counter.';
|
||||
}
|
||||
return `Yandex Metrica API error (HTTP ${status})`;
|
||||
}
|
||||
|
||||
/** Metrica answers throttling with 429 or 420 (the docs use both) and
|
||||
* sends no `Retry-After`, so both get exponential backoff. */
|
||||
function isRetryable(status: number): boolean {
|
||||
return status === 420 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
/** A Metrica failure the UI words in the user's language by `code`; the
|
||||
* English message stays for logs and MCP clients. */
|
||||
export class MetricaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: MetricaErrorCode,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function codeForStatus(status: number): MetricaErrorCode {
|
||||
if (status === 401) return 'token-rejected';
|
||||
if (status === 403) return 'access-denied';
|
||||
if (status === 0) return 'network';
|
||||
return 'api';
|
||||
}
|
||||
|
||||
/** API failure with the HTTP status kept for the caller's fallbacks. */
|
||||
class MetricaApiError extends MetricaError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
code: MetricaErrorCode = codeForStatus(status),
|
||||
) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
/** Metrica's 400 "query is too complicated" for heavy unsampled reports.
|
||||
* It depends on server load, so the same request is retried with longer
|
||||
* pauses first; a smaller page is only the last resort, because each
|
||||
* request costs about the same whatever its page size. */
|
||||
const TOO_COMPLICATED = /too complicated|слишком сложн/i;
|
||||
|
||||
function isTooComplicated(err: unknown): boolean {
|
||||
return err instanceof MetricaApiError && err.status === 400 && TOO_COMPLICATED.test(err.message);
|
||||
}
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Thrown when the caller aborts a pull. */
|
||||
export class MetricaCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Yandex Metrica fetch cancelled.');
|
||||
this.name = 'MetricaCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw new MetricaCancelledError();
|
||||
}
|
||||
|
||||
/** Backoff pause that ends early, with a cancel error, on abort. */
|
||||
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new MetricaCancelledError());
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
reject(new MetricaCancelledError());
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** GET a Metrica endpoint as JSON, retrying throttling and server errors. */
|
||||
async function getJson(
|
||||
token: string,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const url = `${API_BASE}${path}?${new URLSearchParams(params).toString()}`;
|
||||
let lastError = '';
|
||||
let lastStatus = 0;
|
||||
let backoffMs = 2_000;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
throwIfCancelled(signal);
|
||||
const timeout = AbortSignal.timeout(120_000);
|
||||
try {
|
||||
const res = await apiFetch(url, {
|
||||
headers: { Authorization: `OAuth ${token}` },
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as unknown;
|
||||
// A cancel while the body downloads makes json null; report it as a
|
||||
// cancel, not as an API error.
|
||||
throwIfCancelled(signal);
|
||||
if (res.ok && json) return json;
|
||||
lastError = apiError(json, res.status);
|
||||
lastStatus = res.status;
|
||||
const heavy = res.status === 400 && TOO_COMPLICATED.test(lastError);
|
||||
if (heavy) backoffMs = 10_000;
|
||||
else if (!isRetryable(res.status)) break;
|
||||
} catch (err) {
|
||||
throwIfCancelled(signal);
|
||||
// Never echo `err.message`: undici quotes the offending header value
|
||||
// (the token) in its validation errors.
|
||||
const name = err instanceof Error ? err.name : 'Error';
|
||||
lastError = `Yandex Metrica request failed (${name})`;
|
||||
lastStatus = 0;
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
logger.log('warn', 'metrica', `${lastError} — retry ${attempt}/${MAX_ATTEMPTS - 1}`);
|
||||
await sleep(backoffMs * 2 ** (attempt - 1), signal);
|
||||
}
|
||||
}
|
||||
throw new MetricaApiError(lastError, lastStatus);
|
||||
}
|
||||
|
||||
/** Every counter the token can read. */
|
||||
export async function listCounters(token: string): Promise<MetricaCounter[]> {
|
||||
const PER_PAGE = 1000;
|
||||
const out: MetricaCounter[] = [];
|
||||
for (let offset = 1; ; offset += PER_PAGE) {
|
||||
const json = (await getJson(token, '/management/v1/counters', {
|
||||
per_page: String(PER_PAGE),
|
||||
offset: String(offset),
|
||||
})) as {
|
||||
counters?: {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
site?: unknown;
|
||||
site2?: { site?: unknown } | null;
|
||||
owner_login?: unknown;
|
||||
}[];
|
||||
};
|
||||
const counters = Array.isArray(json.counters) ? json.counters : [];
|
||||
for (const c of counters) {
|
||||
if (typeof c.id !== 'number' && typeof c.id !== 'string') continue;
|
||||
const site =
|
||||
typeof c.site2?.site === 'string' ? c.site2.site : typeof c.site === 'string' ? c.site : '';
|
||||
out.push({
|
||||
counterId: String(c.id),
|
||||
name: typeof c.name === 'string' && c.name ? c.name : site || String(c.id),
|
||||
site,
|
||||
ownerLogin: typeof c.owner_login === 'string' ? c.owner_login : '',
|
||||
});
|
||||
}
|
||||
if (counters.length < PER_PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ReportSpec {
|
||||
dimension: string;
|
||||
metrics: string[];
|
||||
filters?: string;
|
||||
}
|
||||
|
||||
interface ReportResult {
|
||||
/** Whole-range totals per metric, as the API reports them. */
|
||||
totals: number[];
|
||||
/** Raw rows received. */
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
/** True when any page came back sampled despite `accuracy=full`. */
|
||||
sampled: boolean;
|
||||
/** Resolved `YYYY-MM-DD` range the API reported on (counter time zone). */
|
||||
date1: string;
|
||||
date2: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull one report in full, paging until a short page. Each row is handed
|
||||
* to `onRow` as it arrives, so raw rows never pile up in memory.
|
||||
*/
|
||||
async function runReport(
|
||||
token: string,
|
||||
counterId: string,
|
||||
date1: string,
|
||||
date2: string,
|
||||
spec: ReportSpec,
|
||||
onRow: (url: string, values: number[]) => void,
|
||||
signal: AbortSignal | undefined,
|
||||
onPage: (rowsSoFar: number) => void,
|
||||
): Promise<ReportResult> {
|
||||
const result: ReportResult = {
|
||||
totals: spec.metrics.map(() => 0),
|
||||
rowCount: 0,
|
||||
truncated: false,
|
||||
sampled: false,
|
||||
date1,
|
||||
date2,
|
||||
};
|
||||
onPage(0);
|
||||
let limit = PAGE_LIMIT;
|
||||
for (let offset = 1; ; ) {
|
||||
const params: Record<string, string> = {
|
||||
ids: counterId,
|
||||
// Relative on the very first page, the resolved dates after that.
|
||||
date1: result.date1,
|
||||
date2: result.date2,
|
||||
dimensions: spec.dimension,
|
||||
metrics: spec.metrics.join(','),
|
||||
// Metric desc, then URL, so the order is total and pages can't
|
||||
// overlap or skip rows that tie on the metric.
|
||||
sort: `-${spec.metrics[0]},${spec.dimension}`,
|
||||
accuracy: 'full',
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
};
|
||||
if (spec.filters) params['filters'] = spec.filters;
|
||||
let json: {
|
||||
data?: { dimensions?: { name?: unknown }[]; metrics?: unknown[] }[];
|
||||
totals?: unknown[];
|
||||
sampled?: unknown;
|
||||
query?: { date1?: unknown; date2?: unknown };
|
||||
};
|
||||
try {
|
||||
json = (await getJson(token, '/stat/v1/data', params, signal)) as typeof json;
|
||||
} catch (err) {
|
||||
if (!isTooComplicated(err) || limit <= MIN_PAGE_LIMIT) throw err;
|
||||
// Retries didn't help: same offset, half the page. Rows already
|
||||
// received stay valid because the sort order is total.
|
||||
limit = Math.max(MIN_PAGE_LIMIT, Math.floor(limit / 2));
|
||||
logger.log(
|
||||
'warn',
|
||||
'metrica',
|
||||
`report ${spec.dimension} too complicated at offset ${offset} — retrying with limit ${limit}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// A response without a row list or resolved dates is a changed or
|
||||
// broken API, not an empty report: fail so the stored data survives.
|
||||
const resolved1 = json.query?.date1;
|
||||
const resolved2 = json.query?.date2;
|
||||
if (
|
||||
!Array.isArray(json.data) ||
|
||||
typeof resolved1 !== 'string' ||
|
||||
!ISO_DATE.test(resolved1) ||
|
||||
typeof resolved2 !== 'string' ||
|
||||
!ISO_DATE.test(resolved2)
|
||||
) {
|
||||
throw new MetricaApiError(
|
||||
'Yandex Metrica returned an unexpected response.',
|
||||
200,
|
||||
'unexpected-response',
|
||||
);
|
||||
}
|
||||
if (offset === 1 && !ISO_DATE.test(result.date1)) {
|
||||
result.date1 = resolved1;
|
||||
result.date2 = resolved2;
|
||||
} else if (resolved1 !== result.date1 || resolved2 !== result.date2) {
|
||||
throw new MetricaApiError(
|
||||
'Yandex Metrica changed the report date range mid-pull.',
|
||||
200,
|
||||
'range-changed',
|
||||
);
|
||||
}
|
||||
if (json.sampled === true) {
|
||||
// accuracy=full should never sample; record it so "no visits"
|
||||
// filters refuse to treat missing rows as zero.
|
||||
result.sampled = true;
|
||||
logger.log('warn', 'metrica', `report ${spec.dimension} came back sampled`);
|
||||
}
|
||||
if (offset === 1 && Array.isArray(json.totals)) {
|
||||
result.totals = spec.metrics.map((_, i) => Number(json.totals?.[i] ?? 0) || 0);
|
||||
}
|
||||
const data = json.data;
|
||||
for (const r of data) {
|
||||
const url = r.dimensions?.[0]?.name;
|
||||
if (typeof url !== 'string' || !url) continue;
|
||||
const m = Array.isArray(r.metrics) ? r.metrics : [];
|
||||
onRow(
|
||||
url,
|
||||
spec.metrics.map((_, i) => Number(m[i] ?? 0) || 0),
|
||||
);
|
||||
}
|
||||
result.rowCount += data.length;
|
||||
onPage(result.rowCount);
|
||||
// `total_rows` is capped near offset + limit on large reports, so a
|
||||
// short page is the only reliable end-of-data signal.
|
||||
if (data.length < limit) break;
|
||||
offset += limit;
|
||||
if (result.rowCount >= MAX_ROWS) {
|
||||
result.truncated = true;
|
||||
logger.log(
|
||||
'warn',
|
||||
'metrica',
|
||||
`report ${spec.dimension} stopped at ${result.rowCount} rows (cap)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** One per-page Metrica row, keyed by the crawl-normalised URL. */
|
||||
export interface MetricaPageRow {
|
||||
url: string;
|
||||
pageviews: number;
|
||||
visits: number;
|
||||
users: number;
|
||||
searchVisits: number;
|
||||
}
|
||||
|
||||
/** Where a running pull is: which of the three reports, and how many raw
|
||||
* rows that report has returned so far. */
|
||||
export interface MetricaFetchProgress {
|
||||
report: 'pageviews' | 'visits' | 'searchVisits';
|
||||
reportIndex: number;
|
||||
reportCount: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface MetricaFetchOptions {
|
||||
/** Aborts the pull, including any backoff pause in progress. */
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: MetricaFetchProgress) => void;
|
||||
}
|
||||
|
||||
export interface MetricaPageMetrics {
|
||||
rows: MetricaPageRow[];
|
||||
totals: Totals;
|
||||
/** Inclusive `YYYY-MM-DD` range the API reported on, counter time zone. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** Raw report rows dropped because their URL failed to normalise. */
|
||||
dropped: number;
|
||||
/** True when a report hit the row ceiling. */
|
||||
truncated: boolean;
|
||||
/** True when a report came back sampled. */
|
||||
sampled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull per-page metrics for the `days` full days ending yesterday and fold
|
||||
* them onto crawl URLs. The range is first sent as `NdaysAgo`…`yesterday`,
|
||||
* so Metrica resolves it in the counter's own time zone. Several raw Metrica
|
||||
* URLs can normalise to the same crawl URL (tracking parameters,
|
||||
* `#fragments`, host / slash rewrites); their counts are summed. For
|
||||
* `users` that sum can count a person once per URL variant — exact for
|
||||
* the common case of one variant per page, an upper bound otherwise.
|
||||
*/
|
||||
export async function fetchPageMetrics(
|
||||
token: string,
|
||||
counterId: string,
|
||||
days: number,
|
||||
normalize: (url: string) => string | null,
|
||||
opts: MetricaFetchOptions = {},
|
||||
): Promise<MetricaPageMetrics> {
|
||||
const { signal, onProgress } = opts;
|
||||
const progress =
|
||||
(report: MetricaFetchProgress['report'], reportIndex: number) =>
|
||||
(rows: number): void =>
|
||||
onProgress?.({ report, reportIndex, reportCount: 3, rows });
|
||||
const started = Date.now();
|
||||
const relative1 = `${days}daysAgo`;
|
||||
const relative2 = 'yesterday';
|
||||
const byUrl = new Map<string, MetricaPageRow>();
|
||||
let dropped = 0;
|
||||
const rowFor = (raw: string): MetricaPageRow | null => {
|
||||
const url = normalize(raw);
|
||||
if (!url) {
|
||||
dropped++;
|
||||
return null;
|
||||
}
|
||||
let row = byUrl.get(url);
|
||||
if (!row) {
|
||||
row = { url, pageviews: 0, visits: 0, users: 0, searchVisits: 0 };
|
||||
byUrl.set(url, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
const pv = await runReport(
|
||||
token,
|
||||
counterId,
|
||||
relative1,
|
||||
relative2,
|
||||
{ dimension: 'ym:pv:URL', metrics: ['ym:pv:pageviews'] },
|
||||
(url, v) => {
|
||||
const row = rowFor(url);
|
||||
if (row) row.pageviews += v[0] ?? 0;
|
||||
},
|
||||
signal,
|
||||
progress('pageviews', 1),
|
||||
);
|
||||
// Later reports reuse the dates the first one resolved to.
|
||||
const { date1, date2 } = pv;
|
||||
const sessions = await runReport(
|
||||
token,
|
||||
counterId,
|
||||
date1,
|
||||
date2,
|
||||
{ dimension: 'ym:s:startURL', metrics: ['ym:s:visits', 'ym:s:users'] },
|
||||
(url, v) => {
|
||||
const row = rowFor(url);
|
||||
if (!row) return;
|
||||
row.visits += v[0] ?? 0;
|
||||
row.users += v[1] ?? 0;
|
||||
},
|
||||
signal,
|
||||
progress('visits', 2),
|
||||
);
|
||||
const search = await runReport(
|
||||
token,
|
||||
counterId,
|
||||
date1,
|
||||
date2,
|
||||
{
|
||||
dimension: 'ym:s:startURL',
|
||||
metrics: ['ym:s:visits'],
|
||||
filters: "ym:s:trafficSource=='organic'",
|
||||
},
|
||||
(url, v) => {
|
||||
const row = rowFor(url);
|
||||
if (row) row.searchVisits += v[0] ?? 0;
|
||||
},
|
||||
signal,
|
||||
progress('searchVisits', 3),
|
||||
);
|
||||
|
||||
const rows = [...byUrl.values()];
|
||||
logger.log(
|
||||
'info',
|
||||
'metrica',
|
||||
`Metrica report: ${rows.length} page(s) for counter ${counterId} (${pv.date1}…${pv.date2}) from ` +
|
||||
`${pv.rowCount}/${sessions.rowCount}/${search.rowCount} raw rows, ` +
|
||||
`${dropped} dropped, ${Math.round((Date.now() - started) / 1000)} s`,
|
||||
);
|
||||
return {
|
||||
rows,
|
||||
totals: {
|
||||
pageviews: pv.totals[0] ?? 0,
|
||||
visits: sessions.totals[0] ?? 0,
|
||||
users: sessions.totals[1] ?? 0,
|
||||
searchVisits: search.totals[0] ?? 0,
|
||||
},
|
||||
startDate: pv.date1,
|
||||
endDate: pv.date2,
|
||||
dropped,
|
||||
truncated: pv.truncated || sessions.truncated || search.truncated,
|
||||
sampled: pv.sampled || sessions.sampled || search.sampled,
|
||||
};
|
||||
}
|
||||
@@ -146,6 +146,12 @@ import {
|
||||
type Ga4FetchResult,
|
||||
type Ga4QueryInput,
|
||||
type Ga4QueryResult,
|
||||
type MetricaListCountersResult,
|
||||
type MetricaFetchInput,
|
||||
type MetricaFetchResult,
|
||||
type MetricaQueryInput,
|
||||
type MetricaQueryResult,
|
||||
type MetricaProgress,
|
||||
type AiRunInput,
|
||||
type AiRunResult,
|
||||
type AiQueryInput,
|
||||
@@ -501,6 +507,14 @@ const api: FreeCrawlApi = {
|
||||
ipcRenderer.invoke(IPC.ga4Fetch, input),
|
||||
ga4Query: (input: Ga4QueryInput): Promise<Ga4QueryResult> =>
|
||||
ipcRenderer.invoke(IPC.ga4Query, input),
|
||||
metricaListCounters: (): Promise<MetricaListCountersResult> =>
|
||||
ipcRenderer.invoke(IPC.metricaListCounters),
|
||||
metricaFetch: (input: MetricaFetchInput): Promise<MetricaFetchResult> =>
|
||||
ipcRenderer.invoke(IPC.metricaFetch, input),
|
||||
metricaQuery: (input: MetricaQueryInput): Promise<MetricaQueryResult> =>
|
||||
ipcRenderer.invoke(IPC.metricaQuery, input),
|
||||
metricaCancel: (): Promise<void> => ipcRenderer.invoke(IPC.metricaCancel),
|
||||
onMetricaProgress: (cb) => subscribe<MetricaProgress>(IPC.metricaProgress, cb),
|
||||
aiRun: (input: AiRunInput): Promise<AiRunResult> =>
|
||||
ipcRenderer.invoke(IPC.aiRun, input),
|
||||
aiCancel: (): Promise<void> => ipcRenderer.invoke(IPC.aiCancel),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CruxTab } from './tabs/CruxTab.js';
|
||||
import { SpellingTab } from './tabs/SpellingTab.js';
|
||||
import { SearchConsoleTab } from './tabs/SearchConsoleTab.js';
|
||||
import { AnalyticsTab } from './tabs/AnalyticsTab.js';
|
||||
import { MetricaTab } from './tabs/MetricaTab.js';
|
||||
import { AiTab } from './tabs/AiTab.js';
|
||||
import { SeoTab } from './tabs/SeoTab.js';
|
||||
import { AssistantTab } from './tabs/AssistantTab.js';
|
||||
@@ -563,6 +564,8 @@ export function App() {
|
||||
<SearchConsoleTab />
|
||||
) : activeTab === 'analytics' ? (
|
||||
<AnalyticsTab />
|
||||
) : activeTab === 'metrica' ? (
|
||||
<MetricaTab />
|
||||
) : activeTab === 'ai' ? (
|
||||
<AiTab />
|
||||
) : activeTab === 'seo' ? (
|
||||
|
||||
@@ -915,20 +915,20 @@ function AnalyticsView({
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (!data.gsc && !data.ga4 && !data.inspection)) {
|
||||
if (!data || (!data.gsc && !data.ga4 && !data.metrica && !data.inspection)) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{t('analyticsView.empty', {
|
||||
defaultValue:
|
||||
'No Search Console / Analytics 4 / URL Inspection data for this URL yet. Fetch it from the GA4 / Search Console tabs.',
|
||||
'No Search Console / Analytics 4 / Yandex Metrica / URL Inspection data for this URL yet. Fetch it from the GA4 / Yandex Metrica / Search Console tabs.',
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { gsc, ga4, inspection } = data;
|
||||
// The cards are three unrelated metric sets; exporting them as one
|
||||
// metric/value sheet keeps a single file per URL rather than three.
|
||||
const { gsc, ga4, metrica, inspection } = data;
|
||||
// The cards are unrelated metric sets; exporting them as one
|
||||
// metric/value sheet keeps a single file per URL rather than one each.
|
||||
const exportRows = (): (string | number | boolean | null)[][] => {
|
||||
const out: (string | number | boolean | null)[][] = [];
|
||||
if (gsc) {
|
||||
@@ -952,6 +952,12 @@ function AnalyticsView({
|
||||
Math.round(ga4.avgSessionDuration),
|
||||
]);
|
||||
}
|
||||
if (metrica) {
|
||||
out.push(['Yandex Metrica', 'Pageviews (hits)', metrica.pageviews]);
|
||||
out.push(['Yandex Metrica', 'Visits (landing)', metrica.visits]);
|
||||
out.push(['Yandex Metrica', 'Users (landing)', metrica.users]);
|
||||
out.push(['Yandex Metrica', 'Search visits (landing)', metrica.searchVisits]);
|
||||
}
|
||||
if (inspection) {
|
||||
out.push(['URL Inspection', 'Verdict', inspection.verdict ?? '']);
|
||||
out.push(['URL Inspection', 'Coverage state', inspection.coverageState ?? '']);
|
||||
@@ -976,7 +982,7 @@ function AnalyticsView({
|
||||
rows: exportRows(),
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.gscTitle', { defaultValue: 'Search Console' })}
|
||||
empty={t('analyticsView.gscEmpty', { defaultValue: 'No clicks / impressions data yet.' })}
|
||||
@@ -1012,6 +1018,48 @@ function AnalyticsView({
|
||||
</>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.metricaTitle', { defaultValue: 'Yandex Metrica' })}
|
||||
empty={t('analyticsView.metricaEmpty', {
|
||||
defaultValue: 'No Yandex Metrica data yet — fetch it from the Yandex Metrica tab.',
|
||||
})}
|
||||
present={!!metrica}
|
||||
>
|
||||
{metrica && (
|
||||
<>
|
||||
<AnalyticsRow
|
||||
label="Pageviews (hits)"
|
||||
value={metrica.pageviews.toLocaleString()}
|
||||
hint={t('analyticsView.metricaPageviewsHint', {
|
||||
defaultValue: 'Hit-level: every view of this page URL.',
|
||||
})}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="Visits (landing)"
|
||||
value={metrica.visits.toLocaleString()}
|
||||
hint={t('analyticsView.metricaVisitsHint', {
|
||||
defaultValue: 'Session-level: sessions that started on this page.',
|
||||
})}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="Users (landing)"
|
||||
value={metrica.users.toLocaleString()}
|
||||
hint={t('analyticsView.metricaUsersHint', {
|
||||
defaultValue:
|
||||
'Session-level, by landing page. An upper bound when several URL variants fold into this page.',
|
||||
})}
|
||||
/>
|
||||
<AnalyticsRow
|
||||
label="Search visits (landing)"
|
||||
value={metrica.searchVisits.toLocaleString()}
|
||||
hint={t('analyticsView.metricaSearchVisitsHint', {
|
||||
defaultValue:
|
||||
'Session-level: sessions from search engines that started on this page.',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnalyticsCard>
|
||||
<AnalyticsCard
|
||||
title={t('analyticsView.inspectionTitle', { defaultValue: 'URL Inspection' })}
|
||||
empty={t('analyticsView.inspectionEmpty', {
|
||||
@@ -1070,11 +1118,22 @@ function AnalyticsCard({
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsRow({ label, value }: { label: string; value: string }) {
|
||||
function AnalyticsRow({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Tooltip on the label, e.g. which level a metric is counted at. */
|
||||
hint?: string;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 text-[11px]">
|
||||
<div className="w-32 shrink-0 text-surface-500">{translateLabel(label, i18n.language)}</div>
|
||||
<div className="w-32 shrink-0 text-surface-500" title={hint}>
|
||||
{translateLabel(label, i18n.language)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-surface-100" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,7 @@ const DATASET_TABS: Partial<Record<TabKey, DatasetTab>> = {
|
||||
spelling: { dataset: 'spelling' },
|
||||
'search-console': { dataset: 'search-console' },
|
||||
analytics: { dataset: 'analytics' },
|
||||
metrica: { dataset: 'metrica' },
|
||||
ai: {
|
||||
subdir: 'ai',
|
||||
providers: [
|
||||
@@ -196,6 +197,7 @@ const TAB_CATEGORY: Partial<Record<TabKey, UrlCategory>> = {
|
||||
pagespeed: 'all',
|
||||
'search-console': 'all',
|
||||
analytics: 'all',
|
||||
metrica: 'all',
|
||||
ai: 'all',
|
||||
seo: 'all',
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, BookOpen, ExternalLink, Lightbulb, X } from 'lucide-react';
|
||||
import type { IntegrationDef } from '@freecrawl/shared-types';
|
||||
import { resolveGuides, type Guide } from './integration-guides/index.js';
|
||||
import { GUIDES, resolveGuides, type Guide } from './integration-guides/index.js';
|
||||
|
||||
/**
|
||||
* In-app step-by-step setup guide for one integration. Lives as a
|
||||
@@ -24,7 +24,8 @@ import { resolveGuides, type Guide } from './integration-guides/index.js';
|
||||
*/
|
||||
|
||||
function pickGuide(lang: string, integrationId: string): Guide | undefined {
|
||||
return resolveGuides(lang)[integrationId];
|
||||
// A guide that is not translated yet falls back to English.
|
||||
return resolveGuides(lang)[integrationId] ?? GUIDES.en[integrationId];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -93,7 +93,7 @@ const REPORT_LABELS: Record<ReportKind, string> = {
|
||||
'url-length-histogram': 'URL Length Histogram',
|
||||
'word-count-per-dir': 'Word Count per Directory',
|
||||
'sitemap-orphans': 'Sitemap Orphans (Top 1000)',
|
||||
'orphan-cross-source': 'Orphan Pages — Sitemap + GSC + GA4 (Top 1000)',
|
||||
'orphan-cross-source': 'Orphan Pages — Sitemap + GSC + GA4 + Metrica (Top 1000)',
|
||||
'server-headers': 'Server Stack (Server Header)',
|
||||
'top-words': 'Top Words (Title + Meta + H1, Top 100)',
|
||||
'pages-over-link-limit': 'Pages Over Max Links per Page',
|
||||
@@ -395,16 +395,58 @@ export function ReportsDialog({ open, onClose }: Props) {
|
||||
r.map((x: OrphanCrossSourceRow) => ({
|
||||
key: x.url,
|
||||
count: 1,
|
||||
// `sources` (sitemap / gsc / ga4) plus any traffic go in the
|
||||
// `sources` (sitemap / gsc / ga4 / metrica) plus any traffic go in the
|
||||
// secondary value column so the user can triage why each URL
|
||||
// was missed and how much it matters — sitemap-only with no
|
||||
// traffic is often a dead entry to drop; GSC clicks / GA4
|
||||
// sessions mean real user-visited pages no crawl path reaches.
|
||||
valueLabel: [
|
||||
x.sources.join(' + '),
|
||||
x.gscClicks != null ? `${x.gscClicks.toLocaleString()} GSC clicks` : null,
|
||||
x.gscImpressions != null ? `${x.gscImpressions.toLocaleString()} impr` : null,
|
||||
x.ga4Sessions != null ? `${x.ga4Sessions.toLocaleString()} GA4 sessions` : null,
|
||||
x.gscClicks != null
|
||||
? t('reports.orphanGscClicks', {
|
||||
defaultValue_one: '{{formatted}} GSC click',
|
||||
defaultValue_other: '{{formatted}} GSC clicks',
|
||||
defaultValue: '{{formatted}} GSC clicks',
|
||||
count: x.gscClicks,
|
||||
formatted: x.gscClicks.toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
x.gscImpressions != null
|
||||
? t('reports.orphanGscImpressions', {
|
||||
defaultValue_one: '{{formatted}} impr',
|
||||
defaultValue_other: '{{formatted}} impr',
|
||||
defaultValue: '{{formatted}} impr',
|
||||
count: x.gscImpressions,
|
||||
formatted: x.gscImpressions.toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
x.ga4Sessions != null
|
||||
? t('reports.orphanGa4Sessions', {
|
||||
defaultValue_one: '{{formatted}} GA4 session',
|
||||
defaultValue_other: '{{formatted}} GA4 sessions',
|
||||
defaultValue: '{{formatted}} GA4 sessions',
|
||||
count: x.ga4Sessions,
|
||||
formatted: x.ga4Sessions.toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
x.metricaVisits != null
|
||||
? t('reports.orphanMetricaVisits', {
|
||||
defaultValue_one: '{{formatted}} Metrica visit (landing)',
|
||||
defaultValue_other: '{{formatted}} Metrica visits (landing)',
|
||||
defaultValue: '{{formatted}} Metrica visits (landing)',
|
||||
count: x.metricaVisits,
|
||||
formatted: x.metricaVisits.toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
x.metricaPageviews != null
|
||||
? t('reports.orphanMetricaPageviews', {
|
||||
defaultValue_one: '{{formatted}} Metrica pageview (hits)',
|
||||
defaultValue_other: '{{formatted}} Metrica pageviews (hits)',
|
||||
defaultValue: '{{formatted}} Metrica pageviews (hits)',
|
||||
count: x.metricaPageviews,
|
||||
formatted: x.metricaPageviews.toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
@@ -481,7 +523,7 @@ export function ReportsDialog({ open, onClose }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, kind, depth, maxLinksPerPage, includeBody]);
|
||||
}, [open, kind, depth, maxLinksPerPage, includeBody, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -622,6 +622,81 @@ export const GUIDES_AZ: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'Hər URL üçün seçməsiz Yandex Metrica məlumatını çəkin: səhifə baxışları (hit səviyyəsi, səhifə URL-inə görə), həmçinin ziyarətlər, istifadəçilər və axtarış sistemlərindən gələn ziyarətlər (sessiya səviyyəsi, giriş səhifəsinə görə). Öz kiçik Yandex OAuth tətbiqinizi və yalnız oxumaq üçün token yaradırsınız, ona görə də API kvotaları yalnız öz Yandex hesabınızdan sərf olunur.',
|
||||
prereqs: [
|
||||
'Metrica sayğacına baxa bilən Yandex hesabı — sayğacın sahibi və ya sahibin ən azı baxış girişi verdiyi istifadəçi (Metrica → Settings → Access).',
|
||||
'Təxminən 5 dəqiqə. Nə ödəniş, nə də tətbiq yoxlaması tələb olunur.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Yandex OAuth tətbiq qeydiyyatı səhifəsini açın',
|
||||
detail:
|
||||
'Sayğaca baxa bilən Yandex hesabı ilə daxil olun. Bir neçə Yandex hesabına daxil olmusunuzsa, əvvəlcə düzgün hesaba keçin — token 5-ci addımda girişi təsdiqləyən hesaba məxsus olur.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Tətbiq tipi olaraq "For API access or debugging" seçin',
|
||||
detail:
|
||||
'Tətbiq yaradıldıqdan sonra tipi dəyişmək olmur. Sonra doldurun:\n• Ad: "FreeCrawl" (tanıyacağınız istənilən ad)\n• Əlaqə e-poçtu: ünvanınız',
|
||||
},
|
||||
{
|
||||
title: 'metrika:read icazəsini əlavə edin',
|
||||
detail:
|
||||
'Məlumata giriş bölməsində Yandex Metrica-nı axtarın və yalnız metrika:read kodlu icazəni (statistikanın və sayğac parametrlərinin oxunması) işarələyin. FreeCrawl Metrica-ya heç vaxt yazmır, ona görə də metrika:write və digər icazələri işarəsiz saxlayın.\n\nSayğac təşkilat hesabına (Yandex 360 for Business) məxsusdursa, passport:business icazəsini də əlavə edin.',
|
||||
},
|
||||
{
|
||||
title: 'Tətbiqi yaradın və onun ClientID-sini kopyalayın',
|
||||
detail:
|
||||
'Saxladıqdan sonra tətbiq səhifəsində ClientID (32 simvolluq onaltılıq sətir) göstərilir. Müştəri sirrinə ehtiyac yoxdur.',
|
||||
},
|
||||
{
|
||||
title: 'Token almaq üçün avtorizasiya keçidini açın',
|
||||
detail:
|
||||
'Həmin brauzerdə <ClientID> yerinə öz ClientID-nizi qoyaraq bu ünvanı açın:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nGiriş sorğusunu təsdiqləyin. Bundan sonra Yandex tokeni səhifədə göstərir — onu kopyalayın.',
|
||||
},
|
||||
{
|
||||
title: 'Tokeni FreeCrawl-a yapışdırın',
|
||||
detail:
|
||||
'Parametrlər → İnteqrasiyalar → Yandex Metrica → OAuth token → Saxla. Token bu kompüterdə qalır və yalnız Metrica API-yə göndərilir. Əməliyyat sisteminin açar anbarı mövcud olduqda onunla şifrələnir; açar dəsti olmayan Linux-da isə yalnız sizin istifadəçinizin oxuya bildiyi faylda şifrələnmədən saxlanılır.',
|
||||
},
|
||||
{
|
||||
title: 'Sayğacları siyahılayın, birini seçin və endirin',
|
||||
detail:
|
||||
'Taranan saytın sayğacını seçin, pəncərə seçin (dünən bitən son 7, 28 və ya 90 tam gün) və endirin. Böyük sayt bir neçə dəqiqə çəkir: Metrica 100.000 URL-ə qədər olan hər nəticə səhifəsini 15–30 san ərzində qaytarır.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'Tokenin müddəti bitib, token ləğv edilib və ya natamam kopyalanıb. 5-ci addımdakı avtorizasiya keçidini yenidən açın və yeni tokeni yapışdırın.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'Tokeni təsdiqləyən hesab bu sayğacı görmür və ya tətbiqdə metrika:read icazəsi yoxdur. Metrica-da sayğacın giriş siyahısını yoxlayın və ya oauth.yandex.com-da tətbiqi redaktə edin, metrika:read əlavə edin və yeni token alın.',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'Token başqa Yandex hesabına məxsusdur. Düzgün hesaba daxil olun, avtorizasiya keçidini yenidən açın və tokeni dəyişin.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl bu sorğuları getdikcə artan fasilələrlə avtomatik təkrarlayır. Endirmə yenə də alınmırsa, bir az sonra yenidən cəhd edin və ya daha qısa pəncərə seçin.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'Səhifə baxışları URL-in hər baxışını sayır; ziyarətlər, istifadəçilər və axtarış sistemlərindən gələn ziyarətlər isə sessiyaları başladıqları səhifəyə görə sayır. Səhifənin çoxlu baxışı və sıfır ziyarəti ola bilər.',
|
||||
'İstifadəçiləri səhifələr üzrə toplamaq artıq sayma ilə nəticələnir: bir nəfər sayta bir neçə səhifədən daxil ola bilər.',
|
||||
'Metrica API limitləri hər Yandex hesabı üçündür: 5 dəqiqədə 200 sorğu, eyni anda 3, gündə 5.000. Bir endirmə hər 100.000 URL üçün bir neçə sorğu istifadə edir.',
|
||||
'Tokeni istənilən vaxt Yandex ID hesabınızda (id.yandex.com), məlumatlarınıza girişi olan tətbiqlər siyahısında ləğv edə bilərsiniz.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Tarama nəticələrini birbaşa Google Sheet-ə ixrac edin. Həmkarlarla paylaşılan canlı sənəd üzərində işləyərkən "CSV endir → Excel-də aç"-dan daha yaxşıdır.',
|
||||
|
||||
@@ -616,6 +616,81 @@ export const GUIDES_EN: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'Pull unsampled per-URL Yandex Metrica data: pageviews (hit-level, per page URL) and visits, users and search-engine visits (session-level, per landing page). You create your own small Yandex OAuth app and a read-only token, so API quotas count against your own Yandex account only.',
|
||||
prereqs: [
|
||||
'A Yandex account that can view the Metrica counter — its owner, or a user the owner granted at least view access (Metrica → Settings → Access).',
|
||||
'About 5 minutes. No payment or app review is needed.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Open the Yandex OAuth app registration page',
|
||||
detail:
|
||||
'Sign in with the Yandex account that can view the counter. If you are signed in to several Yandex accounts, switch to the right one first — the token belongs to whichever account approves it in step 5.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Choose the app type "For API access or debugging"',
|
||||
detail:
|
||||
'The type cannot be changed after the app is created. Then fill in:\n• Name: "FreeCrawl" (any label you will recognise)\n• Contact email: your address',
|
||||
},
|
||||
{
|
||||
title: 'Add the metrika:read permission',
|
||||
detail:
|
||||
'In the data access section, search for Yandex Metrica and tick only the permission with the code metrika:read (reading statistics and counter parameters). FreeCrawl never writes to Metrica, so leave metrika:write and the other permissions unticked.\n\nIf the counter belongs to an organisation (Yandex 360 for Business) account, also add passport:business.',
|
||||
},
|
||||
{
|
||||
title: 'Create the app and copy its ClientID',
|
||||
detail:
|
||||
'After saving, the app page shows the ClientID (a 32-character hex string). You do not need the client secret.',
|
||||
},
|
||||
{
|
||||
title: 'Open the authorize link to get the token',
|
||||
detail:
|
||||
'In the same browser, open this address with your ClientID in place of <ClientID>:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nApprove the access request. Yandex then shows the token on the page — copy it.',
|
||||
},
|
||||
{
|
||||
title: 'Paste the token in FreeCrawl',
|
||||
detail:
|
||||
'Settings → Integrations → Yandex Metrica → OAuth token → Save. The token stays on this computer and is sent only to the Metrica API. It is encrypted with the operating system keychain when one is available; on Linux without a keyring it is stored unencrypted in a file only your user can read.',
|
||||
},
|
||||
{
|
||||
title: 'List counters, pick one and fetch',
|
||||
detail:
|
||||
'Pick the counter for the crawled site, choose a window (the last 7, 28 or 90 full days, ending yesterday) and fetch. A large site takes a few minutes: Metrica answers each page of up to 100,000 URLs in 15–30 s.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'The token expired, was revoked, or was copied incompletely. Open the authorize link from step 5 again and paste the new token.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
"The account that approved the token cannot see this counter, or the app lacks metrika:read. Check the counter's access list in Metrica, or edit the app at oauth.yandex.com, add metrika:read and get a new token.",
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'The token belongs to a different Yandex account. Sign in to the right account, open the authorize link again and replace the token.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl retries these automatically with growing pauses. If the fetch still fails, try again later or use a shorter window.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'Pageviews count every view of a URL; visits, users and search-engine visits count sessions by the page they started on. A page can have many pageviews and zero visits.',
|
||||
'Summing users across pages over-counts: one person can land on several pages.',
|
||||
'Metrica API limits are per Yandex account: 200 requests per 5 minutes, 3 at a time, 5,000 per day. One fetch uses a few requests per 100,000 URLs.',
|
||||
'You can revoke the token at any time in your Yandex ID account (id.yandex.com), in the list of apps with access to your data.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Export crawl results straight to a Google Sheet. Better than "download CSV → open in Excel" when you\'re collaborating with teammates on a shared live document.',
|
||||
|
||||
@@ -628,6 +628,81 @@ export const GUIDES_ES: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'Obtén datos de Yandex Metrica por URL sin muestreo: vistas de página (nivel de hit, por URL de página) y visitas, usuarios y visitas desde buscadores (nivel de sesión, por página de entrada). Creas tu propia pequeña app OAuth de Yandex y un token de solo lectura, así que las cuotas de la API se descuentan únicamente de tu propia cuenta de Yandex.',
|
||||
prereqs: [
|
||||
'Una cuenta de Yandex que pueda ver el contador de Metrica — su propietario o un usuario al que el propietario haya concedido al menos acceso de lectura (Metrica → Settings → Access).',
|
||||
'Unos 5 minutos. No hace falta ningún pago ni revisión de la app.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Abre la página de registro de apps OAuth de Yandex',
|
||||
detail:
|
||||
'Inicia sesión con la cuenta de Yandex que puede ver el contador. Si tienes la sesión iniciada en varias cuentas de Yandex, cambia primero a la correcta — el token pertenece a la cuenta que lo apruebe en el paso 5.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Elige el tipo de app "For API access or debugging"',
|
||||
detail:
|
||||
'El tipo no se puede cambiar después de crear la app. Luego rellena:\n• Name: "FreeCrawl" (cualquier etiqueta que reconozcas)\n• Contact email: tu dirección de correo',
|
||||
},
|
||||
{
|
||||
title: 'Añade el permiso metrika:read',
|
||||
detail:
|
||||
'En la sección de acceso a datos, busca Yandex Metrica y marca solo el permiso con el código metrika:read (lectura de estadísticas y de parámetros del contador). FreeCrawl nunca escribe en Metrica, así que deja sin marcar metrika:write y los demás permisos.\n\nSi el contador pertenece a una cuenta de organización (Yandex 360 for Business), añade también passport:business.',
|
||||
},
|
||||
{
|
||||
title: 'Crea la app y copia su ClientID',
|
||||
detail:
|
||||
'Tras guardar, la página de la app muestra el ClientID (una cadena hexadecimal de 32 caracteres). No necesitas el client secret.',
|
||||
},
|
||||
{
|
||||
title: 'Abre el enlace de autorización para obtener el token',
|
||||
detail:
|
||||
'En el mismo navegador, abre esta dirección poniendo tu ClientID en lugar de <ClientID>:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nAprueba la solicitud de acceso. Yandex mostrará entonces el token en la página — cópialo.',
|
||||
},
|
||||
{
|
||||
title: 'Pega el token en FreeCrawl',
|
||||
detail:
|
||||
'Ajustes → Integraciones → Yandex Metrica → OAuth token → Guardar. El token se queda en este ordenador y solo se envía a la API de Metrica. Se cifra con el almacén de credenciales del sistema operativo cuando hay uno disponible; en Linux sin keyring se guarda sin cifrar en un archivo que solo tu usuario puede leer.',
|
||||
},
|
||||
{
|
||||
title: 'Lista los contadores, elige uno y descarga',
|
||||
detail:
|
||||
'Elige el contador del sitio rastreado, escoge una ventana (los últimos 7, 28 o 90 días completos, hasta ayer) y descarga. Un sitio grande tarda unos minutos: Metrica responde cada página de hasta 100.000 URL en 15–30 s.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'El token caducó, se revocó o se copió de forma incompleta. Vuelve a abrir el enlace de autorización del paso 5 y pega el token nuevo.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'La cuenta que aprobó el token no puede ver este contador, o a la app le falta metrika:read. Revisa la lista de acceso del contador en Metrica, o edita la app en oauth.yandex.com, añade metrika:read y obtén un token nuevo.',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'El token pertenece a otra cuenta de Yandex. Inicia sesión en la cuenta correcta, vuelve a abrir el enlace de autorización y sustituye el token.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl los reintenta automáticamente con pausas cada vez más largas. Si la descarga sigue fallando, inténtalo más tarde o usa una ventana más corta.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'Las vistas de página cuentan cada vista de una URL; las visitas, los usuarios y las visitas desde buscadores cuentan sesiones según la página en la que empezaron. Una página puede tener muchas vistas de página y cero visitas.',
|
||||
'Sumar los usuarios de varias páginas da un recuento inflado: una misma persona puede entrar por varias páginas.',
|
||||
'Los límites de la API de Metrica son por cuenta de Yandex: 200 peticiones cada 5 minutos, 3 a la vez, 5.000 al día. Una descarga usa unas pocas peticiones por cada 100.000 URL.',
|
||||
'Puedes revocar el token en cualquier momento desde tu cuenta de Yandex ID (id.yandex.com), en la lista de apps con acceso a tus datos.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Exporta los resultados del rastreo directamente a una hoja de Google Sheets. Mejor que "descargar CSV → abrir en Excel" cuando colaboras con compañeros en un documento compartido en vivo.',
|
||||
|
||||
@@ -627,6 +627,81 @@ export const GUIDES_FR: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
"Récupérez les données Yandex Metrica non échantillonnées par URL : pages vues (niveau hit, par URL de page) ainsi que visites, utilisateurs et visites depuis les moteurs de recherche (niveau session, par page d'entrée). Vous créez votre propre petite application OAuth Yandex et un jeton en lecture seule : les quotas de l'API sont donc décomptés uniquement de votre propre compte Yandex.",
|
||||
prereqs: [
|
||||
"Un compte Yandex pouvant consulter le compteur Metrica — son propriétaire ou un utilisateur auquel le propriétaire a accordé au moins un accès en lecture (Metrica → Settings → Access).",
|
||||
"Environ 5 minutes. Aucun paiement ni validation de l'application n'est nécessaire.",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "Ouvrez la page d'enregistrement des applications OAuth de Yandex",
|
||||
detail:
|
||||
"Connectez-vous avec le compte Yandex qui peut consulter le compteur. Si vous êtes connecté à plusieurs comptes Yandex, basculez d'abord sur le bon — le jeton appartient au compte qui l'approuve à l'étape 5.",
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Choisissez le type d\'application "For API access or debugging"',
|
||||
detail:
|
||||
'Le type ne peut plus être modifié une fois l\'application créée. Renseignez ensuite :\n• Name : "FreeCrawl" (n\'importe quel libellé que vous reconnaîtrez)\n• Contact email : votre adresse',
|
||||
},
|
||||
{
|
||||
title: "Ajoutez l'autorisation metrika:read",
|
||||
detail:
|
||||
"Dans la section d'accès aux données, recherchez Yandex Metrica et cochez uniquement l'autorisation portant le code metrika:read (lecture des statistiques et des paramètres du compteur). FreeCrawl n'écrit jamais dans Metrica : laissez donc metrika:write et les autres autorisations décochées.\n\nSi le compteur appartient à un compte d'organisation (Yandex 360 for Business), ajoutez aussi passport:business.",
|
||||
},
|
||||
{
|
||||
title: "Créez l'application et copiez son ClientID",
|
||||
detail:
|
||||
"Après l'enregistrement, la page de l'application affiche le ClientID (une chaîne hexadécimale de 32 caractères). Vous n'avez pas besoin du client secret.",
|
||||
},
|
||||
{
|
||||
title: "Ouvrez le lien d'autorisation pour obtenir le jeton",
|
||||
detail:
|
||||
"Dans le même navigateur, ouvrez cette adresse en remplaçant <ClientID> par votre ClientID :\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nApprouvez la demande d'accès. Yandex affiche alors le jeton sur la page — copiez-le.",
|
||||
},
|
||||
{
|
||||
title: 'Collez le jeton dans FreeCrawl',
|
||||
detail:
|
||||
"Paramètres → Intégrations → Yandex Metrica → OAuth token → Enregistrer. Le jeton reste sur cet ordinateur et n'est envoyé qu'à l'API Metrica. Il est chiffré avec le trousseau du système d'exploitation lorsqu'il y en a un ; sous Linux sans keyring, il est stocké non chiffré dans un fichier lisible uniquement par votre utilisateur.",
|
||||
},
|
||||
{
|
||||
title: 'Listez les compteurs, choisissez-en un et téléchargez',
|
||||
detail:
|
||||
"Choisissez le compteur du site exploré, une fenêtre (les 7, 28 ou 90 derniers jours complets, jusqu'à hier) puis téléchargez. Un gros site prend quelques minutes : Metrica répond à chaque page de 100 000 URL maximum en 15–30 s.",
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
"Le jeton a expiré, a été révoqué ou a été copié de façon incomplète. Rouvrez le lien d'autorisation de l'étape 5 et collez le nouveau jeton.",
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
"Le compte qui a approuvé le jeton ne voit pas ce compteur, ou l'application n'a pas metrika:read. Vérifiez la liste d'accès du compteur dans Metrica, ou modifiez l'application sur oauth.yandex.com, ajoutez metrika:read et obtenez un nouveau jeton.",
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
"Le jeton appartient à un autre compte Yandex. Connectez-vous au bon compte, rouvrez le lien d'autorisation et remplacez le jeton.",
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl les relance automatiquement avec des pauses de plus en plus longues. Si le téléchargement échoue encore, réessayez plus tard ou choisissez une fenêtre plus courte.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
"Les pages vues comptent chaque affichage d'une URL ; les visites, les utilisateurs et les visites depuis les moteurs de recherche comptent les sessions selon la page où elles ont commencé. Une page peut avoir beaucoup de pages vues et zéro visite.",
|
||||
'Additionner les utilisateurs de plusieurs pages surestime le total : une même personne peut arriver sur plusieurs pages.',
|
||||
"Les limites de l'API Metrica s'appliquent par compte Yandex : 200 requêtes par tranche de 5 minutes, 3 simultanées, 5 000 par jour. Un téléchargement utilise quelques requêtes par tranche de 100 000 URL.",
|
||||
'Vous pouvez révoquer le jeton à tout moment depuis votre compte Yandex ID (id.yandex.com), dans la liste des applications ayant accès à vos données.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Exportez les résultats d\'exploration directement dans une feuille Google Sheets. Mieux que "télécharger le CSV → ouvrir dans Excel" quand vous collaborez avec des collègues sur un document partagé en direct.',
|
||||
|
||||
@@ -611,6 +611,81 @@ export const GUIDES_HI: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'बिना सैंपलिंग वाला प्रति-URL Yandex Metrica डेटा खींचें: पृष्ठ दृश्य (हिट-स्तर, प्रति पृष्ठ URL) तथा विज़िट, उपयोगकर्ता और खोज इंजन विज़िट (सत्र-स्तर, प्रति लैंडिंग पृष्ठ)। आप अपना छोटा Yandex OAuth ऐप और केवल-पढ़ने वाला टोकन खुद बनाते हैं, इसलिए API कोटा सिर्फ़ आपके अपने Yandex खाते पर गिना जाता है।',
|
||||
prereqs: [
|
||||
'ऐसा Yandex खाता जो Metrica काउंटर देख सके — काउंटर का मालिक, या ऐसा उपयोगकर्ता जिसे मालिक ने कम से कम देखने की अनुमति दी हो (Metrica → Settings → Access)।',
|
||||
'लगभग 5 मिनट। किसी भुगतान या ऐप समीक्षा की ज़रूरत नहीं।',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Yandex OAuth ऐप पंजीकरण पृष्ठ खोलें',
|
||||
detail:
|
||||
'उस Yandex खाते से साइन इन करें जो काउंटर देख सकता है। अगर आप कई Yandex खातों में साइन इन हैं, तो पहले सही खाते पर जाएँ — टोकन उसी खाते का होता है जो चरण 5 में उसे स्वीकृत करता है।',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'ऐप प्रकार "For API access or debugging" चुनें',
|
||||
detail:
|
||||
'ऐप बनने के बाद प्रकार बदला नहीं जा सकता। फिर भरें:\n• नाम: "FreeCrawl" (कोई भी नाम जिसे आप पहचान सकें)\n• संपर्क ईमेल: आपका ईमेल पता',
|
||||
},
|
||||
{
|
||||
title: 'metrika:read अनुमति जोड़ें',
|
||||
detail:
|
||||
'डेटा एक्सेस अनुभाग में Yandex Metrica खोजें और केवल metrika:read कोड वाली अनुमति (आँकड़े और काउंटर पैरामीटर पढ़ना) चेक करें। FreeCrawl कभी Metrica में कुछ नहीं लिखता, इसलिए metrika:write और बाकी अनुमतियाँ अनचेक छोड़ें।\n\nअगर काउंटर किसी संगठन (Yandex 360 for Business) खाते का है, तो passport:business भी जोड़ें।',
|
||||
},
|
||||
{
|
||||
title: 'ऐप बनाएँ और उसका ClientID कॉपी करें',
|
||||
detail:
|
||||
'सहेजने के बाद ऐप पृष्ठ पर ClientID (32 अक्षरों की hex स्ट्रिंग) दिखता है। क्लाइंट सीक्रेट की ज़रूरत नहीं है।',
|
||||
},
|
||||
{
|
||||
title: 'टोकन पाने के लिए अधिकृत करने वाला लिंक खोलें',
|
||||
detail:
|
||||
'उसी ब्राउज़र में <ClientID> की जगह अपना ClientID रखकर यह पता खोलें:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nएक्सेस अनुरोध स्वीकृत करें। फिर Yandex पृष्ठ पर टोकन दिखाता है — उसे कॉपी करें।',
|
||||
},
|
||||
{
|
||||
title: 'FreeCrawl में टोकन पेस्ट करें',
|
||||
detail:
|
||||
'सेटिंग्स → इंटीग्रेशन → Yandex Metrica → OAuth token → सहेजें। टोकन इसी कंप्यूटर पर रहता है और केवल Metrica API को भेजा जाता है। उपलब्ध होने पर इसे ऑपरेटिंग सिस्टम की कीचेन से एन्क्रिप्ट किया जाता है; बिना कीरिंग वाले Linux पर यह बिना एन्क्रिप्शन के ऐसी फ़ाइल में रखा जाता है जिसे केवल आपका उपयोगकर्ता पढ़ सकता है।',
|
||||
},
|
||||
{
|
||||
title: 'काउंटर सूचीबद्ध करें, एक चुनें और डेटा लाएँ',
|
||||
detail:
|
||||
'क्रॉल की गई साइट का काउंटर चुनें, अवधि चुनें (कल तक के पिछले 7, 28 या 90 पूरे दिन) और डेटा लाएँ। बड़ी साइट में कुछ मिनट लगते हैं: Metrica अधिकतम 1,00,000 URL वाले हर पृष्ठ का जवाब 15–30 सेकंड में देता है।',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'टोकन की अवधि समाप्त हो गई, उसे रद्द कर दिया गया, या वह अधूरा कॉपी हुआ। चरण 5 का अधिकृत करने वाला लिंक फिर से खोलें और नया टोकन पेस्ट करें।',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'जिस खाते ने टोकन स्वीकृत किया वह यह काउंटर नहीं देख सकता, या ऐप में metrika:read नहीं है। Metrica में काउंटर की एक्सेस सूची जाँचें, या oauth.yandex.com पर ऐप संपादित करके metrika:read जोड़ें और नया टोकन लें।',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'टोकन किसी दूसरे Yandex खाते का है। सही खाते में साइन इन करें, अधिकृत करने वाला लिंक फिर से खोलें और टोकन बदल दें।',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl इन्हें बढ़ते अंतराल के साथ अपने-आप दोबारा आज़माता है। अगर फिर भी डेटा लाना विफल हो, तो बाद में फिर कोशिश करें या छोटी अवधि चुनें।',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'पृष्ठ दृश्य किसी URL के हर दृश्य को गिनते हैं; विज़िट, उपयोगकर्ता और खोज इंजन विज़िट सत्रों को उस पृष्ठ के अनुसार गिनते हैं जिस पर वे शुरू हुए। किसी पृष्ठ पर कई पृष्ठ दृश्य और शून्य विज़िट हो सकते हैं।',
|
||||
'सभी पृष्ठों के उपयोगकर्ताओं को जोड़ने से गिनती ज़्यादा आती है: एक व्यक्ति कई पृष्ठों पर आ सकता है।',
|
||||
'Metrica API सीमाएँ प्रति Yandex खाता लागू होती हैं: हर 5 मिनट में 200 अनुरोध, एक साथ 3, प्रतिदिन 5,000। एक बार डेटा लाने में प्रति 1,00,000 URL कुछ ही अनुरोध लगते हैं।',
|
||||
'आप अपने Yandex ID खाते (id.yandex.com) में, आपके डेटा तक पहुँच वाले ऐप की सूची से, कभी भी टोकन रद्द कर सकते हैं।',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'क्रॉल परिणाम सीधे Google Sheet में निर्यात करें। सहकर्मियों के साथ साझा लाइव दस्तावेज़ पर काम करते समय "CSV डाउनलोड → Excel में खोलें" से बेहतर।',
|
||||
|
||||
@@ -624,6 +624,81 @@ export const GUIDES_IT: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
"Recupera i dati di Yandex Metrica per URL senza campionamento: visualizzazioni di pagina (livello di hit, per URL di pagina) e visite, utenti e visite dai motori di ricerca (livello di sessione, per pagina di ingresso). Crei una tua piccola app OAuth di Yandex e un token di sola lettura, così le quote dell'API vengono scalate solo dal tuo account Yandex.",
|
||||
prereqs: [
|
||||
"Un account Yandex che possa vedere il contatore Metrica — il suo proprietario o un utente a cui il proprietario ha concesso almeno l'accesso in visualizzazione (Metrica → Settings → Access).",
|
||||
"Circa 5 minuti. Non servono pagamenti né la revisione dell'app.",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Apri la pagina di registrazione delle app OAuth di Yandex',
|
||||
detail:
|
||||
"Accedi con l'account Yandex che può vedere il contatore. Se hai effettuato l'accesso con più account Yandex, passa prima a quello giusto — il token appartiene all'account che lo approva al passaggio 5.",
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Scegli il tipo di app "For API access or debugging"',
|
||||
detail:
|
||||
'Il tipo non può essere modificato dopo la creazione dell\'app. Poi compila:\n• Name: "FreeCrawl" (qualsiasi etichetta che riconoscerai)\n• Contact email: il tuo indirizzo',
|
||||
},
|
||||
{
|
||||
title: 'Aggiungi il permesso metrika:read',
|
||||
detail:
|
||||
'Nella sezione di accesso ai dati, cerca Yandex Metrica e spunta solo il permesso con il codice metrika:read (lettura delle statistiche e dei parametri del contatore). FreeCrawl non scrive mai su Metrica, quindi lascia non spuntati metrika:write e gli altri permessi.\n\nSe il contatore appartiene a un account aziendale (Yandex 360 for Business), aggiungi anche passport:business.',
|
||||
},
|
||||
{
|
||||
title: "Crea l'app e copia il suo ClientID",
|
||||
detail:
|
||||
"Dopo il salvataggio, la pagina dell'app mostra il ClientID (una stringa esadecimale di 32 caratteri). Il client secret non ti serve.",
|
||||
},
|
||||
{
|
||||
title: 'Apri il link di autorizzazione per ottenere il token',
|
||||
detail:
|
||||
'Nello stesso browser, apri questo indirizzo sostituendo <ClientID> con il tuo ClientID:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nApprova la richiesta di accesso. Yandex mostra quindi il token nella pagina — copialo.',
|
||||
},
|
||||
{
|
||||
title: 'Incolla il token in FreeCrawl',
|
||||
detail:
|
||||
"Impostazioni → Integrazioni → Yandex Metrica → OAuth token → Salva. Il token resta su questo computer e viene inviato solo all'API di Metrica. Viene crittografato con il portachiavi del sistema operativo, se disponibile; su Linux senza keyring viene salvato non crittografato in un file leggibile solo dal tuo utente.",
|
||||
},
|
||||
{
|
||||
title: 'Elenca i contatori, scegline uno e scarica',
|
||||
detail:
|
||||
'Scegli il contatore del sito scansionato, una finestra (gli ultimi 7, 28 o 90 giorni interi, fino a ieri) e scarica. Un sito grande richiede alcuni minuti: Metrica risponde a ogni pagina di massimo 100.000 URL in 15–30 s.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'Il token è scaduto, è stato revocato o è stato copiato in modo incompleto. Riapri il link di autorizzazione del passaggio 5 e incolla il nuovo token.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
"L'account che ha approvato il token non può vedere questo contatore, oppure all'app manca metrika:read. Controlla l'elenco degli accessi del contatore in Metrica, oppure modifica l'app su oauth.yandex.com, aggiungi metrika:read e ottieni un nuovo token.",
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
"Il token appartiene a un altro account Yandex. Accedi all'account giusto, riapri il link di autorizzazione e sostituisci il token.",
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl li riprova automaticamente con pause sempre più lunghe. Se il download continua a non riuscire, riprova più tardi o usa una finestra più breve.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'Le visualizzazioni di pagina contano ogni visualizzazione di un URL; visite, utenti e visite dai motori di ricerca contano le sessioni in base alla pagina in cui sono iniziate. Una pagina può avere molte visualizzazioni e zero visite.',
|
||||
'Sommare gli utenti di più pagine porta a un conteggio eccessivo: una stessa persona può entrare da più pagine.',
|
||||
"I limiti dell'API di Metrica valgono per account Yandex: 200 richieste ogni 5 minuti, 3 alla volta, 5.000 al giorno. Un download usa poche richieste ogni 100.000 URL.",
|
||||
"Puoi revocare il token in qualsiasi momento dal tuo account Yandex ID (id.yandex.com), nell'elenco delle app con accesso ai tuoi dati.",
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Esporta i risultati della scansione direttamente in un foglio Google Sheets. Meglio di "scarica CSV → apri in Excel" quando collabori con i colleghi su un documento condiviso in tempo reale.',
|
||||
|
||||
@@ -610,6 +610,81 @@ export const GUIDES_KO: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'샘플링되지 않은 URL별 Yandex Metrica 데이터를 가져옵니다: 페이지뷰(히트 수준, 페이지 URL별)와 방문수, 사용자수, 검색엔진 방문수(세션 수준, 랜딩 페이지별). 작은 Yandex OAuth 앱과 읽기 전용 토큰을 직접 만들기 때문에 API 할당량은 본인의 Yandex 계정에만 집계됩니다.',
|
||||
prereqs: [
|
||||
'Metrica 카운터를 볼 수 있는 Yandex 계정 — 카운터 소유자이거나, 소유자가 최소 보기 권한을 부여한 사용자(Metrica → Settings → Access).',
|
||||
'약 5분. 결제나 앱 심사가 필요 없습니다.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Yandex OAuth 앱 등록 페이지 열기',
|
||||
detail:
|
||||
'카운터를 볼 수 있는 Yandex 계정으로 로그인하세요. 여러 Yandex 계정에 로그인되어 있다면 먼저 올바른 계정으로 전환하세요 — 토큰은 5단계에서 승인한 계정에 속합니다.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: '앱 유형 "For API access or debugging" 선택',
|
||||
detail:
|
||||
'앱을 만든 뒤에는 유형을 바꿀 수 없습니다. 그런 다음 다음 항목을 입력하세요:\n• 이름: "FreeCrawl"(알아볼 수 있는 아무 이름)\n• 연락처 이메일: 본인 이메일 주소',
|
||||
},
|
||||
{
|
||||
title: 'metrika:read 권한 추가',
|
||||
detail:
|
||||
'데이터 액세스 섹션에서 Yandex Metrica를 검색하고 코드가 metrika:read인 권한(통계 및 카운터 매개변수 읽기)만 체크하세요. FreeCrawl은 Metrica에 아무것도 쓰지 않으므로 metrika:write와 다른 권한은 체크하지 마세요.\n\n카운터가 조직(Yandex 360 for Business) 계정에 속해 있다면 passport:business도 추가하세요.',
|
||||
},
|
||||
{
|
||||
title: '앱을 만들고 ClientID 복사',
|
||||
detail:
|
||||
'저장하면 앱 페이지에 ClientID(32자리 16진수 문자열)가 표시됩니다. 클라이언트 비밀 값(client secret)은 필요 없습니다.',
|
||||
},
|
||||
{
|
||||
title: '승인 링크를 열어 토큰 받기',
|
||||
detail:
|
||||
'같은 브라우저에서 <ClientID> 자리에 본인의 ClientID를 넣어 다음 주소를 여세요:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\n액세스 요청을 승인하세요. 그러면 Yandex가 페이지에 토큰을 표시합니다 — 복사하세요.',
|
||||
},
|
||||
{
|
||||
title: 'FreeCrawl에 토큰 붙여넣기',
|
||||
detail:
|
||||
'설정 → 연동 → Yandex Metrica → OAuth token → 저장. 토큰은 이 컴퓨터에만 보관되며 Metrica API로만 전송됩니다. 운영체제 키체인을 사용할 수 있으면 키체인으로 암호화되고, 키링이 없는 Linux에서는 본인 사용자만 읽을 수 있는 파일에 암호화되지 않은 상태로 저장됩니다.',
|
||||
},
|
||||
{
|
||||
title: '카운터 나열, 하나 선택 후 가져오기',
|
||||
detail:
|
||||
'크롤한 사이트의 카운터를 선택하고 기간(어제로 끝나는 최근 7일, 28일 또는 90일의 전체 일수)을 고른 뒤 가져오세요. 큰 사이트는 몇 분 걸립니다: Metrica는 최대 100,000개 URL로 된 각 페이지에 15–30초 안에 응답합니다.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'토큰이 만료되었거나 취소되었거나 일부만 복사되었습니다. 5단계의 승인 링크를 다시 열고 새 토큰을 붙여넣으세요.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'토큰을 승인한 계정이 이 카운터를 볼 수 없거나, 앱에 metrika:read 권한이 없습니다. Metrica에서 카운터의 액세스 목록을 확인하거나, oauth.yandex.com에서 앱을 편집해 metrika:read를 추가한 뒤 새 토큰을 받으세요.',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'토큰이 다른 Yandex 계정에 속해 있습니다. 올바른 계정으로 로그인하고 승인 링크를 다시 열어 토큰을 교체하세요.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl은 점점 길어지는 간격을 두고 자동으로 재시도합니다. 그래도 가져오기가 실패하면 나중에 다시 시도하거나 더 짧은 기간을 사용하세요.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'페이지뷰는 URL의 모든 조회를 집계하고, 방문수·사용자수·검색엔진 방문수는 세션이 시작된 페이지를 기준으로 세션을 집계합니다. 한 페이지에 페이지뷰는 많아도 방문수는 0일 수 있습니다.',
|
||||
'페이지별 사용자수를 합산하면 과다 집계됩니다: 한 사람이 여러 페이지로 유입될 수 있습니다.',
|
||||
'Metrica API 한도는 Yandex 계정별로 적용됩니다: 5분당 요청 200개, 동시 3개, 하루 5,000개. 한 번 가져올 때 URL 100,000개당 요청 몇 개만 사용합니다.',
|
||||
'Yandex ID 계정(id.yandex.com)에서 데이터 액세스 권한이 있는 앱 목록을 통해 언제든지 토큰을 취소할 수 있습니다.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'크롤링 결과를 Google 스프레드시트로 바로 내보냅니다. 팀원과 공유 실시간 문서로 협업할 때 "CSV 다운로드 → Excel에서 열기"보다 낫습니다.',
|
||||
|
||||
@@ -626,6 +626,81 @@ export const GUIDES_PT_BR: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'Obtenha dados não amostrados do Yandex Metrica por URL: visualizações de página (nível de hit, por URL da página) e visitas, usuários e visitas de mecanismos de busca (nível de sessão, por página de entrada). Você cria seu próprio pequeno app OAuth do Yandex e um token somente leitura, então as cotas da API contam apenas na sua própria conta Yandex.',
|
||||
prereqs: [
|
||||
'Uma conta Yandex que possa ver o contador do Metrica — o proprietário ou um usuário a quem o proprietário concedeu pelo menos acesso de visualização (Metrica → Settings → Access).',
|
||||
'Cerca de 5 minutos. Não é necessário pagamento nem revisão do app.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Abra a página de registro de apps OAuth do Yandex',
|
||||
detail:
|
||||
'Entre com a conta Yandex que pode ver o contador. Se você estiver conectado a várias contas Yandex, mude primeiro para a correta — o token pertence à conta que aprovar o acesso no passo 5.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Escolha o tipo de app "For API access or debugging"',
|
||||
detail:
|
||||
'O tipo não pode ser alterado depois que o app é criado. Em seguida, preencha:\n• Nome: "FreeCrawl" (qualquer nome que você reconheça)\n• E-mail de contato: seu endereço',
|
||||
},
|
||||
{
|
||||
title: 'Adicione a permissão metrika:read',
|
||||
detail:
|
||||
'Na seção de acesso a dados, pesquise por Yandex Metrica e marque apenas a permissão com o código metrika:read (leitura de estatísticas e de parâmetros do contador). O FreeCrawl nunca grava nada no Metrica, então deixe metrika:write e as demais permissões desmarcadas.\n\nSe o contador pertencer a uma conta de organização (Yandex 360 for Business), adicione também passport:business.',
|
||||
},
|
||||
{
|
||||
title: 'Crie o app e copie o ClientID',
|
||||
detail:
|
||||
'Depois de salvar, a página do app mostra o ClientID (uma string hexadecimal de 32 caracteres). Você não precisa do segredo do cliente.',
|
||||
},
|
||||
{
|
||||
title: 'Abra o link de autorização para obter o token',
|
||||
detail:
|
||||
'No mesmo navegador, abra este endereço com o seu ClientID no lugar de <ClientID>:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nAprove a solicitação de acesso. Em seguida, o Yandex mostra o token na página — copie-o.',
|
||||
},
|
||||
{
|
||||
title: 'Cole o token no FreeCrawl',
|
||||
detail:
|
||||
'Configurações → Integrações → Yandex Metrica → OAuth token → Salvar. O token fica neste computador e é enviado apenas para a API do Metrica. Ele é criptografado com o chaveiro do sistema operacional quando houver um disponível; no Linux sem chaveiro, fica armazenado sem criptografia em um arquivo que só o seu usuário pode ler.',
|
||||
},
|
||||
{
|
||||
title: 'Liste os contadores, escolha um e baixe os dados',
|
||||
detail:
|
||||
'Escolha o contador do site rastreado, defina uma janela (os últimos 7, 28 ou 90 dias completos, terminando ontem) e baixe. Um site grande leva alguns minutos: o Metrica responde cada página de até 100.000 URLs em 15–30 s.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'O token expirou, foi revogado ou foi copiado pela metade. Abra de novo o link de autorização do passo 5 e cole o novo token.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'A conta que aprovou o token não consegue ver este contador, ou o app não tem metrika:read. Verifique a lista de acesso do contador no Metrica, ou edite o app em oauth.yandex.com, adicione metrika:read e obtenha um novo token.',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'O token pertence a outra conta Yandex. Entre na conta correta, abra o link de autorização de novo e substitua o token.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'O FreeCrawl tenta de novo automaticamente, com pausas cada vez maiores. Se o download ainda falhar, tente mais tarde ou use uma janela mais curta.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'As visualizações de página contam cada visualização de uma URL; visitas, usuários e visitas de mecanismos de busca contam sessões pela página em que começaram. Uma página pode ter muitas visualizações e zero visitas.',
|
||||
'Somar usuários de várias páginas superestima o total: uma mesma pessoa pode entrar por várias páginas.',
|
||||
'Os limites da API do Metrica são por conta Yandex: 200 requisições a cada 5 minutos, 3 simultâneas, 5.000 por dia. Um download usa algumas requisições a cada 100.000 URLs.',
|
||||
'Você pode revogar o token a qualquer momento na sua conta Yandex ID (id.yandex.com), na lista de apps com acesso aos seus dados.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Exporte os resultados do rastreamento direto para uma planilha do Google Sheets. Melhor que "baixar CSV → abrir no Excel" quando você colabora com colegas num documento compartilhado ao vivo.',
|
||||
|
||||
@@ -617,6 +617,81 @@ export const GUIDES_RU: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'Получайте несэмплированные данные Yandex Metrica по каждому URL: просмотры (на уровне хитов, по URL страницы), а также визиты, посетителей и переходы из поисковых систем (на уровне визитов, по странице входа). Вы создаёте собственное небольшое OAuth-приложение Yandex и токен только для чтения, поэтому квоты API расходуются только в рамках вашего аккаунта Yandex.',
|
||||
prereqs: [
|
||||
'Аккаунт Yandex с доступом к счётчику Metrica — владелец счётчика или пользователь, которому владелец выдал как минимум доступ на просмотр (Metrica → Настройки → Доступ).',
|
||||
'Около 5 минут. Ни оплата, ни проверка приложения не требуются.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Откройте страницу регистрации OAuth-приложения Yandex',
|
||||
detail:
|
||||
'Войдите под аккаунтом Yandex, у которого есть доступ к счётчику. Если вы вошли сразу в несколько аккаунтов Yandex, сначала переключитесь на нужный — токен будет принадлежать тому аккаунту, который подтвердит доступ на шаге 5.',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Выберите тип приложения "For API access or debugging"',
|
||||
detail:
|
||||
'После создания приложения тип изменить нельзя. Затем заполните:\n• Название: "FreeCrawl" (любое понятное вам название)\n• Почта для связи: ваш адрес',
|
||||
},
|
||||
{
|
||||
title: 'Добавьте право metrika:read',
|
||||
detail:
|
||||
'В разделе доступа к данным найдите Yandex Metrica и отметьте только право с кодом metrika:read (получение статистики и чтение параметров счётчиков). FreeCrawl ничего не записывает в Metrica, поэтому metrika:write и остальные права оставьте неотмеченными.\n\nЕсли счётчик принадлежит аккаунту организации (Yandex 360 для бизнеса), добавьте также passport:business.',
|
||||
},
|
||||
{
|
||||
title: 'Создайте приложение и скопируйте его ClientID',
|
||||
detail:
|
||||
'После сохранения на странице приложения отображается ClientID (32-символьная шестнадцатеричная строка). Секрет клиента не понадобится.',
|
||||
},
|
||||
{
|
||||
title: 'Откройте ссылку авторизации, чтобы получить токен',
|
||||
detail:
|
||||
'В том же браузере откройте этот адрес, подставив свой ClientID вместо <ClientID>:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nПодтвердите запрос доступа. После этого Yandex покажет токен на странице — скопируйте его.',
|
||||
},
|
||||
{
|
||||
title: 'Вставьте токен в FreeCrawl',
|
||||
detail:
|
||||
'Настройки → Интеграции → Yandex Metrica → OAuth token → Сохранить. Токен остаётся на этом компьютере и отправляется только в API Metrica. Если в системе есть хранилище ключей, токен шифруется с его помощью; в Linux без связки ключей он хранится в незашифрованном виде в файле, который может прочитать только ваш пользователь.',
|
||||
},
|
||||
{
|
||||
title: 'Получите список счётчиков, выберите нужный и загрузите данные',
|
||||
detail:
|
||||
'Выберите счётчик обойдённого сайта, задайте период (последние 7, 28 или 90 полных дней по вчерашний включительно) и запустите загрузку. Для большого сайта это займёт несколько минут: Metrica отдаёт каждую страницу результатов размером до 100 000 URL за 15–30 с.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'Срок действия токена истёк, токен был отозван или скопирован не полностью. Снова откройте ссылку авторизации из шага 5 и вставьте новый токен.',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'Аккаунт, подтвердивший токен, не видит этот счётчик, или у приложения нет права metrika:read. Проверьте список доступа счётчика в Metrica либо отредактируйте приложение на oauth.yandex.com: добавьте metrika:read и получите новый токен.',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'Токен принадлежит другому аккаунту Yandex. Войдите в нужный аккаунт, снова откройте ссылку авторизации и замените токен.',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl автоматически повторяет такие запросы с нарастающими паузами. Если загрузка всё равно не удаётся, повторите попытку позже или выберите более короткий период.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'Просмотры учитывают каждый просмотр URL; визиты, посетители и переходы из поисковых систем считаются по странице, с которой начался визит. У страницы может быть много просмотров и ни одного визита.',
|
||||
'Сумма посетителей по страницам завышена: один и тот же человек может входить на сайт через несколько страниц.',
|
||||
'Лимиты API Metrica действуют на аккаунт Yandex: 200 запросов за 5 минут, не более 3 одновременно, 5 000 в сутки. Одна загрузка расходует несколько запросов на каждые 100 000 URL.',
|
||||
'Токен можно отозвать в любой момент в аккаунте Yandex ID (id.yandex.com), в списке приложений, у которых есть доступ к вашим данным.',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'Экспортируйте результаты сканирования прямо в Google Таблицу. Лучше, чем «скачать CSV → открыть в Excel», когда вы работаете с коллегами в общем живом документе.',
|
||||
|
||||
@@ -619,6 +619,81 @@ export const GUIDES_TR: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
"Yandex Metrica'dan URL başına örneklenmemiş veri çekersin: sayfa görüntülemeleri (hit düzeyi, sayfa URL'sine göre) ile ziyaretler, kullanıcılar ve arama motoru ziyaretleri (oturum düzeyi, giriş sayfasına göre). Kendi küçük Yandex OAuth uygulamanı ve salt okunur bir token oluşturursun; API kotaları yalnızca kendi Yandex hesabından düşer.",
|
||||
prereqs: [
|
||||
"Metrica sayacını görebilen bir Yandex hesabı — sayacın sahibi ya da sahibin en az görüntüleme yetkisi verdiği bir kullanıcı (Metrica → Ayarlar → Erişim).",
|
||||
'Yaklaşık 5 dakika. Ödeme ya da uygulama incelemesi gerekmez.',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: 'Yandex OAuth uygulama kayıt sayfasını aç',
|
||||
detail:
|
||||
"Sayacı görebilen Yandex hesabıyla giriş yap. Birden fazla Yandex hesabıyla giriş yaptıysan önce doğru hesaba geç — token, 5. adımda onayı veren hesaba ait olur.",
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: 'Uygulama türü olarak "For API access or debugging" seç',
|
||||
detail:
|
||||
'Tür, uygulama oluşturulduktan sonra değiştirilemez. Ardından şunları doldur:\n• Ad: "FreeCrawl" (tanıyacağın herhangi bir ad)\n• İletişim e-postası: kendi adresin',
|
||||
},
|
||||
{
|
||||
title: 'metrika:read iznini ekle',
|
||||
detail:
|
||||
"Veri erişimi bölümünde Yandex Metrica'yı ara ve yalnızca metrika:read kodlu izni (istatistikleri ve sayaç parametrelerini okuma) işaretle. FreeCrawl Metrica'ya hiçbir şey yazmaz; metrika:write ve diğer izinleri işaretsiz bırak.\n\nSayaç bir kurum hesabına (Yandex 360 for Business) aitse passport:business iznini de ekle.",
|
||||
},
|
||||
{
|
||||
title: "Uygulamayı oluştur ve ClientID'sini kopyala",
|
||||
detail:
|
||||
"Kaydettikten sonra uygulama sayfasında ClientID görünür (32 karakterlik onaltılık bir dizi). Client secret'a ihtiyacın yok.",
|
||||
},
|
||||
{
|
||||
title: "Token'ı almak için yetkilendirme bağlantısını aç",
|
||||
detail:
|
||||
"Aynı tarayıcıda, <ClientID> yerine kendi ClientID'ni yazarak şu adresi aç:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\nErişim isteğini onayla. Yandex token'ı sayfada gösterir — kopyala.",
|
||||
},
|
||||
{
|
||||
title: "Token'ı FreeCrawl'a yapıştır",
|
||||
detail:
|
||||
"Ayarlar → Entegrasyonlar → Yandex Metrica → OAuth token → Kaydet. Token bu bilgisayarda kalır ve yalnızca Metrica API'sine gönderilir. Varsa işletim sisteminin anahtar zinciriyle şifrelenir; anahtar zinciri olmayan Linux'ta, yalnızca senin kullanıcının okuyabildiği bir dosyada şifrelenmeden saklanır.",
|
||||
},
|
||||
{
|
||||
title: 'Sayaçları listele, birini seç ve getir',
|
||||
detail:
|
||||
'Taranan sitenin sayacını seç, bir aralık belirle (dünle biten son 7, 28 ya da 90 tam gün) ve getir. Büyük bir site birkaç dakika sürer: Metrica en fazla 100.000 URL\'lik her sayfayı 15–30 sn\'de yanıtlar.',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401) hatası',
|
||||
solution:
|
||||
"Token'ın süresi dolmuş, iptal edilmiş ya da eksik kopyalanmış. 5. adımdaki yetkilendirme bağlantısını yeniden aç ve yeni token'ı yapıştır.",
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403) hatası',
|
||||
solution:
|
||||
"Token'ı onaylayan hesap bu sayacı göremiyor ya da uygulamada metrika:read izni yok. Metrica'da sayacın erişim listesini kontrol et veya oauth.yandex.com'da uygulamayı düzenleyip metrika:read ekle ve yeni bir token al.",
|
||||
},
|
||||
{
|
||||
problem: 'Sayaç listesi boş ya da sayaç listede yok',
|
||||
solution:
|
||||
"Token başka bir Yandex hesabına ait. Doğru hesaba giriş yap, yetkilendirme bağlantısını yeniden aç ve token'ı değiştir.",
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" ya da hız sınırı (throttling) hataları',
|
||||
solution:
|
||||
'FreeCrawl bunları giderek uzayan aralarla otomatik olarak yeniden dener. Getirme yine de başarısız olursa daha sonra tekrar dene ya da daha kısa bir aralık seç.',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
"Sayfa görüntülemeleri bir URL'nin her görüntülenmesini sayar; ziyaretler, kullanıcılar ve arama motoru ziyaretleri ise oturumları başladıkları sayfaya göre sayar. Bir sayfanın çok sayıda görüntülemesi olup hiç ziyareti olmayabilir.",
|
||||
'Kullanıcıları sayfalar üzerinden toplamak fazla sayım verir: bir kişi birkaç sayfaya giriş yapmış olabilir.',
|
||||
'Metrica API sınırları Yandex hesabı başınadır: 5 dakikada 200 istek, aynı anda 3 istek, günde 5.000 istek. Bir getirme her 100.000 URL için birkaç istek kullanır.',
|
||||
"Token'ı istediğin zaman Yandex ID hesabından (id.yandex.com), verilerine erişimi olan uygulamalar listesinden iptal edebilirsin.",
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
"Google Sheets entegrasyonu ile crawl sonuçlarını doğrudan bir Google Sheet'e export edersin. CSV indirip Excel'de açmak yerine ekip arkadaşlarıyla canlı paylaşılan Sheet üzerinde çalışmak için ideal.",
|
||||
|
||||
@@ -604,6 +604,81 @@ export const GUIDES_ZH_CN: Record<string, Guide> = {
|
||||
],
|
||||
lastReviewed: '2026-06-01',
|
||||
},
|
||||
metrica: {
|
||||
intro:
|
||||
'获取未抽样的、按 URL 划分的 Yandex Metrica 数据:页面浏览量(命中级,按页面 URL)以及访问次数、用户数和搜索引擎访问次数(会话级,按着陆页)。你需要自行创建一个小型 Yandex OAuth 应用和一个只读令牌,因此 API 配额只计入你自己的 Yandex 账户。',
|
||||
prereqs: [
|
||||
'一个可以查看该 Metrica 计数器的 Yandex 账户——计数器的所有者,或所有者至少授予了查看权限的用户(Metrica → Settings → Access)。',
|
||||
'大约 5 分钟。无需付费,也无需应用审核。',
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: '打开 Yandex OAuth 应用注册页面',
|
||||
detail:
|
||||
'使用可以查看该计数器的 Yandex 账户登录。如果你同时登录了多个 Yandex 账户,请先切换到正确的账户——令牌归属于在第 5 步中批准授权的那个账户。',
|
||||
link: { label: 'oauth.yandex.com', url: 'https://oauth.yandex.com/client/new' },
|
||||
},
|
||||
{
|
||||
title: '选择应用类型 "For API access or debugging"',
|
||||
detail:
|
||||
'应用创建后类型无法更改。然后填写:\n• 名称:"FreeCrawl"(任何你能认出的名称均可)\n• 联系邮箱:你的邮箱地址',
|
||||
},
|
||||
{
|
||||
title: '添加 metrika:read 权限',
|
||||
detail:
|
||||
'在数据访问部分搜索 Yandex Metrica,只勾选代码为 metrika:read 的权限(读取统计数据和计数器参数)。FreeCrawl 从不向 Metrica 写入数据,因此请不要勾选 metrika:write 及其他权限。\n\n如果计数器属于组织(Yandex 360 for Business)账户,还需添加 passport:business。',
|
||||
},
|
||||
{
|
||||
title: '创建应用并复制其 ClientID',
|
||||
detail:
|
||||
'保存后,应用页面会显示 ClientID(一个 32 位十六进制字符串)。你不需要客户端机密(client secret)。',
|
||||
},
|
||||
{
|
||||
title: '打开授权链接以获取令牌',
|
||||
detail:
|
||||
'在同一浏览器中打开以下地址,并将 <ClientID> 替换为你的 ClientID:\n\nhttps://oauth.yandex.com/authorize?response_type=token&client_id=<ClientID>\n\n批准访问请求。随后 Yandex 会在页面上显示令牌——将其复制。',
|
||||
},
|
||||
{
|
||||
title: '在 FreeCrawl 中粘贴令牌',
|
||||
detail:
|
||||
'设置 → 集成 → Yandex Metrica → OAuth token → 保存。令牌只保存在这台计算机上,并且只会发送给 Metrica API。如果系统提供密钥链,令牌会用操作系统的密钥链加密;在没有密钥环的 Linux 上,它会以未加密形式存储在只有你的用户才能读取的文件中。',
|
||||
},
|
||||
{
|
||||
title: '列出计数器,选择一个并获取数据',
|
||||
detail:
|
||||
'选择所抓取网站对应的计数器,选择时间窗口(截至昨天的最近 7、28 或 90 个完整天),然后获取。大型网站需要几分钟:Metrica 每返回一页(最多 100,000 个 URL)需要 15–30 秒。',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
problem: '"Yandex Metrica rejected the token" (HTTP 401)',
|
||||
solution:
|
||||
'令牌已过期、已被撤销,或复制不完整。请再次打开第 5 步中的授权链接,并粘贴新令牌。',
|
||||
},
|
||||
{
|
||||
problem: '"Yandex Metrica denied access" (HTTP 403)',
|
||||
solution:
|
||||
'批准令牌的账户无法查看此计数器,或应用缺少 metrika:read 权限。请在 Metrica 中检查该计数器的访问列表,或在 oauth.yandex.com 编辑应用、添加 metrika:read 并获取新令牌。',
|
||||
},
|
||||
{
|
||||
problem: 'The counter list is empty or the counter is missing',
|
||||
solution:
|
||||
'令牌属于另一个 Yandex 账户。请登录正确的账户,再次打开授权链接并替换令牌。',
|
||||
},
|
||||
{
|
||||
problem: '"Query is too complicated" or throttling errors',
|
||||
solution:
|
||||
'FreeCrawl 会以逐渐延长的间隔自动重试这些请求。如果获取仍然失败,请稍后再试,或使用更短的时间窗口。',
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
'页面浏览量统计某个 URL 的每一次浏览;访问次数、用户数和搜索引擎访问次数则按会话的起始页面统计会话。一个页面可能有大量页面浏览,却没有任何访问。',
|
||||
'将各页面的用户数相加会造成重复计数:同一个人可能从多个页面进入。',
|
||||
'Metrica API 的限制按 Yandex 账户计算:每 5 分钟 200 个请求,同时最多 3 个,每天 5,000 个。一次获取每 100,000 个 URL 只需几个请求。',
|
||||
'你可以随时在 Yandex ID 账户(id.yandex.com)中有权访问你数据的应用列表里撤销该令牌。',
|
||||
],
|
||||
lastReviewed: '2026-10-11',
|
||||
},
|
||||
sheets: {
|
||||
intro:
|
||||
'将抓取结果直接导出到 Google 表格。当你与团队成员在共享的实时文档上协作时,比“下载 CSV → 在 Excel 中打开”更好。',
|
||||
|
||||
@@ -117,6 +117,10 @@ export const AZ_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Səhifə baxışları',
|
||||
'Engagement rate': 'Qarşılıqlı əlaqə nisbəti',
|
||||
'Avg session duration': 'Orta sessiya müddəti',
|
||||
'Pageviews (hits)': 'Səhifə baxışları (hit)',
|
||||
'Visits (landing)': 'Ziyarətlər (giriş)',
|
||||
'Users (landing)': 'İstifadəçilər (giriş)',
|
||||
'Search visits (landing)': 'Axtarış ziyarətləri (giriş)',
|
||||
Verdict: 'Nəticə',
|
||||
Coverage: 'Əhatə',
|
||||
Indexing: 'İndeksləmə',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const ES_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Vistas de página',
|
||||
'Engagement rate': 'Tasa de interacción',
|
||||
'Avg session duration': 'Duración media de sesión',
|
||||
'Pageviews (hits)': 'Vistas de página (hits)',
|
||||
'Visits (landing)': 'Visitas (entrada)',
|
||||
'Users (landing)': 'Usuarios (entrada)',
|
||||
'Search visits (landing)': 'Visitas de búsqueda (entrada)',
|
||||
Verdict: 'Veredicto',
|
||||
Coverage: 'Cobertura',
|
||||
Indexing: 'Indexación',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const FR_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Pages vues',
|
||||
'Engagement rate': 'Taux d’engagement',
|
||||
'Avg session duration': 'Durée moyenne de session',
|
||||
'Pageviews (hits)': 'Pages vues (hits)',
|
||||
'Visits (landing)': 'Visites (entrée)',
|
||||
'Users (landing)': 'Utilisateurs (entrée)',
|
||||
'Search visits (landing)': 'Visites de recherche (entrée)',
|
||||
Verdict: 'Verdict',
|
||||
Coverage: 'Couverture',
|
||||
Indexing: 'Indexation',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const HI_LABELS: Record<string, string> = {
|
||||
Pageviews: 'पृष्ठ दृश्य',
|
||||
'Engagement rate': 'सहभागिता दर',
|
||||
'Avg session duration': 'औसत सत्र अवधि',
|
||||
'Pageviews (hits)': 'पृष्ठ दृश्य (हिट)',
|
||||
'Visits (landing)': 'विज़िट (लैंडिंग)',
|
||||
'Users (landing)': 'उपयोगकर्ता (लैंडिंग)',
|
||||
'Search visits (landing)': 'खोज विज़िट (लैंडिंग)',
|
||||
Verdict: 'निर्णय',
|
||||
Coverage: 'कवरेज',
|
||||
Indexing: 'अनुक्रमण',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const IT_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Visualizzazioni di pagina',
|
||||
'Engagement rate': 'Tasso di coinvolgimento',
|
||||
'Avg session duration': 'Durata media della sessione',
|
||||
'Pageviews (hits)': 'Visualizzazioni di pagina (hit)',
|
||||
'Visits (landing)': 'Visite (ingresso)',
|
||||
'Users (landing)': 'Utenti (ingresso)',
|
||||
'Search visits (landing)': 'Visite da ricerca (ingresso)',
|
||||
Verdict: 'Verdetto',
|
||||
Coverage: 'Copertura',
|
||||
Indexing: 'Indicizzazione',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const KO_LABELS: Record<string, string> = {
|
||||
Pageviews: '페이지뷰',
|
||||
'Engagement rate': '참여율',
|
||||
'Avg session duration': '평균 세션 시간',
|
||||
'Pageviews (hits)': '페이지뷰(히트)',
|
||||
'Visits (landing)': '방문수(랜딩)',
|
||||
'Users (landing)': '사용자수(랜딩)',
|
||||
'Search visits (landing)': '검색 방문수(랜딩)',
|
||||
Verdict: '판정',
|
||||
Coverage: '적용 범위',
|
||||
Indexing: '색인 상태',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const PT_BR_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Visualizações de página',
|
||||
'Engagement rate': 'Taxa de engajamento',
|
||||
'Avg session duration': 'Duração média da sessão',
|
||||
'Pageviews (hits)': 'Visualizações (hits)',
|
||||
'Visits (landing)': 'Visitas (entrada)',
|
||||
'Users (landing)': 'Usuários (entrada)',
|
||||
'Search visits (landing)': 'Visitas de busca (entrada)',
|
||||
Verdict: 'Veredito',
|
||||
Coverage: 'Cobertura',
|
||||
Indexing: 'Indexação',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const RU_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Просмотры страниц',
|
||||
'Engagement rate': 'Уровень вовлечённости',
|
||||
'Avg session duration': 'Средняя длительность сеанса',
|
||||
'Pageviews (hits)': 'Просмотры (хиты)',
|
||||
'Visits (landing)': 'Визиты (вход)',
|
||||
'Users (landing)': 'Посетители (вход)',
|
||||
'Search visits (landing)': 'Переходы из поиска (вход)',
|
||||
Verdict: 'Заключение',
|
||||
Coverage: 'Покрытие',
|
||||
Indexing: 'Индексирование',
|
||||
|
||||
@@ -108,6 +108,10 @@ export const TR_LABELS: Record<string, string> = {
|
||||
Pageviews: 'Sayfa görüntülemeleri',
|
||||
'Engagement rate': 'Etkileşim oranı',
|
||||
'Avg session duration': 'Ort. oturum süresi',
|
||||
'Pageviews (hits)': 'Sayfa görüntülemeleri (hit)',
|
||||
'Visits (landing)': 'Ziyaretler (giriş)',
|
||||
'Users (landing)': 'Kullanıcılar (giriş)',
|
||||
'Search visits (landing)': 'Arama ziyaretleri (giriş)',
|
||||
Verdict: 'Karar',
|
||||
Coverage: 'Kapsam',
|
||||
Indexing: 'İndeksleme',
|
||||
|
||||
@@ -109,6 +109,10 @@ export const ZH_CN_LABELS: Record<string, string> = {
|
||||
Pageviews: '页面浏览量',
|
||||
'Engagement rate': '互动率',
|
||||
'Avg session duration': '平均会话时长',
|
||||
'Pageviews (hits)': '页面浏览量(命中)',
|
||||
'Visits (landing)': '访问次数(着陆页)',
|
||||
'Users (landing)': '用户数(着陆页)',
|
||||
'Search visits (landing)': '搜索访问次数(着陆页)',
|
||||
Verdict: '判定结果',
|
||||
Coverage: '覆盖情况',
|
||||
Indexing: '索引状态',
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "Süni intellekt",
|
||||
"seo": "SEO nüfuzu",
|
||||
"visualization": "Vizuallaşdırma",
|
||||
@@ -355,7 +356,7 @@
|
||||
"urlLengthHistogram": "URL uzunluğu histoqramı",
|
||||
"wordCountPerDir": "Qovluq üzrə söz sayı",
|
||||
"sitemapOrphans": "Sitemap yetimləri (ilk 1000)",
|
||||
"orphanCrossSource": "Yetim səhifələr — Sitemap + GSC + GA4 (ilk 1000)",
|
||||
"orphanCrossSource": "Yetim səhifələr — Sitemap + GSC + GA4 + Metrica (ilk 1000)",
|
||||
"serverHeaders": "Server yığını (Server başlığı)",
|
||||
"topWords": "Ən çox rast gəlinən sözlər (başlıq + meta + H1, ilk 100)",
|
||||
"pagesOverLinkLimit": "Səhifə başına maksimum keçid limitini aşan səhifələr",
|
||||
@@ -378,7 +379,17 @@
|
||||
"keyPosition": "Mövqe",
|
||||
"keyServer": "Server",
|
||||
"keyWord": "Söz",
|
||||
"includeBody": "Mətn gövdəsini daxil et (daha yavaş)"
|
||||
"includeBody": "Mətn gövdəsini daxil et (daha yavaş)",
|
||||
"orphanGscClicks_one": "{{formatted}} GSC kliki",
|
||||
"orphanGscClicks_other": "{{formatted}} GSC kliki",
|
||||
"orphanGscImpressions_one": "{{formatted}} göstərilmə",
|
||||
"orphanGscImpressions_other": "{{formatted}} göstərilmə",
|
||||
"orphanGa4Sessions_one": "{{formatted}} GA4 sessiyası",
|
||||
"orphanGa4Sessions_other": "{{formatted}} GA4 sessiyası",
|
||||
"orphanMetricaVisits_one": "{{formatted}} Metrica ziyarəti (giriş)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} Metrica ziyarəti (giriş)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} Metrica səhifə baxışı (hit)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} Metrica səhifə baxışı (hit)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Sitemap yoxlayıcısı",
|
||||
@@ -1504,6 +1515,71 @@
|
||||
"emptyNoMatch": "Hazırkı süzgəcə uyğun səhifə yoxdur.",
|
||||
"accountTitle": "Bu layihə hansı qoşulmuş Google hesabı üzrə hesabat verir. Hər hesab öz məlumatını ayrı saxlayır."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "indicə",
|
||||
"agoMinutes": "{{count}} dəq əvvəl",
|
||||
"agoHours": "{{count}} saat əvvəl",
|
||||
"agoDays": "{{count}} gün əvvəl",
|
||||
"cancelled": "Endirmə ləğv edildi. Əvvəl saxlanmış məlumat dəyişməyib.",
|
||||
"reportVisits": "ziyarətlər",
|
||||
"reportSearchVisits": "axtarış ziyarətləri",
|
||||
"reportPageviews": "səhifə baxışları",
|
||||
"noticeTruncated": "Son Yandex Metrica endirməsi sətir limitinə çatıb, ona görə də orada olmayan səhifələrin yenə də trafiki ola bilər. Tam endirmə olana qədər bu süzgəc əlçatan deyil.",
|
||||
"noticeSampled": "Son Yandex Metrica endirməsi seçmə əsasında qayıdıb, ona görə də orada olmayan səhifələrin yenə də trafiki ola bilər. Seçməsiz endirmə olana qədər bu süzgəc əlçatan deyil.",
|
||||
"noticeNoPull": "Bu sayğac üçün hələ Yandex Metrica məlumatı endirilməyib. Əvvəlcə endirin: tam endirmə olmadan sətri olmayan səhifəni sıfır trafik kimi qəbul etmək olmaz.",
|
||||
"loading": "Yüklənir…",
|
||||
"unconfiguredTitle": "Yandex Metrica qurulmayıb",
|
||||
"unconfiguredBody": "Parametrlər → İnteqrasiyalar → Yandex Metrica bölməsini açın və metrika:read icazəsi olan tokeni yerləşdirin. Oradakı quraşdırma bələdçisi tokeni necə əldə etməyi addım-addım göstərir. Sonra sayğac seçmək üçün buraya qayıdın.",
|
||||
"sessionHint": "Sessiya səviyyəsi: hər baxılan səhifə üçün deyil, sessiyanın başladığı səhifə (giriş səhifəsi) üçün sayılır.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "URL-ə görə süz…",
|
||||
"filterAll": "Bütün 2xx səhifələr",
|
||||
"filterWith": "Metrica məlumatı olanlar",
|
||||
"filterWithout": "Metrica məlumatı olmayanlar",
|
||||
"filterIndexableNoVisits": "İndekslənə bilən, ziyarətsiz",
|
||||
"filterNon200": "Trafiki olan qeyri-2xx",
|
||||
"countersLoading": "Sayğaclar yüklənir…",
|
||||
"noCounters": "Sayğac yoxdur",
|
||||
"daysTitle": "Dünən bitən tam günlər, sayğacın saat qurşağı ilə.",
|
||||
"days7": "Son 7 gün",
|
||||
"days28": "Son 28 gün",
|
||||
"days90": "Son 90 gün",
|
||||
"cancel": "Ləğv et",
|
||||
"fetch": "Endir",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} səhifə · {{ago}} endirildi",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} səhifə · {{ago}} endirildi",
|
||||
"totalsTitle": "Metrica-nın bu aralıq üçün bildirdiyi bütün sayt üzrə yekunlar — Metrica interfeysi ilə müqayisə üçün.",
|
||||
"totalsLine": "Sayt üzrə yekun: {{pageviews}} səhifə baxışı · {{visits}} ziyarət · {{searchVisits}} axtarış ziyarəti",
|
||||
"truncatedFlag": "Sətir limitinə çatılıb: az trafikli səhifələr çatışmır",
|
||||
"sampledFlag": "Seçmə məlumat",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colPageviewsHint": "Hit səviyyəsi: bu səhifə URL-inin hər baxışı.",
|
||||
"colPageviews": "Səhifə baxışları (hit)",
|
||||
"colVisits": "Ziyarətlər (giriş)",
|
||||
"colUsersHint": "Sessiya səviyyəsi, giriş səhifəsinə görə. Yuxarı hədd: bir neçə səhifədən daxil olan bir nəfər hər səhifə üçün bir dəfə sayılır.",
|
||||
"colUsers": "İstifadəçilər (giriş)",
|
||||
"colSearchVisitsHint": "Sessiya səviyyəsi: axtarış sistemlərindən gələn və bu səhifədə başlayan sessiyalar.",
|
||||
"colSearchVisits": "Axtarış ziyarətləri (giriş)",
|
||||
"colFetched": "Endirildi",
|
||||
"emptyNoData": "Hələ heç bir daxili HTML səhifəsi taranmayıb — əvvəlcə bir tarama işə salın, sonra Metrica məlumatını endirin.",
|
||||
"emptyNoMatch": "Hazırkı süzgəcə uyğun səhifə yoxdur.",
|
||||
"summary_one": "{{formatted}} səhifədən {{shown}}",
|
||||
"summary_other": "{{formatted}} səhifədən {{shown}}",
|
||||
"progress_one": "{{report}} hesabatı endirilir ({{index}}/{{total}}) · {{formatted}} sətir",
|
||||
"progress_other": "{{report}} hesabatı endirilir ({{index}}/{{total}}) · {{formatted}} sətir",
|
||||
"errNotConnected": "Yandex Metrica qoşulmayıb — Parametrlər → İnteqrasiyalar → Yandex Metrica bölməsində token yerləşdirin.",
|
||||
"errTokenFormat": "Saxlanmış Yandex Metrica tokeninin formatı gözlənilməzdir — onu Parametrlər → İnteqrasiyalar → Yandex Metrica bölməsində yenidən yerləşdirin.",
|
||||
"errTokenRejected": "Yandex Metrica tokeni rədd etdi (HTTP 401) — Parametrlər → İnteqrasiyalar → Yandex Metrica bölməsində metrika:read icazəsi olan yeni token yerləşdirin.",
|
||||
"errAccessDenied": "Yandex Metrica girişi rədd etdi (HTTP 403) — tokenin aid olduğu hesab bu sayğacı oxuya bilmir.",
|
||||
"errNoCounter": "Yandex Metrica sayğacı seçilməyib.",
|
||||
"errBadRange": "Dəstəklənməyən tarix aralığı.",
|
||||
"errAlreadyRunning": "Bu pəncərədə artıq Yandex Metrica endirməsi gedir.",
|
||||
"errProjectSwitched": "Endirmə zamanı başqa layihə açıldı, ona görə də nəticə atıldı. Bu layihədə yenidən endirin.",
|
||||
"errUnexpectedResponse": "Yandex Metrica gözlənilməz cavab qaytardı. Əvvəl saxlanmış məlumat dəyişməyib.",
|
||||
"errRangeChanged": "Yandex Metrica endirmə zamanı hesabatın tarix aralığını dəyişdi. Əvvəl saxlanmış məlumat dəyişməyib — yenidən endirin.",
|
||||
"errNetwork": "Yandex Metrica ilə əlaqə qurmaq mümkün olmadı. Bağlantını yoxlayıb yenidən cəhd edin."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "Süni intellekt",
|
||||
"filterPlaceholder": "URL-ə görə süz…",
|
||||
@@ -1556,7 +1632,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Yüklənir…",
|
||||
"empty": "Bu URL üçün hələ Search Console / Analytics 4 / URL yoxlaması məlumatı yoxdur. Onu GA4 ya Search Console tablarından endirin.",
|
||||
"empty": "Bu URL üçün hələ Search Console / Analytics 4 / Yandex Metrica / URL yoxlaması məlumatı yoxdur. Onu GA4, Yandex Metrica ya Search Console tablarından endirin.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Hələ klik / göstərilmə məlumatı yoxdur.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1565,7 +1641,13 @@
|
||||
"inspectionEmpty": "Hələ URL yoxlaması nəticəsi yoxdur — onu Search Console tabından işə salın.",
|
||||
"colSource": "Mənbə",
|
||||
"colMetric": "Metrika",
|
||||
"colValue": "Dəyər"
|
||||
"colValue": "Dəyər",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Hələ Yandex Metrica məlumatı yoxdur — onu Yandex Metrica tabından endirin.",
|
||||
"metricaPageviewsHint": "Hit səviyyəsi: bu səhifə URL-inin hər baxışı.",
|
||||
"metricaVisitsHint": "Sessiya səviyyəsi: bu səhifədə başlayan sessiyalar.",
|
||||
"metricaUsersHint": "Sessiya səviyyəsi, giriş səhifəsinə görə. Bir neçə URL variantı bu səhifədə birləşdikdə yuxarı hədd göstərir.",
|
||||
"metricaSearchVisitsHint": "Sessiya səviyyəsi: axtarış sistemlərindən gələn və bu səhifədə başlayan sessiyalar."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Qırıq hədəf URL-ləri axtar…",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "AI",
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Visualization",
|
||||
@@ -355,7 +356,7 @@
|
||||
"urlLengthHistogram": "URL Length Histogram",
|
||||
"wordCountPerDir": "Word Count per Directory",
|
||||
"sitemapOrphans": "Sitemap Orphans (Top 1000)",
|
||||
"orphanCrossSource": "Orphan Pages — Sitemap + GSC + GA4 (Top 1000)",
|
||||
"orphanCrossSource": "Orphan Pages — Sitemap + GSC + GA4 + Metrica (Top 1000)",
|
||||
"serverHeaders": "Server Stack (Server Header)",
|
||||
"topWords": "Top Words (Title + Meta + H1, Top 100)",
|
||||
"pagesOverLinkLimit": "Pages Over Max Links per Page",
|
||||
@@ -378,7 +379,17 @@
|
||||
"keyPosition": "Position",
|
||||
"keyServer": "Server",
|
||||
"keyWord": "Word",
|
||||
"includeBody": "Include body text (slower)"
|
||||
"includeBody": "Include body text (slower)",
|
||||
"orphanGscClicks_one": "{{formatted}} GSC click",
|
||||
"orphanGscClicks_other": "{{formatted}} GSC clicks",
|
||||
"orphanGscImpressions_one": "{{formatted}} impr",
|
||||
"orphanGscImpressions_other": "{{formatted}} impr",
|
||||
"orphanGa4Sessions_one": "{{formatted}} GA4 session",
|
||||
"orphanGa4Sessions_other": "{{formatted}} GA4 sessions",
|
||||
"orphanMetricaVisits_one": "{{formatted}} Metrica visit (landing)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} Metrica visits (landing)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} Metrica pageview (hits)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} Metrica pageviews (hits)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Sitemap Validator",
|
||||
@@ -1504,6 +1515,71 @@
|
||||
"emptyNoMatch": "No pages match the current filter.",
|
||||
"accountTitle": "Which linked Google account this project reports on. Each account keeps its own stored data."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "just now",
|
||||
"agoMinutes": "{{count}}m ago",
|
||||
"agoHours": "{{count}}h ago",
|
||||
"agoDays": "{{count}}d ago",
|
||||
"cancelled": "Fetch cancelled. The previously stored data is unchanged.",
|
||||
"reportVisits": "visits",
|
||||
"reportSearchVisits": "search visits",
|
||||
"reportPageviews": "pageviews",
|
||||
"noticeTruncated": "The last Yandex Metrica fetch hit the row limit, so pages missing from it may still have traffic. This filter is unavailable until a complete fetch.",
|
||||
"noticeSampled": "The last Yandex Metrica fetch came back sampled, so pages missing from it may still have traffic. This filter is unavailable until an unsampled fetch.",
|
||||
"noticeNoPull": "No Yandex Metrica data has been fetched for this counter yet. Fetch first: without a complete fetch, a page with no row can’t be read as zero traffic.",
|
||||
"loading": "Loading…",
|
||||
"unconfiguredTitle": "Yandex Metrica is not set up",
|
||||
"unconfiguredBody": "Open Settings → Integrations → Yandex Metrica and paste a token with the metrika:read permission. The setup guide there walks through getting one. Then come back here to pick a counter.",
|
||||
"sessionHint": "Session-level: counted for the page a session started on (landing page), not for every page viewed.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
"filterAll": "All 2xx pages",
|
||||
"filterWith": "With Metrica data",
|
||||
"filterWithout": "No Metrica data",
|
||||
"filterIndexableNoVisits": "Indexable, no visits",
|
||||
"filterNon200": "Non-2xx with traffic",
|
||||
"countersLoading": "Loading counters…",
|
||||
"noCounters": "No counters",
|
||||
"daysTitle": "Full days ending yesterday, in the counter’s time zone.",
|
||||
"days7": "Last 7 days",
|
||||
"days28": "Last 28 days",
|
||||
"days90": "Last 90 days",
|
||||
"cancel": "Cancel",
|
||||
"fetch": "Fetch",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} page · fetched {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} pages · fetched {{ago}}",
|
||||
"totalsTitle": "Whole-site totals reported by Metrica for the range, for comparison with the Metrica interface.",
|
||||
"totalsLine": "Site totals: {{pageviews}} pageviews · {{visits}} visits · {{searchVisits}} search visits",
|
||||
"truncatedFlag": "Row limit reached: low-traffic pages are missing",
|
||||
"sampledFlag": "Sampled data",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colPageviewsHint": "Hit-level: every view of this page URL.",
|
||||
"colPageviews": "Pageviews (hits)",
|
||||
"colVisits": "Visits (landing)",
|
||||
"colUsersHint": "Session-level, by landing page. An upper bound: one person landing on several pages counts once per page.",
|
||||
"colUsers": "Users (landing)",
|
||||
"colSearchVisitsHint": "Session-level: sessions from search engines that started on this page.",
|
||||
"colSearchVisits": "Search visits (landing)",
|
||||
"colFetched": "Fetched",
|
||||
"emptyNoData": "No internal HTML pages crawled yet — run a crawl, then fetch Metrica data.",
|
||||
"emptyNoMatch": "No pages match the current filter.",
|
||||
"summary_one": "{{shown}} of {{formatted}} page",
|
||||
"summary_other": "{{shown}} of {{formatted}} pages",
|
||||
"progress_one": "Fetching {{report}} report ({{index}}/{{total}}) · {{formatted}} row",
|
||||
"progress_other": "Fetching {{report}} report ({{index}}/{{total}}) · {{formatted}} rows",
|
||||
"errNotConnected": "Yandex Metrica is not connected — paste a token in Settings → Integrations → Yandex Metrica.",
|
||||
"errTokenFormat": "The saved Yandex Metrica token has an unexpected format — paste it again in Settings → Integrations → Yandex Metrica.",
|
||||
"errTokenRejected": "Yandex Metrica rejected the token (HTTP 401) — paste a fresh token with the metrika:read permission in Settings → Integrations → Yandex Metrica.",
|
||||
"errAccessDenied": "Yandex Metrica denied access (HTTP 403) — the account behind the token cannot read this counter.",
|
||||
"errNoCounter": "No Yandex Metrica counter selected.",
|
||||
"errBadRange": "Unsupported date range.",
|
||||
"errAlreadyRunning": "A Yandex Metrica fetch is already running in this window.",
|
||||
"errProjectSwitched": "Another project was opened during the fetch, so its result was discarded. Fetch again in this project.",
|
||||
"errUnexpectedResponse": "Yandex Metrica returned an unexpected response. The previously stored data is unchanged.",
|
||||
"errRangeChanged": "Yandex Metrica changed the report date range during the fetch. The previously stored data is unchanged — fetch again.",
|
||||
"errNetwork": "Could not reach Yandex Metrica. Check the connection and try again."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "Filter by URL…",
|
||||
@@ -1556,7 +1632,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Loading…",
|
||||
"empty": "No Search Console / Analytics 4 / URL Inspection data for this URL yet. Fetch it from the GA4 / Search Console tabs.",
|
||||
"empty": "No Search Console / Analytics 4 / Yandex Metrica / URL Inspection data for this URL yet. Fetch it from the GA4 / Yandex Metrica / Search Console tabs.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "No clicks / impressions data yet.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1565,7 +1641,13 @@
|
||||
"inspectionEmpty": "No URL Inspection result yet — run it from the Search Console tab.",
|
||||
"colSource": "Source",
|
||||
"colMetric": "Metric",
|
||||
"colValue": "Value"
|
||||
"colValue": "Value",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "No Yandex Metrica data yet — fetch it from the Yandex Metrica tab.",
|
||||
"metricaPageviewsHint": "Hit-level: every view of this page URL.",
|
||||
"metricaVisitsHint": "Session-level: sessions that started on this page.",
|
||||
"metricaUsersHint": "Session-level, by landing page. An upper bound when several URL variants fold into this page.",
|
||||
"metricaSearchVisitsHint": "Session-level: sessions from search engines that started on this page."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Search broken target URLs…",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "IA",
|
||||
"seo": "Autoridad SEO",
|
||||
"visualization": "Visualización",
|
||||
@@ -362,7 +363,7 @@
|
||||
"urlLengthHistogram": "Histograma de longitud de URL",
|
||||
"wordCountPerDir": "Número de palabras por directorio",
|
||||
"sitemapOrphans": "Huérfanas del sitemap (1000 primeras)",
|
||||
"orphanCrossSource": "Páginas huérfanas — Sitemap + GSC + GA4 (1000 primeras)",
|
||||
"orphanCrossSource": "Páginas huérfanas — Sitemap + GSC + GA4 + Metrica (1000 primeras)",
|
||||
"serverHeaders": "Pila del servidor (cabecera Server)",
|
||||
"topWords": "Palabras más frecuentes (título + meta + H1, 100 primeras)",
|
||||
"pagesOverLinkLimit": "Páginas que superan el máximo de enlaces por página",
|
||||
@@ -385,7 +386,22 @@
|
||||
"keyPosition": "Posición",
|
||||
"keyServer": "Servidor",
|
||||
"keyWord": "Palabra",
|
||||
"includeBody": "Incluir texto del cuerpo (más lento)"
|
||||
"includeBody": "Incluir texto del cuerpo (más lento)",
|
||||
"orphanGscClicks_one": "{{formatted}} clic de GSC",
|
||||
"orphanGscClicks_many": "{{formatted}} de clics de GSC",
|
||||
"orphanGscClicks_other": "{{formatted}} clics de GSC",
|
||||
"orphanGscImpressions_one": "{{formatted}} impr.",
|
||||
"orphanGscImpressions_many": "{{formatted}} de impr.",
|
||||
"orphanGscImpressions_other": "{{formatted}} impr.",
|
||||
"orphanGa4Sessions_one": "{{formatted}} sesión de GA4",
|
||||
"orphanGa4Sessions_many": "{{formatted}} de sesiones de GA4",
|
||||
"orphanGa4Sessions_other": "{{formatted}} sesiones de GA4",
|
||||
"orphanMetricaVisits_one": "{{formatted}} visita de Metrica (entrada)",
|
||||
"orphanMetricaVisits_many": "{{formatted}} de visitas de Metrica (entrada)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} visitas de Metrica (entrada)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} vista de página de Metrica (hits)",
|
||||
"orphanMetricaPageviews_many": "{{formatted}} de vistas de página de Metrica (hits)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} vistas de página de Metrica (hits)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Validador de sitemap",
|
||||
@@ -1525,6 +1541,74 @@
|
||||
"emptyNoMatch": "Ninguna página coincide con el filtro actual.",
|
||||
"accountTitle": "Sobre qué cuenta de Google vinculada informa este proyecto. Cada cuenta conserva sus propios datos almacenados."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "ahora mismo",
|
||||
"agoMinutes": "hace {{count}} min",
|
||||
"agoHours": "hace {{count}} h",
|
||||
"agoDays": "hace {{count}} d",
|
||||
"cancelled": "Descarga cancelada. Los datos almacenados anteriormente no han cambiado.",
|
||||
"reportVisits": "visitas",
|
||||
"reportSearchVisits": "visitas de búsqueda",
|
||||
"reportPageviews": "vistas de página",
|
||||
"noticeTruncated": "La última descarga de Yandex Metrica alcanzó el límite de filas, así que las páginas que no aparecen en ella pueden tener tráfico igualmente. Este filtro no estará disponible hasta que haya una descarga completa.",
|
||||
"noticeSampled": "La última descarga de Yandex Metrica llegó muestreada, así que las páginas que no aparecen en ella pueden tener tráfico igualmente. Este filtro no estará disponible hasta que haya una descarga sin muestreo.",
|
||||
"noticeNoPull": "Todavía no se han descargado datos de Yandex Metrica para este contador. Descárgalos primero: sin una descarga completa, una página sin fila no puede interpretarse como tráfico cero.",
|
||||
"loading": "Cargando…",
|
||||
"unconfiguredTitle": "Yandex Metrica no está configurado",
|
||||
"unconfiguredBody": "Abre Ajustes → Integraciones → Yandex Metrica y pega un token con el permiso metrika:read. La guía de configuración que encontrarás allí explica cómo obtenerlo. Luego vuelve aquí para elegir un contador.",
|
||||
"sessionHint": "Nivel de sesión: se cuenta para la página en la que empezó la sesión (página de entrada), no para cada página vista.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Filtrar por URL…",
|
||||
"filterAll": "Todas las páginas 2xx",
|
||||
"filterWith": "Con datos de Metrica",
|
||||
"filterWithout": "Sin datos de Metrica",
|
||||
"filterIndexableNoVisits": "Indexables, sin visitas",
|
||||
"filterNon200": "No 2xx con tráfico",
|
||||
"countersLoading": "Cargando contadores…",
|
||||
"noCounters": "Sin contadores",
|
||||
"daysTitle": "Días completos hasta ayer, en la zona horaria del contador.",
|
||||
"days7": "Últimos 7 días",
|
||||
"days28": "Últimos 28 días",
|
||||
"days90": "Últimos 90 días",
|
||||
"cancel": "Cancelar",
|
||||
"fetch": "Descargar",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} página · descargado {{ago}}",
|
||||
"metaLine_many": "{{counter}} · {{start}} → {{end}} · {{formatted}} de páginas · descargado {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} páginas · descargado {{ago}}",
|
||||
"totalsTitle": "Totales de todo el sitio que Metrica informa para el rango, para compararlos con la interfaz de Metrica.",
|
||||
"totalsLine": "Totales del sitio: {{pageviews}} vistas de página · {{visits}} visitas · {{searchVisits}} visitas de búsqueda",
|
||||
"truncatedFlag": "Límite de filas alcanzado: faltan páginas con poco tráfico",
|
||||
"sampledFlag": "Datos muestreados",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Estado",
|
||||
"colPageviewsHint": "Nivel de hit: cada vista de la URL de esta página.",
|
||||
"colPageviews": "Vistas de página (hits)",
|
||||
"colVisits": "Visitas (entrada)",
|
||||
"colUsersHint": "Nivel de sesión, por página de entrada. Es un límite superior: una persona que entra por varias páginas cuenta una vez en cada una.",
|
||||
"colUsers": "Usuarios (entrada)",
|
||||
"colSearchVisitsHint": "Nivel de sesión: sesiones procedentes de buscadores que empezaron en esta página.",
|
||||
"colSearchVisits": "Visitas de búsqueda (entrada)",
|
||||
"colFetched": "Descargado",
|
||||
"emptyNoData": "Todavía no se ha rastreado ninguna página HTML interna — ejecuta un rastreo y luego descarga los datos de Metrica.",
|
||||
"emptyNoMatch": "Ninguna página coincide con el filtro actual.",
|
||||
"summary_one": "{{shown}} de {{formatted}} página",
|
||||
"summary_many": "{{shown}} de {{formatted}} de páginas",
|
||||
"summary_other": "{{shown}} de {{formatted}} páginas",
|
||||
"progress_one": "Descargando el informe de {{report}} ({{index}}/{{total}}) · {{formatted}} fila",
|
||||
"progress_many": "Descargando el informe de {{report}} ({{index}}/{{total}}) · {{formatted}} de filas",
|
||||
"progress_other": "Descargando el informe de {{report}} ({{index}}/{{total}}) · {{formatted}} filas",
|
||||
"errNotConnected": "Yandex Metrica no está conectado: pega un token en Ajustes → Integraciones → Yandex Metrica.",
|
||||
"errTokenFormat": "El token de Yandex Metrica guardado tiene un formato inesperado: vuelve a pegarlo en Ajustes → Integraciones → Yandex Metrica.",
|
||||
"errTokenRejected": "Yandex Metrica rechazó el token (HTTP 401): pega un token nuevo con el permiso metrika:read en Ajustes → Integraciones → Yandex Metrica.",
|
||||
"errAccessDenied": "Yandex Metrica denegó el acceso (HTTP 403): la cuenta del token no puede leer este contador.",
|
||||
"errNoCounter": "No hay ningún contador de Yandex Metrica seleccionado.",
|
||||
"errBadRange": "Rango de fechas no admitido.",
|
||||
"errAlreadyRunning": "Ya hay una descarga de Yandex Metrica en curso en esta ventana.",
|
||||
"errProjectSwitched": "Se abrió otro proyecto durante la descarga, así que su resultado se descartó. Vuelve a descargar en este proyecto.",
|
||||
"errUnexpectedResponse": "Yandex Metrica devolvió una respuesta inesperada. Los datos almacenados anteriormente no han cambiado.",
|
||||
"errRangeChanged": "Yandex Metrica cambió el rango de fechas del informe durante la descarga. Los datos almacenados anteriormente no han cambiado; vuelve a descargar.",
|
||||
"errNetwork": "No se pudo conectar con Yandex Metrica. Comprueba la conexión e inténtalo de nuevo."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "IA",
|
||||
"filterPlaceholder": "Filtrar por URL…",
|
||||
@@ -1577,7 +1661,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Cargando…",
|
||||
"empty": "Todavía no hay datos de Search Console, Analytics 4 ni inspección de URL para esta URL. Descárgalos desde las pestañas GA4 o Search Console.",
|
||||
"empty": "Todavía no hay datos de Search Console, Analytics 4, Yandex Metrica ni inspección de URL para esta URL. Descárgalos desde las pestañas GA4, Yandex Metrica o Search Console.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Todavía no hay datos de clics ni impresiones.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1586,7 +1670,13 @@
|
||||
"inspectionEmpty": "Todavía no hay resultado de inspección de URL — ejecútala desde la pestaña Search Console.",
|
||||
"colSource": "Origen",
|
||||
"colMetric": "Métrica",
|
||||
"colValue": "Valor"
|
||||
"colValue": "Valor",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Todavía no hay datos de Yandex Metrica — descárgalos desde la pestaña Yandex Metrica.",
|
||||
"metricaPageviewsHint": "Nivel de hit: cada vista de la URL de esta página.",
|
||||
"metricaVisitsHint": "Nivel de sesión: sesiones que empezaron en esta página.",
|
||||
"metricaUsersHint": "Nivel de sesión, por página de entrada. Es un límite superior cuando varias variantes de URL se agrupan en esta página.",
|
||||
"metricaSearchVisitsHint": "Nivel de sesión: sesiones procedentes de buscadores que empezaron en esta página."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Buscar URL de destino roto…",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "IA",
|
||||
"seo": "Autorité SEO",
|
||||
"visualization": "Visualisation",
|
||||
@@ -362,7 +363,7 @@
|
||||
"urlLengthHistogram": "Histogramme de longueur d'URL",
|
||||
"wordCountPerDir": "Nombre de mots par répertoire",
|
||||
"sitemapOrphans": "Orphelines du sitemap (1000 premières)",
|
||||
"orphanCrossSource": "Pages orphelines — Sitemap + GSC + GA4 (1000 premières)",
|
||||
"orphanCrossSource": "Pages orphelines — Sitemap + GSC + GA4 + Metrica (1000 premières)",
|
||||
"serverHeaders": "Pile serveur (en-tête Server)",
|
||||
"topWords": "Mots les plus fréquents (titre + méta + H1, 100 premiers)",
|
||||
"pagesOverLinkLimit": "Pages dépassant le maximum de liens par page",
|
||||
@@ -385,7 +386,22 @@
|
||||
"keyPosition": "Position",
|
||||
"keyServer": "Serveur",
|
||||
"keyWord": "Mot",
|
||||
"includeBody": "Inclure le texte du corps (plus lent)"
|
||||
"includeBody": "Inclure le texte du corps (plus lent)",
|
||||
"orphanGscClicks_one": "{{formatted}} clic GSC",
|
||||
"orphanGscClicks_many": "{{formatted}} de clics GSC",
|
||||
"orphanGscClicks_other": "{{formatted}} clics GSC",
|
||||
"orphanGscImpressions_one": "{{formatted}} impr.",
|
||||
"orphanGscImpressions_many": "{{formatted}} d'impr.",
|
||||
"orphanGscImpressions_other": "{{formatted}} impr.",
|
||||
"orphanGa4Sessions_one": "{{formatted}} session GA4",
|
||||
"orphanGa4Sessions_many": "{{formatted}} de sessions GA4",
|
||||
"orphanGa4Sessions_other": "{{formatted}} sessions GA4",
|
||||
"orphanMetricaVisits_one": "{{formatted}} visite Metrica (entrée)",
|
||||
"orphanMetricaVisits_many": "{{formatted}} de visites Metrica (entrée)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} visites Metrica (entrée)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} page vue Metrica (hits)",
|
||||
"orphanMetricaPageviews_many": "{{formatted}} de pages vues Metrica (hits)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} pages vues Metrica (hits)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Validateur de sitemap",
|
||||
@@ -1525,6 +1541,74 @@
|
||||
"emptyNoMatch": "Aucune page ne correspond au filtre actuel.",
|
||||
"accountTitle": "Sur quel compte Google lié ce projet fait son rapport. Chaque compte conserve ses propres données stockées."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "à l'instant",
|
||||
"agoMinutes": "il y a {{count}} min",
|
||||
"agoHours": "il y a {{count}} h",
|
||||
"agoDays": "il y a {{count}} j",
|
||||
"cancelled": "Téléchargement annulé. Les données enregistrées précédemment restent inchangées.",
|
||||
"reportVisits": "visites",
|
||||
"reportSearchVisits": "visites de recherche",
|
||||
"reportPageviews": "pages vues",
|
||||
"noticeTruncated": "Le dernier téléchargement Yandex Metrica a atteint la limite de lignes : les pages qui en sont absentes peuvent quand même avoir du trafic. Ce filtre reste indisponible jusqu'à un téléchargement complet.",
|
||||
"noticeSampled": "Le dernier téléchargement Yandex Metrica a renvoyé des données échantillonnées : les pages qui en sont absentes peuvent quand même avoir du trafic. Ce filtre reste indisponible jusqu'à un téléchargement non échantillonné.",
|
||||
"noticeNoPull": "Aucune donnée Yandex Metrica n'a encore été téléchargée pour ce compteur. Téléchargez-les d'abord : sans téléchargement complet, une page sans ligne ne peut pas être considérée comme sans trafic.",
|
||||
"loading": "Chargement…",
|
||||
"unconfiguredTitle": "Yandex Metrica n'est pas configuré",
|
||||
"unconfiguredBody": "Ouvrez Paramètres → Intégrations → Yandex Metrica et collez un jeton disposant de l'autorisation metrika:read. Le guide de configuration qui s'y trouve explique comment l'obtenir. Revenez ensuite ici pour choisir un compteur.",
|
||||
"sessionHint": "Niveau session : comptabilisé pour la page où la session a commencé (page d'entrée), et non pour chaque page vue.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Filtrer par URL…",
|
||||
"filterAll": "Toutes les pages 2xx",
|
||||
"filterWith": "Avec données Metrica",
|
||||
"filterWithout": "Sans données Metrica",
|
||||
"filterIndexableNoVisits": "Indexables, sans visites",
|
||||
"filterNon200": "Non-2xx avec trafic",
|
||||
"countersLoading": "Chargement des compteurs…",
|
||||
"noCounters": "Aucun compteur",
|
||||
"daysTitle": "Jours complets se terminant hier, dans le fuseau horaire du compteur.",
|
||||
"days7": "7 derniers jours",
|
||||
"days28": "28 derniers jours",
|
||||
"days90": "90 derniers jours",
|
||||
"cancel": "Annuler",
|
||||
"fetch": "Télécharger",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} page · téléchargé {{ago}}",
|
||||
"metaLine_many": "{{counter}} · {{start}} → {{end}} · {{formatted}} de pages · téléchargé {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} pages · téléchargé {{ago}}",
|
||||
"totalsTitle": "Totaux de l'ensemble du site renvoyés par Metrica pour la période, à comparer avec l'interface de Metrica.",
|
||||
"totalsLine": "Totaux du site : {{pageviews}} pages vues · {{visits}} visites · {{searchVisits}} visites de recherche",
|
||||
"truncatedFlag": "Limite de lignes atteinte : des pages à faible trafic manquent",
|
||||
"sampledFlag": "Données échantillonnées",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Statut",
|
||||
"colPageviewsHint": "Niveau hit : chaque affichage de l'URL de cette page.",
|
||||
"colPageviews": "Pages vues (hits)",
|
||||
"colVisits": "Visites (entrée)",
|
||||
"colUsersHint": "Niveau session, par page d'entrée. Il s'agit d'une borne supérieure : une personne qui arrive sur plusieurs pages est comptée une fois par page.",
|
||||
"colUsers": "Utilisateurs (entrée)",
|
||||
"colSearchVisitsHint": "Niveau session : sessions issues des moteurs de recherche ayant commencé sur cette page.",
|
||||
"colSearchVisits": "Visites de recherche (entrée)",
|
||||
"colFetched": "Téléchargé",
|
||||
"emptyNoData": "Aucune page HTML interne explorée pour l'instant — lancez une exploration, puis téléchargez les données Metrica.",
|
||||
"emptyNoMatch": "Aucune page ne correspond au filtre actuel.",
|
||||
"summary_one": "{{shown}} sur {{formatted}} page",
|
||||
"summary_many": "{{shown}} sur {{formatted}} de pages",
|
||||
"summary_other": "{{shown}} sur {{formatted}} pages",
|
||||
"progress_one": "Téléchargement du rapport des {{report}} ({{index}}/{{total}}) · {{formatted}} ligne",
|
||||
"progress_many": "Téléchargement du rapport des {{report}} ({{index}}/{{total}}) · {{formatted}} de lignes",
|
||||
"progress_other": "Téléchargement du rapport des {{report}} ({{index}}/{{total}}) · {{formatted}} lignes",
|
||||
"errNotConnected": "Yandex Metrica n'est pas connecté — collez un jeton dans Paramètres → Intégrations → Yandex Metrica.",
|
||||
"errTokenFormat": "Le jeton Yandex Metrica enregistré a un format inattendu — collez-le de nouveau dans Paramètres → Intégrations → Yandex Metrica.",
|
||||
"errTokenRejected": "Yandex Metrica a refusé le jeton (HTTP 401) — collez un nouveau jeton disposant de l'autorisation metrika:read dans Paramètres → Intégrations → Yandex Metrica.",
|
||||
"errAccessDenied": "Yandex Metrica a refusé l'accès (HTTP 403) — le compte lié au jeton ne peut pas lire ce compteur.",
|
||||
"errNoCounter": "Aucun compteur Yandex Metrica sélectionné.",
|
||||
"errBadRange": "Plage de dates non prise en charge.",
|
||||
"errAlreadyRunning": "Un téléchargement Yandex Metrica est déjà en cours dans cette fenêtre.",
|
||||
"errProjectSwitched": "Un autre projet a été ouvert pendant le téléchargement ; son résultat a donc été ignoré. Relancez le téléchargement dans ce projet.",
|
||||
"errUnexpectedResponse": "Yandex Metrica a renvoyé une réponse inattendue. Les données enregistrées précédemment restent inchangées.",
|
||||
"errRangeChanged": "Yandex Metrica a modifié la plage de dates du rapport pendant le téléchargement. Les données enregistrées précédemment restent inchangées — relancez le téléchargement.",
|
||||
"errNetwork": "Impossible de joindre Yandex Metrica. Vérifiez la connexion et réessayez."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "IA",
|
||||
"filterPlaceholder": "Filtrer par URL…",
|
||||
@@ -1577,7 +1661,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Chargement…",
|
||||
"empty": "Aucune donnée Search Console / Analytics 4 / inspection d'URL pour cette URL pour l'instant. Téléchargez-les depuis les onglets GA4 ou Search Console.",
|
||||
"empty": "Aucune donnée Search Console / Analytics 4 / Yandex Metrica / inspection d'URL pour cette URL pour l'instant. Téléchargez-les depuis les onglets GA4, Yandex Metrica ou Search Console.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Aucune donnée de clics / impressions pour l'instant.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1586,7 +1670,13 @@
|
||||
"inspectionEmpty": "Aucun résultat d'inspection d'URL pour l'instant — lancez-la depuis l'onglet Search Console.",
|
||||
"colSource": "Source",
|
||||
"colMetric": "Métrique",
|
||||
"colValue": "Valeur"
|
||||
"colValue": "Valeur",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Aucune donnée Yandex Metrica pour l'instant — téléchargez-les depuis l'onglet Yandex Metrica.",
|
||||
"metricaPageviewsHint": "Niveau hit : chaque affichage de l'URL de cette page.",
|
||||
"metricaVisitsHint": "Niveau session : sessions ayant commencé sur cette page.",
|
||||
"metricaUsersHint": "Niveau session, par page d'entrée. Borne supérieure lorsque plusieurs variantes d'URL sont regroupées sur cette page.",
|
||||
"metricaSearchVisitsHint": "Niveau session : sessions issues des moteurs de recherche ayant commencé sur cette page."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Rechercher des URL cibles cassées…",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "AI",
|
||||
"seo": "SEO अधिकार",
|
||||
"visualization": "विज़ुअलाइज़ेशन",
|
||||
@@ -355,7 +356,7 @@
|
||||
"urlLengthHistogram": "URL लंबाई हिस्टोग्राम",
|
||||
"wordCountPerDir": "प्रति निर्देशिका शब्द गणना",
|
||||
"sitemapOrphans": "साइटमैप के अनाथ पृष्ठ (शीर्ष 1000)",
|
||||
"orphanCrossSource": "अनाथ पृष्ठ — साइटमैप + GSC + GA4 (शीर्ष 1000)",
|
||||
"orphanCrossSource": "अनाथ पृष्ठ — साइटमैप + GSC + GA4 + Metrica (शीर्ष 1000)",
|
||||
"serverHeaders": "सर्वर स्टैक (Server हेडर)",
|
||||
"topWords": "सर्वाधिक प्रयुक्त शब्द (शीर्षक + मेटा + H1, शीर्ष 100)",
|
||||
"pagesOverLinkLimit": "प्रति पृष्ठ अधिकतम लिंक सीमा से ऊपर के पृष्ठ",
|
||||
@@ -378,7 +379,17 @@
|
||||
"keyPosition": "स्थान",
|
||||
"keyServer": "सर्वर",
|
||||
"keyWord": "शब्द",
|
||||
"includeBody": "बॉडी टेक्स्ट शामिल करें (धीमा)"
|
||||
"includeBody": "बॉडी टेक्स्ट शामिल करें (धीमा)",
|
||||
"orphanGscClicks_one": "{{formatted}} GSC क्लिक",
|
||||
"orphanGscClicks_other": "{{formatted}} GSC क्लिक",
|
||||
"orphanGscImpressions_one": "{{formatted}} इंप्रेशन",
|
||||
"orphanGscImpressions_other": "{{formatted}} इंप्रेशन",
|
||||
"orphanGa4Sessions_one": "{{formatted}} GA4 सत्र",
|
||||
"orphanGa4Sessions_other": "{{formatted}} GA4 सत्र",
|
||||
"orphanMetricaVisits_one": "{{formatted}} Metrica विज़िट (लैंडिंग)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} Metrica विज़िट (लैंडिंग)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} Metrica पृष्ठ दृश्य (हिट)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} Metrica पृष्ठ दृश्य (हिट)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "साइटमैप सत्यापक",
|
||||
@@ -1504,6 +1515,71 @@
|
||||
"emptyNoMatch": "मौजूदा फ़िल्टर से कोई पृष्ठ मेल नहीं खाता।",
|
||||
"accountTitle": "यह प्रोजेक्ट किस जुड़े Google खाते पर रिपोर्ट करता है। हर खाता अपना डेटा अलग रखता है।"
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "अभी-अभी",
|
||||
"agoMinutes": "{{count}} मिनट पहले",
|
||||
"agoHours": "{{count}} घंटे पहले",
|
||||
"agoDays": "{{count}} दिन पहले",
|
||||
"cancelled": "डेटा लाना रद्द किया गया। पहले से सहेजा गया डेटा नहीं बदला है।",
|
||||
"reportVisits": "विज़िट",
|
||||
"reportSearchVisits": "खोज विज़िट",
|
||||
"reportPageviews": "पृष्ठ दृश्य",
|
||||
"noticeTruncated": "पिछली बार Yandex Metrica से डेटा लाते समय पंक्ति सीमा पूरी हो गई, इसलिए उसमें छूटे पृष्ठों पर भी ट्रैफ़िक हो सकता है। पूरा डेटा लाए जाने तक यह फ़िल्टर उपलब्ध नहीं है।",
|
||||
"noticeSampled": "पिछली बार Yandex Metrica से लाया गया डेटा सैंपल किया हुआ था, इसलिए उसमें छूटे पृष्ठों पर भी ट्रैफ़िक हो सकता है। बिना सैंपलिंग वाला डेटा लाए जाने तक यह फ़िल्टर उपलब्ध नहीं है।",
|
||||
"noticeNoPull": "इस काउंटर के लिए अभी तक Yandex Metrica का कोई डेटा नहीं लाया गया है। पहले डेटा लाएँ: पूरा डेटा लाए बिना, जिस पृष्ठ की कोई पंक्ति नहीं है उसे शून्य ट्रैफ़िक नहीं माना जा सकता।",
|
||||
"loading": "लोड हो रहा है…",
|
||||
"unconfiguredTitle": "Yandex Metrica सेट नहीं है",
|
||||
"unconfiguredBody": "सेटिंग्स → इंटीग्रेशन → Yandex Metrica खोलें और metrika:read अनुमति वाला टोकन चिपकाएँ। वहाँ की सेटअप गाइड टोकन पाने के चरण बताती है। फिर काउंटर चुनने के लिए यहाँ लौटें।",
|
||||
"sessionHint": "सत्र-स्तर: हर देखे गए पृष्ठ के लिए नहीं, बल्कि उस पृष्ठ के लिए गिना जाता है जिस पर सत्र शुरू हुआ (लैंडिंग पृष्ठ)।",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "URL से फ़िल्टर करें…",
|
||||
"filterAll": "सभी 2xx पृष्ठ",
|
||||
"filterWith": "Metrica डेटा सहित",
|
||||
"filterWithout": "Metrica डेटा रहित",
|
||||
"filterIndexableNoVisits": "अनुक्रमणीय, कोई विज़िट नहीं",
|
||||
"filterNon200": "गैर-2xx, ट्रैफ़िक सहित",
|
||||
"countersLoading": "काउंटर लोड हो रहे हैं…",
|
||||
"noCounters": "कोई काउंटर नहीं",
|
||||
"daysTitle": "कल तक के पूरे दिन, काउंटर के समय क्षेत्र के अनुसार।",
|
||||
"days7": "पिछले 7 दिन",
|
||||
"days28": "पिछले 28 दिन",
|
||||
"days90": "पिछले 90 दिन",
|
||||
"cancel": "रद्द करें",
|
||||
"fetch": "लाएँ",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} पृष्ठ · {{ago}} लाया गया",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} पृष्ठ · {{ago}} लाया गया",
|
||||
"totalsTitle": "इस अवधि के लिए Metrica द्वारा बताए गए पूरी साइट के कुल योग, Metrica इंटरफ़ेस से तुलना के लिए।",
|
||||
"totalsLine": "साइट के कुल योग: {{pageviews}} पृष्ठ दृश्य · {{visits}} विज़िट · {{searchVisits}} खोज विज़िट",
|
||||
"truncatedFlag": "पंक्ति सीमा पूरी: कम ट्रैफ़िक वाले पृष्ठ छूट गए हैं",
|
||||
"sampledFlag": "सैंपल किया गया डेटा",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "स्थिति",
|
||||
"colPageviewsHint": "हिट-स्तर: इस पृष्ठ URL का हर दृश्य।",
|
||||
"colPageviews": "पृष्ठ दृश्य (हिट)",
|
||||
"colVisits": "विज़िट (लैंडिंग)",
|
||||
"colUsersHint": "सत्र-स्तर, लैंडिंग पृष्ठ के अनुसार। यह ऊपरी सीमा है: कई पृष्ठों पर आने वाला एक व्यक्ति हर पृष्ठ पर एक बार गिना जाता है।",
|
||||
"colUsers": "उपयोगकर्ता (लैंडिंग)",
|
||||
"colSearchVisitsHint": "सत्र-स्तर: खोज इंजनों से आए वे सत्र जो इस पृष्ठ पर शुरू हुए।",
|
||||
"colSearchVisits": "खोज विज़िट (लैंडिंग)",
|
||||
"colFetched": "लाया गया",
|
||||
"emptyNoData": "अभी कोई आंतरिक HTML पृष्ठ क्रॉल नहीं हुआ — एक क्रॉल चलाएँ, फिर Metrica डेटा लाएँ।",
|
||||
"emptyNoMatch": "मौजूदा फ़िल्टर से कोई पृष्ठ मेल नहीं खाता।",
|
||||
"summary_one": "{{formatted}} में से {{shown}} पृष्ठ",
|
||||
"summary_other": "{{formatted}} में से {{shown}} पृष्ठ",
|
||||
"progress_one": "{{report}} रिपोर्ट लाई जा रही है ({{index}}/{{total}}) · {{formatted}} पंक्ति",
|
||||
"progress_other": "{{report}} रिपोर्ट लाई जा रही है ({{index}}/{{total}}) · {{formatted}} पंक्तियाँ",
|
||||
"errNotConnected": "Yandex Metrica कनेक्ट नहीं है — सेटिंग्स → इंटीग्रेशन → Yandex Metrica में टोकन चिपकाएँ।",
|
||||
"errTokenFormat": "सहेजे गए Yandex Metrica टोकन का फ़ॉर्मैट अपेक्षित नहीं है — इसे सेटिंग्स → इंटीग्रेशन → Yandex Metrica में फिर से चिपकाएँ।",
|
||||
"errTokenRejected": "Yandex Metrica ने टोकन अस्वीकार कर दिया (HTTP 401) — सेटिंग्स → इंटीग्रेशन → Yandex Metrica में metrika:read अनुमति वाला नया टोकन चिपकाएँ।",
|
||||
"errAccessDenied": "Yandex Metrica ने एक्सेस अस्वीकार कर दिया (HTTP 403) — टोकन जिस खाते का है, वह इस काउंटर को नहीं पढ़ सकता।",
|
||||
"errNoCounter": "कोई Yandex Metrica काउंटर नहीं चुना गया।",
|
||||
"errBadRange": "यह तारीख़ सीमा समर्थित नहीं है।",
|
||||
"errAlreadyRunning": "इस विंडो में Yandex Metrica से डेटा पहले से लाया जा रहा है।",
|
||||
"errProjectSwitched": "डेटा लाते समय कोई दूसरा प्रोजेक्ट खोला गया, इसलिए परिणाम हटा दिया गया। इस प्रोजेक्ट में फिर से डेटा लाएँ।",
|
||||
"errUnexpectedResponse": "Yandex Metrica ने अप्रत्याशित जवाब लौटाया। पहले से सहेजा गया डेटा नहीं बदला है।",
|
||||
"errRangeChanged": "डेटा लाते समय Yandex Metrica ने रिपोर्ट की तारीख़ सीमा बदल दी। पहले से सहेजा गया डेटा नहीं बदला है — फिर से डेटा लाएँ।",
|
||||
"errNetwork": "Yandex Metrica तक नहीं पहुँच सके। कनेक्शन जाँचें और फिर से कोशिश करें।"
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "URL से फ़िल्टर करें…",
|
||||
@@ -1556,7 +1632,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "लोड हो रहा है…",
|
||||
"empty": "इस URL के लिए अभी Search Console / Analytics 4 / URL निरीक्षण का कोई डेटा नहीं है। इसे GA4 या Search Console टैब से लाएँ।",
|
||||
"empty": "इस URL के लिए अभी Search Console / Analytics 4 / Yandex Metrica / URL निरीक्षण का कोई डेटा नहीं है। इसे GA4 / Yandex Metrica / Search Console टैब से लाएँ।",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "अभी क्लिक / इंप्रेशन का कोई डेटा नहीं।",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1565,7 +1641,13 @@
|
||||
"inspectionEmpty": "अभी URL निरीक्षण का कोई परिणाम नहीं — इसे Search Console टैब से चलाएँ।",
|
||||
"colSource": "स्रोत",
|
||||
"colMetric": "मीट्रिक",
|
||||
"colValue": "मान"
|
||||
"colValue": "मान",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "अभी Yandex Metrica का कोई डेटा नहीं — इसे Yandex Metrica टैब से लाएँ।",
|
||||
"metricaPageviewsHint": "हिट-स्तर: इस पृष्ठ URL का हर दृश्य।",
|
||||
"metricaVisitsHint": "सत्र-स्तर: वे सत्र जो इस पृष्ठ पर शुरू हुए।",
|
||||
"metricaUsersHint": "सत्र-स्तर, लैंडिंग पृष्ठ के अनुसार। जब कई URL रूप इस पृष्ठ में मिल जाते हैं, तब यह ऊपरी सीमा है।",
|
||||
"metricaSearchVisitsHint": "सत्र-स्तर: खोज इंजनों से आए वे सत्र जो इस पृष्ठ पर शुरू हुए।"
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "टूटे लक्ष्य URL खोजें…",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "IA",
|
||||
"seo": "Autorità SEO",
|
||||
"visualization": "Visualizzazione",
|
||||
@@ -362,7 +363,7 @@
|
||||
"urlLengthHistogram": "Istogramma della lunghezza degli URL",
|
||||
"wordCountPerDir": "Conteggio parole per directory",
|
||||
"sitemapOrphans": "Orfane della sitemap (prime 1000)",
|
||||
"orphanCrossSource": "Pagine orfane — Sitemap + GSC + GA4 (prime 1000)",
|
||||
"orphanCrossSource": "Pagine orfane — Sitemap + GSC + GA4 + Metrica (prime 1000)",
|
||||
"serverHeaders": "Stack del server (header Server)",
|
||||
"topWords": "Parole più frequenti (titolo + meta + H1, prime 100)",
|
||||
"pagesOverLinkLimit": "Pagine oltre il massimo di link per pagina",
|
||||
@@ -385,7 +386,22 @@
|
||||
"keyPosition": "Posizione",
|
||||
"keyServer": "Server",
|
||||
"keyWord": "Parola",
|
||||
"includeBody": "Includi il testo del corpo (più lento)"
|
||||
"includeBody": "Includi il testo del corpo (più lento)",
|
||||
"orphanGscClicks_one": "{{formatted}} clic GSC",
|
||||
"orphanGscClicks_many": "{{formatted}} di clic GSC",
|
||||
"orphanGscClicks_other": "{{formatted}} clic GSC",
|
||||
"orphanGscImpressions_one": "{{formatted}} impr.",
|
||||
"orphanGscImpressions_many": "{{formatted}} di impr.",
|
||||
"orphanGscImpressions_other": "{{formatted}} impr.",
|
||||
"orphanGa4Sessions_one": "{{formatted}} sessione GA4",
|
||||
"orphanGa4Sessions_many": "{{formatted}} di sessioni GA4",
|
||||
"orphanGa4Sessions_other": "{{formatted}} sessioni GA4",
|
||||
"orphanMetricaVisits_one": "{{formatted}} visita Metrica (ingresso)",
|
||||
"orphanMetricaVisits_many": "{{formatted}} di visite Metrica (ingresso)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} visite Metrica (ingresso)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} visualizzazione di pagina Metrica (hit)",
|
||||
"orphanMetricaPageviews_many": "{{formatted}} di visualizzazioni di pagina Metrica (hit)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} visualizzazioni di pagina Metrica (hit)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Validatore di sitemap",
|
||||
@@ -1525,6 +1541,74 @@
|
||||
"emptyNoMatch": "Nessuna pagina corrisponde al filtro attuale.",
|
||||
"accountTitle": "Su quale account Google collegato questo progetto fa i suoi report. Ogni account conserva i propri dati archiviati."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "poco fa",
|
||||
"agoMinutes": "{{count}} min fa",
|
||||
"agoHours": "{{count}} h fa",
|
||||
"agoDays": "{{count}} g fa",
|
||||
"cancelled": "Download annullato. I dati archiviati in precedenza sono invariati.",
|
||||
"reportVisits": "visite",
|
||||
"reportSearchVisits": "visite da ricerca",
|
||||
"reportPageviews": "visualizzazioni di pagina",
|
||||
"noticeTruncated": "L'ultimo download da Yandex Metrica ha raggiunto il limite di righe, quindi le pagine che non vi compaiono potrebbero comunque avere traffico. Questo filtro non è disponibile finché non si esegue un download completo.",
|
||||
"noticeSampled": "L'ultimo download da Yandex Metrica ha restituito dati campionati, quindi le pagine che non vi compaiono potrebbero comunque avere traffico. Questo filtro non è disponibile finché non si esegue un download non campionato.",
|
||||
"noticeNoPull": "Non sono ancora stati scaricati dati di Yandex Metrica per questo contatore. Scaricali prima: senza un download completo, una pagina senza riga non può essere interpretata come traffico zero.",
|
||||
"loading": "Caricamento…",
|
||||
"unconfiguredTitle": "Yandex Metrica non è configurato",
|
||||
"unconfiguredBody": "Apri Impostazioni → Integrazioni → Yandex Metrica e incolla un token con il permesso metrika:read. La guida alla configurazione che trovi lì spiega come ottenerlo. Poi torna qui per scegliere un contatore.",
|
||||
"sessionHint": "Livello di sessione: conteggiato per la pagina in cui è iniziata la sessione (pagina di ingresso), non per ogni pagina visualizzata.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Filtra per URL…",
|
||||
"filterAll": "Tutte le pagine 2xx",
|
||||
"filterWith": "Con dati Metrica",
|
||||
"filterWithout": "Senza dati Metrica",
|
||||
"filterIndexableNoVisits": "Indicizzabili, senza visite",
|
||||
"filterNon200": "Non 2xx con traffico",
|
||||
"countersLoading": "Caricamento contatori…",
|
||||
"noCounters": "Nessun contatore",
|
||||
"daysTitle": "Giorni interi fino a ieri, nel fuso orario del contatore.",
|
||||
"days7": "Ultimi 7 giorni",
|
||||
"days28": "Ultimi 28 giorni",
|
||||
"days90": "Ultimi 90 giorni",
|
||||
"cancel": "Annulla",
|
||||
"fetch": "Scarica",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} pagina · scaricato {{ago}}",
|
||||
"metaLine_many": "{{counter}} · {{start}} → {{end}} · {{formatted}} di pagine · scaricato {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} pagine · scaricato {{ago}}",
|
||||
"totalsTitle": "Totali dell'intero sito riportati da Metrica per l'intervallo, da confrontare con l'interfaccia di Metrica.",
|
||||
"totalsLine": "Totali del sito: {{pageviews}} visualizzazioni di pagina · {{visits}} visite · {{searchVisits}} visite da ricerca",
|
||||
"truncatedFlag": "Limite di righe raggiunto: mancano le pagine con poco traffico",
|
||||
"sampledFlag": "Dati campionati",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Stato",
|
||||
"colPageviewsHint": "Livello di hit: ogni visualizzazione dell'URL di questa pagina.",
|
||||
"colPageviews": "Visualizzazioni di pagina (hit)",
|
||||
"colVisits": "Visite (ingresso)",
|
||||
"colUsersHint": "Livello di sessione, per pagina di ingresso. È un limite superiore: una persona che entra da più pagine viene contata una volta per ciascuna.",
|
||||
"colUsers": "Utenti (ingresso)",
|
||||
"colSearchVisitsHint": "Livello di sessione: sessioni provenienti dai motori di ricerca iniziate su questa pagina.",
|
||||
"colSearchVisits": "Visite da ricerca (ingresso)",
|
||||
"colFetched": "Scaricato",
|
||||
"emptyNoData": "Nessuna pagina HTML interna ancora scansionata — esegui una scansione, poi scarica i dati di Metrica.",
|
||||
"emptyNoMatch": "Nessuna pagina corrisponde al filtro attuale.",
|
||||
"summary_one": "{{shown}} di {{formatted}} pagina",
|
||||
"summary_many": "{{shown}} di {{formatted}} di pagine",
|
||||
"summary_other": "{{shown}} di {{formatted}} pagine",
|
||||
"progress_one": "Download del report delle {{report}} ({{index}}/{{total}}) · {{formatted}} riga",
|
||||
"progress_many": "Download del report delle {{report}} ({{index}}/{{total}}) · {{formatted}} di righe",
|
||||
"progress_other": "Download del report delle {{report}} ({{index}}/{{total}}) · {{formatted}} righe",
|
||||
"errNotConnected": "Yandex Metrica non è collegato: incolla un token in Impostazioni → Integrazioni → Yandex Metrica.",
|
||||
"errTokenFormat": "Il token di Yandex Metrica salvato ha un formato inatteso: incollalo di nuovo in Impostazioni → Integrazioni → Yandex Metrica.",
|
||||
"errTokenRejected": "Yandex Metrica ha rifiutato il token (HTTP 401): incolla un nuovo token con il permesso metrika:read in Impostazioni → Integrazioni → Yandex Metrica.",
|
||||
"errAccessDenied": "Yandex Metrica ha negato l'accesso (HTTP 403): l'account del token non può leggere questo contatore.",
|
||||
"errNoCounter": "Nessun contatore Yandex Metrica selezionato.",
|
||||
"errBadRange": "Intervallo di date non supportato.",
|
||||
"errAlreadyRunning": "In questa finestra è già in corso un download da Yandex Metrica.",
|
||||
"errProjectSwitched": "Durante il download è stato aperto un altro progetto, quindi il risultato è stato scartato. Esegui di nuovo il download in questo progetto.",
|
||||
"errUnexpectedResponse": "Yandex Metrica ha restituito una risposta inattesa. I dati archiviati in precedenza sono invariati.",
|
||||
"errRangeChanged": "Yandex Metrica ha cambiato l'intervallo di date del report durante il download. I dati archiviati in precedenza sono invariati: esegui di nuovo il download.",
|
||||
"errNetwork": "Impossibile raggiungere Yandex Metrica. Controlla la connessione e riprova."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "IA",
|
||||
"filterPlaceholder": "Filtra per URL…",
|
||||
@@ -1577,7 +1661,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Caricamento…",
|
||||
"empty": "Nessun dato di Search Console / Analytics 4 / ispezione URL per questo URL finora. Scaricali dalle schede GA4 o Search Console.",
|
||||
"empty": "Nessun dato di Search Console / Analytics 4 / Yandex Metrica / ispezione URL per questo URL finora. Scaricali dalle schede GA4, Yandex Metrica o Search Console.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Nessun dato di clic / impressioni finora.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1586,7 +1670,13 @@
|
||||
"inspectionEmpty": "Nessun risultato di ispezione URL finora — eseguila dalla scheda Search Console.",
|
||||
"colSource": "Origine",
|
||||
"colMetric": "Metrica",
|
||||
"colValue": "Valore"
|
||||
"colValue": "Valore",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Nessun dato di Yandex Metrica finora — scaricali dalla scheda Yandex Metrica.",
|
||||
"metricaPageviewsHint": "Livello di hit: ogni visualizzazione dell'URL di questa pagina.",
|
||||
"metricaVisitsHint": "Livello di sessione: sessioni iniziate su questa pagina.",
|
||||
"metricaUsersHint": "Livello di sessione, per pagina di ingresso. È un limite superiore quando più varianti di URL confluiscono in questa pagina.",
|
||||
"metricaSearchVisitsHint": "Livello di sessione: sessioni provenienti dai motori di ricerca iniziate su questa pagina."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Cerca URL di destinazione rotti…",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "AI",
|
||||
"seo": "SEO 권위도",
|
||||
"visualization": "시각화",
|
||||
@@ -348,7 +349,7 @@
|
||||
"urlLengthHistogram": "URL 길이 히스토그램",
|
||||
"wordCountPerDir": "디렉터리별 단어 수",
|
||||
"sitemapOrphans": "사이트맵 고립 페이지(상위 1000)",
|
||||
"orphanCrossSource": "고립 페이지 — 사이트맵 + GSC + GA4(상위 1000)",
|
||||
"orphanCrossSource": "고립 페이지 — 사이트맵 + GSC + GA4 + Metrica(상위 1000)",
|
||||
"serverHeaders": "서버 스택(Server 헤더)",
|
||||
"topWords": "상위 단어(제목 + 메타 + H1, 상위 100)",
|
||||
"pagesOverLinkLimit": "페이지당 최대 링크 수를 초과한 페이지",
|
||||
@@ -371,7 +372,12 @@
|
||||
"keyPosition": "위치",
|
||||
"keyServer": "서버",
|
||||
"keyWord": "단어",
|
||||
"includeBody": "본문 텍스트 포함 (느림)"
|
||||
"includeBody": "본문 텍스트 포함 (느림)",
|
||||
"orphanGscClicks_other": "GSC 클릭 {{formatted}}회",
|
||||
"orphanGscImpressions_other": "노출 {{formatted}}회",
|
||||
"orphanGa4Sessions_other": "GA4 세션 {{formatted}}회",
|
||||
"orphanMetricaVisits_other": "Metrica 방문 {{formatted}}회(랜딩)",
|
||||
"orphanMetricaPageviews_other": "Metrica 페이지뷰 {{formatted}}회(히트)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "사이트맵 검사기",
|
||||
@@ -1483,6 +1489,68 @@
|
||||
"emptyNoMatch": "현재 필터와 일치하는 페이지가 없습니다.",
|
||||
"accountTitle": "이 프로젝트가 어떤 연결된 Google 계정을 기준으로 보고하는지입니다. 계정마다 저장된 데이터가 따로 관리됩니다."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "방금 전",
|
||||
"agoMinutes": "{{count}}분 전",
|
||||
"agoHours": "{{count}}시간 전",
|
||||
"agoDays": "{{count}}일 전",
|
||||
"cancelled": "가져오기를 취소했습니다. 이전에 저장된 데이터는 그대로입니다.",
|
||||
"reportVisits": "방문수",
|
||||
"reportSearchVisits": "검색 방문수",
|
||||
"reportPageviews": "페이지뷰",
|
||||
"noticeTruncated": "마지막 Yandex Metrica 가져오기가 행 한도에 도달했으므로 결과에 없는 페이지에도 트래픽이 있을 수 있습니다. 완전한 가져오기를 마칠 때까지 이 필터는 사용할 수 없습니다.",
|
||||
"noticeSampled": "마지막 Yandex Metrica 가져오기 결과가 샘플링되었으므로 결과에 없는 페이지에도 트래픽이 있을 수 있습니다. 샘플링되지 않은 가져오기를 마칠 때까지 이 필터는 사용할 수 없습니다.",
|
||||
"noticeNoPull": "이 카운터에 대해 아직 가져온 Yandex Metrica 데이터가 없습니다. 먼저 가져오십시오. 완전한 가져오기 없이는 행이 없는 페이지를 트래픽 0으로 볼 수 없습니다.",
|
||||
"loading": "불러오는 중…",
|
||||
"unconfiguredTitle": "Yandex Metrica가 설정되지 않았습니다",
|
||||
"unconfiguredBody": "설정 → 연동 → Yandex Metrica를 열고 metrika:read 권한이 있는 토큰을 붙여넣으십시오. 그곳의 설정 가이드에서 토큰을 받는 방법을 단계별로 안내합니다. 그런 다음 여기로 돌아와 카운터를 선택하십시오.",
|
||||
"sessionHint": "세션 수준: 조회한 모든 페이지가 아니라 세션이 시작된 페이지(랜딩 페이지)를 기준으로 집계합니다.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "URL로 필터…",
|
||||
"filterAll": "모든 2xx 페이지",
|
||||
"filterWith": "Metrica 데이터 있음",
|
||||
"filterWithout": "Metrica 데이터 없음",
|
||||
"filterIndexableNoVisits": "색인 가능, 방문 없음",
|
||||
"filterNon200": "2xx 아님, 트래픽 있음",
|
||||
"countersLoading": "카운터 불러오는 중…",
|
||||
"noCounters": "카운터 없음",
|
||||
"daysTitle": "어제로 끝나는 전체 일수이며, 카운터의 시간대를 기준으로 합니다.",
|
||||
"days7": "최근 7일",
|
||||
"days28": "최근 28일",
|
||||
"days90": "최근 90일",
|
||||
"cancel": "취소",
|
||||
"fetch": "가져오기",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · 페이지 {{formatted}}개 · {{ago}}에 가져옴",
|
||||
"totalsTitle": "해당 기간에 대해 Metrica가 보고한 사이트 전체 합계로, Metrica 인터페이스와 비교할 수 있습니다.",
|
||||
"totalsLine": "사이트 합계: 페이지뷰 {{pageviews}} · 방문수 {{visits}} · 검색 방문수 {{searchVisits}}",
|
||||
"truncatedFlag": "행 한도 도달: 트래픽이 적은 페이지가 누락됨",
|
||||
"sampledFlag": "샘플링된 데이터",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "상태",
|
||||
"colPageviewsHint": "히트 수준: 이 페이지 URL의 모든 조회.",
|
||||
"colPageviews": "페이지뷰(히트)",
|
||||
"colVisits": "방문수(랜딩)",
|
||||
"colUsersHint": "세션 수준, 랜딩 페이지 기준. 상한값입니다. 한 사람이 여러 페이지로 유입되면 페이지마다 한 번씩 집계됩니다.",
|
||||
"colUsers": "사용자수(랜딩)",
|
||||
"colSearchVisitsHint": "세션 수준: 이 페이지에서 시작된 검색엔진 유입 세션.",
|
||||
"colSearchVisits": "검색 방문수(랜딩)",
|
||||
"colFetched": "가져옴",
|
||||
"emptyNoData": "아직 크롤한 내부 HTML 페이지가 없습니다 — 크롤을 실행한 뒤 Metrica 데이터를 가져오십시오.",
|
||||
"emptyNoMatch": "현재 필터와 일치하는 페이지가 없습니다.",
|
||||
"summary_other": "페이지 {{formatted}}개 중 {{shown}}개",
|
||||
"progress_other": "{{report}} 보고서 가져오는 중({{index}}/{{total}}) · {{formatted}}행",
|
||||
"errNotConnected": "Yandex Metrica가 연결되어 있지 않습니다. 설정 → 연동 → Yandex Metrica에 토큰을 붙여넣으십시오.",
|
||||
"errTokenFormat": "저장된 Yandex Metrica 토큰의 형식이 올바르지 않습니다. 설정 → 연동 → Yandex Metrica에 다시 붙여넣으십시오.",
|
||||
"errTokenRejected": "Yandex Metrica가 토큰을 거부했습니다 (HTTP 401). 설정 → 연동 → Yandex Metrica에 metrika:read 권한이 있는 새 토큰을 붙여넣으십시오.",
|
||||
"errAccessDenied": "Yandex Metrica가 접근을 거부했습니다 (HTTP 403). 토큰을 발급한 계정은 이 카운터를 읽을 수 없습니다.",
|
||||
"errNoCounter": "선택된 Yandex Metrica 카운터가 없습니다.",
|
||||
"errBadRange": "지원되지 않는 기간입니다.",
|
||||
"errAlreadyRunning": "이 창에서 이미 Yandex Metrica 가져오기가 실행 중입니다.",
|
||||
"errProjectSwitched": "가져오는 동안 다른 프로젝트가 열려 결과를 버렸습니다. 이 프로젝트에서 다시 가져오십시오.",
|
||||
"errUnexpectedResponse": "Yandex Metrica가 예상하지 못한 응답을 반환했습니다. 이전에 저장된 데이터는 그대로입니다.",
|
||||
"errRangeChanged": "가져오는 동안 Yandex Metrica가 보고서 기간을 변경했습니다. 이전에 저장된 데이터는 그대로입니다. 다시 가져오십시오.",
|
||||
"errNetwork": "Yandex Metrica에 연결할 수 없습니다. 연결을 확인한 후 다시 시도하십시오."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "URL로 필터…",
|
||||
@@ -1535,7 +1603,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "불러오는 중…",
|
||||
"empty": "이 URL에 대한 Search Console / Analytics 4 / URL 검사 데이터가 아직 없습니다. GA4 또는 Search Console 탭에서 가져오십시오.",
|
||||
"empty": "이 URL에 대한 Search Console / Analytics 4 / Yandex Metrica / URL 검사 데이터가 아직 없습니다. GA4 / Yandex Metrica / Search Console 탭에서 가져오십시오.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "아직 클릭수 / 노출수 데이터가 없습니다.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1544,7 +1612,13 @@
|
||||
"inspectionEmpty": "아직 URL 검사 결과가 없습니다 — Search Console 탭에서 실행하십시오.",
|
||||
"colSource": "출처",
|
||||
"colMetric": "측정항목",
|
||||
"colValue": "값"
|
||||
"colValue": "값",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "아직 Yandex Metrica 데이터가 없습니다 — Yandex Metrica 탭에서 가져오십시오.",
|
||||
"metricaPageviewsHint": "히트 수준: 이 페이지 URL의 모든 조회.",
|
||||
"metricaVisitsHint": "세션 수준: 이 페이지에서 시작된 세션.",
|
||||
"metricaUsersHint": "세션 수준, 랜딩 페이지 기준. 여러 URL 변형이 이 페이지로 합쳐지면 상한값이 됩니다.",
|
||||
"metricaSearchVisitsHint": "세션 수준: 이 페이지에서 시작된 검색엔진 유입 세션."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "끊어진 대상 URL 검색…",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "IA",
|
||||
"seo": "Autoridade SEO",
|
||||
"visualization": "Visualização",
|
||||
@@ -362,7 +363,7 @@
|
||||
"urlLengthHistogram": "Histograma de comprimento de URL",
|
||||
"wordCountPerDir": "Contagem de palavras por diretório",
|
||||
"sitemapOrphans": "Órfãs do sitemap (1000 primeiras)",
|
||||
"orphanCrossSource": "Páginas órfãs — Sitemap + GSC + GA4 (1000 primeiras)",
|
||||
"orphanCrossSource": "Páginas órfãs — Sitemap + GSC + GA4 + Metrica (1000 primeiras)",
|
||||
"serverHeaders": "Pilha do servidor (cabeçalho Server)",
|
||||
"topWords": "Palavras mais frequentes (título + meta + H1, 100 primeiras)",
|
||||
"pagesOverLinkLimit": "Páginas acima do máximo de links por página",
|
||||
@@ -385,7 +386,22 @@
|
||||
"keyPosition": "Posição",
|
||||
"keyServer": "Servidor",
|
||||
"keyWord": "Palavra",
|
||||
"includeBody": "Incluir texto do corpo (mais lento)"
|
||||
"includeBody": "Incluir texto do corpo (mais lento)",
|
||||
"orphanGscClicks_one": "{{formatted}} clique do GSC",
|
||||
"orphanGscClicks_many": "{{formatted}} de cliques do GSC",
|
||||
"orphanGscClicks_other": "{{formatted}} cliques do GSC",
|
||||
"orphanGscImpressions_one": "{{formatted}} impressão",
|
||||
"orphanGscImpressions_many": "{{formatted}} de impressões",
|
||||
"orphanGscImpressions_other": "{{formatted}} impressões",
|
||||
"orphanGa4Sessions_one": "{{formatted}} sessão do GA4",
|
||||
"orphanGa4Sessions_many": "{{formatted}} de sessões do GA4",
|
||||
"orphanGa4Sessions_other": "{{formatted}} sessões do GA4",
|
||||
"orphanMetricaVisits_one": "{{formatted}} visita do Metrica (entrada)",
|
||||
"orphanMetricaVisits_many": "{{formatted}} de visitas do Metrica (entrada)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} visitas do Metrica (entrada)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} visualização do Metrica (hits)",
|
||||
"orphanMetricaPageviews_many": "{{formatted}} de visualizações do Metrica (hits)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} visualizações do Metrica (hits)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Validador de sitemap",
|
||||
@@ -1525,6 +1541,74 @@
|
||||
"emptyNoMatch": "Nenhuma página corresponde ao filtro atual.",
|
||||
"accountTitle": "Sobre qual conta do Google vinculada este projeto reporta. Cada conta mantém os próprios dados armazenados."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "agora mesmo",
|
||||
"agoMinutes": "há {{count}} min",
|
||||
"agoHours": "há {{count}} h",
|
||||
"agoDays": "há {{count}} d",
|
||||
"cancelled": "Download cancelado. Os dados armazenados anteriormente não foram alterados.",
|
||||
"reportVisits": "visitas",
|
||||
"reportSearchVisits": "visitas de busca",
|
||||
"reportPageviews": "visualizações de página",
|
||||
"noticeTruncated": "O último download do Yandex Metrica atingiu o limite de linhas, então as páginas que ficaram de fora ainda podem ter tráfego. Este filtro fica indisponível até um download completo.",
|
||||
"noticeSampled": "O último download do Yandex Metrica veio amostrado, então as páginas que ficaram de fora ainda podem ter tráfego. Este filtro fica indisponível até um download sem amostragem.",
|
||||
"noticeNoPull": "Ainda não foram baixados dados do Yandex Metrica para este contador. Baixe primeiro: sem um download completo, uma página sem linha não pode ser interpretada como tráfego zero.",
|
||||
"loading": "Carregando…",
|
||||
"unconfiguredTitle": "O Yandex Metrica não está configurado",
|
||||
"unconfiguredBody": "Abra Configurações → Integrações → Yandex Metrica e cole um token com a permissão metrika:read. O guia de configuração ali mostra passo a passo como obtê-lo. Depois volte aqui para escolher um contador.",
|
||||
"sessionHint": "Nível de sessão: contado para a página em que a sessão começou (página de entrada), não para cada página visualizada.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Filtrar por URL…",
|
||||
"filterAll": "Todas as páginas 2xx",
|
||||
"filterWith": "Com dados do Metrica",
|
||||
"filterWithout": "Sem dados do Metrica",
|
||||
"filterIndexableNoVisits": "Indexáveis, sem visitas",
|
||||
"filterNon200": "Não 2xx com tráfego",
|
||||
"countersLoading": "Carregando contadores…",
|
||||
"noCounters": "Sem contadores",
|
||||
"daysTitle": "Dias completos até ontem, no fuso horário do contador.",
|
||||
"days7": "Últimos 7 dias",
|
||||
"days28": "Últimos 28 dias",
|
||||
"days90": "Últimos 90 dias",
|
||||
"cancel": "Cancelar",
|
||||
"fetch": "Baixar",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} página · baixado {{ago}}",
|
||||
"metaLine_many": "{{counter}} · {{start}} → {{end}} · {{formatted}} de páginas · baixado {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} páginas · baixado {{ago}}",
|
||||
"totalsTitle": "Totais do site inteiro informados pelo Metrica para o período, para comparar com a interface do Metrica.",
|
||||
"totalsLine": "Totais do site: {{pageviews}} visualizações de página · {{visits}} visitas · {{searchVisits}} visitas de busca",
|
||||
"truncatedFlag": "Limite de linhas atingido: faltam páginas com pouco tráfego",
|
||||
"sampledFlag": "Dados amostrados",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Status",
|
||||
"colPageviewsHint": "Nível de hit: cada visualização da URL desta página.",
|
||||
"colPageviews": "Visualizações (hits)",
|
||||
"colVisits": "Visitas (entrada)",
|
||||
"colUsersHint": "Nível de sessão, por página de entrada. Um limite superior: uma pessoa que entra por várias páginas é contada uma vez em cada uma.",
|
||||
"colUsers": "Usuários (entrada)",
|
||||
"colSearchVisitsHint": "Nível de sessão: sessões vindas de mecanismos de busca que começaram nesta página.",
|
||||
"colSearchVisits": "Visitas de busca (entrada)",
|
||||
"colFetched": "Baixado",
|
||||
"emptyNoData": "Nenhuma página HTML interna rastreada ainda — execute um rastreamento e depois baixe os dados do Metrica.",
|
||||
"emptyNoMatch": "Nenhuma página corresponde ao filtro atual.",
|
||||
"summary_one": "{{shown}} de {{formatted}} página",
|
||||
"summary_many": "{{shown}} de {{formatted}} de páginas",
|
||||
"summary_other": "{{shown}} de {{formatted}} páginas",
|
||||
"progress_one": "Baixando o relatório de {{report}} ({{index}}/{{total}}) · {{formatted}} linha",
|
||||
"progress_many": "Baixando o relatório de {{report}} ({{index}}/{{total}}) · {{formatted}} de linhas",
|
||||
"progress_other": "Baixando o relatório de {{report}} ({{index}}/{{total}}) · {{formatted}} linhas",
|
||||
"errNotConnected": "O Yandex Metrica não está conectado — cole um token em Configurações → Integrações → Yandex Metrica.",
|
||||
"errTokenFormat": "O token do Yandex Metrica salvo tem um formato inesperado — cole-o novamente em Configurações → Integrações → Yandex Metrica.",
|
||||
"errTokenRejected": "O Yandex Metrica recusou o token (HTTP 401) — cole um token novo com a permissão metrika:read em Configurações → Integrações → Yandex Metrica.",
|
||||
"errAccessDenied": "O Yandex Metrica negou o acesso (HTTP 403) — a conta do token não pode ler este contador.",
|
||||
"errNoCounter": "Nenhum contador do Yandex Metrica selecionado.",
|
||||
"errBadRange": "Intervalo de datas não suportado.",
|
||||
"errAlreadyRunning": "Já há um download do Yandex Metrica em andamento nesta janela.",
|
||||
"errProjectSwitched": "Outro projeto foi aberto durante o download, então o resultado foi descartado. Baixe novamente neste projeto.",
|
||||
"errUnexpectedResponse": "O Yandex Metrica retornou uma resposta inesperada. Os dados armazenados anteriormente não foram alterados.",
|
||||
"errRangeChanged": "O Yandex Metrica mudou o intervalo de datas do relatório durante o download. Os dados armazenados anteriormente não foram alterados — baixe novamente.",
|
||||
"errNetwork": "Não foi possível acessar o Yandex Metrica. Verifique a conexão e tente novamente."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "IA",
|
||||
"filterPlaceholder": "Filtrar por URL…",
|
||||
@@ -1577,7 +1661,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Carregando…",
|
||||
"empty": "Ainda não há dados do Search Console, Analytics 4 nem inspeção de URL para esta URL. Baixe-os pelas abas GA4 ou Search Console.",
|
||||
"empty": "Ainda não há dados do Search Console, Analytics 4, Yandex Metrica nem inspeção de URL para esta URL. Baixe-os pelas abas GA4, Yandex Metrica ou Search Console.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Ainda não há dados de cliques / impressões.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1586,7 +1670,13 @@
|
||||
"inspectionEmpty": "Ainda não há resultado de inspeção de URL — execute-a pela aba Search Console.",
|
||||
"colSource": "Origem",
|
||||
"colMetric": "Métrica",
|
||||
"colValue": "Valor"
|
||||
"colValue": "Valor",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Ainda não há dados do Yandex Metrica — baixe-os pela aba Yandex Metrica.",
|
||||
"metricaPageviewsHint": "Nível de hit: cada visualização da URL desta página.",
|
||||
"metricaVisitsHint": "Nível de sessão: sessões que começaram nesta página.",
|
||||
"metricaUsersHint": "Nível de sessão, por página de entrada. Um limite superior quando várias variantes de URL são agrupadas nesta página.",
|
||||
"metricaSearchVisitsHint": "Nível de sessão: sessões vindas de mecanismos de busca que começaram nesta página."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Pesquisar URLs de destino quebradas…",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "ИИ",
|
||||
"seo": "SEO-авторитет",
|
||||
"visualization": "Визуализация",
|
||||
@@ -369,7 +370,7 @@
|
||||
"urlLengthHistogram": "Гистограмма длины URL",
|
||||
"wordCountPerDir": "Количество слов по каталогам",
|
||||
"sitemapOrphans": "Осиротевшие в sitemap (первые 1000)",
|
||||
"orphanCrossSource": "Осиротевшие страницы — Sitemap + GSC + GA4 (первые 1000)",
|
||||
"orphanCrossSource": "Осиротевшие страницы — Sitemap + GSC + GA4 + Metrica (первые 1000)",
|
||||
"serverHeaders": "Стек сервера (заголовок Server)",
|
||||
"topWords": "Самые частые слова (title + meta + H1, первые 100)",
|
||||
"pagesOverLinkLimit": "Страницы с превышением лимита ссылок на страницу",
|
||||
@@ -392,7 +393,27 @@
|
||||
"keyPosition": "Позиция",
|
||||
"keyServer": "Сервер",
|
||||
"keyWord": "Слово",
|
||||
"includeBody": "Учитывать текст страницы (медленнее)"
|
||||
"includeBody": "Учитывать текст страницы (медленнее)",
|
||||
"orphanGscClicks_one": "{{formatted}} клик GSC",
|
||||
"orphanGscClicks_few": "{{formatted}} клика GSC",
|
||||
"orphanGscClicks_many": "{{formatted}} кликов GSC",
|
||||
"orphanGscClicks_other": "{{formatted}} клика GSC",
|
||||
"orphanGscImpressions_one": "{{formatted}} показ",
|
||||
"orphanGscImpressions_few": "{{formatted}} показа",
|
||||
"orphanGscImpressions_many": "{{formatted}} показов",
|
||||
"orphanGscImpressions_other": "{{formatted}} показа",
|
||||
"orphanGa4Sessions_one": "{{formatted}} сеанс GA4",
|
||||
"orphanGa4Sessions_few": "{{formatted}} сеанса GA4",
|
||||
"orphanGa4Sessions_many": "{{formatted}} сеансов GA4",
|
||||
"orphanGa4Sessions_other": "{{formatted}} сеанса GA4",
|
||||
"orphanMetricaVisits_one": "{{formatted}} визит Metrica (вход)",
|
||||
"orphanMetricaVisits_few": "{{formatted}} визита Metrica (вход)",
|
||||
"orphanMetricaVisits_many": "{{formatted}} визитов Metrica (вход)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} визита Metrica (вход)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} просмотр Metrica (хиты)",
|
||||
"orphanMetricaPageviews_few": "{{formatted}} просмотра Metrica (хиты)",
|
||||
"orphanMetricaPageviews_many": "{{formatted}} просмотров Metrica (хиты)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} просмотра Metrica (хиты)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Валидатор sitemap",
|
||||
@@ -1546,6 +1567,77 @@
|
||||
"emptyNoMatch": "Под текущий фильтр не подходит ни одна страница.",
|
||||
"accountTitle": "По какому подключённому аккаунту Google этот проект строит отчёты. У каждого аккаунта свои сохранённые данные."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "только что",
|
||||
"agoMinutes": "{{count}} мин назад",
|
||||
"agoHours": "{{count}} ч назад",
|
||||
"agoDays": "{{count}} дн. назад",
|
||||
"cancelled": "Загрузка отменена. Ранее сохранённые данные не изменились.",
|
||||
"reportVisits": "визиты",
|
||||
"reportSearchVisits": "переходы из поисковых систем",
|
||||
"reportPageviews": "просмотры",
|
||||
"noticeTruncated": "Последняя загрузка из Yandex Metrica упёрлась в лимит строк, поэтому у страниц, которых в ней нет, всё равно может быть трафик. Фильтр недоступен до полной загрузки.",
|
||||
"noticeSampled": "Последняя загрузка из Yandex Metrica вернула сэмплированные данные, поэтому у страниц, которых в ней нет, всё равно может быть трафик. Фильтр недоступен до загрузки без сэмплирования.",
|
||||
"noticeNoPull": "Для этого счётчика данные Yandex Metrica ещё не загружались. Сначала выполните загрузку: без полной загрузки отсутствие строки у страницы нельзя считать нулевым трафиком.",
|
||||
"loading": "Загрузка…",
|
||||
"unconfiguredTitle": "Yandex Metrica не настроена",
|
||||
"unconfiguredBody": "Откройте Настройки → Интеграции → Yandex Metrica и вставьте токен с правом metrika:read. Как его получить, пошагово описано в руководстве по настройке там же. Затем вернитесь сюда и выберите счётчик.",
|
||||
"sessionHint": "На уровне визитов: учитывается для страницы, с которой начался визит (страница входа), а не для каждой просмотренной страницы.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "Фильтр по URL…",
|
||||
"filterAll": "Все страницы 2xx",
|
||||
"filterWith": "С данными Metrica",
|
||||
"filterWithout": "Без данных Metrica",
|
||||
"filterIndexableNoVisits": "Индексируемые, без визитов",
|
||||
"filterNon200": "Не 2xx, но с трафиком",
|
||||
"countersLoading": "Загрузка счётчиков…",
|
||||
"noCounters": "Счётчиков нет",
|
||||
"daysTitle": "Полные дни по вчерашний включительно, в часовом поясе счётчика.",
|
||||
"days7": "Последние 7 дней",
|
||||
"days28": "Последние 28 дней",
|
||||
"days90": "Последние 90 дней",
|
||||
"cancel": "Отмена",
|
||||
"fetch": "Загрузить",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} страница · загружено {{ago}}",
|
||||
"metaLine_few": "{{counter}} · {{start}} → {{end}} · {{formatted}} страницы · загружено {{ago}}",
|
||||
"metaLine_many": "{{counter}} · {{start}} → {{end}} · {{formatted}} страниц · загружено {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} страницы · загружено {{ago}}",
|
||||
"totalsTitle": "Итоги по всему сайту за период по данным Metrica — для сверки с интерфейсом Metrica.",
|
||||
"totalsLine": "Итого по сайту — просмотры: {{pageviews}} · визиты: {{visits}} · переходы из поисковых систем: {{searchVisits}}",
|
||||
"truncatedFlag": "Достигнут лимит строк: страницы с низким трафиком отсутствуют",
|
||||
"sampledFlag": "Сэмплированные данные",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Статус",
|
||||
"colPageviewsHint": "На уровне хитов: каждый просмотр URL этой страницы.",
|
||||
"colPageviews": "Просмотры (хиты)",
|
||||
"colVisits": "Визиты (вход)",
|
||||
"colUsersHint": "На уровне визитов, по странице входа. Оценка сверху: посетитель, входивший на сайт через несколько страниц, учитывается по разу для каждой из них.",
|
||||
"colUsers": "Посетители (вход)",
|
||||
"colSearchVisitsHint": "На уровне визитов: визиты из поисковых систем, начавшиеся на этой странице.",
|
||||
"colSearchVisits": "Переходы из поиска (вход)",
|
||||
"colFetched": "Загружено",
|
||||
"emptyNoData": "Внутренних HTML-страниц пока не обойдено — выполните обход, затем загрузите данные Metrica.",
|
||||
"emptyNoMatch": "Под текущий фильтр не подходит ни одна страница.",
|
||||
"summary_one": "{{shown}} из {{formatted}} страницы",
|
||||
"summary_few": "{{shown}} из {{formatted}} страниц",
|
||||
"summary_many": "{{shown}} из {{formatted}} страниц",
|
||||
"summary_other": "{{shown}} из {{formatted}} страницы",
|
||||
"progress_one": "Загрузка отчёта «{{report}}» ({{index}}/{{total}}) · {{formatted}} строка",
|
||||
"progress_few": "Загрузка отчёта «{{report}}» ({{index}}/{{total}}) · {{formatted}} строки",
|
||||
"progress_many": "Загрузка отчёта «{{report}}» ({{index}}/{{total}}) · {{formatted}} строк",
|
||||
"progress_other": "Загрузка отчёта «{{report}}» ({{index}}/{{total}}) · {{formatted}} строки",
|
||||
"errNotConnected": "Yandex Metrica не подключена — вставьте токен в Настройки → Интеграции → Yandex Metrica.",
|
||||
"errTokenFormat": "У сохранённого токена Yandex Metrica неожиданный формат — вставьте его заново в Настройки → Интеграции → Yandex Metrica.",
|
||||
"errTokenRejected": "Yandex Metrica отклонила токен (HTTP 401) — вставьте новый токен с правом metrika:read в Настройки → Интеграции → Yandex Metrica.",
|
||||
"errAccessDenied": "Yandex Metrica отказала в доступе (HTTP 403) — у аккаунта, которому принадлежит токен, нет доступа к этому счётчику.",
|
||||
"errNoCounter": "Счётчик Yandex Metrica не выбран.",
|
||||
"errBadRange": "Неподдерживаемый период.",
|
||||
"errAlreadyRunning": "В этом окне уже идёт загрузка из Yandex Metrica.",
|
||||
"errProjectSwitched": "Во время загрузки был открыт другой проект, поэтому результат отброшен. Загрузите данные заново в этом проекте.",
|
||||
"errUnexpectedResponse": "Yandex Metrica вернула неожиданный ответ. Ранее сохранённые данные не изменились.",
|
||||
"errRangeChanged": "Yandex Metrica изменила период отчёта во время загрузки. Ранее сохранённые данные не изменились — загрузите заново.",
|
||||
"errNetwork": "Не удалось связаться с Yandex Metrica. Проверьте подключение и повторите попытку."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "ИИ",
|
||||
"filterPlaceholder": "Фильтр по URL…",
|
||||
@@ -1598,7 +1690,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Загрузка…",
|
||||
"empty": "Для этого URL пока нет данных Search Console / Analytics 4 / проверки URL. Загрузите их на вкладках GA4 или Search Console.",
|
||||
"empty": "Для этого URL пока нет данных Search Console / Analytics 4 / Yandex Metrica / проверки URL. Загрузите их на вкладках GA4, Yandex Metrica или Search Console.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Данных о кликах и показах пока нет.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1607,7 +1699,13 @@
|
||||
"inspectionEmpty": "Результата проверки URL пока нет — запустите её на вкладке Search Console.",
|
||||
"colSource": "Источник",
|
||||
"colMetric": "Метрика",
|
||||
"colValue": "Значение"
|
||||
"colValue": "Значение",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Данных Yandex Metrica пока нет — загрузите их на вкладке Yandex Metrica.",
|
||||
"metricaPageviewsHint": "На уровне хитов: каждый просмотр URL этой страницы.",
|
||||
"metricaVisitsHint": "На уровне визитов: визиты, начавшиеся на этой странице.",
|
||||
"metricaUsersHint": "На уровне визитов, по странице входа. Оценка сверху, если в эту страницу сводится несколько вариантов URL.",
|
||||
"metricaSearchVisitsHint": "На уровне визитов: визиты из поисковых систем, начавшиеся на этой странице."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Поиск по битым целевым URL…",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "AI",
|
||||
"seo": "SEO Authority",
|
||||
"visualization": "Görselleştirme",
|
||||
@@ -355,7 +356,7 @@
|
||||
"urlLengthHistogram": "URL Uzunluğu Histogramı",
|
||||
"wordCountPerDir": "Dizin Başına Kelime Sayısı",
|
||||
"sitemapOrphans": "Sitemap Yetimleri (Top 1000)",
|
||||
"orphanCrossSource": "Yetim Sayfalar — Sitemap + GSC + GA4 (Top 1000)",
|
||||
"orphanCrossSource": "Yetim Sayfalar — Sitemap + GSC + GA4 + Metrica (Top 1000)",
|
||||
"serverHeaders": "Sunucu Stack (Server Header)",
|
||||
"topWords": "En Çok Kelime (Başlık + Meta + H1, Top 100)",
|
||||
"pagesOverLinkLimit": "Sayfa Başına Maks. Link Sınırını Aşan Sayfalar",
|
||||
@@ -378,7 +379,17 @@
|
||||
"keyPosition": "Konum",
|
||||
"keyServer": "Sunucu",
|
||||
"keyWord": "Kelime",
|
||||
"includeBody": "Gövde metnini dahil et (daha yavaş)"
|
||||
"includeBody": "Gövde metnini dahil et (daha yavaş)",
|
||||
"orphanGscClicks_one": "{{formatted}} GSC tıklaması",
|
||||
"orphanGscClicks_other": "{{formatted}} GSC tıklaması",
|
||||
"orphanGscImpressions_one": "{{formatted}} gösterim",
|
||||
"orphanGscImpressions_other": "{{formatted}} gösterim",
|
||||
"orphanGa4Sessions_one": "{{formatted}} GA4 oturumu",
|
||||
"orphanGa4Sessions_other": "{{formatted}} GA4 oturumu",
|
||||
"orphanMetricaVisits_one": "{{formatted}} Metrica ziyareti (giriş)",
|
||||
"orphanMetricaVisits_other": "{{formatted}} Metrica ziyareti (giriş)",
|
||||
"orphanMetricaPageviews_one": "{{formatted}} Metrica görüntülemesi (hit)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} Metrica görüntülemesi (hit)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "Sitemap Doğrulayıcı",
|
||||
@@ -1504,6 +1515,71 @@
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok.",
|
||||
"accountTitle": "Bu projenin hangi bağlı Google hesabını raporlayacağı. Her hesap kendi verisini ayrı saklar."
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "az önce",
|
||||
"agoMinutes": "{{count}} dk önce",
|
||||
"agoHours": "{{count}} sa önce",
|
||||
"agoDays": "{{count}} gün önce",
|
||||
"cancelled": "Getirme iptal edildi. Daha önce kaydedilen veriler değişmedi.",
|
||||
"reportVisits": "ziyaret",
|
||||
"reportSearchVisits": "arama ziyareti",
|
||||
"reportPageviews": "sayfa görüntüleme",
|
||||
"noticeTruncated": "Son Yandex Metrica getirmesi satır sınırına takıldı; listede olmayan sayfalar yine de trafik alıyor olabilir. Tam bir getirme yapılana kadar bu filtre kullanılamaz.",
|
||||
"noticeSampled": "Son Yandex Metrica getirmesi örneklenmiş veriyle döndü; listede olmayan sayfalar yine de trafik alıyor olabilir. Örneklenmemiş bir getirme yapılana kadar bu filtre kullanılamaz.",
|
||||
"noticeNoPull": "Bu sayaç için henüz Yandex Metrica verisi getirilmedi. Önce getir: tam bir getirme olmadan, satırı olmayan bir sayfa sıfır trafik olarak okunamaz.",
|
||||
"loading": "Yükleniyor…",
|
||||
"unconfiguredTitle": "Yandex Metrica kurulu değil",
|
||||
"unconfiguredBody": "Ayarlar → Entegrasyonlar → Yandex Metrica'yı açıp metrika:read izni olan bir token yapıştır. Oradaki kurulum kılavuzu token'ı nasıl alacağını adım adım anlatıyor. Sonra buraya dönüp bir sayaç seç.",
|
||||
"sessionHint": "Oturum düzeyi: görüntülenen her sayfa için değil, oturumun başladığı sayfa (giriş sayfası) için sayılır.",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
"filterAll": "Tüm 2xx sayfalar",
|
||||
"filterWith": "Metrica verisi olan",
|
||||
"filterWithout": "Metrica verisi olmayan",
|
||||
"filterIndexableNoVisits": "İndekslenebilir, ziyaretsiz",
|
||||
"filterNon200": "Trafik alan 2xx dışı",
|
||||
"countersLoading": "Sayaçlar yükleniyor…",
|
||||
"noCounters": "Sayaç yok",
|
||||
"daysTitle": "Dünle biten tam günler, sayacın saat diliminde.",
|
||||
"days7": "Son 7 gün",
|
||||
"days28": "Son 28 gün",
|
||||
"days90": "Son 90 gün",
|
||||
"cancel": "İptal",
|
||||
"fetch": "Getir",
|
||||
"metaLine_one": "{{counter}} · {{start}} → {{end}} · {{formatted}} sayfa · getirildi {{ago}}",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} sayfa · getirildi {{ago}}",
|
||||
"totalsTitle": "Metrica'nın bu aralık için bildirdiği site geneli toplamlar; Metrica arayüzüyle karşılaştırmak için.",
|
||||
"totalsLine": "Site toplamı: {{pageviews}} sayfa görüntüleme · {{visits}} ziyaret · {{searchVisits}} arama ziyareti",
|
||||
"truncatedFlag": "Satır sınırına ulaşıldı: düşük trafikli sayfalar eksik",
|
||||
"sampledFlag": "Örneklenmiş veri",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "Durum",
|
||||
"colPageviewsHint": "Hit düzeyi: bu sayfa URL'sinin her görüntülenmesi.",
|
||||
"colPageviews": "Görüntüleme (hit)",
|
||||
"colVisits": "Ziyaret (giriş)",
|
||||
"colUsersHint": "Oturum düzeyi, giriş sayfasına göre. Üst sınırdır: birkaç sayfaya giriş yapan bir kişi her sayfa için bir kez sayılır.",
|
||||
"colUsers": "Kullanıcı (giriş)",
|
||||
"colSearchVisitsHint": "Oturum düzeyi: arama motorlarından gelip bu sayfada başlayan oturumlar.",
|
||||
"colSearchVisits": "Arama ziyareti (giriş)",
|
||||
"colFetched": "Getirilme",
|
||||
"emptyNoData": "Henüz taranmış iç HTML sayfası yok — önce bir tarama çalıştır, sonra Metrica verisini getir.",
|
||||
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok.",
|
||||
"summary_one": "{{shown}} / {{formatted}} sayfa",
|
||||
"summary_other": "{{shown}} / {{formatted}} sayfa",
|
||||
"progress_one": "{{report}} raporu getiriliyor ({{index}}/{{total}}) · {{formatted}} satır",
|
||||
"progress_other": "{{report}} raporu getiriliyor ({{index}}/{{total}}) · {{formatted}} satır",
|
||||
"errNotConnected": "Yandex Metrica bağlı değil — Ayarlar → Entegrasyonlar → Yandex Metrica'ya bir token yapıştır.",
|
||||
"errTokenFormat": "Kayıtlı Yandex Metrica token'ı beklenmeyen biçimde — Ayarlar → Entegrasyonlar → Yandex Metrica'da yeniden yapıştır.",
|
||||
"errTokenRejected": "Yandex Metrica token'ı reddetti (HTTP 401) — Ayarlar → Entegrasyonlar → Yandex Metrica'ya metrika:read izni olan yeni bir token yapıştır.",
|
||||
"errAccessDenied": "Yandex Metrica erişimi reddetti (HTTP 403) — token'ın ait olduğu hesap bu sayacı okuyamıyor.",
|
||||
"errNoCounter": "Yandex Metrica sayacı seçilmedi.",
|
||||
"errBadRange": "Desteklenmeyen tarih aralığı.",
|
||||
"errAlreadyRunning": "Bu pencerede zaten bir Yandex Metrica getirmesi çalışıyor.",
|
||||
"errProjectSwitched": "Getirme sırasında başka bir proje açıldı, bu yüzden sonuç atıldı. Bu projede yeniden getir.",
|
||||
"errUnexpectedResponse": "Yandex Metrica beklenmeyen bir yanıt döndürdü. Daha önce kaydedilen veriler değişmedi.",
|
||||
"errRangeChanged": "Yandex Metrica getirme sırasında rapor tarih aralığını değiştirdi. Daha önce kaydedilen veriler değişmedi — yeniden getir.",
|
||||
"errNetwork": "Yandex Metrica'ya ulaşılamadı. Bağlantını kontrol edip yeniden dene."
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "URL'ye göre filtrele…",
|
||||
@@ -1556,7 +1632,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "Yükleniyor…",
|
||||
"empty": "Bu URL için henüz Search Console / Analytics 4 / URL Inspection verisi yok. GA4 / Search Console sekmelerinden getir.",
|
||||
"empty": "Bu URL için henüz Search Console / Analytics 4 / Yandex Metrica / URL Inspection verisi yok. GA4 / Yandex Metrica / Search Console sekmelerinden getir.",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "Henüz tıklama / gösterim verisi yok.",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1565,7 +1641,13 @@
|
||||
"inspectionEmpty": "URL Inspection sonucu yok — Search Console sekmesinden çalıştır.",
|
||||
"colSource": "Kaynak",
|
||||
"colMetric": "Metrik",
|
||||
"colValue": "Değer"
|
||||
"colValue": "Değer",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "Henüz Yandex Metrica verisi yok — Yandex Metrica sekmesinden getir.",
|
||||
"metricaPageviewsHint": "Hit düzeyi: bu sayfa URL'sinin her görüntülenmesi.",
|
||||
"metricaVisitsHint": "Oturum düzeyi: bu sayfada başlayan oturumlar.",
|
||||
"metricaUsersHint": "Oturum düzeyi, giriş sayfasına göre. Birkaç URL varyantı bu sayfada birleştiğinde üst sınırdır.",
|
||||
"metricaSearchVisitsHint": "Oturum düzeyi: arama motorlarından gelip bu sayfada başlayan oturumlar."
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "Kırık hedef URL ara…",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"pagespeed": "PageSpeed",
|
||||
"searchConsole": "Search Console",
|
||||
"analytics": "GA4",
|
||||
"metrica": "Yandex Metrica",
|
||||
"ai": "AI",
|
||||
"seo": "SEO 权重",
|
||||
"visualization": "可视化",
|
||||
@@ -348,7 +349,7 @@
|
||||
"urlLengthHistogram": "URL 长度直方图",
|
||||
"wordCountPerDir": "各目录字数",
|
||||
"sitemapOrphans": "站点地图孤立页面(前 1000)",
|
||||
"orphanCrossSource": "孤立页面——站点地图 + GSC + GA4(前 1000)",
|
||||
"orphanCrossSource": "孤立页面——站点地图 + GSC + GA4 + Metrica(前 1000)",
|
||||
"serverHeaders": "服务器技术栈(Server 响应头)",
|
||||
"topWords": "高频词(标题 + meta + H1,前 100)",
|
||||
"pagesOverLinkLimit": "超过每页最大链接数的页面",
|
||||
@@ -371,7 +372,12 @@
|
||||
"keyPosition": "位置",
|
||||
"keyServer": "服务器",
|
||||
"keyWord": "词",
|
||||
"includeBody": "包含正文文本(较慢)"
|
||||
"includeBody": "包含正文文本(较慢)",
|
||||
"orphanGscClicks_other": "{{formatted}} 次 GSC 点击",
|
||||
"orphanGscImpressions_other": "{{formatted}} 次展示",
|
||||
"orphanGa4Sessions_other": "{{formatted}} 次 GA4 会话",
|
||||
"orphanMetricaVisits_other": "{{formatted}} 次 Metrica 访问(着陆页)",
|
||||
"orphanMetricaPageviews_other": "{{formatted}} 次 Metrica 页面浏览(命中)"
|
||||
},
|
||||
"sitemap": {
|
||||
"title": "站点地图校验器",
|
||||
@@ -1483,6 +1489,68 @@
|
||||
"emptyNoMatch": "没有页面符合当前筛选条件。",
|
||||
"accountTitle": "此项目基于哪个已关联的 Google 账号出报告。每个账号各自保存其数据。"
|
||||
},
|
||||
"metricaTab": {
|
||||
"agoNow": "刚刚",
|
||||
"agoMinutes": "{{count}} 分钟前",
|
||||
"agoHours": "{{count}} 小时前",
|
||||
"agoDays": "{{count}} 天前",
|
||||
"cancelled": "已取消获取。之前保存的数据保持不变。",
|
||||
"reportVisits": "访问次数",
|
||||
"reportSearchVisits": "搜索访问次数",
|
||||
"reportPageviews": "页面浏览量",
|
||||
"noticeTruncated": "上一次 Yandex Metrica 获取达到了行数上限,因此结果中缺失的页面仍可能有流量。在完成一次完整获取之前,此筛选不可用。",
|
||||
"noticeSampled": "上一次 Yandex Metrica 获取返回的是抽样数据,因此结果中缺失的页面仍可能有流量。在完成一次未抽样的获取之前,此筛选不可用。",
|
||||
"noticeNoPull": "尚未为此计数器获取任何 Yandex Metrica 数据。请先获取:没有完整的获取结果,就不能把没有数据行的页面视为零流量。",
|
||||
"loading": "加载中…",
|
||||
"unconfiguredTitle": "尚未设置 Yandex Metrica",
|
||||
"unconfiguredBody": "打开“设置 → 集成 → Yandex Metrica”,粘贴一个具有 metrika:read 权限的令牌。那里的设置指南会逐步说明如何获取令牌。然后回到这里选择计数器。",
|
||||
"sessionHint": "会话级:按会话开始时所在的页面(着陆页)计数,而不是按浏览过的每个页面计数。",
|
||||
"title": "Yandex Metrica",
|
||||
"filterPlaceholder": "按 URL 筛选…",
|
||||
"filterAll": "所有 2xx 页面",
|
||||
"filterWith": "有 Metrica 数据",
|
||||
"filterWithout": "无 Metrica 数据",
|
||||
"filterIndexableNoVisits": "可索引但无访问",
|
||||
"filterNon200": "非 2xx 但有流量",
|
||||
"countersLoading": "正在加载计数器…",
|
||||
"noCounters": "没有计数器",
|
||||
"daysTitle": "截至昨天的完整天数,按计数器所在时区计算。",
|
||||
"days7": "最近 7 天",
|
||||
"days28": "最近 28 天",
|
||||
"days90": "最近 90 天",
|
||||
"cancel": "取消",
|
||||
"fetch": "获取",
|
||||
"metaLine_other": "{{counter}} · {{start}} → {{end}} · {{formatted}} 个页面 · 获取于 {{ago}}",
|
||||
"totalsTitle": "Metrica 报告的该时间范围内全站总计,便于与 Metrica 界面对照。",
|
||||
"totalsLine": "全站总计:页面浏览量 {{pageviews}} · 访问次数 {{visits}} · 搜索访问次数 {{searchVisits}}",
|
||||
"truncatedFlag": "已达行数上限:缺少低流量页面",
|
||||
"sampledFlag": "抽样数据",
|
||||
"colUrl": "URL",
|
||||
"colStatus": "状态",
|
||||
"colPageviewsHint": "命中级:此页面 URL 的每一次浏览。",
|
||||
"colPageviews": "页面浏览量(命中)",
|
||||
"colVisits": "访问次数(着陆页)",
|
||||
"colUsersHint": "会话级,按着陆页统计。这是一个上限值:同一个人从多个页面进入时,每个页面各计一次。",
|
||||
"colUsers": "用户数(着陆页)",
|
||||
"colSearchVisitsHint": "会话级:来自搜索引擎、并以此页面开始的会话。",
|
||||
"colSearchVisits": "搜索访问次数(着陆页)",
|
||||
"colFetched": "已获取",
|
||||
"emptyNoData": "尚未抓取任何内部 HTML 页面——先运行一次抓取,然后获取 Metrica 数据。",
|
||||
"emptyNoMatch": "没有页面符合当前筛选条件。",
|
||||
"summary_other": "{{formatted}} 个页面中的 {{shown}} 个",
|
||||
"progress_other": "正在获取{{report}}报告({{index}}/{{total}})· {{formatted}} 行",
|
||||
"errNotConnected": "Yandex Metrica 尚未连接——请在“设置 → 集成 → Yandex Metrica”中粘贴令牌。",
|
||||
"errTokenFormat": "已保存的 Yandex Metrica 令牌格式异常——请在“设置 → 集成 → Yandex Metrica”中重新粘贴。",
|
||||
"errTokenRejected": "Yandex Metrica 拒绝了该令牌 (HTTP 401)——请在“设置 → 集成 → Yandex Metrica”中粘贴一个具有 metrika:read 权限的新令牌。",
|
||||
"errAccessDenied": "Yandex Metrica 拒绝访问 (HTTP 403)——令牌所属的账号无法读取此计数器。",
|
||||
"errNoCounter": "未选择 Yandex Metrica 计数器。",
|
||||
"errBadRange": "不支持的日期范围。",
|
||||
"errAlreadyRunning": "此窗口中已有一个 Yandex Metrica 获取任务正在运行。",
|
||||
"errProjectSwitched": "获取过程中打开了另一个项目,因此结果已被丢弃。请在此项目中重新获取。",
|
||||
"errUnexpectedResponse": "Yandex Metrica 返回了意外的响应。之前保存的数据保持不变。",
|
||||
"errRangeChanged": "Yandex Metrica 在获取过程中更改了报告日期范围。之前保存的数据保持不变——请重新获取。",
|
||||
"errNetwork": "无法连接到 Yandex Metrica。请检查网络连接后重试。"
|
||||
},
|
||||
"aiTab": {
|
||||
"title": "AI",
|
||||
"filterPlaceholder": "按 URL 筛选…",
|
||||
@@ -1535,7 +1603,7 @@
|
||||
},
|
||||
"analyticsView": {
|
||||
"loading": "加载中…",
|
||||
"empty": "此 URL 暂无 Search Console / Analytics 4 / 网址检查数据。请在 GA4 或 Search Console 标签页中获取。",
|
||||
"empty": "此 URL 暂无 Search Console / Analytics 4 / Yandex Metrica / 网址检查数据。请在 GA4 / Yandex Metrica / Search Console 标签页中获取。",
|
||||
"gscTitle": "Search Console",
|
||||
"gscEmpty": "暂无点击/展示数据。",
|
||||
"ga4Title": "Analytics 4",
|
||||
@@ -1544,7 +1612,13 @@
|
||||
"inspectionEmpty": "暂无网址检查结果——请在 Search Console 标签页中运行。",
|
||||
"colSource": "来源",
|
||||
"colMetric": "指标",
|
||||
"colValue": "值"
|
||||
"colValue": "值",
|
||||
"metricaTitle": "Yandex Metrica",
|
||||
"metricaEmpty": "暂无 Yandex Metrica 数据——请在 Yandex Metrica 标签页中获取。",
|
||||
"metricaPageviewsHint": "命中级:此页面 URL 的每一次浏览。",
|
||||
"metricaVisitsHint": "会话级:以此页面开始的会话。",
|
||||
"metricaUsersHint": "会话级,按着陆页统计。当多个 URL 变体合并到此页面时,这是一个上限值。",
|
||||
"metricaSearchVisitsHint": "会话级:来自搜索引擎、并以此页面开始的会话。"
|
||||
},
|
||||
"brokenTab": {
|
||||
"searchPlaceholder": "搜索失效的目标 URL…",
|
||||
|
||||
@@ -59,6 +59,7 @@ export type TabKey =
|
||||
| 'spelling'
|
||||
| 'search-console'
|
||||
| 'analytics'
|
||||
| 'metrica'
|
||||
| 'ai'
|
||||
| 'seo'
|
||||
| 'assistant';
|
||||
@@ -94,6 +95,7 @@ export const TAB_ORDER: { key: TabKey; label: string }[] = [
|
||||
{ key: 'spelling', label: 'Spelling' },
|
||||
{ key: 'search-console', label: 'Search Console' },
|
||||
{ key: 'analytics', label: 'GA4' },
|
||||
{ key: 'metrica', label: 'Yandex Metrica' },
|
||||
{ key: 'ai', label: 'AI' },
|
||||
{ key: 'seo', label: 'SEO Authority' },
|
||||
{ key: 'assistant', label: 'Assistant' },
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TabExportButton } from '../components/TabExportButton.js';
|
||||
import { Loader2, Search, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
defaultMetricaSettings,
|
||||
type MetricaCounter,
|
||||
type MetricaFetchMeta,
|
||||
type MetricaNotice,
|
||||
type MetricaErrorCode,
|
||||
type MetricaProgress,
|
||||
type MetricaQueryInput,
|
||||
type MetricaRow,
|
||||
} from '@freecrawl/shared-types';
|
||||
import { useAppStore } from '../store.js';
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 26;
|
||||
const STATUS_BAR_HEIGHT = 22;
|
||||
const ROW_HEIGHT = 30;
|
||||
/** `queryMetrica` returns at most 1000 rows per call. */
|
||||
const PAGE_SIZE = 1000;
|
||||
const POLL_MS_RUNNING = 5000;
|
||||
|
||||
type FilterMode = NonNullable<MetricaQueryInput['filter']>;
|
||||
type RangeDays = 7 | 28 | 90;
|
||||
type Stage = 'loading' | 'unconfigured' | 'ready' | 'error';
|
||||
|
||||
// The latest progress event, kept outside the component so a pull that
|
||||
// is still running when the user leaves the tab and comes back (or one an
|
||||
// MCP agent started) shows up without waiting for the next page.
|
||||
let lastProgress: MetricaProgress | null = null;
|
||||
const progressListeners = new Set<(p: MetricaProgress | null) => void>();
|
||||
let progressSubscribed = false;
|
||||
function subscribeProgress(cb: (p: MetricaProgress | null) => void): () => void {
|
||||
if (!progressSubscribed) {
|
||||
progressSubscribed = true;
|
||||
window.freecrawl.onMetricaProgress((p) => {
|
||||
lastProgress = p.running ? p : null;
|
||||
for (const l of progressListeners) l(lastProgress);
|
||||
});
|
||||
}
|
||||
progressListeners.add(cb);
|
||||
return () => {
|
||||
progressListeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
type T = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function relativeTime(iso: string, t: T): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const min = Math.floor((Date.now() - then) / 60_000);
|
||||
if (min < 1) return t('metricaTab.agoNow', { defaultValue: 'just now' });
|
||||
if (min < 60) return t('metricaTab.agoMinutes', { defaultValue: '{{count}}m ago', count: min });
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('metricaTab.agoHours', { defaultValue: '{{count}}h ago', count: hr });
|
||||
return t('metricaTab.agoDays', {
|
||||
defaultValue: '{{count}}d ago',
|
||||
count: Math.floor(hr / 24),
|
||||
});
|
||||
}
|
||||
|
||||
function persistCounter(c: MetricaCounter): void {
|
||||
void window.freecrawl.integrationSettingsSet('metrica', {
|
||||
counterId: c.counterId,
|
||||
counterName: c.name || c.counterId,
|
||||
});
|
||||
}
|
||||
|
||||
export function MetricaTab() {
|
||||
const { t } = useTranslation();
|
||||
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 [stage, setStage] = useState<Stage>('loading');
|
||||
const [counters, setCounters] = useState<MetricaCounter[]>([]);
|
||||
const [countersLoading, setCountersLoading] = useState(false);
|
||||
const [counterId, setCounterId] = useState('');
|
||||
const [days, setDays] = useState<RangeDays>(28);
|
||||
const [rows, setRows] = useState<MetricaRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [meta, setMeta] = useState<MetricaFetchMeta | null>(null);
|
||||
const [notice, setNotice] = useState<MetricaNotice | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterMode>('all');
|
||||
const [progress, setProgress] = useState<MetricaProgress | null>(lastProgress);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Set for Metrica failures: the banner words the code in the UI language
|
||||
// and falls back to the main process's English text (Metrica's own
|
||||
// message, or a failure without a code).
|
||||
const [errorCode, setErrorCode] = useState<MetricaErrorCode | null>(null);
|
||||
const showError = useCallback((text: string | null, code: MetricaErrorCode | null = null) => {
|
||||
setError(text);
|
||||
setErrorCode(code);
|
||||
}, []);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fetching = progress !== null;
|
||||
|
||||
const loadCounters = useCallback(async () => {
|
||||
setCountersLoading(true);
|
||||
try {
|
||||
const res = await window.freecrawl.metricaListCounters();
|
||||
if (!res.ok) {
|
||||
showError(res.error, res.errorCode);
|
||||
setCounters([]);
|
||||
return;
|
||||
}
|
||||
showError(null);
|
||||
setCounters(res.counters);
|
||||
// Persisting from a state updater would run it twice in StrictMode;
|
||||
// the stored ID is read from settings instead.
|
||||
const stored = await window.freecrawl.integrationSettingsGet('metrica');
|
||||
const prev = typeof stored?.['counterId'] === 'string' ? stored['counterId'] : '';
|
||||
if (prev && res.counters.some((c) => c.counterId === prev)) {
|
||||
setCounterId(prev);
|
||||
} else {
|
||||
const fallback = res.counters[0];
|
||||
if (fallback) persistCounter(fallback);
|
||||
setCounterId(fallback?.counterId ?? '');
|
||||
}
|
||||
} finally {
|
||||
setCountersLoading(false);
|
||||
}
|
||||
}, [showError]);
|
||||
|
||||
/** Switch the project to another counter: persist the choice so the
|
||||
* export, the orphan report and per-URL analytics read the same counter
|
||||
* the table shows, then reload the table (the effect below). */
|
||||
const changeCounter = useCallback(
|
||||
(next: string) => {
|
||||
setCounterId(next);
|
||||
const selected = counters.find((c) => c.counterId === next);
|
||||
if (selected) persistCounter(selected);
|
||||
},
|
||||
[counters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [integrations, stored] = await Promise.all([
|
||||
window.freecrawl.integrationsGetAll(),
|
||||
window.freecrawl.integrationSettingsGet('metrica'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const s = { ...defaultMetricaSettings(), ...(stored ?? {}) };
|
||||
setDays(s.days);
|
||||
if (s.counterId) setCounterId(s.counterId);
|
||||
if (!(integrations['metrica']?.configured ?? false)) {
|
||||
setStage('unconfigured');
|
||||
return;
|
||||
}
|
||||
setStage('ready');
|
||||
void loadCounters();
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
showError(e instanceof Error ? e.message : String(e));
|
||||
setStage('error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadCounters, retryNonce, showError]);
|
||||
|
||||
// Queries run on a reader pool and can finish out of order; only the
|
||||
// latest one may fill the table.
|
||||
const reloadSeq = useRef(0);
|
||||
const reload = useCallback(async () => {
|
||||
const seq = ++reloadSeq.current;
|
||||
const res = await window.freecrawl.metricaQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
search: search || undefined,
|
||||
filter,
|
||||
counterId: counterId || undefined,
|
||||
});
|
||||
if (seq !== reloadSeq.current) return;
|
||||
setRows(res.rows);
|
||||
setTotal(res.total);
|
||||
setMeta(res.meta);
|
||||
setNotice(res.notice);
|
||||
}, [search, filter, counterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'ready') return;
|
||||
let cancelled = false;
|
||||
void reload();
|
||||
if (!crawlProgress?.running) return;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) void reload();
|
||||
}, POLL_MS_RUNNING);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [stage, reload, dataVersion, crawlProgress?.running]);
|
||||
|
||||
// A pull started elsewhere (an MCP client) stores its counter, and opening
|
||||
// another project brings that project's counter; both fire data-changed.
|
||||
// Follow the stored counter so the table shows what the export reads.
|
||||
const counterRef = useRef({ counterId, counters });
|
||||
counterRef.current = { counterId, counters };
|
||||
const seenDataVersion = useRef(dataVersion);
|
||||
useEffect(() => {
|
||||
if (stage !== 'ready' || seenDataVersion.current === dataVersion) return;
|
||||
seenDataVersion.current = dataVersion;
|
||||
let cancelled = false;
|
||||
const sentFor = counterRef.current.counterId;
|
||||
void window.freecrawl.integrationSettingsGet('metrica').then((stored) => {
|
||||
// A pick made while the read was in flight is newer than what it read.
|
||||
if (cancelled || counterRef.current.counterId !== sentFor) return;
|
||||
const storedId = typeof stored?.['counterId'] === 'string' ? stored['counterId'] : '';
|
||||
const { counterId: shown, counters: list } = counterRef.current;
|
||||
if (storedId) {
|
||||
if (storedId !== shown) setCounterId(storedId);
|
||||
return;
|
||||
}
|
||||
// A project with no stored counter: store the one on screen.
|
||||
const selected = list.find((c) => c.counterId === shown);
|
||||
if (selected) persistCounter(selected);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [stage, dataVersion]);
|
||||
|
||||
useEffect(() => subscribeProgress(setProgress), []);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 16,
|
||||
getItemKey: (index) => rows[index]?.url ?? `idx-${index}`,
|
||||
});
|
||||
|
||||
const runFetch = useCallback(async () => {
|
||||
if (!counterId || fetching) return;
|
||||
const selected = counters.find((c) => c.counterId === counterId);
|
||||
showError(null);
|
||||
setInfo(null);
|
||||
setProgress({ running: true, report: 'pageviews', reportIndex: 1, reportCount: 3, rows: 0 });
|
||||
try {
|
||||
const res = await window.freecrawl.metricaFetch({
|
||||
counterId,
|
||||
counterName: selected?.name || counterId,
|
||||
days,
|
||||
});
|
||||
if (res.cancelled) {
|
||||
setInfo(
|
||||
t('metricaTab.cancelled', {
|
||||
defaultValue: 'Fetch cancelled. The previously stored data is unchanged.',
|
||||
}),
|
||||
);
|
||||
} else if (!res.ok) {
|
||||
showError(res.error, res.errorCode);
|
||||
} else {
|
||||
setMeta(res.meta);
|
||||
}
|
||||
} finally {
|
||||
setProgress(lastProgress);
|
||||
await reload();
|
||||
}
|
||||
}, [counterId, counters, days, fetching, reload, showError, t]);
|
||||
|
||||
const cancelFetch = useCallback(() => {
|
||||
void window.freecrawl.metricaCancel();
|
||||
}, []);
|
||||
|
||||
const reportLabel = (report: MetricaProgress['report']): string =>
|
||||
report === 'visits'
|
||||
? t('metricaTab.reportVisits', { defaultValue: 'visits' })
|
||||
: report === 'searchVisits'
|
||||
? t('metricaTab.reportSearchVisits', { defaultValue: 'search visits' })
|
||||
: t('metricaTab.reportPageviews', { defaultValue: 'pageviews' });
|
||||
|
||||
const errorText = (code: MetricaErrorCode | null): string | null => {
|
||||
switch (code) {
|
||||
case 'not-connected':
|
||||
return t('metricaTab.errNotConnected', {
|
||||
defaultValue:
|
||||
'Yandex Metrica is not connected — paste a token in Settings → Integrations → Yandex Metrica.',
|
||||
});
|
||||
case 'token-format':
|
||||
return t('metricaTab.errTokenFormat', {
|
||||
defaultValue:
|
||||
'The saved Yandex Metrica token has an unexpected format — paste it again in Settings → Integrations → Yandex Metrica.',
|
||||
});
|
||||
case 'token-rejected':
|
||||
return t('metricaTab.errTokenRejected', {
|
||||
defaultValue:
|
||||
'Yandex Metrica rejected the token (HTTP 401) — paste a fresh token with the metrika:read permission in Settings → Integrations → Yandex Metrica.',
|
||||
});
|
||||
case 'access-denied':
|
||||
return t('metricaTab.errAccessDenied', {
|
||||
defaultValue:
|
||||
'Yandex Metrica denied access (HTTP 403) — the account behind the token cannot read this counter.',
|
||||
});
|
||||
case 'no-counter':
|
||||
return t('metricaTab.errNoCounter', { defaultValue: 'No Yandex Metrica counter selected.' });
|
||||
case 'bad-range':
|
||||
return t('metricaTab.errBadRange', { defaultValue: 'Unsupported date range.' });
|
||||
case 'already-running':
|
||||
return t('metricaTab.errAlreadyRunning', {
|
||||
defaultValue: 'A Yandex Metrica fetch is already running in this window.',
|
||||
});
|
||||
case 'project-switched':
|
||||
return t('metricaTab.errProjectSwitched', {
|
||||
defaultValue:
|
||||
'Another project was opened during the fetch, so its result was discarded. Fetch again in this project.',
|
||||
});
|
||||
case 'unexpected-response':
|
||||
return t('metricaTab.errUnexpectedResponse', {
|
||||
defaultValue:
|
||||
'Yandex Metrica returned an unexpected response. The previously stored data is unchanged.',
|
||||
});
|
||||
case 'range-changed':
|
||||
return t('metricaTab.errRangeChanged', {
|
||||
defaultValue:
|
||||
'Yandex Metrica changed the report date range during the fetch. The previously stored data is unchanged — fetch again.',
|
||||
});
|
||||
case 'network':
|
||||
return t('metricaTab.errNetwork', {
|
||||
defaultValue: 'Could not reach Yandex Metrica. Check the connection and try again.',
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const noticeText = (code: MetricaNotice): string =>
|
||||
code === 'truncated'
|
||||
? t('metricaTab.noticeTruncated', {
|
||||
defaultValue:
|
||||
'The last Yandex Metrica fetch hit the row limit, so pages missing from it may still have traffic. This filter is unavailable until a complete fetch.',
|
||||
})
|
||||
: code === 'sampled'
|
||||
? t('metricaTab.noticeSampled', {
|
||||
defaultValue:
|
||||
'The last Yandex Metrica fetch came back sampled, so pages missing from it may still have traffic. This filter is unavailable until an unsampled fetch.',
|
||||
})
|
||||
: t('metricaTab.noticeNoPull', {
|
||||
defaultValue:
|
||||
'No Yandex Metrica data has been fetched for this counter yet. Fetch first: without a complete fetch, a page with no row can’t be read as zero traffic.',
|
||||
});
|
||||
|
||||
// ── Gate states ──────────────────────────────────────────────────────
|
||||
if (stage === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 text-[12px] text-surface-500">
|
||||
<Loader2 size={14} className="mr-2 animate-spin" />
|
||||
{t('metricaTab.loading', { defaultValue: 'Loading…' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'error') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 bg-surface-950 px-6 text-center">
|
||||
<div className="text-[13px] font-semibold text-rose-300">
|
||||
{t('common.loadFailedTitle', { defaultValue: "Couldn't load this view" })}
|
||||
</div>
|
||||
{error && <div className="max-w-md break-words text-[11px] text-surface-500">{error}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-200 hover:bg-surface-800"
|
||||
onClick={() => {
|
||||
showError(null);
|
||||
setStage('loading');
|
||||
setRetryNonce((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
{t('common.retry', { defaultValue: 'Retry' })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'unconfigured') {
|
||||
return (
|
||||
<GateMessage
|
||||
title={t('metricaTab.unconfiguredTitle', {
|
||||
defaultValue: 'Yandex Metrica is not set up',
|
||||
})}
|
||||
body={t('metricaTab.unconfiguredBody', {
|
||||
defaultValue:
|
||||
'Open Settings → Integrations → Yandex Metrica and paste a token with the metrika:read permission. The setup guide there walks through getting one. Then come back here to pick a counter.',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectCls =
|
||||
'h-6 rounded border border-surface-700 bg-surface-950 px-1.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none';
|
||||
const sessionHint = t('metricaTab.sessionHint', {
|
||||
defaultValue:
|
||||
'Session-level: counted for the page a session started on (landing page), not for every page viewed.',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-950">
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-surface-800 bg-surface-900/40 px-3"
|
||||
style={{ height: TOOLBAR_HEIGHT }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
|
||||
{t('metricaTab.title', { defaultValue: 'Yandex Metrica' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="pointer-events-none absolute left-1.5 top-1/2 -translate-y-1/2 text-surface-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('metricaTab.filterPlaceholder', {
|
||||
defaultValue: 'Filter by URL…',
|
||||
})}
|
||||
className="h-6 w-48 rounded border border-surface-700 bg-surface-950 pl-6 pr-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className={selectCls}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterMode)}
|
||||
>
|
||||
<option value="all">
|
||||
{t('metricaTab.filterAll', { defaultValue: 'All 2xx pages' })}
|
||||
</option>
|
||||
<option value="with-data">
|
||||
{t('metricaTab.filterWith', { defaultValue: 'With Metrica data' })}
|
||||
</option>
|
||||
<option value="without-data">
|
||||
{t('metricaTab.filterWithout', { defaultValue: 'No Metrica data' })}
|
||||
</option>
|
||||
<option value="indexable-no-visits">
|
||||
{t('metricaTab.filterIndexableNoVisits', {
|
||||
defaultValue: 'Indexable, no visits',
|
||||
})}
|
||||
</option>
|
||||
<option value="non200-with-traffic">
|
||||
{t('metricaTab.filterNon200', {
|
||||
defaultValue: 'Non-2xx with traffic',
|
||||
})}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<TabExportButton tab="metrica" disabled={total === 0} />
|
||||
<select
|
||||
className={`${selectCls} max-w-[260px]`}
|
||||
value={counterId}
|
||||
onChange={(e) => changeCounter(e.target.value)}
|
||||
disabled={fetching || counters.length === 0}
|
||||
title={counterId}
|
||||
>
|
||||
{counters.length === 0 && (
|
||||
<option value="">
|
||||
{countersLoading
|
||||
? t('metricaTab.countersLoading', { defaultValue: 'Loading counters…' })
|
||||
: t('metricaTab.noCounters', { defaultValue: 'No counters' })}
|
||||
</option>
|
||||
)}
|
||||
{counters.map((c) => (
|
||||
<option key={c.counterId} value={c.counterId}>
|
||||
{c.name || c.site || c.counterId}
|
||||
{c.site && c.site !== c.name ? ` — ${c.site}` : ''} ({c.counterId})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={selectCls}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value) as RangeDays)}
|
||||
disabled={fetching}
|
||||
title={t('metricaTab.daysTitle', {
|
||||
defaultValue: 'Full days ending yesterday, in the counter’s time zone.',
|
||||
})}
|
||||
>
|
||||
<option value={7}>{t('metricaTab.days7', { defaultValue: 'Last 7 days' })}</option>
|
||||
<option value={28}>{t('metricaTab.days28', { defaultValue: 'Last 28 days' })}</option>
|
||||
<option value={90}>{t('metricaTab.days90', { defaultValue: 'Last 90 days' })}</option>
|
||||
</select>
|
||||
{fetching ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelFetch}
|
||||
className="h-6 rounded border border-red-700 bg-red-900/40 px-2.5 text-[11px] font-medium text-red-200 hover:bg-red-900/70"
|
||||
>
|
||||
{t('metricaTab.cancel', { defaultValue: 'Cancel' })}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runFetch()}
|
||||
disabled={!counterId}
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded bg-blue-600 px-2.5 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-surface-700 disabled:text-surface-500"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{t('metricaTab.fetch', { defaultValue: 'Fetch' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context line — last pull and the API's whole-site totals */}
|
||||
{meta && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-b border-surface-800 bg-surface-900/20 px-3 py-1 text-[10px] text-surface-500">
|
||||
<span>
|
||||
{t('metricaTab.metaLine', {
|
||||
defaultValue_one:
|
||||
'{{counter}} · {{start}} → {{end}} · {{formatted}} page · fetched {{ago}}',
|
||||
defaultValue_other:
|
||||
'{{counter}} · {{start}} → {{end}} · {{formatted}} pages · fetched {{ago}}',
|
||||
defaultValue:
|
||||
'{{counter}} · {{start}} → {{end}} · {{formatted}} pages · fetched {{ago}}',
|
||||
count: meta.rowCount,
|
||||
counter: meta.counterName,
|
||||
start: meta.startDate,
|
||||
end: meta.endDate,
|
||||
formatted: meta.rowCount.toLocaleString(),
|
||||
ago: relativeTime(meta.fetchedAt, t),
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
title={t('metricaTab.totalsTitle', {
|
||||
defaultValue:
|
||||
'Whole-site totals reported by Metrica for the range, for comparison with the Metrica interface.',
|
||||
})}
|
||||
>
|
||||
{t('metricaTab.totalsLine', {
|
||||
defaultValue:
|
||||
'Site totals: {{pageviews}} pageviews · {{visits}} visits · {{searchVisits}} search visits',
|
||||
pageviews: meta.totals.pageviews.toLocaleString(),
|
||||
visits: meta.totals.visits.toLocaleString(),
|
||||
searchVisits: meta.totals.searchVisits.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{meta.truncated && (
|
||||
<span className="text-amber-400">
|
||||
{t('metricaTab.truncatedFlag', {
|
||||
defaultValue: 'Row limit reached: low-traffic pages are missing',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{meta.sampled && (
|
||||
<span className="text-amber-400">
|
||||
{t('metricaTab.sampledFlag', { defaultValue: 'Sampled data' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-700/40 bg-red-900/20 px-3 py-1 text-[10px] text-red-300">
|
||||
{errorText(errorCode) ?? error}
|
||||
</div>
|
||||
)}
|
||||
{info && (
|
||||
<div className="border-b border-surface-800 bg-surface-900/20 px-3 py-1 text-[10px] text-surface-400">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex shrink-0 select-none items-center border-b border-surface-800 bg-surface-900/60 text-[10px] font-medium text-surface-400"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex-1 px-2">{t('metricaTab.colUrl', { defaultValue: 'URL' })}</div>
|
||||
<div className="w-[54px] shrink-0 text-center">
|
||||
{t('metricaTab.colStatus', { defaultValue: 'Status' })}
|
||||
</div>
|
||||
<div
|
||||
className="w-[96px] shrink-0 text-right"
|
||||
title={t('metricaTab.colPageviewsHint', {
|
||||
defaultValue: 'Hit-level: every view of this page URL.',
|
||||
})}
|
||||
>
|
||||
{t('metricaTab.colPageviews', { defaultValue: 'Pageviews (hits)' })}
|
||||
</div>
|
||||
<div className="w-[96px] shrink-0 text-right" title={sessionHint}>
|
||||
{t('metricaTab.colVisits', { defaultValue: 'Visits (landing)' })}
|
||||
</div>
|
||||
<div
|
||||
className="w-[96px] shrink-0 text-right"
|
||||
title={t('metricaTab.colUsersHint', {
|
||||
defaultValue:
|
||||
'Session-level, by landing page. An upper bound: one person landing on several pages counts once per page.',
|
||||
})}
|
||||
>
|
||||
{t('metricaTab.colUsers', { defaultValue: 'Users (landing)' })}
|
||||
</div>
|
||||
<div
|
||||
className="w-[120px] shrink-0 text-right"
|
||||
title={t('metricaTab.colSearchVisitsHint', {
|
||||
defaultValue: 'Session-level: sessions from search engines that started on this page.',
|
||||
})}
|
||||
>
|
||||
{t('metricaTab.colSearchVisits', { defaultValue: 'Search visits (landing)' })}
|
||||
</div>
|
||||
<div className="w-[88px] shrink-0 px-2 text-right">
|
||||
{t('metricaTab.colFetched', { defaultValue: 'Fetched' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-[12px] text-surface-500">
|
||||
{notice
|
||||
? noticeText(notice)
|
||||
: total === 0 && filter === 'all' && !search
|
||||
? t('metricaTab.emptyNoData', {
|
||||
defaultValue:
|
||||
'No internal HTML pages crawled yet — run a crawl, then fetch Metrica data.',
|
||||
})
|
||||
: t('metricaTab.emptyNoMatch', {
|
||||
defaultValue: 'No pages match the current filter.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const row = rows[vRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<MetricaDataRow
|
||||
row={row}
|
||||
t={t}
|
||||
active={selectedUrlId === row.id}
|
||||
onSelect={() => {
|
||||
setSelectedUrlId(row.id);
|
||||
setSelectedUrlIds([row.id]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t border-surface-800 bg-surface-900/40 px-3 text-[11px] text-surface-400"
|
||||
style={{ height: STATUS_BAR_HEIGHT }}
|
||||
>
|
||||
<span>
|
||||
{t('metricaTab.summary', {
|
||||
defaultValue_one: '{{shown}} of {{formatted}} page',
|
||||
defaultValue_other: '{{shown}} of {{formatted}} pages',
|
||||
defaultValue: '{{shown}} of {{formatted}} pages',
|
||||
count: total,
|
||||
shown: rows.length.toLocaleString(),
|
||||
formatted: total.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{progress && (
|
||||
<span className="flex items-center gap-1.5 text-surface-500">
|
||||
<Loader2 size={11} className="animate-spin text-blue-400" />
|
||||
{t('metricaTab.progress', {
|
||||
defaultValue_one:
|
||||
'Fetching {{report}} report ({{index}}/{{total}}) · {{formatted}} row',
|
||||
defaultValue_other:
|
||||
'Fetching {{report}} report ({{index}}/{{total}}) · {{formatted}} rows',
|
||||
defaultValue: 'Fetching {{report}} report ({{index}}/{{total}}) · {{formatted}} rows',
|
||||
count: progress.rows,
|
||||
report: reportLabel(progress.report),
|
||||
index: progress.reportIndex,
|
||||
total: progress.reportCount,
|
||||
formatted: progress.rows.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricaDataRow({
|
||||
row,
|
||||
t,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
row: MetricaRow;
|
||||
t: T;
|
||||
/** The page the Detail panel is showing. */
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const m = row.metrica;
|
||||
const status = row.statusCode;
|
||||
const statusCls =
|
||||
status === null
|
||||
? 'text-surface-600'
|
||||
: status >= 200 && status < 300
|
||||
? 'text-emerald-400'
|
||||
: status >= 300 && status < 400
|
||||
? 'text-blue-400'
|
||||
: 'text-red-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`flex h-full cursor-pointer items-center text-[11px] ${
|
||||
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}>
|
||||
{row.url}
|
||||
</div>
|
||||
<div className={`w-[54px] shrink-0 text-center tabular-nums ${statusCls}`}>
|
||||
{status ?? '—'}
|
||||
</div>
|
||||
<div className="w-[96px] shrink-0 text-right tabular-nums text-surface-200">
|
||||
{m ? m.pageviews.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[96px] shrink-0 text-right tabular-nums text-surface-200">
|
||||
{m ? m.visits.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[96px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{m ? m.users.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[120px] shrink-0 text-right tabular-nums text-surface-300">
|
||||
{m ? m.searchVisits.toLocaleString() : '—'}
|
||||
</div>
|
||||
<div className="w-[88px] shrink-0 truncate px-2 text-right text-surface-500">
|
||||
{m ? relativeTime(m.fetchedAt, t) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GateMessage({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-950 px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-1.5 text-[13px] font-semibold text-surface-200">{title}</div>
|
||||
<div className="text-[11px] leading-relaxed text-surface-500">{body}</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -664,6 +664,7 @@ export const COLUMN_SPECS: Record<TabKey, ColumnSpec[]> = {
|
||||
spelling: [],
|
||||
'search-console': [],
|
||||
analytics: [],
|
||||
metrica: [],
|
||||
ai: [],
|
||||
seo: [],
|
||||
assistant: [],
|
||||
|
||||
@@ -104,6 +104,19 @@ function resolveUrlId(
|
||||
throw new Error('Tool requires either `id` (number) or `url` (string).');
|
||||
}
|
||||
|
||||
/** 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 ''
|
||||
* when there is none. */
|
||||
function savedMetricaCounter(db: ProjectDb): string {
|
||||
try {
|
||||
const raw = db.getMeta('integrationSettings:metrica');
|
||||
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. */
|
||||
function clamp(n: unknown, min: number, max: number, fallback: number): number {
|
||||
const v = typeof n === 'number' && Number.isFinite(n) ? n : fallback;
|
||||
@@ -586,7 +599,7 @@ export function buildTools(): Tool[] {
|
||||
{
|
||||
name: 'get_url_analytics',
|
||||
description:
|
||||
'GSC + GA4 + PageSpeed + AI roll-up for one URL: clicks/impressions/CTR/position (GSC), sessions/users/bounceRate (GA4), lab scores + Core Web Vitals (PageSpeed), provider-specific AI verdict. Returns null fields for integrations that have not been fetched yet.',
|
||||
'GSC + GA4 + Yandex Metrica + URL Inspection roll-up for one URL: clicks/impressions/CTR/position (GSC), sessions/users/pageviews/engagement (GA4), pageviews (hit-level) and visits/users/searchVisits (session-level, sessions that started on this page) from the Metrica counter last picked or fetched for this project, and the GSC URL Inspection verdict. Returns null fields for integrations that have not been fetched yet.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -598,7 +611,7 @@ export function buildTools(): Tool[] {
|
||||
const id = resolveUrlId(args, db);
|
||||
const row = db.getUrlRowById(id);
|
||||
if (!row) throw new Error(`URL id ${id} not found.`);
|
||||
return db.getUrlAnalytics(row.url);
|
||||
return db.getUrlAnalytics(row.url, '', '', savedMetricaCounter(db));
|
||||
},
|
||||
},
|
||||
|
||||
@@ -677,6 +690,62 @@ export function buildTools(): Tool[] {
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'query_metrica',
|
||||
description:
|
||||
'Yandex Metrica rows per crawled HTML page: `pageviews` (hit-level, views of that URL) and `visits` / `users` / `searchVisits` (session-level, sessions that STARTED on that page; searchVisits = organic search). A page can have pageviews and zero visits. `users` is an upper bound: URL variants folded into one page are summed, and summing across pages over-counts. Returns `{rows, total, counterId, meta, notice}`; `meta` holds the pulled date range, whole-site totals and `truncated` / `sampled` flags. `without-data` and `indexable-no-visits` need a complete, unsampled pull — otherwise they return no rows and a `notice` code: "no-pull" (nothing fetched for this counter), "truncated" (the last pull hit the row cap) or "sampled". Populate with `metrica_fetch`.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
search: { type: 'string', description: 'Substring match on the URL.' },
|
||||
filter: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'all',
|
||||
'with-data',
|
||||
'without-data',
|
||||
'indexable-no-visits',
|
||||
'non200-with-traffic',
|
||||
],
|
||||
description:
|
||||
'`with-data` / `without-data` = 2xx pages with / without Metrica rows; `indexable-no-visits` = indexable 2xx pages that started no sessions; `non200-with-traffic` = non-2xx pages that still get pageviews or visits. Default "all" (2xx pages).',
|
||||
},
|
||||
counterId: {
|
||||
type: 'string',
|
||||
description: 'Metrica counter ID. Omitted = the counter last picked or fetched for this project.',
|
||||
},
|
||||
limit: { type: 'integer', minimum: 1, maximum: 1000 },
|
||||
offset: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
},
|
||||
handler: (args, db) => {
|
||||
const counterId =
|
||||
(typeof args.counterId === 'string' ? args.counterId.trim() : '') ||
|
||||
savedMetricaCounter(db);
|
||||
const result = db.queryMetrica({
|
||||
limit: clamp(args.limit, 1, 1000, 50),
|
||||
offset: clamp(args.offset, 0, 10_000_000, 0),
|
||||
search: typeof args.search === 'string' ? args.search : undefined,
|
||||
filter: args.filter as
|
||||
| 'all'
|
||||
| 'with-data'
|
||||
| 'without-data'
|
||||
| 'indexable-no-visits'
|
||||
| 'non200-with-traffic'
|
||||
| undefined,
|
||||
counterId,
|
||||
});
|
||||
let meta: unknown = null;
|
||||
try {
|
||||
const raw = counterId ? db.getMeta(`metricaFetchMeta:${counterId}`) : null;
|
||||
if (raw) meta = JSON.parse(raw);
|
||||
} catch {
|
||||
/* no / corrupt meta */
|
||||
}
|
||||
return { ...result, counterId: counterId || null, meta };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'query_pagespeed',
|
||||
description:
|
||||
@@ -1096,13 +1165,18 @@ export function buildTools(): Tool[] {
|
||||
{
|
||||
name: 'report_orphan_cross_source',
|
||||
description:
|
||||
'Orphan pages across all truth sources: URLs present in the XML sitemap, Google Search Console, or Google Analytics 4 but never reached by the crawl (or crawled only as an unfetched stub). Each row lists which sources mentioned it plus its GSC clicks / impressions and GA4 sessions; sorted by traffic (GSC clicks + GA4 sessions) descending so high-value orphans surface first. Sitemap-only with no traffic is often a dead entry; GSC/GA4 orphans are real pages users reach that no internal link path does.',
|
||||
'Orphan pages across all truth sources: URLs present in the XML sitemap, Google Search Console, Google Analytics 4 or Yandex Metrica (the counter last picked or fetched for this project) but never reached by the crawl (or crawled only as an unfetched stub). Each row lists which sources mentioned it plus its GSC clicks / impressions, GA4 sessions and Metrica pageviews (hit-level) / visits (landing page); sorted by traffic (GSC clicks + the larger of GA4 sessions and Metrica visits, then Metrica pageviews) descending so high-value orphans surface first. Sitemap-only with no traffic is often a dead entry; GSC/GA4/Metrica orphans are real pages users reach that no internal link path does.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'integer', minimum: 1, maximum: 10_000 } },
|
||||
},
|
||||
handler: (args, db) => ({
|
||||
rows: db.orphanPagesCrossSource(clamp(args.limit, 1, 10_000, 1000)),
|
||||
rows: db.orphanPagesCrossSource(
|
||||
clamp(args.limit, 1, 10_000, 1000),
|
||||
'',
|
||||
'',
|
||||
savedMetricaCounter(db),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1857,6 +1931,49 @@ export function buildTools(): Tool[] {
|
||||
}>('POST', '/v1/action/ga4-fetch', args),
|
||||
},
|
||||
|
||||
{
|
||||
requiresDb: false,
|
||||
name: 'metrica_list_counters',
|
||||
description:
|
||||
'List every Yandex Metrica counter the saved token can read. Returns `{ok, error, counters: [{counterId, name, site, ownerLogin}]}`. Pass the matching `counterId` to `metrica_fetch` next. Needs a token in Settings → Integrations → Yandex Metrica.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
handler: async () => bridgeRequest<unknown>('POST', '/v1/action/metrica-list-counters', {}),
|
||||
},
|
||||
|
||||
{
|
||||
requiresDb: false,
|
||||
name: 'metrica_fetch',
|
||||
description:
|
||||
'Pull unsampled per-page Yandex Metrica data for one counter over the last N full days, ending yesterday (counter time zone): pageviews by page URL, visits / users / search-engine visits by landing page. Replaces the stored rows for that counter; query them via `query_metrica`. Returns immediately — it does NOT block (a pull takes under a minute on most sites, several minutes with throttling retries). Poll `get_fetch_progress` (kind "metrica") until `running` is false, then check its `error` / `cancelled`; `cancel_fetch` stops it and keeps the stored rows. Returns `{started, state}`; `started:false` + `reason` when the input or token is rejected or a pull is already running.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
counterId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Numeric Metrica counter ID. Get available values from `metrica_list_counters`.',
|
||||
},
|
||||
counterName: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Friendly label saved into the fetch meta for the UI. Optional — falls back to `counterId`.',
|
||||
},
|
||||
days: {
|
||||
type: 'integer',
|
||||
enum: [7, 28, 90],
|
||||
description: 'Trailing window length in days. Default 28.',
|
||||
},
|
||||
},
|
||||
required: ['counterId'],
|
||||
},
|
||||
handler: async (args) =>
|
||||
bridgeRequest<{
|
||||
started: boolean;
|
||||
reason?: string | null;
|
||||
state: unknown;
|
||||
}>('POST', '/v1/action/metrica-fetch', args),
|
||||
},
|
||||
|
||||
// ---- Faz 0.5 Increment 5 — slow batch-fetch run triggers
|
||||
// (PageSpeed / AI / SEO). These are the multi-URL fetchers that
|
||||
// stream progress + support cancellation. The MCP wire protocol
|
||||
@@ -2058,13 +2175,13 @@ export function buildTools(): Tool[] {
|
||||
requiresDb: false,
|
||||
name: 'get_fetch_progress',
|
||||
description:
|
||||
'Poll the live state of the PageSpeed / AI / SEO batch fetchers started by pagespeed_run / ai_run / seo_run (or by the desktop UI — the snapshot is shared). Returns per-kind `{running, done, total, completed, failed, currentUrl, cancelled, startedAt, finishedAt, provider, error}`. Omit `kind` for all three; pass one to narrow. Poll every 1–2 s; once `running` flips to false the batch is done. This is the fetch-side analogue of get_crawl_progress.',
|
||||
'Poll the live state of the PageSpeed / CrUX / spelling / AI / SEO batch fetchers and the Yandex Metrica pull, started by pagespeed_run / crux_run / spelling_run / ai_run / seo_run / metrica_fetch (or by the desktop UI — the snapshot is shared). For "metrica", done/total count its three reports and `detail` names the current one with its rows so far. Returns per-kind `{running, done, total, completed, failed, currentUrl, cancelled, startedAt, finishedAt, provider, error, detail}`. Omit `kind` for all; pass one to narrow. Poll every 1–2 s; once `running` flips to false the batch is done. This is the fetch-side analogue of get_crawl_progress.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['pagespeed', 'crux', 'spelling', 'ai', 'seo'],
|
||||
enum: ['pagespeed', 'crux', 'spelling', 'ai', 'seo', 'metrica'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2076,13 +2193,13 @@ export function buildTools(): Tool[] {
|
||||
requiresDb: false,
|
||||
name: 'cancel_fetch',
|
||||
description:
|
||||
'Request cooperative cancellation of a running batch fetcher. In-flight items finish and are saved; queued items are skipped. Pass `kind` ("pagespeed" / "ai" / "seo") to cancel one, or omit to cancel all three. Returns which kinds were running when the cancel landed. No-op for kinds that are idle.',
|
||||
'Request cooperative cancellation of a running batch fetcher. In-flight items finish and are saved; queued items are skipped. Pass `kind` ("pagespeed" / "crux" / "spelling" / "ai" / "seo" / "metrica") to cancel one, or omit to cancel all. A Metrica pull is aborted at once, even mid-retry, and nothing is stored, unless the rows were already being written. Returns which kinds were running when the cancel landed. No-op for kinds that are idle.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['pagespeed', 'crux', 'spelling', 'ai', 'seo'],
|
||||
enum: ['pagespeed', 'crux', 'spelling', 'ai', 'seo', 'metrica'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface DatasetExportContext {
|
||||
gscAccountId?: string;
|
||||
/** Linked Google account whose GA4 pull to export. */
|
||||
ga4AccountId?: string;
|
||||
/** Yandex Metrica counter whose pull to export. */
|
||||
metricaCounterId?: string;
|
||||
/** Search Console URL-matching options (Settings → Integrations → GSC). */
|
||||
gscMatchSlash?: boolean;
|
||||
gscMatchCase?: boolean;
|
||||
@@ -27,13 +29,14 @@ const PAGE_SIZE = 2000;
|
||||
|
||||
function* paged<T>(
|
||||
fetchPage: (limit: number, offset: number) => { rows: T[] },
|
||||
pageSize = PAGE_SIZE,
|
||||
): Generator<T> {
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const { rows } = fetchPage(PAGE_SIZE, offset);
|
||||
const { rows } = fetchPage(pageSize, offset);
|
||||
for (const row of rows) yield row;
|
||||
if (rows.length < PAGE_SIZE) return;
|
||||
offset += PAGE_SIZE;
|
||||
if (rows.length < pageSize) return;
|
||||
offset += pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +76,15 @@ export function datasetRows(
|
||||
return asRecords(
|
||||
paged((limit, offset) => db.queryGa4({ limit, offset, accountId: ctx.ga4AccountId })),
|
||||
);
|
||||
case 'metrica':
|
||||
// queryMetrica caps a page at 1000 rows; a larger page would read as
|
||||
// the last one.
|
||||
return asRecords(
|
||||
paged(
|
||||
(limit, offset) => db.queryMetrica({ limit, offset, counterId: ctx.metricaCounterId }),
|
||||
1000,
|
||||
),
|
||||
);
|
||||
default: {
|
||||
// Provider-keyed datasets: `ai:<provider>` / `seo:<provider>`.
|
||||
const sep = dataset.indexOf(':');
|
||||
|
||||
@@ -2314,6 +2314,35 @@ const MIGRATIONS: Migration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 93,
|
||||
name: 'add_metrica_results_table',
|
||||
// Yandex Metrica integration. Per-page metrics pulled unsampled from
|
||||
// the Reporting API for a date range, wholesale-replaced per pull like
|
||||
// `ga4_results`. `account_id` is the Metrica counter ID (one token can
|
||||
// read several counters), so `(url, account_id)` keeps two counters
|
||||
// that report on the same URL apart. `url` is the crawl-normalised
|
||||
// form (`normalizeUrl`), so it joins straight onto `urls.url`.
|
||||
//
|
||||
// `pageviews` is hit-level (views of the page URL); `visits`, `users`
|
||||
// and `search_visits` are session-level, attributed to the landing
|
||||
// page.
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS metrica_results (
|
||||
url TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
pageviews INTEGER NOT NULL DEFAULT 0,
|
||||
visits INTEGER NOT NULL DEFAULT 0,
|
||||
users INTEGER NOT NULL DEFAULT 0,
|
||||
search_visits INTEGER NOT NULL DEFAULT 0,
|
||||
fetched_at TEXT NOT NULL,
|
||||
PRIMARY KEY (url, account_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrica_results_account ON metrica_results(account_id);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
+243
-11
@@ -32,6 +32,8 @@ import type {
|
||||
GscRow,
|
||||
GscPreset,
|
||||
Ga4Row,
|
||||
MetricaRow,
|
||||
MetricaNotice,
|
||||
AiProvider,
|
||||
AiResult,
|
||||
AiRow,
|
||||
@@ -1176,6 +1178,7 @@ export class ProjectDb {
|
||||
DELETE FROM spelling_results;
|
||||
DELETE FROM gsc_results;
|
||||
DELETE FROM ga4_results;
|
||||
DELETE FROM metrica_results;
|
||||
DELETE FROM ai_results;
|
||||
DELETE FROM seo_results;
|
||||
DELETE FROM gsc_inspection_results;`,
|
||||
@@ -1724,6 +1727,7 @@ export class ProjectDb {
|
||||
'spelling_results',
|
||||
'gsc_results',
|
||||
'ga4_results',
|
||||
'metrica_results',
|
||||
'ai_results',
|
||||
'seo_results',
|
||||
'gsc_inspection_results',
|
||||
@@ -4994,13 +4998,15 @@ export class ProjectDb {
|
||||
return { total: totalRow.c, rows: rowsDb.map(mapRow) };
|
||||
}
|
||||
|
||||
/** Combined per-URL Analytics view for the detail panel. Three small
|
||||
* lookups (GSC Search Analytics + GA4 + GSC URL Inspection) keyed by
|
||||
* the URL string. Returns null when none of the three is present. */
|
||||
/** Combined per-URL Analytics view for the detail panel. Four small
|
||||
* lookups (GSC Search Analytics + GA4 + Yandex Metrica + GSC URL
|
||||
* Inspection) keyed by the URL string. Returns null when none is
|
||||
* present. */
|
||||
getUrlAnalytics(
|
||||
url: string,
|
||||
gscAccountId = '',
|
||||
ga4AccountId = '',
|
||||
metricaCounterId = '',
|
||||
): UrlAnalyticsDetail | null {
|
||||
const g = this.db
|
||||
.prepare(
|
||||
@@ -5032,6 +5038,20 @@ export class ProjectDb {
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
const m = this.db
|
||||
.prepare(
|
||||
`SELECT pageviews, visits, users, search_visits, fetched_at
|
||||
FROM metrica_results WHERE url = ? AND account_id = ?`,
|
||||
)
|
||||
.get(url, metricaCounterId) as
|
||||
| {
|
||||
pageviews: number;
|
||||
visits: number;
|
||||
users: number;
|
||||
search_visits: number;
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
const i = this.db
|
||||
.prepare(
|
||||
`SELECT verdict, coverage_state, robots_txt_state, indexing_state,
|
||||
@@ -5057,7 +5077,7 @@ export class ProjectDb {
|
||||
fetched_at: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!g && !a && !i) return null;
|
||||
if (!g && !a && !m && !i) return null;
|
||||
return {
|
||||
url,
|
||||
gsc: g
|
||||
@@ -5079,6 +5099,15 @@ export class ProjectDb {
|
||||
fetchedAt: a.fetched_at,
|
||||
}
|
||||
: null,
|
||||
metrica: m
|
||||
? {
|
||||
pageviews: m.pageviews ?? 0,
|
||||
visits: m.visits ?? 0,
|
||||
users: m.users ?? 0,
|
||||
searchVisits: m.search_visits ?? 0,
|
||||
fetchedAt: m.fetched_at,
|
||||
}
|
||||
: null,
|
||||
inspection: i
|
||||
? {
|
||||
verdict: i.verdict,
|
||||
@@ -5351,6 +5380,171 @@ export class ProjectDb {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Yandex Metrica — wholesale-replace the stored rows of one counter.
|
||||
* Each pull is a snapshot for a date range, so the counter's old rows
|
||||
* are dropped and the new set inserted in one transaction. `rows` must
|
||||
* already be crawl-normalised (one row per `normalizeUrl` form).
|
||||
*/
|
||||
replaceMetricaResults(
|
||||
rows: {
|
||||
url: string;
|
||||
pageviews: number;
|
||||
visits: number;
|
||||
users: number;
|
||||
searchVisits: number;
|
||||
}[],
|
||||
fetchedAt: string,
|
||||
counterId: string,
|
||||
/** Fetch meta JSON, written in the same transaction so rows and their
|
||||
* description can't disagree. */
|
||||
metaJson?: string,
|
||||
): void {
|
||||
this.runInTransaction(() => {
|
||||
this.db.prepare('DELETE FROM metrica_results WHERE account_id = ?').run(counterId);
|
||||
if (metaJson !== undefined) this.setMeta(`metricaFetchMeta:${counterId}`, metaJson);
|
||||
const CHUNK = 400;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const slice = rows.slice(i, i + CHUNK);
|
||||
const placeholders = slice.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(',');
|
||||
const args: (string | number)[] = [];
|
||||
for (const r of slice) {
|
||||
args.push(
|
||||
r.url,
|
||||
counterId,
|
||||
r.pageviews,
|
||||
r.visits,
|
||||
r.users,
|
||||
r.searchVisits,
|
||||
fetchedAt,
|
||||
);
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO metrica_results
|
||||
(url, account_id, pageviews, visits, users, search_visits, fetched_at)
|
||||
VALUES ${placeholders}`,
|
||||
)
|
||||
.run(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Yandex Metrica — candidate query for the Metrica tab. Crawled internal
|
||||
* HTML pages left-joined with one counter's stored metrics, ordered by
|
||||
* pageviews desc. The default view keeps to 2xx pages like the GA4 tab;
|
||||
* `non200-with-traffic` deliberately looks at everything else.
|
||||
*
|
||||
* `without-data` and `indexable-no-visits` read a missing row as zero
|
||||
* traffic, which only holds after a complete, unsampled pull. Without
|
||||
* one they return nothing plus a `notice` code saying why.
|
||||
*/
|
||||
queryMetrica(params: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
filter?:
|
||||
| 'all'
|
||||
| 'with-data'
|
||||
| 'without-data'
|
||||
| 'indexable-no-visits'
|
||||
| 'non200-with-traffic';
|
||||
counterId?: string;
|
||||
}): { rows: MetricaRow[]; total: number; notice: MetricaNotice | null } {
|
||||
const filter = params.filter ?? 'all';
|
||||
const limit = Math.min(Math.max(Math.trunc(Number(params.limit)) || 0, 1), 1000);
|
||||
const offset = Math.max(Math.trunc(Number(params.offset)) || 0, 0);
|
||||
if (filter === 'without-data' || filter === 'indexable-no-visits') {
|
||||
type PullFlags = { truncated?: unknown; sampled?: unknown };
|
||||
let meta: PullFlags | null = null;
|
||||
try {
|
||||
const raw = params.counterId
|
||||
? this.getMeta(`metricaFetchMeta:${params.counterId}`)
|
||||
: null;
|
||||
meta = raw ? (JSON.parse(raw) as PullFlags) : null;
|
||||
} catch {
|
||||
meta = null;
|
||||
}
|
||||
const notice: MetricaNotice | null = !meta
|
||||
? 'no-pull'
|
||||
: meta.truncated === true
|
||||
? 'truncated'
|
||||
: meta.sampled === true
|
||||
? 'sampled'
|
||||
: null;
|
||||
if (notice) return { rows: [], total: 0, notice };
|
||||
}
|
||||
const where: string[] = ["u.is_external = 0 AND u.content_kind = 'html'"];
|
||||
if (filter === 'non200-with-traffic') {
|
||||
where.push('u.status_code IS NOT NULL');
|
||||
where.push('(u.status_code < 200 OR u.status_code >= 300)');
|
||||
where.push('(m.pageviews > 0 OR m.visits > 0)');
|
||||
} else {
|
||||
where.push('u.status_code >= 200 AND u.status_code < 300');
|
||||
}
|
||||
// The counter placeholder sits in the JOIN, ahead of the WHERE args.
|
||||
const args: (string | number)[] = [params.counterId ?? ''];
|
||||
if (params.search) {
|
||||
where.push('u.url LIKE ?');
|
||||
args.push(`%${params.search}%`);
|
||||
}
|
||||
if (filter === 'with-data') where.push('m.url IS NOT NULL');
|
||||
else if (filter === 'without-data') where.push('m.url IS NULL');
|
||||
else if (filter === 'indexable-no-visits') {
|
||||
where.push("u.indexability = 'indexable'");
|
||||
where.push('COALESCE(m.visits, 0) = 0');
|
||||
}
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const joinSql =
|
||||
'FROM urls u LEFT JOIN metrica_results m ON m.url = u.url AND m.account_id = ?';
|
||||
|
||||
const totalRow = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c ${joinSql} ${whereSql}`)
|
||||
.get(...args) as { c: number };
|
||||
|
||||
const rowsDb = this.db
|
||||
.prepare(
|
||||
`SELECT u.id AS id, u.url AS url, u.status_code AS status_code,
|
||||
u.indexability AS indexability,
|
||||
m.pageviews AS pageviews, m.visits AS visits,
|
||||
m.users AS users, m.search_visits AS search_visits,
|
||||
m.fetched_at AS fetched_at
|
||||
${joinSql}
|
||||
${whereSql}
|
||||
ORDER BY m.pageviews DESC, u.url ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...args, limit, offset) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
return {
|
||||
total: totalRow.c,
|
||||
rows: rowsDb.map((r) => {
|
||||
const fetchedAt = r['fetched_at'];
|
||||
return {
|
||||
id: r['id'] as number,
|
||||
url: r['url'] as string,
|
||||
statusCode: (r['status_code'] as number | null) ?? null,
|
||||
indexability: (r['indexability'] as string | null) ?? null,
|
||||
metrica:
|
||||
typeof fetchedAt === 'string'
|
||||
? {
|
||||
pageviews: (r['pageviews'] as number | null) ?? 0,
|
||||
visits: (r['visits'] as number | null) ?? 0,
|
||||
users: (r['users'] as number | null) ?? 0,
|
||||
searchVisits: (r['search_visits'] as number | null) ?? 0,
|
||||
fetchedAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
notice: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz 7 — candidate query for the GA4 tab. Crawled internal HTML 2xx
|
||||
* pages left-joined with their stored GA4 metrics, ordered by
|
||||
@@ -9205,7 +9399,8 @@ export class ProjectDb {
|
||||
|
||||
/**
|
||||
* Cross-source orphan URLs — pages that appear in any external truth
|
||||
* source (XML sitemap, Search Console, or Analytics 4) but are *not*
|
||||
* source (XML sitemap, Search Console, Analytics 4 or Yandex Metrica)
|
||||
* but are *not*
|
||||
* in the crawled `urls` table, or are stubs with no fetched status.
|
||||
* Each row carries which sources mentioned the URL so the user can
|
||||
* triage why it was missed (sitemap-only = drop or fix internal
|
||||
@@ -9215,12 +9410,15 @@ export class ProjectDb {
|
||||
limit = 1000,
|
||||
gscAccountId = '',
|
||||
ga4AccountId = '',
|
||||
metricaCounterId = '',
|
||||
): {
|
||||
url: string;
|
||||
sources: string[];
|
||||
gscClicks: number | null;
|
||||
gscImpressions: number | null;
|
||||
ga4Sessions: number | null;
|
||||
metricaPageviews: number | null;
|
||||
metricaVisits: number | null;
|
||||
lastmod: string | null;
|
||||
}[] {
|
||||
const cap = Math.max(1, Math.min(10_000, limit));
|
||||
@@ -9228,40 +9426,72 @@ export class ProjectDb {
|
||||
// keeps these joins 1:1: `(url, account_id)` is the primary key of both
|
||||
// snapshot tables, so without the account predicate a URL reported by
|
||||
// two linked accounts would fan out into duplicated rows. The sitemap
|
||||
// join is 1:1 on its own (url is UNIQUE there). Ordered by traffic (GSC
|
||||
// clicks + GA4 sessions) descending so the highest-value orphans — real
|
||||
// pages users reach that the crawl can't — float to the top.
|
||||
// join is 1:1 on its own (url is UNIQUE there). Metrica is pinned to one
|
||||
// counter the same way. Ordered by traffic (GSC clicks + sessions)
|
||||
// descending so the highest-value orphans — real pages users reach that
|
||||
// the crawl can't — float to the top. GA4 sessions and Metrica visits
|
||||
// measure the same thing on sites with both, so the larger one counts;
|
||||
// Metrica pageviews break ties for pages that are viewed but never land.
|
||||
//
|
||||
// A Metrica counter also reports pages on other hosts that carry it
|
||||
// (syndication like dzen.ru, translate proxies, staging). Those are not
|
||||
// pages the crawl could have found, so Metrica only contributes URLs on
|
||||
// hosts the crawl has internal pages on.
|
||||
const hostOf = (col: string): string => {
|
||||
const rest = `substr(${col}, instr(${col}, '://') + 3)`;
|
||||
return `CASE WHEN instr(${rest}, '/') > 0 THEN substr(${rest}, 1, instr(${rest}, '/') - 1) ELSE ${rest} END`;
|
||||
};
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH src AS (
|
||||
`WITH crawl_hosts AS (
|
||||
SELECT DISTINCT ${hostOf('url')} AS host FROM urls WHERE is_external = 0
|
||||
),
|
||||
src AS (
|
||||
SELECT url, 'sitemap' AS source FROM sitemap_urls
|
||||
UNION ALL
|
||||
SELECT url, 'gsc' AS source FROM gsc_results WHERE account_id = ?
|
||||
UNION ALL
|
||||
SELECT url, 'ga4' AS source FROM ga4_results WHERE account_id = ?
|
||||
UNION ALL
|
||||
SELECT url, 'metrica' AS source FROM metrica_results
|
||||
WHERE account_id = ? AND ${hostOf('url')} IN (SELECT host FROM crawl_hosts)
|
||||
)
|
||||
SELECT s.url AS url,
|
||||
GROUP_CONCAT(DISTINCT s.source) AS sources,
|
||||
g.clicks AS gscClicks,
|
||||
g.impressions AS gscImpressions,
|
||||
a.sessions AS ga4Sessions,
|
||||
m.pageviews AS metricaPageviews,
|
||||
m.visits AS metricaVisits,
|
||||
sm.lastmod AS lastmod
|
||||
FROM src s
|
||||
LEFT JOIN urls u ON u.url = s.url
|
||||
LEFT JOIN gsc_results g ON g.url = s.url AND g.account_id = ?
|
||||
LEFT JOIN ga4_results a ON a.url = s.url AND a.account_id = ?
|
||||
LEFT JOIN metrica_results m ON m.url = s.url AND m.account_id = ?
|
||||
LEFT JOIN sitemap_urls sm ON sm.url = s.url
|
||||
WHERE u.url IS NULL OR u.status_code IS NULL
|
||||
GROUP BY s.url
|
||||
ORDER BY (COALESCE(g.clicks, 0) + COALESCE(a.sessions, 0)) DESC, s.url
|
||||
ORDER BY (COALESCE(g.clicks, 0) + MAX(COALESCE(a.sessions, 0), COALESCE(m.visits, 0))) DESC,
|
||||
COALESCE(m.pageviews, 0) DESC, s.url
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(gscAccountId, ga4AccountId, gscAccountId, ga4AccountId, cap) as {
|
||||
.all(
|
||||
gscAccountId,
|
||||
ga4AccountId,
|
||||
metricaCounterId,
|
||||
gscAccountId,
|
||||
ga4AccountId,
|
||||
metricaCounterId,
|
||||
cap,
|
||||
) as {
|
||||
url: string;
|
||||
sources: string;
|
||||
gscClicks: number | null;
|
||||
gscImpressions: number | null;
|
||||
ga4Sessions: number | null;
|
||||
metricaPageviews: number | null;
|
||||
metricaVisits: number | null;
|
||||
lastmod: string | null;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
@@ -9270,6 +9500,8 @@ export class ProjectDb {
|
||||
gscClicks: r.gscClicks ?? null,
|
||||
gscImpressions: r.gscImpressions ?? null,
|
||||
ga4Sessions: r.ga4Sessions ?? null,
|
||||
metricaPageviews: r.metricaPageviews ?? null,
|
||||
metricaVisits: r.metricaVisits ?? null,
|
||||
lastmod: r.lastmod ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ExportDatasetKey =
|
||||
| 'spelling'
|
||||
| 'search-console'
|
||||
| 'analytics'
|
||||
| 'metrica'
|
||||
| `ai:${AiProvider}`
|
||||
| `seo:${SeoProvider}`;
|
||||
|
||||
@@ -155,6 +156,17 @@ export const EXPORT_DATASET_COLUMNS: Record<ExportDatasetKey, ExportDatasetColum
|
||||
{ key: 'ga4.avgSessionDuration', header: 'Avg. Session Duration' },
|
||||
{ key: 'ga4.fetchedAt', header: 'Fetched At' },
|
||||
],
|
||||
// Pageviews are hit-level (page URL); the rest are session-level,
|
||||
// counted for the landing page. The headers say so.
|
||||
metrica: [
|
||||
...PAGE,
|
||||
{ key: 'indexability', header: 'Indexability' },
|
||||
{ key: 'metrica.pageviews', header: 'Pageviews (hits)' },
|
||||
{ key: 'metrica.visits', header: 'Visits (landing page)' },
|
||||
{ key: 'metrica.users', header: 'Users (landing page)' },
|
||||
{ key: 'metrica.searchVisits', header: 'Search Visits (landing page)' },
|
||||
{ key: 'metrica.fetchedAt', header: 'Fetched At' },
|
||||
],
|
||||
'ai:openai': AI,
|
||||
'ai:anthropic': AI,
|
||||
'ai:ollama': AI,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* encrypted at rest with `safeStorage`, exactly like API keys.
|
||||
*/
|
||||
|
||||
import type { MetricaMetrics } from './metrica.js';
|
||||
|
||||
/**
|
||||
* One connected Google account for an integration. A user can link
|
||||
* several accounts to the same integration (e.g. their own Search
|
||||
@@ -254,12 +256,16 @@ export interface Ga4Row {
|
||||
}
|
||||
|
||||
/** Combined per-URL Analytics view — GSC Search Analytics + GA4 +
|
||||
* GSC URL Inspection, returned by `urlAnalyticsGet` for the detail
|
||||
* panel's Analytics sub-tab. Any field is null when no data exists. */
|
||||
* Yandex Metrica + GSC URL Inspection, returned by `urlAnalyticsGet` for
|
||||
* the detail panel's Analytics sub-tab. Any field is null when no data
|
||||
* exists. */
|
||||
export interface UrlAnalyticsDetail {
|
||||
url: string;
|
||||
gsc: GscMetrics | null;
|
||||
ga4: Ga4Metrics | null;
|
||||
/** The project's selected counter; pageviews hit-level, the rest
|
||||
* session-level by landing page (see `MetricaMetrics`). */
|
||||
metrica: MetricaMetrics | null;
|
||||
inspection: GscInspectionResult | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './pagespeed.js';
|
||||
export * from './crux.js';
|
||||
export * from './spelling.js';
|
||||
export * from './google.js';
|
||||
export * from './metrica.js';
|
||||
export * from './ai.js';
|
||||
export * from './mcp.js';
|
||||
export * from './seo.js';
|
||||
|
||||
@@ -253,6 +253,26 @@ export const INTEGRATIONS: IntegrationDef[] = [
|
||||
setupHint: 'Requires a Semrush subscription with API units. Get your API key from Semrush → Subscription info → API.',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'metrica',
|
||||
name: 'Yandex Metrica',
|
||||
category: 'seo',
|
||||
authType: 'api-key',
|
||||
description:
|
||||
'Unsampled pageviews (per page URL) and visits, users and search-engine visits (per landing page) for each crawled URL.',
|
||||
fields: [
|
||||
{
|
||||
key: 'token',
|
||||
label: 'OAuth token',
|
||||
secret: true,
|
||||
placeholder: 'Paste a token with the metrika:read scope',
|
||||
},
|
||||
],
|
||||
helpUrl: 'https://yandex.com/dev/metrika/en/intro/authorization',
|
||||
setupHint:
|
||||
'Register an app of type "For API access or debugging" at oauth.yandex.com with the metrika:read permission, then open https://oauth.yandex.com/authorize?response_type=token&client_id=<your app ID> while signed in to an account that can view the counter, and paste the token shown. The token only reads data.',
|
||||
},
|
||||
|
||||
// ---- Google (OAuth bring-your-own-client) ----
|
||||
{
|
||||
id: 'gsc',
|
||||
|
||||
@@ -50,6 +50,13 @@ import type {
|
||||
Ga4FetchMeta,
|
||||
UrlAnalyticsDetail,
|
||||
} from './google.js';
|
||||
import type {
|
||||
MetricaCounter,
|
||||
MetricaRow,
|
||||
MetricaFetchMeta,
|
||||
MetricaNotice,
|
||||
MetricaErrorCode,
|
||||
} from './metrica.js';
|
||||
import type { AiProvider, AiRow } from './ai.js';
|
||||
import type {
|
||||
AssistantApprovalInput,
|
||||
@@ -408,6 +415,17 @@ export const IPC = {
|
||||
ga4ListProperties: 'ga4:list-properties',
|
||||
ga4Fetch: 'ga4:fetch',
|
||||
ga4Query: 'ga4:query',
|
||||
/** Yandex Metrica. `metricaListCounters` lists the counters the stored
|
||||
* token can read; `metricaFetch` pulls per-page pageviews (hit-level)
|
||||
* and visits / users / search visits (session-level, by landing page)
|
||||
* unsampled; `metricaQuery` lists crawled pages joined with that data. */
|
||||
metricaListCounters: 'metrica:list-counters',
|
||||
metricaFetch: 'metrica:fetch',
|
||||
metricaQuery: 'metrica:query',
|
||||
/** Aborts an in-flight `metricaFetch`, backoff pauses included. */
|
||||
metricaCancel: 'metrica:cancel',
|
||||
/** main → renderer: live progress of an in-flight Metrica pull. */
|
||||
metricaProgress: 'metrica:progress',
|
||||
/** Faz 7 — AI integrations (OpenAI / Anthropic / Ollama). `aiRun`
|
||||
* applies a prompt template to each selected URL and stores the
|
||||
* response; `aiQuery` lists crawled pages joined with the latest
|
||||
@@ -1332,7 +1350,8 @@ export interface SitemapOrphanRow {
|
||||
/** One row of the cross-source orphan-pages report. */
|
||||
export interface OrphanCrossSourceRow {
|
||||
url: string;
|
||||
/** Which truth sources mentioned this URL — `sitemap`, `gsc`, `ga4`. */
|
||||
/** Which truth sources mentioned this URL — `sitemap`, `gsc`, `ga4`,
|
||||
* `metrica`. */
|
||||
sources: string[];
|
||||
/** Search Console clicks for this URL, when it came from GSC. */
|
||||
gscClicks: number | null;
|
||||
@@ -1340,6 +1359,11 @@ export interface OrphanCrossSourceRow {
|
||||
gscImpressions: number | null;
|
||||
/** Analytics 4 sessions for this URL, when it came from GA4. */
|
||||
ga4Sessions: number | null;
|
||||
/** Yandex Metrica pageviews (hit-level), when it came from Metrica. */
|
||||
metricaPageviews: number | null;
|
||||
/** Yandex Metrica visits that landed on this URL, when it came from
|
||||
* Metrica. */
|
||||
metricaVisits: number | null;
|
||||
/** `<lastmod>` from the sitemap, when it came from the sitemap. */
|
||||
lastmod: string | null;
|
||||
}
|
||||
@@ -1760,6 +1784,73 @@ export interface Ga4QueryResult {
|
||||
meta: Ga4FetchMeta | null;
|
||||
}
|
||||
|
||||
export interface MetricaListCountersResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
/** What went wrong, for the UI to word in the user's language. */
|
||||
errorCode: MetricaErrorCode | null;
|
||||
counters: MetricaCounter[];
|
||||
}
|
||||
|
||||
export interface MetricaFetchInput {
|
||||
/** Counter ID to pull from. */
|
||||
counterId: string;
|
||||
/** Friendly label persisted into the fetch meta for the UI. */
|
||||
counterName: string;
|
||||
days: 7 | 28 | 90;
|
||||
}
|
||||
|
||||
export interface MetricaFetchResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
/** What went wrong, for the UI to word in the user's language. */
|
||||
errorCode: MetricaErrorCode | null;
|
||||
/** True when the pull was cancelled; nothing was stored. */
|
||||
cancelled: boolean;
|
||||
rowCount: number;
|
||||
meta: MetricaFetchMeta | null;
|
||||
}
|
||||
|
||||
/** Live state of a Metrica pull: three reports run in turn, each paged;
|
||||
* `rows` counts the raw rows the current report has returned so far. */
|
||||
export interface MetricaProgress {
|
||||
running: boolean;
|
||||
/** Current report, null once the pull has stopped. */
|
||||
report: 'pageviews' | 'visits' | 'searchVisits' | null;
|
||||
reportIndex: number;
|
||||
reportCount: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface MetricaQueryInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search?: string;
|
||||
/**
|
||||
* `with-data` / `without-data` — any Metrica row at all;
|
||||
* `indexable-no-visits` — indexable 2xx HTML pages with zero
|
||||
* landing-page visits; `non200-with-traffic` — non-2xx pages that
|
||||
* still got pageviews or visits.
|
||||
*/
|
||||
filter?:
|
||||
| 'all'
|
||||
| 'with-data'
|
||||
| 'without-data'
|
||||
| 'indexable-no-visits'
|
||||
| 'non200-with-traffic';
|
||||
/** Counter whose stored rows to join. Omitted / empty falls back to the
|
||||
* project's configured counter. */
|
||||
counterId?: string;
|
||||
}
|
||||
|
||||
export interface MetricaQueryResult {
|
||||
rows: MetricaRow[];
|
||||
total: number;
|
||||
meta: MetricaFetchMeta | null;
|
||||
/** Why an absence filter returned nothing (no complete pull yet). */
|
||||
notice: MetricaNotice | null;
|
||||
}
|
||||
|
||||
export interface AiRunInput {
|
||||
/** Provider to send the prompt to. */
|
||||
provider: AiProvider;
|
||||
@@ -2176,6 +2267,14 @@ export interface FreeCrawlApi {
|
||||
ga4Fetch(input: Ga4FetchInput): Promise<Ga4FetchResult>;
|
||||
/** List crawled pages joined with their stored GA4 metrics. */
|
||||
ga4Query(input: Ga4QueryInput): Promise<Ga4QueryResult>;
|
||||
/** Yandex Metrica — counters, unsampled per-page pull, joined query. */
|
||||
metricaListCounters(): Promise<MetricaListCountersResult>;
|
||||
metricaFetch(input: MetricaFetchInput): Promise<MetricaFetchResult>;
|
||||
metricaQuery(input: MetricaQueryInput): Promise<MetricaQueryResult>;
|
||||
/** Cancel an in-flight Metrica pull; it resolves with `cancelled`. */
|
||||
metricaCancel(): Promise<void>;
|
||||
/** Live progress of an in-flight Metrica pull. */
|
||||
onMetricaProgress(cb: (p: MetricaProgress) => void): () => void;
|
||||
/** Run an AI prompt against the given URLs through the chosen
|
||||
* provider. Resolves when the run finishes; subscribe to
|
||||
* `onAiProgress` for live updates. */
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Yandex Metrica integration — shared types.
|
||||
*
|
||||
* Metrica is connected with a personal OAuth token (scope `metrika:read`)
|
||||
* kept in the credential store under the `metrica` integration. One token
|
||||
* can read several counters, so pulled rows are keyed by `(url,
|
||||
* account_id)` where `account_id` is the counter ID — the analogue of the
|
||||
* linked Google account for GA4.
|
||||
*
|
||||
* Two attribution levels live side by side and must not be mixed up:
|
||||
*
|
||||
* - `pageviews` is hit-level: every view of the page URL counts.
|
||||
* - `visits`, `users` and `searchVisits` are session-level and are
|
||||
* attributed to the session's landing page (`ym:s:startURL`). A page
|
||||
* that is viewed a lot but never starts a session has pageviews and
|
||||
* zero visits.
|
||||
*/
|
||||
|
||||
/** One Metrica counter the connected token can read. */
|
||||
export interface MetricaCounter {
|
||||
/** Numeric counter ID, as a string (it is also the stored `account_id`). */
|
||||
counterId: string;
|
||||
/** Counter name set in Metrica. */
|
||||
name: string;
|
||||
/** Site the counter is installed on (`site2.site`). */
|
||||
site: string;
|
||||
/** Yandex login that owns the counter. */
|
||||
ownerLogin: string;
|
||||
}
|
||||
|
||||
/** Per-page Yandex Metrica metrics for a date range. */
|
||||
export interface MetricaMetrics {
|
||||
/** Hit-level: views of this page URL (`ym:pv:pageviews`). */
|
||||
pageviews: number;
|
||||
/** Session-level: sessions that started on this page (`ym:s:visits`). */
|
||||
visits: number;
|
||||
/** Session-level: users whose sessions started on this page (`ym:s:users`).
|
||||
* When several raw URL variants (tracking parameters, fragments) fold
|
||||
* into one crawl URL their users are summed, so this is an upper bound. */
|
||||
users: number;
|
||||
/** Session-level: sessions from search engines that started on this page
|
||||
* (`ym:s:visits` with `ym:s:trafficSource=='organic'`). */
|
||||
searchVisits: number;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** One Metrica tab row — a crawled page joined with its Metrica data. */
|
||||
export interface MetricaRow {
|
||||
/** `urls.id` — what the Detail panel opens when the row is clicked. */
|
||||
id: number;
|
||||
url: string;
|
||||
statusCode: number | null;
|
||||
indexability: string | null;
|
||||
/** Null when the page had no Metrica data in the range. */
|
||||
metrica: MetricaMetrics | null;
|
||||
}
|
||||
|
||||
/** Per-project Yandex Metrica settings, persisted in the project file. */
|
||||
export interface MetricaIntegrationSettings {
|
||||
/** Last-selected counter ID. */
|
||||
counterId: string;
|
||||
/** Friendly label for that counter, for the fetch meta line. */
|
||||
counterName: string;
|
||||
/** Trailing window in days. */
|
||||
days: 7 | 28 | 90;
|
||||
}
|
||||
|
||||
/** Factory for the default Metrica settings (fresh object each call). */
|
||||
export function defaultMetricaSettings(): MetricaIntegrationSettings {
|
||||
return { counterId: '', counterName: '', days: 28 };
|
||||
}
|
||||
|
||||
/** Why a Metrica call failed, for the UI to word in the user's language.
|
||||
* The result's `error` keeps the English text for logs and MCP clients;
|
||||
* `api` means Metrica's own message (or an HTTP status) is all there is. */
|
||||
export type MetricaErrorCode =
|
||||
| 'not-connected'
|
||||
| 'token-format'
|
||||
| 'token-rejected'
|
||||
| 'access-denied'
|
||||
| 'no-counter'
|
||||
| 'bad-range'
|
||||
| 'already-running'
|
||||
| 'project-switched'
|
||||
| 'unexpected-response'
|
||||
| 'range-changed'
|
||||
| 'network'
|
||||
| 'api';
|
||||
|
||||
/** Why an absence filter (`without-data`, `indexable-no-visits`) returned
|
||||
* nothing: no pull yet, or the last pull hit the row cap or was sampled,
|
||||
* so a missing row can't be read as zero traffic. The UI turns the code
|
||||
* into text. */
|
||||
export type MetricaNotice = 'no-pull' | 'truncated' | 'sampled';
|
||||
|
||||
/** Metadata describing the most recent Metrica pull for one counter. */
|
||||
export interface MetricaFetchMeta {
|
||||
counterId: string;
|
||||
counterName: string;
|
||||
/** Inclusive date range, `YYYY-MM-DD`, in the counter's time zone. The
|
||||
* window ends yesterday: today's data is still arriving. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
fetchedAt: string;
|
||||
/** Number of distinct (normalised) page URLs stored. */
|
||||
rowCount: number;
|
||||
/** True when a report hit the client's row ceiling, so the long tail of
|
||||
* low-traffic URLs is missing. */
|
||||
truncated: boolean;
|
||||
/** True when Metrica returned sampled data despite `accuracy=full`. */
|
||||
sampled: boolean;
|
||||
/** Whole-site totals reported by the API for the range, for a sanity
|
||||
* check against the per-URL sums and the Metrica UI. */
|
||||
totals: {
|
||||
pageviews: number;
|
||||
visits: number;
|
||||
users: number;
|
||||
searchVisits: number;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user