feat(qzone): add complete feed lookup by tid

Refs #633
This commit is contained in:
DrSmoothl
2026-11-01 21:31:52 +08:00
parent f79187f36a
commit c15ecd0402
18 changed files with 547 additions and 13 deletions
+7
View File
@@ -27,6 +27,8 @@ import {
import {
getQzonePublicCapabilities,
getQzoneHomeFeeds,
getQzoneFeedDetail,
type QzoneFeedDetail,
getQzoneShareArk,
type QzoneShareArk,
getQzonePublicFeeds,
@@ -81,6 +83,11 @@ export class QzoneApi {
return getQzoneHomeFeeds(cookies, this.ctx.identity.uin, targetUin?.toString(), cursor);
}
async getFeedDetail(tid: string): Promise<QzoneFeedDetail> {
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
return getQzoneFeedDetail(cookies, tid);
}
async getShareArk(feed: JsonObject): Promise<QzoneShareArk> {
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
return getQzoneShareArk(cookies, feed);
@@ -101,6 +101,31 @@ const cases = [
describe('apis/qzone public', () => {
afterEach(() => vi.restoreAllMocks());
it('reads a post by tid using current-account credentials', async () => {
const { api, getCookies } = makeApi();
const tid = '1127000000f1536501000000';
const detail = { feed, attach_info: 'comments-next' };
const request = vi.spyOn(qzonePublic, 'getQzoneFeedDetail').mockResolvedValue(detail);
expect(await api.getFeedDetail(tid)).toBe(detail);
expect(getCookies).toHaveBeenCalledExactlyOnceWith('qzone.qq.com');
expect(request).toHaveBeenCalledExactlyOnceWith(cookies, tid);
});
it('propagates feed detail and cookie failures without replay', async () => {
const { api, getCookies } = makeApi();
const request = vi
.spyOn(qzonePublic, 'getQzoneFeedDetail')
.mockRejectedValue(new Error('denied'));
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow('denied');
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow(
'cookie failure',
);
expect(request).toHaveBeenCalledOnce();
});
it('generates a share card with the account cookies and preserves its notice', async () => {
const { api, getCookies } = makeApi();
const card = { ark: '{}', requires_confirmation: true, notice: '所有人可见' };
+74 -4
View File
@@ -11241,6 +11241,76 @@
},
"category": "群相册"
},
{
"name": "get_qzone_feed_detail",
"aliases": [],
"summary": "按说说 tid 读取完整空间动态,可用于生成分享卡片",
"returns": "完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。",
"returnsSchema": {
"type": "object",
"additionalProperties": true,
"properties": {
"feed": {
"type": "object",
"additionalProperties": true,
"description": "去除上游包装后的完整动态,可原样传给 get_qzone_share_ark"
},
"attach_info": {
"type": [
"string",
"null"
],
"description": "上游评论续页游标,未提供时为 null;不保证评论及回复完整"
}
},
"required": [
"feed",
"attach_info"
]
},
"examples": [
{
"summary": "按说说编号获取完整动态",
"params": {
"tid": "1127000000f1536501000000"
},
"notes": "将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。"
}
],
"readOnly": true,
"params": [
{
"name": "tid",
"type": "string",
"required": true,
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 24
},
"desc": "24 位十六进制说说编号"
}
],
"invariants": [
"tid must be a 24-character hexadecimal post ID"
],
"inputSchema": {
"type": "object",
"properties": {
"tid": {
"type": "string",
"minLength": 1,
"maxLength": 24,
"description": "24 位十六进制说说编号"
}
},
"required": [
"tid"
],
"additionalProperties": true
},
"category": "空间"
},
{
"name": "get_qzone_feeds",
"aliases": [],
@@ -12385,7 +12455,7 @@
}
}
},
"notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。"
"notes": "feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。"
}
],
"readOnly": false,
@@ -12398,7 +12468,7 @@
"type": "object",
"additionalProperties": true
},
"desc": "主页或推荐列表返回的完整动态,保留所有原始字段"
"desc": "主页、详情或推荐列表返回的完整动态,保留所有原始字段"
}
],
"invariants": [],
@@ -12408,7 +12478,7 @@
"feed": {
"type": "object",
"additionalProperties": true,
"description": "主页或推荐列表返回的完整动态,保留所有原始字段"
"description": "主页、详情或推荐列表返回的完整动态,保留所有原始字段"
}
},
"required": [
@@ -20953,7 +21023,7 @@
},
{
"category": "空间",
"count": 28
"count": 29
},
{
"category": "系统表情",
+74 -4
View File
@@ -11244,6 +11244,76 @@ export const ACTIONS: CatalogAction[] = [
},
"category": "群相册"
},
{
"name": "get_qzone_feed_detail",
"aliases": [],
"summary": "按说说 tid 读取完整空间动态,可用于生成分享卡片",
"returns": "完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。",
"returnsSchema": {
"type": "object",
"additionalProperties": true,
"properties": {
"feed": {
"type": "object",
"additionalProperties": true,
"description": "去除上游包装后的完整动态,可原样传给 get_qzone_share_ark"
},
"attach_info": {
"type": [
"string",
"null"
],
"description": "上游评论续页游标,未提供时为 null;不保证评论及回复完整"
}
},
"required": [
"feed",
"attach_info"
]
},
"examples": [
{
"summary": "按说说编号获取完整动态",
"params": {
"tid": "1127000000f1536501000000"
},
"notes": "将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。"
}
],
"readOnly": true,
"params": [
{
"name": "tid",
"type": "string",
"required": true,
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 24
},
"desc": "24 位十六进制说说编号"
}
],
"invariants": [
"tid must be a 24-character hexadecimal post ID"
],
"inputSchema": {
"type": "object",
"properties": {
"tid": {
"type": "string",
"minLength": 1,
"maxLength": 24,
"description": "24 位十六进制说说编号"
}
},
"required": [
"tid"
],
"additionalProperties": true
},
"category": "空间"
},
{
"name": "get_qzone_feeds",
"aliases": [],
@@ -12388,7 +12458,7 @@ export const ACTIONS: CatalogAction[] = [
}
}
},
"notes": "feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。"
"notes": "feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。"
}
],
"readOnly": false,
@@ -12401,7 +12471,7 @@ export const ACTIONS: CatalogAction[] = [
"type": "object",
"additionalProperties": true
},
"desc": "主页或推荐列表返回的完整动态,保留所有原始字段"
"desc": "主页、详情或推荐列表返回的完整动态,保留所有原始字段"
}
],
"invariants": [],
@@ -12411,7 +12481,7 @@ export const ACTIONS: CatalogAction[] = [
"feed": {
"type": "object",
"additionalProperties": true,
"description": "主页或推荐列表返回的完整动态,保留所有原始字段"
"description": "主页、详情或推荐列表返回的完整动态,保留所有原始字段"
}
},
"required": [
@@ -20957,7 +21027,7 @@ export const CATEGORIES: CatalogCategory[] = [
},
{
"category": "空间",
"count": 28
"count": 29
},
{
"category": "系统表情",
+43 -2
View File
@@ -79,6 +79,45 @@ export const actions = [
run: async (p, ctx) =>
okResponse(await ctx.bridge.apis.qzone.getHomeFeeds(p.target_uin, p.cursor)),
}),
defineAction({
name: 'get_qzone_feed_detail',
summary: '按说说 tid 读取完整空间动态,可用于生成分享卡片',
readOnly: true,
examples: [
{
summary: '按说说编号获取完整动态',
params: { tid: '1127000000f1536501000000' },
notes: '将示例 tid 替换为 get_qzone_msg_list 或 send_qzone_msg 返回的 24 位十六进制说说编号。把结果的 feed 原样传给 get_qzone_share_ark;读取和分享均受空间权限限制。',
},
],
returns:
'完整动态及上游详情字段。feed 保留图片、视频、分享信息和未知字段,可直接用于 get_qzone_share_ark;attach_info 保留上游评论游标,不自动翻页。',
returnsSchema: {
type: 'object',
additionalProperties: true,
properties: {
feed: {
type: 'object',
additionalProperties: true,
description: '去除上游包装后的完整动态,可原样传给 get_qzone_share_ark',
},
attach_info: {
type: ['string', 'null'],
description: '上游评论续页游标,未提供时为 null;不保证评论及回复完整',
},
},
required: ['feed', 'attach_info'],
},
params: {
tid: f.string({ allowEmpty: false, maxLen: 24 }).describe('24 位十六进制说说编号'),
},
rules: (r) => [
r.rule('tid must be a 24-character hexadecimal post ID', (p) =>
/^[0-9a-f]{24}$/i.test(p.tid),
),
],
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getFeedDetail(p.tid)),
}),
defineAction({
name: 'get_qzone_share_ark',
summary: '生成空间动态分享卡片,返回原文及分享提示',
@@ -100,7 +139,7 @@ export const actions = [
},
},
},
notes: 'feed 原样取自 get_qzone_home_feeds 或 get_qzone_public_feeds。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。',
notes: 'feed 原样取自 get_qzone_feed_detail 的 feed,或 get_qzone_home_feeds / get_qzone_public_feeds 的 feeds 条目。分享链接可能允许未登录者访问并包含作者 QQ 号;如 requires_confirmation=true,发送前展示 notice 并取得确认。将 ark 原文作为 JSON 消息发送,不要修改卡片内容。',
},
],
returns: '生成卡片但不发送消息;后续发送仍返回普通消息编号。',
@@ -116,7 +155,9 @@ export const actions = [
},
required: ['ark', 'requires_confirmation', 'notice'],
},
params: { feed: f.object().describe('主页或推荐列表返回的完整动态,保留所有原始字段') },
params: {
feed: f.object().describe('主页、详情或推荐列表返回的完整动态,保留所有原始字段'),
},
run: async (p, ctx) => okResponse(await ctx.bridge.apis.qzone.getShareArk(p.feed)),
}),
defineAction({
@@ -40,6 +40,13 @@ const cases: {
args: [10001, 'cursor'],
readOnly: true,
},
{
action: 'get_qzone_feed_detail',
method: 'getFeedDetail',
params: { tid: '1127000000f1536501000000' },
args: ['1127000000f1536501000000'],
readOnly: true,
},
{
action: 'get_qzone_share_ark',
method: 'getShareArk',
@@ -317,6 +324,13 @@ describe('qzone public actions', () => {
);
it.each([
{ action: 'get_qzone_feed_detail', params: {} },
{ action: 'get_qzone_feed_detail', params: { tid: '' } },
{ action: 'get_qzone_feed_detail', params: { tid: 'T1' } },
{ action: 'get_qzone_feed_detail', params: { tid: [] } },
{ action: 'get_qzone_feed_detail', params: { tid: 'g'.repeat(24) } },
{ action: 'get_qzone_feed_detail', params: { tid: 'a'.repeat(25) } },
{ action: 'get_qzone_feed_detail', params: { tid: '1127000000f1536501000000\n' } },
{ action: 'get_qzone_public_feeds', params: { cursor: '' } },
{ action: 'get_qzone_public_feeds', params: { cursor: 'a'.repeat(65537) } },
{ action: 'get_qzone_public_feed_detail', params: {} },
+4
View File
@@ -20,6 +20,10 @@
"types": "./src/collection.ts",
"default": "./src/collection.ts"
},
"./qzone": {
"types": "./src/qzone.ts",
"default": "./src/qzone.ts"
},
"./dataline": {
"types": "./src/dataline.ts",
"default": "./src/dataline.ts"
+1
View File
@@ -22,6 +22,7 @@
export type * from './action.js';
export type * from './collection.js';
export type * from './qzone.js';
export type * from './element.js';
// NOTE: `highway.ts` intentionally NOT re-exported here — see note above.
export type * from './longmsg.js';
+13
View File
@@ -0,0 +1,13 @@
import type { int_64, pb, pb_optional } from '@snowluma/proton';
/** Personal-space emotion identity carried in GetFeedDetail's base64 ugc_id. */
export interface QzoneEmotionUgcId {
appId: pb<1, int_64>;
uin: pb<2, int_64>;
publishTime: pb<3, int_64>;
// These empty fields have explicit presence in the upstream identifier.
field4: pb_optional<4, string>;
field5: pb_optional<5, string>;
field6: pb<6, string>;
tid: pb<8, string>;
}
+67 -1
View File
@@ -1,4 +1,6 @@
import type { JsonObject } from '@snowluma/common/json';
import type { QzoneEmotionUgcId } from '@snowluma/proto-defs/qzone';
import { protobuf_encode } from '@snowluma/proton';
import { RequestUtil, cookieToString, getBknFromCookie } from './request-util';
export type QzonePublicContent = string | JsonObject[];
@@ -15,6 +17,13 @@ export interface QzoneShareArk extends JsonObject {
notice: string | null;
}
export interface QzoneFeedDetail extends JsonObject {
/** Original cells, ready to pass to getQzoneShareArk when sharing is allowed. */
feed: JsonObject;
/** Upstream comment continuation; null when the server supplies none. */
attach_info: string | null;
}
interface HomeFeedCursor {
version: 1;
kind: 'home';
@@ -238,6 +247,63 @@ export async function getQzoneHomeFeeds(
return { ...data, feeds, has_more: data.has_more, next_cursor: nextCursor };
}
export async function getQzoneFeedDetail(
cookies: Record<string, string>,
tid: string,
): Promise<QzoneFeedDetail> {
if (tid.length !== 24 || !/^[0-9a-f]{24}$/i.test(tid)) {
throw new Error('空间说说 tid 必须是 24 位十六进制编号');
}
const feedId = tid.toLowerCase();
const identity = Buffer.from(feedId, 'hex');
// Personal-space tids carry the author and creation time as little-endian
// uint32 words. Use the actual time rather than guessing an ignored field.
const ugcId = protobuf_encode<QzoneEmotionUgcId>({
appId: 311n,
uin: BigInt(identity.readUInt32LE(0)),
publishTime: BigInt(identity.readUInt32LE(4)),
field4: '',
field5: '',
field6: '0',
tid: feedId,
});
const data = await request(cookies, reader, 'GetFeedDetail', '0x97b1_1', {
union_id: {
feed_id: feedId,
ugc_id: Buffer.from(ugcId).toString('base64'),
adv_id: null,
},
feed_action_domain_type: 1,
from_page: 1,
});
const wrapper = data.feed;
if (!isObject(wrapper) || !isObject(wrapper.feed)) {
throw new Error('qzone GetFeedDetail 响应缺少完整动态 feed.feed');
}
const feed = wrapper.feed;
const common = feed.cell_common;
const unionId = isObject(common) ? common.union_id : undefined;
if (
!isObject(unionId) ||
typeof unionId.feed_id !== 'string' ||
unionId.feed_id.toLowerCase() !== feedId
) {
throw new Error('qzone GetFeedDetail 返回的动态编号与请求不符');
}
if (data.attach_info != null && typeof data.attach_info !== 'string') {
throw new Error('qzone GetFeedDetail 评论游标格式异常');
}
// Match homepage feeds: remove only the item wrapper, retaining every cell
// (especially the opaque sharing fields) and the remaining response metadata.
return { ...data, feed, attach_info: data.attach_info ?? null };
}
export async function getQzoneShareArk(
cookies: Record<string, string>,
feed: JsonObject,
@@ -272,7 +338,7 @@ export async function getQzoneShareArk(
typeof feed.cell_feed_transfer !== 'string' ||
!feed.cell_feed_transfer
) {
throw new Error('空间动态缺少分享信息,请传入主页或推荐列表返回的完整动态');
throw new Error('空间动态缺少分享信息,请传入主页、详情或推荐列表返回的完整动态');
}
const data = await request(cookies, 'trpc.qq.share.Service', 'CreateShare', '0x9409_1', {
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getQzoneFeedDetail, getQzoneShareArk } from '../../src/web/qzone-public';
import { RequestUtil, getBknFromCookie } from '../../src/web/request-util';
const cookies = { p_skey: 'fixture', uin: 'o20002' };
// Synthetic post: author 10001, Unix time 1700000000, sequence word 1.
const tid = '1127000000f1536501000000';
const ugcId = 'CLcCEJFOGIDiz6oGIgAqADIBMEIYMTEyNzAwMDAwMGYxNTM2NTAxMDAwMDAw';
const feed = {
cell_common: { domain_type: 1, union_id: { feed_id: tid, ugc_id: ugcId }, operate_mask: 0 },
cell_summary: { summary: 'Fixture post' },
cell_pic: { pics: [{ url: 'https://example.com/one.png' }, { future_image: true }] },
cell_video: { opaque_video: 'preserved' },
cell_comment: { comments: [{ id: '9007199254740993', unknown: true }] },
cell_operation: {
share_outside: {
share_access_resource: { busi_id: 'qzone_feed', resource_id: 'resource' },
},
},
cell_feed_transfer: 'original-transfer',
cell_future: { nested: [null, '18446744073709551615', { preserved: true }] },
};
const detail = {
feed: { feed },
attach_info: 'opaque-comment-continuation',
contribute_bar: null,
future_detail_field: { preserved: true },
};
afterEach(() => vi.restoreAllMocks());
describe('Qzone feed detail by tid', () => {
it.each([tid, tid.toUpperCase()])(
'requests the personal-space detail with the encoded identity (%s)',
async (input) => {
const request = vi
.spyOn(RequestUtil, 'HttpGetJson')
.mockResolvedValue({ retcode: 0, data: detail });
const result = await getQzoneFeedDetail(cookies, input);
expect(result).toEqual({ ...detail, feed });
expect(result.feed).toBe(feed);
expect(request).toHaveBeenCalledExactlyOnceWith(
`https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.qzone.feeds_reader.FeedsReader/GetFeedDetail?g_tk=${getBknFromCookie(cookies)}`,
'POST',
{
union_id: { feed_id: tid, ugc_id: ugcId, adv_id: null },
feed_action_domain_type: 1,
from_page: 1,
},
expect.objectContaining({
Cookie: 'p_skey=fixture; uin=o20002',
'Content-Type': 'application/json',
'x-oidb': JSON.stringify({
uint32_command: '0x97b1',
uint32_service_type: '1',
}),
}),
);
},
);
it('keeps the author and time unsigned and emits the explicit empty identity fields', async () => {
const highTid = 'ffffffffffffffff01000000';
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
retcode: 0,
data: { feed: { feed: { cell_common: { union_id: { feed_id: highTid } } } } },
});
await getQzoneFeedDetail(cookies, highTid);
const body = request.mock.calls[0]![2] as { union_id: { ugc_id: string } };
// Independent wire vector: app 311; two uint32-max values encoded as
// positive int64; present empty strings in fields 4 and 5; field 8 tid.
expect(Buffer.from(body.union_id.ugc_id, 'base64').toString('hex')).toBe(
'08b70210ffffffff0f18ffffffff0f22002a003201304218' +
'666666666666666666666666666666663031303030303030',
);
});
it.each([undefined, null, ''])(
'preserves an empty or absent upstream comment cursor (%s)',
async (attach_info) => {
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
retcode: 0,
data: { ...detail, attach_info },
});
expect((await getQzoneFeedDetail(cookies, tid)).attach_info).toBe(attach_info ?? null);
expect(request).toHaveBeenCalledOnce();
},
);
it.each(['', 'T1', 'g'.repeat(24), 'a'.repeat(23), 'a'.repeat(25), `${tid}\n`, ` ${tid}`])(
'rejects unsupported or malformed post IDs before requesting (%j)',
async (input) => {
const request = vi.spyOn(RequestUtil, 'HttpGetJson');
await expect(getQzoneFeedDetail(cookies, input)).rejects.toThrow('24 位十六进制');
expect(request).not.toHaveBeenCalled();
},
);
it.each([
{},
{ feed: null },
{ feed: [] },
{ feed: { feed: [] } },
{ feed: { feed: {} } },
{ feed: { feed: { cell_common: { union_id: { feed_id: 'other' } } } } },
{ ...detail, attach_info: {} },
])('rejects incomplete, mismatched or malformed details (%#)', async (data) => {
const request = vi
.spyOn(RequestUtil, 'HttpGetJson')
.mockResolvedValue({ retcode: 0, data });
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('GetFeedDetail');
expect(request).toHaveBeenCalledOnce();
});
it('propagates access and transport failures without a second request', async () => {
const request = vi
.spyOn(RequestUtil, 'HttpGetJson')
.mockResolvedValueOnce({ retcode: 10023, message: '无权限访问' })
.mockRejectedValueOnce(new Error('network down'));
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('10023 无权限访问');
expect(request).toHaveBeenCalledTimes(1);
await expect(getQzoneFeedDetail(cookies, tid)).rejects.toThrow('network down');
expect(request).toHaveBeenCalledTimes(2);
});
it('passes the returned feed straight to card generation without rebuilding sharing data', async () => {
const ark = '{ "app": "com.tencent.tuwen.lua", "fixture": true }';
const request = vi
.spyOn(RequestUtil, 'HttpGetJson')
.mockResolvedValueOnce({ retcode: 0, data: detail })
.mockResolvedValueOnce({ retcode: 0, data: { template_data: ark } });
const result = await getQzoneFeedDetail(cookies, tid);
await expect(getQzoneShareArk(cookies, result.feed)).resolves.toEqual({
ark,
requires_confirmation: false,
notice: null,
});
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(
2,
expect.stringContaining('/trpc.qq.share.Service/CreateShare?g_tk='),
'POST',
{
info: {
busi_id: 'qzone_feed',
resource_id: 'resource',
template: 'ark_busi_select',
busi_data: 'original-transfer',
plat: 1,
target: 3,
},
},
expect.any(Object),
);
});
it('can read a share-restricted feed while preserving the share refusal', async () => {
const restrictedFeed = {
...feed,
cell_common: { ...feed.cell_common, operate_mask: 1 << 17 },
};
const request = vi.spyOn(RequestUtil, 'HttpGetJson').mockResolvedValue({
retcode: 0,
data: { feed: { feed: restrictedFeed } },
});
const result = await getQzoneFeedDetail(cookies, tid);
expect(result.feed).toBe(restrictedFeed);
await expect(getQzoneShareArk(cookies, result.feed)).rejects.toThrow('不支持分享');
expect(request).toHaveBeenCalledOnce();
});
});
+11 -1
View File
@@ -185,7 +185,17 @@ for (const comment of detail.comments) {
`get_qzone_home_feeds` 读取自己的空间主页,可传 `target_uin` 访问有权限的其他主页;`cursor` 使用同一账号、同一主页上次返回的 `next_cursor`。它返回完整动态,旧版 `get_qzone_feeds` 和 `get_qzone_msg_list` 保持原有格式。
把主页或推荐列表的完整动态传给 `get_qzone_share_ark`,可获得 `{ ark, requires_confirmation, notice }`。此动作会生成分享卡片但不发送消息;将 `ark` 原文交给普通 JSON 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。
已有说说编号时,可调用 `get_qzone_feed_detail`,传入 `get_qzone_msg_list` 或 `send_qzone_msg` 返回的 24 位十六进制 `tid`。此接口读取普通空间说说,自动从编号取得作者和发布时间,不需要 `target_uin`;访问仍使用当前账号的权限。结果的 `feed` 是单个完整动态对象,保留图片、视频、分享信息和未知字段,无需再取 `.feed.feed`。其余上游详情字段也会保留,`attach_info` 为上游评论游标(未提供时为 `null`);只读取一次详情,不自动翻页,也不保证评论和回复完整。旧的 `get_qzone_msg_detail` 及其 `pos/num` 分页保持不变。
```ts
const detail = await bot.raw('get_qzone_feed_detail', {
tid: '1127000000f1536501000000', // 替换为实际说说编号
});
const card = await bot.raw('get_qzone_share_ark', { feed: detail.feed });
// 若 card.requires_confirmation 为 true,发送前展示 card.notice 并取得确认。
```
把详情、主页或推荐列表的完整动态传给 `get_qzone_share_ark`,可获得 `{ ark, requires_confirmation, notice }`。此动作会生成分享卡片但不发送消息;将 `ark` 原文交给普通 JSON 消息发送接口即可获得消息编号,继续使用已有撤回及发送事件能力。能读取不代表允许分享:分享受限或缺少上游分享信息时,分享接口会明确报错。
分享链接可能让未登录者也能访问动态,并包含作者 QQ 号。若 `requires_confirmation` 为 `true`,发送前应展示 `notice` 并取得确认;上游禁止分享或生成失败会返回错误。
+1
View File
@@ -185,6 +185,7 @@ export const SNOWLUMA_ACTIONS = [
'get_qun_album_list',
'get_qzone_feeds',
'get_qzone_home_feeds',
'get_qzone_feed_detail',
'get_qzone_msg_detail',
'get_qzone_msg_list',
'get_qzone_profile',
+3
View File
@@ -104,6 +104,7 @@ import type {
GetQzoneProfileParams,
GetQzonePublicFeedsParams,
GetQzoneHomeFeedsParams,
GetQzoneFeedDetailParams,
GetQzoneShareArkParams,
GetQzonePublicCommentsParams,
GetQzonePublicRepliesParams,
@@ -205,6 +206,7 @@ import type {
QzonePublishResult,
QzoneProfileResult,
QzonePublicFeedsPage,
QzoneFeedDetailResult,
QzoneShareArkResult,
QzoneUpdateRightResult,
RkeyServerResult,
@@ -428,6 +430,7 @@ export interface SnowLumaActionMap {
get_qzone_public_capabilities: { params: JsonObject; data: JsonObject };
get_qzone_public_feeds: { params: GetQzonePublicFeedsParams; data: QzonePublicFeedsPage };
get_qzone_home_feeds: { params: GetQzoneHomeFeedsParams; data: QzonePublicFeedsPage };
get_qzone_feed_detail: { params: GetQzoneFeedDetailParams; data: QzoneFeedDetailResult };
get_qzone_share_ark: { params: GetQzoneShareArkParams; data: QzoneShareArkResult };
get_qzone_public_feed_detail: { params: QzonePublicFeedParams; data: JsonObject };
get_qzone_public_comments: { params: GetQzonePublicCommentsParams; data: JsonObject };
+6 -1
View File
@@ -633,8 +633,13 @@ export interface GetQzoneHomeFeedsParams extends JsonObject {
cursor?: string;
}
export interface GetQzoneFeedDetailParams extends JsonObject {
/** 24 hexadecimal characters from get_qzone_msg_list or send_qzone_msg. */
tid: string;
}
export interface GetQzoneShareArkParams extends JsonObject {
/** Original feed from get_qzone_home_feeds or get_qzone_public_feeds. */
/** Original feed from get_qzone_feed_detail, get_qzone_home_feeds or get_qzone_public_feeds. */
feed: JsonObject;
}
+7
View File
@@ -422,6 +422,13 @@ export interface QzonePublicFeedsPage extends JsonObject {
next_cursor: string | null;
}
export interface QzoneFeedDetailResult extends JsonObject {
/** Original cells, suitable for get_qzone_share_ark when sharing is permitted. */
feed: JsonObject;
/** Upstream comment continuation; null when none is supplied. No automatic pagination. */
attach_info: string | null;
}
export interface QzoneShareArkResult extends JsonObject {
/** Preserve this string when sending a JSON message. */
ark: string;
+9
View File
@@ -97,6 +97,7 @@ export const actionParams = {
get_qzone_profile: { target_uin: '10002' },
get_qzone_public_capabilities: {},
get_qzone_home_feeds: { target_uin: '10001', cursor: 'home-feed-cursor' },
get_qzone_feed_detail: { tid: '1127000000f1536501000000' },
get_qzone_share_ark: { feed: qzonePublicFeed },
get_qzone_public_comments: { feed: qzonePublicFeed, cursor: 'comments-from-detail' },
get_qzone_public_feed_detail: { feed: qzonePublicFeed },
@@ -232,6 +233,7 @@ export type CoveredAction =
| 'get_qzone_profile'
| 'get_qzone_public_capabilities'
| 'get_qzone_home_feeds'
| 'get_qzone_feed_detail'
| 'get_qzone_share_ark'
| 'get_qzone_public_comments'
| 'get_qzone_public_feed_detail'
@@ -469,6 +471,13 @@ export const actionResults = {
get_qzone_profile: { name: 'Space', description: 'Description' },
get_qzone_public_capabilities: { public_qzone_entrance_switch: true, can_upvote: true },
get_qzone_home_feeds: { feeds: [qzonePublicFeed], has_more: false, next_cursor: null },
get_qzone_feed_detail: {
feed: {
cell_common: { domain_type: 1, union_id: { feed_id: '1127000000f1536501000000' } },
cell_pic: { future_image_field: true },
},
attach_info: 'comments-from-detail',
},
get_qzone_share_ark: { ark: '{"app":"com.tencent.tuwen.lua"}', requires_confirmation: false, notice: null },
get_qzone_public_comments: {
cell_comment: { comments: [qzonePublicComment] },
+9
View File
@@ -164,6 +164,15 @@ export const invalidQzoneProfilePatch: ActionParams<'set_qzone_profile'> = {
// @ts-expect-error profile fields accept string, number or boolean primitives
description: [],
};
const feedDetail = await http.raw('get_qzone_feed_detail', actionParams.get_qzone_feed_detail);
export const fullQzoneFeed: JsonObject = feedDetail.feed;
export const feedDetailCursor: string | null = feedDetail.attach_info;
export const feedDetailCard = await http.raw('get_qzone_share_ark', { feed: feedDetail.feed });
// @ts-expect-error querying a feed requires a post ID
http.raw('get_qzone_feed_detail', {});
// @ts-expect-error the post ID is hexadecimal text, not an array
http.raw('get_qzone_feed_detail', { tid: [] });
const publicPage = await http.raw('get_qzone_public_feeds', {});
export const publicCells: JsonObject[] = publicPage.feeds;
export const publicHasMore: boolean = publicPage.has_more;