"""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':'',
'laptop':'',
'check':'',
'sensor':'',
'router':'',
'settings':'',
'shield':'',
'user':'',
'server':'',
'bell':'',
'camera':'',
'database':'',
'phone':''
}
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''
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'''
'
def flow_diagram(p):
scene=FLOWS[p['slug']]
devices=[]
for i,(icon,label) in enumerate(scene['nodes']):
if i:devices.append('
')
glyph=f''
devices.append(f'
{glyph}
{esc(label)}0{i+1}
')
return f'ПРИНЦИП РАБОТЫ{esc(scene["title"])}Условная схема движения сигнала, без реальных данных и настроек клиентов.
{"".join(devices)}
'
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'
' if i==1 else '') for i,(h,_) in enumerate(p['sections'],1))
sections=''.join(f'
{esc(h)}
{esc(t)}
'+ (flow_diagram(p) if i==1 else '') for i,(h,t) in enumerate(p['sections'],1))
strengths=''.join(f'
0{i} / НАШ ПОДХОД{esc(t)}
' for i,t in enumerate(p['strengths'],1))
faq=''.join(f'{esc(q)}
{esc(a)}
' 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'''{crumbs([('Главная','/'),(g[1],'/'+g[0]+'/'),(p['card'],None)])}{esc(p['group'])} / {esc(p['label'])}
Укажите ваш объект, существующее оборудование и задачу. Разберём исходные условия, уточним необходимые доступы и предложим порядок работ. Стоимость и сроки согласуем до начала.
{''.join(related_card(BY_ID[s]) for s in p['related'])}
'''+FOOT
def home(original):
result=original
for p in PAGES:
pattern=r']*>\s*(?:(?!).)*
'+re.escape(p['card'])+r'
(?:(?!).)*'
match=re.search(pattern,result,re.S)
if not match:raise RuntimeError('Missing homepage card: '+p['card'])
block=match.group()
heading_end=block.find('')+len('')
before,after=block[:heading_end],block[heading_end:]
after,count=re.subn(r'(
]*>).*?(
)',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'
.*?
','',block,flags=re.S)
link=f'{image(p,small=True)}Подробнее: {esc(p["card"])} ↗'
block=block.replace('',link+'')
result=result[:match.start()]+block+result[match.end():]
# Scope supplementary styles to homepage components; article reset must not overwrite live home.
css=''''''
css=css.replace('.card-landing span{display:block;', '.card-landing span{display:block;min-height:67px;')
result=result.replace('',css+'')
result=re.sub(r'.*?','IT-поддержка бизнеса на КМВ и Юге России — servaki.online',result,count=1,flags=re.S)
result=re.sub(r'(Услуги','
')
# Connect process/pricing context to detailed pages.
result=result.replace('Простой и прозрачный процесс — от вашего сообщения до постоянного мониторинга.','Простой и прозрачный процесс — от вашего сообщения до постоянного мониторинга. Как устроена поддержка →')
result=result.replace('
')
for old in (3000, 7000, 15000):
structured=f'"price": "{old}"'
visible=f'{old}'
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'{new:,}'.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('','
',1)
page=page.replace('
','
',1)
note=f''
page=page.replace('
','
'+note+'
',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'{crumbs([("Главная","/"),(title,None)])}{label}
{title}
{esc(desc)}
'+''.join(related_card(p) for p in PAGES if p['group']==group)+'
'+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','\n'+''.join(f'{esc(u)}' for u in sorted(locations))+'')
robots=ROOT/'reference/robots.txt'
r=robots.read_text(encoding='utf-8') if robots.exists() else 'User-agent: *\nAllow: /\n'
if '