@operstack/audit 0.8.0 → 0.9.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/README.md CHANGED
@@ -58,6 +58,16 @@ npx @operstack/audit render audit.json --pdf
58
58
 
59
59
  Technically: `draft` fills the narrative fields (`summary`, `client.subject`, `overview.note`, `content`, `aeo`, `geo`, `offpage`, `conversion`, `roadmap`, `closing`) from the collected checks. Priorities and roadmap items are written as actions, taken from the same table the Fix checklist uses, so a finding and the work that closes it read the same. Fields an analyst has already written are left alone; anything still empty is listed at the end. For a Russian report over an English audit, check and area names are translated first.
60
60
 
61
+ ## Handing the work to an AI agent: `prompts`
62
+
63
+ **If you do not edit the site yourself.** Most people who read an audit now fix their site by telling an AI assistant what to do. This turns every finding into a task written for that assistant: copy it, paste it into Cursor or Claude Code, and it does the work. Each task says what the state is today, what to change, and how you will know it is finished, so the assistant cannot wander off and cannot quietly invent facts about your business.
64
+
65
+ ```
66
+ npx @operstack/audit prompts audit.json --lang en --limit 3
67
+ ```
68
+
69
+ Technically: every open check is rendered as a Markdown block with `Now` (the measured value), `Task` (the action and its boundaries) and `How to check` (the acceptance test), plus a standing rule against inventing business facts. Twenty checks carry text written for that specific finding; the rest fall back to the Fix action with a generic acceptance test. `--limit 3` is what the free tier hands out; the full list ships with the paid audit. `renderFixChecklist(plan, { withPrompts: true })` embeds the same tasks in the executor checklist.
70
+
61
71
  ## The Fix package: turning findings into agreed work
62
72
 
63
73
  An audit says what is wrong. `fix-plan` says what we will do about it, before anyone pays.
package/bin/audit.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  * the text work the Foundation package would do, page by page, with the price it adds up to
17
17
  * operstack-audit draft audit.json [--lang ru|en] [--out audit.json]
18
18
  * fills the narrative fields from the checks so the analyst edits instead of writing
19
+ * operstack-audit prompts audit.json [--lang ru|en] [--limit 3] [--out prompts.md]
20
+ * every finding rewritten as a task an AI agent can execute
19
21
  * operstack-audit check audit.json
20
22
  * lists narrative fields still holding placeholders and any score the checks do not support
21
23
  *
@@ -26,6 +28,7 @@ import { collect, verifyScores } from '../src/collect.mjs';
26
28
  import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport } from '../src/fix.mjs';
27
29
  import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from '../src/foundation.mjs';
28
30
  import { draftNarrative, stillEmpty } from '../src/narrative.mjs';
31
+ import { renderAgentPrompts, agentPrompts } from '../src/prompts.mjs';
29
32
  import { render, checkNarrative } from '../src/render.mjs';
30
33
  import { writeFileSync, readFileSync } from 'node:fs';
31
34
  import { resolve } from 'node:path';
@@ -43,6 +46,8 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
43
46
  ' render the report; --pdf also prints a PDF with headless Chrome',
44
47
  ' operstack-audit draft <audit.json> [--lang ru|en] [--out audit.json]',
45
48
  ' fill the narrative from the checks: the analyst edits a draft instead of writing one',
49
+ ' operstack-audit prompts <audit.json> [--lang ru|en] [--limit 3] [--out prompts.md]',
50
+ ' every finding rewritten as a task you can paste into Cursor or Claude Code',
46
51
  ' operstack-audit check <audit.json>',
47
52
  ' list narrative fields still holding placeholders and any score the checks do not support',
48
53
  '',
