Greasy Fork is available in English.

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']['result'];
        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_results_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']['result']['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_results']['result']);
            });
            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_results_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']['result']['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_results']['result']);
            });
            return true;
        }
        else {
            return false;
        }
    };
}
天音Author
§
Posted: 2024/01/12

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

频繁下载会被限制访问,不清楚什么条件下会触发
免费用户比较严格,使用vpn的也比较严格(多人共用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 result = match[0].replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":');
        return JSON.parse(result)['queryId'];
    } else {
        return null;
    }
}
console.log(getAPID('TweetDetail'));

Post reply

Sign in to post a reply.