Publish cache-safe Russian text on site photographs, agent=codex

This commit is contained in:
german
2026-09-28 17:53:26 +03:00
parent c9ed6ec8e3
commit 97292166f3
39 changed files with 1699 additions and 27 deletions
+2
View File
@@ -3,3 +3,5 @@
Исходники и опубликованная сборка находятся в Gitea `german/site-servaki-online`; сайт размещён отдельно на Beget. Перед изменением подтянуть `main`, не включать в Git секреты, резервные копии и локальные зависимости.
После **каждой генерации для сайта** сохранять в Яндекс Диск все исходные медиа и варианты, промпты, оптимизированные файлы и готовую сборку. Использовать новую датированную папку внутри `D:\YandexDisk\servaki.online\лендинги\servaki.online site\`. Запустить `save_generations_yadisk.py` сначала без `--copy` для плана, затем с `--copy`; указать `--raw-dir` текущего генератора, `--target` папки выпуска и при необходимости `--source` рабочей копии. Проверить итоговый `MANIFEST.json` и число файлов. Не считать запись в локальную папку подтверждением завершённой облачной синхронизации без проверки клиента Яндекс Диска.
Если на изображении есть читаемые надписи на одежде, вывесках или приборах, использовать осмысленный русский текст кириллицей. Для фото с работой специалиста допустима небольшая метка «СЕРВАКИ»; для иллюстративных объектов — короткая надпись по теме. Проверять каждое слово на полном размере и в опубликованном WebP, исключать случайные латинские буквы и псевдотекст. Не добавлять реальные имена или данные клиентов. Исходный снимок, результат редактирования и точный промпт хранить раздельно.
+2
View File
@@ -15,6 +15,8 @@
Выпуск с анимацией изображений сохранён в `servaki.online/лендинги/servaki.online site/2026-09-28-motion-release/`; 256 файлов проверены по [манифесту](archive-manifests/2026-09-28-motion-release.json). Эффекты работают только на видимых карточках и выключаются при настройке «уменьшить движение». Все 23 проверенных адреса опубликованного сайта совпали со сборкой.
Четыре фото с надписями на одежде и вывесках локализованы на русский язык. Версии WebP имеют суффикс `-ru`, чтобы обновление не зависело от кэша браузера; исходные снимки сохранены отдельно. [Архив нового выпуска](archive-manifests/2026-09-28-russian-image-text.json) содержит 289 файлов. На сайте 27 проверенных адресов совпали со сборкой.
## Сведения о прежней версии
**Сайт:** https://servaki.online
+4 -1
View File
@@ -11,6 +11,7 @@
- Уникальные title/description/canonical, Open Graph, Article/Service/FAQPage/BreadcrumbList, sitemap, robots.
- 12 тематических AI-иллюстраций для карточек главной и 12 тёплых фотореалистичных AI-изображений для внутренних страниц, с WebP-версиями 480/960/1536 px. У каждой статьи своё изображение и подпись о том, что оно создано ИИ без данных и фотографий клиентов.
- На страницах услуг — конкретные пояснения о согласовании доступа, ограничении видимости данных и бережной работе в действующем офисе или на объекте. Это описание подхода, а не обещание абсолютной безопасности.
- На четырёх тёплых иллюстративных фото уточнены надписи: «ГОСТЕВОЙ ДОМ» и «КАФЕ» на вывесках, «СЕРВАКИ» на одежде специалистов. Написание проверено на полном размере и в WebP. Исходные и отредактированные PNG хранятся отдельно; для новых файлов используется суффикс `-ru`, чтобы браузер не показывал старую закешированную версию.
- В тексте каждой из 12 статей — тематическая схема работы оборудования: индикаторы приборов и движение сигнала между ними. Схемы состоят из компактных SVG и CSS, не загружают GIF или видео. При настройке «уменьшить движение» индикаторы и путь остаются статичными.
- На 12 иллюстрациях карточек главной, в каталогах и блоках связанных решений поверх WebP добавлены короткие маршруты сигнала и индикаторы работы оборудования. Они не содержат данных клиентов. Анимация включается только при появлении карточки на экране и выключается при настройке «уменьшить движение»; изображение остаётся доступным без JavaScript. Общий вес новых CSS и JS — 2 404 байта. Самая тяжёлая иллюстрация: 37,1 КБ при 480 px, 116,7 КБ при 960 px и 236,2 КБ при 1536 px.
- Описания под всеми 12 карточками главной переписаны: задача клиента, способ работы и результат; условия отклика указаны без обещания устранить любой сбой за 15 минут. У трёх каталогов и главной — отдельные поисковые описания.
@@ -30,7 +31,7 @@ python -m http.server 5186 --bind 127.0.0.1 --directory public
python verify.py
```
`content/pages.json` — тексты; `content/card-copy.json` — тексты карточек; `content/flow-scenes.json` — подписи и оборудование для анимированных схем; `content/image-motion.json` — иллюстративные маршруты сигнала на карточках; `content/image-prompts.json` и `content/photo-prompts.json` — промпты встроенного image_gen; `content/photo-alt.json` и `content/privacy-notes.json` — описания фото и редакционные пояснения; `artwork/` — оригиналы; `assets/` — CSS, JS и WebP; `public/` — готовый сайт; `reference/original.html` — сохранённая исходная главная.
`content/pages.json` — тексты; `content/card-copy.json` — тексты карточек; `content/flow-scenes.json` — подписи и оборудование для анимированных схем; `content/image-motion.json` — иллюстративные маршруты сигнала на карточках; `content/image-prompts.json` и `content/photo-prompts.json` — промпты исходной генерации; `content/photo-edit-prompts.json` — точные русские надписи и промпты локализации; `content/photo-alt.json` и `content/privacy-notes.json` — описания фото и редакционные пояснения; `artwork/` — оригиналы и отредактированные фото; `assets/` — CSS, JS и WebP; `public/` — готовый сайт; `reference/original.html` — сохранённая исходная главная.
## Фактическая основа
@@ -58,6 +59,8 @@ python verify.py
Выпуск анимации 28.09.2026: локальная проверка подтвердила 12 анимированных иллюстраций, 16 доступных HTML-страниц, лимиты размеров WebP и неизменные цены. В Chrome проверены каталоги при ширине 1280 и 500 px, а также режим «уменьшить движение». Скрипт `deploy_beget.py update-motion` перед заменой сравнивает все файлы предыдущего выпуска с боевым сайтом и сохраняет изменяемые HTML вне публичного каталога.
Выпуск русских надписей 28.09.2026: четыре фото получили версионированные файлы `photo-*-ru-{480,960,1536}.webp`; заменены только четыре профильные статьи, исходные WebP сохранены для отката. Перед загрузкой 100 файлов прежнего выпуска сверены с Beget, после неё 27 публичных адресов побайтно совпали со сборкой, TLS действителен. Предыдущие HTML сохранены вне webroot в `/home/i/infogypa/servaki.online/backup-2026-09-28-before-russian-photos/`. Все 289 исходных и готовых файлов с проверенными SHA-256 сохранены в Яндекс Диске `servaki.online/лендинги/servaki.online site/2026-09-28-russian-image-text/`; облачная синхронизация отдельно не подтверждена.
Браузер: главная и статья на обычном экране и 390×844; горизонтального переполнения нет, responsive images загружаются, оглавление и FAQ работают. Анимированная схема сети визуально проверена на 1280 px и в мобильном окне 390 px: устройства выстраиваются вертикально, путь сигнала сохраняется. Исходная форма на главной остаётся демонстрационной; все новые CTA ведут прямо в Telegram или на телефон, сообщения автоматически не отправлялись.
## Публикация и откат
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+4 -3
View File
@@ -11,6 +11,7 @@ PAGES=json.loads((ROOT/'content/pages.json').read_text(encoding='utf-8'))
BY_ID={p['slug']:p for p in PAGES}
PRIVACY=json.loads((ROOT/'content/privacy-notes.json').read_text(encoding='utf-8'))
PHOTO_ALT=json.loads((ROOT/'content/photo-alt.json').read_text(encoding='utf-8'))
PHOTO_ASSET={item['slug']:item.get('asset','photo-'+item['slug']) for item in json.loads((ROOT/'content/photo-manifest.json').read_text(encoding='utf-8'))}
FLOWS=json.loads((ROOT/'content/flow-scenes.json').read_text(encoding='utf-8'))
IMAGE_MOTION=json.loads((ROOT/'content/image-motion.json').read_text(encoding='utf-8'))
CARD_COPY=json.loads((ROOT/'content/card-copy.json').read_text(encoding='utf-8'))
@@ -44,7 +45,7 @@ def image(p,lazy=True,small=False):
return f'<img src="/assets/editorial/{slug}-960.webp" srcset="/assets/editorial/{slug}-480.webp 480w, /assets/editorial/{slug}-960.webp 960w, /assets/editorial/{slug}-1536.webp 1536w" sizes="{sizes}" width="1536" height="1024" alt="{esc(p["alt"])}" {"loading=\"lazy\"" if lazy else "fetchpriority=\"high\""} decoding="async">'
def photo_image(p):
slug=p['slug']
return image(p,lazy=False).replace(f'/{slug}-',f'/photo-{slug}-').replace(esc(p['alt']),esc(PHOTO_ALT[slug])).replace('(max-width: 1248px) 100vw, 1200px','(max-width: 600px) calc(100vw - 32px), (max-width: 1000px) calc(100vw - 48px), 520px')
return image(p,lazy=False).replace(f'/{slug}-',f'/{PHOTO_ASSET[slug]}-').replace(esc(p['alt']),esc(PHOTO_ALT[slug])).replace('(max-width: 1248px) 100vw, 1200px','(max-width: 600px) calc(100vw - 32px), (max-width: 1000px) calc(100vw - 48px), 520px')
def motion_image(p,small=True):
scene=IMAGE_MOTION[p['slug']]
paths=[]
@@ -136,12 +137,12 @@ def build():
for width in [480,960,1536]:
img=ROOT/'assets'/f'{p["slug"]}-{width}.webp'
if img.exists():shutil.copy2(img,dest/img.name)
photo=ROOT/'assets'/f'photo-{p["slug"]}-{width}.webp'
photo=ROOT/'assets'/f'{PHOTO_ASSET[p["slug"]]}-{width}.webp'
if not photo.exists():raise RuntimeError('Missing warm photograph: '+str(photo))
shutil.copy2(photo,dest/photo.name)
page=article(p)
page=page.replace(image(p,lazy=False),photo_image(p),1)
page=page.replace(f'/assets/editorial/{p["slug"]}-1536.webp',f'/assets/editorial/photo-{p["slug"]}-1536.webp')
page=page.replace(f'/assets/editorial/{p["slug"]}-1536.webp',f'/assets/editorial/{PHOTO_ASSET[p["slug"]]}-1536.webp')
page=page.replace(' · Тематическая AI-иллюстрация',' · Изображение создано ИИ, без данных и фотографий клиентов',1)
page=page.replace('<article><header class="intro">','<article><div class="article-top"><header class="intro">',1)
page=page.replace('</figure><div class="strengths">','</figure></div><div class="strengths">',1)
+34
View File
@@ -0,0 +1,34 @@
{
"mode": "built-in image_gen, text-localization edit",
"rule": "If any text is visible in a site image, use meaningful, correctly spelled Russian Cyrillic. Verify every word at full resolution; avoid invented client details, Latin letters and gibberish.",
"edits": {
"video-surveillance": {
"source": "artwork/photos-selected/video-surveillance.png",
"result": "artwork/photos-localized/video-surveillance.png",
"exact_text": ["ГОСТЕВОЙ ДОМ", "СЕРВАКИ"],
"placement": "Wooden guesthouse sign and technician's navy polo",
"prompt": "Preserve the people, camera, stone courtyard, lighting and composition. Add ГОСТЕВОЙ ДОМ in readable Cyrillic to the blank wooden sign, and a small СЕРВАКИ embroidered label to the technician's polo. No other text or changes."
},
"horeca": {
"source": "artwork/photos-selected/horeca.png",
"result": "artwork/photos-localized/horeca.png",
"exact_text": ["КАФЕ", "СЕРВАКИ"],
"placement": "Chalkboard and technician's dark jacket",
"prompt": "Preserve the people, access point, tablet, wood interior and composition. Add КАФЕ in readable Cyrillic on the blank chalkboard and a small СЕРВАКИ embroidered label on the technician's jacket. No other text or changes."
},
"construction": {
"source": "artwork/photos-selected/construction.png",
"result": "artwork/photos-localized/construction.png",
"exact_text": ["СЕРВАКИ"],
"placement": "Technical specialist's black jacket",
"prompt": "Preserve both people, camera, plans, safety gear, building, lighting and composition. Add one small correctly spelled Cyrillic СЕРВАКИ label on the technical specialist's black jacket. No other text or changes."
},
"agriculture": {
"source": "artwork/photos-selected/agriculture.png",
"result": "artwork/photos-localized/agriculture.png",
"exact_text": ["СЕРВАКИ"],
"placement": "Technical specialist's dark jacket",
"prompt": "Preserve the farmers, specialist, sensor, laptop, farm landscape, lighting and composition. Add one small correctly spelled Cyrillic СЕРВАКИ label on the technical specialist's dark jacket. No other text or changes."
}
}
}
+16 -12
View File
@@ -23,9 +23,10 @@
"slug": "monitoring-security",
"path": "artwork/photos-selected/monitoring-security.png"
},
{
"slug": "video-surveillance",
"path": "artwork/photos-selected/video-surveillance.png"
{
"slug": "video-surveillance",
"path": "artwork/photos-localized/video-surveillance.png",
"asset": "photo-video-surveillance-ru"
},
{
"slug": "servers-hosting",
@@ -35,16 +36,19 @@
"slug": "automation",
"path": "artwork/photos-selected/automation.png"
},
{
"slug": "horeca",
"path": "artwork/photos-selected/horeca.png"
{
"slug": "horeca",
"path": "artwork/photos-localized/horeca.png",
"asset": "photo-horeca-ru"
},
{
"slug": "agriculture",
"path": "artwork/photos-selected/agriculture.png"
{
"slug": "agriculture",
"path": "artwork/photos-localized/agriculture.png",
"asset": "photo-agriculture-ru"
},
{
"slug": "construction",
"path": "artwork/photos-selected/construction.png"
{
"slug": "construction",
"path": "artwork/photos-localized/construction.png",
"asset": "photo-construction-ru"
}
]
+57 -1
View File
@@ -252,6 +252,61 @@ def update_motion():
sftp.close(); ssh.close()
def update_localized_photos():
"""Publish four Russian-lettered photos under new cache-safe asset names."""
previous = ROOT.parent / 'site-servaki-online-git' / 'public'
if not previous.is_dir():
raise RuntimeError('Previous Gitea release is required for the baseline check')
old = {p.relative_to(previous).as_posix(): digest(p.read_bytes())
for p in previous.rglob('*') if p.is_file()}
new = manifest()
slugs = ('video-surveillance', 'horeca', 'agriculture', 'construction')
pages = {'services/video-surveillance/index.html',
*(f'industries/{slug}/index.html' for slug in slugs if slug != 'video-surveillance')}
assets = {f'assets/editorial/photo-{slug}-ru-{width}.webp'
for slug in slugs for width in (480, 960, 1536)}
changed = {rel for rel in new if new[rel] != old.get(rel)}
if set(old) - set(new) or changed != pages | assets:
raise RuntimeError('Unexpected Russian-photo release scope: ' + repr(sorted(changed ^ (pages | assets))))
local_backup = ROOT / 'release-backups' / '2026-09-28-before-russian-photos'
remote_backup = SITE + '/backup-2026-09-28-before-russian-photos'
ssh, sftp = connect()
try:
for rel, expected in old.items():
target = WEBROOT + '/' + rel
if not exists(sftp, target) or digest(remote_bytes(sftp, target)) != expected:
raise RuntimeError('Published baseline changed; stopped before writing: ' + rel)
for rel in assets:
if exists(sftp, WEBROOT + '/' + rel):
raise RuntimeError('New localized asset already exists: ' + rel)
mkdirs(sftp, remote_backup)
for rel in sorted(pages):
data = remote_bytes(sftp, WEBROOT + '/' + rel)
destination = local_backup / rel
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists() and destination.read_bytes() != data:
raise RuntimeError('Local backup conflict: ' + rel)
destination.write_bytes(data)
remote_copy = remote_backup + '/' + rel
if exists(sftp, remote_copy):
if digest(remote_bytes(sftp, remote_copy)) != old[rel]:
raise RuntimeError('Remote backup conflict: ' + rel)
else:
put_atomic(sftp, destination, remote_copy)
for rel in sorted(assets):
put_atomic(sftp, ROOT / 'public' / rel, WEBROOT + '/' + rel)
for rel in sorted(pages):
put_atomic(sftp, ROOT / 'public' / rel, WEBROOT + '/' + rel,
replace=True, backup_dir=remote_backup)
for rel in changed:
if digest(remote_bytes(sftp, WEBROOT + '/' + rel)) != new[rel]:
raise RuntimeError('Published localized photo failed checksum: ' + rel)
print('Published Russian-lettered photos; changed files:', len(changed),
'verified previous files:', len(old), 'backup:', remote_backup)
finally:
sftp.close(); ssh.close()
def reprice():
"""Replace only the published homepage after verifying the six price tokens."""
before = json.loads((BACKUP / 'deployed.json').read_text(encoding='utf-8'))
@@ -304,4 +359,5 @@ def reprice():
if __name__ == '__main__':
{'audit': audit, 'deploy': deploy, 'verify': verify,
'reprice': reprice, 'update-motion': update_motion}[sys.argv[1]]()
'reprice': reprice, 'update-motion': update_motion,
'update-localized-photos': update_localized_photos}[sys.argv[1]]()
+3 -5
View File
@@ -1,19 +1,17 @@
"""Preserve generated editorial photographs and export responsive WebP versions."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageOps
import json, shutil
import json
ROOT=Path(__file__).resolve().parent
manifest=json.loads((ROOT/'content/photo-manifest.json').read_text(encoding='utf-8'))
originals=ROOT/'artwork/photos-selected'; originals.mkdir(parents=True,exist_ok=True)
tiles=[]
for item in manifest:
slug=item['slug']; source=ROOT/item['path']
dest=originals/(slug+'.png')
if source.resolve()!=dest.resolve():shutil.copy2(source,dest)
slug=item['slug']; source=ROOT/item['path']; asset=item.get('asset','photo-'+slug)
picture=Image.open(source).convert('RGB')
picture=ImageOps.fit(picture,(1536,1024),method=Image.Resampling.LANCZOS,centering=(.5,.5))
for width in (480,960,1536):
picture.resize((width,round(width*2/3)),Image.Resampling.LANCZOS).save(ROOT/'assets'/f'photo-{slug}-{width}.webp',quality=83,method=6)
picture.resize((width,round(width*2/3)),Image.Resampling.LANCZOS).save(ROOT/'assets'/f'{asset}-{width}.webp',quality=83,method=6)
tile=Image.new('RGB',(384,280),'#05070f')
tile.paste(picture.resize((384,256)),(0,0))
ImageDraw.Draw(tile).text((12,263),slug,fill='#67e8f9')
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -60,6 +60,10 @@ def main():
scenes=json.loads((ROOT/'content/flow-scenes.json').read_text(encoding='utf-8'))
image_motion=json.loads((ROOT/'content/image-motion.json').read_text(encoding='utf-8'))
pages=json.loads((ROOT/'content/pages.json').read_text(encoding='utf-8'))
photo_manifest=json.loads((ROOT/'content/photo-manifest.json').read_text(encoding='utf-8'))
photo_edits=json.loads((ROOT/'content/photo-edit-prompts.json').read_text(encoding='utf-8'))['edits']
assert len(photo_manifest)==len(pages)==12,'photo manifest coverage'
assert set(photo_edits)=={'video-surveillance','horeca','agriculture','construction'},'localized photo coverage'
card_copy=json.loads((ROOT/'content/card-copy.json').read_text(encoding='utf-8'))
assert set(scenes)=={p['slug'] for p in pages},'missing or extra equipment scene'
assert set(image_motion)==set(scenes),'missing or extra animated illustration'
@@ -108,7 +112,8 @@ def main():
else:raise AssertionError('Negative control did not detect broken image')
locations=[e.text for e in ET.parse(PUBLIC/'sitemap.xml').iter() if e.tag.endswith('}loc')]
assert len(locations)==16 and len(set(locations))==16,'sitemap pages'
small_photos=list((PUBLIC/'assets/editorial').glob('photo-*-480.webp'))
small_photos=[PUBLIC/'assets/editorial'/f'{item.get("asset","photo-"+item["slug"])}-480.webp' for item in photo_manifest]
assert all(p.is_file() for p in small_photos),'missing current article photograph'
assert len(small_photos)==len(pages) and max(p.stat().st_size for p in small_photos)<50000,'oversized mobile photograph'
illustration_budgets={480:50000,960:130000,1536:250000}
illustration_max_bytes={}
+4
View File
@@ -42,6 +42,10 @@ def main():
PUBLIC / 'assets/editorial/motion.css',
PUBLIC / 'assets/editorial/motion.js',
PUBLIC / 'assets/editorial/photo-networks-vpn-960.webp',
PUBLIC / 'assets/editorial/photo-video-surveillance-ru-960.webp',
PUBLIC / 'assets/editorial/photo-horeca-ru-960.webp',
PUBLIC / 'assets/editorial/photo-agriculture-ru-960.webp',
PUBLIC / 'assets/editorial/photo-construction-ru-960.webp',
PUBLIC / 'assets/editorial/networks-vpn-480.webp']
mismatches = []
for file in selected: