feat(blog): Phase 5 SEO/GEO content with 5 new blog posts

Add 5 long-form Thai blog posts (1,200-2,500 words each) with SEO + GEO
optimization for the dealplustech water-systems site. Each post targets
a specific audience (contractors, engineers, project managers) and
follows a content-quality workflow: source real product specs, verify
Thai text, dedupe images, link back to product pages.

## New blog posts (src/content/blog/)
- thermobreak-guide.md (Thermobreak closed-cell insulation overview)
- plastic-grilles-guide.md (ABS plastic grilles for HVAC)
- ppr-pipe-guide.md (PPR pipe properties + heat-fusion welding)
- ppr-vs-hdpe-vs-upvc.md (3-way pipe comparison with PE80/PE100)
- thermobreak-series-guide.md (Thermobreak LS vs Solar series)
- 10-things-checklist-pipe-ordering.md (10-point pre-order checklist)

## Removed legacy posts
- pipe-knowledge.md, valve-guide.md, welcome-post.md (orphans)

## Hero images (public/images/blog/)
~20 product photos sourced from manufacturers (Thermobreak, Thai PPR,
thaiconsupply) plus Nano Banana Pro infographics. All resized to
3:2 aspect ratio per user preference. Source folder preserved for
re-derivation.

## Astro layout/SEO work
- src/components/seo/SEO.astro, JsonLd.astro (new SEO components)
- src/layouts/BaseLayout.astro, Layout.astro (OG/Twitter/JSON-LD wiring)
- src/pages/404.astro
- Product pages (8): added #pricelist anchors + schema work
- src/styles/global.css: scroll-padding for sticky-header anchors

## Automation scripts (scripts/)
- build_og_image.py (OG image builder)
- inject_faq_schema.py, inject_product_schema.py (JSON-LD injection)

## Misc
- public/robots.txt, public/images/og/default-og.jpg
- .gitignore: exclude scripts/__pycache__/
This commit is contained in:
hermes
2026-06-08 12:45:32 +07:00
parent 7c905bdb00
commit b34f8fc2fb
81 changed files with 4031 additions and 282 deletions

40
scripts/build_og_image.py Normal file
View File

@@ -0,0 +1,40 @@
"""Generate a clean OG image (1200x630, white background, centered logo)."""
from PIL import Image
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOGO = os.path.join(ROOT, "public/images/logo/dealplustech-logo.png")
OUT = os.path.join(ROOT, "public/images/og/default-og.jpg")
CANVAS_W, CANVAS_H = 1200, 630
LOGO_MAX_W = int(CANVAS_W * 0.70) # 70% of canvas width
LOGO_MAX_H = int(CANVAS_H * 0.70) # 70% of canvas height
# 1. White canvas
canvas = Image.new("RGB", (CANVAS_W, CANVAS_H), (255, 255, 255))
# 2. Open logo (RGBA)
logo = Image.open(LOGO).convert("RGBA")
lw, lh = logo.size
print(f"logo original: {lw}x{lh}")
# 3. Scale logo to fit within 70% box, preserve aspect ratio
scale = min(LOGO_MAX_W / lw, LOGO_MAX_H / lh)
new_w = int(lw * scale)
new_h = int(lh * scale)
logo = logo.resize((new_w, new_h), Image.LANCZOS)
print(f"logo scaled: {new_w}x{new_h}")
# 4. Center
x0 = (CANVAS_W - new_w) // 2
y0 = (CANVAS_H - new_h) // 2
# 5. Paste (uses alpha as mask)
canvas.paste(logo, (x0, y0), logo)
# 6. Save as JPEG quality 90
os.makedirs(os.path.dirname(OUT), exist_ok=True)
canvas.save(OUT, "JPEG", quality=90, optimize=True, progressive=True)
size = os.path.getsize(OUT)
print(f"wrote: {OUT} ({size:,} bytes)")

View File

