indexgap 1.5.0__py3-none-any.whl
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.
- indexgap/__init__.py +2 -0
- indexgap/__main__.py +9 -0
- indexgap/aeo.py +365 -0
- indexgap/checks.py +678 -0
- indexgap/cite.py +335 -0
- indexgap/cli.py +1083 -0
- indexgap/content.py +517 -0
- indexgap/core.py +835 -0
- indexgap/doctor.py +461 -0
- indexgap/engines.py +179 -0
- indexgap/freshness.py +130 -0
- indexgap/generate.py +453 -0
- indexgap/hreflang.py +324 -0
- indexgap/i18n.py +133 -0
- indexgap/install.py +409 -0
- indexgap/locale/__init__.py +8 -0
- indexgap/locale/en/__init__.py +14 -0
- indexgap/locale/en/checks.py +163 -0
- indexgap/locale/en/cite.py +100 -0
- indexgap/locale/en/cli.py +281 -0
- indexgap/locale/en/content.py +89 -0
- indexgap/locale/en/core.py +135 -0
- indexgap/locale/en/doctor.py +168 -0
- indexgap/locale/en/hreflang.py +116 -0
- indexgap/locale/en/repair.py +229 -0
- indexgap/locale/en/report.py +223 -0
- indexgap/portfolio.py +268 -0
- indexgap/profiles.py +198 -0
- indexgap/publish.py +313 -0
- indexgap/repair.py +343 -0
- indexgap/report.py +431 -0
- indexgap/settings.py +222 -0
- indexgap/skills/indexgap-plan/SKILL.en.md +97 -0
- indexgap/skills/indexgap-plan/SKILL.md +97 -0
- indexgap/skills/indexgap-portfolio/SKILL.en.md +94 -0
- indexgap/skills/indexgap-portfolio/SKILL.md +90 -0
- indexgap/skills/indexgap-publish/SKILL.en.md +151 -0
- indexgap/skills/indexgap-publish/SKILL.md +147 -0
- indexgap/skills/indexgap-review/SKILL.en.md +139 -0
- indexgap/skills/indexgap-review/SKILL.md +137 -0
- indexgap/sources.py +421 -0
- indexgap-1.5.0.dist-info/METADATA +399 -0
- indexgap-1.5.0.dist-info/RECORD +47 -0
- indexgap-1.5.0.dist-info/WHEEL +5 -0
- indexgap-1.5.0.dist-info/entry_points.txt +2 -0
- indexgap-1.5.0.dist-info/licenses/LICENSE +21 -0
- indexgap-1.5.0.dist-info/top_level.txt +1 -0
indexgap/__init__.py
ADDED
indexgap/__main__.py
ADDED
indexgap/aeo.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Машинная читаемость: то, что решает, сможет ли ИИ-поиск взять со страницы ответ.
|
|
4
|
+
|
|
5
|
+
Границу стоит провести сразу, потому что вокруг неё много продаваемого воздуха.
|
|
6
|
+
|
|
7
|
+
**Что проверяется здесь.** Не мешает ли страница сама себе: не закрыта ли
|
|
8
|
+
от сниппетов, не заблокированы ли краулеры ИИ-поисковиков в robots.txt,
|
|
9
|
+
есть ли на странице текст в исходном HTML, есть ли прямой ответ в первом
|
|
10
|
+
абзаце, размечены ли даты и автор, валиден ли JSON-LD. Это необходимые
|
|
11
|
+
условия, и они проверяются локально и детерминированно.
|
|
12
|
+
|
|
13
|
+
**Чего здесь нет и быть не может.** Обещания цитирований. По данным Ahrefs
|
|
14
|
+
на 75 000 брендов с видимостью в ИИ-поиске сильнее всего коррелируют
|
|
15
|
+
упоминания вне сайта (0,66–0,74), а количество страниц на сайте — 0,19.
|
|
16
|
+
76% цитат в AI Overviews — страницы из топ-10 обычной выдачи. То есть
|
|
17
|
+
попадание в ответ определяется работой за пределами файлов проекта.
|
|
18
|
+
Пакет делает первую половину и честно говорит, что вторая — не про код.
|
|
19
|
+
|
|
20
|
+
Отдельно про `llms.txt`: генератора нет намеренно. Google публично заявил,
|
|
21
|
+
что не поддерживает и не планирует, ни один движок не подтвердил
|
|
22
|
+
использование для ранжирования. Генерировать файл, который никто не читает,
|
|
23
|
+
значит продавать ученику ритуал.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
from .checks import is_shell
|
|
33
|
+
from .i18n import N_, tr
|
|
34
|
+
|
|
35
|
+
CONFIG = {
|
|
36
|
+
"answer_min": 40, # символов в прямом ответе
|
|
37
|
+
"answer_max": 320,
|
|
38
|
+
"shell_words": 100, # меньше слов при N скриптах — пустой JS-каркас
|
|
39
|
+
"shell_scripts": 3,
|
|
40
|
+
"long_paragraph": 900, # символов без подзаголовка — плохо извлекается
|
|
41
|
+
"question_share": 0.3, # доля вопросных подзаголовков, ниже которой стоит сказать
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Разгон вместо ответа. Первое предложение, начинающееся так, для ИИ-поиска
|
|
45
|
+
# пустое: цитировать нечего.
|
|
46
|
+
PREAMBLE = (
|
|
47
|
+
"в этой статье", "в данной статье", "в этом материале", "мы рассмотрим",
|
|
48
|
+
"давайте разберёмся", "давайте разберемся", "сегодня мы", "как известно",
|
|
49
|
+
"ни для кого не секрет", "в современном мире", "прежде чем",
|
|
50
|
+
"in this article", "in this post", "we will explore", "let's dive",
|
|
51
|
+
"let us explore", "as we all know", "in today's world",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
QUESTION = re.compile(
|
|
55
|
+
"^(как|что|где|когда|почему|зачем|сколько|какой|какая|какие|какое|кто|можно ли|нужно ли|how|what|where|when|why|who|which|can|do|does|is|are)\\b|[??]\\s*$", re.I | re.U)
|
|
56
|
+
|
|
57
|
+
# Кто ходит за содержимым для ИИ-ответов и что теряется при блокировке.
|
|
58
|
+
AI_AGENTS = {
|
|
59
|
+
"oai-searchbot": N_("ChatGPT не покажет страницу в ответах своего поиска"),
|
|
60
|
+
"gptbot": N_("OpenAI не будет использовать страницу для обучения (на показ в поиске это не влияет)"),
|
|
61
|
+
"chatgpt-user": N_("ChatGPT не сможет открыть страницу по прямой просьбе пользователя"),
|
|
62
|
+
"perplexitybot": N_("Perplexity не проиндексирует страницу"),
|
|
63
|
+
"claudebot": N_("Anthropic не будет использовать страницу"),
|
|
64
|
+
"claude-searchbot": N_("Claude не покажет страницу в ответах с поиском"),
|
|
65
|
+
"google-extended": N_("Gemini не будет использовать страницу для обучения (на AI Overviews не влияет)"),
|
|
66
|
+
"applebot-extended": N_("Apple Intelligence не будет использовать страницу"),
|
|
67
|
+
"bingbot": N_("Bing не проиндексирует страницу — а вместе с ним Copilot"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_robots(path: str) -> dict:
|
|
72
|
+
"""
|
|
73
|
+
Разбирает robots.txt проекта: какие агенты что запрещают.
|
|
74
|
+
Пакет не читал его вовсе, хотя `Disallow` — классическая причина
|
|
75
|
+
«страниц нет в индексе», которую ищут неделями.
|
|
76
|
+
"""
|
|
77
|
+
from .core import read_text, SourceError
|
|
78
|
+
|
|
79
|
+
if not path:
|
|
80
|
+
return {"found": False, "rules": {}, "sitemaps": []}
|
|
81
|
+
if os.path.isdir(path):
|
|
82
|
+
return {"found": False, "error": tr("{a0} — это каталог, а нужен файл", a0=path),
|
|
83
|
+
"rules": {}, "sitemaps": []}
|
|
84
|
+
if not os.path.exists(path):
|
|
85
|
+
return {"found": False, "error": tr("файла {a0} нет", a0=path),
|
|
86
|
+
"rules": {}, "sitemaps": []}
|
|
87
|
+
try:
|
|
88
|
+
text, _ = read_text(path)
|
|
89
|
+
except SourceError as exc:
|
|
90
|
+
return {"found": False, "error": str(exc), "rules": {}, "sitemaps": []}
|
|
91
|
+
|
|
92
|
+
rules, sitemaps = {}, []
|
|
93
|
+
current = []
|
|
94
|
+
previous_was_agent = False
|
|
95
|
+
for raw in text.splitlines():
|
|
96
|
+
line = raw.split("#", 1)[0].strip()
|
|
97
|
+
if not line:
|
|
98
|
+
previous_was_agent = False
|
|
99
|
+
continue
|
|
100
|
+
field, _, value = line.partition(":")
|
|
101
|
+
field, value = field.strip().lower(), value.strip()
|
|
102
|
+
if field == "user-agent":
|
|
103
|
+
if not previous_was_agent:
|
|
104
|
+
current = []
|
|
105
|
+
current.append(value.lower())
|
|
106
|
+
previous_was_agent = True
|
|
107
|
+
for agent in current:
|
|
108
|
+
rules.setdefault(agent, {"disallow": [], "allow": []})
|
|
109
|
+
elif field in ("disallow", "allow"):
|
|
110
|
+
previous_was_agent = False
|
|
111
|
+
for agent in current or ["*"]:
|
|
112
|
+
rules.setdefault(agent, {"disallow": [], "allow": []})
|
|
113
|
+
rules[agent][field].append(value)
|
|
114
|
+
elif field == "sitemap":
|
|
115
|
+
previous_was_agent = False
|
|
116
|
+
sitemaps.append(value)
|
|
117
|
+
else:
|
|
118
|
+
# Crawl-delay, Host, Clean-param и любая другая директива тоже
|
|
119
|
+
# закрывают перечисление агентов. Без этого `Crawl-delay` между
|
|
120
|
+
# двумя `User-agent` склеивал группы, и запрет для одного бота
|
|
121
|
+
# читался как «сайт закрыт целиком» — на типовом robots рунета.
|
|
122
|
+
previous_was_agent = False
|
|
123
|
+
return {"found": True, "rules": rules, "sitemaps": sitemaps}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# `/`, `/*` и `/$` запрещают весь сайт. Строгое сравнение с «/» пропускало
|
|
127
|
+
# полный запрет, записанный вторым способом.
|
|
128
|
+
_BLOCK_ALL = {"/", "/*", "/$", "/*$"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _blocks_everything(entry: dict) -> bool:
|
|
132
|
+
disallow = {d.strip() for d in (entry.get("disallow") or [])}
|
|
133
|
+
allow = {a.strip() for a in (entry.get("allow") or [])}
|
|
134
|
+
return bool(disallow & _BLOCK_ALL) and not (allow & _BLOCK_ALL)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_robots(robots: dict) -> list:
|
|
138
|
+
"""Находки по robots.txt: кого закрыли и что из-за этого теряется."""
|
|
139
|
+
issues = []
|
|
140
|
+
if not robots.get("found"):
|
|
141
|
+
if robots.get("error"):
|
|
142
|
+
issues.append(("warning", "robots.txt", "robots-unreadable",
|
|
143
|
+
tr("robots.txt не прочитан: {a0}", a0=robots['error'])))
|
|
144
|
+
else:
|
|
145
|
+
issues.append(("info", "robots.txt", "no-robots",
|
|
146
|
+
tr("robots.txt не найден — это не ошибка, но и не контроль: передай путь через --robots, чтобы проверить")))
|
|
147
|
+
return issues
|
|
148
|
+
|
|
149
|
+
rules = robots.get("rules") or {}
|
|
150
|
+
star = rules.get("*") or {}
|
|
151
|
+
if _blocks_everything(star):
|
|
152
|
+
issues.append(("critical", "robots.txt", "robots-blocks-all",
|
|
153
|
+
tr("Disallow: / для всех агентов — сайт закрыт от всех поисковиков целиком")))
|
|
154
|
+
for agent, why in sorted(AI_AGENTS.items()):
|
|
155
|
+
entry = rules.get(agent)
|
|
156
|
+
if entry and _blocks_everything(entry):
|
|
157
|
+
level = "critical" if tr("не покажет") in why or tr("не проиндексирует") in why else "info"
|
|
158
|
+
issues.append((level, "robots.txt", "ai-crawler-blocked",
|
|
159
|
+
tr("{a0} закрыт: {a1}", a0=agent, a1=tr(why))))
|
|
160
|
+
if not robots.get("sitemaps"):
|
|
161
|
+
issues.append(("info", "robots.txt", "robots-no-sitemap",
|
|
162
|
+
tr("в robots.txt не указан Sitemap — строка `Sitemap: https://…/sitemap.xml` стоит копейки")))
|
|
163
|
+
return issues
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _first_sentence(text: str) -> str:
|
|
167
|
+
parts = re.split(r"(?<=[.!?。!?])\s+", (text or "").strip(), maxsplit=1)
|
|
168
|
+
return parts[0] if parts else ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_answer(page, cfg: dict = None) -> list:
|
|
172
|
+
"""
|
|
173
|
+
Прямой ответ в первом абзаце.
|
|
174
|
+
|
|
175
|
+
Semrush на 304 805 цитируемых URL: ясность и суммаризация — сильнейший
|
|
176
|
+
контентный фактор цитируемости (+32,8%). Практически это значит,
|
|
177
|
+
что первый абзац должен отвечать на запрос, а не разгоняться.
|
|
178
|
+
"""
|
|
179
|
+
cfg = {**CONFIG, **(cfg or {})}
|
|
180
|
+
paragraphs = [p for p in (page.paragraphs or []) if len(p) > 20]
|
|
181
|
+
if not paragraphs:
|
|
182
|
+
return [("warning", page.url, "no-answer",
|
|
183
|
+
tr("не нашёл ни одного абзаца — цитировать нечего"))]
|
|
184
|
+
first = paragraphs[0]
|
|
185
|
+
issues = []
|
|
186
|
+
lowered = first.lower().lstrip("«\"'— -")
|
|
187
|
+
if lowered.startswith(PREAMBLE):
|
|
188
|
+
issues.append(("warning", page.url, "answer-preamble",
|
|
189
|
+
tr("первый абзац начинается с разгона «{a0}…» — ИИ-поиск цитирует ответ, а не вступление", a0=first[:40])))
|
|
190
|
+
elif len(first) < cfg["answer_min"]:
|
|
191
|
+
issues.append(("info", page.url, "answer-short",
|
|
192
|
+
tr("первый абзац короче {a0} символов — на самостоятельный ответ не тянет", a0=cfg['answer_min'])))
|
|
193
|
+
elif len(first) > cfg["answer_max"]:
|
|
194
|
+
issues.append(("info", page.url, "answer-long",
|
|
195
|
+
tr("первый абзац {a0} символов — для цитирования лучше уложить ответ в {a1}", a0=len(first), a1=cfg['answer_max'])))
|
|
196
|
+
return issues
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_extractable(page, cfg: dict = None) -> list:
|
|
200
|
+
"""
|
|
201
|
+
Извлекаемость: вопросные подзаголовки, списки и таблицы, длина абзаца.
|
|
202
|
+
Q&A-формат +25,5%, структура секций +22,9%, элементы структуры +21,6%
|
|
203
|
+
(тот же корпус Semrush).
|
|
204
|
+
"""
|
|
205
|
+
cfg = {**CONFIG, **(cfg or {})}
|
|
206
|
+
issues = []
|
|
207
|
+
subheads = [t for lvl, t in page.headings if lvl >= 2]
|
|
208
|
+
if len(subheads) >= 3:
|
|
209
|
+
questions = sum(1 for t in subheads if QUESTION.search(t))
|
|
210
|
+
# Считаем долю, а не «хотя бы один»: одно ложное срабатывание
|
|
211
|
+
# регулярки гасило проверку целиком.
|
|
212
|
+
if questions / len(subheads) < cfg["question_share"]:
|
|
213
|
+
issues.append(("info", page.url, "no-question-headings",
|
|
214
|
+
tr("вопросов среди подзаголовков {a0} из {a1} — формат «вопрос → ответ» цитируется заметно чаще", a0=questions, a1=len(subheads))))
|
|
215
|
+
long_paragraphs = [p for p in (page.paragraphs or []) if len(p) > cfg["long_paragraph"]]
|
|
216
|
+
if long_paragraphs:
|
|
217
|
+
issues.append(("info", page.url, "long-paragraph",
|
|
218
|
+
tr("{a0} абзац(ев) длиннее {a1} символов — такой блок трудно процитировать целиком", a0=len(long_paragraphs), a1=cfg['long_paragraph'])))
|
|
219
|
+
blocks = page.blocks or {}
|
|
220
|
+
if page.word_count > 400 and not blocks.get("li") and not blocks.get("table"):
|
|
221
|
+
issues.append(("info", page.url, "no-structure",
|
|
222
|
+
tr("в тексте нет ни списков, ни таблиц — структурные элементы повышают шанс попасть в ответ")))
|
|
223
|
+
if blocks.get("img") and blocks.get("img_no_alt"):
|
|
224
|
+
issues.append(("info", page.url, "img-no-alt",
|
|
225
|
+
tr("{a0} изображени(й) без alt", a0=blocks['img_no_alt'])))
|
|
226
|
+
return issues
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def check_shell(page, cfg: dict = None) -> list:
|
|
230
|
+
"""
|
|
231
|
+
Пустой JS-каркас. GPTBot, OAI-SearchBot, ClaudeBot и PerplexityBot
|
|
232
|
+
не исполняют JavaScript: страница, которую рисует скрипт, для них пустая.
|
|
233
|
+
Googlebot исполняет — поэтому проблема видна не сразу.
|
|
234
|
+
|
|
235
|
+
Саму находку теперь выдаёт `checks.run_all`: она нужна и без `--no-aeo`,
|
|
236
|
+
и от неё зависит, какие проверки на странице вообще имеют смысл.
|
|
237
|
+
Здесь остался только вердикт, чтобы `aeo` не оценивал пустую страницу
|
|
238
|
+
по прямому ответу и извлекаемости — это было бы шумом поверх шума.
|
|
239
|
+
"""
|
|
240
|
+
cfg = {**CONFIG, **(cfg or {})}
|
|
241
|
+
if is_shell(page, cfg):
|
|
242
|
+
return [("critical", page.url, "js-shell",
|
|
243
|
+
tr("в исходном HTML {a0} слов при {a1} скриптах — краулеры ИИ-поиска не исполняют JavaScript и увидят пустую страницу", a0=page.word_count, a1=(page.blocks or {}).get('script', 0)))]
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def check_jsonld(page) -> list:
|
|
248
|
+
"""
|
|
249
|
+
Разметка проверяется на валидность и на соответствие тексту, а не на наличие.
|
|
250
|
+
|
|
251
|
+
Ahrefs отследил 1 885 страниц, добавивших JSON-LD: цитирования
|
|
252
|
+
не выросли. Поэтому «нет разметки» — не находка. А вот битый JSON
|
|
253
|
+
или FAQ, которого нет на странице, — находка: это риск санкций
|
|
254
|
+
без единого плюса.
|
|
255
|
+
"""
|
|
256
|
+
issues = []
|
|
257
|
+
for raw in page.jsonld or ():
|
|
258
|
+
try:
|
|
259
|
+
data = json.loads(raw)
|
|
260
|
+
except json.JSONDecodeError as exc:
|
|
261
|
+
issues.append(("warning", page.url, "jsonld-broken",
|
|
262
|
+
tr("блок JSON-LD не парсится ({a0}) — для поисковика его просто нет", a0=exc.msg)))
|
|
263
|
+
continue
|
|
264
|
+
for item in _ld_items(data):
|
|
265
|
+
# Контейнер `@graph` сам по себе типа не имеет — это обёртка,
|
|
266
|
+
# а не сущность. Раньше каждая страница с разметкой Yoast
|
|
267
|
+
# получала за неё ложную находку.
|
|
268
|
+
if "@type" not in item and not {"@graph", "@context"} & set(item):
|
|
269
|
+
issues.append(("info", page.url, "jsonld-no-type",
|
|
270
|
+
tr("в блоке JSON-LD нет @type")))
|
|
271
|
+
if "faqpage" in _ld_types(item):
|
|
272
|
+
haystack = (page.text or "").lower()
|
|
273
|
+
missing = []
|
|
274
|
+
entities = item.get("mainEntity") or []
|
|
275
|
+
if isinstance(entities, dict):
|
|
276
|
+
entities = [entities]
|
|
277
|
+
for entity in entities:
|
|
278
|
+
if not isinstance(entity, dict):
|
|
279
|
+
continue
|
|
280
|
+
question = str(entity.get("name") or "").strip()
|
|
281
|
+
if question and question.lower()[:40] not in haystack:
|
|
282
|
+
missing.append(question)
|
|
283
|
+
if missing:
|
|
284
|
+
issues.append(("warning", page.url, "jsonld-faq-invisible",
|
|
285
|
+
tr("{a0} вопрос(ов) из FAQPage нет в видимом тексте — разметка, не совпадающая со страницей, это риск ручных санкций", a0=len(missing))))
|
|
286
|
+
return issues
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _ld_items(data) -> list:
|
|
290
|
+
"""
|
|
291
|
+
Разворачивает разметку в плоский список объектов.
|
|
292
|
+
|
|
293
|
+
Форму `@graph` отдают Yoast и RankMath, то есть половина сайтов. Раньше
|
|
294
|
+
она не разворачивалась, и один и тот же блок давал сразу три ложные
|
|
295
|
+
находки: «нет @type», «нет даты», «нет автора» — плюс не находился
|
|
296
|
+
FAQ, которого нет на странице.
|
|
297
|
+
"""
|
|
298
|
+
out = []
|
|
299
|
+
stack = [data]
|
|
300
|
+
while stack:
|
|
301
|
+
node = stack.pop()
|
|
302
|
+
if isinstance(node, list):
|
|
303
|
+
stack.extend(node)
|
|
304
|
+
elif isinstance(node, dict):
|
|
305
|
+
out.append(node)
|
|
306
|
+
graph = node.get("@graph")
|
|
307
|
+
if isinstance(graph, (list, dict)):
|
|
308
|
+
stack.append(graph)
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _ld_types(item: dict) -> set:
|
|
313
|
+
value = item.get("@type")
|
|
314
|
+
values = value if isinstance(value, list) else [value]
|
|
315
|
+
return {str(v).lower() for v in values if v}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
DATE_KEYS = ("datepublished", "datemodified", "date", "updated", "published")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def check_provenance(page) -> list:
|
|
322
|
+
"""Даты и автор в машиночитаемом виде — сигналы, которые ИИ-поиск читает."""
|
|
323
|
+
issues = []
|
|
324
|
+
meta_keys = {k.lower() for k in (page.meta or {})}
|
|
325
|
+
has_date = bool(meta_keys & set(DATE_KEYS))
|
|
326
|
+
has_author = "author" in meta_keys
|
|
327
|
+
for raw in page.jsonld or ():
|
|
328
|
+
try:
|
|
329
|
+
data = json.loads(raw)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
continue
|
|
332
|
+
for item in _ld_items(data):
|
|
333
|
+
keys = {str(k).lower() for k in item}
|
|
334
|
+
has_date = has_date or bool(keys & set(DATE_KEYS))
|
|
335
|
+
has_author = has_author or "author" in keys
|
|
336
|
+
if not has_date and re.search(r"<time[^>]+datetime=", page.raw or "", re.I):
|
|
337
|
+
has_date = True
|
|
338
|
+
if not has_date:
|
|
339
|
+
issues.append(("info", page.url, "no-date",
|
|
340
|
+
tr("нет машиночитаемой даты публикации или обновления")))
|
|
341
|
+
if not has_author:
|
|
342
|
+
issues.append(("info", page.url, "no-author",
|
|
343
|
+
tr("не указан автор или организация — сигнал E-E-A-T")))
|
|
344
|
+
return issues
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def run(pages: list, robots_path: str = "", cfg: dict = None) -> dict:
|
|
348
|
+
"""Все проверки машинной читаемости разом."""
|
|
349
|
+
cfg = {**CONFIG, **(cfg or {})}
|
|
350
|
+
robots = read_robots(robots_path)
|
|
351
|
+
issues = check_robots(robots)
|
|
352
|
+
for page in sorted(pages, key=lambda p: p.url):
|
|
353
|
+
# Пустую оболочку `checks` уже назвал по имени. Оценивать её прямой
|
|
354
|
+
# ответ и извлекаемость бессмысленно: оценивать нечего.
|
|
355
|
+
if is_shell(page, cfg):
|
|
356
|
+
continue
|
|
357
|
+
issues += check_answer(page, cfg)
|
|
358
|
+
issues += check_extractable(page, cfg)
|
|
359
|
+
issues += check_jsonld(page)
|
|
360
|
+
issues += check_provenance(page)
|
|
361
|
+
return {
|
|
362
|
+
"issues": issues,
|
|
363
|
+
"robots": robots,
|
|
364
|
+
"note": tr("Проверено то, что не мешает машине взять ответ со страницы. Попадание в ответы ИИ-поиска определяется в основном вне сайта: упоминания и позиция в обычной выдаче. Пакет на это не влияет."),
|
|
365
|
+
}
|