@operstack/audit 0.33.3 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -3
- package/src/collect.mjs +222 -46
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +26 -20
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +57 -21
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +12 -2
- package/src/review.mjs +271 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +11 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +11 -2
- package/src/test-shopify-store.mjs +159 -0
- package/src/verify.mjs +347 -0
- package/src/visibility.mjs +40 -10
package/src/niche.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ниша сайта до советов (14.10.2026).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Отчёт клинике советовал «кнопку Получить расчёт», разметку RealEstateAgent и
|
|
5
|
+
* «ответы на вопросы покупателей», а цены предлагал ставить на гемодиализ по ОМС. Советы были
|
|
6
|
+
* написаны под магазин и агентство недвижимости и шли всем. Здесь ниша определяется по тому, что
|
|
7
|
+
* сайт сам о себе говорит (разметка, заголовки, текст выборки), и от неё зависят слова, примеры
|
|
8
|
+
* кнопок и тип разметки. Не определилась: нейтральные слова и никаких нишевых примеров.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SIGNALS = {
|
|
12
|
+
medical: {
|
|
13
|
+
schema: /MedicalOrganization|MedicalClinic|Hospital|Physician|Dentist|Pharmacy|MedicalBusiness|DiagnosticLab/i,
|
|
14
|
+
text: /(?<![\p{L}\p{N}])(?:пациент\p{L}*|врач\p{L}*|клиник\p{L}*|медицинск\p{L}*|лечени\p{L}*|диагностик\p{L}*|ОМС|ДМС|полис\p{L}*|анализ\p{L}*|процедур\p{L}*|гемодиализ\p{L}*|стоматолог\p{L}*|patient\p{L}*|clinic\p{L}*|doctor\p{L}*|treatment\p{L}*|medical|dental)(?![\p{L}\p{N}])/giu,
|
|
15
|
+
},
|
|
16
|
+
shop: {
|
|
17
|
+
schema: /\b(?:Product|OnlineStore|Store)\b/,
|
|
18
|
+
text: /(?<![\p{L}\p{N}])(?:корзин\p{L}*|в корзину|купить|доставк\p{L}*|артикул|add to cart|checkout|shipping|in stock|buy now)(?![\p{L}\p{N}])/giu,
|
|
19
|
+
},
|
|
20
|
+
realestate: {
|
|
21
|
+
schema: /RealEstateAgent|RealEstateListing|Residence|Apartment|House\b/,
|
|
22
|
+
text: /(?<![\p{L}\p{N}])(?:недвижимост\p{L}*|квартир\p{L}*|застройщик\p{L}*|вилл\p{L}*|кондо|ипотек\p{L}*|real estate|apartment\p{L}*|condo\p{L}*|villa\p{L}*|property|properties|developer)(?![\p{L}\p{N}])/giu,
|
|
23
|
+
},
|
|
24
|
+
b2b: {
|
|
25
|
+
schema: /SoftwareApplication|WebApplication/,
|
|
26
|
+
text: /(?<![\p{L}\p{N}])(?:для бизнеса|B2B|интеграци\p{L}*|API|SaaS|внедрени\p{L}*|enterprise|integration\p{L}*|platform|pricing plans?)(?![\p{L}\p{N}])/giu,
|
|
27
|
+
},
|
|
28
|
+
services: {
|
|
29
|
+
schema: /LocalBusiness|ProfessionalService|HomeAndConstructionBusiness|LegalService|AccountingService|BeautySalon|AutoRepair|ChildCare|\bService\b/,
|
|
30
|
+
text: /(?<![\p{L}\p{N}])(?:услуг\p{L}*|заявк\p{L}*|записаться|такси|поездк\p{L}*|водител\p{L}*|пассажир\p{L}*|мастер\p{L}*|ремонт\p{L}*|консультаци\p{L}*|service\p{L}*|book an? appointment|get a quote|consultation)(?![\p{L}\p{N}])/giu,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ORDER = ['medical', 'shop', 'realestate', 'b2b', 'services'];
|
|
35
|
+
|
|
36
|
+
/** Слова и примеры по нише. other это нейтральный набор без нишевых примеров. */
|
|
37
|
+
export const VOCAB = {
|
|
38
|
+
medical: {
|
|
39
|
+
ru: { people: 'пациенты', peopleGen: 'пациентов', peopleDat: 'пациентам', cta: 'кнопка заявки, звонка или записи', schemaType: 'MedicalOrganization', price: 'Цены указывай только у платных услуг; у услуг по ОМС напиши, что они бесплатны по полису, и не придумывай цену.' },
|
|
40
|
+
en: { people: 'patients', peopleGen: 'patients', cta: '"Book an appointment" or "Call us" (only an action the site already offers)', schemaType: 'MedicalOrganization', price: 'Show prices only for paid services; do not invent prices for covered care.' },
|
|
41
|
+
},
|
|
42
|
+
shop: {
|
|
43
|
+
ru: { people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Купить», «В корзину» или «Посмотреть цены»', schemaType: 'Store или OnlineStore', price: 'Цена должна быть видна на странице товара и совпадать с разметкой Offer.' },
|
|
44
|
+
en: { people: 'buyers', peopleGen: 'buyers', cta: '"Buy", "Add to cart" or "See prices"', schemaType: 'Store or OnlineStore', price: 'The price must be visible on the product page and match the Offer markup.' },
|
|
45
|
+
},
|
|
46
|
+
realestate: {
|
|
47
|
+
ru: { people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Подобрать объект» или «Получить подборку»', schemaType: 'RealEstateAgent', price: 'Цены и условия оплаты бери только у владельца или застройщика.' },
|
|
48
|
+
en: { people: 'buyers', peopleGen: 'buyers', cta: '"Find a property" or "Get a shortlist"', schemaType: 'RealEstateAgent', price: 'Take prices and payment terms only from the owner or the developer.' },
|
|
49
|
+
},
|
|
50
|
+
b2b: {
|
|
51
|
+
ru: { people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Запросить демо» или «Узнать цену»', schemaType: 'Organization (и SoftwareApplication для продукта)', price: 'Цены пиши только те, что владелец готов публиковать.' },
|
|
52
|
+
en: { people: 'customers', peopleGen: 'customers', cta: '"Request a demo" or "See pricing"', schemaType: 'Organization (and SoftwareApplication for the product)', price: 'Publish only the prices the owner is ready to show.' },
|
|
53
|
+
},
|
|
54
|
+
services: {
|
|
55
|
+
ru: { people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Оставить заявку» или «Позвонить»', schemaType: 'LocalBusiness или ProfessionalService', price: 'Цены пиши только те, что владелец готов публиковать.' },
|
|
56
|
+
en: { people: 'customers', peopleGen: 'customers', cta: '"Request a call" or "Get a quote"', schemaType: 'LocalBusiness or ProfessionalService', price: 'Publish only the prices the owner is ready to show.' },
|
|
57
|
+
},
|
|
58
|
+
other: {
|
|
59
|
+
ru: { people: 'посетители', peopleGen: 'посетителей', peopleDat: 'посетителям', cta: 'кнопка или ссылка с понятной подписью', schemaType: 'Organization или более точный тип по роду деятельности', price: 'Цены пиши только те, что владелец готов публиковать.' },
|
|
60
|
+
en: { people: 'visitors', peopleGen: 'visitors', cta: 'the action the site already offers (a button or link with a clear label)', schemaType: 'Organization or a more precise type that fits the business', price: 'Publish only the prices the owner is ready to show.' },
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @returns {{ niche: string, confidence: number, scores: Record<string, number> }}
|
|
66
|
+
* Ниша засчитывается, только если её сигнал заметно сильнее остальных: иначе other.
|
|
67
|
+
*/
|
|
68
|
+
export function detectNiche(audit) {
|
|
69
|
+
const pages = (audit?.sample || []).filter((p) => p && p.title !== undefined);
|
|
70
|
+
const text = [audit?.meta?.description, audit?.client?.subject, ...pages.flatMap((p) => [p.title, p.h1, p.description, p.firstPara])].filter(Boolean).join(' \n ');
|
|
71
|
+
const schema = pages.flatMap((p) => p.schemaTypes || []).join(' ');
|
|
72
|
+
const scores = {};
|
|
73
|
+
for (const k of ORDER) {
|
|
74
|
+
const hits = (text.match(SIGNALS[k].text) || []).length;
|
|
75
|
+
const sch = SIGNALS[k].schema.test(schema) ? 6 : 0;
|
|
76
|
+
scores[k] = hits + sch;
|
|
77
|
+
}
|
|
78
|
+
// Движок магазина это сильный сигнал: Shopify и WooCommerce почти всегда продают товары.
|
|
79
|
+
const cms = String((audit?.checks || []).find((c) => c.id === 'cms')?.value || '');
|
|
80
|
+
if (/shopify|woocommerce/i.test(cms)) scores.shop += 6;
|
|
81
|
+
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
82
|
+
const [top, second] = ranked;
|
|
83
|
+
const confident = top[1] >= 4 && top[1] >= (second ? second[1] : 0) * 1.5;
|
|
84
|
+
return { niche: confident ? top[0] : 'other', confidence: top[1], scores };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function vocabFor(audit, lang = audit?.meta?.lang || 'en') {
|
|
88
|
+
const n = audit?.niche?.niche || detectNiche(audit).niche;
|
|
89
|
+
const v = VOCAB[n] || VOCAB.other;
|
|
90
|
+
return { niche: n, ...(v[lang === 'ru' ? 'ru' : 'en']) };
|
|
91
|
+
}
|
package/src/prompts.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
18
18
|
import { localiseChecks } from './i18n.mjs';
|
|
19
|
+
import { vocabFor } from './niche.mjs';
|
|
19
20
|
|
|
20
21
|
const P = {
|
|
21
22
|
alt: {
|
|
@@ -189,8 +190,8 @@ const P = {
|
|
|
189
190
|
en: { task: 'Add BreadcrumbList JSON-LD to the inner pages: the path from the home page to the current one, each step with a name and a full URL. Build the path from the site\'s real section structure; if the page already shows visible breadcrumbs, the markup must match them word for word.', verify: 'Inner sampled pages carry JSON-LD of type BreadcrumbList, every URL in it opens, and the validator shows no errors.' },
|
|
190
191
|
},
|
|
191
192
|
'org-schema': {
|
|
192
|
-
ru: { task: 'Добавь в общий шаблон (или хотя бы на главную) разметку организации в JSON-LD: Organization или более точный тип (LocalBusiness
|
|
193
|
-
en: { task: 'Add organisation JSON-LD to the shared template (or at least the home page): Organization or a more precise type (LocalBusiness,
|
|
193
|
+
ru: { task: 'Добавь в общий шаблон (или хотя бы на главную) разметку организации в JSON-LD: Organization или более точный тип по роду бизнеса (например, MedicalOrganization для клиники, LocalBusiness для местной услуги, Store для магазина), с названием, адресом сайта, логотипом, контактами и ссылками на профили в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец, остальные поля оставь пустыми.', verify: 'На главной есть JSON-LD с типом Organization или его подтипом, в нём name и url, и валидатор не показывает ошибок.' },
|
|
194
|
+
en: { task: 'Add organisation JSON-LD to the shared template (or at least the home page): Organization or a more precise type that fits the business (for example MedicalOrganization for a clinic, LocalBusiness for a local service, Store for a shop), with the name, site address, logo, contacts and profile links in sameAs. Fill in only what is on the site or confirmed by the owner; leave the other fields out.', verify: 'The home page has JSON-LD of type Organization or a subtype, with name and url, and the validator shows no errors.' },
|
|
194
195
|
},
|
|
195
196
|
favicon: {
|
|
196
197
|
ru: { task: 'Добавь значок сайта: файл favicon (лучше SVG и запасной PNG 32 на 32 или ICO) из логотипа компании, и ссылку <link rel="icon"> на него в общем шаблоне. Логотип бери тот, что уже есть на сайте, новый не рисуй.', verify: 'В HTML главной есть <link rel="icon">, и файл по ссылке открывается с кодом 200.' },
|
|
@@ -258,6 +259,136 @@ const P = {
|
|
|
258
259
|
|
|
259
260
|
const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
|
|
260
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Задания, текст которых зависит от того, что на сайте есть на самом деле (14.10.2026).
|
|
264
|
+
*
|
|
265
|
+
* Простым языком. Шаблонное задание врёт, когда сайт не такой, как в шаблоне: «убери лишний H1»
|
|
266
|
+
* там, где H1 нет вовсе; «проставь alt», когда alt уже стоит у всех, но пустой; «перепиши
|
|
267
|
+
* robots.txt», когда в нём единственное правильное правило. А «Как проверить» тогда проходит до
|
|
268
|
+
* всякой работы. Здесь задание собирается из замера: что именно сейчас, что сделать и как
|
|
269
|
+
* убедиться, что сделано, причём проверка не может пройти до начала работы.
|
|
270
|
+
* ctx: { check, audit, lang, ru, v (слова ниши), platform }
|
|
271
|
+
*/
|
|
272
|
+
const sampleOf = (audit) => (audit?.sample || []).filter((p) => p && p.title !== undefined);
|
|
273
|
+
const pathOf = (u) => { try { return new URL(u).pathname; } catch { return String(u || ''); } };
|
|
274
|
+
const PF = {
|
|
275
|
+
title: ({ ru, v }) => (ru
|
|
276
|
+
? { task: 'Перепиши заголовок главной (тег title): до 60 знаков, словами, которыми ищут ' + v.people + ', что это за компания и чем она занимается, название компании в конце. Не повторяй слова и не пиши список ключевых слов через запятую.', verify: 'Заголовок главной от 30 до 60 знаков, в нём названо, чем занимается компания, и он отличается от заголовков других страниц.' }
|
|
277
|
+
: { task: 'Rewrite the home page title: up to 60 characters, in the words ' + v.people + ' search with, saying what the business is and does, company name at the end. No repeated words, no keyword lists.', verify: 'The home page title is 30 to 60 characters, says what the business does, and differs from other page titles.' }),
|
|
278
|
+
dates: ({ ru }) => (ru
|
|
279
|
+
? { task: 'Сверь даты публикации в разметке (datePublished) с настоящими датами материалов: если тема сортирует записи по этой дате, сначала переключи сортировку на настоящую дату, потом исправь даты в разметке. Затем выведи дату публикации видимой строкой на странице каждого материала.', verify: 'На странице каждого материала видна дата публикации, и она совпадает с datePublished в его разметке.' }
|
|
280
|
+
: { task: 'Check the published dates in the markup (datePublished) against the real dates of the posts: if the theme sorts posts by that date, switch the sorting first, then correct the markup dates. Then show the published date as a visible line on every post.', verify: 'Every post shows its published date on the page, and it matches datePublished in its markup.' }),
|
|
281
|
+
'trust-entity': ({ ru }) => (ru
|
|
282
|
+
? { task: 'После того как появится разметка организации (задача про неё выше), допиши в неё адрес: город и страну (PostalAddress с addressLocality и addressCountry). Адрес головного офиса спроси у владельца, если на сайте его нет; адреса филиалов бери со страниц сайта.', verify: 'В разметке организации есть город и страна, и они совпадают с адресом, который подтвердил владелец.' }
|
|
283
|
+
: { task: 'Once the organisation markup exists (task above), add the address: city and country (PostalAddress with addressLocality and addressCountry). Ask the owner for the head office address if the site does not show it.', verify: 'The organisation markup carries city and country, matching the address the owner confirmed.' }),
|
|
284
|
+
'train-bots': ({ ru }) => (ru
|
|
285
|
+
? { task: 'Спроси владельца, ставил ли он запрет для этих роботов. Если нет, выясни у хостинга, какое правило сервера или защиты обрывает их соединение, и сними его. Если владелец хочет закрыть обучающих роботов, оставь запрет, но пропиши его явно в robots.txt, чтобы было видно, что это решение.', verify: 'Запрос robots.txt с user-agent каждого названного робота возвращает ответ 200, либо запрет явно записан в robots.txt по решению владельца.' }
|
|
286
|
+
: { task: 'Ask the owner whether they blocked these robots. If not, ask the host which server or firewall rule drops them and remove it. If the owner wants the training crawlers out, keep the block but also write it into robots.txt so the decision is visible.', verify: 'A robots.txt request with each named robot\'s user-agent returns 200, or the block is written into robots.txt by the owner\'s decision.' }),
|
|
287
|
+
alt: ({ audit, ru }) => {
|
|
288
|
+
const s = sampleOf(audit);
|
|
289
|
+
const missing = s.reduce((a, p) => a + (Number(p.imagesAltMissing) || 0), 0);
|
|
290
|
+
const empty = s.reduce((a, p) => a + (Number(p.imagesNoAlt) || 0), 0) - missing;
|
|
291
|
+
if (missing === 0 && empty > 0) return ru
|
|
292
|
+
? { task: 'Атрибут alt стоит у всех картинок, но почти везде пустой. Впиши непустой alt каждой содержательной картинке (фото на страницах, в новостях, галереях и карточках): своими словами, что изображено, от пяти до пятнадцати слов. Значкам оформления темы (иконки, стрелки, фоны) оставь пустой alt="", это правильно. Если непонятно, что на фото, не выдумывай: собери такие файлы в список для владельца.', verify: 'У каждой фотографии на проверенных страницах alt не пустой и описывает изображение; пустой alt="" остался только у значков оформления.' }
|
|
293
|
+
: { task: 'Every image already has an alt attribute, but nearly all are empty. Give every meaningful image (photos on pages, posts, galleries, cards) a non-empty alt: what it shows, in five to fifteen words. Theme icons, arrows and backgrounds keep an empty alt="", deliberately. If you cannot tell what a photo shows, do not invent it: list those files for the owner.', verify: 'Every photo on the sampled pages has a non-empty alt describing it; only decorative icons keep alt="".' };
|
|
294
|
+
return null;
|
|
295
|
+
},
|
|
296
|
+
h1: ({ audit, ru }) => {
|
|
297
|
+
const home = sampleOf(audit)[0];
|
|
298
|
+
if (home && !home.h1Count) return ru
|
|
299
|
+
? { task: 'Добавь на главную один заголовок первого уровня (H1) о том, чем занимается компания. Существующие заголовки H2 не трогай. Не делай H1 из логотипа и из названия сайта в шапке.', verify: 'На главной ровно один H1, и он говорит, чем занимается компания.' }
|
|
300
|
+
: { task: 'Add one first-level heading (H1) to the home page saying what the business does. Leave the existing H2s alone. Do not make an H1 out of the logo or the site name in the header.', verify: 'The home page has exactly one H1, and it says what the business does.' };
|
|
301
|
+
return null;
|
|
302
|
+
},
|
|
303
|
+
'h1-sample': ({ audit, ru }) => {
|
|
304
|
+
const s = sampleOf(audit);
|
|
305
|
+
const none = s.filter((p) => !p.h1Count).length; const many = s.filter((p) => p.h1Count > 1).length;
|
|
306
|
+
if (none && !many) return ru
|
|
307
|
+
? { task: 'На страницах без H1 добавь один заголовок первого уровня с названием страницы (на главной: чем занимается компания). Если H1 не выводит шаблон темы, правь шаблон, а не каждую страницу.', verify: 'На каждой проверенной странице ровно один H1. На внутренних страницах он совпадает с названием страницы, на главной говорит, чем занимается компания.' }
|
|
308
|
+
: { task: 'On pages without an H1, add one first-level heading with the page name (on the home page: what the business does). If the theme template does not output an H1, fix the template rather than every page.', verify: 'Every sampled page has exactly one H1: the page name on inner pages, what the business does on the home page.' };
|
|
309
|
+
return null;
|
|
310
|
+
},
|
|
311
|
+
description: ({ check, ru }) => (/нет|missing|none|no description/i.test(String(check.value)) ? (ru
|
|
312
|
+
? { task: 'Напиши описание главной (meta description) в 120-160 знаков: чем занимается компания и что человек найдёт на сайте. Одно предложение, без рекламных восклицаний. Не обещай того, чего на сайте нет.', verify: 'У главной есть описание длиной от 120 до 160 знаков, и оно говорит, чем занимается компания.' }
|
|
313
|
+
: { task: 'Write the home page meta description in 120 to 160 characters: what the business does and what a visitor finds. One sentence, no exclamation marks. Promise nothing the site does not have.', verify: 'The home page has a description of 120 to 160 characters saying what the business does.' }) : null),
|
|
314
|
+
'description-all': ({ ru }) => (ru
|
|
315
|
+
? { task: 'Добавь meta description на страницы, где его нет: 120-160 знаков своими словами о том, что человек найдёт именно на этой странице. Бери смысл из текста страницы. Если описание пустое из-за шаблона или плагина, почини шаблон, чтобы он брал поле описания страницы.', verify: 'На каждой проверенной странице есть meta description длиной от 120 до 160 знаков, и описания не повторяются.' }
|
|
316
|
+
: { task: 'Add a meta description where it is missing: 120 to 160 characters, in your own words, on what this particular page offers. Take the meaning from the page text. If the template or plugin leaves it empty, fix the template so it uses the page description field.', verify: 'Every sampled page has a meta description of 120 to 160 characters, and none repeat.' }),
|
|
317
|
+
robots: ({ check, ru, platform }) => {
|
|
318
|
+
const rules = Number((String(check.value).match(/(\d+)/) || [])[1]);
|
|
319
|
+
const onlySitemap = /строки с картой сайта нет|no sitemap line/i.test(String(check.value)) && rules <= 2;
|
|
320
|
+
if (!onlySitemap) return null;
|
|
321
|
+
const wp = platform === 'wordpress';
|
|
322
|
+
return ru
|
|
323
|
+
? { task: `${wp ? 'robots.txt на WordPress часто собирается на лету, тогда файла на диске нет: создай его в плагине SEO (в Yoast: SEO, Инструменты, Редактор файлов) и сохрани нынешние строки. ' : ''}Добавь в конец robots.txt строку Sitemap с полным адресом карты сайта. Нынешние правила не трогай: закрытых обычных страниц в них нет.`, verify: 'В robots.txt есть строка Sitemap, и адрес в ней открывает карту сайта.' }
|
|
324
|
+
: { task: `${wp ? 'On WordPress robots.txt is often generated on the fly with no file on disk: create it in the SEO plugin (Yoast: SEO, Tools, File editor) and keep the current lines. ' : ''}Add a Sitemap line with the full sitemap address at the end of robots.txt. Leave the current rules alone: they block no ordinary pages.`, verify: 'robots.txt has a Sitemap line, and its address opens the sitemap.' };
|
|
325
|
+
},
|
|
326
|
+
'faq-schema': ({ ru, v }) => (ru
|
|
327
|
+
? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
|
|
328
|
+
: { task: `Collect 5 to 7 frequent questions from ${v.peopleGen} with the owner, publish them as a visible block on the main service pages, and only then mark them up with FAQPage. The markup text must match the visible text word for word.`, verify: 'The chosen pages carry FAQPage markup with at least five questions, each question and answer visible on the page word for word.' }),
|
|
329
|
+
sections: ({ audit, ru }) => {
|
|
330
|
+
const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3);
|
|
331
|
+
if (!long.length) return null;
|
|
332
|
+
const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
|
|
333
|
+
// Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
|
|
334
|
+
if (long.every((p) => (p.h3Count || 0) >= 2)) return ru
|
|
335
|
+
? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
|
|
336
|
+
: { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text.`, verify: `${list} carry at least three H2s that follow the text.` };
|
|
337
|
+
return ru
|
|
338
|
+
? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только структурируй.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
|
|
339
|
+
: { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
|
|
340
|
+
},
|
|
341
|
+
'conv-cta': ({ ru, v }) => (ru
|
|
342
|
+
? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице в верхних 15 процентах высоты, в основном блоке страницы, а не в шапке, есть кнопка или ссылка с этим действием.' }
|
|
343
|
+
: { task: `Put one clear action the site already offers into the first screen of every main page: ${v.cta}. One action per screen. Invent no new promises or timings.`, verify: 'Each such page has that button or link within the top 15 percent of its height, in the main content block rather than the header.' }),
|
|
344
|
+
'conv-messenger': ({ ru }) => (ru
|
|
345
|
+
? { task: 'Спроси владельца, есть ли у компании мессенджер для связи (WhatsApp, Telegram, MAX). Если есть, поставь одну ссылку рядом с телефоном, чтобы она открывала диалог. Если нет, задачу закрой.', verify: 'Либо рядом с телефоном есть рабочая ссылка на мессенджер, либо владелец подтвердил, что его нет.' }
|
|
346
|
+
: { task: 'Ask the owner whether the business uses a messenger (WhatsApp, Telegram and the like). If yes, put one link next to the phone that opens a conversation. If no, close the task.', verify: 'Either a working messenger link sits next to the phone, or the owner confirmed there is none.' }),
|
|
347
|
+
'org-schema': ({ audit, ru, v }) => {
|
|
348
|
+
const links = (audit?.checks || []).find((c) => c.id === 'trust-profiles');
|
|
349
|
+
const has = /ссылки на сайте есть|pages link to/i.test(String(links?.value || ''));
|
|
350
|
+
return ru
|
|
351
|
+
? { task: `Добавь в общий шаблон разметку организации в JSON-LD с типом ${v.schemaType}: название, адрес сайта, логотип, телефон, почта${has ? ' и ссылки на профили, которые уже стоят на сайте,' : ''} в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец.`, verify: 'На главной есть JSON-LD организации с name и url, и валидатор разметки не показывает ошибок.' }
|
|
352
|
+
: { task: `Add organisation JSON-LD of type ${v.schemaType} to the shared template: name, site address, logo, phone, email${has ? ', and the profile links already on the site' : ''} in sameAs. Fill in only what is on the site or confirmed by the owner.`, verify: 'The home page carries organisation JSON-LD with name and url, and the validator shows no errors.' };
|
|
353
|
+
},
|
|
354
|
+
'agent-card': ({ ru }) => (ru
|
|
355
|
+
? { task: 'Спроси владельца, есть ли у компании свой ИИ-агент или сервис, который могут вызывать другие агенты. Если нет, ничего не делай: карточка агента описывает программного агента, а не компанию, и делать её ради галочки нельзя.', verify: 'Либо владелец подтвердил, что агента нет, либо /.well-known/agent.json отвечает 200 с типом application/json.' }
|
|
356
|
+
: { task: 'Ask the owner whether the business runs its own AI agent or a service other agents can call. If not, do nothing: an agent card describes a software agent, not a company, and making one for a tick is wrong.', verify: 'Either the owner confirmed there is no agent, or /.well-known/agent.json answers 200 with application/json.' }),
|
|
357
|
+
utility: ({ check, ru, platform }) => (/author/i.test(String(check.value)) && platform === 'wordpress' ? (ru
|
|
358
|
+
? { task: 'Выключи архивы авторов в плагине SEO (в Yoast: SEO, Отображение в поисковой выдаче, вкладка Архивы; в английском интерфейсе Search Appearance, Archives), тогда страница автора уйдёт из карты сайта. Если имя автора выглядит как логин (например, admin), смени отображаемое имя в профиле пользователя WordPress на название компании.', verify: 'В карте сайта нет страниц авторов, и в разметке страниц нет имени-логина.' }
|
|
359
|
+
: { task: 'Switch off author archives in the SEO plugin (Yoast: Search Appearance, Archives), so the author page leaves the sitemap. If the author name looks like a login (admin, say), change the display name in the WordPress user profile to the company name.', verify: 'The sitemap has no author pages, and the page markup carries no login-like name.' }) : null),
|
|
360
|
+
ttfb: ({ ru }) => (ru
|
|
361
|
+
? { task: 'Сначала сделай три замера из страны, где живут твои посетители. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы меряем не из твоей страны.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
|
|
362
|
+
: { task: 'First take three measurements from the country your visitors live in. If the answer takes over 0.8 seconds (the level Google calls good), turn on page caching: a cache plugin, host cache or a CDN. Do not move host on our measurement alone: we measure from elsewhere.', verify: 'Three runs in a row from your visitors\' country (curl -o /dev/null -w "%{time_starttransfer}" home URL) show 0.8 seconds or less.' }),
|
|
363
|
+
thin: ({ audit, ru, v }) => {
|
|
364
|
+
const sm = sampleOf(audit); const home = sm[0];
|
|
365
|
+
const homeH2 = (home?.h2List || []).join('|');
|
|
366
|
+
// Страницы, где все H2 те же, что на главной: это шаблон, обрамляющий почти пустую запись (14.10.2026, карточки врачей medeq-mc.ru).
|
|
367
|
+
const templated = homeH2 && (home.h2List || []).length >= 3 ? sm.slice(1).filter((p) => (p.h2List || []).join('|') === homeH2) : [];
|
|
368
|
+
if (templated.length >= 2) {
|
|
369
|
+
const list = templated.slice(0, 4).map((p) => pathOf(p.url)).join(', ');
|
|
370
|
+
return ru
|
|
371
|
+
? { task: `На ${templated.length} страницах выборки (например, ${list}) своего текста почти нет: шаблон показывает блоки главной вместо содержания записи. Сделай для таких записей свой шаблон: заголовок H1 с названием (для сотрудника ФИО), а дальше данные записи (должность, филиал, опыт). Если записи не нужны в поиске, закрой их noindex. Факты бери только у владельца.`, verify: 'На этих страницах есть свой H1 и свои данные записи, а блоки главной на них больше не повторяются (или страницы закрыты noindex по решению владельца).' }
|
|
372
|
+
: { task: `On ${templated.length} sampled pages (e.g. ${list}) there is almost no own text: the template shows home page blocks instead of the post. Give such posts their own template: an H1 with the name, then the post's own data. If they do not need to be in search, set noindex. Take facts only from the owner.`, verify: 'These pages carry their own H1 and data, and no longer repeat the home page blocks (or are noindexed by the owner\'s decision).' };
|
|
373
|
+
}
|
|
374
|
+
return ru
|
|
375
|
+
? { task: `Допиши короткие страницы фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
|
|
376
|
+
: { task: `Fill the short pages with facts the owner has and ${v.people} need: what it is, for whom, how, where and when. ${v.price} No filler for length. If there are no facts, list questions for the owner.`, verify: 'Each page you filled is now over 300 words thanks to facts (names, terms, places, timings), not general phrases.' };
|
|
377
|
+
},
|
|
378
|
+
'trust-policies': ({ ru }) => (ru
|
|
379
|
+
? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
|
|
380
|
+
: { task: 'The owner or a lawyer writes the privacy policy and terms: do not draft legal text. Once it exists, publish it as its own page, link it from the footer, and add a consent checkbox linking to it on every form that collects a name, phone or email.', verify: 'Every page footer links the policy, it answers 200, and every form has a consent checkbox.' }),
|
|
381
|
+
'trust-profiles': ({ check, ru }) => (/ссылки на сайте есть|pages link to/i.test(String(check.value)) ? (ru
|
|
382
|
+
? { task: 'Профили, на которые сайт уже ссылается, впиши в поле sameAs разметки организации. Спроси владельца, есть ли другие настоящие профили (карты, отраслевые каталоги, соцсети), и добавь только подтверждённые.', verify: 'В разметке организации есть sameAs со ссылками на профили, которые уже стоят на сайте, и каждая открывает профиль этой компании.' }
|
|
383
|
+
: { task: 'Put the profiles the site already links to into the sameAs field of the organisation markup. Ask the owner for any other real profiles (maps, directories, social) and add only confirmed ones.', verify: 'The organisation markup carries sameAs with the profiles already linked on the site, each opening this company\'s profile.' }) : null),
|
|
384
|
+
sources: ({ ru }) => (ru
|
|
385
|
+
? { task: 'Подпиши источник к каждой цифре статистики на сайте (доли, количества людей, сравнения «в N раз»): ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, цифру убери. Не ставь ссылку наугад.', verify: 'Рядом с каждой цифрой статистики есть ссылка на первоисточник или названный словами источник, либо цифра убрана.' }
|
|
386
|
+
: { task: 'Add a source to every statistic on the site (shares, counts of people, "N times" comparisons): a link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.', verify: 'Every statistic has a link or a named source beside it, or has been removed.' }),
|
|
387
|
+
'answer-first': ({ ru, v }) => (ru
|
|
388
|
+
? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом. Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру с названным источником.' }
|
|
389
|
+
: { task: `Rewrite the opening of the main pages so the first paragraph answers what ${v.people} ask: what it is, for whom, on what terms. Twenty to ninety words with a concrete figure from the owner's facts, its source named beside it. If there is no figure, do not invent one: ask the owner.`, verify: 'The first paragraph after the heading on those pages is 20 to 90 words and carries a figure with its source named.' }),
|
|
390
|
+
};
|
|
391
|
+
|
|
261
392
|
const HEAD = {
|
|
262
393
|
ru: { now: 'Сейчас', task: 'Задача', verify: 'Как проверить', pages: 'Страницы, где это видно', genericVerify: 'Прогони проверку сайта заново: эта строка должна перестать быть проблемой, а остальные не должны испортиться.', rule: 'Правило: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца и оставляй пометку.' },
|
|
263
394
|
en: { now: 'Now', task: 'Task', verify: 'How to check', pages: 'Pages where this shows', genericVerify: 'Run the site check again: this line must stop being a finding, and nothing else may get worse.', rule: 'Rule: invent no facts about the business. Anything not on the site and not told to you, ask the owner and leave a marker.' },
|
|
@@ -350,7 +481,13 @@ export function agentPrompt(check, opts = {}) {
|
|
|
350
481
|
const h = HEAD[lang];
|
|
351
482
|
const noForm = check.id === 'conv-form-depth' && /no form|формы нет/i.test(String(check.value || ''));
|
|
352
483
|
const key = noForm ? 'conv-form-missing' : promptKey(check.id);
|
|
353
|
-
|
|
484
|
+
// Задание по замеру (PF) важнее шаблонного (P): шаблон не знает, что на этом сайте на самом деле.
|
|
485
|
+
const ctx = { check, audit: opts.audit || null, lang, ru: lang === 'ru', v: vocabFor(opts.audit || {}, lang), platform: opts.platform || null };
|
|
486
|
+
const measured = PF[key] ? PF[key](ctx) : null;
|
|
487
|
+
const base = measured || (P[key] ? withLang(P[key], lang) : null);
|
|
488
|
+
// Правка проверяющего ИИ (review.mjs) важнее любого шаблона: она сверена с сайтом.
|
|
489
|
+
const over = opts.audit?.taskOverrides?.[check.id];
|
|
490
|
+
const own = over ? { task: over.task || base?.task || check.label, verify: over.verify || base?.verify || HEAD[lang].genericVerify } : base;
|
|
354
491
|
const rule = FIX_ACTIONS[check.id];
|
|
355
492
|
const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
|
|
356
493
|
const title = noForm ? (lang === 'ru' ? 'Форма заявки' : 'Enquiry form') : check.label;
|
|
@@ -372,11 +509,15 @@ export function agentPrompts(audit, opts = {}) {
|
|
|
372
509
|
// Русский файл по английскому аудиту: названия и значения проверок переводим, иначе владелец
|
|
373
510
|
// читает русское задание с английскими вставками и не понимает, про что оно.
|
|
374
511
|
const source = lang === 'ru' && audit.meta?.lang !== 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
375
|
-
|
|
512
|
+
// Карточка агента, каталог API и markdown нужны сайтам, где есть свой ИИ-сервис или API: клинике, магазину и
|
|
513
|
+
// агентству это задачи ради галочки (14.10.2026). Остальным нишам эти строки в задачи не идут.
|
|
514
|
+
const nicheNow = vocabFor(audit, lang).niche;
|
|
515
|
+
const AGENT_ONLY = new Set(['agent-card', 'api-catalog', 'agent-markdown']);
|
|
516
|
+
const checks = source.filter((c) => (c.status === 'bad' || c.status === 'warn') && !(AGENT_ONLY.has(c.id) && nicheNow !== 'b2b'));
|
|
376
517
|
const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
|
|
377
518
|
const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
378
519
|
const platform = opts.platform || detectPlatform(audit);
|
|
379
|
-
return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)]), text: agentPrompt(c, { lang, platform }) }));
|
|
520
|
+
return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)] || PF[promptKey(c.id)]), text: agentPrompt(c, { lang, platform, audit }) }));
|
|
380
521
|
}
|
|
381
522
|
|
|
382
523
|
/** Файл, который отдаётся клиенту вместе с отчётом. */
|
package/src/render.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* through headless Chrome. Every narrative field is read from the JSON; a field that still
|
|
4
4
|
* holds a {{placeholder}} is rendered highlighted so it cannot ship unnoticed.
|
|
5
5
|
*/
|
|
6
|
-
import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
import { writeFileSync, existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
10
10
|
import { computeScores, computeOverall } from './collect.mjs';
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
12
|
import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
|
|
@@ -35,7 +35,7 @@ const LABELS = {
|
|
|
35
35
|
// Подпись обязана сказать три вещи: откуда число, что именно успели прочитать, и почему
|
|
36
36
|
// повтор может дать на пункт-другой иначе. Без последнего человек читает разницу как ошибку.
|
|
37
37
|
overallNote: (o) => {
|
|
38
|
-
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''}. ` : '';
|
|
38
|
+
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''} for this score; the rest of the report counts the whole sample. ` : '';
|
|
39
39
|
const where = o.source === 'visibility:reused'
|
|
40
40
|
? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
|
|
41
41
|
: 'Measured here by the same code and the same settings as the free check at oper-stack.com/ai-visibility. ';
|
|
@@ -60,10 +60,10 @@ const LABELS = {
|
|
|
60
60
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
61
61
|
secContent: 'Content and on-page', contentEyebrow: '05 · Content', contentTitle: 'Content and on-page SEO', strengths: 'Strengths', weaknesses: 'Weaknesses', gaps: 'Structural gaps: pages that do not exist yet',
|
|
62
62
|
secAeo: 'AEO and GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Answer engines: AEO', aeoLead: 'Whether the site\'s answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.', aeoWorks: 'What already works', aeoBlocks: 'What blocks the wins', recommendation: 'Recommendation:', geoTitle: 'Generative engines: GEO', colSignal: 'Signal', colDetail: 'Detail',
|
|
63
|
-
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '
|
|
64
|
-
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same numbers.',
|
|
63
|
+
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '10 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
64
|
+
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same or very close numbers.',
|
|
65
65
|
limitations: 'Limitations',
|
|
66
|
-
secRoadmap: 'Roadmap', roadmapEyebrow: '
|
|
66
|
+
secRoadmap: 'Roadmap', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
67
67
|
runningHead: 'SEO, AEO and GEO audit',
|
|
68
68
|
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
69
69
|
areas: null,
|
|
@@ -83,7 +83,7 @@ const LABELS = {
|
|
|
83
83
|
overallNote: (o) => {
|
|
84
84
|
const p = o.basis ? o.basis.pages : 0;
|
|
85
85
|
const pl = ruPages(p);
|
|
86
|
-
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}
|
|
86
|
+
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}: это для балла; остальные разделы отчёта считаются по всей выборке. ` : '';
|
|
87
87
|
const where = o.source === 'visibility:reused'
|
|
88
88
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
89
89
|
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.ru/ai-visibility. ';
|
|
@@ -108,10 +108,10 @@ const LABELS = {
|
|
|
108
108
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
109
109
|
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страниц, которых пока нет',
|
|
110
110
|
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Может ли ответ с вашего сайта попасть в блок «Люди также спрашивают», в ответы ИИ, в ChatGPT и Perplexity.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
111
|
-
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '
|
|
112
|
-
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же цифры.',
|
|
111
|
+
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '10 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
112
|
+
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же или очень близкие цифры.',
|
|
113
113
|
limitations: 'Ограничения',
|
|
114
|
-
secRoadmap: 'План работ', roadmapEyebrow: '
|
|
114
|
+
secRoadmap: 'План работ', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
115
115
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
116
116
|
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
117
117
|
areas: AREAS_RU,
|
|
@@ -665,7 +665,8 @@ export function toHtml(audit, brand = null) {
|
|
|
665
665
|
* 54 пикселя, от верха листа до таблицы уходит 189, снизу отступ и подвал ещё 106.
|
|
666
666
|
* Остаётся около 790 пикселей, это четырнадцать строк вместе с шапкой таблицы.
|
|
667
667
|
*/
|
|
668
|
-
|
|
668
|
+
// Строки делятся поровну: 30 строк это три листа по 10, а не 14, 14 и лист с двумя строками (14.10.2026).
|
|
669
|
+
const APPENDIX_ROWS = Math.ceil(allSampleRows.length / Math.max(1, Math.ceil(allSampleRows.length / 14)));
|
|
669
670
|
for (let i = 0; i < allSampleRows.length; i += APPENDIX_ROWS) {
|
|
670
671
|
const chunk = allSampleRows.slice(i, i + APPENDIX_ROWS);
|
|
671
672
|
const part = Math.floor(i / APPENDIX_ROWS) + 1;
|
|
@@ -678,7 +679,66 @@ export function toHtml(audit, brand = null) {
|
|
|
678
679
|
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
679
680
|
pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
|
|
680
681
|
}
|
|
681
|
-
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
|
|
682
|
+
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}${FIT_SCRIPT}</body></html>`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
/*
|
|
687
|
+
* Подгонка листов перед печатью (14.10.2026). Chrome печатает каждый .page на A4, и всё, что не
|
|
688
|
+
* влезло, уходит на следующий лист: клиент получал отчёт с листом, где один подвал. Скрипт в самой
|
|
689
|
+
* странице до печати (1) переносит хвост длинной таблицы на новый лист, (2) малый перелив ужимает
|
|
690
|
+
* масштабом, не мельче 0.9, (3) перенумеровывает подвалы. Работает и у печати на сервере, потому что
|
|
691
|
+
* живёт в HTML, а не в печатающем коде. Проверка числа листов после печати: pdfPageCount.
|
|
692
|
+
*/
|
|
693
|
+
export const FIT_SCRIPT = `<script>(document.fonts?document.fonts.ready:Promise.resolve()).then(function(){var H=297*96/25.4;function rowsOf(t){return Array.prototype.filter.call(t.querySelectorAll('tr'),function(r){return !r.querySelector('th')})}
|
|
694
|
+
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i];if(el.scrollHeight<=H+1)continue;var tbs=el.querySelectorAll('table');if(!tbs.length)continue;var tb=tbs[tbs.length-1],rows=rowsOf(tb);if(el.scrollHeight/H<1.06||rows.length<4)continue;
|
|
695
|
+
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;
|
|
696
|
+
for(var j=k;j<rows.length;j++)rows[j].parentNode.removeChild(rows[j]);for(var j2=0;j2<k;j2++)crows[j2].parentNode.removeChild(crows[j2]);
|
|
697
|
+
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
698
|
+
var top=tb;while(top.parentNode&&top.parentNode!==el)top=top.parentNode;var nx=top.nextElementSibling;while(nx){var after=nx.nextElementSibling;if(!/page-footer/.test(nx.className||''))nx.parentNode.removeChild(nx);nx=after}el.parentNode.insertBefore(clone,el.nextSibling);did=true;break}if(!did)break}
|
|
699
|
+
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.9,Math.floor((H-2)/h*1000)/1000);el.style.zoom=z;el.style.height=(H/z)+'px';el.style.minHeight=(H/z)+'px';el.style.overflow='hidden';if(z===0.9&&h*z>H+1)document.documentElement.setAttribute('data-overflow','1')}});
|
|
700
|
+
var no=0;Array.prototype.forEach.call(document.querySelectorAll('.page-footer span:last-child'),function(s){if(/^\\d+$/.test(s.textContent.trim()))s.textContent=String(++no)});
|
|
701
|
+
document.documentElement.setAttribute('data-pages',String(document.querySelectorAll('.page').length))});</script>`;
|
|
702
|
+
|
|
703
|
+
/** Сколько листов в PDF: по объектам /Type /Page, без сторонних библиотек. */
|
|
704
|
+
export function pdfPageCount(buf) {
|
|
705
|
+
const s = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
|
|
706
|
+
return (s.match(/\/Type\s*\/Page(?![s\w])/g) || []).length;
|
|
707
|
+
}
|
|
708
|
+
/** Сколько блоков .page в HTML до подгонки: подгонка может только добавить листы с хвостом таблицы. */
|
|
709
|
+
export function htmlPageCount(html) {
|
|
710
|
+
return (String(html).match(/<div class="page[ "]/g) || []).length;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
/*
|
|
715
|
+
* Печать в два шага (14.10.2026). Сначала Chrome открывает отчёт и отдаёт DOM после подгонки листов
|
|
716
|
+
* (FIT_SCRIPT), потом печатается уже подогнанный DOM. Так мы знаем, сколько листов должно выйти, и
|
|
717
|
+
* сверяем с тем, сколько вышло. Не сошлось или что-то не влезло даже после ужатия: PDF не отдаём,
|
|
718
|
+
* отчёт с пустым листом клиенту не уходит.
|
|
719
|
+
*/
|
|
720
|
+
export function printFitted(chrome, htmlFileIn, pdfPathIn) {
|
|
721
|
+
// Относительный путь давал Chrome адрес file://report.html и ошибку RR_INVALID_URL.
|
|
722
|
+
const htmlFile = resolvePath(htmlFileIn); const pdfPath = resolvePath(pdfPathIn);
|
|
723
|
+
const run = (args) => {
|
|
724
|
+
const profile = mkdtempSync(join(tmpdir(), 'operstack-audit-'));
|
|
725
|
+
const r = spawnSync(chrome, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`, '--virtual-time-budget=8000', ...args], { encoding: 'utf8', timeout: 90000, maxBuffer: 64 * 1024 * 1024 });
|
|
726
|
+
try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* временная папка */ }
|
|
727
|
+
return r;
|
|
728
|
+
};
|
|
729
|
+
const dom = run(['--dump-dom', `file://${htmlFile}`]);
|
|
730
|
+
const fitted = String(dom.stdout || '');
|
|
731
|
+
const want = Number((fitted.match(/data-pages="(\d+)"/) || [])[1]);
|
|
732
|
+
if (!want) return { ok: false, error: `layout gate: Chrome did not lay the report out${dom.stderr ? `: ${dom.stderr.slice(-300)}` : ''}` };
|
|
733
|
+
if (/data-overflow="1"/.test(fitted)) return { ok: false, error: 'layout gate: a page does not fit A4 even at 90% scale; shorten it before sending' };
|
|
734
|
+
const fittedFile = htmlFile.replace(/\.html?$/, '') + '.fitted.html';
|
|
735
|
+
writeFileSync(fittedFile, `<!doctype html>${fitted.replace(/<script>\(document\.fonts[\s\S]*?<\/script>/, '')}`);
|
|
736
|
+
const r = run(['--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, `file://${fittedFile}`]);
|
|
737
|
+
rmSync(fittedFile, { force: true });
|
|
738
|
+
if (!existsSync(pdfPath)) return { ok: false, error: `Chrome did not write the PDF${r.stderr ? `: ${r.stderr.slice(-300)}` : ''}` };
|
|
739
|
+
const got = pdfPageCount(readFileSync(pdfPath));
|
|
740
|
+
if (got !== want) { rmSync(pdfPath, { force: true }); return { ok: false, error: `layout gate: ${got} sheets printed for ${want} report pages, so some sheet is a spill-over; the PDF was not kept` }; }
|
|
741
|
+
return { ok: true, pages: got };
|
|
682
742
|
}
|
|
683
743
|
|
|
684
744
|
function findChrome() {
|
|
@@ -695,10 +755,8 @@ export async function render(audit, { out, pdf = false, brand = null } = {}) {
|
|
|
695
755
|
if (!chrome) { console.error('no Chrome found; set CHROME_PATH or open the HTML and print to PDF'); }
|
|
696
756
|
else {
|
|
697
757
|
pdfPath = out.replace(/\.html?$/, '') + '.pdf';
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
rmSync(profile, { recursive: true, force: true });
|
|
701
|
-
if (!existsSync(pdfPath)) { console.error(`Chrome did not write the PDF${r.stderr ? `: ${r.stderr.slice(-300)}` : ''}`); pdfPath = null; }
|
|
758
|
+
const r = printFitted(chrome, out, pdfPath);
|
|
759
|
+
if (!r.ok) { console.error(r.error); pdfPath = null; }
|
|
702
760
|
}
|
|
703
761
|
}
|
|
704
762
|
return { html, pdf: pdfPath };
|
package/src/rendered.mjs
CHANGED
|
@@ -52,6 +52,13 @@ export function findChrome() {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
|
|
55
|
+
/** Chrome после остановки ещё дописывает профиль: без повторов rm падал ENOTEMPTY на macOS и ронял
|
|
56
|
+
* весь сбор (brainmd.com, 12.10.2026). Временную папку не удалось убрать, значит уберёт система:
|
|
57
|
+
* ронять из-за неё замер нельзя. */
|
|
58
|
+
export function removeProfile(dir, rm = rmSync) {
|
|
59
|
+
try { rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); return true; } catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
|
|
56
63
|
const bin = chrome || findChrome();
|
|
57
64
|
if (!bin) return Promise.resolve(null);
|
|
@@ -88,7 +95,7 @@ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 80
|
|
|
88
95
|
if (value && isBrowserErrorPage(value)) value = null;
|
|
89
96
|
clearTimeout(timer);
|
|
90
97
|
stopGroup();
|
|
91
|
-
|
|
98
|
+
removeProfile(profile);
|
|
92
99
|
resolve(value);
|
|
93
100
|
};
|
|
94
101
|
const stopGroup = () => {
|
|
@@ -159,7 +166,10 @@ export function jsBlindnessCheck(pages, lang = 'en') {
|
|
|
159
166
|
id: 'js-content', group: 'technical',
|
|
160
167
|
label: lang === 'ru' ? 'Виден ли текст без выполнения скриптов' : 'Text visible without running JavaScript',
|
|
161
168
|
status: 'na',
|
|
162
|
-
|
|
169
|
+
// Браузер есть, но страница не догрузилась (14.10.2026: на medeq-mc.ru висел скрипт Яндекс.Карт): клиенту про «машину» не пишем.
|
|
170
|
+
value: findChrome()
|
|
171
|
+
? (lang === 'ru' ? 'не измеряли: страница не догрузилась в браузере за отведённое время (часто это зависший внешний скрипт, например карты)' : 'not measured: the page did not finish loading in a browser in time (often a stuck third-party script such as a map)')
|
|
172
|
+
: (lang === 'ru' ? 'не измеряли в этой проверке' : 'not measured in this check'),
|
|
163
173
|
comment: lang === 'ru' ? 'проверка сравнивает страницу, прочитанную без скриптов, с ней же в настоящем браузере' : 'the check compares the page read without scripts against the same page in a real browser',
|
|
164
174
|
};
|
|
165
175
|
}
|