Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(route): add iandaily daily digest
  • Loading branch information
DeyunMa committed Sep 10, 2026
commit a610f994c846bf250e3c436c04a828d2983c19c4
70 changes: 70 additions & 0 deletions lib/routes/iandaily/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { load } from 'cheerio';

import type { Data, Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';

import { findEditionImage, getColumnNames, parseEditionsFromScript, renderEdition } from './utils';

const baseUrl = 'https://iandaily.xyz';
const homeUrl = `${baseUrl}/`;

export const route: Route = {
path: '/',
categories: ['new-media'],
example: '/iandaily',
parameters: {},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
source: ['iandaily.xyz/', 'iandaily.xyz/d/:date'],
},
],
name: '每日消化',
maintainers: ['DeyunMa'],
handler,
url: 'iandaily.xyz',
description: '将每期日报作为一条 RSS 条目,并保留栏目、摘要与原始信息链接。',
};

async function handler(): Promise<Data> {
const html = await ofetch(homeUrl, { responseType: 'text' });
const $ = load(html);
const scriptPath = $('script[type="module"][src*="/assets/index-"]').attr('src');
if (!scriptPath) {
throw new Error('Unable to find the iandaily application bundle; the site structure may have changed');
}

const scriptUrl = new URL(scriptPath, homeUrl).href;
const script = await ofetch(scriptUrl, { responseType: 'text' });
const editions = parseEditionsFromScript(script);

return {
title: '伊恩日刊',
description: $('meta[name="description"]').attr('content') ?? '产品设计师伊恩的每日消化。',
link: homeUrl,
image: `${baseUrl}/favicon.png`,
language: 'zh-CN',
item: editions.map((edition) => {
const link = `${baseUrl}/d/${edition.date}`;
return {
title: `伊恩日刊 · ${edition.date}`,
description: renderEdition(edition),
link,
guid: link,
author: '伊恩',
pubDate: parseDate(edition.date, 'YYYY-MM-DD'),
category: getColumnNames(edition),
image: findEditionImage(edition, baseUrl),
};
}),
};
}
9 changes: 9 additions & 0 deletions lib/routes/iandaily/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Namespace } from '@/types';

export const namespace: Namespace = {
name: '伊恩日刊',
url: 'iandaily.xyz',
categories: ['new-media'],
description: '产品设计师伊恩的每日消化。',
lang: 'zh-CN',
};
194 changes: 194 additions & 0 deletions lib/routes/iandaily/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { type AnyNode, parseExpressionAt } from 'acorn';
import { escape } from 'entities';

export type DigestEntry = {
title: string;
judgment: string;
url: string;
media?: {
image: string;
};
};

export type Edition = {
editionVersion: number;
date: string;
overview: string;
highlights: string[];
columns: Record<string, DigestEntry[]>;
};

const columnNames: Record<string, string> = {
ai: 'AI 资讯',
collab: 'AI 协作',
indie: '一人公司',
marketing: '产品营销',
product: '产品设计',
taste: '审美提升',
};
const jsonParseCall = 'JSON.parse(';
const maxScriptLength = 2_000_000;

const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);

const readStaticJsonArgument = (source: string, callStart: number): string | undefined => {
let expression: AnyNode;
try {
expression = parseExpressionAt(source, callStart, { ecmaVersion: 'latest' });
} catch {
return;
}

const call = expression.type === 'SequenceExpression' ? expression.expressions[0] : expression;
if (
call.type !== 'CallExpression' ||
call.callee.type !== 'MemberExpression' ||
call.callee.computed ||
call.callee.object.type !== 'Identifier' ||
call.callee.object.name !== 'JSON' ||
call.callee.property.type !== 'Identifier' ||
call.callee.property.name !== 'parse'
) {
return;
}

const argument = call.arguments[0];
if (!argument || argument.type === 'SpreadElement') {
return;
}
if (argument.type === 'Literal' && typeof argument.value === 'string') {
return argument.value;
}
if (argument.type === 'TemplateLiteral' && argument.expressions.length === 0 && argument.quasis.length === 1) {
return argument.quasis[0].value.cooked ?? undefined;
}
};

const readString = (record: Record<string, unknown>, key: string, context: string): string => {
const value = record[key];
if (typeof value !== 'string' || !value) {
throw new Error(`Invalid ${context}: ${key} must be a non-empty string`);
}
return value;
};

