Files
site-servaki-online/build.py
T

159 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Static HTML build; no JavaScript required for article content or navigation."""
from pathlib import Path
import json, html, re, shutil, math
from xml.etree import ElementTree as ET
ROOT=Path(__file__).resolve().parent
OUT=ROOT/'public'
BASE='https://servaki.online'
DATE='2026-09-28'
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'))
FLOWS=json.loads((ROOT/'content/flow-scenes.json').read_text(encoding='utf-8'))
CARD_COPY=json.loads((ROOT/'content/card-copy.json').read_text(encoding='utf-8'))
GROUPS={'Подход':('approach','Как мы работаем','Поддержка с понятной ответственностью'),'Услуги':('services','IT-услуги для бизнеса','От рабочего места до инфраструктуры'),'Отрасли':('industries','IT-решения для отраслей','Учитываем, как работает ваш бизнес')}
HUB_DESCRIPTIONS={
'Подход':'Как servaki.online принимает обращения, поддерживает удалённые объекты и выстраивает абонентское IT-обслуживание бизнеса на КМВ и Юге России.',
'Услуги':'Удалённая помощь, сети и VPN, мониторинг, видеонаблюдение, серверы и автоматизация для бизнеса на КМВ и Юге России. Состав работ и порядок поддержки.',
'Отрасли':'IT-поддержка гостиниц и кафе, сельского хозяйства и строительных объектов: связь, рабочие системы, камеры и удалённый контроль на Юге России.'
}
ICON_PATHS={
'ticket':'<path d="M4 6h16v5a2 2 0 0 0 0 4v5H4v-5a2 2 0 0 0 0-4V6Z"/><path d="M12 7v2m0 3v2m0 3v2"/>',
'laptop':'<rect x="4" y="4" width="16" height="12" rx="2"/><path d="M2 20h20M8 20l1-4m7 4-1-4"/>',
'check':'<circle cx="12" cy="12" r="9"/><path d="m8 12 2.5 2.5L16 9"/>',
'sensor':'<rect x="7" y="3" width="10" height="18" rx="3"/><circle cx="12" cy="11" r="2"/><path d="M10 17h4"/>',
'router':'<rect x="3" y="10" width="18" height="9" rx="2"/><path d="M7 10V5m10 5V5M6 15h.01M10 15h.01M14 15h4"/>',
'settings':'<circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M4.9 4.9 7 7m10 10 2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1"/>',
'shield':'<path d="M12 2 4 5v6c0 5 3 8 8 11 5-3 8-6 8-11V5l-8-3Z"/><path d="m9 12 2 2 4-4"/>',
'user':'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3-7 8-7s8 3 8 7"/>',
'server':'<rect x="4" y="3" width="16" height="8" rx="2"/><rect x="4" y="13" width="16" height="8" rx="2"/><path d="M8 7h.01M8 17h.01M12 7h5M12 17h5"/>',
'bell':'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9Z"/><path d="M10 21h4"/>',
'camera':'<rect x="3" y="6" width="14" height="12" rx="2"/><circle cx="10" cy="12" r="3"/><path d="m17 10 4-2v8l-4-2"/>',
'database':'<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 2 4 3 8 3s8-1 8-3V5M4 12c0 2 4 3 8 3s8-1 8-3"/>',
'phone':'<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4m-3 14h2"/>'
}
def esc(t): return html.escape(str(t),quote=True)
def write(rel,text):
p=OUT/rel;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(text,encoding='utf-8')
def url(p):return '/'+p['path']+'/'
def image(p,lazy=True,small=False):
slug=p['slug']; sizes='(max-width: 600px) 100vw, 380px' if small else '(max-width: 1248px) 100vw, 1200px'
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')
def head(title,description,canonical,img,graph):
data=json.dumps({'@context':'https://schema.org','@graph':graph},ensure_ascii=False).replace('<','\\u003c')
return f'''<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{esc(title)} | servaki.online</title><meta name="description" content="{esc(description)}"><meta name="robots" content="index,follow,max-image-preview:large"><meta name="theme-color" content="#05070f"><link rel="canonical" href="{canonical}"><meta property="og:type" content="article"><meta property="og:locale" content="ru_RU"><meta property="og:site_name" content="servaki.online"><meta property="og:title" content="{esc(title)}"><meta property="og:description" content="{esc(description)}"><meta property="og:url" content="{canonical}"><meta property="og:image" content="{img}"><meta property="og:image:width" content="1536"><meta property="og:image:height" content="1024"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="{esc(title)}"><meta name="twitter:description" content="{esc(description)}"><meta name="twitter:image" content="{img}"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"><link rel="stylesheet" href="/assets/editorial/editorial.css"><script type="application/ld+json">{data}</script></head><body>'''
NAV='''<a class="skip" href="#content">К содержанию</a><header class="site-nav"><div class="wrap nav-row"><a class="brand" href="/" aria-label="servaki.online — главная"><span class="brand-orbit" aria-hidden="true">S</span><span class="brand-name">servaki.online<small>Ваш IT-отдел на орбите</small></span></a><nav class="nav-links" aria-label="Основная навигация"><a class="desktop-link" href="/services/">Услуги</a><a class="desktop-link" href="/industries/">Отрасли</a><a class="desktop-link" href="/#pricing">Тарифы</a><a class="button" href="https://t.me/servaki_online">Обсудить задачу ↗</a></nav></div></header>'''
FOOT='''<footer class="site-footer"><div class="wrap footer-row"><div>© 2026 servaki.online<br>Юг России · Ставропольский край · КМВ</div><nav class="footer-links" aria-label="Ссылки в подвале"><a href="/approach/">Наш подход</a><a href="/services/">Услуги</a><a href="/industries/">Отрасли</a><a href="tel:+79620151414">+7 962 015-14-14</a><a href="https://t.me/servaki_online">Telegram ↗</a></nav></div></footer></body></html>'''
ORG={'@type':'Organization','@id':BASE+'/#organization','name':'servaki.online','url':BASE+'/','telephone':'+79620151414','sameAs':['https://t.me/servaki_online'],'areaServed':['Юг России','Ставропольский край','Кавказские Минеральные Воды']}
def crumbs(items):
return '<nav aria-label="Хлебные крошки"><ol class="crumbs">'+''.join(f'<li><a href="{u}">{esc(t)}</a></li>' if u else f'<li aria-current="page">{esc(t)}</li>' for t,u in items)+'</ol></nav>'
def related_card(p):
return f'<a class="related-card" href="{url(p)}">{image(p,small=True)}<div class="copy"><h3>{esc(p["card"])}</h3><p>{esc(p["label"])}</p><span class="link-arrow">Подробнее <span aria-hidden="true">↗</span></span></div></a>'
def flow_diagram(p):
scene=FLOWS[p['slug']]
devices=[]
for i,(icon,label) in enumerate(scene['nodes']):
if i:devices.append('<div class="flow-link" aria-hidden="true"><span></span></div>')
glyph=f'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{ICON_PATHS[icon]}</svg>'
devices.append(f'<div class="flow-device"><div class="flow-device-top"><span class="flow-icon">{glyph}</span><span class="flow-led" aria-hidden="true"></span></div><strong>{esc(label)}</strong><span class="flow-device-index">0{i+1}</span></div>')
return f'<figure class="system-flow" id="equipment-flow"><figcaption><span class="flow-kicker">ПРИНЦИП РАБОТЫ</span><strong>{esc(scene["title"])}</strong><small>Условная схема движения сигнала, без реальных данных и настроек клиентов.</small></figcaption><div class="flow-track">{"".join(devices)}</div></figure>'
def article(p):
canonical=BASE+url(p);img=BASE+'/assets/editorial/'+p['slug']+'-1536.webp';g=GROUPS[p['group']]
article_id=canonical+'#article'
graph=[ORG,{'@type':'WebPage','@id':canonical,'url':canonical,'name':p['title'],'description':p['description'],'inLanguage':'ru-RU','isPartOf':{'@id':BASE+'/#website'},'mainEntity':{'@id':article_id}}, {'@type':'Article','@id':article_id,'headline':p['title'],'description':p['description'],'image':[img],'author':{'@id':BASE+'/#organization'},'publisher':{'@id':BASE+'/#organization'},'datePublished':DATE,'dateModified':DATE,'inLanguage':'ru-RU','mainEntityOfPage':{'@id':canonical}}, {'@type':'BreadcrumbList','itemListElement':[{'@type':'ListItem','position':i+1,'name':n,'item':BASE+u} for i,(n,u) in enumerate([('Главная','/'),(g[1],'/'+g[0]+'/'),(p['card'],url(p))])]}, {'@type':'FAQPage','@id':canonical+'#faq','mainEntity':[{'@type':'Question','name':q,'acceptedAnswer':{'@type':'Answer','text':a}} for q,a in p['faq']]}]
if p['group'] in ['Услуги','Отрасли']:
graph.append({'@type':'Service','@id':canonical+'#service','name':p['title'],'description':p['lead'],'provider':{'@id':BASE+'/#organization'},'areaServed':['Ставропольский край','Кавказские Минеральные Воды','Юг России'],'url':canonical})
toc=''.join(f'<li><a href="#part-{i}">{esc(h)}</a></li>'+('<li><a href="#equipment-flow">Схема работы оборудования</a></li>' if i==1 else '') for i,(h,_) in enumerate(p['sections'],1))
sections=''.join(f'<section id="part-{i}"><h2>{esc(h)}</h2><p>{esc(t)}</p></section>'+ (flow_diagram(p) if i==1 else '') for i,(h,t) in enumerate(p['sections'],1))
strengths=''.join(f'<div class="strength"><span>0{i} / НАШ ПОДХОД</span>{esc(t)}</div>' for i,t in enumerate(p['strengths'],1))
faq=''.join(f'<details><summary>{esc(q)}</summary><p>{esc(a)}</p></details>' for q,a in p['faq'])
minutes=max(3,math.ceil(len(' '.join(t for _,t in p['sections']).split())/170))
return head(p['title'],p['description'],canonical,img,graph)+NAV+f'''<main id="content" class="wrap"><article><header class="intro">{crumbs([('Главная','/'),(g[1],'/'+g[0]+'/'),(p['card'],None)])}<span class="eyebrow">{esc(p['group'])} / {esc(p['label'])}</span><h1>{esc(p['title'])}</h1><p class="lead">{esc(p['lead'])}</p><div class="actions"><a class="button" href="https://t.me/servaki_online">Обсудить задачу ↗</a><a class="button secondary" href="#part-1">Как это работает ↓</a></div><p class="meta">Команда servaki.online · <time datetime="{DATE}">28 сентября 2026</time> · {minutes} мин чтения</p></header><figure class="hero-figure">{image(p,lazy=False)}<figcaption>{esc(p['label'])} · Тематическая AI-иллюстрация</figcaption></figure><div class="strengths">{strengths}</div><div class="reading-grid"><aside class="toc"><strong>На этой странице</strong><ol>{toc}<li><a href="#faq">Вопросы и ответы</a></li><li><a href="#discuss">Обсудить задачу</a></li></ol></aside><div class="article-body">{sections}<section class="faq" id="faq"><h2>Вопросы и ответы</h2>{faq}</section></div></div></article><section class="consult" id="discuss"><span class="eyebrow">Следующий шаг</span><h2>Расскажите, что должно работать</h2><p>Укажите ваш объект, существующее оборудование и задачу. Разберём исходные условия, уточним необходимые доступы и предложим порядок работ. Стоимость и сроки согласуем до начала.</p><div class="actions"><a class="button" href="https://t.me/servaki_online">Написать в Telegram ↗</a><a class="button secondary" href="tel:+79620151414">+7 962 015-14-14</a><a class="button secondary" href="/#pricing">Тарифы поддержки</a></div></section><section class="related"><h2>Связанные решения</h2><div class="related-grid">{''.join(related_card(BY_ID[s]) for s in p['related'])}</div></section></main>'''+FOOT
def home(original):
result=original
for p in PAGES:
pattern=r'<article\b[^>]*>\s*(?:(?!</article>).)*<h3>'+re.escape(p['card'])+r'</h3>(?:(?!</article>).)*</article>'
match=re.search(pattern,result,re.S)
if not match:raise RuntimeError('Missing homepage card: '+p['card'])
block=match.group()
heading_end=block.find('</h3>')+len('</h3>')
before,after=block[:heading_end],block[heading_end:]
after,count=re.subn(r'(<p\b[^>]*>).*?(</p>)',lambda m:m.group(1)+esc(CARD_COPY[p['slug']])+m.group(2),after,count=1,flags=re.S)
if count!=1:raise RuntimeError('Missing homepage description: '+p['card'])
block=before+after
# Industry illustrations move below their descriptions, consistent with all cards.
block=re.sub(r'<div class="top">.*?</svg></div></div>','',block,flags=re.S)
link=f'<a class="card-landing" href="{url(p)}" aria-label="Подробнее: {esc(p["card"])}">{image(p,small=True)}<span>Подробнее: {esc(p["card"])} <b aria-hidden="true">↗</b></span></a>'
block=block.replace('</article>',link+'</article>')
result=result[:match.start()]+block+result[match.end():]
# Scope supplementary styles to homepage components; article reset must not overwrite live home.
css='''<style>.card{display:flex;flex-direction:column}.card-landing{display:block;margin-top:22px;border-radius:12px;overflow:hidden;border:1px solid rgba(255,255,255,.12);position:relative;z-index:2}.card-landing img{display:block;width:100%;height:auto;aspect-ratio:3/2;object-fit:cover;transition:transform .25s}.card-landing span{display:block;padding:14px 15px;background:#0c1326;color:#67e8f9;font-size:13px;font-weight:600;line-height:1.5}.card-landing:hover img{transform:scale(1.025)}.card-landing:focus-visible{outline:3px solid #22d3ee;outline-offset:4px}.ind .card-landing{margin:0 24px 24px}.ind .body{padding-top:26px}.card .card-landing{margin-top:auto}.card h3+p{margin-bottom:22px}.card .stack{margin-bottom:22px}body .reveal{opacity:1;transform:none}.editorial-entry{color:#67e8f9!important}@media(prefers-reduced-motion:reduce){.card-landing img{transition:none}}</style>'''
css=css.replace('.card-landing span{display:block;', '.card-landing span{display:block;min-height:67px;')
result=result.replace('</head>',css+'</head>')
result=re.sub(r'<title>.*?</title>','<title>IT-поддержка бизнеса на КМВ и Юге России — servaki.online</title>',result,count=1,flags=re.S)
result=re.sub(r'(<meta name="description" content=")[^"]*(")',lambda m:m.group(1)+'IT-поддержка бизнеса на КМВ и Юге России: рабочие места, сети и VPN, серверы, видеонаблюдение и удалённые объекты. Услуги, тарифы и порядок обращения.'+m.group(2),result,count=1)
result=re.sub(r'(<meta property="og:description" content=")[^"]*(")',lambda m:m.group(1)+'Поддержка рабочих мест, сетей, серверов и удалённых объектов для бизнеса на Юге России.'+m.group(2),result,count=1)
result=re.sub(r'(<meta (?:name|property)="(?:og:title|twitter:title)" content=")[^"]*(")',lambda m:m.group(1)+'IT-поддержка бизнеса на КМВ и Юге России — servaki.online'+m.group(2),result)
result=re.sub(r'(<meta (?:name|property)="twitter:description" content=")[^"]*(")',lambda m:m.group(1)+'Рабочие места, сети и VPN, серверы, видеонаблюдение и удалённые объекты. Услуги и тарифы servaki.online.'+m.group(2),result)
result=result.replace('https://servaki.online/og-cover.png',BASE+'/assets/editorial/remote-sites-1536.webp')
# Make essential content crawlable and visible even if the original reveal script fails.
result=result.replace('<li><a href="#services">Услуги</a></li>','<li><a href="/services/">Все услуги</a></li>')
result=result.replace('<li><a href="#industries">Отрасли</a></li>','<li><a href="/industries/">Решения для отраслей</a></li>')
result=result.replace('<li><a href="#about">О нас</a></li>','<li><a href="/approach/">Наш подход</a></li>')
# Connect process/pricing context to detailed pages.
result=result.replace('Простой и прозрачный процесс — от вашего сообщения до постоянного мониторинга.','Простой и прозрачный процесс — от вашего сообщения до постоянного мониторинга. <a class="editorial-entry" href="/approach/rapid-response/">Как устроена поддержка →</a>')
result=result.replace('<h2 class="sec-title reveal d1" id="pricing-h">Прозрачные подписки</h2>','<h2 class="sec-title reveal d1" id="pricing-h">Прозрачные подписки</h2><p><a class="editorial-entry" href="/approach/it-subscription/">Что входит в обслуживание и как выбрать тариф →</a></p>')
for old in (3000, 7000, 15000):
structured=f'"price": "{old}"'
visible=f'<span class="amount">{old}</span>'
if result.count(structured)!=1 or result.count(visible)!=1:
raise RuntimeError(f'Expected one visible and one structured price for {old}')
new=old*3
result=result.replace(structured,f'"price": "{new}"')
result=result.replace(visible,f'<span class="amount">{new:,}</span>'.replace(',', ' '))
return result
def build():
OUT.mkdir(exist_ok=True)
dest=OUT/'assets/editorial';dest.mkdir(parents=True,exist_ok=True)
shutil.copy2(ROOT/'assets/editorial.css',dest/'editorial.css')
for p in PAGES:
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'
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(' · Тематическая 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)
note=f'<aside class="privacy-note" aria-label="Бережный доступ"><span>БЕРЕЖНО К ДАННЫМ</span><p>{esc(PRIVACY[p["slug"]])}</p></aside>'
page=page.replace('</figure></div><div class="strengths">','</figure></div>'+note+'<div class="strengths">',1)
write(p['path']+'/index.html',page)
original=(ROOT/'reference/original.html').read_text(encoding='utf-8')
write('index.html',home(original))
for group,(slug,title,label) in GROUPS.items():
desc=HUB_DESCRIPTIONS[group]
write(slug+'/index.html',head(title,desc,BASE+'/'+slug+'/',BASE+'/assets/editorial/remote-sites-1536.webp',[ORG,{'@type':'CollectionPage','name':title,'url':BASE+'/'+slug+'/','inLanguage':'ru-RU'}])+NAV+f'<main class="wrap" id="content"><header class="intro hub-intro">{crumbs([("Главная","/"),(title,None)])}<span class="eyebrow">{label}</span><h1>{title}</h1><p class="lead">{esc(desc)}</p></header><div class="hub-grid">'+''.join(related_card(p) for p in PAGES if p['group']==group)+'</div></main>'+FOOT)
# Preserve existing URLs when a previous sitemap is available.
locations={BASE+'/',*[BASE+url(p) for p in PAGES],*[BASE+'/'+v[0]+'/' for v in GROUPS.values()]}
previous=ROOT/'reference/sitemap.xml'
if previous.exists():
try:locations.update(e.text for e in ET.fromstring(previous.read_bytes()).iter() if e.tag.endswith('}loc') and e.text.startswith(BASE+'/'))
except ET.ParseError:pass
write('sitemap.xml','<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+''.join(f'<url><loc>{esc(u)}</loc></url>' for u in sorted(locations))+'</urlset>')
robots=ROOT/'reference/robots.txt'
r=robots.read_text(encoding='utf-8') if robots.exists() else 'User-agent: *\nAllow: /\n'
if '<html' in r.lower():raise RuntimeError('robots reference is HTML')
if 'Sitemap: '+BASE+'/sitemap.xml' not in r:r+='\nSitemap: '+BASE+'/sitemap.xml\n'
write('robots.txt',r)
print(f'Built {len(PAGES)} articles, 3 hubs, updated homepage, sitemap and robots.')
if __name__=='__main__':build()