@sns-parse/core 0.4.0-alpha.1 → 0.4.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface ConfigField {
22
22
  /** koishi 的 role:多行文本 / 密钥 */
23
23
  role?: 'textarea' | 'secret';
24
24
  hidden?: boolean;
25
+ /** 宿主 Schema 生成时的必填标记(仅 UI 层语义) */
26
+ required?: boolean;
25
27
  min?: number;
26
28
  max?: number;
27
29
  step?: number;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 引擎层配置声明(中立 DSL):fetcher/parser/compose/sender/gateway 等引擎
3
+ * 自身读取的配置项,由 core 统一声明;koishi / CLI 兼容层据此生成 Schema 与默认值。
4
+ *
5
+ * 组序即 UI 展示序(koishi 兼容层在「发送策略」后插入扩展与平台的动态声明)。
6
+ */
7
+ import { type ConfigContribution } from '../config';
8
+ /** 引擎层配置声明(有序;组名即 UI 分组标题) */
9
+ export declare function engineConfigContributions(): ConfigContribution[];
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.engineConfigContributions = engineConfigContributions;
4
+ /**
5
+ * 引擎层配置声明(中立 DSL):fetcher/parser/compose/sender/gateway 等引擎
6
+ * 自身读取的配置项,由 core 统一声明;koishi / CLI 兼容层据此生成 Schema 与默认值。
7
+ *
8
+ * 组序即 UI 展示序(koishi 兼容层在「发送策略」后插入扩展与平台的动态声明)。
9
+ */
10
+ const config_1 = require("../config");
11
+ const PROXY_FIELDS = [
12
+ { key: 'enabled', type: 'boolean', default: false, description: '启用代理' },
13
+ {
14
+ key: 'protocol', type: 'union', default: 'http', description: '协议',
15
+ values: [
16
+ { value: 'http', description: 'HTTP' },
17
+ { value: 'https', description: 'HTTPS' },
18
+ ],
19
+ },
20
+ { key: 'host', type: 'string', default: '127.0.0.1', description: '地址' },
21
+ { key: 'port', type: 'number', default: 7890, description: '端口' },
22
+ {
23
+ key: 'auth', type: 'object', description: '认证',
24
+ fields: [
25
+ { key: 'username', type: 'string', default: '', description: '用户名' },
26
+ { key: 'password', type: 'string', default: '', description: '密码' },
27
+ ],
28
+ },
29
+ ];
30
+ const AUTH_HEADER_FIELDS = [
31
+ {
32
+ key: 'authHeaderType', type: 'union', default: 'Bearer', description: '认证头类型',
33
+ values: [
34
+ { value: 'Bearer', description: 'Bearer' },
35
+ { value: 'X-API-Key', description: 'X-API-Key' },
36
+ { value: 'Custom', description: '自定义' },
37
+ ],
38
+ },
39
+ { key: 'customHeaderName', type: 'string', default: 'X-API-Key', description: '自定义头名称' },
40
+ ];
41
+ const FIELD_MAPPING_FIELD = {
42
+ key: 'fieldMapping', type: 'string', role: 'textarea',
43
+ default: '{}', description: '字段映射 JSON',
44
+ };
45
+ const API_PLATFORM_VALUES = [
46
+ { value: 'bilibili', description: '哔哩哔哩' },
47
+ { value: 'douyin', description: '抖音' },
48
+ { value: 'kuaishou', description: '快手' },
49
+ { value: 'xiaohongshu', description: '小红书' },
50
+ { value: 'weibo', description: '微博' },
51
+ { value: 'xigua', description: '西瓜视频' },
52
+ { value: 'youtube', description: 'YouTube' },
53
+ { value: 'tiktok', description: 'TikTok' },
54
+ { value: 'acfun', description: 'AcFun(A站)' },
55
+ { value: 'zhihu', description: '知乎' },
56
+ { value: 'weishi', description: '微视' },
57
+ { value: 'huya', description: '虎牙' },
58
+ { value: 'haokan', description: '好看视频' },
59
+ { value: 'meipai', description: '美拍' },
60
+ { value: 'twitter', description: 'Twitter/X' },
61
+ { value: 'instagram', description: 'Instagram' },
62
+ { value: 'doubao', description: '豆包' },
63
+ { value: 'doubao_image', description: '豆包图片' },
64
+ { value: 'jimeng', description: '即梦' },
65
+ { value: 'oasis', description: '绿洲' },
66
+ { value: 'wechat_channel', description: '视频号' },
67
+ { value: 'lishi', description: '梨视频' },
68
+ { value: 'quanmin', description: '全民直播' },
69
+ { value: 'pipigx', description: '皮皮搞笑' },
70
+ { value: 'pipixia', description: '皮皮虾' },
71
+ { value: 'zuiyou', description: '最右' },
72
+ { value: 'toutiao', description: '今日头条' },
73
+ ];
74
+ const GLOBAL_FIELD_MAPPING_DEFAULT = [
75
+ '{',
76
+ ' "title": "data.title",',
77
+ ' "desc": "data.description",',
78
+ ' "author": "data.author.name",',
79
+ ' "uid": "data.author.id",',
80
+ ' "avatar": "data.author.avatar",',
81
+ ' "cover": "data.cover_url",',
82
+ ' "video": "data.video_url",',
83
+ ' "video_backup": "data.video_qualities",',
84
+ ' "videos": "data.videos",',
85
+ ' "type": "data.type",',
86
+ ' "like": "data.statistics.likes",',
87
+ ' "comment": "data.statistics.comments",',
88
+ ' "collect": "data.statistics.favorites",',
89
+ ' "share": "data.statistics.shares",',
90
+ ' "play": "data.statistics.plays",',
91
+ ' "duration": "data.duration",',
92
+ ' "publishTime": "data.create_time",',
93
+ ' "music_title": "data.music.title",',
94
+ ' "music_author": "data.music.author",',
95
+ ' "music_cover": "data.music.cover",',
96
+ ' "music_url": "data.music.url"',
97
+ '}',
98
+ ].join('\n');
99
+ /** 引擎层配置声明(有序;组名即 UI 分组标题) */
100
+ function engineConfigContributions() {
101
+ return [
102
+ (0, config_1.defineConfig)({
103
+ group: '消息格式',
104
+ fields: [
105
+ {
106
+ key: 'unifiedMessageFormat', type: 'string', role: 'textarea',
107
+ default: '标题:${标题}\n作者:${作者}\n简介:${简介}\n正文:${正文}\n翻译(${翻译提供方},${原文语言}):${翻译}\n音乐标题:${音乐标题}\n音乐作者:${音乐作者}\n点赞:${点赞数}\n收藏:${收藏数}\n转发:${转发数}\n播放:${播放数}\n评论:${评论数}\n图片数量:${图片数量}',
108
+ description: '消息文字模板,可用变量:${标题} ${作者} ${简介} ${正文}(纯文字作品正文,启用后该类作品不再显示为简介) ${翻译} ${翻译提供方} ${原文语言} ${视频时长} ${点赞数} ${收藏数} ${转发数} ${播放数} ${评论数} ${发布时间} ${图片数量} ${作者ID} ${音乐标题} ${音乐作者}(空行自动隐藏)',
109
+ },
110
+ ],
111
+ }),
112
+ (0, config_1.defineConfig)({
113
+ group: '媒体发送',
114
+ fields: [
115
+ { key: 'showImageText', type: 'boolean', default: true, description: '发送文字内容' },
116
+ { key: 'showCoverImage', type: 'boolean', default: true, description: '发送封面图片' },
117
+ { key: 'showCoverFile', type: 'boolean', default: true, description: '封面以图片形式发送(关闭则只发链接)' },
118
+ { key: 'showCoverText', type: 'boolean', default: true, description: '封面图前显示文字提示' },
119
+ { key: 'coverText', type: 'string', default: '封面:', description: '封面提示文字' },
120
+ { key: 'showImageFileNew', type: 'boolean', default: true, description: '图片以图片形式发送(关闭则只发链接)' },
121
+ { key: 'showAuthorAvatar', type: 'boolean', default: true, description: '发送作者头像' },
122
+ { key: 'showAuthorAvatarFile', type: 'boolean', default: true, description: '头像以图片形式发送(关闭则只发链接)' },
123
+ { key: 'showAuthorAvatarText', type: 'boolean', default: true, description: '头像前显示文字提示' },
124
+ { key: 'authorAvatarText', type: 'string', default: '作者头像:', description: '头像提示文字' },
125
+ { key: 'showMusicCover', type: 'boolean', default: true, description: '发送音乐封面' },
126
+ { key: 'showVideoFile', type: 'boolean', default: true, description: '视频以视频形式发送(关闭则只发链接)' },
127
+ { key: 'sendLiveMessage', type: 'boolean', default: true, description: '直播作品发文字消息(不发视频)' },
128
+ { key: 'mergeSameOriginImages', type: 'boolean', default: true, description: '同源切图内容识别合并(纯像素内容裁决,不依赖链接/文件名/色调风格):网格/竖堆/横拼三种布局各自经接缝连续性验证(低频趋势延续+纹理能量可验证性),取证据最强者合并;均不过回退逐张;需 ffmpeg' },
129
+ ],
130
+ }),
131
+ (0, config_1.defineConfig)({
132
+ group: '音乐语音(需 silk 和 ffmpeg)',
133
+ fields: [
134
+ { key: 'showMusicVoice', type: 'boolean', default: false, description: '音乐链接以语音形式发送' },
135
+ { key: 'showMusicVoiceFile', type: 'boolean', default: true, description: '音乐是否以语音形式发送(关闭则只发送链接)' },
136
+ ],
137
+ }),
138
+ (0, config_1.defineConfig)({
139
+ group: 'GIF 转换',
140
+ fields: [
141
+ { key: 'gifConvertEnabled', type: 'boolean', default: true, description: '推文动图转 GIF 发送(ffmpeg 随插件自动安装,失败回退原视频)' },
142
+ { key: 'gifMaxWidth', type: 'number', min: 120, max: 1080, step: 1, default: 480, description: 'GIF 宽度 (px)' },
143
+ { key: 'gifFps', type: 'number', min: 5, max: 30, step: 1, default: 15, description: 'GIF 帧率' },
144
+ { key: 'gifMaxDurationSec', type: 'number', min: 1, max: 60, step: 1, default: 15, description: 'GIF 时长上限 (s)' },
145
+ ],
146
+ }),
147
+ (0, config_1.defineConfig)({
148
+ group: '性能与限制',
149
+ fields: [
150
+ { key: 'maxDescLength', type: 'number', min: 0, step: 1, default: 200, description: '简介长度上限' },
151
+ { key: 'maxConcurrent', type: 'number', min: 1, step: 1, default: 3, description: '解析最大并发数' },
152
+ ],
153
+ }),
154
+ (0, config_1.defineConfig)({
155
+ group: '发送策略',
156
+ fields: [
157
+ {
158
+ key: 'sendStrategy', type: 'union', default: 'single', description: '发送策略',
159
+ values: [
160
+ { value: 'single', description: '单条整合(默认;图片过多/含视频自动回退合并转发)' },
161
+ { value: 'split', description: '逐条分开发送(旧版行为)' },
162
+ ],
163
+ },
164
+ { key: 'singleSendMaxImages', type: 'number', min: 1, default: 10, description: '单条消息最大图片数(超出回退合并转发)' },
165
+ ],
166
+ }),
167
+ (0, config_1.defineConfig)({
168
+ group: '网络与请求',
169
+ fields: [
170
+ { key: 'timeout', type: 'number', min: 0, step: 1, default: 180000, description: 'API 请求超时 (ms)' },
171
+ { key: 'videoSendTimeout', type: 'number', min: 0, step: 1, default: 180000, description: '消息发送超时 (ms)' },
172
+ { key: 'userAgent', type: 'string', default: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', description: 'User-Agent' },
173
+ { key: 'proxy', type: 'object', description: 'HTTP/HTTPS 代理', fields: PROXY_FIELDS },
174
+ {
175
+ key: 'customHeaders', type: 'array', default: [], description: '自定义请求头',
176
+ itemFields: [
177
+ { key: 'name', type: 'string', required: true, description: '头名称' },
178
+ { key: 'value', type: 'string', required: true, description: '头值' },
179
+ ],
180
+ },
181
+ ],
182
+ }),
183
+ (0, config_1.defineConfig)({
184
+ group: '发送与重试',
185
+ fields: [
186
+ { key: 'ignoreSendError', type: 'boolean', default: true, description: '忽略发送失败' },
187
+ { key: 'retryTimes', type: 'number', min: 0, default: 3, description: '重试次数' },
188
+ { key: 'retryInterval', type: 'number', min: 0, default: 1000, description: '重试间隔 (ms)' },
189
+ { key: 'enableForward', type: 'boolean', default: false, description: '合并转发(OneBot/Satori)' },
190
+ ],
191
+ }),
192
+ (0, config_1.defineConfig)({
193
+ group: '缓存与临时文件',
194
+ fields: [
195
+ { key: 'deduplicationInterval', type: 'number', min: 0, default: 180, description: '去重间隔 (s)' },
196
+ { key: 'enableDeduplication', type: 'boolean', default: true, description: '启用重复解析检测与提示' },
197
+ { key: 'cacheTTL', type: 'number', min: 0, default: 600, description: '缓存时间 (s)' },
198
+ ],
199
+ }),
200
+ (0, config_1.defineConfig)({
201
+ group: 'API 与平台',
202
+ fields: [
203
+ { key: 'primaryApiUrl', type: 'string', default: 'https://api.bugpk.com/api/short_videos', hidden: true },
204
+ { key: 'backupApiUrl', type: 'string', default: 'https://api.bugpk.com/api/svparse', hidden: true },
205
+ {
206
+ key: 'customApis', type: 'array', default: [], description: '覆盖内置平台 API',
207
+ itemFields: [
208
+ { key: 'platform', type: 'union', required: true, description: '平台', values: API_PLATFORM_VALUES },
209
+ { key: 'apiUrl', type: 'string', description: 'API 地址' },
210
+ { key: 'apiKey', type: 'string', default: '', description: 'API Key' },
211
+ ...AUTH_HEADER_FIELDS.map(f => ({ ...f })),
212
+ FIELD_MAPPING_FIELD,
213
+ ],
214
+ },
215
+ {
216
+ key: 'customPlatforms', type: 'array', default: [], description: '自定义新平台',
217
+ itemFields: [
218
+ { key: 'name', type: 'string', required: true, description: '平台名称' },
219
+ { key: 'exampleUrl', type: 'string', description: '示例链接' },
220
+ { key: 'keywords', type: 'string', required: true, description: '关键词(逗号分隔)' },
221
+ { key: 'apiUrl', type: 'string', required: true, description: '解析 API' },
222
+ { key: 'apiKey', type: 'string', default: '', description: 'API Key' },
223
+ ...AUTH_HEADER_FIELDS.map(f => ({ ...f })),
224
+ FIELD_MAPPING_FIELD,
225
+ { key: 'proxy', type: 'object', description: '独立代理(覆盖全局代理)', fields: PROXY_FIELDS },
226
+ ],
227
+ },
228
+ {
229
+ key: 'globalFieldMapping', type: 'string', role: 'textarea',
230
+ default: GLOBAL_FIELD_MAPPING_DEFAULT, description: '全局字段映射 JSON',
231
+ },
232
+ { key: 'twitterAuthToken', type: 'string', default: '', role: 'secret', description: 'X 登录态 auth_token(解析需登录推文用,受 Cloudflare 指纹限制可能 403)' },
233
+ { key: 'twitterCt0', type: 'string', default: '', role: 'secret', description: 'X 登录态 ct0(与 auth_token 配对)' },
234
+ { key: 'tweetTranslateEnabled', type: 'boolean', default: true, description: '外语推文自动翻译(配置 X 登录态时用网页同源 Grok 翻译,否则通用翻译;附译文行)' },
235
+ {
236
+ key: 'tweetTranslateLang', type: 'union', default: 'zh', description: '翻译目标语言',
237
+ values: [
238
+ { value: 'zh', description: '简体中文' },
239
+ { value: 'zh-TW', description: '繁體中文' },
240
+ { value: 'en', description: 'English' },
241
+ { value: 'ja', description: '日本語' },
242
+ { value: 'ko', description: '한국어' },
243
+ { value: 'fr', description: 'Français' },
244
+ { value: 'de', description: 'Deutsch' },
245
+ { value: 'ru', description: 'Русский' },
246
+ { value: 'es', description: 'Español' },
247
+ { value: 'pt', description: 'Português' },
248
+ ],
249
+ },
250
+ { key: 'apiKey', type: 'string', default: '', role: 'secret', description: '新网关 API Key(https://api-new.ifphp.com/auth/login 注册获取;留空用旧网关)' },
251
+ ],
252
+ }),
253
+ (0, config_1.defineConfig)({
254
+ group: '界面文本',
255
+ fields: [
256
+ { key: 'waitingTipText', type: 'string', default: '正在解析视频,请稍候...', description: '等待提示' },
257
+ { key: 'unsupportedPlatformText', type: 'string', default: '不支持该平台链接', description: '不支持提示' },
258
+ { key: 'invalidLinkText', type: 'string', default: '无效的视频链接', description: '无效链接提示' },
259
+ { key: 'parseErrorPrefix', type: 'string', default: '❌ 解析失败:', description: '错误前缀' },
260
+ { key: 'parseErrorItemFormat', type: 'string', default: '【${url}】: ${msg}', description: '错误格式' },
261
+ { key: 'deduplicationTipText', type: 'string', default: '链接 ${url} 在最近 ${interval} 秒内已解析过,已跳过。', description: '重复解析提示,支持变量 ${url} ${interval}' },
262
+ ],
263
+ }),
264
+ ];
265
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * X 用户维度查询:时间线(推文/回复/点赞)与关注/粉丝列表。
3
+ *
4
+ * 全部走鉴权 GraphQL(auth_token + ct0,TLS 指纹由 tlsget-rs 提供)。
5
+ * queryId 与特性集等公共件见 ./twitter(DEFAULT_QUERY_IDS 可被调用方覆盖)。
6
+ */
7
+ import type { ParsedData } from '../types';
8
+ import { categorizeTweetResult, DEFAULT_QUERY_IDS, TIMELINE_FEATURES, type GraphqlGetter, type TwitterCreds, type TwitterQueryIds } from './twitter';
9
+ export { categorizeTweetResult, DEFAULT_QUERY_IDS, TIMELINE_FEATURES };
10
+ export type { TwitterQueryIds };
11
+ export interface TwitterUserInfo {
12
+ userId: string;
13
+ name: string;
14
+ screenName: string;
15
+ description: string;
16
+ followers: number;
17
+ friends: number;
18
+ }
19
+ export declare function resolveTwitterUser(screenName: string, creds: TwitterCreds, get?: GraphqlGetter, queryIds?: TwitterQueryIds): Promise<TwitterUserInfo>;
20
+ export type TimelineTab = 'tweets' | 'replies' | 'likes';
21
+ export interface TimelineEntry {
22
+ id: string;
23
+ tweet: ParsedData;
24
+ /** 文字/视频/图片/转推/回复 中的若干类 */
25
+ kinds: ('text' | 'video' | 'image' | 'retweet' | 'reply')[];
26
+ isRetweet: boolean;
27
+ isReply: boolean;
28
+ replyToId?: string;
29
+ url: string;
30
+ }
31
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
32
+ export declare function parseTimeline(instructions: any[]): {
33
+ results: any[];
34
+ bottomCursor?: string;
35
+ };
36
+ /** 用户时间线(tab:tweets=推文/转推;replies=含回复;likes=当前登录用户的点赞) */
37
+ export declare function fetchUserTimeline(opts: {
38
+ screenName?: string;
39
+ userId?: string;
40
+ tab?: TimelineTab;
41
+ limit?: number;
42
+ creds: TwitterCreds;
43
+ get?: GraphqlGetter;
44
+ queryIds?: TwitterQueryIds;
45
+ }): Promise<TimelineEntry[]>;
46
+ export interface TwitterConnectionUser {
47
+ name: string;
48
+ screenName: string;
49
+ description: string;
50
+ followers: number;
51
+ verified: boolean;
52
+ followedBy: boolean;
53
+ }
54
+ /** 纯函数:从 Followers/Following instructions 抽取用户与底部游标 */
55
+ export declare function parseConnections(instructions: any[]): {
56
+ users: TwitterConnectionUser[];
57
+ bottomCursor?: string;
58
+ };
59
+ /** 关注列表(following)或粉丝列表(followers) */
60
+ export declare function fetchUserConnections(opts: {
61
+ screenName: string;
62
+ type: 'followers' | 'following';
63
+ limit?: number;
64
+ creds: TwitterCreds;
65
+ get?: GraphqlGetter;
66
+ queryIds?: TwitterQueryIds;
67
+ }): Promise<TwitterConnectionUser[]>;
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIMELINE_FEATURES = exports.DEFAULT_QUERY_IDS = exports.categorizeTweetResult = void 0;
4
+ exports.resolveTwitterUser = resolveTwitterUser;
5
+ exports.parseTimeline = parseTimeline;
6
+ exports.fetchUserTimeline = fetchUserTimeline;
7
+ exports.parseConnections = parseConnections;
8
+ exports.fetchUserConnections = fetchUserConnections;
9
+ const tls_client_1 = require("../utils/tls-client");
10
+ const twitter_1 = require("./twitter");
11
+ Object.defineProperty(exports, "categorizeTweetResult", { enumerable: true, get: function () { return twitter_1.categorizeTweetResult; } });
12
+ Object.defineProperty(exports, "DEFAULT_QUERY_IDS", { enumerable: true, get: function () { return twitter_1.DEFAULT_QUERY_IDS; } });
13
+ Object.defineProperty(exports, "TIMELINE_FEATURES", { enumerable: true, get: function () { return twitter_1.TIMELINE_FEATURES; } });
14
+ async function resolveTwitterUser(screenName, creds, get = tls_client_1.tlsGet, queryIds = {}) {
15
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...queryIds };
16
+ const variables = {
17
+ screen_name: screenName.replace(/^@/, ''),
18
+ withSafetyModeUserFields: true,
19
+ withSuperFollowsUserFields: true,
20
+ };
21
+ const data = await (0, twitter_1.twitterGraphql)('UserByScreenName', ids.UserByScreenName, variables, creds, get);
22
+ const user = data?.data?.user?.result;
23
+ if (!user || user.__typename === 'UserUnavailable') {
24
+ throw new Error(`用户 @${screenName} 不可访问(不存在、被封或需登录)`);
25
+ }
26
+ const legacy = user.legacy || user.core || {};
27
+ return {
28
+ userId: String(user.rest_id ?? legacy.id_str ?? ''),
29
+ name: String(legacy.name ?? ''),
30
+ screenName: String(legacy.screen_name ?? screenName.replace(/^@/, '')),
31
+ description: String(user.profile_bio?.description ?? legacy.description ?? ''),
32
+ followers: Number(legacy.followers_count ?? 0) || 0,
33
+ friends: Number(legacy.friends_count ?? 0) || 0,
34
+ };
35
+ }
36
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
37
+ function parseTimeline(instructions) {
38
+ return (0, twitter_1.walkTimelineResults)(instructions);
39
+ }
40
+ /** 用户时间线(tab:tweets=推文/转推;replies=含回复;likes=当前登录用户的点赞) */
41
+ async function fetchUserTimeline(opts) {
42
+ const tab = opts.tab || 'tweets';
43
+ const limit = Math.max(1, Math.min(200, opts.limit ?? 20));
44
+ const get = opts.get || tls_client_1.tlsGet;
45
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
46
+ let userId = opts.userId;
47
+ if (!userId) {
48
+ const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
49
+ userId = u.userId;
50
+ }
51
+ const [opName, queryId] = tab === 'likes'
52
+ ? ['Likes', ids.Likes]
53
+ : tab === 'replies'
54
+ ? ['UserTweetsAndReplies', ids.UserTweetsAndReplies]
55
+ : ['UserTweets', ids.UserTweets];
56
+ const out = [];
57
+ const seen = new Set();
58
+ let cursor;
59
+ let pages = 0;
60
+ while (out.length < limit && pages < 20) {
61
+ pages++;
62
+ const variables = {
63
+ userId,
64
+ count: 20,
65
+ includePromotedContent: false,
66
+ withQuickPromoteEligibilityTweetQuery: false,
67
+ withVoice: true,
68
+ withV2Timeline: true,
69
+ };
70
+ if (cursor)
71
+ variables.cursor = cursor;
72
+ const data = await (0, twitter_1.twitterGraphql)(opName, queryId, variables, opts.creds, get);
73
+ const instructions = (() => {
74
+ const user = data?.data?.user?.result;
75
+ return (user?.timeline_v2?.timeline || user?.timeline?.timeline)?.instructions || [];
76
+ })();
77
+ const { results, bottomCursor } = (0, twitter_1.walkTimelineResults)(instructions);
78
+ for (const r of results) {
79
+ const cat = (0, twitter_1.categorizeTweetResult)(r);
80
+ if (!cat.id || seen.has(cat.id))
81
+ continue;
82
+ seen.add(cat.id);
83
+ const kinds = [];
84
+ if (cat.isRetweet)
85
+ kinds.push('retweet');
86
+ if (cat.isReply)
87
+ kinds.push('reply');
88
+ if (cat.hasVideo)
89
+ kinds.push('video');
90
+ else if (cat.hasImage)
91
+ kinds.push('image');
92
+ else if (!cat.isRetweet)
93
+ kinds.push('text');
94
+ out.push({
95
+ id: cat.id,
96
+ tweet: (0, twitter_1.mapGraphql)(cat.inner),
97
+ kinds,
98
+ isRetweet: cat.isRetweet,
99
+ isReply: cat.isReply,
100
+ replyToId: cat.replyToId,
101
+ url: `https://x.com/i/web/status/${cat.id}`,
102
+ });
103
+ if (out.length >= limit)
104
+ break;
105
+ }
106
+ if (!bottomCursor || !results.length)
107
+ break;
108
+ cursor = bottomCursor;
109
+ }
110
+ return out;
111
+ }
112
+ function userOfEntry(itemContent) {
113
+ const user = itemContent?.user_results?.result;
114
+ if (!user || user.__typename === 'UserUnavailable')
115
+ return null;
116
+ const legacy = user.legacy || {};
117
+ return {
118
+ name: String(legacy.name ?? user.core?.name ?? ''),
119
+ screenName: String(legacy.screen_name ?? user.core?.screen_name ?? ''),
120
+ description: String(user.profile_bio?.description ?? legacy.description ?? ''),
121
+ followers: Number(legacy.followers_count ?? 0) || 0,
122
+ verified: !!user.is_blue_verified || !!legacy.verified,
123
+ followedBy: !!legacy.followed_by,
124
+ };
125
+ }
126
+ /** 纯函数:从 Followers/Following instructions 抽取用户与底部游标 */
127
+ function parseConnections(instructions) {
128
+ const users = [];
129
+ let bottomCursor;
130
+ for (const ins of instructions || []) {
131
+ for (const entry of ins?.entries || []) {
132
+ const content = entry?.content;
133
+ if (!content)
134
+ continue;
135
+ if (content.entryType === 'TimelineTimelineCursor' && content.contentType === 'Bottom') {
136
+ bottomCursor = String(content.value || bottomCursor || '');
137
+ continue;
138
+ }
139
+ if (content.entryType === 'TimelineTimelineItem' && content.itemContent) {
140
+ const u = userOfEntry(content.itemContent);
141
+ if (u && u.screenName)
142
+ users.push(u);
143
+ }
144
+ }
145
+ }
146
+ return { users, bottomCursor };
147
+ }
148
+ /** 关注列表(following)或粉丝列表(followers) */
149
+ async function fetchUserConnections(opts) {
150
+ const limit = Math.max(1, Math.min(1000, opts.limit ?? 50));
151
+ const get = opts.get || tls_client_1.tlsGet;
152
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
153
+ const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
154
+ const [opName, queryId] = opts.type === 'following'
155
+ ? ['Following', ids.Following]
156
+ : ['Followers', ids.Followers];
157
+ const out = [];
158
+ const seen = new Set();
159
+ let cursor;
160
+ let pages = 0;
161
+ while (out.length < limit && pages < 50) {
162
+ pages++;
163
+ const variables = { userId: u.userId, count: 50, includePromotedContent: false };
164
+ if (cursor)
165
+ variables.cursor = cursor;
166
+ const data = await (0, twitter_1.twitterGraphql)(opName, queryId, variables, opts.creds, get);
167
+ const instructions = (() => {
168
+ const user = data?.data?.user?.result;
169
+ return (user?.timeline_v2?.timeline || user?.timeline?.timeline)?.instructions || [];
170
+ })();
171
+ const { users, bottomCursor } = parseConnections(instructions);
172
+ for (const user of users) {
173
+ if (seen.has(user.screenName))
174
+ continue;
175
+ seen.add(user.screenName);
176
+ out.push(user);
177
+ if (out.length >= limit)
178
+ break;
179
+ }
180
+ if (!bottomCursor || !users.length)
181
+ break;
182
+ cursor = bottomCursor;
183
+ }
184
+ return out;
185
+ }
@@ -13,6 +13,37 @@ export interface TwitterCreds {
13
13
  authToken: string;
14
14
  ct0: string;
15
15
  }
16
+ export interface TwitterQueryIds {
17
+ UserByScreenName?: string;
18
+ UserTweets?: string;
19
+ UserTweetsAndReplies?: string;
20
+ Likes?: string;
21
+ Followers?: string;
22
+ Following?: string;
23
+ TweetDetail?: string;
24
+ }
25
+ /** 默认 queryId 清单(随 x.com web 版本漂移;调用方可整体覆盖) */
26
+ export declare const DEFAULT_QUERY_IDS: Required<TwitterQueryIds>;
27
+ /** 时间线类查询的特性集(UserTweets/Likes/Followers/TweetDetail 共用) */
28
+ export declare const TIMELINE_FEATURES: Record<string, boolean>;
29
+ /** 鉴权 GraphQL 通用请求(headers/cookie 与 TweetResultByRestId 同源) */
30
+ export declare function twitterGraphql(opName: string, queryId: string, variables: Record<string, any>, creds: TwitterCreds, get: GraphqlGetter, features?: Record<string, boolean>): Promise<any>;
31
+ /** 纯函数:把单条 tweet result 归类(含转推解包) */
32
+ export declare function categorizeTweetResult(result: any): {
33
+ inner: any;
34
+ isRetweet: boolean;
35
+ isReply: boolean;
36
+ hasVideo: boolean;
37
+ hasImage: boolean;
38
+ isText: boolean;
39
+ id: string;
40
+ replyToId?: string;
41
+ };
42
+ /** 纯函数:从 timeline instructions 抽取推文结果与底部游标(Item/Module 均收) */
43
+ export declare function walkTimelineResults(instructions: any[]): {
44
+ results: any[];
45
+ bottomCursor?: string;
46
+ };
16
47
  export interface GrokTranslation {
17
48
  text: string;
18
49
  sourceLang?: string;
@@ -23,4 +54,49 @@ export interface GrokTranslation {
23
54
  * x-client-transaction-id / cf_clearance。任何失败返回 null 由调用方回落通用翻译。
24
55
  */
25
56
  export declare function fetchGrokTranslation(url: string, targetLang: string, creds: TwitterCreds, get?: GraphqlGetter): Promise<GrokTranslation | null>;
57
+ export declare function extractTweetId(url: string): string | null;
58
+ /** 把 GraphQL TweetResultByRestId 响应映射为 ParsedData */
59
+ export declare function mapGraphql(rawResult: any): ParsedData;
60
+ /** 解除 TweetWithVisibilityResults 包裹(NSFW/受限推文) */
61
+ export declare function unwrapTweetResult(result: any): any;
62
+ /** 鉴权 GraphQL 原始获取:返回未映射的 tweet result 节点(树构建复用) */
63
+ export declare function fetchGraphqlRaw(id: string, creds: TwitterCreds, get: GraphqlGetter): Promise<any>;
26
64
  export declare function parseTwitter(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter): Promise<ParsedData>;
65
+ /**
66
+ * 推文树节点:
67
+ * - quoted = 本推引用的推文(递归)
68
+ * - replyTo = 本推回复的目标(向根方向递归)
69
+ * - replies = 本推收到的回复(评论,向下递归;withReplies 时填充)
70
+ */
71
+ export interface TweetTree {
72
+ id: string;
73
+ tweet: ParsedData;
74
+ quoted?: TweetTree;
75
+ replyTo?: TweetTree;
76
+ replies?: TweetTree[];
77
+ }
78
+ /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
79
+ * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
80
+ export declare function guestQuoteId(tw: any): string | undefined;
81
+ /** 游客态(syndication)取回复目标推文 ID */
82
+ export declare function guestReplyToId(tw: any): string | undefined;
83
+ export interface FetchTweetTreeOptions {
84
+ /** 拉取会话回复树(TweetDetail;需登录态) */
85
+ withReplies?: boolean;
86
+ /** 回复条数上限(默认 100) */
87
+ repliesLimit?: number;
88
+ /** queryId 覆盖 */
89
+ queryIds?: TwitterQueryIds;
90
+ }
91
+ /**
92
+ * 纯函数:由会话内的全部 tweet result 装配回复树(挂在 focal 节点下)。
93
+ * 依据 in_reply_to_status_id_str 建父子边;父不在集合内的孤儿回复跳过。
94
+ */
95
+ export declare function assembleReplies(focalId: string, results: any[], limit: number, makeNode: (raw: any) => TweetTree): TweetTree[];
96
+ /**
97
+ * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)
98
+ * + 可选会话回复树(replies,向下递归,TweetDetail 装配)。
99
+ * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
100
+ * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
101
+ */
102
+ export declare function fetchTweetTree(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter, opts?: FetchTweetTreeOptions): Promise<TweetTree>;
@@ -1,7 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIMELINE_FEATURES = exports.DEFAULT_QUERY_IDS = void 0;
4
+ exports.twitterGraphql = twitterGraphql;
5
+ exports.categorizeTweetResult = categorizeTweetResult;
6
+ exports.walkTimelineResults = walkTimelineResults;
3
7
  exports.fetchGrokTranslation = fetchGrokTranslation;
8
+ exports.extractTweetId = extractTweetId;
9
+ exports.mapGraphql = mapGraphql;
10
+ exports.unwrapTweetResult = unwrapTweetResult;
11
+ exports.fetchGraphqlRaw = fetchGraphqlRaw;
4
12
  exports.parseTwitter = parseTwitter;
13
+ exports.guestQuoteId = guestQuoteId;
14
+ exports.guestReplyToId = guestReplyToId;
15
+ exports.assembleReplies = assembleReplies;
16
+ exports.fetchTweetTree = fetchTweetTree;
5
17
  const tls_client_1 = require("../utils/tls-client");
6
18
  /**
7
19
  * X / Twitter 原生解析器。
@@ -33,6 +45,138 @@ const GRAPHQL_FEATURES = {
33
45
  longform_notetweets_rich_text_read_enabled: true,
34
46
  responsive_web_graphql_timeline_navigation_enabled: true,
35
47
  };
48
+ /** 默认 queryId 清单(随 x.com web 版本漂移;调用方可整体覆盖) */
49
+ exports.DEFAULT_QUERY_IDS = {
50
+ UserByScreenName: 'sLVLhk0bGj3MVFEKTdax1w',
51
+ UserTweets: 'HuTx74BxAnezK1gWvYY7zg',
52
+ UserTweetsAndReplies: 'RIWc55YCNyUJ-U3HHGYkdg',
53
+ Likes: 'nXEl0lfN_XSznVMlprThgQ',
54
+ Followers: 'pd8Tt1qUz1YWrICegqZ8cw',
55
+ Following: 'wjvx62Hye2dGVvnvVco0xA',
56
+ TweetDetail: 'zXaXQgfyR4GxE21uwYQSyA',
57
+ };
58
+ /** 时间线类查询的特性集(UserTweets/Likes/Followers/TweetDetail 共用) */
59
+ exports.TIMELINE_FEATURES = {
60
+ blue_business_profile_image_shape_enabled: true,
61
+ creator_subscriptions_tweet_preview_api_enabled: true,
62
+ freedom_of_speech_not_reach_fetch_enabled: true,
63
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
64
+ graphql_timeline_v2_bookmark_timeline: true,
65
+ hidden_profile_likes_enabled: true,
66
+ highlights_tweets_tab_ui_enabled: true,
67
+ interactive_text_enabled: true,
68
+ longform_notetweets_consumption_enabled: true,
69
+ longform_notetweets_inline_media_enabled: true,
70
+ longform_notetweets_rich_text_read_enabled: true,
71
+ longform_notetweets_richtext_consumption_enabled: true,
72
+ profile_foundations_tweet_stats_enabled: true,
73
+ profile_foundations_tweet_stats_tweet_frequency: true,
74
+ responsive_web_birdwatch_note_limit_enabled: true,
75
+ responsive_web_edit_tweet_api_enabled: true,
76
+ responsive_web_enhance_cards_enabled: false,
77
+ responsive_web_graphql_exclude_directive_enabled: true,
78
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
79
+ responsive_web_graphql_timeline_navigation_enabled: true,
80
+ responsive_web_media_download_video_enabled: false,
81
+ responsive_web_text_conversations_enabled: false,
82
+ responsive_web_twitter_article_data_v2_enabled: true,
83
+ responsive_web_twitter_article_tweet_consumption_enabled: false,
84
+ responsive_web_twitter_blue_verified_badge_is_enabled: true,
85
+ rweb_lists_timeline_redesign_enabled: true,
86
+ spaces_2022_h2_clipping: true,
87
+ spaces_2022_h2_spaces_communities: true,
88
+ standardized_nudges_misinfo: true,
89
+ subscriptions_verification_info_verified_since_enabled: true,
90
+ tweet_awards_web_tipping_enabled: false,
91
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
92
+ tweetypie_unmention_optimization_enabled: true,
93
+ verified_phone_label_enabled: false,
94
+ vibe_api_enabled: true,
95
+ view_counts_everywhere_api_enabled: true,
96
+ };
97
+ /** 鉴权 GraphQL 通用请求(headers/cookie 与 TweetResultByRestId 同源) */
98
+ async function twitterGraphql(opName, queryId, variables, creds, get, features = exports.TIMELINE_FEATURES) {
99
+ const url = `https://x.com/i/api/graphql/${queryId}/${opName}` +
100
+ `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`;
101
+ let res;
102
+ try {
103
+ res = await get(url, {
104
+ headers: {
105
+ authorization: `Bearer ${WEB_BEARER}`,
106
+ 'x-csrf-token': creds.ct0,
107
+ 'x-twitter-auth-type': 'OAuth2Session',
108
+ 'x-twitter-active-user': 'yes',
109
+ 'x-twitter-client-language': 'en',
110
+ },
111
+ cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
112
+ timeout: 30000,
113
+ });
114
+ }
115
+ catch (e) {
116
+ throw new Error(`X GraphQL 请求失败:${e?.message || e}`);
117
+ }
118
+ if (res.status === 403 || res.status === 429) {
119
+ throw new Error(`X GraphQL 被 Cloudflare 拦截 (HTTP ${res.status}):TLS 指纹校验未通过。` +
120
+ `请确认已安装可选依赖 @char46/tlsget-rs(npm i @char46/tlsget-rs),它提供浏览器级 TLS 指纹。`);
121
+ }
122
+ if (res.status !== 200) {
123
+ throw new Error(`X GraphQL ${opName} 返回 HTTP ${res.status}:${typeof res.data === 'string' ? res.data.slice(0, 120) : JSON.stringify(res.data || {}).slice(0, 120)}`);
124
+ }
125
+ return res.data;
126
+ }
127
+ /** 纯函数:把单条 tweet result 归类(含转推解包) */
128
+ function categorizeTweetResult(result) {
129
+ const raw = unwrapTweetResult(result);
130
+ const rtInner = raw?.legacy?.retweeted_status_result?.result;
131
+ const inner = rtInner ? unwrapTweetResult(rtInner) : raw;
132
+ const legacy = inner?.legacy || {};
133
+ const media = Array.isArray(legacy.extended_entities?.media) ? legacy.extended_entities.media : [];
134
+ const hasImage = media.some((m) => m?.type === 'photo');
135
+ const hasVideo = media.some((m) => m?.type === 'video' || m?.type === 'animated_gif');
136
+ const isRetweet = !!rtInner;
137
+ const replyToId = typeof legacy.in_reply_to_status_id_str === 'string' ? legacy.in_reply_to_status_id_str : undefined;
138
+ const isReply = !!replyToId;
139
+ const isText = !hasImage && !hasVideo && !isRetweet;
140
+ return {
141
+ inner,
142
+ isRetweet,
143
+ isReply,
144
+ hasVideo,
145
+ hasImage,
146
+ isText,
147
+ id: String(inner?.rest_id ?? legacy.id_str ?? ''),
148
+ replyToId,
149
+ };
150
+ }
151
+ /** 纯函数:从 timeline instructions 抽取推文结果与底部游标(Item/Module 均收) */
152
+ function walkTimelineResults(instructions) {
153
+ const results = [];
154
+ let bottomCursor;
155
+ for (const ins of instructions || []) {
156
+ const entries = ins?.entries || ins?.entry ? [].concat(ins.entry, ins.entries).filter(Boolean) : [];
157
+ for (const entry of entries) {
158
+ const content = entry?.content;
159
+ if (!content)
160
+ continue;
161
+ if (content.entryType === 'TimelineTimelineCursor' && content.contentType === 'Bottom') {
162
+ bottomCursor = String(content.value || bottomCursor || '');
163
+ continue;
164
+ }
165
+ if (content.entryType === 'TimelineTimelineItem' && content.itemContent?.tweet_results?.result) {
166
+ results.push(content.itemContent.tweet_results.result);
167
+ continue;
168
+ }
169
+ if (content.entryType === 'TimelineTimelineModule') {
170
+ for (const item of content.items || []) {
171
+ const r = item?.itemContent?.tweet_results?.result;
172
+ if (r)
173
+ results.push(r);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return { results, bottomCursor };
179
+ }
36
180
  /** Grok 翻译特性集(与网页端抓包一致;关键是 grok_show_grok_translated_post 开关) */
37
181
  const GROK_FEATURES = {
38
182
  creator_subscriptions_tweet_preview_api_enabled: true,
@@ -138,14 +282,45 @@ function pick(...vals) {
138
282
  return v;
139
283
  return '';
140
284
  }
141
- /** 清理描述中的 t.co 短链(Twitter 自动附加的截断 URL,无实际内容价值) */
285
+ /** BFS 深取子树中第一个非空的指定键值(X GraphQL 用户结构随版本漂移:legacy ↔ core/avatar) */
286
+ function deepPick(node, key) {
287
+ const queue = [node];
288
+ while (queue.length) {
289
+ const cur = queue.shift();
290
+ if (!cur || typeof cur !== 'object')
291
+ continue;
292
+ const v = cur[key];
293
+ if (v !== undefined && v !== null && v !== '')
294
+ return v;
295
+ for (const k of Object.keys(cur))
296
+ queue.push(cur[k]);
297
+ }
298
+ return undefined;
299
+ }
300
+ /** BFS 找子树中最长的 text 字符串(笔记正文位置随版本漂移,取最长以避开截断副本) */
301
+ function deepFindLongestText(node) {
302
+ let best;
303
+ const queue = [node];
304
+ while (queue.length) {
305
+ const cur = queue.shift();
306
+ if (!cur || typeof cur !== 'object')
307
+ continue;
308
+ if (typeof cur.text === 'string' && cur.text && (!best || cur.text.length > best.length))
309
+ best = cur.text;
310
+ for (const k of Object.keys(cur))
311
+ queue.push(cur[k]);
312
+ }
313
+ return best;
314
+ }
315
+ /** 清理描述中的 t.co 短链(Twitter 自动附加的截断 URL,无实际内容价值)。
316
+ * 注意保留段落换行:仅折叠水平空白,≥3 连续换行压成空行一档。 */
142
317
  function cleanDesc(text) {
143
318
  if (!text)
144
319
  return text;
145
- // 移除末尾的 t.co URL(含可能的空格前导)
146
- let cleaned = text.replace(/\s*https?:\/\/t\.co\/[A-Za-z0-9]+(?:\?[^\s]*)?\s*$/g, '').trim();
147
- // 移除中间出现的 t.co URL(较少见,但推文内嵌的缩短链)
148
- cleaned = cleaned.replace(/\s*https?:\/\/t\.co\/[A-Za-z0-9]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
320
+ // 去掉全部 t.co URL(文末自动附加 + 文中内嵌)
321
+ let cleaned = text.replace(/https?:\/\/t\.co\/[A-Za-z0-9]+(?:\?[^\s]*)?/g, '');
322
+ // 仅折叠空格/制表(不动换行);去掉行首尾空格;连续空行压成一个空行
323
+ cleaned = cleaned.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
149
324
  return cleaned || text;
150
325
  }
151
326
  function baseParsed() {
@@ -273,8 +448,10 @@ function mapGraphql(rawResult) {
273
448
  }
274
449
  const legacy = result.legacy || {};
275
450
  const user = result.core?.user_results?.result;
276
- const ulegacy = user?.legacy || {};
277
- const text = String(pick(legacy.full_text, result.note_tweet?.note_results?.note?.text, ''));
451
+ const ures = user || {};
452
+ // 长推(note tweet):legacy.full_text 是截断版,全文在 note_tweet 子树(位置随版本漂移,深取最长 text)
453
+ // 用户字段同理:legacy ↔ core/avatar/profile_bio 漂移,深取键值
454
+ const text = String(pick(deepFindLongestText(result.note_tweet), legacy.full_text, ''));
278
455
  const p = baseParsed();
279
456
  const media = Array.isArray(legacy.entities?.media) ? legacy.entities.media : [];
280
457
  const photos = [];
@@ -318,9 +495,9 @@ function mapGraphql(rawResult) {
318
495
  p.title = cleaned.slice(0, 100);
319
496
  p.desc = cleaned;
320
497
  p.lang = legacy.lang ? String(legacy.lang) : undefined;
321
- p.author = String(pick(ulegacy.name, ulegacy.screen_name, ''));
322
- p.uid = String(pick(ulegacy.screen_name, user?.rest_id, ''));
323
- p.avatar = String(pick(ulegacy.profile_image_url_https, ''));
498
+ p.author = String(pick(deepPick(ures, 'name'), ''));
499
+ p.uid = String(pick(deepPick(ures, 'screen_name'), user?.rest_id, ''));
500
+ p.avatar = String(pick(deepPick(ures, 'profile_image_url_https'), deepPick(ures, 'image_url'), ''));
324
501
  p.like = Number(pick(legacy.favorite_count, 0)) || 0;
325
502
  p.comment = Number(pick(legacy.reply_count, 0)) || 0;
326
503
  p.share = Number(pick(legacy.retweet_count, 0)) || 0;
@@ -331,14 +508,18 @@ function mapGraphql(rawResult) {
331
508
  if (!isNaN(t))
332
509
  p.publishTime = t;
333
510
  }
334
- p.author_followers = Number(pick(ulegacy.followers_count, 0)) || 0;
335
- p.author_signature = String(pick(ulegacy.description, ''));
511
+ p.author_followers = Number(pick(deepPick(ures, 'followers_count'), 0)) || 0;
512
+ p.author_signature = String(pick(deepPick(ures, 'description'), ''));
336
513
  if (p.title && p.desc && p.desc.startsWith(p.title))
337
514
  p.title = '';
338
515
  return p;
339
516
  }
340
- /** 鉴权 GraphQL:仅用 auth_token + ct0,回退取登录受限推文 */
341
- async function fetchGraphqlTweet(id, creds, get) {
517
+ /** 解除 TweetWithVisibilityResults 包裹(NSFW/受限推文) */
518
+ function unwrapTweetResult(result) {
519
+ return result && result.__typename === 'TweetWithVisibilityResults' && result.tweet ? result.tweet : result;
520
+ }
521
+ /** 鉴权 GraphQL 原始获取:返回未映射的 tweet result 节点(树构建复用) */
522
+ async function fetchGraphqlRaw(id, creds, get) {
342
523
  const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
343
524
  const url = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
344
525
  `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(GRAPHQL_FEATURES))}`;
@@ -373,7 +554,11 @@ async function fetchGraphqlTweet(id, creds, get) {
373
554
  const tb = result?.tombstone?.text?.text || result?.tombstone?.text;
374
555
  throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${tb ? ':' + tb : ''}`);
375
556
  }
376
- return mapGraphql(result);
557
+ return unwrapTweetResult(result);
558
+ }
559
+ /** 鉴权 GraphQL:仅用 auth_token + ct0,回退取登录受限推文 */
560
+ async function fetchGraphqlTweet(id, creds, get) {
561
+ return mapGraphql(await fetchGraphqlRaw(id, creds, get));
377
562
  }
378
563
  async function parseTwitter(url, http, creds, getGraphql) {
379
564
  const id = extractTweetId(url);
@@ -387,7 +572,19 @@ async function parseTwitter(url, http, creds, getGraphql) {
387
572
  });
388
573
  const tw = res.data;
389
574
  if (tw && tw.__typename === 'Tweet' && tw.user) {
390
- return mapSyndication(tw);
575
+ const p = mapSyndication(tw);
576
+ // 长推:syndication 现仅返回 note_tweet 的 id 引用(无 text),tw.text 被
577
+ // display_text_range 截断。有登录态时改走 GraphQL 取全文;失败回退截断结果。
578
+ const noteTruncated = tw.note_tweet && !tw.note_tweet.text;
579
+ if (noteTruncated && creds && creds.authToken && creds.ct0) {
580
+ try {
581
+ return await fetchGraphqlTweet(id, creds, getGraphql || tls_client_1.tlsGet);
582
+ }
583
+ catch {
584
+ return p;
585
+ }
586
+ }
587
+ return p;
391
588
  }
392
589
  // 2) tombstone(需登录):回退到鉴权 GraphQL(TLS 指纹模拟)
393
590
  if (creds && creds.authToken && creds.ct0) {
@@ -396,3 +593,199 @@ async function parseTwitter(url, http, creds, getGraphql) {
396
593
  const reasonRaw = pick(tw?.tombstone?.text, tw?.tombstone?.name);
397
594
  throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${reasonRaw ? ':' + reasonRaw : ''}`);
398
595
  }
596
+ const TREE_QUOTE_DEPTH = 6;
597
+ const TREE_REPLY_DEPTH = 12;
598
+ const TREE_REPLIES_LIMIT = 100;
599
+ /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
600
+ * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
601
+ function guestQuoteId(tw) {
602
+ const q = tw?.quoted_tweet || tw?.quoted_status_result;
603
+ if (q) {
604
+ const id = String(pick(q.id_str, q.rest_id, ''));
605
+ if (/^\d+$/.test(id))
606
+ return id;
607
+ }
608
+ const urls = Array.isArray(tw?.entities?.urls) ? tw.entities.urls : [];
609
+ const rawText = String(tw?.text || '');
610
+ const textEnd = rawText.trimEnd();
611
+ for (const u of urls) {
612
+ if (!u?.url || !textEnd.endsWith(u.url))
613
+ continue;
614
+ const m = /(?:x|twitter)\.com\/[^/]+\/status(?:es)?\/(\d+)/.exec(String(u.expanded_url || ''));
615
+ if (m)
616
+ return m[1];
617
+ }
618
+ return undefined;
619
+ }
620
+ /** 游客态(syndication)取回复目标推文 ID */
621
+ function guestReplyToId(tw) {
622
+ const id = pick(tw?.in_reply_to_status_id_str, tw?.in_reply_to_status_id);
623
+ if (typeof id === 'string' && /^\d+$/.test(id))
624
+ return id;
625
+ if (typeof id === 'number')
626
+ return String(id);
627
+ return undefined;
628
+ }
629
+ async function fetchSyndicationRaw(tweetId, http) {
630
+ const res = await http.get(SYNDICATION_URL, {
631
+ params: { id: tweetId, token: 'a' },
632
+ timeout: 30000,
633
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
634
+ });
635
+ const tw = res.data;
636
+ return tw && tw.__typename === 'Tweet' && tw.user ? tw : null;
637
+ }
638
+ /**
639
+ * 纯函数:由会话内的全部 tweet result 装配回复树(挂在 focal 节点下)。
640
+ * 依据 in_reply_to_status_id_str 建父子边;父不在集合内的孤儿回复跳过。
641
+ */
642
+ function assembleReplies(focalId, results, limit, makeNode) {
643
+ const byId = new Map();
644
+ for (const r of results) {
645
+ const cat = categorizeTweetResult(r);
646
+ if (!cat.id || byId.has(cat.id) || cat.id === focalId)
647
+ continue;
648
+ byId.set(cat.id, { node: makeNode(cat.inner), replyToId: cat.replyToId });
649
+ }
650
+ const childrenOf = new Map();
651
+ const roots = [];
652
+ for (const { node, replyToId } of byId.values()) {
653
+ if (replyToId && byId.has(replyToId)) {
654
+ ;
655
+ (childrenOf.get(replyToId) || childrenOf.set(replyToId, []).get(replyToId)).push(node);
656
+ }
657
+ else if (replyToId === focalId) {
658
+ roots.push(node);
659
+ }
660
+ /* 父不在集合内(祖先线程外)→ 跳过 */
661
+ }
662
+ let budget = limit;
663
+ const attach = (nodes) => {
664
+ const out = [];
665
+ for (const n of nodes) {
666
+ if (budget <= 0)
667
+ break;
668
+ budget--;
669
+ n.replies = attach(childrenOf.get(n.id) || []);
670
+ out.push(n);
671
+ }
672
+ return out;
673
+ };
674
+ return attach(roots);
675
+ }
676
+ /**
677
+ * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)
678
+ * + 可选会话回复树(replies,向下递归,TweetDetail 装配)。
679
+ * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
680
+ * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
681
+ */
682
+ async function fetchTweetTree(url, http, creds, getGraphql, opts = {}) {
683
+ const id = extractTweetId(url);
684
+ if (!id)
685
+ throw new Error('无法从 X 链接提取推文 ID');
686
+ const visited = new Set();
687
+ async function buildGuest(tweetId, depth) {
688
+ visited.add(tweetId);
689
+ const tw = await fetchSyndicationRaw(tweetId, http);
690
+ if (!tw)
691
+ throw new Error(`推文 ${tweetId} 不可访问(可能需要登录或已删除)`);
692
+ const node = { id: tweetId, tweet: mapSyndication(tw) };
693
+ const qid = guestQuoteId(tw);
694
+ if (qid && !visited.has(qid) && depth < TREE_QUOTE_DEPTH) {
695
+ try {
696
+ node.quoted = await buildGuest(qid, depth + 1);
697
+ }
698
+ catch { /* 引用不可达则截断 */ }
699
+ }
700
+ const rid = guestReplyToId(tw);
701
+ if (rid && !visited.has(rid) && depth < TREE_REPLY_DEPTH) {
702
+ try {
703
+ node.replyTo = await buildGuest(rid, depth + 1);
704
+ }
705
+ catch { /* 父推不可达则截断 */ }
706
+ }
707
+ return node;
708
+ }
709
+ async function buildFromGraphqlResult(raw, depth) {
710
+ raw = unwrapTweetResult(raw);
711
+ const rid0 = String(pick(raw?.rest_id, raw?.legacy?.id_str, ''));
712
+ if (rid0)
713
+ visited.add(rid0);
714
+ const node = { id: rid0, tweet: mapGraphql(raw) };
715
+ const q = unwrapTweetResult(raw?.quoted_status_result?.result);
716
+ if (q && q.legacy && q.__typename !== 'TweetTombstone' && depth < TREE_QUOTE_DEPTH) {
717
+ const qid = String(pick(q.rest_id, q.legacy?.id_str, ''));
718
+ if (!qid || !visited.has(qid))
719
+ node.quoted = await buildFromGraphqlResult(q, depth + 1);
720
+ }
721
+ const rid = raw?.legacy?.in_reply_to_status_id_str;
722
+ if (typeof rid === 'string' && /^\d+$/.test(rid) && !visited.has(rid) && depth < TREE_REPLY_DEPTH) {
723
+ try {
724
+ node.replyTo = await buildGraphqlById(rid, depth + 1);
725
+ }
726
+ catch { /* 父推不可达则截断 */ }
727
+ }
728
+ return node;
729
+ }
730
+ async function buildGraphqlById(tweetId, depth) {
731
+ if (visited.has(tweetId))
732
+ throw new Error('引用环');
733
+ const raw = await fetchGraphqlRaw(tweetId, creds, getGraphql || tls_client_1.tlsGet);
734
+ return buildFromGraphqlResult(raw, depth);
735
+ }
736
+ /** 同步构建会话回复节点:嵌套引用链照常递归(无需网络),父链不拉(由会话集合装配) */
737
+ function nodeFromResultSync(raw) {
738
+ raw = unwrapTweetResult(raw);
739
+ const rid0 = String(pick(raw?.rest_id, raw?.legacy?.id_str, ''));
740
+ if (rid0)
741
+ visited.add(rid0);
742
+ const node = { id: rid0, tweet: mapGraphql(raw) };
743
+ const q = unwrapTweetResult(raw?.quoted_status_result?.result);
744
+ if (q && q.legacy && q.__typename !== 'TweetTombstone') {
745
+ const qid = String(pick(q.rest_id, q.legacy?.id_str, ''));
746
+ if (!qid || !visited.has(qid))
747
+ node.quoted = nodeFromResultSync(q);
748
+ }
749
+ return node;
750
+ }
751
+ async function attachConversation(node) {
752
+ if (!node)
753
+ return;
754
+ const qids = { ...exports.DEFAULT_QUERY_IDS, ...opts.queryIds };
755
+ const variables = {
756
+ focalTweetId: node.id,
757
+ with_rux_injections: false,
758
+ rankingMode: 'Relevance',
759
+ includePromotedContent: true,
760
+ withCommunity: true,
761
+ withQuickPromoteEligibilityTweetQuery: false,
762
+ withBirdwatchNotes: true,
763
+ withVoice: true,
764
+ };
765
+ const data = await twitterGraphql('TweetDetail', qids.TweetDetail, variables, creds, getGraphql || tls_client_1.tlsGet);
766
+ const instructions = data?.data?.threaded_conversation_with_injections_v2?.timeline?.instructions;
767
+ const { results } = walkTimelineResults(instructions || []);
768
+ node.replies = assembleReplies(node.id, results, opts.repliesLimit ?? TREE_REPLIES_LIMIT, nodeFromResultSync);
769
+ }
770
+ const usedGraphql = !!(creds && creds.authToken && creds.ct0);
771
+ let tree;
772
+ if (usedGraphql) {
773
+ try {
774
+ tree = await buildGraphqlById(id, 0);
775
+ }
776
+ catch {
777
+ /* 登录态失败(如 Cloudflare)回退游客路径 */
778
+ tree = await buildGuest(id, 0);
779
+ }
780
+ }
781
+ else {
782
+ tree = await buildGuest(id, 0);
783
+ }
784
+ if (opts.withReplies && usedGraphql && tree) {
785
+ try {
786
+ await attachConversation(tree);
787
+ }
788
+ catch { /* 会话不可达则保留无回复树 */ }
789
+ }
790
+ return tree;
791
+ }
package/lib/index.d.ts CHANGED
@@ -19,9 +19,11 @@ export * from './utils/tls-client';
19
19
  export * from './utils/gif';
20
20
  export * from './utils/merge';
21
21
  export * from './engine/gateway';
22
+ export * from './engine/config';
22
23
  export * from './engine/platform-config';
23
24
  export * from './engine/translate';
24
25
  export * from './engine/twitter';
26
+ export * from './engine/twitter-user';
25
27
  export * from './engine/parser';
26
28
  export * from './engine/fetcher';
27
29
  export * from './engine/runtime';
package/lib/index.js CHANGED
@@ -35,9 +35,11 @@ __exportStar(require("./utils/tls-client"), exports);
35
35
  __exportStar(require("./utils/gif"), exports);
36
36
  __exportStar(require("./utils/merge"), exports);
37
37
  __exportStar(require("./engine/gateway"), exports);
38
+ __exportStar(require("./engine/config"), exports);
38
39
  __exportStar(require("./engine/platform-config"), exports);
39
40
  __exportStar(require("./engine/translate"), exports);
40
41
  __exportStar(require("./engine/twitter"), exports);
42
+ __exportStar(require("./engine/twitter-user"), exports);
41
43
  __exportStar(require("./engine/parser"), exports);
42
44
  __exportStar(require("./engine/fetcher"), exports);
43
45
  __exportStar(require("./engine/runtime"), exports);
@@ -28,10 +28,15 @@ function formatPublishTime(ms) {
28
28
  }
29
29
  function generateFormattedText(p, format, index, total) {
30
30
  const imageCount = p.images.length || p.live_photo.length;
31
+ // 纯文字作品(type=text):正文即内容。模板含 ${正文} 时启用新语义——
32
+ // 正文=desc、简介置空(自动隐藏),避免把正文标成「简介」;旧模板保持原行为。
33
+ const plainTextWork = p.type === 'text' && !!p.desc;
34
+ const wantsBody = plainTextWork && format.includes('${正文}');
31
35
  const vars = {
32
36
  '标题': p.title,
33
37
  '作者': p.author,
34
- '简介': p.desc,
38
+ '简介': wantsBody ? '' : p.desc,
39
+ '正文': plainTextWork ? p.desc : '',
35
40
  '视频时长': p.duration > 0 ? formatDuration(p.duration) : '',
36
41
  '点赞数': String(p.like),
37
42
  '收藏数': String(p.collect),
@@ -28,7 +28,10 @@ export interface SeamVerdict {
28
28
  /** 结论文案 */
29
29
  reason: string;
30
30
  }
31
- /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
31
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;接缝总数 ≥3 时允许至多一条
32
+ * 「边界容差缝」(比值 ∈ [3,5)——平台独立重压缩的典型错位幅度;多条缝相互
33
+ * 印证才可信,2 缝场景证据不足不放行;平坦不可验证缝不参与容差)。
34
+ * 返回最大接缝比值与逐缝证据。 */
32
35
  export declare function verifyLayout(layout: MergeLayout, grays: Buffer[]): {
33
36
  pass: boolean;
34
37
  score: number;
@@ -37,6 +37,12 @@ const COL_LEN = 64;
37
37
  /** 接缝差异 / 内部自然差异 的判定阈值(母图切片 ≈1,无关图片 ≫1;下限 3 防平坦图除零) */
38
38
  const BOUNDARY_THRESH = 3.0;
39
39
  const MAD_FLOOR = 3.0;
40
+ /** 单缝边界容差:平台(如 X)对各分片独立重压缩会让个别接缝低频轻微错位。
41
+ * 仅当接缝总数 ≥3(≥4 图,多条缝相互印证)且恰好一条落在
42
+ * [BOUNDARY_THRESH, BORDERLINE_THRESH)、其余全部通过时放行;平坦不可验证缝不参与容差 */
43
+ const BORDERLINE_THRESH = 5.0;
44
+ const BORDERLINE_MAX = 1;
45
+ const BORDERLINE_MIN_SEAMS = 3;
40
46
  /** 边界带纹理能量下限:低于此值视为「不可验证接缝」(相似背景/平边照片会骗过亮度对比) */
41
47
  const TEXTURE_MIN = 4.0;
42
48
  /* ---------- 尺寸候选 ---------- */
@@ -184,7 +190,10 @@ function seamVerdictH(a, b, from, to) {
184
190
  [colOf(b, 2), colOf(b, 1)],
185
191
  ]);
186
192
  }
187
- /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
193
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;接缝总数 ≥3 时允许至多一条
194
+ * 「边界容差缝」(比值 ∈ [3,5)——平台独立重压缩的典型错位幅度;多条缝相互
195
+ * 印证才可信,2 缝场景证据不足不放行;平坦不可验证缝不参与容差)。
196
+ * 返回最大接缝比值与逐缝证据。 */
188
197
  function verifyLayout(layout, grays) {
189
198
  const seams = [];
190
199
  const n = grays.length;
@@ -208,7 +217,11 @@ function verifyLayout(layout, grays) {
208
217
  if (!seams.length)
209
218
  return { pass: false, score: Infinity, seams };
210
219
  const worst = Math.max(...seams.map((v) => v.score));
211
- return { pass: seams.every((v) => v.ok), score: worst, seams };
220
+ const failed = seams.filter((v) => !v.ok);
221
+ const borderline = failed.filter((v) => v.score < BORDERLINE_THRESH);
222
+ const pass = seams.every((v) => v.ok)
223
+ || (seams.length >= BORDERLINE_MIN_SEAMS && borderline.length <= BORDERLINE_MAX && failed.length === borderline.length);
224
+ return { pass, score: worst, seams };
212
225
  }
213
226
  /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
214
227
  function detectMergeLayout(sizes, grays) {
@@ -232,8 +245,9 @@ function pickMergeLayout(sizes, grays) {
232
245
  let best = null;
233
246
  for (const c of candidates) {
234
247
  const { pass, score, seams } = verifyLayout(c, grays);
248
+ const tolerated = seams.filter((v) => !v.ok).length;
235
249
  const detail = seams.map((v) => `[${v.dir === 'V' ? '竖' : '横'}缝 ${v.from}→${v.to}] ${v.reason}`).join(';');
236
- (0, logger_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
250
+ (0, logger_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'}${pass && tolerated ? `(含 ${tolerated} 条边界容差缝)` : ''},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
237
251
  if (pass && (!best || score < best.score))
238
252
  best = { layout: c, score, seams };
239
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sns-parse/core",
3
- "version": "0.4.0-alpha.1+upstream.1.6.7",
3
+ "version": "0.4.0-alpha.10+upstream.1.6.7",
4
4
  "description": "sns-parse 核心:契约、宿主抽象、配置 DSL 与解析引擎(fetcher/parser/翻译/X 原生解析/合并/GIF/TLS 指纹客户端)",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "axios": "^1.16.1",
33
- "https-proxy-agent": "^9.1.0"
33
+ "https-proxy-agent": "^5.0.1"
34
34
  },
35
35
  "optionalDependencies": {
36
36
  "@char46/tlsget-rs": "^1.0.0",