omg.lol (където посочвам своя домейн) и хоствам по-голямата част от съдържанието на моя сайт наскоро пусна поддръжка за /now страници.
...връзка, която гласи „сега“ води към страница, която ви казва върху какво е фокусиран този човек в този момент от живота си. Накратко, ние наричаме това е „страница сега“.
Тази страница може да се актуализира ръчно, но, както при почти всичко, предлагано от omg.lol, има API за изпращане на актуализации на страницата. Вече рядко водя блог и знаех, че няма да успея често да актуализирам страницата ръчно, което предостави възможност за автоматизиране на актуализациите на страницата. Моята страница е достъпна на адрес coryd.dev/now.
Заимствайки от Robb Knight, започнах, като създадох паста, съдържаща yaml
със статичен текст, за да запълня горната част на моята сега страница с кратки подробности за семейството, работата и хобитата (или липсата на такива).
Оттам се обърнах към безбройните услуги, базирани на съдържание, които използвам, за да следя какво слушам, каква телевизия и филми гледам и какви книги чета, за да получа актуализации от тях.
Вече излагам най-скоро слушаните си песни и активно чета книги на моята начална страница/профил omg.lol. Тези данни се извличат от приложение next.js, хоствано във Vercel, което разкрива редица крайни точки. За моите данни за слушане на музика използвам маршрут на /api/music
, който изглежда така:
export default async function handler(req: any, res: any) { const KEY = process.env.API_KEY_LASTFM const METHODS: {[key: string]: string} = { 'default': 'user.getrecenttracks', 'albums': 'user.gettopalbums', 'artists': 'user.gettopartists', } const METHOD = METHODS[req.query.type] || METHODS['default']; const data = await fetch(`http://ws.audioscrobbler.com/2.0/?method=${METHOD}&user=cdme_&api_key=${KEY}&limit=${req.query.limit || 20}&format=${req.query.format || 'json'}&period=${req.query.period || 'overall'}`).then((response) => response.json()) res.json(data) }
Този API приема параметър тип и преминава през няколко от параметрите на Last.fm, за да може да бъде използван повторно за моя дисплей, който сега слушам, и страницата /now
.
API на Last.fm връща изображения на албуми, но вече не връща изображения на изпълнители. За да разреша това, създадох крайна точка /api/media
, която проверява за налично, статично изображение на изпълнител и връща контейнер, ако тази проверка даде 404. Ако се върне 404, записвам липсващото име на изпълнител в паста в omg Услуга paste.lol на .lol:
import siteMetadata from '@/data/siteMetadata' export default async function handler(req: any, res: any) { const env = process.env.NODE_ENV let host = siteMetadata.siteUrl if (env === 'development') host = 'http://localhost:3000' const ARTIST = req.query.artist const ALBUM = req.query.album const MEDIA = ARTIST ? 'artists' : 'albums' const MEDIA_VAL = ARTIST ? ARTIST : ALBUM; const data = await fetch(`${host}/media/${MEDIA}/${MEDIA_VAL}.jpg`).then(response => { if (response.status === 200) return `${host}/media/${MEDIA}/${MEDIA_VAL}.jpg`; fetch(`${host}/api/omg/paste-edit?paste=404-images&editType=append&content=${MEDIA_VAL}`).then((response) => response.json()); return `${host}/media/404.jpg`; }).then(image => image) res.redirect(data) }
За моите данни за четене Oku.club излага RSS емисия за всички изгледи на колекция. Използвам @extractus/feed-extractor, за да трансформирам този RSS канал в JSON и да го изложа, както следва:
import { extract } from '@extractus/feed-extractor' import siteMetadata from '@/data/siteMetadata' export default async function handler(req: any, res: any) { const env = process.env.NODE_ENV let host = siteMetadata.siteUrl if (env === 'development') host = 'http://localhost:3000' const url = `${host}/feeds/books` const result = await extract(url) res.json(result) }
За данни за гледана телевизия Trakt предлага RSS емисия на моята гледана история, която се обслужва като крайна точка, както следва:
import { extract } from '@extractus/feed-extractor' import siteMetadata from '@/data/siteMetadata' export default async function handler(req: any, res: any) { const KEY = process.env.API_KEY_TRAKT const env = process.env.NODE_ENV let host = siteMetadata.siteUrl if (env === 'development') host = 'http://localhost:3000' const url = `${host}/feeds/tv?slurm=${KEY}` const result = await extract(url, { getExtraEntryFields: (feedEntry) => { return { image: feedEntry['media:content']['@_url'], thumbnail: feedEntry['media:thumbnail']['@_url'] } } }) res.json(result) }
За данни за филми от Letterboxd ние отново търсим трансформиране на моя профил RSS емисия:
import { extract } from '@extractus/feed-extractor' import siteMetadata from '@/data/siteMetadata' export default async function handler(req: any, res: any) { const env = process.env.NODE_ENV let host = siteMetadata.siteUrl if (env === 'development') host = 'http://localhost:3000' const url = `${host}/feeds/movies` const result = await extract(url) res.json(result) }
Всичко това се събира в още една, може би преуморена крайна точка на /api/now
. Обажданията към тази крайна точка се удостоверяват с код на носител и всеки отговор на крайна точка е конфигуриран да връща JSON, Markdown и, в случай на секции с по-сложни оформления (музикални изпълнители и албуми), HTML. Съдържанието на тази крайна точка е както следва:
import jsYaml from 'js-yaml' import siteMetadata from '@/data/siteMetadata' import { listsToMarkdown } from '@/utils/transforms' import { getRandomIcon } from '@/utils/icons' import { nowResponseToMarkdown } from '@/utils/transforms' import { ALBUM_DENYLIST } from '@/utils/constants' export default async function handler(req: any, res: any) { const env = process.env.NODE_ENV const { APP_KEY_OMG, API_KEY_OMG } = process.env const ACTION_KEY = req.headers.authorization?.split(" ")[1] let host = siteMetadata.siteUrl if (env === 'development') host = 'http://localhost:3000' try { if (ACTION_KEY === APP_KEY_OMG) { const now = await fetch('https://api.omg.lol/address/cory/pastebin/now.yaml') .then(res => res.json()) .then(json => { const now = jsYaml.load(json.response.paste.content) Object.keys(jsYaml.load(json.response.paste.content)).forEach(key => { now[key] = listsToMarkdown(now[key]) }) return { now } }) const books = await fetch(`${host}/api/books`).then(res => res.json()) .then(json => { const data = json.entries.slice(0, 5).map((book: {title: string, link: string}) => { return { title: book.title, link: book.link, } }) return { json: data, md: data.map((d: any) => { return `- [${d.title}](${d.link}) {${getRandomIcon('books')}}` }).join('\n') } }) const movies = await fetch(`${host}/api/movies`).then(res => res.json()) .then(json => { const data = json.entries.slice(0, 5).map((movie: {title: string, link: string, description: string}) => { return { title: movie.title, link: movie.link, desc: movie.description, } }) return { json: data, md: data.map((d: any) => { return `- [${d.title}](${d.link}): ${d.desc} {${getRandomIcon('movies')}}` }).join('\n') } }) const tv = await fetch(`${host}/api/tv`).then(res => res.json()) .then(json => { const data = json.entries.splice(0, 5).map((episode: {title: string, link: string, image: string, thumbnail: string, }) => { return { title: episode.title, link: episode.link, image: episode.image, thumbnail: episode.thumbnail, } }) return { json: data, html: data.map((d: any) => { return `<div class="container"><a href=${d.link} title='${d.title} by ${d.artist}'><div class='cover'></div><div class='details'><div class='text-main'>${d.title}</div></div><img src='${d.thumbnail}' alt='${d.title}' /></div></a>` }).join('\n'), md: data.map((d: any) => { return `- [${d.title}](${d.link}) {${getRandomIcon('tv')}}` }).join('\n') } }) const musicArtists = await fetch(`https://utils.coryd.dev/api/music?type=artists&period=7day&limit=8`).then(res => res.json()) .then(json => { const data = json.topartists.artist.map((a: any) => { return { artist: a.name, link: `https://rateyourmusic.com/search?searchterm=${encodeURIComponent(a.name)}`, image: `${host}/api/media?artist=${a.name.replace(/\s+/g, '-').toLowerCase()}` } }) return { json: data, html: data.map((d: any) => { return `<div class="container"><a href=${d.link} title='${d.title} by ${d.artist}'><div class='cover'></div><div class='details'><div class='text-main'>${d.artist}</div></div><img src='${d.image}' alt='${d.artist}' /></div></a>` }).join('\n'), md: data.map((d: any) => { return `- [${d.artist}](${d.link}) {${getRandomIcon('music')}}` }).join('\n') } }) const musicAlbums = await fetch(`https://utils.coryd.dev/api/music?type=albums&period=7day&limit=8`).then(res => res.json()) .then(json => { const data = json.topalbums.album.map((a: any) => ({ title: a.name, artist: a.artist.name, link: `https://rateyourmusic.com/search?searchterm=${encodeURIComponent(a.name)}`, image: (!ALBUM_DENYLIST.includes(a.name.replace(/\s+/g, '-').toLowerCase()) ? a.image[a.image.length - 1]['#text'] : `${host}/api/media?album=${a.name.replace(/\s+/g, '-').toLowerCase()}`) })) return { json: data, html: data.map((d: any) => { return `<div class="container"><a href=${d.link} title='${d.title} by ${d.artist}'><div class='cover'></div><div class='details'><div class='text-main'>${d.title}</div><div class='text-secondary'>${d.artist}</div></div><img src='${d.image}' alt='${d.title} by ${d.artist}' /></div></a>` }).join('\n'), md: data.map((d: any) => { return `- [${d.title}](${d.link}) by ${d.artist} {${getRandomIcon('music')}}` }).join('\n') } }) fetch('https://api.omg.lol/address/cory/now', { method: 'post', headers: { Authorization: `Bearer ${API_KEY_OMG}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content: nowResponseToMarkdown({ now, books, movies, tv, music: { artists: musicArtists, albums: musicAlbums } }), listed: 1, }) }) res.status(200).json({success: true}) } else { res.status(401).json({success: false}) } } catch(err) { res.status(500).json({success: false}) } }
Тази крайна точка също така поддържа списък за отказ за албуми, върнати от last.fm, които може да не са подходящи за показване в любезна компания - ако даден албум е в списъка за отказ, ние търсим алтернативна, статично хоствана корица или служим на нашия контейнер 404, ако такъв не е лесно достъпен.
За елементи, показани от Markdown, прикачвам произволна икона на FontAwesome (напр. getRandomIcon('music')
):
export const getRandomIcon = (type: string) => { const icons = { 'books': ['book', 'book-bookmark', 'book-open', 'book-open-reader', 'bookmark'], 'music': ['music', 'headphones', 'record-vinyl', 'radio', 'guitar', 'compact-disc'], 'movies': ['film', 'display', 'video', 'ticket'], 'tv': ['tv', 'display', 'video'] } return icons[type][Math.floor(Math.random() * ((icons[type].length - 1) - 0))] }
Като последна стъпка за завършване на това, обажданията до /api/now
се правят на всеки 8 часа с помощта на GitHub действие:
name: scheduled-cron-job on: schedule: - cron: '0 */8 * * *' jobs: cron: runs-on: ubuntu-latest steps: - name: scheduled-cron-job run: | curl -X POST 'https://utils.coryd.dev/api/now' \ -H 'Authorization: Bearer ${{ secrets.ACTION_KEY }}'
Тази крайна точка може също да бъде извикана ръчно с помощта на друг работен процес:
name: manual-job on: [workflow_dispatch] jobs: cron: runs-on: ubuntu-latest steps: - name: manual-job run: | curl -X POST 'https://utils.coryd.dev/api/now' \ -H 'Authorization: Bearer ${{ secrets.ACTION_KEY }}'
Засега това работи безпроблемно — ако искам да актуализирам или добавя статично съдържание, мога да го направя чрез моя yaml paste на paste.lol и промяната ще се появи своевременно.
въпроси? коментари? Чувствайте се свободни да се свържете с:
- "Електронна поща"
- Мастодонт
Robb Knight има „страхотна публикация“ за неговия процес за автоматизиране на неговата /now
страница с помощта на „Eleventy“ и отразяването й към omg.lol.