OSDN Git Service

bf89cd9a85ea31b3301ef8be96d2c07a073f012d
[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             "4sq.com",
75             "airme.us",
76             "amzn.to",
77             "bctiny.com",
78             "bit.ly",
79             "bkite.com",
80             "blip.fm",
81             "budurl.com",
82             "cli.gs",
83             "digg.com",
84             "dlvr.it",
85             "fb.me",
86             "feeds.feedburner.com",
87             "ff.im",
88             "flic.kr",
89             "goo.gl",
90             "ht.ly",
91             "htn.to",
92             "icanhaz.com",
93             "is.gd",
94             "j.mp",
95             "linkbee.com",
96             "moi.st",
97             "nico.ms",
98             "nsfw.in",
99             "on.fb.me",
100             "ow.ly",
101             "p.tl",
102             "pic.gd",
103             "qurl.com",
104             "rubyurl.com",
105             "snipurl.com",
106             "snurl.com",
107             "t.co",
108             "tinami.jp",
109             "tiny.cc",
110             "tinyurl.com",
111             "tl.gd",
112             "traceurl.com",
113             "tumblr.com",
114             "twitthis.com",
115             "twurl.nl",
116             "urlenco.de",
117             "ustre.am",
118             "ux.nu",
119             "www.qurl.com",
120             "youtu.be",
121         };
122
123         static ShortUrl()
124         {
125             _instance = new Lazy<ShortUrl>(() =>
126             {
127                 var handler = new HttpClientHandler
128                 {
129                     AllowAutoRedirect = false,
130                 };
131
132                 var http = MyCommon.CreateHttpClient(handler);
133                 http.Timeout = new TimeSpan(0, 0, seconds: 5);
134
135                 return new ShortUrl(http);
136             }, true);
137         }
138
139         internal ShortUrl(HttpClient http)
140         {
141             this.DisableExpanding = false;
142             this.PurgeCount = 500;
143             this.BitlyId = "";
144             this.BitlyKey = "";
145
146             this.http = http;
147         }
148
149         [Obsolete]
150         public string ExpandUrl(string uri)
151         {
152             try
153             {
154                 return this.ExpandUrlAsync(new Uri(uri), 10).Result.ToString();
155             }
156             catch (UriFormatException)
157             {
158                 return uri;
159             }
160         }
161
162         /// <summary>
163         /// 短縮 URL を非同期に展開します
164         /// </summary>
165         /// <param name="uri">展開するURL</param>
166         /// <returns>URLの展開を行うタスク</returns>
167         public Task<Uri> ExpandUrlAsync(Uri uri)
168         {
169             return this.ExpandUrlAsync(uri, 10);
170         }
171
172         /// <summary>
173         /// 短縮 URL を非同期に展開します
174         /// </summary>
175         /// <param name="uri">展開するURL</param>
176         /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
177         /// <returns>URLの展開を行うタスク</returns>
178         public async Task<Uri> ExpandUrlAsync(Uri uri, int redirectLimit)
179         {
180             if (this.DisableExpanding)
181                 return uri;
182
183             if (redirectLimit <= 0)
184                 return uri;
185
186             if (!uri.IsAbsoluteUri)
187                 return uri;
188
189             try
190             {
191                 if (!ShortUrlHosts.Contains(uri.Host))
192                     return uri;
193
194                 Uri expanded;
195                 if (this.urlCache.TryGetValue(uri, out expanded))
196                     return expanded;
197
198                 if (this.urlCache.Count > this.PurgeCount)
199                     this.urlCache.Clear();
200
201                 expanded = null;
202                 try
203                 {
204                     expanded = await this.GetRedirectTo(uri)
205                         .ConfigureAwait(false);
206                 }
207                 catch (TaskCanceledException) { }
208                 catch (HttpRequestException) { }
209
210                 if (expanded == null || expanded == uri)
211                     return uri;
212
213                 this.urlCache[uri] = expanded;
214
215                 var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit)
216                     .ConfigureAwait(false);
217
218                 // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する
219                 if (recursiveExpanded != expanded)
220                     this.urlCache[uri] = recursiveExpanded;
221
222                 return recursiveExpanded;
223             }
224             catch (UriFormatException)
225             {
226                 return uri;
227             }
228         }
229
230         /// <summary>
231         /// 短縮 URL を非同期に展開します
232         /// </summary>
233         /// <remarks>
234         /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
235         /// </remarks>
236         /// <param name="uriStr">展開するURL</param>
237         /// <returns>URLの展開を行うタスク</returns>
238         public Task<string> ExpandUrlAsync(string uriStr)
239         {
240             return this.ExpandUrlAsync(uriStr, 10);
241         }
242
243         /// <summary>
244         /// 短縮 URL を非同期に展開します
245         /// </summary>
246         /// <remarks>
247         /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
248         /// </remarks>
249         /// <param name="uriStr">展開するURL</param>
250         /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
251         /// <returns>URLの展開を行うタスク</returns>
252         public async Task<string> ExpandUrlAsync(string uriStr, int redirectLimit)
253         {
254             Uri uri;
255
256             try
257             {
258                 if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase))
259                     uri = new Uri("http://" + uriStr);
260                 else
261                     uri = new Uri(uriStr);
262             }
263             catch (UriFormatException)
264             {
265                 return uriStr;
266             }
267
268             var expandedUri = await this.ExpandUrlAsync(uri, redirectLimit)
269                 .ConfigureAwait(false);
270
271             return expandedUri.OriginalString;
272         }
273
274         [Obsolete]
275         public string ExpandUrlHtml(string html)
276         {
277             return this.ExpandUrlHtmlAsync(html, 10).Result;
278         }
279
280         /// <summary>
281         /// HTML内に含まれるリンクのURLを非同期に展開する
282         /// </summary>
283         /// <param name="html">処理対象のHTML</param>
284         /// <returns>URLの展開を行うタスク</returns>
285         public Task<string> ExpandUrlHtmlAsync(string html)
286         {
287             return this.ExpandUrlHtmlAsync(html, 10);
288         }
289
290         /// <summary>
291         /// HTML内に含まれるリンクのURLを非同期に展開する
292         /// </summary>
293         /// <param name="html">処理対象のHTML</param>
294         /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
295         /// <returns>URLの展開を行うタスク</returns>
296         public Task<string> ExpandUrlHtmlAsync(string html, int redirectLimit)
297         {
298             if (this.DisableExpanding)
299                 return Task.FromResult(html);
300
301             return HtmlLinkPattern.ReplaceAsync(html, async m =>
302                 m.Groups[1].Value + await this.ExpandUrlAsync(m.Groups[2].Value, redirectLimit).ConfigureAwait(false) + m.Groups[3].Value);
303         }
304
305         /// <summary>
306         /// 指定された短縮URLサービスを使用してURLを短縮します
307         /// </summary>
308         /// <param name="shortenerType">使用する短縮URLサービス</param>
309         /// <param name="srcUri">短縮するURL</param>
310         /// <returns>短縮されたURL</returns>
311         public Task<Uri> ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri)
312         {
313             // 既に短縮されている状態のURLであれば短縮しない
314             if (ShortUrlHosts.Contains(srcUri.Host))
315                 return Task.FromResult(srcUri);
316
317             switch (shortenerType)
318             {
319                 case MyCommon.UrlConverter.TinyUrl:
320                     return this.ShortenByTinyUrlAsync(srcUri);
321                 case MyCommon.UrlConverter.Isgd:
322                     return this.ShortenByIsgdAsync(srcUri);
323                 case MyCommon.UrlConverter.Twurl:
324                     return this.ShortenByTwurlAsync(srcUri);
325                 case MyCommon.UrlConverter.Bitly:
326                     return this.ShortenByBitlyAsync(srcUri, "bit.ly");
327                 case MyCommon.UrlConverter.Jmp:
328                     return this.ShortenByBitlyAsync(srcUri, "j.mp");
329                 case MyCommon.UrlConverter.Uxnu:
330                     return this.ShortenByUxnuAsync(srcUri);
331                 default:
332                     throw new ArgumentException("Unknown shortener.", "shortenerType");
333             }
334         }
335
336         private async Task<Uri> ShortenByTinyUrlAsync(Uri srcUri)
337         {
338             // 明らかに長くなると推測できる場合は短縮しない
339             if ("http://tinyurl.com/xxxxxx".Length > srcUri.OriginalString.Length)
340                 return srcUri;
341
342             var content = new FormUrlEncodedContent(new[]
343             {
344                 new KeyValuePair<string, string>("url", srcUri.OriginalString),
345             });
346
347             using (var response = await this.http.PostAsync("http://tinyurl.com/api-create.php", content).ConfigureAwait(false))
348             {
349                 response.EnsureSuccessStatusCode();
350
351                 var result = await response.Content.ReadAsStringAsync()
352                     .ConfigureAwait(false);
353
354                 if (!Regex.IsMatch(result, @"^https?://"))
355                     throw new WebApiException("Failed to create URL.", result);
356
357                 return new Uri(result.TrimEnd());
358             }
359         }
360
361         private async Task<Uri> ShortenByIsgdAsync(Uri srcUri)
362         {
363             // 明らかに長くなると推測できる場合は短縮しない
364             if ("http://is.gd/xxxx".Length > srcUri.OriginalString.Length)
365                 return srcUri;
366
367             var content = new FormUrlEncodedContent(new[]
368             {
369                 new KeyValuePair<string, string>("format", "simple"),
370                 new KeyValuePair<string, string>("url", srcUri.OriginalString),
371             });
372
373             using (var response = await this.http.PostAsync("http://is.gd/create.php", content).ConfigureAwait(false))
374             {
375                 response.EnsureSuccessStatusCode();
376
377                 var result = await response.Content.ReadAsStringAsync()
378                     .ConfigureAwait(false);
379
380                 if (!Regex.IsMatch(result, @"^https?://"))
381                     throw new WebApiException("Failed to create URL.", result);
382
383                 return new Uri(result.TrimEnd());
384             }
385         }
386
387         private async Task<Uri> ShortenByTwurlAsync(Uri srcUri)
388         {
389             // 明らかに長くなると推測できる場合は短縮しない
390             if ("http://twurl.nl/xxxxxx".Length > srcUri.OriginalString.Length)
391                 return srcUri;
392
393             var content = new FormUrlEncodedContent(new[]
394             {
395                 new KeyValuePair<string, string>("link[url]", srcUri.OriginalString),
396             });
397
398             using (var response = await this.http.PostAsync("http://tweetburner.com/links", content).ConfigureAwait(false))
399             {
400                 response.EnsureSuccessStatusCode();
401
402                 var result = await response.Content.ReadAsStringAsync()
403                     .ConfigureAwait(false);
404
405                 if (!Regex.IsMatch(result, @"^https?://"))
406                     throw new WebApiException("Failed to create URL.", result);
407
408                 return new Uri(result.TrimEnd());
409             }
410         }
411
412         private async Task<Uri> ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly")
413         {
414             // 明らかに長くなると推測できる場合は短縮しない
415             if ("http://bit.ly/xxxx".Length > srcUri.OriginalString.Length)
416                 return srcUri;
417
418             // bit.ly 短縮機能実装のプライバシー問題の暫定対応
419             // ログインIDとAPIキーが指定されていない場合は短縮せずにPOSTする
420             // 参照: http://sourceforge.jp/projects/opentween/lists/archive/dev/2012-January/000020.html
421             if (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey))
422                 return srcUri;
423
424             var query = new Dictionary<string, string>
425             {
426                 {"login", this.BitlyId},
427                 {"apiKey", this.BitlyKey},
428                 {"format", "txt"},
429                 {"domain", domain},
430                 {"longUrl", srcUri.OriginalString},
431             };
432
433             var uri = new Uri("https://api-ssl.bitly.com/v3/shorten?" + MyCommon.BuildQueryString(query));
434             using (var response = await this.http.GetAsync(uri).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> ShortenByUxnuAsync(Uri srcUri)
449         {
450             // 明らかに長くなると推測できる場合は短縮しない
451             if ("http://ux.nx/xxxxxx".Length > srcUri.OriginalString.Length)
452                 return srcUri;
453
454             var query = new Dictionary<string, string>
455             {
456                 {"format", "plain"},
457                 {"url", srcUri.OriginalString},
458             };
459
460             var uri = new Uri("http://ux.nu/api/short?" + MyCommon.BuildQueryString(query));
461             using (var response = await this.http.GetAsync(uri).ConfigureAwait(false))
462             {
463                 response.EnsureSuccessStatusCode();
464
465                 var result = await response.Content.ReadAsStringAsync()
466                     .ConfigureAwait(false);
467
468                 if (!Regex.IsMatch(result, @"^https?://"))
469                     throw new WebApiException("Failed to create URL.", result);
470
471                 return new Uri(result.TrimEnd());
472             }
473         }
474
475         private async Task<Uri> GetRedirectTo(Uri url)
476         {
477             var request = new HttpRequestMessage(HttpMethod.Head, url);
478
479             using (var response = await this.http.SendAsync(request).ConfigureAwait(false))
480             {
481                 if (!response.IsSuccessStatusCode)
482                 {
483                     // ステータスコードが 3xx であれば例外を発生させない
484                     if ((int)response.StatusCode / 100 != 3)
485                         response.EnsureSuccessStatusCode();
486                 }
487
488                 return response.Headers.Location;
489             }
490         }
491     }
492 }