diff --git a/interface.d.ts b/interface.d.ts index 20b82d0..3ca5a3c 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -1281,6 +1281,25 @@ export function user_event( } & RequestBaseConfig, ): Promise +export interface UserEventAllResponse { + code: number + events: Record[] + /** Upstream account statistic; it can include events no longer returned. */ + size: number | null + /** Number of unique events in `events`. */ + retrievedCount: number + /** Positive difference between `size` and `retrievedCount`. */ + unavailableCount: number | null + sizeMismatch: boolean | null + pageCount: number + more: false + lasttime: string | number | null +} + +export function user_event_all( + params: RequestBaseConfig, +): Promise> + export function user_followeds( params: { uid: string | number diff --git a/module/user_event.js b/module/user_event.js index 8b947b8..2cbbe4c 100644 --- a/module/user_event.js +++ b/module/user_event.js @@ -4,9 +4,10 @@ const createOption = require('../util/option.js') module.exports = (query, request) => { const data = { getcounts: true, - time: query.lasttime || -1, - limit: query.limit || 30, + time: query.lasttime ?? -1, + limit: query.limit ?? 30, total: false, + fromRN: 'true', } return request(`/api/event/get/${query.uid}`, data, createOption(query)) } diff --git a/module/user_event_all.js b/module/user_event_all.js new file mode 100644 index 0000000..a83695d --- /dev/null +++ b/module/user_event_all.js @@ -0,0 +1,135 @@ +// 获取当前登录用户可被上游枚举的全部动态 + +const userAccount = require('./user_account.js') +const userEvent = require('./user_event.js') + +const PAGE_SIZE = 100 +const MAX_PAGES = 1000 + +const errorResponse = (message, cookie = []) => ({ + status: 502, + body: { + code: 502, + message, + }, + cookie, +}) + +module.exports = async (query, request) => { + const accountResult = await userAccount(query, request) + const uid = + accountResult.body?.account?.id || accountResult.body?.profile?.userId + + if (!uid) { + if ( + accountResult.status !== 200 || + (accountResult.body?.code && accountResult.body.code !== 200) + ) { + return accountResult + } + + return { + status: 401, + body: { + code: 401, + message: 'A valid login cookie is required', + }, + cookie: accountResult.cookie || [], + } + } + + const cookies = [...(accountResult.cookie || [])] + const events = [] + const eventIds = new Set() + const cursors = new Set() + let lasttime = -1 + let more = true + let pageCount = 0 + let size = null + + while (more) { + pageCount += 1 + + const pageResult = await userEvent( + { + ...query, + uid, + lasttime, + limit: PAGE_SIZE, + }, + request, + ) + + cookies.push(...(pageResult.cookie || [])) + + if (pageResult.status !== 200 || pageResult.body?.code !== 200) { + return { + ...pageResult, + cookie: cookies, + } + } + + if (pageCount === 1) { + const reportedSize = pageResult.body.size + const numericSize = Number(reportedSize) + size = + reportedSize != null && Number.isFinite(numericSize) + ? numericSize + : null + } + + for (const event of pageResult.body.events || []) { + if (event?.id == null) { + events.push(event) + continue + } + + const eventId = String(event.id) + if (!eventIds.has(eventId)) { + eventIds.add(eventId) + events.push(event) + } + } + + more = Boolean(pageResult.body.more) + lasttime = pageResult.body.lasttime + + if (more) { + const cursor = String(lasttime ?? '') + if (!cursor || cursors.has(cursor)) { + return errorResponse( + 'Upstream event pagination cursor stalled', + cookies, + ) + } + cursors.add(cursor) + } + + if (more && pageCount >= MAX_PAGES) { + return errorResponse( + `Upstream event pagination exceeded ${MAX_PAGES} pages`, + cookies, + ) + } + } + + const retrievedCount = events.length + const unavailableCount = + size == null ? null : Math.max(size - retrievedCount, 0) + + return { + status: 200, + body: { + code: 200, + events, + size, + retrievedCount, + unavailableCount, + sizeMismatch: size == null ? null : size !== retrievedCount, + pageCount, + more: false, + lasttime: lasttime ?? null, + }, + cookie: cookies, + } +} diff --git a/public/docs/home.md b/public/docs/home.md index fb2bc75..e4f172b 100644 --- a/public/docs/home.md +++ b/public/docs/home.md @@ -826,6 +826,8 @@ tags: 歌单标签 `lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据 +接口会传入官方客户端使用的 `fromRN=true`,登录用户读取自己的动态时可获得网易云允许本人查看的非公开动态。返回的 `size` 是网易云账户侧统计值,不保证等于分页后实际可获取的动态数量。 + **接口地址 :** `/user/event` **调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743` @@ -843,9 +845,29 @@ tags: 歌单标签 41、21 分享视频 ``` +### 获取当前登录用户的全部可枚举动态 + +说明 : 登录后调用此接口,会组合 `/user/account` 与 `/user/event` 的原子能力:先从 Cookie 取得当前用户 id,再自动跟随 `lasttime` 游标读取至 `more=false`。接口返回网易云允许当前用户本人查看的公开及非公开动态,不能读取其他用户的私密动态。 + +本接口不接受 `limit` 或 `lasttime`,一次请求会完成全部上游分页。账号动态较多时,请预留足够的请求时间。 + +数量字段说明: + +- `size`:网易云返回的账户统计数量,可能包含已删除、被屏蔽、资源失效或旧类型且不再下发的记录。 +- `retrievedCount`:本次实际取得的唯一动态数量,始终等于 `events.length`。 +- `unavailableCount`:`size - retrievedCount` 的正差值;缺失记录没有返回 id,无法继续读取或修改。 +- `sizeMismatch`:`size` 与 `retrievedCount` 是否不一致。若上游未返回 `size`,相关字段为 `null`。 +- `pageCount`:本次实际请求的上游分页数量。 + +每条动态的 `privacySetting` 表示当前可见权限:`0` 为所有人,`1` 为我关注的人,`2` 为仅自己,`6` 为互相关注的人。 + +**接口地址 :** `/user/event/all` + +**调用例子 :** `/user/event/all` + ### 修改动态可见权限 -说明 : 登录后调用此接口,可以修改当前账号本人发布的单条动态的可见权限。此接口只负责一次原子修改;上游没有批量修改接口,如需批量操作,请由调用方逐条调用并自行处理限速、失败重试和部分成功。 +说明 : 登录后调用此接口,可以修改当前账号本人发布的单条动态的可见权限。此接口只负责一次原子修改;上游没有批量修改接口。如需批量操作,可先调用 `/user/event/all` 并按 `privacySetting` 筛选,再由调用方逐条调用本接口,同时自行处理限速、失败重试和部分成功。 **必选参数 :** `evId` : 动态 id diff --git a/test/user_event_all.test.js b/test/user_event_all.test.js new file mode 100644 index 0000000..0bbb3b3 --- /dev/null +++ b/test/user_event_all.test.js @@ -0,0 +1,145 @@ +const assert = require('assert') +const userEventAll = require('../module/user_event_all') + +describe('all current user events module', () => { + it('resolves the current user and aggregates every upstream page', async () => { + const calls = [] + const pages = [ + { + status: 200, + body: { + code: 200, + events: [{ id: 1 }, { id: 2 }], + size: 5, + more: true, + lasttime: 100, + }, + cookie: ['page=1'], + }, + { + status: 200, + body: { + code: 200, + events: [{ id: 2 }, { id: 3 }], + size: 5, + more: true, + lasttime: 50, + }, + cookie: ['page=2'], + }, + { + status: 200, + body: { + code: 200, + events: [{ id: 4 }], + size: 1, + more: false, + lasttime: 0, + }, + cookie: ['page=3'], + }, + ] + + const result = await userEventAll( + { cookie: { MUSIC_U: 'x' } }, + async (uri, data, options) => { + calls.push({ uri, data, options }) + if (uri === '/api/nuser/account/get') { + return { + status: 200, + body: { code: 200, account: { id: 42 } }, + cookie: ['account=1'], + } + } + return pages.shift() + }, + ) + + assert.deepStrictEqual( + result.body.events.map((event) => event.id), + [1, 2, 3, 4], + ) + assert.strictEqual(result.body.size, 5) + assert.strictEqual(result.body.retrievedCount, 4) + assert.strictEqual(result.body.unavailableCount, 1) + assert.strictEqual(result.body.sizeMismatch, true) + assert.strictEqual(result.body.pageCount, 3) + assert.strictEqual(result.body.more, false) + assert.deepStrictEqual(result.cookie, [ + 'account=1', + 'page=1', + 'page=2', + 'page=3', + ]) + + assert.strictEqual(calls[0].uri, '/api/nuser/account/get') + assert.strictEqual(calls[0].options.crypto, 'weapi') + assert.deepStrictEqual( + calls.slice(1).map((call) => call.data), + [ + { + getcounts: true, + time: -1, + limit: 100, + total: false, + fromRN: 'true', + }, + { + getcounts: true, + time: 100, + limit: 100, + total: false, + fromRN: 'true', + }, + { + getcounts: true, + time: 50, + limit: 100, + total: false, + fromRN: 'true', + }, + ], + ) + }) + + it('requires a valid login cookie', async () => { + const result = await userEventAll({}, async () => ({ + status: 200, + body: { code: 200, account: null, profile: null }, + cookie: [], + })) + + assert.strictEqual(result.status, 401) + assert.strictEqual(result.body.code, 401) + }) + + it('fails instead of returning a partial list when the cursor stalls', async () => { + let page = 0 + const result = await userEventAll({}, async (uri) => { + if (uri === '/api/nuser/account/get') { + return { + status: 200, + body: { code: 200, account: { id: 42 } }, + cookie: [], + } + } + + page += 1 + return { + status: 200, + body: { + code: 200, + events: [], + size: 1, + more: true, + lasttime: 100, + }, + cookie: [], + } + }) + + assert.strictEqual(page, 2) + assert.strictEqual(result.status, 502) + assert.match(result.body.message, /cursor stalled/) + }) +})