新增页码选择

This commit is contained in:
2026-07-21 00:54:20 +08:00
parent 3eef54496c
commit 0f03ad9801
7 changed files with 178 additions and 12 deletions
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "@jest/globals";
import { parsePageSpec, PageInvalidError } from "./page";
describe('page spec parser', () => {
test('defaults to first page', () => {
expect(parsePageSpec(undefined)).toEqual({
pages: [1],
key: '1',
});
});
test('parses a single page', () => {
expect(parsePageSpec('7')).toEqual({
pages: [7],
key: '7',
});
});
test('parses a page range', () => {
expect(parsePageSpec('1-3')).toEqual({
pages: [1, 2, 3],
key: '1,2,3',
});
});
test('parses a page list', () => {
expect(parsePageSpec('1,3,5')).toEqual({
pages: [1, 3, 5],
key: '1,3,5',
});
});
test('parses a mixed page spec', () => {
expect(parsePageSpec('1-3, 5')).toEqual({
pages: [1, 2, 3, 5],
key: '1,2,3,5',
});
});
test('rejects invalid page input', () => {
expect(() => parsePageSpec('0')).toThrow(PageInvalidError);
expect(() => parsePageSpec('3-1')).toThrow(PageInvalidError);
expect(() => parsePageSpec('abc')).toThrow(PageInvalidError);
expect(() => parsePageSpec('1,,3')).toThrow(PageInvalidError);
});
});
+89
View File
@@ -0,0 +1,89 @@
export class PageInvalidError extends Error {
constructor(page: string) {
super(`page: ${page}`);
}
}
export interface PageSpec {
pages: number[];
key: string;
}
const SINGLE_PAGE_PATTERN = /^[1-9]\d*$/;
const PAGE_RANGE_PATTERN = /^([1-9]\d*)\s*-\s*([1-9]\d*)$/;
const normalizePages = (pages: number[]) => {
const seen = new Set<number>();
const normalized: number[] = [];
for (const page of pages) {
if (seen.has(page)) {
continue;
}
seen.add(page);
normalized.push(page);
}
return normalized;
};
const parsePageToken = (token: string) => {
if (SINGLE_PAGE_PATTERN.test(token)) {
return [Number(token)];
}
const rangeMatch = token.match(PAGE_RANGE_PATTERN);
if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (end < start) {
throw new PageInvalidError(token);
}
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}
throw new PageInvalidError(token);
};
export const parsePageSpec = (page: unknown): PageSpec => {
if (page === undefined || page === null) {
return {
pages: [1],
key: '1',
};
}
if (Array.isArray(page)) {
throw new PageInvalidError(page.join(','));
}
const rawPage = String(page).trim();
if (!rawPage) {
return {
pages: [1],
key: '1',
};
}
const tokens = rawPage.split(',');
if (tokens.some(token => !token.trim())) {
throw new PageInvalidError(rawPage);
}
const pages: number[] = [];
for (const token of tokens) {
const tokenPages = parsePageToken(token.trim());
pages.push(...tokenPages);
}
const normalizedPages = normalizePages(pages);
if (!normalizedPages.length) {
throw new PageInvalidError(rawPage);
}
return {
pages: normalizedPages,
key: normalizedPages.join(','),
};
};
+10 -2
View File
@@ -8,6 +8,7 @@ import config from '../config';
import { DomainNotFoundError, statusToHTML, UserNotFoundError, WeiboCookieInvalidError } from './weibo/weibo';
import { ThrottledError } from './throttler';
import { logger } from './logger';
import { PageInvalidError, parsePageSpec } from './page';
export class UidInvalidError extends Error {
constructor(uid: string) {
@@ -32,10 +33,12 @@ export const registerRoutes = (
throw new UidInvalidError(uid);
}
const pageSpec = parsePageSpec(ctx.query.page);
// get data
let cacheMiss = false;
const xmlData = await ctx.cache.memo(async () => {
const weiboData = await ctx.weibo.fetchUserLatestWeibo(uid);
const weiboData = await ctx.weibo.fetchUserLatestWeibo(uid, pageSpec.pages);
if (weiboData) {
// basic info
const feed = new NodeRSS({
@@ -59,7 +62,7 @@ export const registerRoutes = (
cacheMiss = true;
return feed.xml();
}
}, `xml-${uid}`, config.cacheTTL.rssXml);
}, `xml-${uid}-page-${pageSpec.key}`, config.cacheTTL.rssXml);
// send data
ctx.set('Content-Type', 'text/xml');
@@ -78,6 +81,11 @@ export const registerRoutes = (
ctx.body = `找不到用户,可能用户仅登录可见,不支持订阅。可以通过打开 https://m.weibo.cn/u/:uid 验证(<a href="https://m.weibo.cn/u/${uid}" target="_blank">uid: ${uid}</a>`;
return;
}
if (error instanceof PageInvalidError) {
ctx.status = 400;
ctx.body = `page 参数格式有误。支持单页如 7,范围如 1-10,或列表如 1,3,5。page: ${error.message.replace(/^page:\s*/, '')}`;
return;
}
if (error instanceof WeiboCookieInvalidError) {
ctx.status = 401;
ctx.body = `微博 Cookie 可能已过期或失效,请更新 WEIBO_COOKIE 后重试。uid: ${uid}`;
+3 -3
View File
@@ -64,12 +64,12 @@ export const createIndexAPI = () => {
return result;
}).catch(err => handleForbiddenErr(err, disable));
}),
getWeiboContentList: (uid: string, containerId: string) => runner.runFunc<WeiboStatus[]>(async (disable) => {
logger.debug(`[getContList] ${uid} ${containerId}`);
getWeiboContentList: (uid: string, containerId: string, page: number = 1) => runner.runFunc<WeiboStatus[]>(async (disable) => {
logger.debug(`[getContList] ${uid} ${containerId} page=${page}`);
await waitMs(Math.floor(Math.random() * 100));
return await axiosInstance({
method: 'get',
url: `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}&containerid=${containerId}`,
url: `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}&containerid=${containerId}&page=${page}`,
headers: {
'Mweibo-Pwa': 1,
'Referer': `https://m.weibo.cn/u/${uid}`,
+21 -6
View File
@@ -42,15 +42,30 @@ export class WeiboData {
/**
* get user's weibo
*/
fetchUserLatestWeibo = async (uid: string) => {
fetchUserLatestWeibo = async (uid: string, pages: number[] = [1]) => {
const indexInfo = await this.cache.memo(() => this.getIndexUserInfo(uid), `info-${uid}`, config.cacheTTL.apiIndexInfo);
const { containerId } = indexInfo;
const statusList = await this.cache.memo(async () => {
const wbList = await this.getWeiboContentList(uid, containerId);
return await Promise.all(
wbList.map(status => this.fillStatusWithLongText(status))
const statusList: WeiboStatus[] = [];
const seenStatusIds = new Set<string>();
for (const page of pages) {
const wbList = await this.cache.memo(
() => this.getWeiboContentList(uid, containerId, page),
`list-${uid}-page-${page}`,
config.cacheTTL.apiStatusList,
);
}, `list-${uid}`, config.cacheTTL.apiStatusList);
const freshList = wbList.filter((status) => {
if (seenStatusIds.has(status.id)) {
return false;
}
seenStatusIds.add(status.id);
return true;
});
const pageStatusList = await Promise.all(
freshList.map(status => this.fillStatusWithLongText(status))
);
statusList.push(...pageStatusList);
}
return {
...indexInfo,