omg.lol (където посочвам своя домейн) и хоствам по-голямата част от съдържанието на моя сайт наскоро пусна поддръжка за /now страници.

nownownow.com

...връзка, която гласи „сега“ води към страница, която ви казва върху какво е фокусиран този човек в този момент от живота си. Накратко, ние наричаме това е „страница сега“.

Тази страница може да се актуализира ръчно, но, както при почти всичко, предлагано от 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.