Skip to content

Commit 11954b0

Browse files
committed
Rebuild language pages as genuinely single-language static HTML
index.html/ru/es/hi previously shared one multi-language template with all 4 languages' content hidden via CSS+JS — AI crawlers and raw-HTML readers saw every language mixed together on every page. Same issue existed for privacy.html/terms.html. - Rename index.html/privacy.html/terms.html to *_template.html (source templates, no longer deployed directly) - build.py now generates index.html, ru/, es/, hi/ *and* privacy/terms per language from these templates via a stdlib html.parser filter that strips every non-matching data-lang subtree, instead of just toggling CSS classes — each deployed page now contains only its own language - Fix lang-switcher buttons to navigate cross-page (they used to just toggle a CSS class, which broke once pages stopped containing every language's markup); drop the now-meaningless ?lang= override - Wire the "find your sign by birth year" widget into the same compatibility/result flow as the quiz instead of a dead-end teaser text; move the quiz above it so the primary path comes first - Reorder + complete the quiz section's hidden a11y heading for es/hi - Expand sitemap.xml to include all 48 zodiac pages and the new per-language legal pages with proper hreflang (was 6 URLs, now 60) - Update llms.txt with zodiac page links and per-language legal URLs - Fix validate.py for the new architecture and a Windows path-join bug with site-root-absolute (/astroscan_bot/...) asset references
1 parent bb29807 commit 11954b0

24 files changed

Lines changed: 3531 additions & 1737 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches: [main]
66
paths:
7-
- 'index.html'
7+
- 'template.html'
88
- 'build.py'
99

1010
permissions:
@@ -29,7 +29,7 @@ jobs:
2929
- name: Check for real content changes
3030
id: changes
3131
run: |
32-
if git diff --quiet -- ru/index.html es/index.html hi/index.html; then
32+
if git diff --quiet -- index.html ru/index.html es/index.html hi/index.html; then
3333
echo "changed=false" >> "$GITHUB_OUTPUT"
3434
else
3535
echo "changed=true" >> "$GITHUB_OUTPUT"
@@ -53,7 +53,7 @@ jobs:
5353
run: |
5454
git config user.name "github-actions[bot]"
5555
git config user.email "github-actions[bot]@users.noreply.github.com"
56-
git add ru/index.html es/index.html hi/index.html sitemap.xml
56+
git add index.html ru/index.html es/index.html hi/index.html sitemap.xml
5757
git diff --cached --quiet || git commit -m "chore: rebuild ru/es/hi from index.html [skip ci]"
5858
git push
5959

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
data/
2+
.env*
3+
node_modules/
4+
claude-telegram.yaml

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ We **do not store** birth data (name, date, time, location) after analysis. Data
6060

6161
## 📂 Project Files
6262

63-
- `index.html`Main SPA with the test (source of truth for all languages)
64-
- `ru/`, `es/`, `hi/` – Static per-language snapshots generated by `build.py` (for SEO/crawlers)
65-
- `build.py` – Regenerates the `ru/`, `es/`, `hi/` snapshots from `index.html`
63+
- `template.html`Multi-language source template (not deployed directly)
64+
- `index.html`, `ru/`, `es/`, `hi/` – Static, genuinely single-language pages generated by `build.py` from `template.html` (each page contains only its own language — no client-side language switching of body content, for clean indexing/AI-crawler reading)
65+
- `build.py` – Regenerates `index.html`, `ru/`, `es/`, `hi/` from `template.html`
6666
- `terms.html` – Terms of service (4 languages)
6767
- `privacy.html` – Privacy policy (4 languages)
6868
- `robots.txt` & `sitemap.xml` – Search engine configuration

