171 lines
23 KiB
Python
171 lines
23 KiB
Python
"""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'))
|
||
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'))
|
||
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_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=[]
|
||
for route in scene['routes']:
|
||
d=esc(route)
|
||
paths.append(f'<path class="motion-path" d="{d}"/><circle class="motion-packet" r="6"><animateMotion path="{d}" dur="4.8s" repeatCount="indefinite"/></circle><circle class="motion-packet motion-packet-late" r="4"><animateMotion path="{d}" dur="4.8s" begin="2.4s" repeatCount="indefinite"/></circle>')
|
||
lights=''.join(f'<circle class="motion-light" cx="{x}" cy="{y}" r="7"/>' for x,y in scene['lights'])
|
||
return f'<div class="motion-visual">{image(p,small=small)}<svg class="motion-overlay" viewBox="0 0 960 640" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">{"".join(paths)}{lights}</svg></div>'
|
||
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"><link rel="stylesheet" href="/assets/editorial/motion.css"><script src="/assets/editorial/motion.js" defer></script><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)}">{motion_image(p)}<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"])}">{motion_image(p)}<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>','<link rel="stylesheet" href="/assets/editorial/motion.css"><script src="/assets/editorial/motion.js" defer></script>'+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')
|
||
shutil.copy2(ROOT/'assets/motion.css',dest/'motion.css')
|
||
shutil.copy2(ROOT/'assets/motion.js',dest/'motion.js')
|
||
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_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_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)
|
||
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()
|