From a477e6217a1ab6809178919652d277e0dac0f31b Mon Sep 17 00:00:00 2001 From: Kimura Youichi Date: Sat, 25 Nov 2023 01:08:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?graphql=E3=82=A8=E3=83=B3=E3=83=89=E3=83=9D?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=88=E3=82=92=E4=BD=BF=E7=94=A8=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=83=97=E3=83=AD=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E3=81=AE=E5=8F=96=E5=BE=97=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.txt | 1 + .../Api/GraphQL/TwitterGraphqlUserTest.cs | 55 ++++++++ .../GraphQL/UserByScreenNameRequestTest.cs | 63 +++++++++ OpenTween.Tests/OpenTween.Tests.csproj | 6 + .../Resources/Responses/UserByScreenName.json | 76 +++++++++++ .../Resources/Responses/User_Simple.json | 70 ++++++++++ OpenTween/Api/GraphQL/TimelineTweet.cs | 12 +- OpenTween/Api/GraphQL/TwitterGraphqlUser.cs | 124 ++++++++++++++++++ .../Api/GraphQL/UserByScreenNameRequest.cs | 88 +++++++++++++ OpenTween/Tween.cs | 2 +- OpenTween/Twitter.cs | 24 +++- 11 files changed, 509 insertions(+), 12 deletions(-) create mode 100644 OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs create mode 100644 OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs create mode 100644 OpenTween.Tests/Resources/Responses/UserByScreenName.json create mode 100644 OpenTween.Tests/Resources/Responses/User_Simple.json create mode 100644 OpenTween/Api/GraphQL/TwitterGraphqlUser.cs create mode 100644 OpenTween/Api/GraphQL/UserByScreenNameRequest.cs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 1854522ea..07b92ce10 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -2,6 +2,7 @@ ==== Unreleased * NEW: graphqlエンドポイントを使用した検索タイムラインの取得に対応 + * NEW: graphqlエンドポイントを使用したプロフィール情報の取得に対応 * CHG: タイムライン更新が停止する不具合が報告される件への暫定的な対処 - タイムライン更新に30秒以上掛かっている場合は完了を待機せず次のタイマーを開始させる - タイムライン更新の次回実行が1時間以上先になる場合は異常値としてタイマーをリセットする diff --git a/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs b/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs new file mode 100644 index 000000000..5448ecc76 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs @@ -0,0 +1,55 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class TwitterGraphqlUserTest + { + private XElement LoadResponseDocument(string filename) + { + using var stream = File.OpenRead($"Resources/Responses/{filename}"); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); + return XElement.Load(jsonReader); + } + + [Fact] + public void ToTwitterUser_Test() + { + var userElm = this.LoadResponseDocument("User_Simple.json"); + var graphqlUser = new TwitterGraphqlUser(userElm); + var user = graphqlUser.ToTwitterUser(); + + Assert.Equal("514241801", user.IdStr); + Assert.Equal("opentween", user.ScreenName); + } + } +} diff --git a/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs new file mode 100644 index 000000000..b27043a58 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs @@ -0,0 +1,63 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using OpenTween.Connection; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class UserByScreenNameRequestTest + { + [Fact] + public async Task Send_Test() + { + using var responseStream = File.OpenRead("Resources/Responses/UserByScreenName.json"); + + var mock = new Mock(); + mock.Setup(x => + x.GetStreamAsync(It.IsAny(), It.IsAny>()) + ) + .Callback>((url, param) => + { + Assert.Equal(new("https://twitter.com/i/api/graphql/xc8f1g7BYqr6VTzTbvNlGw/UserByScreenName"), url); + Assert.Contains(@"""screen_name"":""opentween""", param["variables"]); + }) + .ReturnsAsync(responseStream); + + var request = new UserByScreenNameRequest + { + ScreenName = "opentween", + }; + + var user = await request.Send(mock.Object).ConfigureAwait(false); + Assert.Equal("514241801", user.ToTwitterUser().IdStr); + + mock.VerifyAll(); + } + } +} diff --git a/OpenTween.Tests/OpenTween.Tests.csproj b/OpenTween.Tests/OpenTween.Tests.csproj index ad697df92..e2f296863 100644 --- a/OpenTween.Tests/OpenTween.Tests.csproj +++ b/OpenTween.Tests/OpenTween.Tests.csproj @@ -94,5 +94,11 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + diff --git a/OpenTween.Tests/Resources/Responses/UserByScreenName.json b/OpenTween.Tests/Resources/Responses/UserByScreenName.json new file mode 100644 index 000000000..2589258b2 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/UserByScreenName.json @@ -0,0 +1,76 @@ +{ + "data": { + "user": { + "result": { + "__typename": "User", + "id": "VXNlcjo1MTQyNDE4MDE=", + "rest_id": "514241801", + "affiliates_highlighted_label": {}, + "has_graduated_access": false, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": true, + "can_media_tag": false, + "created_at": "Sun Mar 04 11:33:45 +0000 2012", + "default_profile": false, + "default_profile_image": false, + "description": "Windows 用 Twitter クライアント OpenTween のアカウントです。", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "opentween.org", + "expanded_url": "https://www.opentween.org/", + "url": "https://t.co/An6OJeC28u", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 0, + "followers_count": 302, + "friends_count": 1, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 14, + "location": "", + "media_count": 0, + "name": "OpenTween", + "normal_followers_count": 302, + "pinned_tweet_ids_str": [ + "1617124615347908609" + ], + "possibly_sensitive": false, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/661168792488153088/-UAFci6G_normal.png", + "profile_interstitial_type": "", + "screen_name": "opentween", + "statuses_count": 31, + "translator_type": "none", + "url": "https://t.co/An6OJeC28u", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + }, + "smart_blocked_by": false, + "smart_blocking": false, + "legacy_extended_profile": {}, + "is_profile_translatable": true, + "verification_info": {}, + "highlights_info": { + "can_highlight_tweets": false, + "highlighted_tweets": "0" + }, + "business_account": {}, + "creator_subscriptions_count": 0 + } + } + } +} diff --git a/OpenTween.Tests/Resources/Responses/User_Simple.json b/OpenTween.Tests/Resources/Responses/User_Simple.json new file mode 100644 index 000000000..7d1f8e065 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/User_Simple.json @@ -0,0 +1,70 @@ +{ + "__typename": "User", + "id": "VXNlcjo1MTQyNDE4MDE=", + "rest_id": "514241801", + "affiliates_highlighted_label": {}, + "has_graduated_access": false, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": true, + "can_media_tag": false, + "created_at": "Sun Mar 04 11:33:45 +0000 2012", + "default_profile": false, + "default_profile_image": false, + "description": "Windows 用 Twitter クライアント OpenTween のアカウントです。", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "opentween.org", + "expanded_url": "https://www.opentween.org/", + "url": "https://t.co/An6OJeC28u", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 0, + "followers_count": 302, + "friends_count": 1, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 14, + "location": "", + "media_count": 0, + "name": "OpenTween", + "normal_followers_count": 302, + "pinned_tweet_ids_str": [ + "1617124615347908609" + ], + "possibly_sensitive": false, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/661168792488153088/-UAFci6G_normal.png", + "profile_interstitial_type": "", + "screen_name": "opentween", + "statuses_count": 31, + "translator_type": "none", + "url": "https://t.co/An6OJeC28u", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + }, + "smart_blocked_by": false, + "smart_blocking": false, + "legacy_extended_profile": {}, + "is_profile_translatable": true, + "verification_info": {}, + "highlights_info": { + "can_highlight_tweets": false, + "highlighted_tweets": "0" + }, + "business_account": {}, + "creator_subscriptions_count": 0 +} diff --git a/OpenTween/Api/GraphQL/TimelineTweet.cs b/OpenTween/Api/GraphQL/TimelineTweet.cs index f44305a9d..04392faf9 100644 --- a/OpenTween/Api/GraphQL/TimelineTweet.cs +++ b/OpenTween/Api/GraphQL/TimelineTweet.cs @@ -83,8 +83,8 @@ public static TwitterStatus ParseTweet(XElement tweetElm) { var tweetLegacyElm = tweetElm.Element("legacy") ?? throw CreateParseError(); var userElm = tweetElm.Element("core")?.Element("user_results")?.Element("result") ?? throw CreateParseError(); - var userLegacyElm = userElm.Element("legacy") ?? throw CreateParseError(); var retweetedTweetElm = tweetLegacyElm.Element("retweeted_status_result")?.Element("result"); + var user = new TwitterGraphqlUser(userElm); static string GetText(XElement elm, string name) => elm.Element(name)?.Value ?? throw CreateParseError(); @@ -143,15 +143,7 @@ static string GetText(XElement elm, string name) }) .ToArray(), }, - User = new() - { - Id = long.Parse(GetText(userElm, "rest_id")), - IdStr = GetText(userElm, "rest_id"), - Name = GetText(userLegacyElm, "name"), - ProfileImageUrlHttps = GetText(userLegacyElm, "profile_image_url_https"), - ScreenName = GetText(userLegacyElm, "screen_name"), - Protected = GetTextOrNull(userLegacyElm, "protected") == "true", - }, + User = user.ToTwitterUser(), RetweetedStatus = retweetedTweetElm != null ? TimelineTweet.ParseTweetUnion(retweetedTweetElm) : null, }; } diff --git a/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs b/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs new file mode 100644 index 000000000..96ab805c1 --- /dev/null +++ b/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs @@ -0,0 +1,124 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using System.Xml.XPath; +using OpenTween.Api.DataModel; + +namespace OpenTween.Api.GraphQL +{ + public class TwitterGraphqlUser + { + public const string TypeName = "User"; + + public XElement Element { get; } + + public TwitterGraphqlUser(XElement element) + { + var typeName = element.Element("__typename")?.Value; + if (typeName != TypeName) + throw new ArgumentException($"Invalid itemType: {typeName}", nameof(element)); + + this.Element = element; + } + + public TwitterUser ToTwitterUser() + { + try + { + return TwitterGraphqlUser.ParseUser(this.Element); + } + catch (WebApiException ex) + { + ex.ResponseText = JsonUtils.JsonXmlToString(this.Element); + MyCommon.TraceOut(ex); + throw; + } + } + + public static TwitterUser ParseUser(XElement userElm) + { + var userLegacyElm = userElm.Element("legacy") ?? throw CreateParseError(); + + static string GetText(XElement elm, string name) + => elm.Element(name)?.Value ?? throw CreateParseError(); + + static string? GetTextOrNull(XElement elm, string name) + => elm.Element(name)?.Value; + + return new() + { + Id = long.Parse(GetText(userElm, "rest_id")), + IdStr = GetText(userElm, "rest_id"), + Name = GetText(userLegacyElm, "name"), + ProfileImageUrlHttps = GetText(userLegacyElm, "profile_image_url_https"), + ScreenName = GetText(userLegacyElm, "screen_name"), + Protected = GetTextOrNull(userLegacyElm, "protected") == "true", + Verified = GetTextOrNull(userLegacyElm, "verified") == "true", + CreatedAt = GetText(userLegacyElm, "created_at"), + FollowersCount = int.Parse(GetText(userLegacyElm, "followers_count")), + FriendsCount = int.Parse(GetText(userLegacyElm, "friends_count")), + FavouritesCount = int.Parse(GetText(userLegacyElm, "favourites_count")), + StatusesCount = int.Parse(GetText(userLegacyElm, "statuses_count")), + Description = GetTextOrNull(userLegacyElm, "description"), + Location = GetTextOrNull(userLegacyElm, "location"), + Url = GetTextOrNull(userLegacyElm, "url"), + Entities = new() + { + Description = new() + { + Urls = userLegacyElm.XPathSelectElements("entities/description/urls/item") + .Select(x => new TwitterEntityUrl() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + DisplayUrl = GetText(x, "display_url"), + ExpandedUrl = GetText(x, "expanded_url"), + Url = GetText(x, "url"), + }) + .ToArray(), + }, + Url = new() + { + Urls = userLegacyElm.XPathSelectElements("entities/url/urls/item") + .Select(x => new TwitterEntityUrl() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + DisplayUrl = GetText(x, "display_url"), + ExpandedUrl = GetText(x, "expanded_url"), + Url = GetText(x, "url"), + }) + .ToArray(), + }, + }, + }; + } + + private static Exception CreateParseError() + => throw new WebApiException("Parse error on User"); + } +} diff --git a/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs new file mode 100644 index 000000000..ddd315511 --- /dev/null +++ b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs @@ -0,0 +1,88 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using OpenTween.Connection; + +namespace OpenTween.Api.GraphQL +{ + public class UserByScreenNameRequest + { + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/xc8f1g7BYqr6VTzTbvNlGw/UserByScreenName"); + + required public string ScreenName { get; set; } + + public Dictionary CreateParameters() + { + return new() + { + ["variables"] = $$""" + {"screen_name":"{{this.ScreenName}}","withSafetyModeUserFields":true} + """, + ["features"] = """ + {"hidden_profile_likes_enabled":false,"hidden_profile_subscriptions_enabled":false,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"subscriptions_verification_info_verified_since_enabled":true,"highlights_tweets_tab_ui_enabled":true,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true} + """, + ["fieldToggles"] = """ + {"withAuxiliaryUserLabels":false} + """, + }; + } + + public async Task Send(IApiConnection apiConnection) + { + var param = this.CreateParameters(); + + XElement rootElm; + try + { + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); + rootElm = XElement.Load(jsonReader); + } + catch (IOException ex) + { + throw new WebApiException("IO Error", ex); + } + catch (NotSupportedException ex) + { + // NotSupportedException: Stream does not support reading. のエラーが時々報告される + throw new WebApiException("Stream Error", ex); + } + + ErrorResponse.ThrowIfError(rootElm); + + var userElm = rootElm.XPathSelectElement("/data/user/result"); + + return new(userElm); + } + } +} diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs index d420696a5..7bf8bac8a 100644 --- a/OpenTween/Tween.cs +++ b/OpenTween/Tween.cs @@ -9020,7 +9020,7 @@ private async Task DoShowUserStatus(string id, bool showInputDialog) try { - var task = this.tw.Api.UsersShow(id); + var task = this.tw.GetUserInfo(id); user = await dialog.WaitForAsync(this, task); } catch (WebApiException ex) diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index b449dd901..1046b9fa0 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -381,7 +381,7 @@ public async Task SendDirectMessage(string postStr, long? mediaId = null) var body = mc.Groups["body"].Value; var recipientName = mc.Groups["id"].Value; - var recipient = await this.Api.UsersShow(recipientName) + var recipient = await this.GetUserInfo(recipientName) .ConfigureAwait(false); var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId) @@ -462,6 +462,28 @@ await this.Api.StatusesDestroy(post.StatusId.ToTwitterStatusId()) } } + public async Task GetUserInfo(string screenName) + { + if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie) + { + var request = new UserByScreenNameRequest + { + ScreenName = screenName, + }; + var response = await request.Send(this.Api.Connection) + .ConfigureAwait(false); + + return response.ToTwitterUser(); + } + else + { + var user = await this.Api.UsersShow(screenName) + .ConfigureAwait(false); + + return user; + } + } + public string Username => this.Api.CurrentScreenName; From 3b20d4c862521b70c37759eb4ea7b1a97f245d46 Mon Sep 17 00:00:00 2001 From: Kimura Youichi Date: Sat, 25 Nov 2023 01:11:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?graphql=E3=82=A8=E3=83=B3=E3=83=89=E3=83=9D?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=88=E3=82=92=E4=BD=BF=E7=94=A8=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=AE=E5=8F=96=E5=BE=97?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.txt | 1 + .../Api/GraphQL/UserTweetsRequestTest.cs | 96 +++++++++ OpenTween.Tests/OpenTween.Tests.csproj | 3 + .../Responses/UserTweets_SimpleTweet.json | 185 ++++++++++++++++++ OpenTween/Api/GraphQL/UserTweetsRequest.cs | 102 ++++++++++ OpenTween/Models/TabInformations.cs | 5 +- OpenTween/Models/UserTimelineTabModel.cs | 6 +- OpenTween/Setting/SettingTabs.cs | 8 + OpenTween/Tween.cs | 1 + OpenTween/Twitter.cs | 31 ++- 10 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 OpenTween.Tests/Api/GraphQL/UserTweetsRequestTest.cs create mode 100644 OpenTween.Tests/Resources/Responses/UserTweets_SimpleTweet.json create mode 100644 OpenTween/Api/GraphQL/UserTweetsRequest.cs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 07b92ce10..6f6773623 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -3,6 +3,7 @@ ==== Unreleased * NEW: graphqlエンドポイントを使用した検索タイムラインの取得に対応 * NEW: graphqlエンドポイントを使用したプロフィール情報の取得に対応 + * NEW: graphqlエンドポイントを使用したユーザータイムラインの取得に対応 * CHG: タイムライン更新が停止する不具合が報告される件への暫定的な対処 - タイムライン更新に30秒以上掛かっている場合は完了を待機せず次のタイマーを開始させる - タイムライン更新の次回実行が1時間以上先になる場合は異常値としてタイマーをリセットする diff --git a/OpenTween.Tests/Api/GraphQL/UserTweetsRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UserTweetsRequestTest.cs new file mode 100644 index 000000000..337828eb4 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/UserTweetsRequestTest.cs @@ -0,0 +1,96 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using OpenTween.Connection; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class UserTweetsRequestTest + { + [Fact] + public async Task Send_Test() + { + using var responseStream = File.OpenRead("Resources/Responses/UserTweets_SimpleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.GetStreamAsync(It.IsAny(), It.IsAny>()) + ) + .Callback>((url, param) => + { + Assert.Equal(new("https://twitter.com/i/api/graphql/2GIWTr7XwadIixZDtyXd4A/UserTweets"), url); + Assert.Equal(3, param.Count); + Assert.Equal("""{"userId":"40480664","count":20,"includePromotedContent":true,"withQuickPromoteEligibilityTweetFields":true,"withVoice":true,"withV2Timeline":true}""", param["variables"]); + Assert.True(param.ContainsKey("features")); + Assert.True(param.ContainsKey("fieldToggles")); + }) + .ReturnsAsync(responseStream); + + var request = new UserTweetsRequest(userId: "40480664") + { + Count = 20, + }; + + var response = await request.Send(mock.Object).ConfigureAwait(false); + Assert.Single(response.Tweets); + Assert.Equal("DAABCgABF_tTnZu__-0KAAIWZa6KTRoAAwgAAwAAAAIAAA", response.CursorBottom); + + mock.VerifyAll(); + } + + [Fact] + public async Task Send_RequestCursor_Test() + { + using var responseStream = File.OpenRead("Resources/Responses/UserTweets_SimpleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.GetStreamAsync(It.IsAny(), It.IsAny>()) + ) + .Callback>((url, param) => + { + Assert.Equal(new("https://twitter.com/i/api/graphql/2GIWTr7XwadIixZDtyXd4A/UserTweets"), url); + Assert.Equal(3, param.Count); + Assert.Equal("""{"userId":"40480664","count":20,"includePromotedContent":true,"withQuickPromoteEligibilityTweetFields":true,"withVoice":true,"withV2Timeline":true,"cursor":"aaa"}""", param["variables"]); + Assert.True(param.ContainsKey("features")); + Assert.True(param.ContainsKey("fieldToggles")); + }) + .ReturnsAsync(responseStream); + + var request = new UserTweetsRequest(userId: "40480664") + { + Count = 20, + Cursor = "aaa", + }; + + await request.Send(mock.Object).ConfigureAwait(false); + mock.VerifyAll(); + } + } +} diff --git a/OpenTween.Tests/OpenTween.Tests.csproj b/OpenTween.Tests/OpenTween.Tests.csproj index e2f296863..ebdd16c4f 100644 --- a/OpenTween.Tests/OpenTween.Tests.csproj +++ b/OpenTween.Tests/OpenTween.Tests.csproj @@ -100,5 +100,8 @@ PreserveNewest + + PreserveNewest + diff --git a/OpenTween.Tests/Resources/Responses/UserTweets_SimpleTweet.json b/OpenTween.Tests/Resources/Responses/UserTweets_SimpleTweet.json new file mode 100644 index 000000000..fc2ee0f15 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/UserTweets_SimpleTweet.json @@ -0,0 +1,185 @@ +{ + "data": { + "user": { + "result": { + "__typename": "User", + "timeline_v2": { + "timeline": { + "instructions": [ + { + "type": "TimelineClearCache" + }, + { + "type": "TimelineAddEntries", + "entries": [ + { + "entryId": "tweet-1612926719035600906", + "sortIndex": "1728066818414739426", + "content": { + "entryType": "TimelineTimelineItem", + "__typename": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1612926719035600906", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "following": true, + "can_dm": false, + "can_media_tag": false, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 215409, + "followers_count": 1288, + "friends_count": 2, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 876, + "name": "upsilon", + "normal_followers_count": 1288, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10081, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "unmention_data": {}, + "edit_control": { + "edit_tweet_ids": [ + "1612926719035600906" + ], + "editable_until_msecs": "1673388471000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "is_translatable": true, + "views": { + "count": "288", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Tue Jan 10 21:37:51 +0000 2023", + "conversation_id_str": "1612926719035600906", + "display_text_range": [ + 0, + 5 + ], + "entities": { + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 2, + "favorited": false, + "full_text": "よろしくね", + "is_quote_status": false, + "lang": "ja", + "quote_count": 0, + "reply_count": 0, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1612926719035600906" + }, + "quick_promote_eligibility": { + "eligibility": "IneligibleNotProfessional" + } + } + }, + "tweetDisplayType": "Tweet" + }, + "clientEventInfo": { + "component": "tweet", + "element": "tweet", + "details": { + "timelinesDetails": { + "injectionType": "RankedOrganicTweet", + "controllerData": "DAACDAABDAABCgABAAAAAAAAAAAKAAkAAAAAZE0+pwAAAAA=" + } + } + } + } + }, + { + "entryId": "cursor-top-1728066818414739457", + "sortIndex": "1728066818414739457", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF_tTnZvAJxEKAAIWes8rE1oQAAgAAwAAAAEAAA", + "cursorType": "Top" + } + }, + { + "entryId": "cursor-bottom-1728066818414739439", + "sortIndex": "1728066818414739439", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF_tTnZu__-0KAAIWZa6KTRoAAwgAAwAAAAIAAA", + "cursorType": "Bottom" + } + } + ] + } + ], + "metadata": { + "scribeConfig": { + "page": "profileBest" + } + } + } + } + } + } + } +} diff --git a/OpenTween/Api/GraphQL/UserTweetsRequest.cs b/OpenTween/Api/GraphQL/UserTweetsRequest.cs new file mode 100644 index 000000000..e9749c44e --- /dev/null +++ b/OpenTween/Api/GraphQL/UserTweetsRequest.cs @@ -0,0 +1,102 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using OpenTween.Connection; + +namespace OpenTween.Api.GraphQL +{ + public class UserTweetsRequest + { + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/2GIWTr7XwadIixZDtyXd4A/UserTweets"); + + public string UserId { get; set; } + + public int Count { get; set; } = 20; + + public string? Cursor { get; set; } + + public UserTweetsRequest(string userId) + => this.UserId = userId; + + public Dictionary CreateParameters() + { + return new() + { + ["variables"] = "{" + + $@"""userId"":""{JsonUtils.EscapeJsonString(this.UserId)}""," + + $@"""count"":20," + + $@"""includePromotedContent"":true," + + $@"""withQuickPromoteEligibilityTweetFields"":true," + + $@"""withVoice"":true," + + $@"""withV2Timeline"":true" + + (this.Cursor != null ? $@",""cursor"":""{JsonUtils.EscapeJsonString(this.Cursor)}""" : "") + + "}", + ["features"] = """ + {"rweb_lists_timeline_redesign_enabled":true,"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,"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,"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} + """, + ["fieldToggles"] = """ + {"withAuxiliaryUserLabels":false,"withArticleRichContentState":false} + """, + }; + } + + public async Task Send(IApiConnection apiConnection) + { + var param = this.CreateParameters(); + + XElement rootElm; + try + { + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); + rootElm = XElement.Load(jsonReader); + } + catch (IOException ex) + { + throw new WebApiException("IO Error", ex); + } + catch (NotSupportedException ex) + { + // NotSupportedException: Stream does not support reading. のエラーが時々報告される + throw new WebApiException("Stream Error", ex); + } + + ErrorResponse.ThrowIfError(rootElm); + + var tweets = TimelineTweet.ExtractTimelineTweets(rootElm); + var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value; + + return new(tweets, cursorBottom); + } + } +} diff --git a/OpenTween/Models/TabInformations.cs b/OpenTween/Models/TabInformations.cs index eee786929..7b338462a 100644 --- a/OpenTween/Models/TabInformations.cs +++ b/OpenTween/Models/TabInformations.cs @@ -253,7 +253,10 @@ public void LoadTabsFromSettings(SettingTabs settingTabs) MyCommon.TabUsageType.UserDefined => new FilterTabModel(tabName), MyCommon.TabUsageType.UserTimeline - => new UserTimelineTabModel(tabName, tabSetting.User!), + => new UserTimelineTabModel(tabName, tabSetting.User!) + { + UserId = tabSetting.UserId, + }, MyCommon.TabUsageType.PublicSearch => new PublicSearchTabModel(tabName) { diff --git a/OpenTween/Models/UserTimelineTabModel.cs b/OpenTween/Models/UserTimelineTabModel.cs index 1d876ec0e..51faea6eb 100644 --- a/OpenTween/Models/UserTimelineTabModel.cs +++ b/OpenTween/Models/UserTimelineTabModel.cs @@ -43,8 +43,12 @@ public override MyCommon.TabUsageType TabType public string ScreenName { get; } + public string? UserId { get; set; } + public PostId? OldestId { get; set; } + public string? CursorBottom { get; set; } + public UserTimelineTabModel(string tabName, string screenName) : base(tabName) { @@ -64,7 +68,7 @@ public override async Task RefreshAsync(Twitter tw, bool backward, bool startup, progress.Report("UserTimeline refreshing..."); - await tw.GetUserTimelineApi(read, this.ScreenName, this, backward) + await tw.GetUserTimelineApi(read, this, backward) .ConfigureAwait(false); TabInformations.GetInstance().DistributePosts(); diff --git a/OpenTween/Setting/SettingTabs.cs b/OpenTween/Setting/SettingTabs.cs index f8974b99a..7f14dd5f2 100644 --- a/OpenTween/Setting/SettingTabs.cs +++ b/OpenTween/Setting/SettingTabs.cs @@ -83,6 +83,14 @@ public class SettingTabItem /// public string? User { get; set; } + /// + /// 表示するユーザーのID ( で使用) + /// + /// + /// v3.7.1 まで存在しない項目のため、空の場合は から補う必要がある + /// + public string? UserId { get; set; } + /// /// 検索文字列 ( で使用) /// diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs index 7bf8bac8a..f879d58c9 100644 --- a/OpenTween/Tween.cs +++ b/OpenTween/Tween.cs @@ -5807,6 +5807,7 @@ private void SaveConfigsTabs() break; case UserTimelineTabModel userTab: tabSetting.User = userTab.ScreenName; + tabSetting.UserId = userTab.UserId; break; case PublicSearchTabModel searchTab: tabSetting.SearchWords = searchTab.SearchWords; diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index 1046b9fa0..1942c99ee 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -660,7 +660,7 @@ public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool m tab.OldestId = minimumId; } - public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more) + public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool more) { this.CheckAccountState(); @@ -671,24 +671,39 @@ public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTab count = Math.Min(count, 99); TwitterStatus[] statuses; - if (MyCommon.IsNullOrEmpty(userName)) + if (this.Api.AppToken.AuthType == APIAuthType.TwitterComCookie) { - var target = tab.ScreenName; - if (MyCommon.IsNullOrEmpty(target)) return; - userName = target; - statuses = await this.Api.StatusesUserTimeline(userName, count) + var userId = tab.UserId; + if (MyCommon.IsNullOrEmpty(userId)) + { + var user = await this.GetUserInfo(tab.ScreenName) + .ConfigureAwait(false); + + userId = user.IdStr; + tab.UserId = user.IdStr; + } + + var request = new UserTweetsRequest(userId) + { + Count = count, + Cursor = more ? tab.CursorBottom : null, + }; + var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); + + statuses = response.Tweets.Select(x => x.ToTwitterStatus()).ToArray(); + tab.CursorBottom = response.CursorBottom; } else { if (more) { - statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId as TwitterStatusId) + statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count, maxId: tab.OldestId as TwitterStatusId) .ConfigureAwait(false); } else { - statuses = await this.Api.StatusesUserTimeline(userName, count) + statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count) .ConfigureAwait(false); } }