@@ -92,6 +97,15 @@ if (cmd === 'collect') {
92
97
  if (after.length) for (const f of after) console.log(` still yours to write: ${f}`);
93
98
  console.log(`wrote ${out}`);
94
99
  console.log('read it before sending: the draft states only what was measured, and says so where nothing was.');
100
+ } else if (cmd === 'prompts') {
101
+ const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
102
+ const lang = opt('--lang', audit.meta?.lang || 'en');
103
+ const limit = Number(opt('--limit', 0));
104
+ const list = agentPrompts(audit, { lang, limit });
105
+ const out = resolve(opt('--out', target.replace(/\.json$/, '') + '-prompts.md'));
106
+ writeFileSync(out, renderAgentPrompts(audit, { lang, limit }));
107
+ console.log(`${list.length} task(s): ${list.filter((x) => x.hasOwnText).length} written for that finding, ${list.filter((x) => !x.hasOwnText).length} generic`);
108
+ console.log(`wrote ${out}`);
95
109
  } else if (cmd === 'fix-plan') {
96
110
  const audit = JSON.parse(readFileSync(resolve(target), 'utf8'));
97
111
  // Язык покупки решает и валюту, и письмо: за доллары платят по английскому списку, за рубли по русскому.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,8 @@
14
14
  "./i18n": "./src/i18n.mjs",
15
15
  "./fix": "./src/fix.mjs",
16
16
  "./foundation": "./src/foundation.mjs",
17
- "./narrative": "./src/narrative.mjs"
17
+ "./narrative": "./src/narrative.mjs",
18
+ "./prompts": "./src/prompts.mjs"
18
19
  },
19
20
  "files": [
20
21
  "bin",
@@ -25,7 +26,7 @@
25
26
  "LICENSE"
26
27
  ],
27
28
  "scripts": {
28
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
29
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
29
30
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
30
31
  },