assets/js/main.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,11 @@
339339
renderZodiacCalcResult();
340340
}
341341
function detectLang() {
342-
const urlLang = new URLSearchParams(location.search).get('lang');
343-
if (urlLang && LANGS.includes(urlLang)) return urlLang;
342+
// Each deployed page is genuinely single-language (baked by build.py from
343+
// template.html), so data-forced-lang is authoritative — content for any
344+
// other language doesn't exist in this page's DOM.
344345
const forced = document.documentElement.getAttribute('data-forced-lang');
345-
if (forced && LANGS.includes(forced)) return forced;
346-
const saved = localStorage.getItem('astroscan_lang');
347-
if (saved && LANGS.includes(saved)) return saved;
348-
const browser = navigator.language.split('-')[0];
349-
return LANGS.includes(browser) ? browser : 'en';
346+
return (forced && LANGS.includes(forced)) ? forced : 'en';
350347
}
351348
function buildLangBar() {
352349
const bar = document.getElementById('lang-bar');
@@ -357,7 +354,11 @@
357354
btn.textContent = LANG_NAMES[l];
358355
btn.dataset.lang = l;
359356
btn.setAttribute('aria-label', `Switch to ${LANG_NAMES[l]}`);
360-
btn.onclick = () => setLang(l);
357+
btn.onclick = () => {
358+
window.location.href = l === 'en'
359+
? 'https://astroscan-lab.github.io/astroscan_bot/'
360+
: `https://astroscan-lab.github.io/astroscan_bot/${l}/`;
361+
};
361362
bar.appendChild(btn);
362363
});
363364
setLang(detectLang());
@@ -381,9 +382,14 @@
381382
if (!input || !out) return;
382383
const year = parseInt(input.value, 10);
383384
if (!year || year < 1900 || year > 2026) { out.textContent = ''; return; }
384-
const t = translations[currentLang];
385385
const z = zodiacByYear(year);
386-
out.innerHTML = `${t.zcResultPrefix}<span style="color:var(--accent-primary)">${getZodiacName(z)}</span> (${t.zcElementLabel}: ${getElementName(z.element)})<br><span style="font-weight:400;color:var(--text-dim);font-size:0.9rem;">${t.zcCta}</span>`;
386+
out.textContent = '';
387+
userZodiac = z.id;
388+
userArchetype = zodiacToArchetype[userZodiac];
389+
window.currentQuizState = 'result';
390+
renderResult();
391+
showGlobalCTA();
392+
document.getElementById('appRoot').scrollIntoView({ behavior: 'smooth', block: 'start' });
387393
}
388394
function bindZodiacCalculator() {
389395
const btn = document.getElementById('zcCalcBtn');

build.py

Lines changed: 192 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import io, re
1+
import io, re, os
2+
import json as _json
3+
from html.parser import HTMLParser
24

35
BASE = "https://astroscan-lab.github.io/astroscan_bot"
6+
ALL_LANGS = ["en", "ru", "es", "hi"]
47

58
LANGS = {
69
"ru": {
@@ -53,20 +56,32 @@
5356
},
5457
}
5558

56-
import json as _json
59+
EN_HOWTO_STEPS = [
60+
"Answer 10 questions about your behavior.",
61+
"Receive your Chinese zodiac sign and archetype.",
62+
"Choose a partner's sign and context (love/friendship/work).",
63+
"See compatibility percentage and personalized insights.",
64+
]
65+
EN_PRODUCT_DESC = ("Free online test to discover your Chinese zodiac sign, behavioral archetype, "
66+
"and compatibility. No registration required. Compare Western and Vedic astrology. "
67+
"Based on Swiss Ephemeris calculations.")
68+
EN_HOWTO_NAME = "How to use AstroScan"
69+
5770

5871
def clean_faq_text(s):
5972
s = re.sub(r'<[^>]+>', '', s)
6073
s = s.replace('&amp;', '&').replace('&quot;', '"').replace('&#39;', "'")
6174
return s.strip()
6275

76+
6377
def extract_faq(html, lang):
6478
pattern = re.compile(
6579
r'<details data-lang="%s"[^>]*>\s*<summary[^>]*>(.*?)</summary>\s*<p[^>]*>(.*?)</p>\s*</details>' % lang,
6680
re.S
6781
)
6882
return [(clean_faq_text(q), clean_faq_text(a)) for q, a in pattern.findall(html)]
6983

84+
7085
def faq_mainentity_json(pairs):
7186
entries = []
7287
for q, a in pairs:
@@ -79,77 +94,190 @@ def faq_mainentity_json(pairs):
7994
)
8095
return '"mainEntity": [\n' + ',\n'.join(entries) + '\n ]'
8196

82-
EN_HOWTO_STEPS = [
83-
"Answer 10 questions about your behavior.",
84-
"Receive your Chinese zodiac sign and archetype.",
85-
"Choose a partner's sign and context (love/friendship/work).",
86-
"See compatibility percentage and personalized insights.",
87-
]
8897

