2
0

parse.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """
  2. Everything related to parsing links from input sources.
  3. For a list of supported services, see the README.md.
  4. For examples of supported import formats see tests/.
  5. Link: {
  6. 'url': 'https://example.com/example/?abc=123&xyc=345#lmnop',
  7. 'timestamp': '1544212312.4234',
  8. 'title': 'Example.com Page Title',
  9. 'tags': 'abc,def',
  10. 'sources': [
  11. 'output/sources/ril_export.html',
  12. 'output/sources/getpocket.com-1523422111.txt',
  13. 'output/sources/stdin-234234112312.txt'
  14. ]
  15. }
  16. """
  17. import re
  18. import json
  19. from datetime import datetime
  20. import xml.etree.ElementTree as etree
  21. from config import TIMEOUT
  22. from util import (
  23. str_between,
  24. URL_REGEX,
  25. check_url_parsing_invariants,
  26. TimedProgress,
  27. )
  28. def parse_links(source_file):
  29. """parse a list of URLs with their metadata from an
  30. RSS feed, bookmarks export, or text file
  31. """
  32. check_url_parsing_invariants()
  33. PARSERS = (
  34. # Specialized parsers
  35. ('Pocket HTML', parse_pocket_html_export),
  36. ('Pinboard RSS', parse_pinboard_rss_export),
  37. ('Shaarli RSS', parse_shaarli_rss_export),
  38. ('Medium RSS', parse_medium_rss_export),
  39. # General parsers
  40. ('Netscape HTML', parse_netscape_html_export),
  41. ('Generic RSS', parse_rss_export),
  42. ('Generic JSON', parse_json_export),
  43. # Fallback parser
  44. ('Plain Text', parse_plain_text_export),
  45. )
  46. timer = TimedProgress(TIMEOUT * 4)
  47. with open(source_file, 'r', encoding='utf-8') as file:
  48. for parser_name, parser_func in PARSERS:
  49. try:
  50. links = list(parser_func(file))
  51. if links:
  52. timer.end()
  53. return links, parser_name
  54. except Exception as err:
  55. # Parsers are tried one by one down the list, and the first one
  56. # that succeeds is used. To see why a certain parser was not used
  57. # due to error or format incompatibility, uncomment this line:
  58. # print('[!] Parser {} failed: {} {}'.format(parser_name, err.__class__.__name__, err))
  59. pass
  60. timer.end()
  61. return [], 'Failed to parse'
  62. ### Import Parser Functions
  63. def parse_pocket_html_export(html_file):
  64. """Parse Pocket-format bookmarks export files (produced by getpocket.com/export/)"""
  65. html_file.seek(0)
  66. pattern = re.compile("^\\s*<li><a href=\"(.+)\" time_added=\"(\\d+)\" tags=\"(.*)\">(.+)</a></li>", re.UNICODE)
  67. for line in html_file:
  68. # example line
  69. # <li><a href="http://example.com/ time_added="1478739709" tags="tag1,tag2">example title</a></li>
  70. match = pattern.search(line)
  71. if match:
  72. url = match.group(1).replace('http://www.readability.com/read?url=', '') # remove old readability prefixes to get original url
  73. time = datetime.fromtimestamp(float(match.group(2)))
  74. tags = match.group(3)
  75. title = match.group(4).replace(' — Readability', '').replace('http://www.readability.com/read?url=', '')
  76. yield {
  77. 'url': url,
  78. 'timestamp': str(time.timestamp()),
  79. 'title': title or None,
  80. 'tags': tags or '',
  81. 'sources': [html_file.name],
  82. }
  83. def parse_json_export(json_file):
  84. """Parse JSON-format bookmarks export files (produced by pinboard.in/export/, or wallabag)"""
  85. json_file.seek(0)
  86. links = json.load(json_file)
  87. json_date = lambda s: datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z')
  88. for link in links:
  89. # example line
  90. # {"href":"http:\/\/www.reddit.com\/r\/example","description":"title here","extended":"","meta":"18a973f09c9cc0608c116967b64e0419","hash":"910293f019c2f4bb1a749fb937ba58e3","time":"2014-06-14T15:51:42Z","shared":"no","toread":"no","tags":"reddit android"}]
  91. if link:
  92. # Parse URL
  93. url = link.get('href') or link.get('url') or link.get('URL')
  94. if not url:
  95. raise Exception('JSON must contain URL in each entry [{"url": "http://...", ...}, ...]')
  96. # Parse the timestamp
  97. ts_str = str(datetime.now().timestamp())
  98. if link.get('timestamp'):
  99. # chrome/ff histories use a very precise timestamp
  100. ts_str = str(link['timestamp'] / 10000000)
  101. elif link.get('time'):
  102. ts_str = str(json_date(link['time'].split(',', 1)[0]).timestamp())
  103. elif link.get('created_at'):
  104. ts_str = str(json_date(link['created_at']).timestamp())
  105. elif link.get('created'):
  106. ts_str = str(json_date(link['created']).timestamp())
  107. elif link.get('date'):
  108. ts_str = str(json_date(link['date']).timestamp())
  109. elif link.get('bookmarked'):
  110. ts_str = str(json_date(link['bookmarked']).timestamp())
  111. elif link.get('saved'):
  112. ts_str = str(json_date(link['saved']).timestamp())
  113. # Parse the title
  114. title = None
  115. if link.get('title'):
  116. title = link['title'].strip() or None
  117. elif link.get('description'):
  118. title = link['description'].replace(' — Readability', '').strip() or None
  119. elif link.get('name'):
  120. title = link['name'].strip() or None
  121. yield {
  122. 'url': url,
  123. 'timestamp': ts_str,
  124. 'title': title,
  125. 'tags': link.get('tags') or '',
  126. 'sources': [json_file.name],
  127. }
  128. def parse_rss_export(rss_file):
  129. """Parse RSS XML-format files into links"""
  130. rss_file.seek(0)
  131. items = rss_file.read().split('<item>')
  132. items = items[1:] if items else []
  133. for item in items:
  134. # example item:
  135. # <item>
  136. # <title><![CDATA[How JavaScript works: inside the V8 engine]]></title>
  137. # <category>Unread</category>
  138. # <link>https://blog.sessionstack.com/how-javascript-works-inside</link>
  139. # <guid>https://blog.sessionstack.com/how-javascript-works-inside</guid>
  140. # <pubDate>Mon, 21 Aug 2017 14:21:58 -0500</pubDate>
  141. # </item>
  142. trailing_removed = item.split('</item>', 1)[0]
  143. leading_removed = trailing_removed.split('<item>', 1)[-1].strip()
  144. rows = leading_removed.split('\n')
  145. def get_row(key):
  146. return [r for r in rows if r.strip().startswith('<{}>'.format(key))][0]
  147. url = str_between(get_row('link'), '<link>', '</link>')
  148. ts_str = str_between(get_row('pubDate'), '<pubDate>', '</pubDate>')
  149. time = datetime.strptime(ts_str, "%a, %d %b %Y %H:%M:%S %z")
  150. title = str_between(get_row('title'), '<![CDATA[', ']]').strip() or None
  151. yield {
  152. 'url': url,
  153. 'timestamp': str(time.timestamp()),
  154. 'title': title,
  155. 'tags': '',
  156. 'sources': [rss_file.name],
  157. }
  158. def parse_shaarli_rss_export(rss_file):
  159. """Parse Shaarli-specific RSS XML-format files into links"""
  160. rss_file.seek(0)
  161. entries = rss_file.read().split('<entry>')[1:]
  162. for entry in entries:
  163. # example entry:
  164. # <entry>
  165. # <title>Aktuelle Trojaner-Welle: Emotet lauert in gefälschten Rechnungsmails | heise online</title>
  166. # <link href="https://www.heise.de/security/meldung/Aktuelle-Trojaner-Welle-Emotet-lauert-in-gefaelschten-Rechnungsmails-4291268.html" />
  167. # <id>https://demo.shaarli.org/?cEV4vw</id>
  168. # <published>2019-01-30T06:06:01+00:00</published>
  169. # <updated>2019-01-30T06:06:01+00:00</updated>
  170. # <content type="html" xml:lang="en"><![CDATA[<div class="markdown"><p>&#8212; <a href="https://demo.shaarli.org/?cEV4vw">Permalink</a></p></div>]]></content>
  171. # </entry>
  172. trailing_removed = entry.split('</entry>', 1)[0]
  173. leading_removed = trailing_removed.strip()
  174. rows = leading_removed.split('\n')
  175. def get_row(key):
  176. return [r.strip() for r in rows if r.strip().startswith('<{}'.format(key))][0]
  177. title = str_between(get_row('title'), '<title>', '</title>').strip()
  178. url = str_between(get_row('link'), '<link href="', '" />')
  179. ts_str = str_between(get_row('published'), '<published>', '</published>')
  180. time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
  181. yield {
  182. 'url': url,
  183. 'timestamp': str(time.timestamp()),
  184. 'title': title or None,
  185. 'tags': '',
  186. 'sources': [rss_file.name],
  187. }
  188. def parse_netscape_html_export(html_file):
  189. """Parse netscape-format bookmarks export files (produced by all browsers)"""
  190. html_file.seek(0)
  191. pattern = re.compile("<a href=\"(.+?)\" add_date=\"(\\d+)\"[^>]*>(.+)</a>", re.UNICODE | re.IGNORECASE)
  192. for line in html_file:
  193. # example line
  194. # <DT><A HREF="https://example.com/?q=1+2" ADD_DATE="1497562974" LAST_MODIFIED="1497562974" ICON_URI="https://example.com/favicon.ico" ICON="data:image/png;base64,...">example bookmark title</A>
  195. match = pattern.search(line)
  196. if match:
  197. url = match.group(1)
  198. time = datetime.fromtimestamp(float(match.group(2)))
  199. yield {
  200. 'url': url,
  201. 'timestamp': str(time.timestamp()),
  202. 'title': match.group(3).strip() or None,
  203. 'tags': '',
  204. 'sources': [html_file.name],
  205. }
  206. def parse_pinboard_rss_export(rss_file):
  207. """Parse Pinboard RSS feed files into links"""
  208. rss_file.seek(0)
  209. root = etree.parse(rss_file).getroot()
  210. items = root.findall("{http://purl.org/rss/1.0/}item")
  211. for item in items:
  212. url = item.find("{http://purl.org/rss/1.0/}link").text
  213. tags = item.find("{http://purl.org/dc/elements/1.1/}subject").text if item.find("{http://purl.org/dc/elements/1.1/}subject") else None
  214. title = item.find("{http://purl.org/rss/1.0/}title").text.strip() if item.find("{http://purl.org/rss/1.0/}title").text.strip() else None
  215. ts_str = item.find("{http://purl.org/dc/elements/1.1/}date").text if item.find("{http://purl.org/dc/elements/1.1/}date").text else None
  216. # Pinboard includes a colon in its date stamp timezone offsets, which
  217. # Python can't parse. Remove it:
  218. if ts_str and ts_str[-3:-2] == ":":
  219. ts_str = ts_str[:-3]+ts_str[-2:]
  220. if ts_str:
  221. time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
  222. else:
  223. time = datetime.now()
  224. yield {
  225. 'url': url,
  226. 'timestamp': str(time.timestamp()),
  227. 'title': title or None,
  228. 'tags': tags or '',
  229. 'sources': [rss_file.name],
  230. }
  231. def parse_medium_rss_export(rss_file):
  232. """Parse Medium RSS feed files into links"""
  233. rss_file.seek(0)
  234. root = etree.parse(rss_file).getroot()
  235. items = root.find("channel").findall("item")
  236. for item in items:
  237. url = item.find("link").text
  238. title = item.find("title").text.strip()
  239. ts_str = item.find("pubDate").text
  240. time = datetime.strptime(ts_str, "%a, %d %b %Y %H:%M:%S %Z")
  241. yield {
  242. 'url': url,
  243. 'timestamp': str(time.timestamp()),
  244. 'title': title or None,
  245. 'tags': '',
  246. 'sources': [rss_file.name],
  247. }
  248. def parse_plain_text_export(text_file):
  249. """Parse raw links from each line in a text file"""
  250. text_file.seek(0)
  251. for line in text_file.readlines():
  252. urls = re.findall(URL_REGEX, line) if line.strip() else ()
  253. for url in urls:
  254. yield {
  255. 'url': url,
  256. 'timestamp': str(datetime.now().timestamp()),
  257. 'title': None,
  258. 'tags': '',
  259. 'sources': [text_file.name],
  260. }