@sns-parse/core 0.3.0-alpha.1 → 0.4.0-alpha.1
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 +5 -0
- package/lib/config.js +28 -0
- package/lib/engine/config-io.d.ts +44 -0
- package/lib/engine/config-io.js +155 -0
- package/lib/engine/fetcher.d.ts +21 -0
- package/lib/engine/fetcher.js +228 -0
- package/lib/engine/gateway.d.ts +21 -0
- package/lib/engine/gateway.js +19 -0
- package/lib/engine/parser.d.ts +2 -0
- package/lib/engine/parser.js +162 -0
- package/lib/engine/platform-config.d.ts +24 -0
- package/lib/engine/platform-config.js +76 -0
- package/lib/engine/runtime.d.ts +49 -0
- package/lib/engine/runtime.js +90 -0
- package/lib/engine/translate.d.ts +15 -0
- package/lib/engine/translate.js +107 -0
- package/lib/engine/twitter.d.ts +26 -0
- package/lib/engine/twitter.js +398 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +19 -0
- package/lib/registry.d.ts +20 -0
- package/lib/registry.js +77 -0
- package/lib/utils/cache.d.ts +9 -0
- package/lib/utils/cache.js +34 -0
- package/lib/utils/common.d.ts +7 -0
- package/lib/utils/common.js +58 -0
- package/lib/utils/concurrency.d.ts +8 -0
- package/lib/utils/concurrency.js +29 -0
- package/lib/utils/field-mapping.d.ts +2 -0
- package/lib/utils/field-mapping.js +29 -0
- package/lib/utils/format.d.ts +4 -0
- package/lib/utils/format.js +79 -0
- package/lib/utils/gif.d.ts +10 -0
- package/lib/utils/gif.js +104 -0
- package/lib/utils/image-size.d.ts +10 -0
- package/lib/utils/image-size.js +69 -0
- package/lib/utils/merge.d.ts +61 -0
- package/lib/utils/merge.js +388 -0
- package/lib/utils/tls-client.d.ts +30 -0
- package/lib/utils/tls-client.js +120 -0
- package/lib/utils/url.d.ts +10 -0
- package/lib/utils/url.js +89 -0
- package/package.json +16 -4
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchGrokTranslation = fetchGrokTranslation;
|
|
4
|
+
exports.parseTwitter = parseTwitter;
|
|
5
|
+
const tls_client_1 = require("../utils/tls-client");
|
|
6
|
+
/**
|
|
7
|
+
* X / Twitter 原生解析器。
|
|
8
|
+
*
|
|
9
|
+
* - 公开推文:走 syndication API(cdn.syndication.twimg.com),无需登录,Node 友好。
|
|
10
|
+
* - 需登录推文(syndication 返回 tombstone):若提供 {authToken, ct0},回退到
|
|
11
|
+
* GraphQL TweetResultByRestId(仅用 auth_token + ct0 两个 cookie,最小化)。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 重要限制:X 的 GraphQL 端点受 Cloudflare TLS 指纹校验保护。纯 Node/axios 的
|
|
14
|
+
* TLS 握手(JA3/JA4)与 Chrome 不同,会被 CF 直接 403(cookie 到不了应用层)。
|
|
15
|
+
* 因此登录态回退由 tlsget-rs(wreq/BoringSSL,Chrome 指纹)完成;未安装该
|
|
16
|
+
* 可选依赖时会返回明确错误。
|
|
17
|
+
*/
|
|
18
|
+
const SYNDICATION_URL = 'https://cdn.syndication.twimg.com/tweet-result';
|
|
19
|
+
// X 网页端公开 bearer(抓包固定值,非密钥)
|
|
20
|
+
const WEB_BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
21
|
+
// TweetResultByRestId 的 queryId(随 web 版本变动,可被覆盖)
|
|
22
|
+
const TWEET_RESULT_QUERY_ID = 'GZsN2Pc4knAoit6pXa4HSA';
|
|
23
|
+
const GRAPHQL_FEATURES = {
|
|
24
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
25
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
26
|
+
view_counts_everywhere_api_enabled: true,
|
|
27
|
+
longform_notetweets_consumption_enabled: true,
|
|
28
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
29
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
30
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
31
|
+
standardized_nudges_misinfo: true,
|
|
32
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
33
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
34
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
35
|
+
};
|
|
36
|
+
/** Grok 翻译特性集(与网页端抓包一致;关键是 grok_show_grok_translated_post 开关) */
|
|
37
|
+
const GROK_FEATURES = {
|
|
38
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
39
|
+
premium_content_api_read_enabled: false,
|
|
40
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
41
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
42
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
43
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
44
|
+
rweb_cashtags_composer_attachment_enabled: true,
|
|
45
|
+
responsive_web_jetfuel_frame: true,
|
|
46
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
47
|
+
responsive_web_grok_annotations_enabled: true,
|
|
48
|
+
articles_preview_enabled: true,
|
|
49
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
50
|
+
rweb_conversational_replies_downvote_enabled: false,
|
|
51
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
52
|
+
view_counts_everywhere_api_enabled: true,
|
|
53
|
+
longform_notetweets_consumption_enabled: true,
|
|
54
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
55
|
+
content_disclosure_indicator_enabled: true,
|
|
56
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
57
|
+
responsive_web_grok_show_grok_translated_post: true,
|
|
58
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
59
|
+
post_ctas_fetch_enabled: false,
|
|
60
|
+
rweb_cashtags_enabled: true,
|
|
61
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
62
|
+
standardized_nudges_misinfo: true,
|
|
63
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
64
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
65
|
+
longform_notetweets_inline_media_enabled: false,
|
|
66
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
67
|
+
responsive_web_profile_redirect_enabled: true,
|
|
68
|
+
rweb_tipjar_consumption_enabled: false,
|
|
69
|
+
verified_phone_label_enabled: false,
|
|
70
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
71
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
72
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: true,
|
|
73
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
74
|
+
};
|
|
75
|
+
const GROK_FIELD_TOGGLES = {
|
|
76
|
+
withArticleRichContentState: true,
|
|
77
|
+
withArticlePlainText: false,
|
|
78
|
+
withArticleSummaryText: true,
|
|
79
|
+
withArticleVoiceOver: true,
|
|
80
|
+
};
|
|
81
|
+
/** 目标语言 → x-twitter-client-language(译文语言由该头驱动) */
|
|
82
|
+
function clientLanguage(target) {
|
|
83
|
+
const t = target.toLowerCase();
|
|
84
|
+
if (t === 'zh')
|
|
85
|
+
return 'zh-cn';
|
|
86
|
+
if (t === 'zh-tw')
|
|
87
|
+
return 'zh-tw';
|
|
88
|
+
return t;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* X 原生 Grok 翻译(与网页"翻译推文"同源)。
|
|
92
|
+
* 需登录态;网页抓包实证:grok 特性开关 + client-language 头即可,无需
|
|
93
|
+
* x-client-transaction-id / cf_clearance。任何失败返回 null 由调用方回落通用翻译。
|
|
94
|
+
*/
|
|
95
|
+
async function fetchGrokTranslation(url, targetLang, creds, get = tls_client_1.tlsGet) {
|
|
96
|
+
const id = extractTweetId(url);
|
|
97
|
+
if (!id)
|
|
98
|
+
return null;
|
|
99
|
+
const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
|
|
100
|
+
const gqlUrl = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
|
|
101
|
+
`?variables=${encodeURIComponent(JSON.stringify(variables))}` +
|
|
102
|
+
`&features=${encodeURIComponent(JSON.stringify(GROK_FEATURES))}` +
|
|
103
|
+
`&fieldToggles=${encodeURIComponent(JSON.stringify(GROK_FIELD_TOGGLES))}`;
|
|
104
|
+
let res;
|
|
105
|
+
try {
|
|
106
|
+
res = await get(gqlUrl, {
|
|
107
|
+
headers: {
|
|
108
|
+
authorization: `Bearer ${WEB_BEARER}`,
|
|
109
|
+
'x-csrf-token': creds.ct0,
|
|
110
|
+
'x-twitter-auth-type': 'OAuth2Session',
|
|
111
|
+
'x-twitter-active-user': 'yes',
|
|
112
|
+
'x-twitter-client-language': clientLanguage(targetLang),
|
|
113
|
+
},
|
|
114
|
+
cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
|
|
115
|
+
timeout: 30000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (res.status !== 200)
|
|
122
|
+
return null;
|
|
123
|
+
const node = res.data?.data?.tweetResult?.result?.grok_translated_post_with_availability;
|
|
124
|
+
if (!node || node.is_available !== true)
|
|
125
|
+
return null;
|
|
126
|
+
const text = node.data?.translation;
|
|
127
|
+
if (typeof text !== 'string' || !text.trim())
|
|
128
|
+
return null;
|
|
129
|
+
return { text: text.trim(), sourceLang: node.data?.source_language ? String(node.data.source_language) : undefined };
|
|
130
|
+
}
|
|
131
|
+
function extractTweetId(url) {
|
|
132
|
+
const m = /\/status(?:es)?\/(\d+)/.exec(url);
|
|
133
|
+
return m ? m[1] : null;
|
|
134
|
+
}
|
|
135
|
+
function pick(...vals) {
|
|
136
|
+
for (const v of vals)
|
|
137
|
+
if (v !== undefined && v !== null && v !== '')
|
|
138
|
+
return v;
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
/** 清理描述中的 t.co 短链(Twitter 自动附加的截断 URL,无实际内容价值) */
|
|
142
|
+
function cleanDesc(text) {
|
|
143
|
+
if (!text)
|
|
144
|
+
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();
|
|
149
|
+
return cleaned || text;
|
|
150
|
+
}
|
|
151
|
+
function baseParsed() {
|
|
152
|
+
return {
|
|
153
|
+
type: 'video', title: '', desc: '', author: '', uid: '', avatar: '', cover: '',
|
|
154
|
+
video: '', videos: [], images: [], live_photo: [], music: {},
|
|
155
|
+
like: 0, comment: 0, collect: 0, share: 0, play: 0, duration: 0, publishTime: 0,
|
|
156
|
+
author_followers: 0, author_signature: '', admire: 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** 归一化变体列表(三种来源的字段名不同:url/src、content_type/type) */
|
|
160
|
+
function normalizeVariants(variants) {
|
|
161
|
+
return variants
|
|
162
|
+
.filter((v) => v && (v.url || v.src))
|
|
163
|
+
.map((v) => ({
|
|
164
|
+
quality: v.bitrate ? `${v.bitrate}bps` : (v.content_type || v.type || 'unknown'),
|
|
165
|
+
url: v.url || v.src,
|
|
166
|
+
bit_rate: Number(v.bitrate || 0),
|
|
167
|
+
}))
|
|
168
|
+
.sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 统一媒体提取(新结构优先,旧结构兜底):
|
|
172
|
+
* - 新:mediaDetails[](photo/video_info.variants)+ 顶层 video(poster/src variants)
|
|
173
|
+
* - 旧:videoDetails(posterUrl/url variants)+ photos[]
|
|
174
|
+
*/
|
|
175
|
+
function extractSyndicationMedia(tw, p) {
|
|
176
|
+
// 1) mediaDetails[]:新版标准位置
|
|
177
|
+
if (Array.isArray(tw.mediaDetails)) {
|
|
178
|
+
for (const m of tw.mediaDetails) {
|
|
179
|
+
if (!m)
|
|
180
|
+
continue;
|
|
181
|
+
if (m.type === 'photo') {
|
|
182
|
+
if (m.media_url_https && !p.images.includes(m.media_url_https))
|
|
183
|
+
p.images.push(m.media_url_https);
|
|
184
|
+
}
|
|
185
|
+
else if ((m.type === 'video' || m.type === 'animated_gif') && Array.isArray(m.video_info?.variants)) {
|
|
186
|
+
const vs = normalizeVariants(m.video_info.variants);
|
|
187
|
+
if (vs.length) {
|
|
188
|
+
p.videos.push(...vs);
|
|
189
|
+
const dur = m.video_info.duration_millis ? Math.floor(Number(m.video_info.duration_millis) / 1000) : 0;
|
|
190
|
+
if (!p.video) {
|
|
191
|
+
p.video = vs[0].url;
|
|
192
|
+
p.cover = String(pick(m.media_url_https, p.cover));
|
|
193
|
+
if (dur)
|
|
194
|
+
p.duration = dur;
|
|
195
|
+
if (m.type === 'animated_gif')
|
|
196
|
+
p.isGif = true;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// 多视频推文:其余视频逐条携带(发送层每条独立发送,封面随行用于展示与单独审核)
|
|
200
|
+
;
|
|
201
|
+
(p.extraVideos || (p.extraVideos = [])).push({ url: vs[0].url, isGif: m.type === 'animated_gif', duration: dur, cover: String(pick(m.media_url_https, '')) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 2) 顶层 video(amplify 等):poster + variants
|
|
208
|
+
if (!p.video && Array.isArray(tw.video?.variants)) {
|
|
209
|
+
const vs = normalizeVariants(tw.video.variants);
|
|
210
|
+
if (vs.length) {
|
|
211
|
+
p.videos.push(...vs);
|
|
212
|
+
p.video = vs[0].url;
|
|
213
|
+
p.cover = String(pick(tw.video.poster, p.cover));
|
|
214
|
+
if (tw.video.durationMs)
|
|
215
|
+
p.duration = Math.floor(Number(tw.video.durationMs) / 1000);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 3) 旧结构兜底:videoDetails + photos
|
|
219
|
+
if (!p.video && Array.isArray(tw.videoDetails?.variants)) {
|
|
220
|
+
const vs = normalizeVariants(tw.videoDetails.variants);
|
|
221
|
+
if (vs.length) {
|
|
222
|
+
p.videos.push(...vs);
|
|
223
|
+
p.video = vs[0].url;
|
|
224
|
+
p.cover = String(pick(tw.videoDetails.posterUrl, p.cover));
|
|
225
|
+
if (tw.videoDetails.durationMs)
|
|
226
|
+
p.duration = Math.floor(Number(tw.videoDetails.durationMs) / 1000);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!p.images.length && Array.isArray(tw.photos)) {
|
|
230
|
+
p.images = tw.photos.map((x) => (typeof x === 'string' ? x : x?.url)).filter((u) => !!u);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** 把 syndication 响应映射为 ParsedData */
|
|
234
|
+
function mapSyndication(tw) {
|
|
235
|
+
const user = tw.user || {};
|
|
236
|
+
const text = String(pick(tw.note_tweet?.text, tw.text, ''));
|
|
237
|
+
const p = baseParsed();
|
|
238
|
+
extractSyndicationMedia(tw, p);
|
|
239
|
+
if (!p.cover)
|
|
240
|
+
p.cover = String(pick(p.images[0], ''));
|
|
241
|
+
// 纯文字推文:type=text(无任何媒体但有正文,属合法内容)
|
|
242
|
+
p.type = p.video ? 'video' : (p.images.length ? 'image' : 'text');
|
|
243
|
+
// 标题/简介同源;标题也须基于清理后的文本,否则 t.co 短链会让去重判断失效
|
|
244
|
+
const cleaned = cleanDesc(text);
|
|
245
|
+
p.title = cleaned.slice(0, 100);
|
|
246
|
+
p.desc = cleaned;
|
|
247
|
+
p.lang = tw.lang ? String(tw.lang) : undefined;
|
|
248
|
+
p.author = String(pick(user.name, user.screen_name, ''));
|
|
249
|
+
p.uid = String(pick(user.screen_name, user.id_str, ''));
|
|
250
|
+
p.avatar = String(pick(user.profile_image_url_https, user.profile_image_url, ''));
|
|
251
|
+
p.like = Number(pick(tw.favorite_count, 0)) || 0;
|
|
252
|
+
p.comment = Number(pick(tw.conversation_count, tw.reply_count, 0)) || 0;
|
|
253
|
+
p.share = Number(pick(tw.retweet_count, 0)) || 0;
|
|
254
|
+
p.play = Number(pick(tw.video?.viewCount, tw.videoDetails?.viewCount, tw.views, 0)) || 0;
|
|
255
|
+
p.collect = Number(pick(tw.bookmark_count, 0)) || 0;
|
|
256
|
+
if (tw.created_at) {
|
|
257
|
+
const t = Date.parse(tw.created_at);
|
|
258
|
+
if (!isNaN(t))
|
|
259
|
+
p.publishTime = t;
|
|
260
|
+
}
|
|
261
|
+
p.author_followers = Number(pick(user.followers_count, 0)) || 0;
|
|
262
|
+
p.author_signature = String(pick(user.description, ''));
|
|
263
|
+
if (p.title && p.desc && p.desc.startsWith(p.title))
|
|
264
|
+
p.title = '';
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
267
|
+
/** 把 GraphQL TweetResultByRestId 响应映射为 ParsedData */
|
|
268
|
+
function mapGraphql(rawResult) {
|
|
269
|
+
// NSFW/受限推文会被 TweetWithVisibilityResults 包裹,真实推文在其 .tweet 下
|
|
270
|
+
let result = rawResult;
|
|
271
|
+
if (result && result.__typename === 'TweetWithVisibilityResults' && result.tweet) {
|
|
272
|
+
result = result.tweet;
|
|
273
|
+
}
|
|
274
|
+
const legacy = result.legacy || {};
|
|
275
|
+
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, ''));
|
|
278
|
+
const p = baseParsed();
|
|
279
|
+
const media = Array.isArray(legacy.entities?.media) ? legacy.entities.media : [];
|
|
280
|
+
const photos = [];
|
|
281
|
+
for (const m of media) {
|
|
282
|
+
if (!m)
|
|
283
|
+
continue;
|
|
284
|
+
if (m.type === 'photo') {
|
|
285
|
+
if (m.media_url_https)
|
|
286
|
+
photos.push(m.media_url_https);
|
|
287
|
+
}
|
|
288
|
+
else if ((m.type === 'video' || m.type === 'animated_gif') && m.video_info?.variants?.length) {
|
|
289
|
+
const vs = m.video_info.variants
|
|
290
|
+
.filter((v) => v && v.url && (v.content_type || '').includes('mp4'))
|
|
291
|
+
.map((v) => ({ quality: v.bitrate ? `${v.bitrate}bps` : 'unknown', url: v.url, bit_rate: Number(v.bitrate || 0) }))
|
|
292
|
+
.sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
|
|
293
|
+
if (vs.length) {
|
|
294
|
+
p.videos.push(...vs);
|
|
295
|
+
const dur = m.video_info.duration_millis ? Math.floor(Number(m.video_info.duration_millis) / 1000) : 0;
|
|
296
|
+
if (!p.video) {
|
|
297
|
+
p.video = vs[0].url;
|
|
298
|
+
p.cover = String(pick(m.media_url_https, p.cover));
|
|
299
|
+
if (dur)
|
|
300
|
+
p.duration = dur;
|
|
301
|
+
if (m.type === 'animated_gif')
|
|
302
|
+
p.isGif = true;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
;
|
|
306
|
+
(p.extraVideos || (p.extraVideos = [])).push({ url: vs[0].url, isGif: m.type === 'animated_gif', duration: dur, cover: String(pick(m.media_url_https, '')) });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
p.images = photos;
|
|
312
|
+
if (!p.cover)
|
|
313
|
+
p.cover = String(pick(photos[0], ''));
|
|
314
|
+
// 纯文字推文:type=text
|
|
315
|
+
p.type = p.video ? 'video' : (p.images.length ? 'image' : 'text');
|
|
316
|
+
// 标题/简介同源;标题也须基于清理后的文本,否则 t.co 短链会让去重判断失效
|
|
317
|
+
const cleaned = cleanDesc(text);
|
|
318
|
+
p.title = cleaned.slice(0, 100);
|
|
319
|
+
p.desc = cleaned;
|
|
320
|
+
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, ''));
|
|
324
|
+
p.like = Number(pick(legacy.favorite_count, 0)) || 0;
|
|
325
|
+
p.comment = Number(pick(legacy.reply_count, 0)) || 0;
|
|
326
|
+
p.share = Number(pick(legacy.retweet_count, 0)) || 0;
|
|
327
|
+
p.play = Number(pick(result.views?.count, 0)) || 0;
|
|
328
|
+
p.collect = Number(pick(legacy.bookmark_count, 0)) || 0;
|
|
329
|
+
if (legacy.created_at) {
|
|
330
|
+
const t = Date.parse(legacy.created_at);
|
|
331
|
+
if (!isNaN(t))
|
|
332
|
+
p.publishTime = t;
|
|
333
|
+
}
|
|
334
|
+
p.author_followers = Number(pick(ulegacy.followers_count, 0)) || 0;
|
|
335
|
+
p.author_signature = String(pick(ulegacy.description, ''));
|
|
336
|
+
if (p.title && p.desc && p.desc.startsWith(p.title))
|
|
337
|
+
p.title = '';
|
|
338
|
+
return p;
|
|
339
|
+
}
|
|
340
|
+
/** 鉴权 GraphQL:仅用 auth_token + ct0,回退取登录受限推文 */
|
|
341
|
+
async function fetchGraphqlTweet(id, creds, get) {
|
|
342
|
+
const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
|
|
343
|
+
const url = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
|
|
344
|
+
`?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(GRAPHQL_FEATURES))}`;
|
|
345
|
+
let res;
|
|
346
|
+
try {
|
|
347
|
+
res = await get(url, {
|
|
348
|
+
headers: {
|
|
349
|
+
authorization: 'Bearer ' + WEB_BEARER,
|
|
350
|
+
'x-csrf-token': creds.ct0,
|
|
351
|
+
'x-twitter-auth-type': 'OAuth2Session',
|
|
352
|
+
'x-twitter-active-user': 'yes',
|
|
353
|
+
'x-twitter-client-language': 'en',
|
|
354
|
+
},
|
|
355
|
+
cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
|
|
356
|
+
timeout: 30000,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
throw new Error(`X GraphQL 请求失败:${e?.message || e}(需登录的推文)`);
|
|
361
|
+
}
|
|
362
|
+
if (res.status === 403 || res.status === 429) {
|
|
363
|
+
throw new Error(`X GraphQL 被 Cloudflare 拦截 (HTTP ${res.status}):TLS 指纹校验未通过。` +
|
|
364
|
+
`请确认已安装可选依赖 @char46/tlsget-rs(npm i @char46/tlsget-rs),它提供浏览器级 TLS 指纹。`);
|
|
365
|
+
}
|
|
366
|
+
if (res.status !== 200) {
|
|
367
|
+
throw new Error(`X GraphQL 返回 HTTP ${res.status}:${typeof res.data === 'string' ? res.data.slice(0, 120) : JSON.stringify(res.data || {}).slice(0, 120)}`);
|
|
368
|
+
}
|
|
369
|
+
const result = res.data?.data?.tweetResult?.result;
|
|
370
|
+
if (!result)
|
|
371
|
+
throw new Error('X GraphQL 返回无数据');
|
|
372
|
+
if (result.__typename === 'TweetTombstone' || result.__typename === 'TweetUnavailable') {
|
|
373
|
+
const tb = result?.tombstone?.text?.text || result?.tombstone?.text;
|
|
374
|
+
throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${tb ? ':' + tb : ''}`);
|
|
375
|
+
}
|
|
376
|
+
return mapGraphql(result);
|
|
377
|
+
}
|
|
378
|
+
async function parseTwitter(url, http, creds, getGraphql) {
|
|
379
|
+
const id = extractTweetId(url);
|
|
380
|
+
if (!id)
|
|
381
|
+
throw new Error('无法从 X 链接提取推文 ID');
|
|
382
|
+
// 1) 公开 syndication 路径
|
|
383
|
+
const res = await http.get(SYNDICATION_URL, {
|
|
384
|
+
params: { id, token: 'a' },
|
|
385
|
+
timeout: 30000,
|
|
386
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
|
387
|
+
});
|
|
388
|
+
const tw = res.data;
|
|
389
|
+
if (tw && tw.__typename === 'Tweet' && tw.user) {
|
|
390
|
+
return mapSyndication(tw);
|
|
391
|
+
}
|
|
392
|
+
// 2) tombstone(需登录):回退到鉴权 GraphQL(TLS 指纹模拟)
|
|
393
|
+
if (creds && creds.authToken && creds.ct0) {
|
|
394
|
+
return fetchGraphqlTweet(id, creds, getGraphql || tls_client_1.tlsGet);
|
|
395
|
+
}
|
|
396
|
+
const reasonRaw = pick(tw?.tombstone?.text, tw?.tombstone?.name);
|
|
397
|
+
throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${reasonRaw ? ':' + reasonRaw : ''}`);
|
|
398
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -8,3 +8,22 @@ export * from './sender';
|
|
|
8
8
|
export * from './extensions';
|
|
9
9
|
export * from './platform';
|
|
10
10
|
export * from './host';
|
|
11
|
+
export * from './utils/common';
|
|
12
|
+
export * from './utils/url';
|
|
13
|
+
export * from './utils/format';
|
|
14
|
+
export * from './utils/cache';
|
|
15
|
+
export * from './utils/concurrency';
|
|
16
|
+
export * from './utils/field-mapping';
|
|
17
|
+
export * from './utils/image-size';
|
|
18
|
+
export * from './utils/tls-client';
|
|
19
|
+
export * from './utils/gif';
|
|
20
|
+
export * from './utils/merge';
|
|
21
|
+
export * from './engine/gateway';
|
|
22
|
+
export * from './engine/platform-config';
|
|
23
|
+
export * from './engine/translate';
|
|
24
|
+
export * from './engine/twitter';
|
|
25
|
+
export * from './engine/parser';
|
|
26
|
+
export * from './engine/fetcher';
|
|
27
|
+
export * from './engine/runtime';
|
|
28
|
+
export * from './engine/config-io';
|
|
29
|
+
export * from './registry';
|
package/lib/index.js
CHANGED
|
@@ -24,3 +24,22 @@ __exportStar(require("./sender"), exports);
|
|
|
24
24
|
__exportStar(require("./extensions"), exports);
|
|
25
25
|
__exportStar(require("./platform"), exports);
|
|
26
26
|
__exportStar(require("./host"), exports);
|
|
27
|
+
__exportStar(require("./utils/common"), exports);
|
|
28
|
+
__exportStar(require("./utils/url"), exports);
|
|
29
|
+
__exportStar(require("./utils/format"), exports);
|
|
30
|
+
__exportStar(require("./utils/cache"), exports);
|
|
31
|
+
__exportStar(require("./utils/concurrency"), exports);
|
|
32
|
+
__exportStar(require("./utils/field-mapping"), exports);
|
|
33
|
+
__exportStar(require("./utils/image-size"), exports);
|
|
34
|
+
__exportStar(require("./utils/tls-client"), exports);
|
|
35
|
+
__exportStar(require("./utils/gif"), exports);
|
|
36
|
+
__exportStar(require("./utils/merge"), exports);
|
|
37
|
+
__exportStar(require("./engine/gateway"), exports);
|
|
38
|
+
__exportStar(require("./engine/platform-config"), exports);
|
|
39
|
+
__exportStar(require("./engine/translate"), exports);
|
|
40
|
+
__exportStar(require("./engine/twitter"), exports);
|
|
41
|
+
__exportStar(require("./engine/parser"), exports);
|
|
42
|
+
__exportStar(require("./engine/fetcher"), exports);
|
|
43
|
+
__exportStar(require("./engine/runtime"), exports);
|
|
44
|
+
__exportStar(require("./engine/config-io"), exports);
|
|
45
|
+
__exportStar(require("./registry"), exports);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PlatformDefinition } from './platform';
|
|
2
|
+
import type { ConfigContribution } from './config';
|
|
3
|
+
import type { VideoParserExtensions } from './extensions';
|
|
4
|
+
/** 收集已安装平台的定义(含 label/rules/dedicated) */
|
|
5
|
+
export declare function collectPlatformDefinitions(): PlatformDefinition[];
|
|
6
|
+
/** 已安装平台的链接规则(扁平) */
|
|
7
|
+
export declare function collectPlatformLinkRules(): {
|
|
8
|
+
pattern: RegExp;
|
|
9
|
+
type: string;
|
|
10
|
+
}[];
|
|
11
|
+
export interface LoadedExtension {
|
|
12
|
+
name: string;
|
|
13
|
+
contribution?: ConfigContribution;
|
|
14
|
+
extension?: Partial<VideoParserExtensions>;
|
|
15
|
+
}
|
|
16
|
+
/** 探测并加载全部已安装扩展 */
|
|
17
|
+
export declare function loadExtensions(): LoadedExtension[];
|
|
18
|
+
export declare function loadExtensionContributions(): ConfigContribution[];
|
|
19
|
+
/** 合并所有已安装扩展的能力片段 */
|
|
20
|
+
export declare function loadExtensionImplementations(): Partial<VideoParserExtensions>;
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectPlatformDefinitions = collectPlatformDefinitions;
|
|
4
|
+
exports.collectPlatformLinkRules = collectPlatformLinkRules;
|
|
5
|
+
exports.loadExtensions = loadExtensions;
|
|
6
|
+
exports.loadExtensionContributions = loadExtensionContributions;
|
|
7
|
+
exports.loadExtensionImplementations = loadExtensionImplementations;
|
|
8
|
+
/**
|
|
9
|
+
* 已安装碎片包的动态收集(平台 @sns-parse/platform-* 与扩展 @sns-parse/ext-*)。
|
|
10
|
+
*
|
|
11
|
+
* - 可自选安装单个碎片包;安装聚合包(@sns-parse/platforms / extensions)则全部到位
|
|
12
|
+
* - 未安装的碎片自动缺席(链接识别、配置项与能力同时缺席)
|
|
13
|
+
* - koishi 兼容层与 CLI 均经此处收集定义与配置声明
|
|
14
|
+
*/
|
|
15
|
+
const node_module_1 = require("node:module");
|
|
16
|
+
const nodeRequire = (0, node_module_1.createRequire)(typeof __filename !== 'undefined' ? __filename : process.cwd() + '/');
|
|
17
|
+
const PLATFORM_TYPES = [
|
|
18
|
+
'bilibili', 'douyin', 'kuaishou', 'xiaohongshu', 'weibo', 'xigua', 'youtube',
|
|
19
|
+
'tiktok', 'acfun', 'zhihu', 'weishi', 'huya', 'haokan', 'meipai', 'twitter',
|
|
20
|
+
'instagram', 'doubao', 'doubao_image', 'jimeng', 'oasis', 'wechat_channel',
|
|
21
|
+
'lishi', 'quanmin', 'pipigx', 'pipixia', 'zuiyou', 'toutiao',
|
|
22
|
+
];
|
|
23
|
+
/** 收集已安装平台的定义(含 label/rules/dedicated) */
|
|
24
|
+
function collectPlatformDefinitions() {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const type of PLATFORM_TYPES) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = nodeRequire(`@sns-parse/platform-${type}`);
|
|
29
|
+
const def = mod.default || mod[type];
|
|
30
|
+
if (def && def.type)
|
|
31
|
+
out.push(def);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// 未安装该平台包 → 跳过
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** 已安装平台的链接规则(扁平) */
|
|
40
|
+
function collectPlatformLinkRules() {
|
|
41
|
+
return collectPlatformDefinitions().flatMap(d => d.rules.map(pattern => ({ pattern, type: d.type })));
|
|
42
|
+
}
|
|
43
|
+
const EXTENSION_SPECS = [
|
|
44
|
+
{ name: 'ext-nsfw', contributionKey: 'nsfwConfigContribution', extensionKey: 'nsfwExtension' },
|
|
45
|
+
{ name: 'ext-merge', extensionKey: 'mergeExtension' },
|
|
46
|
+
{ name: 'ext-translate', extensionKey: 'translateExtension' },
|
|
47
|
+
{ name: 'ext-gif', extensionKey: 'gifExtension' },
|
|
48
|
+
];
|
|
49
|
+
/** 探测并加载全部已安装扩展 */
|
|
50
|
+
function loadExtensions() {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const spec of EXTENSION_SPECS) {
|
|
53
|
+
try {
|
|
54
|
+
const mod = nodeRequire(`@sns-parse/${spec.name}`);
|
|
55
|
+
out.push({
|
|
56
|
+
name: spec.name,
|
|
57
|
+
contribution: spec.contributionKey ? mod[spec.contributionKey] : undefined,
|
|
58
|
+
extension: spec.extensionKey && typeof mod[spec.extensionKey] === 'function'
|
|
59
|
+
? mod[spec.extensionKey]()
|
|
60
|
+
: undefined,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// 未安装该扩展 → 跳过(配置项与能力同时缺席)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function loadExtensionContributions() {
|
|
70
|
+
return loadExtensions()
|
|
71
|
+
.map(e => e.contribution)
|
|
72
|
+
.filter((c) => !!c);
|
|
73
|
+
}
|
|
74
|
+
/** 合并所有已安装扩展的能力片段 */
|
|
75
|
+
function loadExtensionImplementations() {
|
|
76
|
+
return Object.assign({}, ...loadExtensions().map(e => e.extension || {}));
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SimpleLRUCache = void 0;
|
|
4
|
+
class SimpleLRUCache {
|
|
5
|
+
constructor(max, ttlMs) {
|
|
6
|
+
this.max = max;
|
|
7
|
+
this.ttlMs = ttlMs;
|
|
8
|
+
this.map = new Map();
|
|
9
|
+
}
|
|
10
|
+
get(key) {
|
|
11
|
+
const entry = this.map.get(key);
|
|
12
|
+
if (!entry)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (Date.now() > entry.expireAt) {
|
|
15
|
+
this.map.delete(key);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return entry.value;
|
|
19
|
+
}
|
|
20
|
+
set(key, value) {
|
|
21
|
+
this.map.delete(key);
|
|
22
|
+
while (this.map.size >= this.max) {
|
|
23
|
+
const k = this.map.keys().next().value;
|
|
24
|
+
if (k === undefined)
|
|
25
|
+
break;
|
|
26
|
+
this.map.delete(k);
|
|
27
|
+
}
|
|
28
|
+
this.map.set(key, { value, expireAt: Date.now() + this.ttlMs });
|
|
29
|
+
}
|
|
30
|
+
clear() {
|
|
31
|
+
this.map.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.SimpleLRUCache = SimpleLRUCache;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { VideoQuality, ParsedData } from '../types';
|
|
2
|
+
export declare const delay: (ms: number) => Promise<unknown>;
|
|
3
|
+
export declare function getErrorMessage(error: unknown): string;
|
|
4
|
+
export declare function parseCount(val: any): number;
|
|
5
|
+
export declare function pickBestQuality(videoBackup: any[]): VideoQuality[];
|
|
6
|
+
export declare function contentFingerprint(p: ParsedData): string;
|
|
7
|
+
export declare function getText(config: any, key: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.delay = void 0;
|
|
4
|
+
exports.getErrorMessage = getErrorMessage;
|
|
5
|
+
exports.parseCount = parseCount;
|
|
6
|
+
exports.pickBestQuality = pickBestQuality;
|
|
7
|
+
exports.contentFingerprint = contentFingerprint;
|
|
8
|
+
exports.getText = getText;
|
|
9
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
exports.delay = delay;
|
|
11
|
+
function getErrorMessage(error) {
|
|
12
|
+
if (error instanceof Error)
|
|
13
|
+
return error.message;
|
|
14
|
+
if (error && typeof error === 'object' && 'message' in error)
|
|
15
|
+
return String(error.message);
|
|
16
|
+
return String(error);
|
|
17
|
+
}
|
|
18
|
+
function parseCount(val) {
|
|
19
|
+
if (val === undefined || val === null)
|
|
20
|
+
return 0;
|
|
21
|
+
if (typeof val === 'number')
|
|
22
|
+
return val;
|
|
23
|
+
const str = String(val).trim();
|
|
24
|
+
if (str.includes('万')) {
|
|
25
|
+
const num = parseFloat(str);
|
|
26
|
+
return isNaN(num) ? 0 : Math.round(num * 10000);
|
|
27
|
+
}
|
|
28
|
+
if (str.includes('亿')) {
|
|
29
|
+
const num = parseFloat(str);
|
|
30
|
+
return isNaN(num) ? 0 : Math.round(num * 100000000);
|
|
31
|
+
}
|
|
32
|
+
const num = parseInt(str, 10);
|
|
33
|
+
return isNaN(num) ? 0 : num;
|
|
34
|
+
}
|
|
35
|
+
function pickBestQuality(videoBackup) {
|
|
36
|
+
if (!Array.isArray(videoBackup))
|
|
37
|
+
return [];
|
|
38
|
+
return videoBackup.filter(v => v && v.url).map(v => ({
|
|
39
|
+
quality: v.quality || v.label || 'unknown',
|
|
40
|
+
url: v.url,
|
|
41
|
+
bit_rate: Number(v.bit_rate || 0)
|
|
42
|
+
})).sort((a, b) => b.bit_rate - a.bit_rate);
|
|
43
|
+
}
|
|
44
|
+
function contentFingerprint(p) {
|
|
45
|
+
const imgSig = p.images?.length ? p.images.slice(0, 3).join('|') : (p.live_photo?.slice(0, 3).map(lp => lp.image).join('|') || '');
|
|
46
|
+
return [p.type, p.title, p.author, p.uid, p.video, imgSig].map(v => String(v ?? '')).join('::');
|
|
47
|
+
}
|
|
48
|
+
function getText(config, key) {
|
|
49
|
+
const defaults = {
|
|
50
|
+
waitingTipText: '正在解析视频,请稍候...',
|
|
51
|
+
unsupportedPlatformText: '不支持该平台链接',
|
|
52
|
+
invalidLinkText: '无效的视频链接',
|
|
53
|
+
parseErrorPrefix: '❌ 解析失败:',
|
|
54
|
+
parseErrorItemFormat: '【${url}】: ${msg}',
|
|
55
|
+
deduplicationTipText: '链接 ${url} 在最近 ${interval} 秒内已解析过,已跳过。',
|
|
56
|
+
};
|
|
57
|
+
return config[key] || defaults[key] || '';
|
|
58
|
+
}
|