Publish cache-safe Russian text on site photographs, agent=codex
@@ -3,3 +3,5 @@
|
|||||||
Исходники и опубликованная сборка находятся в Gitea `german/site-servaki-online`; сайт размещён отдельно на Beget. Перед изменением подтянуть `main`, не включать в Git секреты, резервные копии и локальные зависимости.
|
Исходники и опубликованная сборка находятся в 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` и число файлов. Не считать запись в локальную папку подтверждением завершённой облачной синхронизации без проверки клиента Яндекс Диска.
|
После **каждой генерации для сайта** сохранять в Яндекс Диск все исходные медиа и варианты, промпты, оптимизированные файлы и готовую сборку. Использовать новую датированную папку внутри `D:\YandexDisk\servaki.online\лендинги\servaki.online site\`. Запустить `save_generations_yadisk.py` сначала без `--copy` для плана, затем с `--copy`; указать `--raw-dir` текущего генератора, `--target` папки выпуска и при необходимости `--source` рабочей копии. Проверить итоговый `MANIFEST.json` и число файлов. Не считать запись в локальную папку подтверждением завершённой облачной синхронизации без проверки клиента Яндекс Диска.
|
||||||
|
|
||||||
|
Если на изображении есть читаемые надписи на одежде, вывесках или приборах, использовать осмысленный русский текст кириллицей. Для фото с работой специалиста допустима небольшая метка «СЕРВАКИ»; для иллюстративных объектов — короткая надпись по теме. Проверять каждое слово на полном размере и в опубликованном WebP, исключать случайные латинские буквы и псевдотекст. Не добавлять реальные имена или данные клиентов. Исходный снимок, результат редактирования и точный промпт хранить раздельно.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
Выпуск с анимацией изображений сохранён в `servaki.online/лендинги/servaki.online site/2026-09-28-motion-release/`; 256 файлов проверены по [манифесту](archive-manifests/2026-09-28-motion-release.json). Эффекты работают только на видимых карточках и выключаются при настройке «уменьшить движение». Все 23 проверенных адреса опубликованного сайта совпали со сборкой.
|
Выпуск с анимацией изображений сохранён в `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
|
**Сайт:** https://servaki.online
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- Уникальные title/description/canonical, Open Graph, Article/Service/FAQPage/BreadcrumbList, sitemap, robots.
|
- Уникальные title/description/canonical, Open Graph, Article/Service/FAQPage/BreadcrumbList, sitemap, robots.
|
||||||
- 12 тематических AI-иллюстраций для карточек главной и 12 тёплых фотореалистичных AI-изображений для внутренних страниц, с WebP-версиями 480/960/1536 px. У каждой статьи своё изображение и подпись о том, что оно создано ИИ без данных и фотографий клиентов.
|
- 12 тематических AI-иллюстраций для карточек главной и 12 тёплых фотореалистичных AI-изображений для внутренних страниц, с WebP-версиями 480/960/1536 px. У каждой статьи своё изображение и подпись о том, что оно создано ИИ без данных и фотографий клиентов.
|
||||||
- На страницах услуг — конкретные пояснения о согласовании доступа, ограничении видимости данных и бережной работе в действующем офисе или на объекте. Это описание подхода, а не обещание абсолютной безопасности.
|
- На страницах услуг — конкретные пояснения о согласовании доступа, ограничении видимости данных и бережной работе в действующем офисе или на объекте. Это описание подхода, а не обещание абсолютной безопасности.
|
||||||
|
- На четырёх тёплых иллюстративных фото уточнены надписи: «ГОСТЕВОЙ ДОМ» и «КАФЕ» на вывесках, «СЕРВАКИ» на одежде специалистов. Написание проверено на полном размере и в WebP. Исходные и отредактированные PNG хранятся отдельно; для новых файлов используется суффикс `-ru`, чтобы браузер не показывал старую закешированную версию.
|
||||||
- В тексте каждой из 12 статей — тематическая схема работы оборудования: индикаторы приборов и движение сигнала между ними. Схемы состоят из компактных SVG и CSS, не загружают GIF или видео. При настройке «уменьшить движение» индикаторы и путь остаются статичными.
|
- В тексте каждой из 12 статей — тематическая схема работы оборудования: индикаторы приборов и движение сигнала между ними. Схемы состоят из компактных SVG и CSS, не загружают GIF или видео. При настройке «уменьшить движение» индикаторы и путь остаются статичными.
|
||||||
- На 12 иллюстрациях карточек главной, в каталогах и блоках связанных решений поверх WebP добавлены короткие маршруты сигнала и индикаторы работы оборудования. Они не содержат данных клиентов. Анимация включается только при появлении карточки на экране и выключается при настройке «уменьшить движение»; изображение остаётся доступным без JavaScript. Общий вес новых CSS и JS — 2 404 байта. Самая тяжёлая иллюстрация: 37,1 КБ при 480 px, 116,7 КБ при 960 px и 236,2 КБ при 1536 px.
|
- На 12 иллюстрациях карточек главной, в каталогах и блоках связанных решений поверх WebP добавлены короткие маршруты сигнала и индикаторы работы оборудования. Они не содержат данных клиентов. Анимация включается только при появлении карточки на экране и выключается при настройке «уменьшить движение»; изображение остаётся доступным без JavaScript. Общий вес новых CSS и JS — 2 404 байта. Самая тяжёлая иллюстрация: 37,1 КБ при 480 px, 116,7 КБ при 960 px и 236,2 КБ при 1536 px.
|
||||||
- Описания под всеми 12 карточками главной переписаны: задача клиента, способ работы и результат; условия отклика указаны без обещания устранить любой сбой за 15 минут. У трёх каталогов и главной — отдельные поисковые описания.
|
- Описания под всеми 12 карточками главной переписаны: задача клиента, способ работы и результат; условия отклика указаны без обещания устранить любой сбой за 15 минут. У трёх каталогов и главной — отдельные поисковые описания.
|
||||||
@@ -30,7 +31,7 @@ python -m http.server 5186 --bind 127.0.0.1 --directory public
|
|||||||
python verify.py
|
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: локальная проверка подтвердила 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 или на телефон, сообщения автоматически не отправлялись.
|
Браузер: главная и статья на обычном экране и 390×844; горизонтального переполнения нет, responsive images загружаются, оглавление и FAQ работают. Анимированная схема сети визуально проверена на 1280 px и в мобильном окне 390 px: устройства выстраиваются вертикально, путь сигнала сохраняется. Исходная форма на главной остаётся демонстрационной; все новые CTA ведут прямо в Telegram или на телефон, сообщения автоматически не отправлялись.
|
||||||
|
|
||||||
## Публикация и откат
|
## Публикация и откат
|
||||||
|
|||||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -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}
|
BY_ID={p['slug']:p for p in PAGES}
|
||||||
PRIVACY=json.loads((ROOT/'content/privacy-notes.json').read_text(encoding='utf-8'))
|
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_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'))
|
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'))
|
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'))
|
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">'
|
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):
|
def photo_image(p):
|
||||||
slug=p['slug']
|
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):
|
def motion_image(p,small=True):
|
||||||
scene=IMAGE_MOTION[p['slug']]
|
scene=IMAGE_MOTION[p['slug']]
|
||||||
paths=[]
|
paths=[]
|
||||||
@@ -136,12 +137,12 @@ def build():
|
|||||||
for width in [480,960,1536]:
|
for width in [480,960,1536]:
|
||||||
img=ROOT/'assets'/f'{p["slug"]}-{width}.webp'
|
img=ROOT/'assets'/f'{p["slug"]}-{width}.webp'
|
||||||
if img.exists():shutil.copy2(img,dest/img.name)
|
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))
|
if not photo.exists():raise RuntimeError('Missing warm photograph: '+str(photo))
|
||||||
shutil.copy2(photo,dest/photo.name)
|
shutil.copy2(photo,dest/photo.name)
|
||||||
page=article(p)
|
page=article(p)
|
||||||
page=page.replace(image(p,lazy=False),photo_image(p),1)
|
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(' · Тематическая AI-иллюстрация',' · Изображение создано ИИ, без данных и фотографий клиентов',1)
|
||||||
page=page.replace('<article><header class="intro">','<article><div class="article-top"><header class="intro">',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)
|
page=page.replace('</figure><div class="strengths">','</figure></div><div class="strengths">',1)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "video-surveillance",
|
"slug": "video-surveillance",
|
||||||
"path": "artwork/photos-selected/video-surveillance.png"
|
"path": "artwork/photos-localized/video-surveillance.png",
|
||||||
|
"asset": "photo-video-surveillance-ru"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "servers-hosting",
|
"slug": "servers-hosting",
|
||||||
@@ -37,14 +38,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "horeca",
|
"slug": "horeca",
|
||||||
"path": "artwork/photos-selected/horeca.png"
|
"path": "artwork/photos-localized/horeca.png",
|
||||||
|
"asset": "photo-horeca-ru"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "agriculture",
|
"slug": "agriculture",
|
||||||
"path": "artwork/photos-selected/agriculture.png"
|
"path": "artwork/photos-localized/agriculture.png",
|
||||||
|
"asset": "photo-agriculture-ru"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "construction",
|
"slug": "construction",
|
||||||
"path": "artwork/photos-selected/construction.png"
|
"path": "artwork/photos-localized/construction.png",
|
||||||
|
"asset": "photo-construction-ru"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -252,6 +252,61 @@ def update_motion():
|
|||||||
sftp.close(); ssh.close()
|
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():
|
def reprice():
|
||||||
"""Replace only the published homepage after verifying the six price tokens."""
|
"""Replace only the published homepage after verifying the six price tokens."""
|
||||||
before = json.loads((BACKUP / 'deployed.json').read_text(encoding='utf-8'))
|
before = json.loads((BACKUP / 'deployed.json').read_text(encoding='utf-8'))
|
||||||
@@ -304,4 +359,5 @@ def reprice():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
{'audit': audit, 'deploy': deploy, 'verify': verify,
|
{'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]]()
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
"""Preserve generated editorial photographs and export responsive WebP versions."""
|
"""Preserve generated editorial photographs and export responsive WebP versions."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageOps
|
from PIL import Image, ImageDraw, ImageOps
|
||||||
import json, shutil
|
import json
|
||||||
ROOT=Path(__file__).resolve().parent
|
ROOT=Path(__file__).resolve().parent
|
||||||
manifest=json.loads((ROOT/'content/photo-manifest.json').read_text(encoding='utf-8'))
|
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)
|
originals=ROOT/'artwork/photos-selected'; originals.mkdir(parents=True,exist_ok=True)
|
||||||
tiles=[]
|
tiles=[]
|
||||||
for item in manifest:
|
for item in manifest:
|
||||||
slug=item['slug']; source=ROOT/item['path']
|
slug=item['slug']; source=ROOT/item['path']; asset=item.get('asset','photo-'+slug)
|
||||||
dest=originals/(slug+'.png')
|
|
||||||
if source.resolve()!=dest.resolve():shutil.copy2(source,dest)
|
|
||||||
picture=Image.open(source).convert('RGB')
|
picture=Image.open(source).convert('RGB')
|
||||||
picture=ImageOps.fit(picture,(1536,1024),method=Image.Resampling.LANCZOS,centering=(.5,.5))
|
picture=ImageOps.fit(picture,(1536,1024),method=Image.Resampling.LANCZOS,centering=(.5,.5))
|
||||||
for width in (480,960,1536):
|
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=Image.new('RGB',(384,280),'#05070f')
|
||||||
tile.paste(picture.resize((384,256)),(0,0))
|
tile.paste(picture.resize((384,256)),(0,0))
|
||||||
ImageDraw.Draw(tile).text((12,263),slug,fill='#67e8f9')
|
ImageDraw.Draw(tile).text((12,263),slug,fill='#67e8f9')
|
||||||
|
|||||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -60,6 +60,10 @@ def main():
|
|||||||
scenes=json.loads((ROOT/'content/flow-scenes.json').read_text(encoding='utf-8'))
|
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'))
|
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'))
|
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'))
|
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(scenes)=={p['slug'] for p in pages},'missing or extra equipment scene'
|
||||||
assert set(image_motion)==set(scenes),'missing or extra animated illustration'
|
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')
|
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')]
|
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'
|
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'
|
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_budgets={480:50000,960:130000,1536:250000}
|
||||||
illustration_max_bytes={}
|
illustration_max_bytes={}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ def main():
|
|||||||
PUBLIC / 'assets/editorial/motion.css',
|
PUBLIC / 'assets/editorial/motion.css',
|
||||||
PUBLIC / 'assets/editorial/motion.js',
|
PUBLIC / 'assets/editorial/motion.js',
|
||||||
PUBLIC / 'assets/editorial/photo-networks-vpn-960.webp',
|
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']
|
PUBLIC / 'assets/editorial/networks-vpn-480.webp']
|
||||||
mismatches = []
|
mismatches = []
|
||||||
for file in selected:
|
for file in selected:
|
||||||
|
|||||||