89-
with io.open("index.html", "r", encoding="utf-8") as f:
90-
template = f.read()
98+
VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input",
99+
"link", "meta", "param", "source", "track", "wbr"}
100+
101+
102+
class LangFilter(HTMLParser):
103+
"""Strips every element carrying a data-lang attribute that doesn't match
104+
target_lang (and its whole subtree), so the output is genuinely single-language
105+
instead of all 4 languages hidden/shown via CSS+JS."""
106+
107+
def __init__(self, target_lang):
108+
super().__init__(convert_charrefs=False)
109+
self.target_lang = target_lang
110+
self.out = []
111+
self.skip_stack = []
112+
self.depth = 0
113+
114+
def _skipping(self):
115+
return len(self.skip_stack) > 0
116+
117+
def _render_starttag(self, tag, attrs, self_close):
118+
parts = [f"<{tag}"]
119+
for k, v in attrs:
120+
if k == "data-lang":
121+
continue
122+
parts.append(f" {k}" if v is None else f' {k}="{v}"')
123+
parts.append(" />" if self_close else ">")
124+
self.out.append("".join(parts))
125+
126+
def handle_starttag(self, tag, attrs):
127+
lang = dict(attrs).get("data-lang")
128+
if tag in VOID_TAGS:
129+
if self._skipping():
130+
return
131+
if lang is not None and lang != self.target_lang:
132+
return
133+
self._render_starttag(tag, attrs, self_close=False)
134+
return
135+
self.depth += 1
136+
if self._skipping():
137+
return
138+
if lang is not None and lang != self.target_lang:
139+
self.skip_stack.append(self.depth)
140+
return
141+
self._render_starttag(tag, attrs, self_close=False)
142+
143+
def handle_startendtag(self, tag, attrs):
144+
if self._skipping():
145+
return
146+
lang = dict(attrs).get("data-lang")
147+
if lang is not None and lang != self.target_lang:
148+
return
149+
self._render_starttag(tag, attrs, self_close=True)
150+
151+
def handle_endtag(self, tag):
152+
if self.skip_stack and self.skip_stack[-1] == self.depth:
153+
self.skip_stack.pop()
154+
self.depth -= 1
155+
return
156+
self.depth -= 1
157+
if self._skipping():
158+
return
159+
self.out.append(f"</{tag}>")
91160

92-
import os
161+
def handle_data(self, data):
162+
if not self._skipping():
163+
self.out.append(data)
93164

94-
# Keep index.html's own JSON-LD FAQPage in sync with its visible <details data-lang="en"> blocks,
95-
# since index.html is the source template and never gets "built" from anything else.
96-
_en_pairs = extract_faq(template, "en")
97-
_en_faq_block = faq_mainentity_json(_en_pairs)
98-
_resynced = re.sub(r'"mainEntity": \[.*?\n \]', lambda m: _en_faq_block, template, count=1, flags=re.S)
99-
if _resynced != template:
100-
template = _resynced
101-
with io.open("index.html", "w", encoding="utf-8", newline="\n") as f:
102-
f.write(template)
103-
print("Resynced index.html JSON-LD FAQ to", len(_en_pairs), "entries")
165+
def handle_entityref(self, name):
166+
if not self._skipping():
167+
self.out.append(f"&{name};")
104168

105-
for lang, t in LANGS.items():
169+
def handle_charref(self, name):
170+
if not self._skipping():
171+
self.out.append(f"&#{name};")
172+
173+
def handle_comment(self, data):
174+
if not self._skipping():
175+
self.out.append(f"<!--{data}-->")
176+
177+
def handle_decl(self, decl):
178+
if not self._skipping():
179+
self.out.append(f"<!{decl}>")
180+
181+
def filtered(self, html):
182+
self.feed(html)
183+
self.close()
184+
return "".join(self.out)
185+
186+
187+
with io.open("template.html", "r", encoding="utf-8") as f:
188+
template = f.read()
189+
190+
for lang in ALL_LANGS:
106191
html = template
192+
is_en = lang == "en"
193+
t = LANGS.get(lang)
107194

108195
html = html.replace('<html lang="en">', '<html lang="%s" data-forced-lang="%s">' % (lang, lang), 1)
109196

