🏠 Home 

Twitter Media Downloader

Save Video/Photo by One-Click.

< Feedback on Twitter Media Downloader

Review: Good - script works

§
Posted: 2024-01-12
Edited: 2024-01-12

看到之前的反馈有人提到有时无法下载
关于不能下载可能是请求API中ID限额的问题,不知道有没有解决,但是我看最新的代码还是固定的ID

我也找到了这个请求https://twitter.com/i/api/graphql/${apidTwetDetail}/TweetDetail
大概研究了下,这个应该是每个人都不一样,控制台的话可以使用下面这段代码获取
下载不了的小伙伴可以尝试自己更改一下代码中的ID

webpackChunk_twitter_responsive_web.forEach((arr) => {
let funcMap = new Map(Object.entries(arr[1]))
for (let func of funcMap.values()) {
if (func.toString().includes('operationName:"TweetDetail",')) {
let e = new Object();
func(e);
console.log(e.exports.queryId);
}
}
});

脚本因为js上下文隔离的问题,情况不太一样
油猴比较麻烦,只能通过修改原型链方式截获请求(也可能有我没想到的办法)
插件的脚本方便一些,可以向页面插入script标签方式间接获取

const opNameList = [
'UserByScreenName',
'Following',
'UserMedia'
]
const apidEventObj = document.createElement('object');
apidEventObj.id = 'hex7c00-twitter-apid';
document.body.append(apidEventObj);
apidEventObj.addEventListener('hex7c00-get-twitter-apid', () => {
let detail = new Map();
webpackChunk_twitter_responsive_web.forEach((i) => {
let funcMap = new Map(Object.entries(i[1]))
for (let func of funcMap.values()) {
opNameList.forEach((opName) => {
if (func.toString().includes(`operationName:"${opName}",`)) {
let e = new Object();
func(e);
detail.set(opName, e.exports.queryId);
}
});
}
});
apidEventObj.dispatchEvent(new CustomEvent('hex7c00-twitter-apid', { 'detail': detail }));
});

因为之前一直在用的脚本前段时间突然不能用了,最开始自己尝试在网页端模拟请求无果,本以为推特的请求只能通过官方100刀的api获取,后来看到作者脚本代码才成功请求到数据,在此表示感谢。

在这里也提供一些我封装的API请求方法,希望在后续更新中有所帮助。

