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.
Files changed (47) hide show
  1. indexgap/__init__.py +2 -0
  2. indexgap/__main__.py +9 -0
  3. indexgap/aeo.py +365 -0
  4. indexgap/checks.py +678 -0
  5. indexgap/cite.py +335 -0
  6. indexgap/cli.py +1083 -0
  7. indexgap/content.py +517 -0
  8. indexgap/core.py +835 -0
  9. indexgap/doctor.py +461 -0
  10. indexgap/engines.py +179 -0
  11. indexgap/freshness.py +130 -0
  12. indexgap/generate.py +453 -0
  13. indexgap/hreflang.py +324 -0
  14. indexgap/i18n.py +133 -0
  15. indexgap/install.py +409 -0
  16. indexgap/locale/__init__.py +8 -0
  17. indexgap/locale/en/__init__.py +14 -0
  18. indexgap/locale/en/checks.py +163 -0
  19. indexgap/locale/en/cite.py +100 -0
  20. indexgap/locale/en/cli.py +281 -0
  21. indexgap/locale/en/content.py +89 -0
  22. indexgap/locale/en/core.py +135 -0
  23. indexgap/locale/en/doctor.py +168 -0
  24. indexgap/locale/en/hreflang.py +116 -0
  25. indexgap/locale/en/repair.py +229 -0
  26. indexgap/locale/en/report.py +223 -0
  27. indexgap/portfolio.py +268 -0
  28. indexgap/profiles.py +198 -0
  29. indexgap/publish.py +313 -0
  30. indexgap/repair.py +343 -0
  31. indexgap/report.py +431 -0
  32. indexgap/settings.py +222 -0
  33. indexgap/skills/indexgap-plan/SKILL.en.md +97 -0
  34. indexgap/skills/indexgap-plan/SKILL.md +97 -0
  35. indexgap/skills/indexgap-portfolio/SKILL.en.md +94 -0
  36. indexgap/skills/indexgap-portfolio/SKILL.md +90 -0
  37. indexgap/skills/indexgap-publish/SKILL.en.md +151 -0
  38. indexgap/skills/indexgap-publish/SKILL.md +147 -0
  39. indexgap/skills/indexgap-review/SKILL.en.md +139 -0
  40. indexgap/skills/indexgap-review/SKILL.md +137 -0
  41. indexgap/sources.py +421 -0
  42. indexgap-1.5.0.dist-info/METADATA +399 -0
  43. indexgap-1.5.0.dist-info/RECORD +47 -0
  44. indexgap-1.5.0.dist-info/WHEEL +5 -0
  45. indexgap-1.5.0.dist-info/entry_points.txt +2 -0
  46. indexgap-1.5.0.dist-info/licenses/LICENSE +21 -0
  47. indexgap-1.5.0.dist-info/top_level.txt +1 -0
indexgap/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """indexgap — контроль качества programmatic-конвейера. Без зависимостей."""
2
+ __version__ = "1.5.0"
indexgap/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Точка входа для `python -m indexgap`. Установленный пакет даёт команду `indexgap`."""
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
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
+ }