110-
html = re.sub(r'(<title>)[^<]*(</title>)', lambda m: m.group(1) + t["title"] + m.group(2), html, count=1)
111-
html = re.sub(r'(<meta name="description" content=")[^"]*(")', lambda m: m.group(1) + t["desc"] + m.group(2), html, count=1)
112-
html = re.sub(r'(<meta name="keywords" content=")[^"]*(")', lambda m: m.group(1) + t["keywords"] + m.group(2), html, count=1)
113-
html = re.sub(r'(<link rel="canonical" href=")[^"]*(")', lambda m: m.group(1) + BASE + "/" + lang + "/" + m.group(2), html, count=1)
114-
html = re.sub(r'(<meta property="og:title" content=")[^"]*(")', lambda m: m.group(1) + t["og_title"] + m.group(2), html, count=1)
115-
html = re.sub(r'(<meta property="og:description" content=")[^"]*(")', lambda m: m.group(1) + t["og_desc"] + m.group(2), html, count=1)
116-
html = re.sub(r'(<meta property="og:url" content=")[^"]*(")', lambda m: m.group(1) + BASE + "/" + lang + "/" + m.group(2), html, count=1)
117-
html = re.sub(r'(<meta property="og:locale" content=")[^"]*(")', lambda m: m.group(1) + t["locale"] + m.group(2), html, count=1)
118-
119-
# localize JSON-LD: Product description, FAQPage Q&A, HowTo name+steps
120-
html = html.replace(
121-
'"description": "Free online test to discover your Chinese zodiac sign, behavioral archetype, and compatibility. No registration required. Compare Western and Vedic astrology. Based on Swiss Ephemeris calculations.",',
122-
'"description": "%s",' % t["product_desc"],
123-
1
124-
)
197+
if not is_en:
198+
html = re.sub(r'(<title>)[^<]*(</title>)', lambda m: m.group(1) + t["title"] + m.group(2), html, count=1)
199+
html = re.sub(r'(<meta name="description" content=")[^"]*(")', lambda m: m.group(1) + t["desc"] + m.group(2), html, count=1)
200+
html = re.sub(r'(<meta name="keywords" content=")[^"]*(")', lambda m: m.group(1) + t["keywords"] + m.group(2), html, count=1)
201+
html = re.sub(r'(<link rel="canonical" href=")[^"]*(")', lambda m: m.group(1) + BASE + "/" + lang + "/" + m.group(2), html, count=1)
202+
html = re.sub(r'(<meta property="og:title" content=")[^"]*(")', lambda m: m.group(1) + t["og_title"] + m.group(2), html, count=1)
203+
html = re.sub(r'(<meta property="og:description" content=")[^"]*(")', lambda m: m.group(1) + t["og_desc"] + m.group(2), html, count=1)
204+
html = re.sub(r'(<meta property="og:url" content=")[^"]*(")', lambda m: m.group(1) + BASE + "/" + lang + "/" + m.group(2), html, count=1)
205+
html = re.sub(r'(<meta property="og:locale" content=")[^"]*(")', lambda m: m.group(1) + t["locale"] + m.group(2), html, count=1)
206+
207+
html = html.replace(
208+
'"description": "%s",' % EN_PRODUCT_DESC,
209+
'"description": "%s",' % t["product_desc"],
210+
1
211+
)
212+
html = html.replace('"name": "%s",' % EN_HOWTO_NAME, '"name": "%s",' % t["howto_name"], 1)
213+
for en_s, loc_s in zip(EN_HOWTO_STEPS, t["howto_steps"]):
214+
html = html.replace('"text": "%s" }' % en_s, '"text": "%s" }' % loc_s, 1)
215+
216+
html = html.replace('<body class="lang-en">', '<body class="lang-%s">' % lang, 1)
217+
218+
# relative asset paths need ../ prefix from a subdirectory
219+
html = html.replace('href="favicon.ico"', 'href="../favicon.ico"')
220+
html = html.replace('href="apple-touch-icon.png"', 'href="../apple-touch-icon.png"')
221+
html = html.replace('src="cover.jpg"', 'src="../cover.jpg"')
222+
html = html.replace('srcset="cover.webp"', 'srcset="../cover.webp"')
223+
# terms.html/privacy.html exist in every language folder now (no ../ prefix needed)
224+
html = html.replace('href="icon-192.png"', 'href="../icon-192.png"')
225+
html = html.replace('href="manifest.json"', 'href="../manifest.json"')
226+
html = html.replace('href="assets/css/styles.css"', 'href="../assets/css/styles.css"')
227+
html = html.replace('src="assets/js/main.js"', 'src="../assets/js/main.js"')
228+
229+
# FAQPage JSON-LD must reflect only this language's Q&A
125230
faq_pairs = extract_faq(template, lang)
126231
new_faq_block = faq_mainentity_json(faq_pairs)
127-
html = re.sub(
128-
r'"mainEntity": \[.*?\n \]',
129-
lambda m: new_faq_block,
130-
html, count=1, flags=re.S
131-
)
132-
html = html.replace('"name": "How to use AstroScan",', '"name": "%s",' % t["howto_name"], 1)
133-
for en_s, loc_s in zip(EN_HOWTO_STEPS, t["howto_steps"]):
134-
html = html.replace('"text": "%s" }' % en_s, '"text": "%s" }' % loc_s, 1)
135-
136-
html = html.replace('<body class="lang-en">', '<body class="lang-%s">' % lang, 1)
137-
138-
# relative asset paths need ../ prefix from a subdirectory
139-
html = html.replace('href="favicon.ico"', 'href="../favicon.ico"')
140-
html = html.replace('href="apple-touch-icon.png"', 'href="../apple-touch-icon.png"')
141-
html = html.replace('src="cover.jpg"', 'src="../cover.jpg"')
142-
html = html.replace('srcset="cover.webp"', 'srcset="../cover.webp"')
143-
html = html.replace('href="terms.html"', 'href="../terms.html"')
144-
html = html.replace('href="privacy.html"', 'href="../privacy.html"')
145-
html = html.replace('href="icon-192.png"', 'href="../icon-192.png"')
146-
html = html.replace('href="manifest.json"', 'href="../manifest.json"')
147-
html = html.replace('href="assets/css/styles.css"', 'href="../assets/css/styles.css"')
148-
html = html.replace('src="assets/js/main.js"', 'src="../assets/js/main.js"')
149-
150-
os.makedirs(lang, exist_ok=True)
151-
with io.open(os.path.join(lang, "index.html"), "w", encoding="utf-8", newline="\n") as f:
232+
html = re.sub(r'"mainEntity": \[.*?\n \]', lambda m: new_faq_block, html, count=1, flags=re.S)
233+
234+
# Strip every other language's markup so the deployed page is single-language
235+
html = LangFilter(lang).filtered(html)
236+
237+
out_path = "index.html" if is_en else os.path.join(lang, "index.html")
238+
if not is_en:
239+
os.makedirs(lang, exist_ok=True)
240+
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
152241
f.write(html)
153-
print("OK", lang + "/index.html", len(html), "bytes")
242+
print("OK", out_path, len(html), "bytes")
243+
244+
print("Done:", ", ".join(ALL_LANGS))
245+
246+
# --- Legal pages (privacy.html, terms.html): same single-language-per-page approach ---
247+
LANG_BAR_NAMES = {"en": "English", "ru": "Русский", "es": "Español", "hi": "हिन्दी"}
248+
249+
for page in ["privacy", "terms"]:
250+
with io.open("%s_template.html" % page, "r", encoding="utf-8") as f:
251+
legal_template = f.read()
252+
253+
lang_bar_re = re.compile(r'<div class="lang-bar">.*?</div>', re.S)
254+
script_re = re.compile(r'\s*<script>\s*function setPLang.*?</script>', re.S)
255+
256+
for lang in ALL_LANGS:
257+
html = legal_template
258+
is_en = lang == "en"
259+
canon = BASE + ("/%s.html" % page if is_en else "/%s/%s.html" % (lang, page))
260+
261+
html = html.replace('<html lang="en">', '<html lang="%s">' % lang, 1)
262+
html = re.sub(r'(<link rel="canonical" href=")[^"]*(")', lambda m: m.group(1) + canon + m.group(2), html, count=1)
263+
html = html.replace('<body class="lang-en">', '<body class="lang-%s">' % lang, 1)
264+
265+
lang_bar_links = "\n".join(
266+
' <a href="%s">%s</a>' % (
267+
BASE + ("/%s.html" % page if l == "en" else "/%s/%s.html" % (l, page)),
268+
LANG_BAR_NAMES[l]
269+
) for l in ALL_LANGS
270+
)
271+
html = lang_bar_re.sub('<div class="lang-bar">\n%s\n </div>' % lang_bar_links, html, count=1)
272+
html = script_re.sub('', html, count=1)
273+
274+
html = LangFilter(lang).filtered(html)
275+
276+
out_path = "%s.html" % page if is_en else os.path.join(lang, "%s.html" % page)
277+
if not is_en:
278+
os.makedirs(lang, exist_ok=True)
279+
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
280+
f.write(html)
281+
print("OK", out_path, len(html), "bytes")
154282

155-
print("Done:", ", ".join(LANGS.keys()))
283+
print("Done: privacy, terms")

0 commit comments

Comments
 (0)