@@ -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: '所有人可见' };
|
||||
|
||||
@@ -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": "系统表情",
|
||||
|
||||
@@ -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": "系统表情",
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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` 并取得确认;上游禁止分享或生成失败会返回错误。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user