omg.lol (где я указываю свой домен) и размещаю большую часть контента моего сайта недавно запустил поддержку страниц /now.

nownownow.com

…ссылка с надписью «сейчас» ведет на страницу, на которой рассказывается, на чем сосредоточен этот человек в данный момент своей жизни. Короче говоря, мы звоним это страница «сейчас».

Эту страницу можно обновлять вручную, но, как и почти все, что предлагает omg.lol, существует API для отправки обновлений на страницу. Я уже нечасто веду блог и знал, что не смогу часто обновлять страницу вручную, что дало возможность автоматизировать обновления страницы. Моя страница доступна по адресу coryd.dev/now.

Заимствуя у Робба Найта, я начал с создания пасты, содержащей 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.lol, и изменения вступят в силу в свое время.

Вопросы? Комментарии? Свяжитесь с нами:

У Робба Найта есть отличный пост о том, как автоматизировать свою /now страницу с помощью Eleventy и отразить ее на omg.lol.