feat(share): 링크 공유 미리보기 이미지와 파비콘

카카오톡·슬랙·iMessage 에 사이트 주소를 붙이면 회색 링크 아이콘만 나왔다.
webforai.kr 은 og:image 와 파비콘이 없었고, 서포터즈 사이트는 robots.txt 가
미리보기 봇까지 막았으며 파비콘이 Astro 기본 로고였다.

- scripts/make_share_assets.py: 1200x630 공유 이미지(JPEG)와 파비콘 세트 생성.
  서포터즈는 해외 환자용 영어 판(병원 공식 영문 표기 shareNameEn), 파비콘은 영문 첫 글자.
- 워커 build 단계에서 자동 생성. briefs/<clinic>/clinic.json 에 shareNameEn 과 출처.
- Base.astro: og:image 기본값 /og.jpg, 크기·twitter:card·아이콘 링크.
- robots.txt(샘플): 미리보기 봇만 허용. 검색·AI 크롤러 차단과 noindex 는 유지.
- vite.config.ts: discovery 빌드에만 og:image·아이콘 태그. public/ 은 infinith-demo 와
  공용이라 파일명에 discovery- 접두어.
- Astro 기본 favicon.svg 삭제는 직전 커밋(f2fe99b)에 섞여 들어갔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-15 09:36:45 +09:00
parent f2fe99b81d
commit f6aae1c396
16 changed files with 272 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/og-discovery.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""링크 공유 미리보기 이미지(og:image 1200x630 JPEG)와 파비콘 세트를 만든다.
카카오톡·슬랙·iMessage 주소를 붙였을 회색 링크 아이콘 대신 이미지와 아이콘이 나오게 한다.
# AI Discovery 랜딩(webforai.kr). 결과: public/og-discovery.jpg, public/discovery-favicon-*.png 외
# public/ 은 infinith-demo 배포와 공용이라 파일명에 접두어를 붙이고, 링크 태그는 VITE_SITE=discovery 빌드에만 넣는다.
python3 scripts/make_share_assets.py discovery --out public
# 서포터즈 사이트. 워커 build 단계가 부른다. 결과: <site>/public/og.jpg, favicon.ico 외
python3 scripts/make_share_assets.py supporters --site ~/supporters-builds/oracle/site
서포터즈 입력: src/data/site.json(shareNameEn·buildingImage·heroImage), src/data/factSheet.json(shortName),
vercel.json build.env.SITE_URL(하단 주소 표기). 사진은 heroImage, 없으면 buildingImage. 사진이 없으면 사진 없이 글자 판만 만든다.
서포터즈 이미지는 해외 환자용이라 영어로 낸다. 파비콘은 병원 로고 대신 영문 상호 글자를 쓴다. 가로형 로고는 16px 에서 읽히지 않는다.
"""
import argparse
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
W, H = 1200, 630
INK = (10, 17, 40) # #0A1128
INDIGO = (29, 0, 36) # #1D0024
VIOLET = (79, 29, 161) # #4F1DA1
NAVY = (2, 19, 65) # #021341
LIGHT = (247, 248, 252) # #F7F8FC
SLATE = (71, 85, 105) # #475569
LAVENDER = (233, 213, 255) # #E9D5FF, 어두운 배경 전용
FONT_DIRS = [Path.home() / "Library/Fonts", Path("/Library/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")]
def font(weight: str, size: int) -> ImageFont.FreeTypeFont:
for d in FONT_DIRS:
for name in (f"Pretendard-{weight}.otf", f"Pretendard-{weight}.ttf"):
for p in [d / name, *d.rglob(name)] if d.exists() else []:
if p.exists():
return ImageFont.truetype(str(p), size)
for fallback in ("/System/Library/Fonts/AppleSDGothicNeo.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc"):
if Path(fallback).exists():
return ImageFont.truetype(fallback, size)
sys.exit("한글 글꼴을 찾지 못했습니다. Pretendard 를 설치하세요.")
def gradient(size, start, end, diagonal=True):
w, h = size
base = Image.new("RGB", size, start)
top = Image.new("RGB", size, end)
mask = Image.new("L", size)
md = mask.load()
for y in range(h):
for x in range(0, w, 1 if w <= 64 else 2):
t = ((x / max(w - 1, 1)) + (y / max(h - 1, 1))) / 2 if diagonal else y / max(h - 1, 1)
v = int(255 * t)
md[x, y] = v
if w > 64 and x + 1 < w:
md[x + 1, y] = v
return Image.composite(top, base, mask)
def wrap(draw, text, fnt, max_w):
"""한국어 어절 단위 줄바꿈(keep-all). 줄바꿈 문자는 그대로 존중한다."""
lines = []
for para in text.split("\n"):
cur = ""
for word in para.split(" "):
trial = f"{cur} {word}".strip()
if draw.textlength(trial, font=fnt) <= max_w or not cur:
cur = trial
else:
lines.append(cur)
cur = word
lines.append(cur)
return lines
def save_favicons(out: Path, label: str, prefix: str = ""):
"""상호 첫 글자(또는 AI) 모노그램. 둥근 정사각 그라디언트 위 흰 글자."""
out.mkdir(parents=True, exist_ok=True)
def icon(px, radius_ratio=0.22, full_bleed=False):
s = px * 4
bg = gradient((s, s), VIOLET, NAVY)
mask = Image.new("L", (s, s), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, s - 1, s - 1], radius=0 if full_bleed else int(s * radius_ratio), fill=255)
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
img.paste(bg, (0, 0), mask)
d = ImageDraw.Draw(img)
size = int(s * (0.62 if len(label) == 1 else 0.46))
f = font("Bold", size)
box = d.textbbox((0, 0), label, font=f)
tw, th = box[2] - box[0], box[3] - box[1]
d.text(((s - tw) / 2 - box[0], (s - th) / 2 - box[1]), label, font=f, fill="white")
return img.resize((px, px), Image.LANCZOS)
written = []
for name, px, bleed in [("favicon-32.png", 32, False), ("favicon-192.png", 192, False), ("favicon-512.png", 512, False), ("apple-touch-icon.png", 180, True)]:
icon(px, full_bleed=bleed).save(out / f"{prefix}{name}")
written.append(f"{prefix}{name}")
icon(256).save(out / f"{prefix}favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
written.append(f"{prefix}favicon.ico")
return written
def discovery(out: Path):
img = Image.new("RGB", (W, H), LIGHT)
d = ImageDraw.Draw(img)
# 왼쪽 세로 띠
img.paste(gradient((16, H), VIOLET, NAVY, diagonal=False), (0, 0))
x = 96
d.text((x, 92), "INFINITH AI Discovery", font=font("SemiBold", 30), fill=VIOLET)
title = font("ExtraBold", 84)
d.text((x, 170), "AI가 추천하는 병원은", font=title, fill=INDIGO)
d.text((x, 272), "따로 있습니다.", font=title, fill=VIOLET)
sub = font("Medium", 34)
d.text((x, 410), "고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.", font=sub, fill=SLATE)
d.text((x, 458), "답변에 인용될 근거를 실측해 진단하고, 8주 안에 구축합니다.", font=sub, fill=SLATE)
d.line([(x, 540), (W - 96, 540)], fill=(226, 232, 240), width=2)
d.text((x, 556), "webforai.kr", font=font("SemiBold", 28), fill=INDIGO)
img.save(out / "og-discovery.jpg", quality=88, optimize=True, progressive=True)
return ["og-discovery.jpg", *save_favicons(out, "AI", prefix="discovery-")]
def playfair(size: int) -> ImageFont.FreeTypeFont:
for d in FONT_DIRS:
p = d / "PlayfairDisplay-Bold.ttf"
if p.exists():
return ImageFont.truetype(str(p), size)
return font("ExtraBold", size)
def supporters(site: Path):
"""해외 환자가 받는 링크라 영어로 낸다. 병원 영문 표기는 site.json shareNameEn(병원 공식 영문 표기, briefs 에서 전달)만 쓴다.
값이 없으면 한국어 상호를 그대로 쓴다. 영문 이름을 지어내지 않는다."""
data = site / "src/data"
s = json.loads((data / "site.json").read_text())
fact = json.loads((data / "factSheet.json").read_text())
short_ko = (fact.get("shortName") or "").strip() or (s.get("siteName") or "").replace(" 서포터즈", "").strip()
name = (s.get("shareNameEn") or "").strip() or short_ko
if not name:
sys.exit("site.json shareNameEn 과 factSheet.json shortName 이 모두 비어 있습니다.")
url = ""
vj = site / "vercel.json"
if vj.exists():
url = json.loads(vj.read_text()).get("build", {}).get("env", {}).get("SITE_URL", "")
host = url.replace("https://", "").replace("http://", "").rstrip("/")
photo = None
for key in ("heroImage", "buildingImage"):
src = (s.get(key) or {}).get("src") if isinstance(s.get(key), dict) else None
if src and (site / "public" / src.lstrip("/")).exists():
photo = site / "public" / src.lstrip("/")
break
img = Image.new("RGB", (W, H), INK)
panel_w = 700 if photo else W
img.paste(gradient((panel_w, H), VIOLET, NAVY), (0, 0))
if photo:
ph = ImageOps.fit(Image.open(photo).convert("RGB"), (W - panel_w, H), Image.LANCZOS, centering=(0.5, 0.4))
img.paste(ph, (panel_w, 0))
d = ImageDraw.Draw(img)
x, max_w = 72, panel_w - 72 * 2
d.text((x, 78), "SUPPORTERS · FOR INTERNATIONAL PATIENTS", font=font("SemiBold", 24), fill=LAVENDER)
name_f = playfair(72)
while max(d.textlength(w, font=name_f) for w in name.split(" ")) > max_w and name_f.size > 44:
name_f = playfair(name_f.size - 4)
y = 150
for line in wrap(d, name, name_f, max_w):
d.text((x, y), line, font=name_f, fill="white")
y += int(name_f.size * 1.18)
y += 30
title_f = font("SemiBold", 42)
for line in wrap(d, "Answers to the questions\npatients ask first.", title_f, max_w):
d.text((x, y), line, font=title_f, fill="white")
y += int(title_f.size * 1.32)
d.text((x, H - 96), "Recovery planner · Getting here · Clinic facts", font=font("Medium", 26), fill=LAVENDER)
if host:
d.text((x, H - 58), host, font=font("SemiBold", 24), fill="white")
pub = site / "public"
# JPEG 로 낸다. WhatsApp 등 일부 미리보기는 300KB 를 넘는 이미지를 버린다.
img.save(pub / "og.jpg", quality=86, optimize=True, progressive=True)
if (pub / "og.png").exists():
(pub / "og.png").unlink()
# 템플릿 기본(Astro) 파비콘 제거. 로고 기반 favicon.svg 가 있어도 16px 에서 읽히지 않아 모노그램으로 통일한다.
for old in ("favicon.svg",):
if (pub / old).exists():
(pub / old).unlink()
return ["og.jpg", *save_favicons(pub, name[0].upper())]
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="mode", required=True)
a = sub.add_parser("discovery"); a.add_argument("--out", default="public")
b = sub.add_parser("supporters"); b.add_argument("--site", required=True)
args = ap.parse_args()
if args.mode == "discovery":
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
files = discovery(out)
else:
files = supporters(Path(args.site).expanduser())
print("공유 이미지·파비콘: " + ", ".join(files))
if __name__ == "__main__":
main()

View File

@ -38,7 +38,8 @@
"alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판", "alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판",
"caption": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지" "caption": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지"
}, },
"naverSiteVerification": "f083f28e6b117805e9722f0a1adf7834a8219688" "naverSiteVerification": "f083f28e6b117805e9722f0a1adf7834a8219688",
"shareNameEn": "Oracle Dermatology"
}, },
"excludeImages": [ "excludeImages": [
"sub_0" "sub_0"
@ -46,5 +47,6 @@
"_logoNote": "2026-09-10 haewon 지적(녹색 배너형 GIF는 완성도가 떨어짐) → 그룹 사이트 oraclemedicalgroup.com/images/logo_oracle_02.png (176×68, 투명 배경 워드마크)로 교체. 클리닉 사이트에는 CI 페이지가 없음(메뉴 CI소개가 빈 링크). 이전 배너형은 logo-horizontal.png 로 보존. haewon 제안(2026-09-10)대로 원본 배너에서 왼쪽 문구 영역을 잘라낸 logo-horizontal-cropped.png(133×49, 녹색 배경 유지)도 보관. 녹색 박스형을 쓰려면 logo 값을 이 파일로 바꾸고 --from youtube 재실행.", "_logoNote": "2026-09-10 haewon 지적(녹색 배너형 GIF는 완성도가 떨어짐) → 그룹 사이트 oraclemedicalgroup.com/images/logo_oracle_02.png (176×68, 투명 배경 워드마크)로 교체. 클리닉 사이트에는 CI 페이지가 없음(메뉴 CI소개가 빈 링크). 이전 배너형은 logo-horizontal.png 로 보존. haewon 제안(2026-09-10)대로 원본 배너에서 왼쪽 문구 영역을 잘라낸 logo-horizontal-cropped.png(133×49, 녹색 배경 유지)도 보관. 녹색 박스형을 쓰려면 logo 값을 이 파일로 바꾸고 --from youtube 재실행.",
"_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외.", "_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외.",
"_factSheetNote": "상호 고정. 홈 제목 추정은 슬로건을 상호로 삼을 수 있다. 근거: supporters-oracle 기존 배포본 표기.", "_factSheetNote": "상호 고정. 홈 제목 추정은 슬로건을 상호로 삼을 수 있다. 근거: supporters-oracle 기존 배포본 표기.",
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, oracle.webforai.kr 용). 사이트마다 값이 달라 다른 병원에 옮겨 쓰면 확인되지 않는다." "_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, oracle.webforai.kr 용). 사이트마다 값이 달라 다른 병원에 옮겨 쓰면 확인되지 않는다.",
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 공식 영문 사이트 en.cdoracleclinic.com/about 'Introduction to Oracle Dermatology' (2026-09-15 확인)"
} }

View File

@ -24,8 +24,10 @@
"width": 886, "width": 886,
"height": 885 "height": 885
}, },
"naverSiteVerification": "e47011473eef74d7b2c4f20e93f97a889cb699cc" "naverSiteVerification": "e47011473eef74d7b2c4f20e93f97a889cb699cc",
"shareNameEn": "VIEW Plastic Surgery"
}, },
"_siteNote": "홈 히어로가 가슴 라인 일러스트로 잡혀 있었고 건물 사진의 alt 도 '가슴확대 시설 사진'으로 잘못 붙어 있었다. 건물 외관으로 고정한다.", "_siteNote": "홈 히어로가 가슴 라인 일러스트로 잡혀 있었고 건물 사진의 alt 도 '가슴확대 시설 사진'으로 잘못 붙어 있었다. 건물 외관으로 고정한다.",
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, viewpl.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다. 구글은 webforai.kr 도메인 속성을 DNS TXT 로 확인해 서브도메인 전부가 함께 확인된다." "_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, viewpl.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다. 구글은 webforai.kr 도메인 속성을 DNS TXT 로 확인해 서브도메인 전부가 함께 확인된다.",
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 병원 홈페이지 로고·본문 표기 VIEW PLASTIC SURGERY (근거 수집 OCR)"
} }

View File

@ -16,9 +16,11 @@
"width": 1600, "width": 1600,
"height": 633 "height": 633
}, },
"naverSiteVerification": "5df40b24e88fd3fdf7ece9dd0fb69cf83439ad44" "naverSiteVerification": "5df40b24e88fd3fdf7ece9dd0fb69cf83439ad44",
"shareNameEn": "WONJIN Plastic Surgery"
}, },
"_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.", "_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.",
"_imageReviewNote": "2026-09-12 haewon 육안 검토: 원진 수집 이미지 66장 전부 사용 가능. 제외할 것 없음.", "_imageReviewNote": "2026-09-12 haewon 육안 검토: 원진 수집 이미지 66장 전부 사용 가능. 제외할 것 없음.",
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, wonjin.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다." "_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, wonjin.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다.",
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 병원 로고 표기 WJ WONJIN PLASTIC SURGERY (근거 수집 OCR)"
} }

View File

@ -1,5 +1,6 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { existsSync } from 'node:fs';
import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib'; import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib';
import { UI, switchHref } from '../lib/i18n'; import { UI, switchHref } from '../lib/i18n';
import EN from '../data/factSheetEn.json'; import EN from '../data/factSheetEn.json';
@ -16,7 +17,9 @@ interface Props {
lang?: 'ko' | 'en'; lang?: 'ko' | 'en';
alternates?: { lang: string; href: string }[]; alternates?: { lang: string; href: string }[];
} }
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko', alternates = [] } = Astro.props; // 공유 미리보기 기본 이미지: 워커 build 단계가 만드는 public/og.jpg(1200x630, scripts/make_share_assets.py). 없으면 건물 사진.
const shareCard = existsSync('public/og.jpg');
const { title, description, jsonLd, ogImage = shareCard ? '/og.jpg' : (S.buildingImage?.src || '/favicon-512.png'), type = 'website', noindex = false, lang = 'ko', alternates = [] } = Astro.props;
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
@ -54,6 +57,13 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL(ogImage, Astro.site).toString()} /> <meta property="og:image" content={new URL(ogImage, Astro.site).toString()} />
{ogImage === '/og.jpg' && <meta property="og:image:width" content="1200" />}
{ogImage === '/og.jpg' && <meta property="og:image:height" content="630" />}
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:locale" content={lang === 'en' ? 'en_US' : 'ko_KR'} /> <meta property="og:locale" content={lang === 'en' ? 'en_US' : 'ko_KR'} />
{alternates.map((a) => <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} />)} {alternates.map((a) => <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} />)}
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />

View File

@ -1,10 +1,13 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
// 샘플: 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용. // 샘플: 검색·AI 크롤러 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용.
// 링크 미리보기 봇은 샘플에서도 허용한다. 막으면 카카오톡·슬랙·iMessage 에 주소를 붙였을 때 제목·이미지가 안 나온다.
// 이 봇들은 붙여 넣은 주소 한 장만 읽고 색인하지 않는다. noindex 메타·X-Robots-Tag 는 그대로다.
const PREVIEW_BOTS = ['kakaotalk-scrap', 'facebookexternalhit', 'Facebot', 'Twitterbot', 'Slackbot', 'Slackbot-LinkExpanding', 'Discordbot', 'TelegramBot', 'WhatsApp', 'LinkedInBot'];
const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot']; const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot'];
export const GET: APIRoute = ({ site }) => { export const GET: APIRoute = ({ site }) => {
const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true'; const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true';
const body = indexable const body = indexable
? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n' ? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n'
: `# 샘플 사이트. 색인 금지.\nUser-agent: *\nDisallow: /\n`; : [`# 샘플 사이트. 색인 금지. 링크 미리보기만 허용.`, ...PREVIEW_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nDisallow: /'].join('\n\n') + '\n';
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}; };

View File

@ -1,5 +1,6 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { existsSync } from 'node:fs';
import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib'; import { SITE_NAME, SITE_TAGLINE, INDEXABLE, fact, site as S, surface, reservationUrl, editor, has, v } from '../lib';
import { UI, switchHref } from '../lib/i18n'; import { UI, switchHref } from '../lib/i18n';
import EN from '../data/factSheetEn.json'; import EN from '../data/factSheetEn.json';
@ -16,7 +17,9 @@ interface Props {
lang?: 'ko' | 'en'; lang?: 'ko' | 'en';
alternates?: { lang: string; href: string }[]; alternates?: { lang: string; href: string }[];
} }
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko', alternates = [] } = Astro.props; // 공유 미리보기 기본 이미지: 워커 build 단계가 만드는 public/og.jpg(1200x630, scripts/make_share_assets.py). 없으면 건물 사진.
const shareCard = existsSync('public/og.jpg');
const { title, description, jsonLd, ogImage = shareCard ? '/og.jpg' : (S.buildingImage?.src || '/favicon-512.png'), type = 'website', noindex = false, lang = 'ko', alternates = [] } = Astro.props;
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
@ -54,6 +57,13 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL(ogImage, Astro.site).toString()} /> <meta property="og:image" content={new URL(ogImage, Astro.site).toString()} />
{ogImage === '/og.jpg' && <meta property="og:image:width" content="1200" />}
{ogImage === '/og.jpg' && <meta property="og:image:height" content="630" />}
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:locale" content={lang === 'en' ? 'en_US' : 'ko_KR'} /> <meta property="og:locale" content={lang === 'en' ? 'en_US' : 'ko_KR'} />
{alternates.map((a) => <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} />)} {alternates.map((a) => <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} />)}
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />

View File

@ -1,10 +1,13 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
// 샘플: 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용. // 샘플: 검색·AI 크롤러 전면 차단. 프로덕션(PUBLIC_INDEXABLE=true): 전면 허용 + AI 크롤러 명시 허용.
// 링크 미리보기 봇은 샘플에서도 허용한다. 막으면 카카오톡·슬랙·iMessage 에 주소를 붙였을 때 제목·이미지가 안 나온다.
// 이 봇들은 붙여 넣은 주소 한 장만 읽고 색인하지 않는다. noindex 메타·X-Robots-Tag 는 그대로다.
const PREVIEW_BOTS = ['kakaotalk-scrap', 'facebookexternalhit', 'Facebot', 'Twitterbot', 'Slackbot', 'Slackbot-LinkExpanding', 'Discordbot', 'TelegramBot', 'WhatsApp', 'LinkedInBot'];
const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot']; const AI_BOTS = ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Claude-SearchBot', 'Google-Extended', 'Applebot-Extended', 'Bingbot', 'Yeti', 'Googlebot'];
export const GET: APIRoute = ({ site }) => { export const GET: APIRoute = ({ site }) => {
const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true'; const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true';
const body = indexable const body = indexable
? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n' ? [...AI_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nAllow: /', `Sitemap: ${site}sitemap.xml`].join('\n\n') + '\n'
: `# 샘플 사이트. 색인 금지.\nUser-agent: *\nDisallow: /\n`; : [`# 샘플 사이트. 색인 금지. 링크 미리보기만 허용.`, ...PREVIEW_BOTS.map((b) => `User-agent: ${b}\nAllow: /`), 'User-agent: *\nDisallow: /'].join('\n\n') + '\n';
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}; };

View File

@ -25,6 +25,21 @@ export default defineConfig(({mode}) => {
const verify = isDiscovery const verify = isDiscovery
? ['<meta name="naver-site-verification" content="6ebcb18640acf31bb3653822d178fcf071b94009" />'] ? ['<meta name="naver-site-verification" content="6ebcb18640acf31bb3653822d178fcf071b94009" />']
: []; : [];
// 링크 공유 미리보기 이미지와 파비콘(scripts/make_share_assets.py discovery 로 생성).
// og:image 는 절대 주소여야 카카오톡·슬랙이 읽는다. public/ 은 infinith-demo 와 공용이라 discovery 빌드에만 넣는다.
const share = isDiscovery
? [
'<meta property="og:url" content="https://webforai.kr" />',
'<meta property="og:image" content="https://webforai.kr/og-discovery.jpg" />',
'<meta property="og:image:width" content="1200" />',
'<meta property="og:image:height" content="630" />',
'<meta name="twitter:card" content="summary_large_image" />',
'<link rel="icon" href="/discovery-favicon.ico" sizes="48x48" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/discovery-favicon-32.png" />',
'<link rel="icon" type="image/png" sizes="192x192" href="/discovery-favicon-192.png" />',
'<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />',
]
: [];
const headHtml = [ const headHtml = [
`<title>${head.title}</title>`, `<title>${head.title}</title>`,
`<meta name="description" content="${head.description}" />`, `<meta name="description" content="${head.description}" />`,
@ -32,6 +47,7 @@ export default defineConfig(({mode}) => {
`<meta property="og:title" content="${head.title}" />`, `<meta property="og:title" content="${head.title}" />`,
`<meta property="og:description" content="${head.description}" />`, `<meta property="og:description" content="${head.description}" />`,
`<meta property="og:locale" content="ko_KR" />`, `<meta property="og:locale" content="ko_KR" />`,
...share,
...verify, ...verify,
].join('\n '); ].join('\n ');

View File

@ -489,6 +489,9 @@ await phase('build', () => {
// 주소·색인 설정을 먼저 적용한다. 원격 빌드가 이 값으로 canonical·사이트맵을 만든다. // 주소·색인 설정을 먼저 적용한다. 원격 빌드가 이 값으로 canonical·사이트맵을 만든다.
applySiteUrl(); applySiteUrl();
const refreshed = refreshTemplateCode(); const refreshed = refreshTemplateCode();
// 링크 공유 미리보기 이미지(og.png)와 파비콘. 실패해도 빌드는 계속한다(Base.astro 가 건물 사진으로 대신 낸다).
const share = run('python3', [join(ROOT, 'scripts', 'make_share_assets.py'), 'supporters', '--site', SITE]);
status.report = { ...(status.report ?? {}), shareAssets: share.ok ? tail(share.out, 1) : `공유 이미지 생성 실패\n${tail(share.out, 3)}` };
const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE }); const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE });
if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`); if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`);
const b = run('npx', ['astro', 'build'], { cwd: SITE }); const b = run('npx', ['astro', 'build'], { cwd: SITE });