OSDN Git Service

ShortUrlの非同期メソッドを使用する箇所にawaitをなるべく使用する
[opentween/open-tween.git] / OpenTween / ShortUrl.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
8 // All rights reserved.
9 //
10 // This file is part of OpenTween.
11 //
12 // This program is free software; you can redistribute it and/or modify it
13 // under the terms of the GNU General public License as published by the Free
14 // Software Foundation; either version 3 of the License, or (at your option)
15 // any later version.
16 //
17 // This program is distributed in the hope that it will be useful, but
18 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
20 // for more details.
21 //
22 // You should have received a copy of the GNU General public License along
23 // with this program. if (not, see <http://www.gnu.org/licenses/>, or write to
24 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
25 // Boston, MA 02110-1301, USA.
26
27 using System;
28 using System.Collections.Concurrent;
29 using System.Collections.Generic;
30 using System.Linq;
31 using System.Net.Http;
32 using System.Text;
33 using System.Text.RegularExpressions;
34 using System.Threading.Tasks;
35 using System.Web;
36
37 namespace OpenTween
38 {
39     /// <summary>
40     /// 短縮 URL サービスによる URL の展開・短縮を行うクラス
41     /// </summary>
42     public class ShortUrl
43     {
44         private static Lazy<ShortUrl> _instance;
45
46         /// <summary>
47         /// ShortUrl のインストタンスを取得します
48         /// </summary>
49         public static ShortUrl Instance
50         {
51             get { return _instance.Value; }
52         }
53
54         /// <summary>
55         /// 短縮 URL の展開を無効にするか否か
56         /// </summary>
57         public bool DisableExpanding { get; set; }
58
59         /// <summary>
60         /// 短縮 URL のキャッシュを定期的にクリアする回数
61         /// </summary>
62         public int PurgeCount { get; set; }
63
64         public string BitlyId { get; set; }
65         public string BitlyKey { get; set; }
66
67         private HttpClient http;
68         private ConcurrentDictionary<Uri, Uri> urlCache = new ConcurrentDictionary<Uri, Uri>();
69
70         private static readonly Regex HtmlLinkPattern = new Regex(@"(<a href="")(.+?)("")");
71
72         private static readonly HashSet<string> ShortUrlHosts = new HashSet<string>()
73         {
74             "airme.us",
75             "amzn.to",
76             "bctiny.com",
77             "bit.ly",
78             "bkite.com",
79             "blip.fm",
80             "budurl.com",
81             "cli.gs",
82             "digg.com",
83             "dlvr.it",
84             "fb.me",
85             "feeds.feedburner.com",
86             "ff.im",
87             "flic.kr",
88             "goo.gl",
89             "ht.ly",
90             "htn.to",
91             "icanhaz.com",
92             "is.gd",
93             "j.mp",
94             "linkbee.com",
95             "moi.st",
96             "nico.ms",
97             "nsfw.in",
98             "on.fb.me",
99             "ow.ly",
100             "p.tl",
101             "pic.gd",
102             "qurl.com",
103             "rubyurl.com",
104             "snipurl.com",
105             "snurl.com",
106             "t.co",
107             "tinami.jp",
108             "tiny.cc",
109             "tinyurl.com",
110             "tl.gd",
111             "traceurl.com",
112             "tumblr.com",
113             "twitthis.com",
114             "twurl.nl",
115             "urlenco.de",
116             "ustre.am",
117             "ux.nu",
118             "www.qurl.com",
119             "youtu.be",
120         };
121
122         static ShortUrl()
123         {
124             _instance = new Lazy<ShortUrl>(() =>
125             {
126                 var handler = new HttpClientHandler
127                 {
128                     AllowAutoRedirect = false,
129                 };
130
131                 var http = MyCommon.CreateHttpClient(handler);
132                 http.Timeout = new TimeSpan(0, 0, seconds: 5);
133
134                 return new ShortUrl(http);
135             }, true);
136         }
137
138         internal ShortUrl(HttpClient http)
139         {
140             this.DisableExpanding = false;
141             this.PurgeCount = 500;
142             this.BitlyId = "";
143             this.BitlyKey = "";
144
145             this.http = http;
146         }
147
148         [Obsolete]
149         public string ExpandUrl(string uri)
150         {
151             try
152             {
153                 return this.ExpandUrlAsync(new Uri(uri), 10).Result.ToString();
154             }
155             catch (UriFormatException)
156             {
157                 return uri;
158             }
159         }
160
161         /// <summary>
162         /// 短縮 URL を非同期に展開します
163         /// </summary>
164         /// <param name="uri">展開するURL</param>
165         /// <returns>URLの展開を行うタスク</returns>
166         public Task<Uri> ExpandUrlAsync(Uri uri)
167         {
168             return this.ExpandUrlAsync(uri, 10);
169         }
170
171         /// <summary>
172         /// 短縮 URL を非同期に展開します
173         /// </summary>
174         /// <param name="uri">展開するURL</param>
175         /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
176         /// <returns>URLの展開を行うタスク</returns>
177         public async Task<Uri> ExpandUrlAsync(Uri uri, int redirectLimit)
178         {
179             if (this.DisableExpanding)
180                 return uri;
181
182             if (redirectLimit <= 0)
183                 return uri;
184
185             try
186             {
187                 if (!ShortUrlHosts.Contains(uri.Host))
188                     return uri;
189
190                 Uri expanded;
191                 if (this.urlCache.TryGetValue(uri, out expanded))
192                     return expanded;
193
194                 if (this.urlCache.Count > this.PurgeCount)
195                     this.urlCache.Clear();
196
197                 expanded = null;
198                 try
199                 {
200                     expanded = await this.GetRedirectTo(uri)
201                         .ConfigureAwait(false);
202                 }
203                 catch (TaskCanceledException) { }
204                 catch (HttpRequestException) { }
205
206                 if (expanded == null || expanded == uri)
207                     return uri;
208
209                 this.urlCache[uri] = expanded;
210
211                 var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit)
212                     .ConfigureAwait(false);
213
214                 // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する
215                 if (recursiveExpanded != expanded)
216                     this.urlCache[uri] = recursiveExpanded;
217
218                 return recursiveExpanded;
219             }
220             catch (UriFormatException)
221             {
222                 return uri;
223             }
224         }
225
226         /// <summary>
227         /// 短縮 URL を非同期に展開します
228         /// </summary>
229         /// <param name="uriStr">展開するURL</param>
230         /// <returns>URLの展開を行うタスク</returns>
231         public async Task<string> ExpandUrlStrAsync(string uriStr)
232         {
233             Uri uri;
234
235             try
236             {
237                 if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase))
238                     uri = new Uri("http://" + uriStr);
239                 else
240                     uri = new Uri(uriStr);
241             }
242             catch (UriFormatException)
243             {
244                 return uriStr;
245             }
246
247             var expandedUri = await this.ExpandUrlAsync(uri, 10)
248                 .ConfigureAwait(false);
249
250             return expandedUri.OriginalString;
251         }
252
253         [Obsolete]
254         public string ExpandUrlHtml(string html)
255         {
256             return this.ExpandUrlHtmlAsync(html, 10).Result;
257         }
258
259         /// <summary>
260         /// HTML内に含まれるリンクのURLを非同期に展開する
261         /// </summary>
262         /// <param name="html">処理対象のHTML</param>
263         /// <returns>URLの展開を行うタスク</returns>
264         public Task<string> ExpandUrlHtmlAsync(string html)
265         {
266             return this.ExpandUrlHtmlAsync(html, 10);
267         }
268
269         /// <summary>
270         /// HTML内に含まれるリンクのURLを非同期に展開する
271         /// </summary>
272         /// <param name="html">処理対象のHTML</param>
273         /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
274         /// <returns>URLの展開を行うタスク</returns>
275         public Task<string> ExpandUrlHtmlAsync(string html, int redirectLimit)
276         {
277             if (this.DisableExpanding)
278                 return Task.FromResult(html);
279
280             return HtmlLinkPattern.ReplaceAsync(html, async m =>
281                 m.Groups[1].Value + await this.ExpandUrlAsync(new Uri(m.Groups[2].Value), redirectLimit).ConfigureAwait(false) + m.Groups[3].Value);
282         }
283
284         /// <summary>
285         /// 指定された短縮URLサービスを使用してURLを短縮します
286         /// </summary>
287         /// <param name="shortenerType">使用する短縮URLサービス</param>
288         /// <param name="srcUri">短縮するURL</param>
289         /// <returns>短縮されたURL</returns>
290         public Task<Uri> ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri)
291         {
292             // 既に短縮されている状態のURLであれば短縮しない
293             if (ShortUrlHosts.Contains(srcUri.Host))
294                 return Task.FromResult(srcUri);
295
296             switch (shortenerType)
297             {
298                 case MyCommon.UrlConverter.TinyUrl:
299                     return this.ShortenByTinyUrlAsync(srcUri);
300                 case MyCommon.UrlConverter.Isgd:
301                     return this.ShortenByIsgdAsync(srcUri);
302                 case MyCommon.UrlConverter.Twurl:
303                     return this.ShortenByTwurlAsync(srcUri);
304                 case MyCommon.UrlConverter.Bitly:
305                     return this.ShortenByBitlyAsync(srcUri, "bit.ly");
306                 case MyCommon.UrlConverter.Jmp:
307                     return this.ShortenByBitlyAsync(srcUri, "j.mp");
308                 case MyCommon.UrlConverter.Uxnu:
309                     return this.ShortenByUxnuAsync(srcUri);
310                 default:
311                     throw new ArgumentException("Unknown shortener.", "shortenerType");
312             }
313         }
314
315         private async Task<Uri> ShortenByTinyUrlAsync(Uri srcUri)
316         {
317             // 明らかに長くなると推測できる場合は短縮しない
318             if ("http://tinyurl.com/xxxxxx".Length > srcUri.OriginalString.Length)
319                 return srcUri;
320
321             var content = new FormUrlEncodedContent(new[]
322             {
323                 new KeyValuePair<string, string>("url", srcUri.OriginalString),
324             });
325
326             using (var response = await this.http.PostAsync("http://tinyurl.com/api-create.php", content).ConfigureAwait(false))
327             {
328                 response.EnsureSuccessStatusCode();
329
330                 var result = await response.Content.ReadAsStringAsync()
331                     .ConfigureAwait(false);
332
333                 if (!Regex.IsMatch(result, @"^https?://"))
334                     throw new WebApiException("Failed to create URL.", result);
335
336                 return new Uri(result.TrimEnd());
337             }
338         }
339
340         private async Task<Uri> ShortenByIsgdAsync(Uri srcUri)
341         {
342             // 明らかに長くなると推測できる場合は短縮しない
343             if ("http://is.gd/xxxx".Length > srcUri.OriginalString.Length)
344                 return srcUri;
345
346             var content = new FormUrlEncodedContent(new[]
347             {
348                 new KeyValuePair<string, string>("format", "simple"),
349                 new KeyValuePair<string, string>("url", srcUri.OriginalString),
350             });
351
352             using (var response = await this.http.PostAsync("http://is.gd/create.php", content).ConfigureAwait(false))
353             {
354                 response.EnsureSuccessStatusCode();
355
356                 var result = await response.Content.ReadAsStringAsync()
357                     .ConfigureAwait(false);
358
359                 if (!Regex.IsMatch(result, @"^https?://"))
360                     throw new WebApiException("Failed to create URL.", result);
361
362                 return new Uri(result.TrimEnd());
363             }
364         }
365
366         private async Task<Uri> ShortenByTwurlAsync(Uri srcUri)
367         {
368             // 明らかに長くなると推測できる場合は短縮しない
369             if ("http://twurl.nl/xxxxxx".Length > srcUri.OriginalString.Length)
370                 return srcUri;
371
372             var content = new FormUrlEncodedContent(new[]
373             {
374                 new KeyValuePair<string, string>("link[url]", srcUri.OriginalString),
375             });
376
377             using (var response = await this.http.PostAsync("http://tweetburner.com/links", content).ConfigureAwait(false))
378             {
379                 response.EnsureSuccessStatusCode();
380
381                 var result = await response.Content.ReadAsStringAsync()
382                     .ConfigureAwait(false);
383
384                 if (!Regex.IsMatch(result, @"^https?://"))
385                     throw new WebApiException("Failed to create URL.", result);
386
387                 return new Uri(result.TrimEnd());
388             }
389         }
390
391         private async Task<Uri> ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly")
392         {
393             // 明らかに長くなると推測できる場合は短縮しない
394             if ("http://bit.ly/xxxx".Length > srcUri.OriginalString.Length)
395                 return srcUri;
396
397             // bit.ly 短縮機能実装のプライバシー問題の暫定対応
398             // ログインIDとAPIキーが指定されていない場合は短縮せずにPOSTする
399             // 参照: http://sourceforge.jp/projects/opentween/lists/archive/dev/2012-January/000020.html
400             if (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey))
401                 return srcUri;
402
403             var query = HttpUtility.ParseQueryString(string.Empty);
404             query["login"] = this.BitlyId;
405             query["apiKey"] = this.BitlyKey;
406             query["format"] = "txt";
407             query["domain"] = domain;
408             query["longUrl"] = srcUri.OriginalString;
409
410             using (var response = await this.http.GetAsync("https://api-ssl.bitly.com/v3/shorten?" + query).ConfigureAwait(false))
411             {
412                 response.EnsureSuccessStatusCode();
413
414                 var result = await response.Content.ReadAsStringAsync()
415                     .ConfigureAwait(false);
416
417                 if (!Regex.IsMatch(result, @"^https?://"))
418                     throw new WebApiException("Failed to create URL.", result);
419
420                 return new Uri(result.TrimEnd());
421             }
422         }
423
424         private async Task<Uri> ShortenByUxnuAsync(Uri srcUri)
425         {
426             // 明らかに長くなると推測できる場合は短縮しない
427             if ("http://ux.nx/xxxxxx".Length > srcUri.OriginalString.Length)
428                 return srcUri;
429
430             var query = HttpUtility.ParseQueryString(string.Empty);
431             query["format"] = "plain";
432             query["url"] = srcUri.OriginalString;
433
434             using (var response = await this.http.GetAsync("http://ux.nu/api/short?" + query).ConfigureAwait(false))
435             {
436                 response.EnsureSuccessStatusCode();
437
438                 var result = await response.Content.ReadAsStringAsync()
439                     .ConfigureAwait(false);
440
441                 if (!Regex.IsMatch(result, @"^https?://"))
442                     throw new WebApiException("Failed to create URL.", result);
443
444                 return new Uri(result.TrimEnd());
445             }
446         }
447
448         private async Task<Uri> GetRedirectTo(Uri url)
449         {
450             var request = new HttpRequestMessage(HttpMethod.Head, url);
451
452             using (var response = await this.http.SendAsync(request).ConfigureAwait(false))
453             {
454                 if (!response.IsSuccessStatusCode)
455                 {
456                     // ステータスコードが 3xx であれば例外を発生させない
457                     if ((int)response.StatusCode / 100 != 3)
458                         response.EnsureSuccessStatusCode();
459                 }
460
461                 return response.Headers.Location;
462             }
463         }
464     }
465 }