const parseDigestEntry = (value: unknown, context: string): DigestEntry => {
if (!isRecord(value)) {
throw new Error(`Invalid ${context}: entry must be an object`);
}

const title = readString(value, 'title', context);
const judgment = readString(value, 'judgment', context);
const url = readString(value, 'url', context);
const media = value.media;
const image = isRecord(media) && typeof media.image === 'string' && media.image ? media.image : undefined;

return {
title,
judgment,
url,
...(image && { media: { image } }),
};
};

const parseEdition = (value: unknown, index: number): Edition => {
const context = `edition at index ${index}`;
if (!isRecord(value)) {
throw new Error(`Invalid ${context}: edition must be an object`);
}

const editionVersion = value.editionVersion;
if (typeof editionVersion !== 'number') {
throw new TypeError(`Invalid ${context}: editionVersion must be a number`);
}

const date = readString(value, 'date', context);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`Invalid ${context}: date must use YYYY-MM-DD format`);
}

const overview = readString(value, 'overview', context);
if (!Array.isArray(value.highlights) || value.highlights.some((highlight) => typeof highlight !== 'string')) {
throw new Error(`Invalid ${context}: highlights must be an array of strings`);
}
if (!isRecord(value.columns)) {
throw new Error(`Invalid ${context}: columns must be an object`);
}

const columns = Object.fromEntries(
Object.entries(value.columns).map(([columnId, entries]) => {
if (!Array.isArray(entries)) {
throw new TypeError(`Invalid ${context}: column ${columnId} must be an array`);
}
return [columnId, entries.map((entry, entryIndex) => parseDigestEntry(entry, `${context}, column ${columnId}, entry ${entryIndex}`))];
})
);

return {
editionVersion,
date,
overview,
highlights: value.highlights,
columns,
};
};

const parseEditions = (value: unknown): Edition[] => {
if (!Array.isArray(value) || value.length === 0) {
throw new Error('Invalid iandaily data: expected at least one edition');
}

const editions = value.map((edition, index) => parseEdition(edition, index)).toSorted((a, b) => b.date.localeCompare(a.date));
if (new Set(editions.map((edition) => edition.date)).size !== editions.length) {
throw new Error('Invalid iandaily data: edition dates must be unique');
}
return editions;
};

export const parseEditionsFromScript = (source: string): Edition[] => {
if (source.length > maxScriptLength) {
throw new Error('Unable to parse iandaily data: application bundle exceeds the size limit');
}

let candidateError: unknown;
let callStart = source.indexOf(jsonParseCall);
while (callStart !== -1) {
const serialized = readStaticJsonArgument(source, callStart);
if (serialized?.includes('"editionVersion"')) {
try {
return parseEditions(JSON.parse(serialized));
} catch (error) {
candidateError = error;
}
}
callStart = source.indexOf(jsonParseCall, callStart + jsonParseCall.length);
}

if (candidateError) {
throw new Error('Unable to parse iandaily edition data; the data format may have changed', { cause: candidateError });
}
throw new Error('Unable to find iandaily edition data; the application bundle may have changed');
};

export const renderEdition = (edition: Edition): string => {
const highlights = edition.highlights.length ? `<h2>今日看点</h2><ul>${edition.highlights.map((highlight) => `<li>${escape(highlight)}</li>`).join('')}</ul>` : '';
const columns = Object.entries(edition.columns)
.map(
([columnId, entries]) =>
`<h2>${escape(columnNames[columnId] ?? columnId)}</h2><ul>${entries.map((entry) => `<li><a href="${escape(entry.url)}"><strong>${escape(entry.title)}</strong></a><br>${escape(entry.judgment)}</li>`).join('')}</ul>`
)
.join('');

return `<p>${escape(edition.overview)}</p>${highlights}${columns}`;
};

export const findEditionImage = (edition: Edition, baseUrl: string): string | undefined => {
for (const entries of Object.values(edition.columns)) {
const image = entries.find((entry) => entry.media?.image)?.media?.image;
if (image) {
return new URL(image, baseUrl).href;
}
}
};

