Correct JSON writing (Closes #596)
[youtube-dl] / youtube_dl / utils.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 import gzip
5 import io
6 import json
7 import locale
8 import os
9 import re
10 import sys
11 import zlib
12 import email.utils
13 import json
14
15 try:
16     import urllib.request as compat_urllib_request
17 except ImportError: # Python 2
18     import urllib2 as compat_urllib_request
19
20 try:
21     import urllib.error as compat_urllib_error
22 except ImportError: # Python 2
23     import urllib2 as compat_urllib_error
24
25 try:
26     import urllib.parse as compat_urllib_parse
27 except ImportError: # Python 2
28     import urllib as compat_urllib_parse
29
30 try:
31     from urllib.parse import urlparse as compat_urllib_parse_urlparse
32 except ImportError: # Python 2
33     from urlparse import urlparse as compat_urllib_parse_urlparse
34
35 try:
36     import http.cookiejar as compat_cookiejar
37 except ImportError: # Python 2
38     import cookielib as compat_cookiejar
39
40 try:
41     import html.entities as compat_html_entities
42 except ImportError: # Python 2
43     import htmlentitydefs as compat_html_entities
44
45 try:
46     import html.parser as compat_html_parser
47 except ImportError: # Python 2
48     import HTMLParser as compat_html_parser
49
50 try:
51     import http.client as compat_http_client
52 except ImportError: # Python 2
53     import httplib as compat_http_client
54
55 try:
56     from subprocess import DEVNULL
57     compat_subprocess_get_DEVNULL = lambda: DEVNULL
58 except ImportError:
59     compat_subprocess_get_DEVNULL = lambda: open(os.path.devnull, 'w')
60
61 try:
62     from urllib.parse import parse_qs as compat_parse_qs
63 except ImportError: # Python 2
64     # HACK: The following is the correct parse_qs implementation from cpython 3's stdlib.
65     # Python 2's version is apparently totally broken
66     def _unquote(string, encoding='utf-8', errors='replace'):
67         if string == '':
68             return string
69         res = string.split('%')
70         if len(res) == 1:
71             return string
72         if encoding is None:
73             encoding = 'utf-8'
74         if errors is None:
75             errors = 'replace'
76         # pct_sequence: contiguous sequence of percent-encoded bytes, decoded
77         pct_sequence = b''
78         string = res[0]
79         for item in res[1:]:
80             try:
81                 if not item:
82                     raise ValueError
83                 pct_sequence += item[:2].decode('hex')
84                 rest = item[2:]
85                 if not rest:
86                     # This segment was just a single percent-encoded character.
87                     # May be part of a sequence of code units, so delay decoding.
88                     # (Stored in pct_sequence).
89                     continue
90             except ValueError:
91                 rest = '%' + item
92             # Encountered non-percent-encoded characters. Flush the current
93             # pct_sequence.
94             string += pct_sequence.decode(encoding, errors) + rest
95             pct_sequence = b''
96         if pct_sequence:
97             # Flush the final pct_sequence
98             string += pct_sequence.decode(encoding, errors)
99         return string
100
101     def _parse_qsl(qs, keep_blank_values=False, strict_parsing=False,
102                 encoding='utf-8', errors='replace'):
103         qs, _coerce_result = qs, unicode
104         pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')]
105         r = []
106         for name_value in pairs:
107             if not name_value and not strict_parsing:
108                 continue
109             nv = name_value.split('=', 1)
110             if len(nv) != 2:
111                 if strict_parsing:
112                     raise ValueError("bad query field: %r" % (name_value,))
113                 # Handle case of a control-name with no equal sign
114                 if keep_blank_values:
115                     nv.append('')
116                 else:
117                     continue
118             if len(nv[1]) or keep_blank_values:
119                 name = nv[0].replace('+', ' ')
120                 name = _unquote(name, encoding=encoding, errors=errors)
121                 name = _coerce_result(name)
122                 value = nv[1].replace('+', ' ')
123                 value = _unquote(value, encoding=encoding, errors=errors)
124                 value = _coerce_result(value)
125                 r.append((name, value))
126         return r
127
128     def compat_parse_qs(qs, keep_blank_values=False, strict_parsing=False,
129                 encoding='utf-8', errors='replace'):
130         parsed_result = {}
131         pairs = _parse_qsl(qs, keep_blank_values, strict_parsing,
132                         encoding=encoding, errors=errors)
133         for name, value in pairs:
134             if name in parsed_result:
135                 parsed_result[name].append(value)
136             else:
137                 parsed_result[name] = [value]
138         return parsed_result
139
140 try:
141     compat_str = unicode # Python 2
142 except NameError:
143     compat_str = str
144
145 try:
146     compat_chr = unichr # Python 2
147 except NameError:
148     compat_chr = chr
149
150 std_headers = {
151     'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0',
152     'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
153     'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
154     'Accept-Encoding': 'gzip, deflate',
155     'Accept-Language': 'en-us,en;q=0.5',
156 }
157 def preferredencoding():
158     """Get preferred encoding.
159
160     Returns the best encoding scheme for the system, based on
161     locale.getpreferredencoding() and some further tweaks.
162     """
163     try:
164         pref = locale.getpreferredencoding()
165         u'TEST'.encode(pref)
166     except:
167         pref = 'UTF-8'
168
169     return pref
170
171 if sys.version_info < (3,0):
172     def compat_print(s):
173         print(s.encode(preferredencoding(), 'xmlcharrefreplace'))
174 else:
175     def compat_print(s):
176         assert type(s) == type(u'')
177         print(s)
178
179 # In Python 2.x, json.dump expects a bytestream.
180 # In Python 3.x, it writes to a character stream
181 if sys.version_info < (3,0):
182     def write_json_file(obj, fn):
183         with open(fn, 'wb') as f:
184             json.dump(obj, f)
185 else:
186     def write_json_file(obj, fn):
187         with open(fn, 'w', encoding='utf-8') as f:
188             json.dump(obj, f)
189
190
191 def htmlentity_transform(matchobj):
192     """Transforms an HTML entity to a character.
193
194     This function receives a match object and is intended to be used with
195     the re.sub() function.
196     """
197     entity = matchobj.group(1)
198
199     # Known non-numeric HTML entity
200     if entity in compat_html_entities.name2codepoint:
201         return compat_chr(compat_html_entities.name2codepoint[entity])
202
203     mobj = re.match(u'(?u)#(x?\\d+)', entity)
204     if mobj is not None:
205         numstr = mobj.group(1)
206         if numstr.startswith(u'x'):
207             base = 16
208             numstr = u'0%s' % numstr
209         else:
210             base = 10
211         return compat_chr(int(numstr, base))
212
213     # Unknown entity in name, return its literal representation
214     return (u'&%s;' % entity)
215
216 compat_html_parser.locatestarttagend = re.compile(r"""<[a-zA-Z][-.a-zA-Z0-9:_]*(?:\s+(?:(?<=['"\s])[^\s/>][^\s/=>]*(?:\s*=+\s*(?:'[^']*'|"[^"]*"|(?!['"])[^>\s]*))?\s*)*)?\s*""", re.VERBOSE) # backport bugfix
217 class IDParser(compat_html_parser.HTMLParser):
218     """Modified HTMLParser that isolates a tag with the specified id"""
219     def __init__(self, id):
220         self.id = id
221         self.result = None
222         self.started = False
223         self.depth = {}
224         self.html = None
225         self.watch_startpos = False
226         self.error_count = 0
227         compat_html_parser.HTMLParser.__init__(self)
228
229     def error(self, message):
230         if self.error_count > 10 or self.started:
231             raise compat_html_parser.HTMLParseError(message, self.getpos())
232         self.rawdata = '\n'.join(self.html.split('\n')[self.getpos()[0]:]) # skip one line
233         self.error_count += 1
234         self.goahead(1)
235
236     def loads(self, html):
237         self.html = html
238         self.feed(html)
239         self.close()
240
241     def handle_starttag(self, tag, attrs):
242         attrs = dict(attrs)
243         if self.started:
244             self.find_startpos(None)
245         if 'id' in attrs and attrs['id'] == self.id:
246             self.result = [tag]
247             self.started = True
248             self.watch_startpos = True
249         if self.started:
250             if not tag in self.depth: self.depth[tag] = 0
251             self.depth[tag] += 1
252
253     def handle_endtag(self, tag):
254         if self.started:
255             if tag in self.depth: self.depth[tag] -= 1
256             if self.depth[self.result[0]] == 0:
257                 self.started = False
258                 self.result.append(self.getpos())
259
260     def find_startpos(self, x):
261         """Needed to put the start position of the result (self.result[1])
262         after the opening tag with the requested id"""
263         if self.watch_startpos:
264             self.watch_startpos = False
265             self.result.append(self.getpos())
266     handle_entityref = handle_charref = handle_data = handle_comment = \
267     handle_decl = handle_pi = unknown_decl = find_startpos
268
269     def get_result(self):
270         if self.result is None:
271             return None
272         if len(self.result) != 3:
273             return None
274         lines = self.html.split('\n')
275         lines = lines[self.result[1][0]-1:self.result[2][0]]
276         lines[0] = lines[0][self.result[1][1]:]
277         if len(lines) == 1:
278             lines[-1] = lines[-1][:self.result[2][1]-self.result[1][1]]
279         lines[-1] = lines[-1][:self.result[2][1]]
280         return '\n'.join(lines).strip()
281
282 def get_element_by_id(id, html):
283     """Return the content of the tag with the specified id in the passed HTML document"""
284     parser = IDParser(id)
285     try:
286         parser.loads(html)
287     except compat_html_parser.HTMLParseError:
288         pass
289     return parser.get_result()
290
291
292 def clean_html(html):
293     """Clean an HTML snippet into a readable string"""
294     # Newline vs <br />
295     html = html.replace('\n', ' ')
296     html = re.sub('\s*<\s*br\s*/?\s*>\s*', '\n', html)
297     # Strip html tags
298     html = re.sub('<.*?>', '', html)
299     # Replace html entities
300     html = unescapeHTML(html)
301     return html
302
303
304 def sanitize_open(filename, open_mode):
305     """Try to open the given filename, and slightly tweak it if this fails.
306
307     Attempts to open the given filename. If this fails, it tries to change
308     the filename slightly, step by step, until it's either able to open it
309     or it fails and raises a final exception, like the standard open()
310     function.
311
312     It returns the tuple (stream, definitive_file_name).
313     """
314     try:
315         if filename == u'-':
316             if sys.platform == 'win32':
317                 import msvcrt
318                 msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
319             return (sys.stdout, filename)
320         stream = open(encodeFilename(filename), open_mode)
321         return (stream, filename)
322     except (IOError, OSError) as err:
323         # In case of error, try to remove win32 forbidden chars
324         filename = re.sub(u'[/<>:"\\|\\\\?\\*]', u'#', filename)
325
326         # An exception here should be caught in the caller
327         stream = open(encodeFilename(filename), open_mode)
328         return (stream, filename)
329
330
331 def timeconvert(timestr):
332     """Convert RFC 2822 defined time string into system timestamp"""
333     timestamp = None
334     timetuple = email.utils.parsedate_tz(timestr)
335     if timetuple is not None:
336         timestamp = email.utils.mktime_tz(timetuple)
337     return timestamp
338
339 def sanitize_filename(s, restricted=False, is_id=False):
340     """Sanitizes a string so it could be used as part of a filename.
341     If restricted is set, use a stricter subset of allowed characters.
342     Set is_id if this is not an arbitrary string, but an ID that should be kept if possible
343     """
344     def replace_insane(char):
345         if char == '?' or ord(char) < 32 or ord(char) == 127:
346             return ''
347         elif char == '"':
348             return '' if restricted else '\''
349         elif char == ':':
350             return '_-' if restricted else ' -'
351         elif char in '\\/|*<>':
352             return '_'
353         if restricted and (char in '!&\'()[]{}$;`^,#' or char.isspace()):
354             return '_'
355         if restricted and ord(char) > 127:
356             return '_'
357         return char
358
359     result = u''.join(map(replace_insane, s))
360     if not is_id:
361         while '__' in result:
362             result = result.replace('__', '_')
363         result = result.strip('_')
364         # Common case of "Foreign band name - English song title"
365         if restricted and result.startswith('-_'):
366             result = result[2:]
367         if not result:
368             result = '_'
369     return result
370
371 def orderedSet(iterable):
372     """ Remove all duplicates from the input iterable """
373     res = []
374     for el in iterable:
375         if el not in res:
376             res.append(el)
377     return res
378
379 def unescapeHTML(s):
380     """
381     @param s a string
382     """
383     assert type(s) == type(u'')
384
385     result = re.sub(u'(?u)&(.+?);', htmlentity_transform, s)
386     return result
387
388 def encodeFilename(s):
389     """
390     @param s The name of the file
391     """
392
393     assert type(s) == type(u'')
394
395     # Python 3 has a Unicode API
396     if sys.version_info >= (3, 0):
397         return s
398
399     if sys.platform == 'win32' and sys.getwindowsversion()[0] >= 5:
400         # Pass u'' directly to use Unicode APIs on Windows 2000 and up
401         # (Detecting Windows NT 4 is tricky because 'major >= 4' would
402         # match Windows 9x series as well. Besides, NT 4 is obsolete.)
403         return s
404     else:
405         return s.encode(sys.getfilesystemencoding(), 'ignore')
406
407 class DownloadError(Exception):
408     """Download Error exception.
409
410     This exception may be thrown by FileDownloader objects if they are not
411     configured to continue on errors. They will contain the appropriate
412     error message.
413     """
414     pass
415
416
417 class SameFileError(Exception):
418     """Same File exception.
419
420     This exception will be thrown by FileDownloader objects if they detect
421     multiple files would have to be downloaded to the same file on disk.
422     """
423     pass
424
425
426 class PostProcessingError(Exception):
427     """Post Processing exception.
428
429     This exception may be raised by PostProcessor's .run() method to
430     indicate an error in the postprocessing task.
431     """
432     pass
433
434 class MaxDownloadsReached(Exception):
435     """ --max-downloads limit has been reached. """
436     pass
437
438
439 class UnavailableVideoError(Exception):
440     """Unavailable Format exception.
441
442     This exception will be thrown when a video is requested
443     in a format that is not available for that video.
444     """
445     pass
446
447
448 class ContentTooShortError(Exception):
449     """Content Too Short exception.
450
451     This exception may be raised by FileDownloader objects when a file they
452     download is too small for what the server announced first, indicating
453     the connection was probably interrupted.
454     """
455     # Both in bytes
456     downloaded = None
457     expected = None
458
459     def __init__(self, downloaded, expected):
460         self.downloaded = downloaded
461         self.expected = expected
462
463
464 class Trouble(Exception):
465     """Trouble helper exception
466
467     This is an exception to be handled with
468     FileDownloader.trouble
469     """
470
471 class YoutubeDLHandler(compat_urllib_request.HTTPHandler):
472     """Handler for HTTP requests and responses.
473
474     This class, when installed with an OpenerDirector, automatically adds
475     the standard headers to every HTTP request and handles gzipped and
476     deflated responses from web servers. If compression is to be avoided in
477     a particular request, the original request in the program code only has
478     to include the HTTP header "Youtubedl-No-Compression", which will be
479     removed before making the real request.
480
481     Part of this code was copied from:
482
483     http://techknack.net/python-urllib2-handlers/
484
485     Andrew Rowls, the author of that code, agreed to release it to the
486     public domain.
487     """
488
489     @staticmethod
490     def deflate(data):
491         try:
492             return zlib.decompress(data, -zlib.MAX_WBITS)
493         except zlib.error:
494             return zlib.decompress(data)
495
496     @staticmethod
497     def addinfourl_wrapper(stream, headers, url, code):
498         if hasattr(compat_urllib_request.addinfourl, 'getcode'):
499             return compat_urllib_request.addinfourl(stream, headers, url, code)
500         ret = compat_urllib_request.addinfourl(stream, headers, url)
501         ret.code = code
502         return ret
503
504     def http_request(self, req):
505         for h in std_headers:
506             if h in req.headers:
507                 del req.headers[h]
508             req.add_header(h, std_headers[h])
509         if 'Youtubedl-no-compression' in req.headers:
510             if 'Accept-encoding' in req.headers:
511                 del req.headers['Accept-encoding']
512             del req.headers['Youtubedl-no-compression']
513         return req
514
515     def http_response(self, req, resp):
516         old_resp = resp
517         # gzip
518         if resp.headers.get('Content-encoding', '') == 'gzip':
519             gz = gzip.GzipFile(fileobj=io.BytesIO(resp.read()), mode='r')
520             resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code)
521             resp.msg = old_resp.msg
522         # deflate
523         if resp.headers.get('Content-encoding', '') == 'deflate':
524             gz = io.BytesIO(self.deflate(resp.read()))
525             resp = self.addinfourl_wrapper(gz, old_resp.headers, old_resp.url, old_resp.code)
526             resp.msg = old_resp.msg
527         return resp
528
529     https_request = http_request
530     https_response = http_response