Kitty Space начинался как Telegram-бот. Люди присылают фото уличных котов, бот их сохраняет и возвращает ссылку. Дальше всем хотелось одного: поделиться этим котом в Twitter или в чате. Но вставляешь ссылку, а превью нет: голый URL или в лучшем случае карточка без картинки. Дальше о причине и о небольшом сервере, который вокруг этого собрали.
Причина простая, если присмотреться. Социальные платформы не строят карточку из URL картинки или ссылки t.me. Они шлют краулера, который скачивает страницу и читает <meta>-теги из HTML: og:image, og:title и их twitter-аналоги. Фото в объектном хранилище остаётся файлом, а не веб-страницей. На нём нет ни HTML, ни места под эти теги, поэтому платформе и нечего разворачивать.
Почему нужна отдельная страница
К JPEG нельзя прицепить мета-теги. Имеющиеся ссылки вели либо на бота, либо на голую картинку, и ни одна не является HTML-страницей, с которой краулер прочитает теги. Сайта на тот момент тоже не было, только бот и бакет хранилища.
Значит, каждому фото нужна веб-страница, которой пока не существует, и кто-то должен её отдавать. Этим «кем-то» становится небольшой HTTP-сервер с одной ручкой. Вся его задача: взять id фото и вернуть HTML-документ с нужными тегами. А ссылка для шеринга тогда ведёт на этот сервер, а не на картинку.
Поток получается такой:
- Ссылка для шеринга это
https://…/p/{photo-id}, она ведёт на превью-сервер. - Краулер её скачивает и получает HTML-страницу, у которой
og:imageуказывает на реальное фото на CDN. - Платформа читает теги и строит карточку.
Картинка как лежала на CDN, так и лежит, её никуда не двигают. Новая страница существует только чтобы нести описывающие её теги.
Шаг 1: сервер с одной ручкой
Сервер написан на обычном Go, с единственным хендлером. По id фото он собирает URL картинки на CDN и публичный URL этой страницы, рендерит HTML и отдаёт его:
r.HandleFunc("/p/{id}", func(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
imageURL := fmt.Sprintf("https://%s.%s/photos/%s", bucket, cdn, id) // the real photo
pageURL := publicBaseURL + "/p/" + id // this page
html, _ := gen.GetPreviewHTML(imageURL, pageURL, shouldRedirect)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000") // a photo's page never changes
w.Write([]byte(html))
})
Никакого запроса в базу и никакой работы с картинкой тут нет. Одного id достаточно, чтобы знать, где лежит фото, поэтому страница строится и отдаётся за один шаг.
Шаг 2: теги, которые делают карточку
HTML почти целиком состоит из head. Тело краулеру почти безразлично, смысл в тегах:
<meta property="og:title" content="🐱 Street cat spotted!">
<meta property="og:image" content="{{.ImageURL}}"> <!-- absolute CDN url -->
<meta property="og:url" content="{{.PageURL}}">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{.ImageURL}}">
og:image и twitter:image оба указывают на фото на CDN. summary_large_image просит у Twitter раскладку во всю ширину, а не маленькую боковую миниатюру.
Шаг 3: делиться страницей, а не картинкой
Этот шаг легко упустить. Кнопки шеринга в боте должны вести на превью-страницу, а не на фото, поэтому ссылка для шеринга строится вокруг /p/{id}:
func GetPreviewURL(baseURL, imageID string) string {
return fmt.Sprintf("%s/p/%s", baseURL, imageID)
}
func GetTwitterShareURL(baseURL, text, imageID string) string {
return fmt.Sprintf(
"https://twitter.com/intent/tweet?text=%s&url=%s",
url.QueryEscape(text),
url.QueryEscape(GetPreviewURL(baseURL, imageID)), // the page, so the card resolves
)
}
Теперь кнопка «Поделиться в Twitter» в боте открывает твит, ссылка в котором ведёт на превью-страницу, и карточка подтягивается сама.
Части, где пришлось пробовать и ошибаться
Форма маленькая. Заставить каждую платформу её уважать было медленной частью.
og:image должен быть абсолютным URL на публичном хосте. Относительный путь или ссылка за авторизацией, и карточка приходит пустой. Со ссылкой прямо на CDN картинка грузится быстро и остаётся доступной.
summary_large_image превращает карточку Twitter из крошечной боковой миниатюры в фото на всю ширину. Без него кот размером с почтовую марку.
Страница кэшируется жёстко. Превью фото не меняется, поэтому max-age равен году. Краулеры всё равно перезапрашивают по своему графику, а кэш делает повторные шеры дешёвыми.
Есть и запасной случай. Неизвестный путь всё равно отдаёт валидную страницу с тегами по умолчанию, и опечатка или устаревшая ссылка вырождается в «этот кот не найден», а не в сломанную карточку.
Редирект появился позже
Витрина появилась намного позже, с настоящей страницей-профилем на каждого кота. Заменять превью-сервер не пришлось. Он научился уводить людей дальше, оставляя ботов на месте.
Рычагом служит user agent. Краулер должен остаться на странице, чтобы прочитать теги; человек должен попасть на сам профиль. Поэтому страница делает редирект только когда посетитель не из известных ботов:
func isBot(userAgent string) bool {
bots := []string{
"facebookexternalhit", "Twitterbot", "Slackbot",
"TelegramBot", "LinkedInBot", "WhatsApp", "Googlebot",
}
ua := strings.ToLower(userAgent)
for _, b := range bots {
if strings.Contains(ua, strings.ToLower(b)) { return true }
}
return false
}
shouldRedirect := !isBot(r.UserAgent())
Когда shouldRedirect выставлен, страница добавляет редирект; иначе остаётся на месте для краулера:
{{if .ShouldRedirect}}
<meta http-equiv="refresh" content="0; url={{.RedirectURL}}">
<script>setTimeout(() => location.replace("{{.RedirectURL}}"), 1000);</script>
{{end}}
Бот читает карточку. Человек секунду видит фото, потом попадает на профиль кота.
Приём
Если контент лежит там, где у него нет своей веб-страницы, например в боте или в бакете хранилища, поделиться им с превью как есть не получится. Сработала одна маленькая серверная страница на каждую единицу, с тегами og: и twitter:, которые указывают на реальную картинку, отдаваемая вместо файла, жёстко закэшированная, с редиректом только для людей, добавленным когда появилось более полное место назначения. Этой одной ручки хватило, чтобы любая ссылка разворачивалась в карточку.
Kitty Space живёт на kitty.tinygods.dev.