@operstack/audit 0.34.0 → 0.34.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
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",
package/src/collect.mjs CHANGED
@@ -790,7 +790,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
790
790
  const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
791
791
  const dupTitles = [...titles].filter(([, n]) => n > 1);
792
792
  if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
793
- const thin = good.filter((p) => p.words < 300);
793
+ // Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
794
+ const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
795
+ const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
794
796
  if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
795
797
  const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
796
798
  if (utility.length) {
@@ -899,7 +901,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
899
901
  const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
900
902
  if (depths.length) {
901
903
  const md = median(depths);
902
- checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
904
+ // Если кнопка действия стоит на первом экране почти везде, форма внизу не беда: к ней ведёт кнопка (14.10.2026, neronov.pro).
905
+ const ctaShare = pagesForTrust.length ? pagesForTrust.filter((p) => p.ctaFirstScreen).length / pagesForTrust.length : 0;
906
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 || ctaShare >= 0.8 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, ctaShare >= 0.8 && md >= 50 ? 'the first screen already has a button that leads to it' : md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
903
907
  } else {
904
908
  // Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
905
909
  const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
package/src/i18n.mjs CHANGED
@@ -154,7 +154,7 @@ const RU = {
154
154
  'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
155
155
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
156
156
  'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
157
- 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
157
+ 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
158
158
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
159
159
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
160
160
  };
package/src/prompts.mjs CHANGED
@@ -365,6 +365,12 @@ const PF = {
365
365
  const homeH2 = (home?.h2List || []).join('|');
366
366
  // Страницы, где все H2 те же, что на главной: это шаблон, обрамляющий почти пустую запись (14.10.2026, карточки врачей medeq-mc.ru).
367
367
  const templated = homeH2 && (home.h2List || []).length >= 3 ? sm.slice(1).filter((p) => (p.h2List || []).join('|') === homeH2) : [];
368
+ // Список статей и страница отзывов с виджетом короткие по устройству, дописывать их не нужно (14.10.2026).
369
+ const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
370
+ const shortReal = sm.filter((p) => (p.words || 0) < 300 && !LISTY.test(pathOf(p.url)));
371
+ if (!templated.length && !shortReal.length && sm.some((p) => (p.words || 0) < 300)) return ru
372
+ ? { task: `Короткие страницы выборки это списки и отзывы (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): дописывать их не нужно. Если отзывы подгружаются виджетом, проверь, что хотя бы несколько отзывов видны в самом коде страницы, иначе поиск и ИИ их не читают.`, verify: 'На странице отзывов несколько отзывов видны в исходном коде страницы, а не только в виджете.' }
373
+ : { task: `The short sampled pages are lists and reviews (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): no need to pad them. If reviews load through a widget, make sure a few reviews are present in the page source, otherwise search and AI cannot read them.`, verify: 'A few reviews are visible in the page source, not only in the widget.' };
368
374
  if (templated.length >= 2) {
369
375
  const list = templated.slice(0, 4).map((p) => pathOf(p.url)).join(', ');
370
376
  return ru
@@ -375,7 +381,10 @@ const PF = {
375
381
  ? { task: `Допиши короткие страницы фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
376
382
  : { 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
383
  },
378
- 'trust-policies': ({ ru }) => (ru
384
+ 'trust-policies': ({ audit, ru }) => (audit?.verification?.consentBox ? (ru
385
+ ? { task: 'Галочка согласия на обработку данных у формы уже есть, не хватает самой политики конфиденциальности. Её текст готовит владелец или юрист, не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку на неё в подвал и рядом с галочкой согласия у формы.', verify: 'Страница политики конфиденциальности открывается с кодом 200, ссылка на неё есть в подвале каждой страницы и рядом с галочкой согласия у формы.' }
386
+ : { task: 'The form already has a consent checkbox; what is missing is the privacy policy itself. The owner or a lawyer writes it. Once it exists, publish it as a page and link it from the footer and next to the consent checkbox.', verify: 'The privacy policy page answers 200 and is linked from every footer and next to the consent checkbox.' })
387
+ : null) || (ru
379
388
  ? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
380
389
  : { 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
390
  'trust-profiles': ({ check, ru }) => (/ссылки на сайте есть|pages link to/i.test(String(check.value)) ? (ru
package/src/review.mjs CHANGED
@@ -133,7 +133,8 @@ export function toolFacts(audit) {
133
133
  sitemapUrlCount: (String(probe('sitemap') || '').match(/(\d+)/) || [])[1],
134
134
  filesAndProbes: { 'https': probe('https'), www: probe('www'), 'redirect-chain': probe('redirect-chain'), ttfb: probe('ttfb'), 'llms.txt': probe('llms'), 'agent.json': probe('agent-card'), 'api-catalog': probe('api-catalog'), markdown: probe('agent-markdown'), 'robots-to-ai-robots': probe('train-bots'), 'js-hidden-text': probe('js-content'), cms: probe('cms') },
135
135
  images: { total: sm.reduce((a, p) => a + (p.images || 0), 0), withoutAltText: sm.reduce((a, p) => a + (p.imagesNoAlt || 0), 0), withoutAltAttribute: sm.reduce((a, p) => a + (p.imagesAltMissing || 0), 0) },
136
- perSampledPage: sm.map((p) => ({ url: p.url, words: p.words, h1Count: p.h1Count, h2Count: p.h2Count, images: p.images, imagesNoAlt: p.imagesNoAlt, ctaFirstScreen: p.ctaFirstScreen })),
136
+ perSampledPage: sm.map((p) => ({ url: p.url, words: p.words, h1Count: p.h1Count, h2Count: p.h2Count, tables: p.tables, images: p.images, imagesNoAlt: p.imagesNoAlt, ctaFirstScreen: p.ctaFirstScreen, priceShown: p.priceShown })),
137
+ sampleRule: 'Числа вида «X из N страниц выборки» считаются только по sampleUrls (perSampledPage), а не по всем страницам выжимки сайта.',
137
138
  classification: 'Деление открытых проверок на «правятся технически» и «требуют фактов владельца» и оценки областей считает инструмент по своему списку; это не утверждения о сайте.',
138
139
  };
139
140
  }
@@ -214,10 +215,10 @@ export async function reviewRound(audit, digest, statements, ask, { lang = audit
214
215
 
215
216
  const CONFIRM = (ru) => (ru
216
217
  ? `Ты второй проверяющий. Первый пометил утверждения отчёта о сайте как ложные или вводящие в заблуждение. Он часто ошибается: помечает верное. Твоя задача отсеять ложные тревоги.
217
- По каждому помеченному утверждению найди в выжимке сайта или toolFacts КОНКРЕТНОЕ доказательство: адрес страницы и поле (или файл) со значением, которое прямо опровергает утверждение, либо (для задачи) показывает, что проблемы нет, совет чужой ниши или проверка уже проходит. Нашёл: keep true, evidence с адресом и значением, fix с полным новым текстом по-русски без длинного и среднего тире. Не нашёл прямого доказательства: keep false. Мнения, стиль и «можно сказать лучше» это не доказательство.
218
+ По каждому помеченному утверждению найди в выжимке сайта или toolFacts КОНКРЕТНОЕ доказательство: адрес страницы и поле (или файл) со значением, которое прямо опровергает утверждение, либо (для задачи) показывает, что проблемы нет, совет чужой ниши или проверка уже проходит. Нашёл: keep true, evidence с адресом и значением, fix с полным новым текстом по-русски без длинного и среднего тире. Не нашёл прямого доказательства: keep false. Мнения, стиль и «можно сказать лучше» это не доказательство: перефразировка без изменения смысла это keep false. Числа «X из N страниц выборки» сверяй только со страницами из toolFacts.sampleUrls и perSampledPage, не со всем сайтом.
218
219
  Ответ строго JSON: {"items":[{"id":"...","keep":true,"evidence":"адрес: поле = значение","fix":"..."}]}`
219
220
  : `You are the second reviewer. The first one flagged report statements as false or misleading. It often errs and flags correct text. Your job is to drop the false alarms.
220
- For each flagged statement find SPECIFIC evidence in the site digest or toolFacts: a page URL and a field (or file) whose value directly contradicts the statement, or (for a task) shows the problem is absent, the advice belongs to another niche, or the check already passes. Found it: keep true, evidence with URL and value, fix with the full replacement in English, no em or en dashes. No direct evidence: keep false. Opinions, style and "could be phrased better" are not evidence.
221
+ For each flagged statement find SPECIFIC evidence in the site digest or toolFacts: a page URL and a field (or file) whose value directly contradicts the statement, or (for a task) shows the problem is absent, the advice belongs to another niche, or the check already passes. Found it: keep true, evidence with URL and value, fix with the full replacement in English, no em or en dashes. No direct evidence: keep false. Opinions, style and "could be phrased better" are not evidence: a rewording that keeps the meaning is keep false. Check "X of N sampled pages" only against toolFacts.sampleUrls and perSampledPage, not the whole site.
221
222
  Answer strictly as JSON: {"items":[{"id":"...","keep":true,"evidence":"url: field = value","fix":"..."}]}`);
222
223
 
223
224
  /** Второй шаг: подтвердить пометки первого более сильной моделью, только с прямым доказательством. */
@@ -254,7 +255,10 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
254
255
  // Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
255
256
  const { items: conf, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
256
257
  add(cu, usage.confirm);
257
- const kept = conf.filter((x) => x.keep);
258
+ // Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
259
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
260
+ const textOf = (id) => all.find((s) => s.id === id)?.text || '';
261
+ const kept = conf.filter((x) => x.keep && !(x.fix && norm(x.fix) === norm(textOf(x.id))));
258
262
  const lost = items.filter((x) => x.verdict === 'unverifiable');
259
263
  rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, unverifiable: lost.length, items: kept.map((x) => ({ ...x, text: all.find((s) => s.id === x.id)?.text })) });
260
264
  log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, не проверить ${lost.length}`);
package/src/verify.mjs CHANGED
@@ -18,7 +18,8 @@ import { request as httpRequest } from 'node:http';
18
18
 
19
19
  const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36';
20
20
  const MESSENGER = /(?:wa\.me\/|api\.whatsapp\.com|whatsapp:\/\/|\bt\.me\/|telegram\.me\/|viber:\/\/|invite\.viber\.com|\bmax\.ru\/)/i;
21
- const SOCIAL = /\/\/(?:www\.)?(vk\.com|ok\.ru|t\.me|instagram\.com|facebook\.com|youtube\.com|rutube\.ru|dzen\.ru|linkedin\.com|tiktok\.com|x\.com|twitter\.com|pinterest\.com)\/[^"'\s?#]+/gi;
21
+ // Соцсети и отраслевые каталоги: для врача профиль на ПроДокторов важнее соцсетей (14.10.2026, neronov.pro).
22
+ const SOCIAL = /\/\/(?:www\.)?(vk\.com|ok\.ru|t\.me|instagram\.com|facebook\.com|youtube\.com|rutube\.ru|dzen\.ru|linkedin\.com|tiktok\.com|x\.com|twitter\.com|pinterest\.com|prodoctorov\.ru|napopravku\.ru|docdoc\.ru|sberhealth\.ru|zoon\.ru|2gis\.ru|yandex\.ru\/maps\/org|yandex\.ru\/profile|google\.com\/maps|g\.page|yell\.ru|profi\.ru|avito\.ru\/brands|trustpilot\.com|yelp\.com|g2\.com|clutch\.co)\/[^"'\s?#]+/gi;
22
23
  const ABOUT_HREF = /\/(about|about-us|about-me|our-story|team|company|o-nas|o-kompanii|o-czentre|o-centre|o-klinike|kompaniya|obo-mne|o-sebe|ob-avtore|o-proekte|our-practice|o-vrache|o-klinike)\/?($|[?#])/i;
23
24
  const ABOUT_TITLE = /^(о нас|о компании|о клинике|о центре|обо мне|о себе|об авторе|о проекте|о враче|о докторе|о специалисте|о практике|about|about us|about me|about the doctor|our practice|our story|who we are)(?![\p{L}])/iu;
24
25
  const POLICY_TEXT = { privacy: /политик[аи] (?:конфиденциальности|обработки персональных данных)|privacy policy/i, terms: /пользовательское соглашение|публичная оферта|договор(?:-| )оферт|правила (?:и порядок )?оказания (?:платных )?услуг|terms (?:of (?:service|use)|and conditions)/i };
@@ -92,7 +93,9 @@ export function rebuildCritical(audit) {
92
93
  if (week1 && Array.isArray(week1.items)) {
93
94
  // В «быстрые победы» только техническое: то, что требует фактов владельца или юриста, уходит во вторую очередь (14.10.2026).
94
95
  const quick = bad.filter((c) => MECHANICAL_IDS.has(c.id) && c.id !== 'trust-policies');
95
- const slow = bad.filter((c) => !quick.includes(c));
96
+ let slow = bad.filter((c) => !quick.includes(c));
97
+ // Пустая первая неделя при красной строке выглядит как «срочного нет», хотя приоритет 1 есть (14.10.2026, neronov.pro).
98
+ if (!quick.length && slow.length) { quick.push(slow[0]); slow = slow.slice(1); }
96
99
  const other = week1.items.filter((i) => !String(i).startsWith(`${fix}:`) && !String(i).includes('{{'));
97
100
  week1.items = [...quick.map((c) => `${fix}: ${c.label}`), ...other];
98
101
  if (week2 && Array.isArray(week2.items)) {
@@ -160,10 +163,12 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
160
163
  const textOnly = Object.entries(inText).filter(([k]) => !kinds.some((x) => (k === 'privacy' ? /privacy|konfid|personal|polit/.test(x) : !/privacy|konfid|personal|polit/.test(x))));
161
164
  if (kinds.length || textOnly.length) {
162
165
  const parts = [];
163
- if (kinds.length) parts.push(ru ? `ссылки есть: ${kinds.join(', ')}` : `linked: ${kinds.join(', ')}`);
166
+ if (kinds.length) parts.push(ru ? `ссылки есть: ${kinds.map((k) => ({ privacy: 'политика конфиденциальности', terms: 'условия или оферта', consent: 'согласие на обработку данных', legal: 'правовая информация', cookie: 'политика cookie', disclaimer: 'отказ от ответственности' })[k] || k).join(', ')}` : `linked: ${kinds.join(', ')}`);
164
167
  for (const [k, u] of textOnly) parts.push(ru ? `${k === 'privacy' ? 'политика конфиденциальности' : 'правила или условия оказания услуг'} есть только в тексте страницы ${path(u)}, отдельной страницы и ссылки нет` : `${k === 'privacy' ? 'privacy policy' : 'terms'} only inside the text of ${path(u)}, no page of its own and no link`);
165
168
  const missingPrivacy = !kinds.some((x) => /privacy|konfid|personal|polit/.test(x)) && !inText.privacy;
166
169
  if (missingPrivacy) parts.push(ru ? 'политики конфиденциальности нет нигде на сайте' : 'no privacy policy anywhere on the site');
170
+ const consentBox = pages.some((p) => /type=["']checkbox["'][^>]*(?:consent|soglas|personal|pd)|(?:consent|soglas|personal)[^>]*type=["']checkbox/i.test(p.html) || /согласие на обработку[^<]{0,80}<\/(?:label|span|a)/i.test(p.html));
171
+ audit.verification = audit.verification || {}; audit.verification.consentBox = consentBox;
167
172
  const withForms = pages.filter((p) => /<form[\s\S]*?(?:type=["']?tel|name=["'][^"']*(?:phone|tel|name|email)|placeholder=["'][^"']*(?:телефон|номер|имя|почт))/i.test(p.html)).length;
168
173
  if (missingPrivacy && withForms && ru) parts.push(`при этом формы, которые собирают контакты, стоят на ${withForms} из ${pages.length} страниц; по 152-ФЗ сайт, который собирает персональные данные, обязан опубликовать политику их обработки и получать согласие`);
169
174
  change('trust-policies', { status: missingPrivacy ? 'bad' : kinds.length >= 2 ? 'ok' : 'warn', value: parts.join('; ') }, { pagesRead: pages.length, linked: kinds, inText, withForms });
@@ -334,7 +339,9 @@ export function siteDigest(pages, robotsText = '') {
334
339
  words: text.split(/\s+/).filter(Boolean).length,
335
340
  visibleDates: [...new Set((text.match(/(?<!\d)\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2}(?!\d)/g) || []))].slice(0, 6),
336
341
  prices: [...new Set((text.match(/\d[\d\s]{0,9}(?:₽|руб(?:\.|лей|ля|ль)?(?![а-яё])|USD|\$)/gi) || []).map((x) => x.trim()))].slice(0, 6),
337
- privacyText: /политик[аи] (?:конфиденциальности|обработки персональных данных)|согласи[ея] на обработку|privacy policy/i.test(text),
342
+ // Политика и согласие раздельно: согласие на обработку данных это не политика (14.10.2026, neronov.pro).
343
+ privacyText: /политик[аи] (?:конфиденциальности|в отношении обработки персональных данных|обработки персональных данных)|privacy policy/i.test(text),
344
+ consentText: /согласи[ея] на обработку|consent to (?:the )?processing/i.test(text),
338
345
  termsText: /правила (?:и порядок )?оказания|публичная оферта|пользовательское соглашение|terms (?:of|and)/i.test(text),
339
346
  accordions: (h.match(/class=["'][^"']*(?:faq|accordion|spoiler|toggle)[^"']*["']/gi) || []).length,
340
347
  tableTags: (h.match(/<table\b/gi) || []).length,