@@ -0,0 +1,152 @@
"""
Extract FAQ Q&A pairs from product pages and inject as `faq={[...]}` prop.
Pattern detected:
<h3 ...>Q: ...question...</h3>
<p ...>...answer...</p>
Only operates on pages that have a FAQ section (search for 'คำถามที่พบบ่อย').
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PAGES = ROOT / "src" / "pages"
# Product pages with FAQ UI (from earlier audit: lines >= 240 and grep -c FAQ > 0)
TARGETS = [
"pipe-coupling.astro",
"ท่อ-syler.astro",
"หัวจ่าย-ball-jet.astro",
"เม็กกรู๊ฟ-คับปลิ้ง.astro",
"เครื่องเชื่อม-hdpe.astro",
"เครื่องเชื่อม-ppr.astro",
"เทอร์โมเบรค-thermobreak.astro",
"realflex.astro",
"water-treatment.astro",
"วาล์ว-valve.astro",
"รั้วเทวดา.astro",
"ระบบรั้วไวน์แมน.astro",
"durgo-avvs.astro",
"ตู้ดับเพลิง.astro",
"water-pump.astro",
"grilles.astro",
"ท่อ-upvc.astro",
"armflex.astro",
"aeroflex.astro",
"maxflex.astro",
]
def extract_faq(content: str) -> list[tuple[str, str]]:
"""Return list of (question, answer) tuples from FAQ section.
Scopes regex to the FAQ section only: starts at "คำถามที่พบบ่อย" and
ends at the next "<!-- Contact CTA" or "Contact CTA" marker. This
prevents pattern D from catching feature lists elsewhere on the page.
Handles 4 patterns:
A. <h3>Q: ...</h3><p>...</p>
B. <h3>1. ...</h3><p>...</p> (number prefix)
C. <details><summary>...</summary><div>...</div></details>
D. <h3>question</h3><p>answer</p> (no prefix — grilles style)
"""
if 'คำถามที่พบบ่อย' not in content:
return []
# Slice content to the FAQ block: from "คำถามที่พบบ่อย" up to the next
# Contact CTA / section end. Fall back to end-of-file if no terminator.
start = content.index('คำถามที่พบบ่อย')
end = len(content)
for marker in ('<!-- Contact CTA', 'Contact CTA', '<!-- End FAQ', '</section>\n <!--'):
idx = content.find(marker, start)
if idx != -1 and idx < end:
end = idx
block = content[start:end]
pairs: list[tuple[str, str]] = []
# Pattern A + B: <h2-h4 ...>prefix...</h2-h4><p>answer</p>
p_ab = re.compile(
r'<h[234]\b[^>]*>\s*(?:Q:|\d+\.\s*)(?P<q>[^<]+?)\s*</h[234]>\s*<p\b[^>]*>(?P<a>.*?)</p>',
re.DOTALL,
)
for m in p_ab.finditer(block):
pairs.append((m.group('q').strip(), m.group('a').strip()))
if not pairs:
# Pattern D: <h2-h4>question</h2-h4><p>answer</p> (no prefix)
p_d = re.compile(
r'<h[234]\b[^>]*>\s*(?P<q>[^<]+?)\s*</h[234]>\s*<p\b[^>]*>(?P<a>.*?)</p>',
re.DOTALL,
)
for m in p_d.finditer(block):
pairs.append((m.group('q').strip(), m.group('a').strip()))
if not pairs:
# Pattern C: <details><summary>...</summary><div>...</div></details>
p_c = re.compile(
r'<details\b[^>]*>\s*<summary\b[^>]*>(?P<sum>.*?)</summary>'
r'.*?<div\b[^>]*>(?P<a>.*?)</div>\s*</details>',
re.DOTALL,
)
for m in p_c.finditer(block):
sum_html = m.group('sum')
sp = re.search(r'<span\b[^>]*>(?P<q>.*?)</span>', sum_html, re.DOTALL)
q = sp.group('q').strip() if sp else re.sub(r'<[^>]+>', '', sum_html).strip()
pairs.append((q, m.group('a').strip()))
return pairs
def js_string(value: str) -> str:
"""Single-quoted JS literal that's safe to embed in Astro JSX."""
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def build_faq_prop(pairs: list[tuple[str, str]]) -> str:
lines = ['\n faq={[']
for q, a in pairs:
lines.append(f' {{ question: {js_string(q)}, answer: {js_string(a)} }},')
lines.append(' ]}')
return '\n'.join(lines)
def inject_prop(content: str, prop_block: str) -> str:
"""Insert prop_block before the closing > of the first <BaseLayout> tag."""
m = re.search(r'<BaseLayout\b[^>]*>', content)
if not m:
return content
insert_at = m.end() - 1
return content[:insert_at] + prop_block + content[insert_at:]
def process_file(path: Path) -> str:
content = path.read_text(encoding='utf-8')
# Idempotent: if faq prop already exists, skip.
if 'faq={[' in content:
return f"SKIP (already has faq prop): {path.name}"
pairs = extract_faq(content)
if not pairs:
return f"SKIP (no FAQ): {path.name}"
prop_block = build_faq_prop(pairs)
new_content = inject_prop(content, prop_block)
if new_content == content:
return f"NO-CHANGE: {path.name}"
path.write_text(new_content, encoding='utf-8')
return f"OK ({len(pairs)} pairs): {path.name}"
def main() -> None:
for name in TARGETS:
path = PAGES / name
if not path.exists():
print(f"MISSING: {name}")
continue
print(process_file(path))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
"""
Inject `product={...}` prop into <BaseLayout> calls in product pages.
For pages that ALREADY have a Product JSON-LD block, this script also
removes that block (since the layout's `product` prop replaces it).
Run from project root: python3 scripts/inject_product_schema.py
"""
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PAGES = ROOT / "src" / "pages"
# Map page slug -> brand name for product schema
# Anything not listed falls back to "ดีล พลัส เทค"
BRAND_MAP = {
"ท่อ-ppr-thai-ppr": "Thai PPR",
"ท่อ-ppr-scg": "SCG",
"ท่อ-hdpe": "HDPE",
"ท่อ-upvc": "UPVC",
"ท่อ-syler": "Syler",
"ท่อ-xy-lent": "XYLENT",
"เครื่องเชื่อม-hdpe": "HDPE",
"เครื่องเชื่อม-ppr": "PPR",
"pipe-coupling": "SMC",
"เม็กกรู๊ฟ-คับปลิ้ง": "MECH",
"วาล์ว-valve": "Generic",
"water-pump": "Generic",
"water-treatment": "Generic",
"realflex": "Realflex",
"armflex": "Armacell",
"aeroflex": "Aerocell",
"maxflex": "Maxflex",
"เทอร์โมเบรค-thermobreak": "Thermobreak",
"หัวจ่าย-ball-jet": "SAPA",
"grilles": "Generic",
"durgo-avvs": "DURGO",
"ตู้ดับเพลิง": "Generic",
"รั้วเทวดา": "Tevada",
"ระบบรั้วไวน์แมน": "Vineman",
}
# Pages that are NOT products (skip them)
SKIP_PAGES = {
"index.astro", "all-products.astro", "about-us.astro", "contact-us.astro",
"portfolio.astro", "privacy-policy.astro", "terms-and-conditions.astro",
"ระบบน้ำ.astro", # category overview, not a product
}
IMG_RE = re.compile(r'<img[^>]*\bsrc="(/images/[^"]+)"')
def escape_for_jsx(value: str) -> str:
"""Escape a Python string for safe use inside a single-quoted JSX expression.
Handles backslashes, single quotes, and newlines.
"""
out = value.replace("\\", "\\\\")
out = out.replace("'", "\\'")
out = out.replace("\n", " ").replace("\r", " ")
return out
def jsx_string(value: str) -> str:
return "'" + escape_for_jsx(value) + "'"
def first_image_src(content: str) -> str | None:
"""Return the first <img src="/images/..."> path, skipping data URIs."""
m = IMG_RE.search(content)
return m.group(1) if m else None
def remove_existing_product_jsonld(content: str) -> str:
"""Strip the standalone Product JSON-LD <script> block (kept the rest)."""
# Match <script type="application/ld+json"> with @type: Product ... </script>
pattern = re.compile(
r'<script[^>]*type="application/ld\+json"[^>]*>\s*'
r'\{[^{}]*"@type"\s*:\s*"Product"[\s\S]*?'
r'\}\s*</script>\s*',
re.MULTILINE,
)
return pattern.sub('', content)
def extract_title_and_desc(content: str) -> tuple[str | None, str | None]:
"""Parse <BaseLayout title="..." description="..."> attrs (single or double quoted)."""
# Title may contain " - ดีล พลัส เทค" suffix
title_m = re.search(r'title=(?:"([^"]+)"|\'([^\']+)\')', content)
desc_m = re.search(r'description=(?:"([^"]+)"|\'([^\']+)\')', content)
title = (title_m.group(1) or title_m.group(2)) if title_m else None
desc = (desc_m.group(1) or desc_m.group(2)) if desc_m else None
return title, desc
def inject_product_prop(content: str, image: str, title: str, slug: str) -> str:
"""Add product={...} prop to the FIRST <BaseLayout> tag."""
brand = BRAND_MAP.get(slug, "ดีล พลัส เทค")
prop_block = (
f'\n product={{{{\n'
f' name: {jsx_string(title)},\n'
f' image: {jsx_string(image)},\n'
f' brand: {jsx_string(brand)},\n'
f' }}}}'
)
# Find first <BaseLayout ... > — we insert product={...} before the closing >
# Use a non-greedy match up to the first > that is NOT inside a quoted attr.
m = re.search(r'<BaseLayout\b[^>]*>', content)
if not m:
return content # No <BaseLayout> tag — leave file untouched.
insert_at = m.end() - 1 # position of the closing >
return content[:insert_at] + prop_block + content[insert_at:]
def process_file(path: Path) -> str:
content = path.read_text(encoding='utf-8')
original = content
slug = path.stem # e.g. "ท่อ-hdpe"
title, _ = extract_title_and_desc(content)
if not title:
return f"SKIP (no title): {path.name}"
image = first_image_src(content)
if not image:
return f"SKIP (no image): {path.name}"
has_existing_jsonld = '"Product"' in content and '<script type="application/ld+json"' in content
content = inject_product_prop(content, image, title, slug)
if has_existing_jsonld:
content = remove_existing_product_jsonld(content)
if content == original:
return f"NO-CHANGE: {path.name}"
path.write_text(content, encoding='utf-8')
action = "migrated" if has_existing_jsonld else "injected"
return f"OK ({action}): {path.name}"
def main() -> None:
targets = sorted([
p for p in PAGES.glob("*.astro")
if p.name not in SKIP_PAGES
])
for path in targets:
print(process_file(path))
if __name__ == "__main__":
main()