fix(route): AEON (#21030)

* fix aeon

* improve layout

* remove escape chars

* Apply suggestion from TonyRL
This commit is contained in:
Enoch Ma 2026-02-02 18:25:27 +01:00 committed by GitHub
parent a4db4c217d
commit e6598a0d91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 164 additions and 69 deletions

View File

@ -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,
};
}

View File

@ -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,
}));

View File

@ -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 ? (
<figure>
<img src={banner.url} alt={banner.alt} />
{banner.caption ? <figcaption>{banner.caption}</figcaption> : null}
{banner.caption ? <figcaption>{raw(banner.caption)}</figcaption> : null}
</figure>
) : 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) => '<p>' + author.name + author.authorBio.replaceAll(/^<p>/g, ' ')).join('');
const authorsBio = renderToString(
<>
<hr />
{data.authors.map((author) => (
<p>
{author.name}
{raw(author.authorBio.replaceAll(/^<p>/g, ' '))}
</p>
))}
<hr />
<br />
</>
);
item.description = renderEssayDescription({ banner, authorsBio, content: capture.html() });
}
@ -111,5 +125,3 @@ const getData = async (list) => {
return items;
};
export { getData };