export const getColumnNames = (edition: Edition): string[] => Object.keys(edition.columns).map((columnId) => columnNames[columnId] ?? columnId);
53 changes: 52 additions & 1 deletion tests/source-date-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { route as eastmoney } from '../lib/routes/eastmoney/report';
import { route as hellogithub } from '../lib/routes/hellogithub';
import { route as iCable } from '../lib/routes/i-cable/news';
import { route as iandaily } from '../lib/routes/iandaily';
import { route as javbus } from '../lib/routes/javbus';
import { route as smzdm } from '../lib/routes/smzdm/keyword';
import { parseSearchDate } from '../lib/routes/smzdm/utils';
import type { Data, Route } from '../lib/types';

const mocks = vi.hoisted(() => ({ request: vi.fn(), tryGet: vi.fn() }));
const mocks = vi.hoisted(() => ({ fetch: vi.fn(), request: vi.fn(), tryGet: vi.fn() }));
vi.mock('../lib/utils/got', () => ({ default: mocks.request }));
vi.mock('../lib/utils/ofetch', () => ({ default: mocks.fetch }));
vi.mock('../lib/utils/cache', () => ({ default: { tryGet: mocks.tryGet } }));
vi.mock('../lib/config', () => ({ config: { smzdm: { cookie: 'test-only-cookie' }, feature: { allow_user_supply_unsafe_domain: false }, cache: { routeExpire: 900 } } }));

Expand All @@ -19,6 +21,7 @@ const invoke = async (route: Route, path: string, params: Record<string, string>

beforeEach(() => {
vi.resetAllMocks();
mocks.fetch.mockRejectedValue(new Error('Unexpected upstream fetch'));
mocks.request.mockRejectedValue(new Error('Unexpected upstream request'));
mocks.tryGet.mockImplementation(async (_key, callback) => await callback());
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('External network access is disabled')));
Expand Down Expand Up @@ -101,4 +104,52 @@ describe('explicit source dates', () => {
expect(parseSearchDate('')).toBeUndefined();
expect(parseSearchDate('unknown')).toBeUndefined();
});

it('preserves date-only iandaily editions and renders every source column', async () => {
const columnIds = ['ai', 'indie', 'product', 'taste', 'marketing', 'collab'];
const dates = ['2026-09-10', '2026-09-09', '2026-09-08', '2026-09-07', '2026-09-06'];
const editions = dates.map((date, editionIndex) => ({
editionVersion: 2,
date,
overview: `Overview ${editionIndex}`,
highlights: [`Highlight ${editionIndex}`],
columns: Object.fromEntries(
columnIds.map((columnId) => [
columnId,
[
{
title: `${columnId} title ${editionIndex}`,
judgment: `${columnId} judgment ${editionIndex}`,
url: `https://example.com/${columnId}/${editionIndex}`,
...(columnId === 'ai' && { media: { image: '/digest/cover.jpg' } }),
},
],
])
),
}));
const homepage = '<meta name="description" content="Daily description"><script type="module" src="/assets/index-test.js"></script>';
const bundle = `const editionVersion=1,unrelated=JSON.parse('{"editionVersion":"metadata"}'),editions=JSON.parse(\`${JSON.stringify(editions)}\`);`;
mocks.fetch.mockResolvedValueOnce(homepage).mockResolvedValueOnce(bundle);

const result = await invoke(iandaily, '/iandaily');
const items = result.item ?? [];
expect(items).toHaveLength(5);
expect(new Set(items.map((item) => item.guid)).size).toBe(5);
expect(items[0]).toMatchObject({
title: '伊恩日刊 · 2026-09-10',
link: 'https://iandaily.xyz/d/2026-09-10',
guid: 'https://iandaily.xyz/d/2026-09-10',
author: '伊恩',
category: ['AI 资讯', '一人公司', '产品设计', '审美提升', '产品营销', 'AI 协作'],
image: 'https://iandaily.xyz/digest/cover.jpg',
pubDate: new Date('2026-09-10T00:00:00.000Z'),
});
for (const columnId of columnIds) {
expect(items[0].description).toContain(`${columnId} title 0`);
expect(items[0].description).toContain(`https://example.com/${columnId}/0`);
}
expect(result).toMatchObject({ title: '伊恩日刊', description: 'Daily description', link: 'https://iandaily.xyz/' });
expect(mocks.fetch).toHaveBeenNthCalledWith(1, 'https://iandaily.xyz/', { responseType: 'text' });
expect(mocks.fetch).toHaveBeenNthCalledWith(2, 'https://iandaily.xyz/assets/index-test.js', { responseType: 'text' });
});
});