From e6598a0d91cf07d66b76ed064f7c3bfcd7452763 Mon Sep 17 00:00:00 2001 From: Enoch Ma Date: Mon, 2 Feb 2026 18:25:27 +0100 Subject: [PATCH] fix(route): AEON (#21030) * fix aeon * improve layout * remove escape chars * Apply suggestion from TonyRL --- lib/routes/aeon/category.ts | 70 +++++++++++++++++++---- lib/routes/aeon/type.ts | 55 +++++++++++++++--- lib/routes/aeon/utils.tsx | 108 ++++++++++++++++++++---------------- 3 files changed, 164 insertions(+), 69 deletions(-) diff --git a/lib/routes/aeon/category.ts b/lib/routes/aeon/category.ts index bc87a7e54..714bd4ec5 100644 --- a/lib/routes/aeon/category.ts +++ b/lib/routes/aeon/category.ts @@ -1,8 +1,7 @@ import type { Route } from '@/types'; import ofetch from '@/utils/ofetch'; -import { parseDate } from '@/utils/parse-date'; -import { getBuildId, getData } from './utils'; +import { getData } from './utils'; export const route: Route = { path: '/category/:category', @@ -38,32 +37,79 @@ export const route: Route = { handler, }; +const ENDPOINT = 'https://api.aeonmedia.co/graphql'; +const LIST_BY_SECTION = /* GraphQL */ ` +query getAeonArticlesBySection($section: String!, $sortField: ArticleSortEnum = published_at, $afterCursor: String, $tag: String) { + section(site: aeon, slug: $section) { + slug + title + metaDescription + } + articles( + site: aeon + section: $section + status: [published] + tag: $tag + sort: {field: $sortField, order: desc} + after: $afterCursor + first: 24 + ) { + nodes { + slug + ...aeonArticleCardFragment + } + pageInfo { + hasNextPage + endCursor + } + } +} + +fragment aeonArticleCardFragment on Article { + id + title + slug + type + standfirstLong + authors { name } + image { url } + primaryTopic { title } + section { slug } +} +`; + async function handler(ctx) { const category = ctx.req.param('category').toLowerCase(); const url = `https://aeon.co/category/${category}`; - const buildId = await getBuildId(); - const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${category}.json`); + const response = await ofetch(ENDPOINT, { + method: 'POST', + body: { + operationName: 'getAeonArticlesBySection', + query: LIST_BY_SECTION, + variables: { + section: category, + }, + }, + }); - const section = response.pageProps.section; - - const list = section.articles.edges.map(({ node }) => ({ + const list = response.data.articles.nodes.map((node) => ({ title: node.title, description: node.standfirstLong, - author: node.authors.map((author) => author.displayName).join(', '), + author: node.authors.map((author) => author.name).join(', '), link: `https://aeon.co/${node.type}s/${node.slug}`, - pubDate: parseDate(node.createdAt), - category: [node.section.title, ...node.topics.map((topic) => topic.title)], + category: node.primaryTopic.title, image: node.image.url, type: node.type, + section: node.section.slug, slug: node.slug, })); const items = await getData(list); return { - title: `AEON | ${section.title}`, + title: `AEON | ${response.data.section.title}`, link: url, - description: section.metaDescription, + description: response.data.section.metaDescription, item: items, }; } diff --git a/lib/routes/aeon/type.ts b/lib/routes/aeon/type.ts index 581c25fa8..40fbb2d61 100644 --- a/lib/routes/aeon/type.ts +++ b/lib/routes/aeon/type.ts @@ -1,8 +1,39 @@ import type { Route } from '@/types'; import ofetch from '@/utils/ofetch'; -import { parseDate } from '@/utils/parse-date'; -import { getBuildId, getData } from './utils'; +import { getData } from './utils'; + +const ENDPOINT = 'https://api.aeonmedia.co/graphql'; +const LIST_BY_TYPE = /* GraphQL */ ` +query getAeonArticlesByType($type: [ArticleTypeEnum!], $sortField: ArticleSortEnum = published_at, $afterCursor: String, $tag: String) { + articles( + site: aeon + type: $type + status: [published] + tag: $tag + sort: {field: $sortField, order: desc} + after: $afterCursor + first: 12 + ) { + nodes { + slug + ...aeonArticleCardFragment + } + } +} + +fragment aeonArticleCardFragment on Article { + id + title + slug + type + standfirstLong + authors { name } + image { url } + primaryTopic { title } + section { slug } +} +`; export const route: Route = { path: '/:type', @@ -43,19 +74,25 @@ async function handler(ctx) { const type = ctx.req.param('type'); const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1); - const buildId = await getBuildId(); const url = `https://aeon.co/${type}`; - const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${type}.json`); + const response = await ofetch(ENDPOINT, { + method: 'POST', + body: { + query: LIST_BY_TYPE, + variables: { type: [type.slice(0, -1)], sortField: 'published_at' }, + operationName: 'getAeonArticlesByType', + }, + }); - const list = response.pageProps.articles.map((node) => ({ + const list = response.data.articles.nodes.map((node) => ({ title: node.title, description: node.standfirstLong, - author: node.authors.map((author) => author.displayName).join(', '), - link: `https://aeon.co/${node.type}s/${node.slug}`, - pubDate: parseDate(node.createdAt), - category: [node.section.title, ...node.topics.map((topic) => topic.title)], + author: node.authors.map((author) => author.name).join(', '), + link: `https://aeon.co/${type}/${node.slug}`, + category: node.primaryTopic.title, image: node.image.url, type: node.type, + section: node.section.slug, slug: node.slug, })); diff --git a/lib/routes/aeon/utils.tsx b/lib/routes/aeon/utils.tsx index 7cb63fba2..7500e7eb9 100644 --- a/lib/routes/aeon/utils.tsx +++ b/lib/routes/aeon/utils.tsx @@ -2,23 +2,36 @@ import { load } from 'cheerio'; import { raw } from 'hono/html'; import { renderToString } from 'hono/jsx/dom/server'; -import { config } from '@/config'; import cache from '@/utils/cache'; import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; -export const getBuildId = () => - cache.tryGet( - 'aeon:buildId', - async () => { - const response = await ofetch('https://aeon.co'); - const $ = load(response); - const nextData = JSON.parse($('script#__NEXT_DATA__').text()); - return nextData.buildId; - }, - config.cache.routeExpire, - false - ); +const ENDPOINT = 'https://api.aeonmedia.co/graphql'; + +const ESSAY = /* GraphQL */ ` +query getAeonEssay($slug: String!) { + essay(slug: $slug) { + publishedAt + updatedAt + authors { name authorBio } + audioUrl + image { url alt caption } + body + } +}`; + +const VIDEO = /* GraphQL */ ` +query getAeonVideo($slug: String!, $site: SiteEnum!) { + video(slug: $slug, site: $site) { + publishedAt + updatedAt + authors { name authorBio } + hoster + hosterId + credits + description + } +}`; const renderVideoDescription = (article) => { let video = article.hosterId; @@ -44,7 +57,7 @@ const renderEssayDescription = ({ banner, authorsBio, content }) => {banner?.url ? (
{banner.alt} - {banner.caption ?
{banner.caption}
: null} + {banner.caption ?
{raw(banner.caption)}
: null}
) : null} {authorsBio ? raw(authorsBio) : null} @@ -52,54 +65,55 @@ const renderEssayDescription = ({ banner, authorsBio, content }) => ); -const getData = async (list) => { +const getJSON = (slug, site) => { + const query = site ? VIDEO : ESSAY; + const variables = site ? { slug, site } : { slug }; + const operationName = site ? 'getAeonVideo' : 'getAeonEssay'; + return ofetch(ENDPOINT, { + method: 'POST', + body: { + operationName, + query, + variables, + }, + }); +}; + +export const getData = async (list) => { const items = await Promise.all( list.map((item) => cache.tryGet(item.link, async () => { - const buildId = await getBuildId(); - const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${item.type}s/${item.slug}.json?id=${item.slug}`); - - const data = response.pageProps.article; - const type = data.type.toLowerCase(); + const res = await getJSON(item.slug, item.type === 'video' ? 'aeon' : null); + const data = res.data[item.type]; item.pubDate = parseDate(data.publishedAt); - if (type === 'video') { + if (item.type === 'video') { item.description = renderVideoDescription(data); } else { - if (data.audio?.id) { - const response = await ofetch('https://api.aeonmedia.co/graphql', { - method: 'POST', - body: { - query: `query getAudio($audioId: ID!) { - audio(id: $audioId) { - id - streamUrl - } - }`, - variables: { - audioId: data.audio.id, - }, - operationName: 'getAudio', - }, - }); - + if (data.audioUrl) { delete item.image; - item.enclosure_url = response.data.audio.streamUrl; + item.enclosure_url = data.audioUrl; item.enclosure_type = 'audio/mpeg'; } - // Besides, it seems that the method based on __NEXT_DATA__ - // does not include the information of the two-column - // images in the article body, - // e.g. https://aeon.co/essays/how-to-mourn-a-forest-a-lesson-from-west-papua . - // But that's very rare. - const capture = load(data.body, null, false); const banner = data.image; capture('p.pullquote').remove(); - const authorsBio = data.authors.map((author) => '

' + author.name + author.authorBio.replaceAll(/^

/g, ' ')).join(''); + const authorsBio = renderToString( + <> +


+ {data.authors.map((author) => ( +

+ {author.name} + {raw(author.authorBio.replaceAll(/^

/g, ' '))} +

+ ))} +
+
+ + ); item.description = renderEssayDescription({ banner, authorsBio, content: capture.html() }); } @@ -111,5 +125,3 @@ const getData = async (list) => { return items; }; - -export { getData };