diff --git a/helpers/api/client.js b/helpers/api/client.js index 2e3b7bc..d5d06bb 100644 --- a/helpers/api/client.js +++ b/helpers/api/client.js @@ -25,7 +25,10 @@ const defaultFetchMethod = async function (...args) { return axios(...args) .then( response => response.data ) .catch( error => { - console.error( error ) + if ( error?.response?.status !== 404 ) { + console.error( error ) + } + throw error }) } diff --git a/helpers/build-video-list.js b/helpers/build-video-list.js index a3df213..634e057 100644 --- a/helpers/build-video-list.js +++ b/helpers/build-video-list.js @@ -33,6 +33,10 @@ const videoFeaturesApp = function (app, video) { return false } +export function makeVideoSlug ( title, videoId ) { + return makeSlug( `${ title }-i-${ videoId }` ) +} + const generateVideoTags = function ( video ) { const tags = { 'benchmark': { @@ -129,7 +133,7 @@ const makeThumbnailData = function ( thumbnails, widthLimit = null ) { } } -async function handleFetchedVideo ( fetchedVideo, videoId, applist ) { +export async function buildVideoListingFromFetchedVideo ( fetchedVideo, videoId, applist ) { // Skip private videos if (fetchedVideo.title === 'Private video') return @@ -138,7 +142,7 @@ async function handleFetchedVideo ( fetchedVideo, videoId, applist ) { if (fetchedVideo.title === 'Deleted video') return // Build video slug - const slug = makeSlug( `${fetchedVideo.title}-i-${videoId}` ) + const slug = makeVideoSlug( fetchedVideo.title, videoId ) const appLinks = [] // Generate new tag set based on api data @@ -200,8 +204,7 @@ export default async function ( applist ) { .withConcurrency(1000) .for( Object.entries( fetchedVideos ) ) .process(async ( [ videoId, fetchedVideo ], index, pool ) => { - const mappedVideo = await handleFetchedVideo ( fetchedVideo, videoId, applist ) - + const mappedVideo = await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, applist ) // Skip if this video is not an object if ( Object( mappedVideo ) !== mappedVideo ) return diff --git a/helpers/site-listings.js b/helpers/site-listings.js new file mode 100644 index 0000000..6ba4c90 --- /dev/null +++ b/helpers/site-listings.js @@ -0,0 +1,47 @@ +import fs from 'fs-extra' +import path from 'path' +import { fileURLToPath } from 'url' + +import { + buildVideoListingFromFetchedVideo, + makeVideoSlug +} from '~/helpers/build-video-list.js' +import { youtubeVideoPath } from '~/helpers/api/youtube/build.js' + +const currentModuleDirectory = path.dirname( fileURLToPath( import.meta.url ) ) +const appListPath = path.join( currentModuleDirectory, '../static/app-list.json' ) +const gameListPath = path.join( currentModuleDirectory, '../static/game-list.json' ) +const deviceListPath = path.join( currentModuleDirectory, '../static/device-list.json' ) +const trailingCommaPattern = /,\s*([\]}])/g + +function parseGeneratedJsonFile ( filePath ) { + const fileContents = fs.readFileSync( filePath, 'utf8' ) + + return JSON.parse( fileContents.replace( trailingCommaPattern, '$1' ) ) +} + +export function getDeviceListingBySlug ( slug ) { + const deviceList = parseGeneratedJsonFile( deviceListPath ) + + return deviceList.find( device => device.slug === slug ) || null +} + +function getAllVideoAppsList () { + return [ + ...parseGeneratedJsonFile( appListPath ), + ...parseGeneratedJsonFile( gameListPath ) + ] +} + +export async function getVideoListingBySlug ( slug ) { + const fetchedVideos = await fs.readJson( youtubeVideoPath ) + const allVideoAppsList = getAllVideoAppsList() + + for ( const [ videoId, fetchedVideo ] of Object.entries( fetchedVideos ) ) { + if ( makeVideoSlug( fetchedVideo.title, videoId ) !== slug ) continue + + return await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, allVideoAppsList ) + } + + return null +} diff --git a/scripts/health b/scripts/health new file mode 100755 index 0000000..3c5c81a --- /dev/null +++ b/scripts/health @@ -0,0 +1,104 @@ +#!/usr/bin/env bun + +const routeGroups = { + topLevel: [ + '/', + '/categories', + '/devices', + '/benchmarks', + '/games', + '/apple-silicon-app-test' + ], + dynamic: [ + '/app/kicad-eda', + '/app/spotify', + '/formula/bash', + '/kind/developer-tools', + '/device/m1-imac', + '/app/expressvpn/benchmarks' + ], + video: [ + '/tv/apple-silicon-gaming-is-here', + '/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i', + '/tv/xamarin-and-visual-studio-on-apple-macbook-pro-13-m1-in-4k-i-rwpspmmlos', + '/tv/watch-this-before-buying-apple-m1-macbook-for-xampp-or-apple-silicon-tests-in-4k-i-ebwwewsis8s' + ] +} + +function parseHosts(rawHosts) { + const source = rawHosts && rawHosts.trim().length > 0 + ? rawHosts + : 'doesitarm.com' + + return source + .split(',') + .map(host => host.trim()) + .filter(Boolean) + .map(host => host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`) +} + +function getPaths() { + return Object.values(routeGroups).flat() +} + +function extractTitle(html) { + const match = html.match(/([^<]+)<\/title>/i) + + return match ? match[1].trim() : '' +} + +async function runCheck(host, path) { + const url = new URL(path, host) + const response = await fetch(url, { + redirect: 'follow', + headers: { + 'user-agent': 'doesitarm-health-check' + } + }) + + const html = await response.text() + const finalUrl = response.url + + return { + host: new URL(host).host, + path, + status: response.status, + ok: response.ok, + finalPath: new URL(finalUrl).pathname, + title: extractTitle(html) + } +} + +const hosts = parseHosts(process.argv[2] || '') +const paths = getPaths() + +console.log(`Checking ${paths.length} routes across ${hosts.length} host(s)`) + +let hasFailures = false + +for (const host of hosts) { + console.log(`\nHost: ${new URL(host).host}`) + + const results = await Promise.all(paths.map(path => runCheck(host, path))) + + for (const result of results) { + const statusLabel = result.ok ? 'PASS' : 'FAIL' + const redirectSuffix = result.finalPath !== result.path ? ` -> ${result.finalPath}` : '' + const titleSuffix = result.title.length > 0 ? ` | ${result.title}` : '' + + console.log(`${statusLabel} ${result.status} ${result.path}${redirectSuffix}${titleSuffix}`) + } + + const failures = results.filter(result => !result.ok) + + if (failures.length > 0) { + hasFailures = true + console.log(`Failures: ${failures.length}`) + } else { + console.log('Failures: 0') + } +} + +if (hasFailures) { + process.exit(1) +} diff --git a/src/pages/device/[...devicePath].astro b/src/pages/device/[...devicePath].astro index 7207eaf..2f79bfe 100644 --- a/src/pages/device/[...devicePath].astro +++ b/src/pages/device/[...devicePath].astro @@ -10,6 +10,7 @@ import { applyResponseDefaults } from '~/helpers/astro/request.js' import { deviceSupportsApp } from '~/helpers/devices.js' +import { getDeviceListingBySlug } from '~/helpers/site-listings.js' import Layout from '../../layouts/default.astro' @@ -36,7 +37,22 @@ if ( redirectResponse !== null ) { applyResponseDefaults( Astro ) -const device = await DoesItAPI.device( pathSlug ).get() +let device + +try { + device = await DoesItAPI.device( pathSlug ).get() +} catch ( error ) { + if ( error?.response?.status !== 404 ) { + throw error + } + + device = getDeviceListingBySlug( pathSlug ) +} + +if ( device === null || typeof device === 'undefined' ) { + return Astro.redirect( '/devices' ) +} + const rawAppPage = await DoesItAPI.kind( 'app' )( subSlug ).get() diff --git a/src/pages/tv/[...videoPath].astro b/src/pages/tv/[...videoPath].astro index 6621668..fafeb2f 100644 --- a/src/pages/tv/[...videoPath].astro +++ b/src/pages/tv/[...videoPath].astro @@ -11,6 +11,7 @@ import { getVideoImages, ListingDetails } from '~/helpers/listing-page.js' +import { getVideoListingBySlug } from '~/helpers/site-listings.js' import { getPathPartsFromAstroRequest } from '~/helpers/url.js' import Layout from '~/src/layouts/default.astro' @@ -39,7 +40,21 @@ const { // https://docs.astro.build/en/reference/api-reference/#astrorequests // Request App data from API -const tvListing = await DoesItAPI.tv( pathSlug ).get() +let tvListing + +try { + tvListing = await DoesItAPI.tv( pathSlug ).get() +} catch ( error ) { + if ( error?.response?.status !== 404 ) { + throw error + } + + tvListing = await getVideoListingBySlug( pathSlug ) +} + +if ( tvListing === null || typeof tvListing === 'undefined' ) { + return Astro.redirect( '/benchmarks' ) +} const listingDetails = new ListingDetails( tvListing ) diff --git a/test/prebuild/site-listings.test.js b/test/prebuild/site-listings.test.js new file mode 100644 index 0000000..7c27420 --- /dev/null +++ b/test/prebuild/site-listings.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { + getDeviceListingBySlug, + getVideoListingBySlug +} from '~/helpers/site-listings.js' + +describe( 'site listing fallbacks', () => { + it( 'loads known devices from the bundled device list', () => { + expect( getDeviceListingBySlug( 'm1-imac' ) ).toMatchObject({ + name: 'M1 iMac', + endpoint: '/device/m1-imac' + }) + }) + + it( 'rebuilds known tv listings from the bundled YouTube source', async () => { + await expect( + getVideoListingBySlug( 'install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i' ) + ).resolves.toMatchObject({ + endpoint: '/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i' + }) + }) + + it( 'returns null for missing tv slugs', async () => { + await expect( + getVideoListingBySlug( 'apple-silicon-gaming-is-here' ) + ).resolves.toBeNull() + }) +})