diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 82c3fafdb..ffc2bc540 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,12 @@ 更新履歴 +==== Ver 3.9.0(2023/12/03) + * NEW: graphqlエンドポイントに対するレートリミットの表示に対応 + * CHG: タイムライン更新時に全件ではなく新着投稿のみ差分を取得する動作に変更 + * FIX: 設定したタイムアウト時間を超えてAPI接続が持続する場合がある不具合を修正 + * FIX: プロフィール情報のURL欄のパースに失敗する場合がある不具合を修正 + - この問題が起きるユーザーのツイートが含まれているとタイムラインの読み込みに失敗する問題も改善されます + ==== Ver 3.8.0(2023/11/29) * NEW: graphqlエンドポイントを使用した検索タイムラインの取得に対応 * NEW: graphqlエンドポイントを使用したプロフィール情報の取得に対応 diff --git a/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs b/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs index 0516c8435..dc24d7c7d 100644 --- a/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs @@ -41,14 +41,15 @@ public async Task Send_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/6ClPnsuzQJ1p7-g32GQw9Q/ListLatestTweetsTimeline"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"listId":"1675863884757110790","count":20}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("ListLatestTweetsTimeline", endpointName); }) .ReturnsAsync(responseStream); @@ -59,6 +60,7 @@ public async Task Send_Test() var response = await request.Send(mock.Object).ConfigureAwait(false); Assert.Single(response.Tweets); + Assert.Equal("DAABCgABF0HfRMjAJxEKAAIWes8rE1oQAAgAAwAAAAEAAA", response.CursorTop); Assert.Equal("DAABCgABF0HfRMi__7QKAAIVAxUYmFWQAwgAAwAAAAIAAA", response.CursorBottom); mock.VerifyAll(); @@ -71,14 +73,15 @@ public async Task Send_RequestCursor_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/6ClPnsuzQJ1p7-g32GQw9Q/ListLatestTweetsTimeline"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"listId":"1675863884757110790","count":20,"cursor":"aaa"}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("ListLatestTweetsTimeline", endpointName); }) .ReturnsAsync(responseStream); diff --git a/OpenTween.Tests/Api/GraphQL/SearchTimelineRequestTest.cs b/OpenTween.Tests/Api/GraphQL/SearchTimelineRequestTest.cs index f7310488e..97459b11d 100644 --- a/OpenTween.Tests/Api/GraphQL/SearchTimelineRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/SearchTimelineRequestTest.cs @@ -40,14 +40,15 @@ public async Task Send_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/lZ0GCEojmtQfiUQa5oJSEw/SearchTimeline"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"rawQuery":"#OpenTween","count":20,"product":"Latest"}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("SearchTimeline", endpointName); }) .ReturnsAsync(responseStream); @@ -58,6 +59,7 @@ public async Task Send_Test() var response = await request.Send(mock.Object).ConfigureAwait(false); Assert.Single(response.Tweets); + Assert.Equal("DAADDAABCgABFnlh4hraMAYKAAIOTm0DEhTAAQAIAAIAAAABCAADAAAAAAgABAAAAAAKAAUX8j3ezIAnEAoABhfyPd7Mf9jwAAA", response.CursorTop); Assert.Equal("DAADDAABCgABFnlh4hraMAYKAAIOTm0DEhTAAQAIAAIAAAACCAADAAAAAAgABAAAAAAKAAUX8j3ezIAnEAoABhfyPd7Mf9jwAAA", response.CursorBottom); mock.VerifyAll(); @@ -70,14 +72,15 @@ public async Task Send_RequestCursor_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/lZ0GCEojmtQfiUQa5oJSEw/SearchTimeline"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"rawQuery":"#OpenTween","count":20,"product":"Latest","cursor":"aaa"}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("SearchTimeline", endpointName); }) .ReturnsAsync(responseStream); diff --git a/OpenTween.Tests/Api/GraphQL/TweetDetailRequestTest.cs b/OpenTween.Tests/Api/GraphQL/TweetDetailRequestTest.cs index 0045f4e4f..69b7c16a7 100644 --- a/OpenTween.Tests/Api/GraphQL/TweetDetailRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/TweetDetailRequestTest.cs @@ -41,12 +41,13 @@ public async Task Send_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/-Ls3CrSQNo2fRKH6i6Na1A/TweetDetail"), url); Assert.Contains(@"""focalTweetId"":""1619433164757413894""", param["variables"]); + Assert.Equal("TweetDetail", endpointName); }) .ReturnsAsync(responseStream); diff --git a/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs b/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs index 5448ecc76..c88b53e17 100644 --- a/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs +++ b/OpenTween.Tests/Api/GraphQL/TwitterGraphqlUserTest.cs @@ -51,5 +51,20 @@ public void ToTwitterUser_Test() Assert.Equal("514241801", user.IdStr); Assert.Equal("opentween", user.ScreenName); } + + [Fact] + public void ToTwitterUser_EntityWithoutDisplayUrlTest() + { + var userElm = this.LoadResponseDocument("User_EntityWithoutDisplayUrl.json"); + var graphqlUser = new TwitterGraphqlUser(userElm); + var user = graphqlUser.ToTwitterUser(); + + Assert.Equal("4104111", user.IdStr); + var urlEntity = user.Entities?.Url?.Urls.First()!; + Assert.Equal("http://earthquake.transrain.net/", urlEntity.Url); + Assert.Equal(new[] { 0, 32 }, urlEntity.Indices); + Assert.Null(urlEntity.DisplayUrl); + Assert.Null(urlEntity.ExpandedUrl); + } } } diff --git a/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs index 81f03d992..77786fe8e 100644 --- a/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/UserByScreenNameRequestTest.cs @@ -40,12 +40,13 @@ public async Task Send_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/xc8f1g7BYqr6VTzTbvNlGw/UserByScreenName"), url); Assert.Contains(@"""screen_name"":""opentween""", param["variables"]); + Assert.Equal("UserByScreenName", endpointName); }) .ReturnsAsync(responseStream); @@ -67,7 +68,7 @@ public async Task Send_UserUnavailableTest() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) .ReturnsAsync(responseStream); diff --git a/OpenTween.Tests/Api/GraphQL/UserTweetsAndRepliesRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UserTweetsAndRepliesRequestTest.cs index cd158433c..ea1c51ed5 100644 --- a/OpenTween.Tests/Api/GraphQL/UserTweetsAndRepliesRequestTest.cs +++ b/OpenTween.Tests/Api/GraphQL/UserTweetsAndRepliesRequestTest.cs @@ -40,14 +40,15 @@ public async Task Send_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/YlkSUg0mRBx7-EkxCvc-bw/UserTweetsAndReplies"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"userId":"40480664","count":20,"includePromotedContent":true,"withCommunity":true,"withVoice":true,"withV2Timeline":true}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("UserTweetsAndReplies", endpointName); }) .ReturnsAsync(responseStream); @@ -58,6 +59,7 @@ public async Task Send_Test() var response = await request.Send(mock.Object).ConfigureAwait(false); Assert.Single(response.Tweets); + Assert.Equal("DAABCgABF_tTnZvAJxEKAAIWes8rE1oQAAgAAwAAAAEAAA", response.CursorTop); Assert.Equal("DAABCgABF_tTnZu__-0KAAIWZa6KTRoAAwgAAwAAAAIAAA", response.CursorBottom); mock.VerifyAll(); @@ -70,14 +72,15 @@ public async Task Send_RequestCursor_Test() var mock = new Mock(); mock.Setup(x => - x.GetStreamAsync(It.IsAny(), It.IsAny>()) + x.GetStreamAsync(It.IsAny(), It.IsAny>(), It.IsAny()) ) - .Callback>((url, param) => + .Callback, string>((url, param, endpointName) => { Assert.Equal(new("https://twitter.com/i/api/graphql/YlkSUg0mRBx7-EkxCvc-bw/UserTweetsAndReplies"), url); Assert.Equal(2, param.Count); Assert.Equal("""{"userId":"40480664","count":20,"includePromotedContent":true,"withCommunity":true,"withVoice":true,"withV2Timeline":true,"cursor":"aaa"}""", param["variables"]); Assert.True(param.ContainsKey("features")); + Assert.Equal("UserTweetsAndReplies", endpointName); }) .ReturnsAsync(responseStream); diff --git a/OpenTween.Tests/OpenTween.Tests.csproj b/OpenTween.Tests/OpenTween.Tests.csproj index e4472eafe..f9444e3b6 100644 --- a/OpenTween.Tests/OpenTween.Tests.csproj +++ b/OpenTween.Tests/OpenTween.Tests.csproj @@ -100,6 +100,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/OpenTween.Tests/Resources/Responses/User_EntityWithoutDisplayUrl.json b/OpenTween.Tests/Resources/Responses/User_EntityWithoutDisplayUrl.json new file mode 100644 index 000000000..56e515d12 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/User_EntityWithoutDisplayUrl.json @@ -0,0 +1,125 @@ +{ + "__typename": "User", + "id": "VXNlcjo0MTA0MTEx", + "rest_id": "4104111", + "affiliates_highlighted_label": { + "label": { + "badge": { + "url": "https://pbs.twimg.com/semantic_core_img/1428827730364096519/4ZXpTBhS?format=png&name=orig" + }, + "description": "Automated", + "longDescription": { + "text": "Automated by @ariela", + "entities": [ + { + "fromIndex": 13, + "toIndex": 20, + "ref": { + "type": "TimelineRichTextMention", + "screen_name": "ariela", + "mention_results": { + "result": { + "__typename": "User", + "legacy": { + "screen_name": "ariela" + }, + "rest_id": "3486871" + } + } + } + } + ] + }, + "userLabelType": "AutomatedLabel" + } + }, + "has_graduated_access": true, + "is_blue_verified": true, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": false, + "can_media_tag": false, + "created_at": "Wed Apr 11 01:33:52 +0000 2007", + "default_profile": false, + "default_profile_image": false, + "description": "警戒:震度1以上 もしくは M3以上の地震情報を提供しています。 基本的に返事は行いません。問い合わせは@ariela もしくはyuki at https://t.co/DrMBNu9mAfにどうぞ。 非公式RTを繰り返すBOTはブロックします。", + "entities": { + "description": { + "urls": [ + { + "display_url": "transrain.net", + "expanded_url": "http://transrain.net", + "url": "https://t.co/DrMBNu9mAf", + "indices": [ + 72, + 95 + ] + } + ] + }, + "url": { + "urls": [ + { + "url": "http://earthquake.transrain.net/", + "indices": [ + 0, + 32 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 1, + "followers_count": 3219441, + "friends_count": 5, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 44208, + "location": "", + "media_count": 0, + "name": "地震速報", + "normal_followers_count": 3219441, + "pinned_tweet_ids_str": [ + "1623494931666046977" + ], + "possibly_sensitive": false, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/368358807/eqjp_normal.png", + "profile_interstitial_type": "", + "screen_name": "earthquake_jp", + "statuses_count": 59090, + "translator_type": "none", + "url": "http://earthquake.transrain.net/", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + }, + "smart_blocked_by": false, + "smart_blocking": false, + "legacy_extended_profile": {}, + "is_profile_translatable": true, + "verification_info": { + "reason": { + "description": { + "text": "This account is verified. Learn more", + "entities": [ + { + "from_index": 26, + "to_index": 36, + "ref": { + "url": "https://help.twitter.com/managing-your-account/about-twitter-verified-accounts", + "url_type": "ExternalUrl" + } + } + ] + }, + "verified_since_msec": "1682244679134" + } + }, + "highlights_info": { + "can_highlight_tweets": true, + "highlighted_tweets": "0" + }, + "business_account": {}, + "creator_subscriptions_count": 0 +} diff --git a/OpenTween/Api/DataModel/TwitterEntity.cs b/OpenTween/Api/DataModel/TwitterEntity.cs index cd7e2e0cf..e39cbc0e9 100644 --- a/OpenTween/Api/DataModel/TwitterEntity.cs +++ b/OpenTween/Api/DataModel/TwitterEntity.cs @@ -162,11 +162,11 @@ public class TwitterEntitySymbol : TwitterEntity [DataContract] public class TwitterEntityUrl : TwitterEntity { - [DataMember(Name = "display_url")] - public string DisplayUrl { get; set; } + [DataMember(Name = "display_url", IsRequired = false)] + public string? DisplayUrl { get; set; } - [DataMember(Name = "expanded_url")] - public string ExpandedUrl { get; set; } + [DataMember(Name = "expanded_url", IsRequired = false)] + public string? ExpandedUrl { get; set; } [DataMember(Name = "url")] public string Url { get; set; } diff --git a/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs index c3d267824..a241e4a86 100644 --- a/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs +++ b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs @@ -37,6 +37,8 @@ namespace OpenTween.Api.GraphQL { public class ListLatestTweetsTimelineRequest { + public static readonly string EndpointName = "ListLatestTweetsTimeline"; + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/6ClPnsuzQJ1p7-g32GQw9Q/ListLatestTweetsTimeline"); public string ListId { get; set; } @@ -89,7 +91,7 @@ public async Task Send(IApiConnection apiConnection) XElement rootElm; try { - using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param, EndpointName); using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); rootElm = XElement.Load(jsonReader); } @@ -106,9 +108,10 @@ public async Task Send(IApiConnection apiConnection) ErrorResponse.ThrowIfError(rootElm); var tweets = TimelineTweet.ExtractTimelineTweets(rootElm); + var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value; var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value; - return new(tweets, cursorBottom); + return new(tweets, cursorTop, cursorBottom); } } } diff --git a/OpenTween/Api/GraphQL/SearchTimelineRequest.cs b/OpenTween/Api/GraphQL/SearchTimelineRequest.cs index cc712ea03..9e76ca05b 100644 --- a/OpenTween/Api/GraphQL/SearchTimelineRequest.cs +++ b/OpenTween/Api/GraphQL/SearchTimelineRequest.cs @@ -37,6 +37,8 @@ namespace OpenTween.Api.GraphQL { public class SearchTimelineRequest { + public static readonly string EndpointName = "SearchTimeline"; + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/lZ0GCEojmtQfiUQa5oJSEw/SearchTimeline"); public string RawQuery { get; set; } @@ -91,7 +93,7 @@ public async Task Send(IApiConnection apiConnection) XElement rootElm; try { - using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param, EndpointName); using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); rootElm = XElement.Load(jsonReader); } @@ -108,9 +110,10 @@ public async Task Send(IApiConnection apiConnection) ErrorResponse.ThrowIfError(rootElm); var tweets = TimelineTweet.ExtractTimelineTweets(rootElm); + var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value; var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value; - return new(tweets, cursorBottom); + return new(tweets, cursorTop, cursorBottom); } } } diff --git a/OpenTween/Api/GraphQL/TimelineResponse.cs b/OpenTween/Api/GraphQL/TimelineResponse.cs index f1942c145..d92430a07 100644 --- a/OpenTween/Api/GraphQL/TimelineResponse.cs +++ b/OpenTween/Api/GraphQL/TimelineResponse.cs @@ -31,6 +31,7 @@ namespace OpenTween.Api.GraphQL { public record TimelineResponse( TimelineTweet[] Tweets, + string? CursorTop, string? CursorBottom ); } diff --git a/OpenTween/Api/GraphQL/TimelineTweet.cs b/OpenTween/Api/GraphQL/TimelineTweet.cs index 375a87d1c..dd20e3401 100644 --- a/OpenTween/Api/GraphQL/TimelineTweet.cs +++ b/OpenTween/Api/GraphQL/TimelineTweet.cs @@ -138,8 +138,8 @@ static string GetText(XElement elm, string name) .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"), + DisplayUrl = GetTextOrNull(x, "display_url"), + ExpandedUrl = GetTextOrNull(x, "expanded_url"), Url = GetText(x, "url"), }) .ToArray(), diff --git a/OpenTween/Api/GraphQL/TweetDetailRequest.cs b/OpenTween/Api/GraphQL/TweetDetailRequest.cs index 86edb401e..009e1041f 100644 --- a/OpenTween/Api/GraphQL/TweetDetailRequest.cs +++ b/OpenTween/Api/GraphQL/TweetDetailRequest.cs @@ -38,6 +38,8 @@ namespace OpenTween.Api.GraphQL { public class TweetDetailRequest { + public static readonly string EndpointName = "TweetDetail"; + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/-Ls3CrSQNo2fRKH6i6Na1A/TweetDetail"); required public TwitterStatusId FocalTweetId { get; set; } @@ -65,7 +67,7 @@ public async Task Send(IApiConnection apiConnection) XElement rootElm; try { - using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param, EndpointName); using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); rootElm = XElement.Load(jsonReader); } diff --git a/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs b/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs index 96ab805c1..22221b646 100644 --- a/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs +++ b/OpenTween/Api/GraphQL/TwitterGraphqlUser.cs @@ -96,8 +96,8 @@ static string GetText(XElement elm, string name) .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"), + DisplayUrl = GetTextOrNull(x, "display_url"), + ExpandedUrl = GetTextOrNull(x, "expanded_url"), Url = GetText(x, "url"), }) .ToArray(), @@ -108,8 +108,8 @@ static string GetText(XElement elm, string name) .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"), + DisplayUrl = GetTextOrNull(x, "display_url"), + ExpandedUrl = GetTextOrNull(x, "expanded_url"), Url = GetText(x, "url"), }) .ToArray(), diff --git a/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs index c72764b11..f261924b6 100644 --- a/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs +++ b/OpenTween/Api/GraphQL/UserByScreenNameRequest.cs @@ -37,6 +37,8 @@ namespace OpenTween.Api.GraphQL { public class UserByScreenNameRequest { + public static readonly string EndpointName = "UserByScreenName"; + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/xc8f1g7BYqr6VTzTbvNlGw/UserByScreenName"); required public string ScreenName { get; set; } @@ -64,7 +66,7 @@ public async Task Send(IApiConnection apiConnection) XElement rootElm; try { - using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param, EndpointName); using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); rootElm = XElement.Load(jsonReader); } diff --git a/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs b/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs index 446165e77..916a914b8 100644 --- a/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs +++ b/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs @@ -37,6 +37,8 @@ namespace OpenTween.Api.GraphQL { public class UserTweetsAndRepliesRequest { + public static readonly string EndpointName = "UserTweetsAndReplies"; + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/YlkSUg0mRBx7-EkxCvc-bw/UserTweetsAndReplies"); public string UserId { get; set; } @@ -74,7 +76,7 @@ public async Task Send(IApiConnection apiConnection) XElement rootElm; try { - using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param, EndpointName); using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); rootElm = XElement.Load(jsonReader); } @@ -91,9 +93,10 @@ public async Task Send(IApiConnection apiConnection) ErrorResponse.ThrowIfError(rootElm); var tweets = TimelineTweet.ExtractTimelineTweets(rootElm); + var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value; var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value; - return new(tweets, cursorBottom); + return new(tweets, cursorTop, cursorBottom); } } } diff --git a/OpenTween/Connection/IApiConnection.cs b/OpenTween/Connection/IApiConnection.cs index e54126306..b7c866f4c 100644 --- a/OpenTween/Connection/IApiConnection.cs +++ b/OpenTween/Connection/IApiConnection.cs @@ -36,6 +36,8 @@ public interface IApiConnection : IDisposable Task GetStreamAsync(Uri uri, IDictionary? param); + Task GetStreamAsync(Uri uri, IDictionary? param, string? endpointName); + Task GetStreamingStreamAsync(Uri uri, IDictionary? param); Task> PostLazyAsync(Uri uri, IDictionary? param); diff --git a/OpenTween/Connection/Networking.cs b/OpenTween/Connection/Networking.cs index b75013ceb..bdc0e47ad 100644 --- a/OpenTween/Connection/Networking.cs +++ b/OpenTween/Connection/Networking.cs @@ -143,6 +143,7 @@ public static WebRequestHandler CreateHttpClientHandler() { UseCookies = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + ReadWriteTimeout = (int)DefaultTimeout.TotalMilliseconds, }; if (Networking.Proxy != null) diff --git a/OpenTween/Connection/TwitterApiConnection.cs b/OpenTween/Connection/TwitterApiConnection.cs index d76e08824..16e2c342b 100644 --- a/OpenTween/Connection/TwitterApiConnection.cs +++ b/OpenTween/Connection/TwitterApiConnection.cs @@ -167,8 +167,15 @@ private void ThrowIfRateLimitExceeded(string endpointName) } } - public async Task GetStreamAsync(Uri uri, IDictionary? param) + public Task GetStreamAsync(Uri uri, IDictionary? param) + => this.GetStreamAsync(uri, param, null); + + public async Task GetStreamAsync(Uri uri, IDictionary? param, string? endpointName) { + // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる + if (endpointName != null) + this.ThrowIfRateLimitExceeded(endpointName); + var requestUri = new Uri(RestApiBase, uri); if (param != null) @@ -176,7 +183,16 @@ public async Task GetStreamAsync(Uri uri, IDictionary? p try { - return await this.Http.GetStreamAsync(requestUri) + var response = await this.Http.GetAsync(requestUri) + .ConfigureAwait(false); + + if (endpointName != null) + MyCommon.TwitterApiInfo.UpdateFromHeader(response.Headers, endpointName); + + await TwitterApiConnection.CheckStatusCode(response) + .ConfigureAwait(false); + + return await response.Content.ReadAsStreamAsync() .ConfigureAwait(false); } catch (HttpRequestException ex) diff --git a/OpenTween/Models/ListTimelineTabModel.cs b/OpenTween/Models/ListTimelineTabModel.cs index c792a4029..04e2a1b7a 100644 --- a/OpenTween/Models/ListTimelineTabModel.cs +++ b/OpenTween/Models/ListTimelineTabModel.cs @@ -45,6 +45,8 @@ public override MyCommon.TabUsageType TabType public PostId? OldestId { get; set; } + public string? CursorTop { get; set; } + public string? CursorBottom { get; set; } public ListTimelineTabModel(string tabName, ListElement list) diff --git a/OpenTween/Models/PublicSearchTabModel.cs b/OpenTween/Models/PublicSearchTabModel.cs index ce609f352..5c1c73f3e 100644 --- a/OpenTween/Models/PublicSearchTabModel.cs +++ b/OpenTween/Models/PublicSearchTabModel.cs @@ -45,6 +45,8 @@ public override MyCommon.TabUsageType TabType public PostId? SinceId { get; set; } + public string? CursorTop { get; set; } + public string? CursorBottom { get; set; } public string SearchWords diff --git a/OpenTween/Models/TwitterPostFactory.cs b/OpenTween/Models/TwitterPostFactory.cs index 6fb82e1b4..b540d2ae6 100644 --- a/OpenTween/Models/TwitterPostFactory.cs +++ b/OpenTween/Models/TwitterPostFactory.cs @@ -114,7 +114,7 @@ public PostClass CreateFromStatus( .ToArray(); var expandedUrls = entities.OfType() - .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl)) + .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl ?? x.Url)) .ToArray(); // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる) @@ -229,7 +229,7 @@ long selfUserId .ToArray(); var expandedUrls = entities.OfType() - .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl)) + .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl ?? x.Url)) .ToArray(); // 以下、ユーザー情報 @@ -512,7 +512,7 @@ public static IEnumerable GetQuoteTweetStatusIds(IEnumerable(); - var urls = entities.OfType().Select(x => x.ExpandedUrl); + var urls = entities.OfType().Select(x => x.ExpandedUrl ?? x.Url); if (quotedStatusLink != null) urls = urls.Append(quotedStatusLink.Expanded); diff --git a/OpenTween/Models/UserTimelineTabModel.cs b/OpenTween/Models/UserTimelineTabModel.cs index 51faea6eb..ab82511bd 100644 --- a/OpenTween/Models/UserTimelineTabModel.cs +++ b/OpenTween/Models/UserTimelineTabModel.cs @@ -47,6 +47,8 @@ public override MyCommon.TabUsageType TabType public PostId? OldestId { get; set; } + public string? CursorTop { get; set; } + public string? CursorBottom { get; set; } public UserTimelineTabModel(string tabName, string screenName) diff --git a/OpenTween/Properties/AssemblyInfo.cs b/OpenTween/Properties/AssemblyInfo.cs index 55fa49f90..b7e69474f 100644 --- a/OpenTween/Properties/AssemblyInfo.cs +++ b/OpenTween/Properties/AssemblyInfo.cs @@ -22,7 +22,7 @@ // 次の GUID は、このプロジェクトが COM に公開される場合の、typelib の ID です [assembly: Guid("2d0ae0ba-adac-49a2-9b10-26fd69e695bf")] -[assembly: AssemblyVersion("3.8.0.0")] +[assembly: AssemblyVersion("3.9.0.0")] [assembly: InternalsVisibleTo("OpenTween.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq diff --git a/OpenTween/Properties/Resources.Designer.cs b/OpenTween/Properties/Resources.Designer.cs index d837cebbc..0201229ab 100644 --- a/OpenTween/Properties/Resources.Designer.cs +++ b/OpenTween/Properties/Resources.Designer.cs @@ -580,19 +580,19 @@ internal static string ChangeIconToolStripMenuItem_Confirm { /// /// 更新履歴 /// + ///==== Ver 3.9.0(2023/12/03) + /// * NEW: graphqlエンドポイントに対するレートリミットの表示に対応 + /// * CHG: タイムライン更新時に全件ではなく新着投稿のみ差分を取得する動作に変更 + /// * FIX: 設定したタイムアウト時間を超えてAPI接続が持続する場合がある不具合を修正 + /// * FIX: プロフィール情報のURL欄のパースに失敗する場合がある不具合を修正 + /// - この問題が起きるユーザーのツイートが含まれているとタイムラインの読み込みに失敗する問題も改善されます + /// ///==== Ver 3.8.0(2023/11/29) /// * NEW: graphqlエンドポイントを使用した検索タイムラインの取得に対応 /// * NEW: graphqlエンドポイントを使用したプロフィール情報の取得に対応 /// * NEW: graphqlエンドポイントを使用したユーザータイムラインの取得に対応 /// * CHG: タイムライン更新が停止する不具合が報告される件への暫定的な対処 - /// - タイムライン更新に30秒以上掛かっている場合は完了を待機せず次のタイマーを開始させる - /// - タイムライン更新の次回実行が1時間以上先になる場合は異常値としてタイマーをリセットする - /// * FIX: 動画のサムネイル表示時に再生可能であることを示すアイコンが表示されない不具合を修正 - /// * FIX: リスト更新時に発生したネットワークエラーが適切に処理されない不具合を修正 - /// * FIX: 起動直後にタイムラインの取得が重複して行われる不具合を修正 - /// - ///==== Ver 3.7.1(2023/07/20) - /// * FIX: Cookie使用時に複数回ツイートを投稿するとDelaying [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 + /// - タイムライン更新に30秒以上掛かっている場合は完了を待機せず次のタイマーを [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 /// internal static string ChangeLog { get { diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs index f879d58c9..969462a2d 100644 --- a/OpenTween/Tween.cs +++ b/OpenTween/Tween.cs @@ -52,6 +52,7 @@ using System.Windows.Forms; using OpenTween.Api; using OpenTween.Api.DataModel; +using OpenTween.Api.GraphQL; using OpenTween.Api.TwitterV2; using OpenTween.Connection; using OpenTween.MediaUploadServices; @@ -7083,17 +7084,22 @@ private void SetApiStatusLabel(string? endpointName = null) if (endpointName == null) { + var authByCookie = this.tw.Api.AppToken.AuthType == APIAuthType.TwitterComCookie; + // 表示中のタブに応じて更新 endpointName = tabType switch { - MyCommon.TabUsageType.Home => GetTimelineRequest.EndpointName, + MyCommon.TabUsageType.Home => "/statuses/home_timeline", MyCommon.TabUsageType.UserDefined => "/statuses/home_timeline", MyCommon.TabUsageType.Mentions => "/statuses/mentions_timeline", MyCommon.TabUsageType.Favorites => "/favorites/list", MyCommon.TabUsageType.DirectMessage => "/direct_messages/events/list", - MyCommon.TabUsageType.UserTimeline => "/statuses/user_timeline", - MyCommon.TabUsageType.Lists => "/lists/statuses", - MyCommon.TabUsageType.PublicSearch => "/search/tweets", + MyCommon.TabUsageType.UserTimeline => + authByCookie ? UserTweetsAndRepliesRequest.EndpointName : "/statuses/user_timeline", + MyCommon.TabUsageType.Lists => + authByCookie ? ListLatestTweetsTimelineRequest.EndpointName : "/lists/statuses", + MyCommon.TabUsageType.PublicSearch => + authByCookie ? SearchTimelineRequest.EndpointName : "/search/tweets", MyCommon.TabUsageType.Related => "/statuses/show/:id", _ => null, }; @@ -7101,31 +7107,8 @@ private void SetApiStatusLabel(string? endpointName = null) } else { - // 表示中のタブに関連する endpoint であれば更新 - bool update; - if (endpointName == GetTimelineRequest.EndpointName) - { - update = tabType == MyCommon.TabUsageType.Home || tabType == MyCommon.TabUsageType.UserDefined; - } - else - { - update = endpointName switch - { - "/statuses/mentions_timeline" => tabType == MyCommon.TabUsageType.Mentions, - "/favorites/list" => tabType == MyCommon.TabUsageType.Favorites, - "/direct_messages/events/list" => tabType == MyCommon.TabUsageType.DirectMessage, - "/statuses/user_timeline" => tabType == MyCommon.TabUsageType.UserTimeline, - "/lists/statuses" => tabType == MyCommon.TabUsageType.Lists, - "/search/tweets" => tabType == MyCommon.TabUsageType.PublicSearch, - "/statuses/show/:id" => tabType == MyCommon.TabUsageType.Related, - _ => false, - }; - } - - if (update) - { - this.toolStripApiGauge.ApiEndpoint = endpointName; - } + var currentEndpointName = this.toolStripApiGauge.ApiEndpoint; + this.toolStripApiGauge.ApiEndpoint = currentEndpointName; } } diff --git a/OpenTween/TweetFormatter.cs b/OpenTween/TweetFormatter.cs index 557e6137f..a036bd28d 100644 --- a/OpenTween/TweetFormatter.cs +++ b/OpenTween/TweetFormatter.cs @@ -132,7 +132,7 @@ private static string FormatUrlEntity(string targetText, TwitterEntityUrl entity // 過去に存在した壊れたエンティティの対策 // 参照: https://dev.twitter.com/discussions/12628 - if (entity.DisplayUrl == null) + if (entity.DisplayUrl == null || entity.ExpandedUrl == null) { expandedUrl = MyCommon.ConvertToReadableUrl(targetText); return $"""{T(E(targetText))}"""; diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index b9a7d3a61..4e34545ba 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -682,7 +682,7 @@ public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool m var request = new UserTweetsAndRepliesRequest(userId) { Count = count, - Cursor = more ? tab.CursorBottom : null, + Cursor = more ? tab.CursorBottom : tab.CursorTop, }; var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); @@ -694,6 +694,9 @@ public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool m .ToArray(); tab.CursorBottom = response.CursorBottom; + + if (!more) + tab.CursorTop = response.CursorTop; } else { @@ -881,7 +884,7 @@ public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, var request = new ListLatestTweetsTimelineRequest(tab.ListInfo.Id.ToString()) { Count = count, - Cursor = more ? tab.CursorBottom : null, + Cursor = more ? tab.CursorBottom : tab.CursorTop, }; var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); @@ -895,6 +898,9 @@ public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, statuses = convertedStatuses.ToArray(); tab.CursorBottom = response.CursorBottom; + + if (!more) + tab.CursorTop = response.CursorTop; } else if (more) { @@ -1088,7 +1094,7 @@ public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more) var request = new SearchTimelineRequest(tab.SearchWords) { Count = count, - Cursor = more ? tab.CursorBottom : null, + Cursor = more ? tab.CursorBottom : tab.CursorTop, }; var response = await request.Send(this.Api.Connection) .ConfigureAwait(false); @@ -1099,6 +1105,9 @@ public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more) .ToArray(); tab.CursorBottom = response.CursorBottom; + + if (!more) + tab.CursorTop = response.CursorTop; } else { diff --git a/OpenTween/UserInfoDialog.cs b/OpenTween/UserInfoDialog.cs index ac778d17f..176ac2e4d 100644 --- a/OpenTween/UserInfoDialog.cs +++ b/OpenTween/UserInfoDialog.cs @@ -169,7 +169,12 @@ private async Task SetDescriptionAsync(string? descriptionText, TwitterEntities? var urlEntities = entities?.Urls ?? Array.Empty(); foreach (var entity in urlEntities) + { + if (entity.ExpandedUrl == null) + continue; + entity.ExpandedUrl = await ShortUrl.Instance.ExpandUrlAsync(entity.ExpandedUrl); + } // user.entities には urls 以外のエンティティが含まれていないため、テキストをもとに生成する var mergedEntities = urlEntities.AsEnumerable() @@ -253,7 +258,7 @@ private async Task SetRecentStatusAsync(TwitterStatus? status, CancellationToken var urlEntities = entities.Urls ?? Array.Empty(); foreach (var entity in urlEntities) - entity.ExpandedUrl = await ShortUrl.Instance.ExpandUrlAsync(entity.ExpandedUrl); + entity.ExpandedUrl = await ShortUrl.Instance.ExpandUrlAsync(entity.ExpandedUrl ?? entity.Url); var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(status.FullText)); diff --git a/appveyor.yml b/appveyor.yml index 5986621db..6d75fa444 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 3.7.1.{build} +version: 3.8.0.{build} os: Visual Studio 2022