diff --git a/public/discovery-apple-touch-icon.png b/public/discovery-apple-touch-icon.png new file mode 100644 index 0000000..585060b Binary files /dev/null and b/public/discovery-apple-touch-icon.png differ diff --git a/public/discovery-favicon-192.png b/public/discovery-favicon-192.png new file mode 100644 index 0000000..1e9d8f2 Binary files /dev/null and b/public/discovery-favicon-192.png differ diff --git a/public/discovery-favicon-32.png b/public/discovery-favicon-32.png new file mode 100644 index 0000000..45e540d Binary files /dev/null and b/public/discovery-favicon-32.png differ diff --git a/public/discovery-favicon-512.png b/public/discovery-favicon-512.png new file mode 100644 index 0000000..61de2be Binary files /dev/null and b/public/discovery-favicon-512.png differ diff --git a/public/discovery-favicon.ico b/public/discovery-favicon.ico new file mode 100644 index 0000000..38837f5 Binary files /dev/null and b/public/discovery-favicon.ico differ diff --git a/public/og-discovery.jpg b/public/og-discovery.jpg new file mode 100644 index 0000000..91c9a2d Binary files /dev/null and b/public/og-discovery.jpg differ diff --git a/scripts/make_share_assets.py b/scripts/make_share_assets.py new file mode 100644 index 0000000..e9d756c --- /dev/null +++ b/scripts/make_share_assets.py @@ -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 단계가 부른다. 결과: /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() diff --git a/supporters/briefs/oracle/clinic.json b/supporters/briefs/oracle/clinic.json index eae5fe3..99985db 100644 --- a/supporters/briefs/oracle/clinic.json +++ b/supporters/briefs/oracle/clinic.json @@ -38,7 +38,8 @@ "alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판", "caption": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지" }, - "naverSiteVerification": "f083f28e6b117805e9722f0a1adf7834a8219688" + "naverSiteVerification": "f083f28e6b117805e9722f0a1adf7834a8219688", + "shareNameEn": "Oracle Dermatology" }, "excludeImages": [ "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 재실행.", "_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외.", "_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 확인)" } diff --git a/supporters/briefs/viewclinic/clinic.json b/supporters/briefs/viewclinic/clinic.json index ac50036..add343c 100644 --- a/supporters/briefs/viewclinic/clinic.json +++ b/supporters/briefs/viewclinic/clinic.json @@ -24,8 +24,10 @@ "width": 886, "height": 885 }, - "naverSiteVerification": "e47011473eef74d7b2c4f20e93f97a889cb699cc" + "naverSiteVerification": "e47011473eef74d7b2c4f20e93f97a889cb699cc", + "shareNameEn": "VIEW Plastic Surgery" }, "_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)" } diff --git a/supporters/briefs/wonjin/clinic.json b/supporters/briefs/wonjin/clinic.json index 6444ef8..2908efe 100644 --- a/supporters/briefs/wonjin/clinic.json +++ b/supporters/briefs/wonjin/clinic.json @@ -16,9 +16,11 @@ "width": 1600, "height": 633 }, - "naverSiteVerification": "5df40b24e88fd3fdf7ece9dd0fb69cf83439ad44" + "naverSiteVerification": "5df40b24e88fd3fdf7ece9dd0fb69cf83439ad44", + "shareNameEn": "WONJIN Plastic Surgery" }, "_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.", "_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)" } diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index ef27302..76a3c1f 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -1,5 +1,6 @@ --- 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 { UI, switchHref } from '../lib/i18n'; import EN from '../data/factSheetEn.json'; @@ -16,7 +17,9 @@ interface Props { lang?: 'ko' | 'en'; 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 cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); @@ -54,6 +57,13 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array + {ogImage === '/og.jpg' && } + {ogImage === '/og.jpg' && } + + + + + {alternates.map((a) => )} diff --git a/supporters/src/pages/robots.txt.ts b/supporters/src/pages/robots.txt.ts index 360359b..a351b2f 100644 --- a/supporters/src/pages/robots.txt.ts +++ b/supporters/src/pages/robots.txt.ts @@ -1,10 +1,13 @@ 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']; export const GET: APIRoute = ({ site }) => { const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true'; const body = indexable ? [...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' } }); }; diff --git a/templates/supporters-astro/src/layouts/Base.astro b/templates/supporters-astro/src/layouts/Base.astro index ef27302..76a3c1f 100644 --- a/templates/supporters-astro/src/layouts/Base.astro +++ b/templates/supporters-astro/src/layouts/Base.astro @@ -1,5 +1,6 @@ --- 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 { UI, switchHref } from '../lib/i18n'; import EN from '../data/factSheetEn.json'; @@ -16,7 +17,9 @@ interface Props { lang?: 'ko' | 'en'; 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 cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); @@ -54,6 +57,13 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array + {ogImage === '/og.jpg' && } + {ogImage === '/og.jpg' && } + + + + + {alternates.map((a) => )} diff --git a/templates/supporters-astro/src/pages/robots.txt.ts b/templates/supporters-astro/src/pages/robots.txt.ts index 360359b..a351b2f 100644 --- a/templates/supporters-astro/src/pages/robots.txt.ts +++ b/templates/supporters-astro/src/pages/robots.txt.ts @@ -1,10 +1,13 @@ 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']; export const GET: APIRoute = ({ site }) => { const indexable = import.meta.env.PUBLIC_INDEXABLE === 'true'; const body = indexable ? [...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' } }); }; diff --git a/vite.config.ts b/vite.config.ts index 7b5641f..a84cb6c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,6 +25,21 @@ export default defineConfig(({mode}) => { const verify = isDiscovery ? [''] : []; + // 링크 공유 미리보기 이미지와 파비콘(scripts/make_share_assets.py discovery 로 생성). + // og:image 는 절대 주소여야 카카오톡·슬랙이 읽는다. public/ 은 infinith-demo 와 공용이라 discovery 빌드에만 넣는다. + const share = isDiscovery + ? [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + ] + : []; const headHtml = [ `${head.title}`, ``, @@ -32,6 +47,7 @@ export default defineConfig(({mode}) => { ``, ``, ``, + ...share, ...verify, ].join('\n '); diff --git a/workers/supporters-build/run.mjs b/workers/supporters-build/run.mjs index d125d9e..2316609 100644 --- a/workers/supporters-build/run.mjs +++ b/workers/supporters-build/run.mjs @@ -489,6 +489,9 @@ await phase('build', () => { // 주소·색인 설정을 먼저 적용한다. 원격 빌드가 이 값으로 canonical·사이트맵을 만든다. applySiteUrl(); 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 }); if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`); const b = run('npx', ['astro', 'build'], { cwd: SITE });