const UserByScreenName_features = {
"hidden_profile_likes_enabled": true,
"hidden_profile_subscriptions_enabled": true,
"responsive_web_graphql_exclude_directive_enabled": true,
"verified_phone_label_enabled": false,
"subscriptions_verification_info_is_identity_verified_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"highlights_tweets_tab_ui_enabled": true,
"responsive_web_twitter_article_notes_tab_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": true
};
const UserByScreenName_fieldToggles = {
"withAuxiliaryUserLabels": false
};
const UserByScreenName_features_JSON = JSON.stringify(UserByScreenName_features);
const UserByScreenName_fieldToggles_JSON = JSON.stringify(UserByScreenName_fieldToggles);
const fetchUserByScreenName = async (userName) => {
let baseURL = `https://twitter.com/i/api/graphql/${apidUserByScreenName}/UserByScreenName`;
let variables = {
"screen_name": userName,
"withSafetyModeUserFields": true
};
let url = encodeURI(`${baseURL}?variables=${JSON.stringify(variables)}&features=${UserByScreenName_features_JSON}&fieldToggles=${UserByScreenName_fieldToggles_JSON}`);
let response = await fetch(url, { 'headers': getHeahers() });
return await response.json();
};
class User {
userName;
userID;
data;
headURL;
bannerURL;
constructor(userName) {
this.userName = userName;
}
fetchData = async () => {
let _data = await fetchUserByScreenName(this.userName);
this.data = _data['data']['user']['r###lt'];
this.userID = this.data['rest_id'];
this.headURL = this.data['legacy']['profile_image_url_https'].replace(/_normal\.jpg$/, '.jpg');
this.bannerURL = this.data['legacy']['profile_banner_url'];
};
}
const Following_features = {
"responsive_web_graphql_exclude_directive_enabled": true,
"verified_phone_label_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"tweetypie_unmention_optimization_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_r###lts_prefer_gql_limited_actions_policy_enabled": true,
"rweb_video_timestamps_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"responsive_web_media_download_video_enabled": false,
"responsive_web_enhance_cards_enabled": false
};
const Following_features_JSON = JSON.stringify(Following_features);
const fetchFollowing = async (userID, cursor) => {
let baseURL = `https://twitter.com/i/api/graphql/${apidFollowing}/Following`;
let variables = {
"userId": userID,
"cursor": cursor,
"count": 20,
"includePromotedContent": false
};
let url = encodeURI(`${baseURL}?variables=${JSON.stringify(variables)}&features=${Following_features_JSON}`);
let response = await fetch(url, { 'headers': getHeahers() });
return await response.json();
};
class Following {
userID;
cursor;
userList;
constructor(userID) {
this.userID = userID;
this.cursor = undefined;
this.userList = new Array();
}
nextPage = async () => {
this.userList.length = 0;
let _data = await fetchFollowing(this.userID, this.cursor);
let data = _data['data']['user']['r###lt']['timeline']['timeline']['instructions'];
let users = new Array();
data.forEach(item => {
if ('TimelineAddEntries' == item['type']) {
item['entries'].forEach((entry) => {
if (entry['entryId'].startsWith('user-')) {
users.push(entry);
}
else if (entry['entryId'].startsWith('cursor-bottom-')) {
this.cursor = entry['content']['value'];
}
});
}
});
if (0 != users.length) {
users.forEach(user => {
this.userList.push(user['content']['itemContent']['user_r###lts']['r###lt']);
});
return true;
}
else {
return false;
}
};
}
const UserMedia_features = {
"responsive_web_graphql_exclude_directive_enabled": true,
"verified_phone_label_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"tweetypie_unmention_optimization_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_r###lts_prefer_gql_limited_actions_policy_enabled": true,
"rweb_video_timestamps_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"responsive_web_media_download_video_enabled": false,
"responsive_web_enhance_cards_enabled": false
};
const UserMedia_features_JSON = JSON.stringify(UserMedia_features);
const fetchUserMedia = async (userID, cursor) => {
let baseURL = `https://twitter.com/i/api/graphql/${apidUserMedia}/UserMedia`;
let variables = {
"userId": userID,
"cursor": cursor,
"count": 20,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true,
"withV2Timeline": true
};
let url = encodeURI(`${baseURL}?variables=${JSON.stringify(variables)}&features=${UserMedia_features_JSON}`);
let response = await fetch(url, { 'headers': getHeahers() });
return await response.json();
};
class Media {
userID;
cursor;
mediaList;
constructor(userID) {
this.userID = userID;
this.cursor = undefined;
this.mediaList = new Array();
}
nextPage = async () => {
this.mediaList.length = 0;
let _data = await fetchUserMedia(this.userID, this.cursor);
let data = _data['data']['user']['r###lt']['timeline_v2']['timeline']['instructions'];
let tweets = null;
data.forEach(item => {
if ('TimelineAddEntries' == item['type']) {
item['entries'].forEach((entry) => {
if ('profile-grid-0' == entry['entryId']) {
tweets = entry['content']['items'];
}
else if (entry['entryId'].startsWith('cursor-bottom-')) {
this.cursor = entry['content']['value'];
}
});
}
else if ("TimelineAddToModule" == item['type']) {
tweets = item['moduleItems'];
}
});
if (null != tweets) {
tweets.forEach(tweet => {
this.mediaList.push(tweet['item']['itemContent']['tweet_r###lts']['r###lt']);
});
return true;
}
else {
return false;
}
};
}
天音Author
§
Posted: 2024-01-12

graphql后面的id会定期变更
应该是所有人都一样的,跟访问限制好像没多大关系

频繁下载会被限制访问,不清楚什么条件下会触发
免费用户比较严格,使用###的也比较严格(多人共用1个ip)

§
Posted: 2024-01-16

我的两个推特账号的queryID是不同的,全局搜索的结果显示
这个queryID来自请求https://abs.twimg.com/responsive-web/client-web/main.{hash}.js
这个js文件是通过网页head中的link标签引入的
那就说明至少不是所有人的queryID都是相同的
但是不清楚具体的机制了

(我还是觉得这个queryID每账号不同的可能性大一些)

§
Posted: 2024-02-07

这两天有时间又试了试,如果需要动态获取可以参考这个方法

const mainJSURLRegex = /^https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main\.[a-f0-9]+\.js$/;
let mainJSURL;
document.querySelectorAll('link').forEach(link => {
if (mainJSURLRegex.test(link.href)) {
mainJSURL = link.href;
}
});
const mainJSString = await fetch(mainJSURL)
.then(response => response.text())
.then(data => data.replaceAll(' ', ''));
const getAPID = (opName) => {
const regex = new RegExp(`{\\s*queryId:"[^"]*",\\s*operationName:"${opName}",\\s*operationType:"query",\\s*metadata:{\\s*featureSwitches:[^}]*,\\s*fieldToggles:[^}]*}\\s*}`, 'g');
const match = regex.exec(mainString);
if (match) {
const r###lt = match[0].replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":');
return JSON.parse(r###lt)['queryId'];
} else {
return null;
}
}
console.log(getAPID('TweetDetail'));

Post reply

Sign in to post a reply.