31
32
  "keywords": [
package/src/fix.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  * с английскими названиями проверок это брак, который клиент видит первым.
20
20
  */
21
21
  import { localiseChecks } from './i18n.mjs';
22
+ import { agentPrompt } from './prompts.mjs';
22
23
 
23
24
  /** Что мы делаем по каждой проверке. Без этого «закроем» это слово, а не работа. */
24
25
  export const FIX_ACTIONS = {
@@ -220,14 +221,24 @@ export function renderFixPlan(plan) {
220
221
  return L.join('\n');
221
222
  }
222
223
 
223
- /** Чек-лист исполнителю: тот же список, но по порядку работы. */
224
- export function renderFixChecklist(plan) {
224
+ /**
225
+ * Чек-лист исполнителю: тот же список, но по порядку работы.
226
+ * @param {object} plan результат buildFixPlan
227
+ * @param {{withPrompts?: boolean}} opts withPrompts добавляет задания для ИИ-агента: работа по ним
228
+ * идёт быстрее, потому что исполнитель не переписывает задачу своими словами.
229
+ */
230
+ export function renderFixChecklist(plan, opts = {}) {
225
231
  const t = dict(plan.lang);
226
232
  const L = [t.listTitle(plan.host), '', t.listHead(plan.platform, plan.price, plan.currency, plan.share), ''];
227
233
  plan.included.forEach((x, i) => {
228
234
  L.push(`- [ ] ${i + 1}. ${x.check.label} (\`${x.check.id}\`)`);
229
235
  L.push(` ${t.was}: ${x.check.value}`);
230
236
  L.push(` ${t.todo}: ${x.action}`);
237
+ if (opts.withPrompts) {
238
+ L.push('');
239
+ L.push(agentPrompt(x.check, { lang: plan.lang }).split('\n').map((line) => ` ${line}`).join('\n'));
240
+ L.push('');
241
+ }
231
242
  });
232
243
  L.push('');
233
244
  L.push(t.listTail);
package/src/index.mjs CHANGED
@@ -4,3 +4,4 @@ export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18
4
4
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
5
5
  export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
6
6
  export { draftNarrative, stillEmpty } from './narrative.mjs';
7
+ export { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.mjs';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Находка превращается в задание для ИИ-агента.
3
+ *
4
+ * Наш покупатель правит сайт не руками: он открывает Cursor или Claude Code и говорит агенту, что
5
+ * сделать. До сих пор мы отдавали ему список «что не так», а формулировку задания он придумывал
6
+ * сам. Здесь это делается за него: каждая найденная проблема выходит текстом, который вставляется
7
+ * в агента и исполняется без правок.
8
+ *
9
+ * У каждого задания три обязательные части, иначе агент делает не то:
10
+ * 1. Что сейчас: измеренное число, чтобы агент знал точку отсчёта и не чинил уже целое.
11
+ * 2. Что сделать: одно действие, границы, и прямой запрет выдумывать факты о бизнесе.
12
+ * 3. Как проверить: признак, по которому видно, что работа сделана, а не «кажется сделана».
13
+ *
14
+ * Заданий с собственным текстом столько, сколько проверок мы умеем чинить осмысленно. Для
15
+ * остальных собирается общее задание из описания работы: оно короче, но не врёт.
16
+ */
17
+ import { FIX_ACTIONS } from './fix.mjs';
18
+ import { localiseChecks } from './i18n.mjs';
19
+
20
+ const P = {
21
+ alt: {
22
+ ru: { task: 'Пройди по шаблонам и контенту сайта и проставь атрибут alt каждой картинке, у которой его нет или он пустой. Подпись описывает своими словами, что изображено, от пяти до пятнадцати слов, без перечисления ключевых слов. Если картинка чисто декоративная, поставь пустой alt="", это правильно и намеренно. Если по имени файла и окружающему тексту непонятно, что на картинке, не выдумывай: собери такие файлы в список в конце ответа и не трогай их.', verify: 'В собранном HTML не осталось картинок без атрибута alt.' },
23
+ en: { task: 'Go through the site templates and content and give every image an alt attribute where it is missing or empty. The text describes in plain words what the image shows, five to fifteen words, no keyword lists. Purely decorative images take an empty alt="", deliberately. Where the file name and the surrounding text do not tell you what the image shows, do not invent it: list those files at the end and leave them alone.', verify: 'No image in the built HTML is left without an alt attribute.' },
24
+ },
25
+ 'conv-cta': {
26
+ ru: { task: 'Поставь на первый экран каждой важной страницы одно понятное действие: кнопку или ссылку, которая говорит, что произойдёт после нажатия («Получить расчёт», «Посмотреть цены», «Написать в Telegram»). Одно действие на экран, не три. Текст кнопки бери из того, что реально происходит дальше, не выдумывай новых обещаний и сроков.', verify: 'На каждой такой странице в верхних тридцати процентах высоты есть кликабельное действие.' },
27
+ en: { task: 'Put one clear thing to do in the first screen of every page that matters: a button or link that says what happens next ("Get a quote", "See prices", "Message us"). One action per screen, not three. Take the wording from what actually happens next; do not invent new promises or timings.', verify: 'Every such page has a clickable action within the top thirty percent of its height.' },
28
+ },
29
+ 'conv-messenger': {
30
+ ru: { task: 'Добавь в подвал и на страницу контактов прямую ссылку на мессенджер, которым компания действительно пользуется. Ссылка должна открывать диалог, а не страницу профиля. Если ты не знаешь, какой мессенджер используется, не выбирай его сам: оставь место и спроси владельца.', verify: 'Ссылка открывается и ведёт в диалог, а не на общую страницу.' },
31
+ en: { task: 'Add a direct messenger link to the footer and the contact page, for a messenger the business actually uses. The link opens a conversation, not a profile page. If you do not know which messenger they use, do not choose one: leave the slot and ask the owner.', verify: 'The link opens and starts a conversation rather than a general page.' },
32
+ },
33
+ robots: {
34
+ ru: { task: 'Перепиши robots.txt: убери запреты, которые закрывают обычные страницы сайта, оставь закрытыми только служебные адреса (корзина, личный кабинет, поиск по сайту, страницы благодарности). В конце добавь строку Sitemap с полным адресом карты сайта. Ничего не закрывай «на всякий случай»: каждый запрет должен иметь причину.', verify: 'Главная и разделы сайта открыты для всех роботов, в файле есть строка Sitemap, и она отдаёт карту по указанному адресу.' },
35
+ en: { task: 'Rewrite robots.txt: drop the rules that block ordinary pages, keep blocked only the utility addresses (cart, account, site search, thank-you pages). Add a Sitemap line with the full address at the end. Block nothing "just in case": every rule needs a reason.', verify: 'The homepage and sections are open to all robots, the file has a Sitemap line, and that address serves the sitemap.' },
36
+ },
37
+ 'ai-search-access': {
38
+ ru: { task: 'Открой в robots.txt роботов, которые достают страницу, чтобы процитировать её в ответе ИИ: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, DuckAssistBot, Applebot. Это не те роботы, что обучают модели, это те, что приносят вас в ответ на вопрос покупателя. Обучающие краулеры (GPTBot, CCBot, ClaudeBot, Google-Extended) оставь как есть: закрывать их или нет, решает владелец.', verify: 'В robots.txt ни один из перечисленных восьми агентов не запрещён.' },
39
+ en: { task: 'In robots.txt, allow the fetchers that pull a page to quote it in an AI answer: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, DuckAssistBot, Applebot. These are not the training crawlers; these are the ones that bring you into an answer to a buyer\'s question. Leave the training crawlers (GPTBot, CCBot, ClaudeBot, Google-Extended) as they are: blocking them is the owner\'s call.', verify: 'None of those eight agents is disallowed in robots.txt.' },
40
+ },
41
+ llms: {
42
+ ru: { task: 'Сгенерируй файл llms.txt в корне сайта: заголовок с названием компании и одной строкой о том, чем она занимается, затем разделы по типам страниц, в каждом ссылки вида «- [Заголовок](полный адрес): одно предложение о чём страница». Бери только страницы, открытые для индексации. Не включай страницы с noindex, служебные адреса и то, что отвечает перенаправлением.', verify: 'Файл отдаётся по адресу /llms.txt, все ссылки в нём ведут на страницы этого же сайта и отвечают кодом 200.' },
43
+ en: { task: 'Generate an llms.txt at the site root: a heading with the company name and one line on what it does, then sections by page type, each holding links shaped "- [Title](full URL): one sentence on what the page is". Include only pages open to indexing. Leave out noindex pages, utility addresses, and anything that answers with a redirect.', verify: 'The file is served at /llms.txt, and every link in it points at this site and answers 200.' },
44
+ },
45
+ sitemap: {
46
+ ru: { task: 'Почини карту сайта: она должна содержать все страницы, открытые для индексации, и только их. Убери из неё служебные адреса, страницы с noindex и адреса, отвечающие перенаправлением. Проставь честную дату последнего изменения для каждой страницы, не сегодняшнюю дату для всех.', verify: 'Карта отдаётся по адресу из robots.txt, все адреса в ней отвечают кодом 200, дат «сегодня» у всех страниц нет.' },
47
+ en: { task: 'Fix the sitemap: it must list every page open to indexing and nothing else. Remove utility addresses, noindex pages and anything that answers with a redirect. Give each page an honest last-modified date, not today\'s date for all of them.', verify: 'The sitemap is served at the address in robots.txt, every URL in it answers 200, and the dates are not all today.' },
48
+ },
49
+ title: {
50
+ ru: { task: 'Перепиши заголовок страницы (тег title) так, чтобы он укладывался в 60 знаков и говорил, что на странице, словами, которыми это ищет человек. Название компании в конце только если остаётся место. Не повторяй одно и то же слово дважды и не пиши список ключевых слов через запятую.', verify: 'Длина title не больше 60 знаков, и он отличается от заголовков других страниц.' },
51
+ en: { task: 'Rewrite the page title so it fits 60 characters and says what is on the page, in the words a person would search with. Put the company name at the end only if there is room. Do not repeat a word twice and do not write a comma-separated keyword list.', verify: 'The title is 60 characters or fewer and differs from other pages\' titles.' },
52
+ },
53
+ description: {
54
+ ru: { task: 'Перепиши описание страницы (meta description) в 120-160 знаков: что человек найдёт на странице и почему ему стоит зайти. Одно предложение, без рекламных восклицаний. Убери из него любые остатки кода и шорткодов. Не обещай того, чего на странице нет.', verify: 'Описание есть, его длина от 120 до 160 знаков, и оно описывает именно эту страницу.' },
55
+ en: { task: 'Rewrite the meta description in 120 to 160 characters: what the reader will find on the page and why it is worth opening. One sentence, no exclamation marks. Strip any leftover code or shortcodes. Do not promise anything the page does not have.', verify: 'The description exists, is 120 to 160 characters, and describes this page in particular.' },
56
+ },
57
+ h1: {
58
+ ru: { task: 'Оставь на странице ровно один заголовок первого уровня (H1), он должен совпадать по смыслу с тем, что на странице. Остальные крупные заголовки переведи в H2. Не делай H1 из логотипа и из названия сайта в шапке.', verify: 'На странице ровно один H1, и он про содержание страницы, а не про название сайта.' },
59
+ en: { task: 'Leave exactly one first-level heading (H1) on the page, matching what the page is about. Demote the other large headings to H2. Do not make an H1 out of the logo or the site name in the header.', verify: 'The page has exactly one H1, and it is about the page rather than the site name.' },
60
+ },
61
+ canonical: {
62
+ ru: { task: 'Проставь на каждой странице канонический адрес (link rel="canonical") с полным адресом этой же страницы, включая протокол и домен, без параметров отслеживания. На страницах с постраничной навигацией канонический адрес указывает на саму страницу, а не на первую.', verify: 'На каждой странице есть канонический адрес, он абсолютный и указывает на неё саму.' },
63
+ en: { task: 'Give every page a canonical link with the full address of that same page, protocol and domain included, without tracking parameters. On paginated pages the canonical points at the page itself, not at page one.', verify: 'Every page has a canonical link, absolute, pointing at itself.' },
64
+ },
65
+ og: {
66
+ ru: { task: 'Добавь на страницы карточки для соцсетей и мессенджеров: og:title, og:description, og:image с картинкой не меньше 1200 на 630 точек, og:url. Заголовок и описание бери со страницы, не выдумывай новых. Проверь, что картинка отдаётся по прямой ссылке без авторизации.', verify: 'При вставке ссылки в мессенджер появляется карточка с заголовком, описанием и картинкой.' },
67
+ en: { task: 'Add social cards to the pages: og:title, og:description, og:image at 1200 by 630 or larger, og:url. Take the title and description from the page itself; do not write new ones. Check the image is served over a plain link without authentication.', verify: 'Pasting the link into a messenger shows a card with title, description and image.' },
68
+ },
69
+ schema: {
70
+ ru: { task: 'Добавь на главную страницу разметку организации (schema.org Organization) в формате JSON-LD: название, адрес сайта, логотип, город и страна, почта или телефон, и ссылки на профили компании в других местах. Заполняй только тем, что действительно есть: выдуманный адрес или несуществующий профиль хуже, чем его отсутствие.', verify: 'Разметка проходит проверку в Rich Results Test, и каждое поле в ней соответствует правде.' },
71
+ en: { task: 'Add Organization markup (schema.org, JSON-LD) to the homepage: name, site address, logo, city and country, email or phone, and links to the company\'s profiles elsewhere. Fill it only with what actually exists: an invented address or a profile that is not yours is worse than nothing.', verify: 'The markup passes the Rich Results Test and every field in it is true.' },
72
+ },
73
+ 'faq-schema': {
74
+ ru: { task: 'Найди страницы, где уже есть блок вопросов и ответов, и размечай их схемой FAQPage в JSON-LD. Текст в разметке должен слово в слово совпадать с тем, что видит человек на странице. Не добавляй в разметку вопросы, которых на странице нет.', verify: 'Разметка проходит проверку, и каждый вопрос из неё виден на самой странице.' },
75
+ en: { task: 'Find the pages that already carry a questions-and-answers block and mark them up as FAQPage in JSON-LD. The text in the markup must match word for word what a person sees on the page. Do not add questions that are not on the page.', verify: 'The markup validates and every question in it is visible on the page itself.' },
76
+ },
77
+ 'trust-entity': {
78
+ ru: { task: 'Допиши в разметку организации город и страну (PostalAddress с addressLocality и addressCountry). Это то, по чему ИИ понимает, кто вы и откуда. Бери реальный адрес компании: если публиковать его полностью не хочется, достаточно города и страны.', verify: 'В разметке организации есть город и страна, и они совпадают с действительностью.' },
79
+ en: { task: 'Add the city and country to the Organization markup (PostalAddress with addressLocality and addressCountry). This is how an AI knows who you are and where. Use the real address: if you would rather not publish it in full, the city and country are enough.', verify: 'The Organization markup carries a city and country, and they are true.' },
80
+ },
81
+ 'trust-contact': {
82
+ ru: { task: 'Опубликуй прямые контакты на странице контактов и в подвале: почта, мессенджер, форма. Те же контакты продублируй в разметке организации (contactPoint). Публикуй только те адреса, которые действительно читают: неотвечающий ящик хуже, чем его отсутствие.', verify: 'Контакты видны на странице и присутствуют в разметке, письмо на указанный адрес доходит.' },
83
+ en: { task: 'Publish direct contacts on the contact page and in the footer: email, messenger, form. Mirror the same contacts in the Organization markup (contactPoint). Publish only addresses somebody actually reads: a mailbox nobody opens is worse than none.', verify: 'The contacts are visible on the page and present in the markup, and mail to that address arrives.' },
84
+ },
85
+ dates: {
86
+ ru: { task: 'Выведи на страницах дату публикации и дату последнего изменения: видимой строкой для человека и полями datePublished и dateModified в разметке. Дата изменения должна меняться только тогда, когда текст действительно правился, а не при каждой сборке сайта.', verify: 'На странице видна дата, в разметке есть оба поля, и дата изменения не равна дате сборки.' },
87
+ en: { task: 'Expose the published and last-modified dates on the pages: as a visible line for the reader and as datePublished and dateModified in the markup. The modified date changes only when the text actually changed, not on every build.', verify: 'The page shows a date, the markup carries both fields, and the modified date is not simply the build date.' },
88
+ },
89
+ 'answer-first': {
90
+ ru: { task: 'Перепиши начало страницы так, чтобы первый абзац сразу отвечал на вопрос, ради которого страницу открыли: от двадцати до девяноста слов, и в нём должна быть конкретная цифра (цена, срок, доля, количество). Цифру бери у владельца сайта и рядом назови, откуда она. Если цифры нет, не придумывай её: спроси владельца и оставь пометку.', verify: 'Первый абзац после заголовка укладывается в 20-90 слов и содержит цифру с названным источником.' },
91
+ en: { task: 'Rewrite the opening so the first paragraph answers the question the page was opened for: twenty to ninety words, carrying a concrete figure (a price, a timing, a share, a count). Get the figure from the site owner and name its source beside it. If there is no figure, do not invent one: ask the owner and leave a marker.', verify: 'The first paragraph after the heading is 20 to 90 words and carries a figure with a named source.' },
92
+ },
93
+ sources: {
94
+ ru: { task: 'Пройди по абзацам, где есть цифры, и подпиши к каждой, откуда она: ссылкой на первоисточник или словами («по данным реестра за 2026 год»). Если источника нет ни у тебя, ни у владельца, цифру надо убрать, а не оставлять голой. Не ставь ссылку наугад: неверный источник хуже отсутствующего.', verify: 'В каждом абзаце с цифрой есть либо ссылка на чужой сайт, либо названный словами источник.' },
95
+ en: { task: 'Go through the paragraphs carrying figures and name where each came from: a link to the primary source, or words ("per the 2026 registry"). If neither you nor the owner has a source, the figure comes out rather than standing bare. Do not guess a link: a wrong source is worse than none.', verify: 'Every paragraph with a figure carries either an outbound link or a source named in words.' },
96
+ },
97
+ thin: {
98
+ ru: { task: 'Возьми страницы короче трёхсот слов и допиши их фактами, которые знает владелец: условия, цены, сроки, ограничения, частые вопросы покупателей с ответами. Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов и оставь страницу как есть.', verify: 'Страница стала длиннее трёхсот слов, и каждый добавленный абзац содержит факт, а не общие слова.' },
99
+ en: { task: 'Take the pages under three hundred words and fill them with facts the owner has: terms, prices, timings, limits, the questions buyers actually ask with the answers. Do not pad for length. Where the facts are missing, write the owner a list of questions and leave the page alone.', verify: 'The page is over three hundred words and every added paragraph carries a fact rather than filler.' },
100
+ },
101
+ };
102
+
103
+ const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
104
+
105
+ const HEAD = {
106
+ ru: { now: 'Сейчас', task: 'Задача', verify: 'Как проверить', pages: 'Страницы, где это видно', genericVerify: 'Прогони проверку сайта заново: эта строка должна перестать быть проблемой, а остальные не должны испортиться.', rule: 'Правило: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца и оставляй пометку.' },
107
+ 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.' },
108
+ };
109
+
110
+ /**
111
+ * Задание для ИИ-агента по одной находке.
112
+ * @param {object} check строка проверки из аудита
113
+ * @param {{lang?: 'ru'|'en', urls?: string[]}} opts
114
+ */
115
+ export function agentPrompt(check, opts = {}) {
116
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
117
+ const h = HEAD[lang];
118
+ const own = P[check.id] ? withLang(P[check.id], lang) : null;
119
+ const rule = FIX_ACTIONS[check.id];
120
+ const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
121
+
122
+ const lines = [`### ${check.label}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
123
+ // Проверка обязательна у каждого задания: без неё агент не знает, когда остановиться.
124
+ lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
125
+ if (opts.urls && opts.urls.length) { lines.push('', `**${h.pages}:** ${opts.urls.slice(0, 8).join(', ')}`); }
126
+ lines.push('', h.rule);
127
+ return lines.join('\n');
128
+ }
129
+
130
+ /** Все задания по открытым находкам аудита, в порядке важности. */
131
+ export function agentPrompts(audit, opts = {}) {
132
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
133
+ const limit = Number(opts.limit ?? 0);
134
+ // Русский файл по английскому аудиту: названия и значения проверок переводим, иначе владелец
135
+ // читает русское задание с английскими вставками и не понимает, про что оно.
136
+ const source = lang === 'ru' && audit.meta?.lang !== 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
137
+ const checks = source.filter((c) => c.status === 'bad' || c.status === 'warn');
138
+ const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
139
+ const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
140
+ return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[c.id]), text: agentPrompt(c, { lang }) }));
141
+ }
142
+
143
+ /** Файл, который отдаётся клиенту вместе с отчётом. */
144
+ export function renderAgentPrompts(audit, opts = {}) {
145
+ const lang = opts.lang === 'ru' ? 'ru' : 'en';
146
+ const host = audit.meta?.host || '';
147
+ const list = agentPrompts(audit, opts);
148
+ const intro = lang === 'ru'
149
+ ? [`# Задания для ИИ-агента: ${host}`, '',
150
+ 'Это тот же список находок, что в отчёте, но переписанный так, чтобы его можно было отдать ИИ-агенту (Cursor, Claude Code, любому другому) и он сделал работу. Копируй задание целиком, вместе со строками «Сейчас» и «Как проверить»: без них агент не поймёт, откуда начинать и чем закончить.', '',
151
+ 'Делайте по одному заданию за раз и проверяйте результат, прежде чем брать следующее. Агент, которому дали десять задач сразу, делает первую и половину остальных.', '']
152
+ : [`# Prompts for an AI agent: ${host}`, '',
153
+ 'This is the same list of findings as in the report, rewritten so you can hand it to an AI agent (Cursor, Claude Code, any other) and it does the work. Copy a task whole, including the "Now" and "How to check" lines: without them the agent does not know where it starts or when it is done.', '',
154
+ 'Do one task at a time and check the result before taking the next. An agent given ten tasks at once does the first and half of the rest.', ''];
155
+ return [intro.join('\n').trimEnd(), ...list.map((x) => x.text)].join('\n\n');
156
+ }
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
3
+ import { agentPrompt, agentPrompts, renderAgentPrompts } from './prompts.mjs';
4
+ import { FIX_ACTIONS } from './fix.mjs';
5
+
6
+ let bad = 0;
7
+ const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
8
+ const ok = (n, c) => is(n, Boolean(c), true);
9
+
10
+ const c = (id, status, label, value) => ({ id, status, label, value, group: 'technical' });
11
+ const audit = (checks, lang = 'en') => ({ meta: { host: 'x.ru', lang }, checks });
12
+
13
+ const one = agentPrompt(c('alt', 'bad', 'Подписи к картинкам', '3 из 10 картинок с подписью'), { lang: 'ru' });
14
+ ok('в задании есть точка отсчёта', one.includes('**Сейчас:** 3 из 10'));
15
+ ok('в задании есть само действие', one.includes('**Задача:**'));
16
+ ok('в задании есть признак готовности', one.includes('**Как проверить:**'));
17
+ ok('в задании есть запрет выдумывать', one.includes('не выдумывай факты о бизнесе'));
18
+
19
+ // Даже там, где своего текста нет, все три части на месте
20
+ const generic = agentPrompt(c('xmlrpc', 'warn', 'xmlrpc.php', 'открыт'), { lang: 'ru' });
21
+ ok('общее задание тоже с проверкой', generic.includes('**Как проверить:**'));
22
+ ok('и с действием из набора работ', generic.includes('Закрыть открытую точку'));
23
+
24
+ // Порядок: сначала провалы, потом предупреждения
25
+ const list = agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
26
+ is('первым идёт провал, а не предупреждение', list[0].id, 'robots');
27
+ is('заданий столько же, сколько открытых проверок', list.length, 2);
28
+ is('пройденные проверки заданий не порождают', agentPrompts(audit([c('og', 'ok', 'OG', 'есть')])).length, 0);
29
+ is('лимит режет список', agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'r', 'з')]), { limit: 1 }).length, 1);
30
+
31
+ // Язык: русский файл по английскому аудиту не должен содержать английских проверок
32
+ const ruFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images carry alt text (30%) across 5 sampled page(s)')], 'en'), { lang: 'ru' });
33
+ ok('название проверки переведено', ruFile.includes('Подписи к картинкам'));
34
+ ok('и значение тоже', !ruFile.includes('images carry alt text'));
35
+ const enFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images')], 'en'), { lang: 'en' });
36
+ ok('английский файл без кириллицы', !/[А-Яа-яЁё]/.test(enFile));
37
+
38
+ // Задания не обещают того, чего мы не знаем
39
+ const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
40
+ ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/.test(contact));
41
+ const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
42
+ ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/.test(src));
43
+ const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
44
+ ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
45
+
46
+ // Покрытие: у каждой проверки, которую мы беремся чинить, задание осмысленное
47
+ const mechanical = Object.entries(FIX_ACTIONS).filter(([, r]) => r.scope === 'always' || r.scope === 'files');
48
+ const empty = mechanical.filter(([id]) => {
49
+ const p = agentPrompt(c(id, 'bad', id, 'значение'), { lang: 'ru' });
50
+ return !p.includes('**Задача:**') || p.split('**Задача:**')[1].trim().length < 20;
51
+ }).map(([id]) => id);
52
+ is('у каждой чинимой проверки есть внятное задание', empty, []);
53
+
54
+ // Шапка файла объясняет, как этим пользоваться
55
+ const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
56
+ ok('шапка объясняет, что копировать', /Копируй задание целиком/.test(head));
57
+ ok('и предупреждает делать по одному', /по одному заданию за раз/.test(head));
58
+ ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
59
+
60
+ if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
61
+ console.log('\nвсе тесты заданий прошли');