OSDN Git Service

graphqlエンドポイントを使用したプロフィール情報の取得に対応
authorKimura Youichi <kim.upsilon@bucyou.net>
Fri, 24 Nov 2023 16:08:05 +0000 (01:08 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Fri, 24 Nov 2023 16:12:23 +0000 (01:12 +0900)
CHANGELOG.txt
OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs [new file with mode: 0644]
OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs [new file with mode: 0644]
OpenTween.Tests/OpenTween.Tests.csproj
OpenTween.Tests/Resources/Responses/UserByScreenName.json [new file with mode: 0644]
OpenTween.Tests/Resources/Responses/User_Simple.json [new file with mode: 0644]
OpenTween/Api/GraphQL/TimelineTweet.cs
OpenTween/Api/GraphQL/TwitterGraphqlUser.cs [new file with mode: 0644]
OpenTween/Api/GraphQL/UserByScreenNameRequest.cs [new file with mode: 0644]
OpenTween/Tween.cs
OpenTween/Twitter.cs

index 1854522..07b92ce 100644 (file)
@@ -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 (file)
index 0000000..5448ecc
--- /dev/null
@@ -0,0 +1,55 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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 (file)
index 0000000..b27043a
--- /dev/null
@@ -0,0 +1,63 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<IApiConnection>();
+            mock.Setup(x =>
+                    x.GetStreamAsync(It.IsAny<Uri>(), It.IsAny<IDictionary<string, string>>())
+                )
+                .Callback<Uri, IDictionary<string, string>>((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();
+        }
+    }
+}
index ad697df..e2f2968 100644 (file)
     <None Update="Resources\Responses\TimelineTweet_SelfThread.json">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </None>
+    <None Update="Resources\Responses\User_Simple.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+    <None Update="Resources\Responses\UserByScreenName.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 </Project>
diff --git a/OpenTween.Tests/Resources/Responses/UserByScreenName.json b/OpenTween.Tests/Resources/Responses/UserByScreenName.json
new file mode 100644 (file)
index 0000000..2589258
--- /dev/null
@@ -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 (file)
index 0000000..7d1f8e0
--- /dev/null
@@ -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
+}
index f44305a..04392fa 100644 (file)
@@ -83,8 +83,8 @@ namespace OpenTween.Api.GraphQL
         {
             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 @@ namespace OpenTween.Api.GraphQL
                         })
                         .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 (file)
index 0000000..96ab805
--- /dev/null
@@ -0,0 +1,124 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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 (file)
index 0000000..ddd3155
--- /dev/null
@@ -0,0 +1,88 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<string, string> 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<TwitterGraphqlUser> 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);
+        }
+    }
+}
index d420696..7bf8bac 100644 (file)
@@ -9020,7 +9020,7 @@ namespace OpenTween
 
                 try
                 {
-                    var task = this.tw.Api.UsersShow(id);
+                    var task = this.tw.GetUserInfo(id);
                     user = await dialog.WaitForAsync(this, task);
                 }
                 catch (WebApiException ex)
index b449dd9..1046b9f 100644 (file)
@@ -381,7 +381,7 @@ namespace OpenTween
             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 @@ namespace OpenTween
             }
         }
 
+        public async Task<TwitterUser> 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;