OSDN Git Service

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