parse.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. # coding: utf-8
  2. """
  3. Everything related to parsing links from bookmark services.
  4. For a list of supported services, see the README.md.
  5. For examples of supported files see examples/.
  6. Parsed link schema: {
  7. 'url': 'https://example.com/example/?abc=123&xyc=345#lmnop',
  8. 'domain': 'example.com',
  9. 'base_url': 'example.com/example/',
  10. 'timestamp': '15442123124234',
  11. 'tags': 'abc,def',
  12. 'title': 'Example.com Page Title',
  13. 'sources': ['ril_export.html', 'downloads/getpocket.com.txt'],
  14. }
  15. """
  16. import re
  17. import sys
  18. import json
  19. from collections import OrderedDict
  20. import xml.etree.ElementTree as etree
  21. from datetime import datetime
  22. from config import ANSI, SHOW_PROGRESS
  23. from util import (
  24. domain,
  25. base_url,
  26. str_between,
  27. get_link_type,
  28. URL_REGEX,
  29. )
  30. def get_parsers(file):
  31. """return all parsers that work on a given file, defaults to all of them"""
  32. return OrderedDict([
  33. ('Pocket HTML', parse_pocket_html_export),
  34. ('Pinboard JSON', parse_pinboard_json_export),
  35. ('Netscape HTML', parse_netscape_html_export),
  36. ('RSS', parse_rss_export),
  37. ('Pinboard RSS', parse_pinboard_rss_export),
  38. ('Shaarli RSS', parse_shaarli_rss_export),
  39. ('Medium RSS', parse_medium_rss_export),
  40. ('Plain Text', parse_plain_text_export),
  41. ])
  42. def parse_links(path):
  43. """parse a list of links dictionaries from a bookmark export file"""
  44. links = []
  45. with open(path, 'r', encoding='utf-8') as file:
  46. print('{green}[*] [{}] Parsing new links from output/sources/{}...{reset}'.format(
  47. datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  48. path.rsplit('/', 1)[-1],
  49. **ANSI,
  50. ))
  51. for parser_name, parser_func in get_parsers(file).items():
  52. # otherwise try all parsers until one works
  53. try:
  54. links += list(parser_func(file))
  55. if links:
  56. break
  57. except Exception as err:
  58. # we try each parser one by one, each parser will throw exeption an exception if unsupported
  59. # so we accept the first one that
  60. # uncomment the following line to see why the parser was unsupported for each attempted format
  61. # print('[!] Parser {} failed: {} {}'.format(parser_name, err.__class__.__name__, err))
  62. pass
  63. return links, parser_name
  64. def parse_pocket_html_export(html_file):
  65. """Parse Pocket-format bookmarks export files (produced by getpocket.com/export/)"""
  66. html_file.seek(0)
  67. pattern = re.compile("^\\s*<li><a href=\"(.+)\" time_added=\"(\\d+)\" tags=\"(.*)\">(.+)</a></li>", re.UNICODE)
  68. for line in html_file:
  69. # example line
  70. # <li><a href="http://example.com/ time_added="1478739709" tags="tag1,tag2">example title</a></li>
  71. match = pattern.search(line)
  72. if match:
  73. fixed_url = match.group(1).replace('http://www.readability.com/read?url=', '') # remove old readability prefixes to get original url
  74. time = datetime.fromtimestamp(float(match.group(2)))
  75. info = {
  76. 'url': fixed_url,
  77. 'domain': domain(fixed_url),
  78. 'base_url': base_url(fixed_url),
  79. 'timestamp': str(time.timestamp()),
  80. 'tags': match.group(3),
  81. 'title': match.group(4).replace(' — Readability', '').replace('http://www.readability.com/read?url=', '') or None,
  82. 'sources': [html_file.name],
  83. }
  84. info['type'] = get_link_type(info)
  85. yield info
  86. def parse_pinboard_json_export(json_file):
  87. """Parse JSON-format bookmarks export files (produced by pinboard.in/export/, or wallabag)"""
  88. json_file.seek(0)
  89. json_content = json.load(json_file)
  90. for line in json_content:
  91. # example line
  92. # {"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"}]
  93. if line:
  94. erg = line
  95. if erg.get('timestamp'):
  96. timestamp = str(erg['timestamp']/10000000) # chrome/ff histories use a very precise timestamp
  97. elif erg.get('time'):
  98. timestamp = str(datetime.strptime(erg['time'].split(',', 1)[0], '%Y-%m-%dT%H:%M:%SZ').timestamp())
  99. elif erg.get('created_at'):
  100. timestamp = str(datetime.strptime(erg['created_at'], '%Y-%m-%dT%H:%M:%S%z').timestamp())
  101. else:
  102. timestamp = str(datetime.now().timestamp())
  103. if erg.get('href'):
  104. url = erg['href']
  105. else:
  106. url = erg['url']
  107. if erg.get('description'):
  108. title = (erg.get('description') or '').replace(' — Readability', '')
  109. else:
  110. title = erg['title'].strip()
  111. info = {
  112. 'url': url,
  113. 'domain': domain(url),
  114. 'base_url': base_url(url),
  115. 'timestamp': timestamp,
  116. 'tags': erg.get('tags') or '',
  117. 'title': title or None,
  118. 'sources': [json_file.name],
  119. }
  120. info['type'] = get_link_type(info)
  121. yield info
  122. def parse_rss_export(rss_file):
  123. """Parse RSS XML-format files into links"""
  124. rss_file.seek(0)
  125. items = rss_file.read().split('</item>\n<item>')
  126. for item in items:
  127. # example item:
  128. # <item>
  129. # <title><![CDATA[How JavaScript works: inside the V8 engine]]></title>
  130. # <category>Unread</category>
  131. # <link>https://blog.sessionstack.com/how-javascript-works-inside</link>
  132. # <guid>https://blog.sessionstack.com/how-javascript-works-inside</guid>
  133. # <pubDate>Mon, 21 Aug 2017 14:21:58 -0500</pubDate>
  134. # </item>
  135. trailing_removed = item.split('</item>', 1)[0]
  136. leading_removed = trailing_removed.split('<item>', 1)[-1]
  137. rows = leading_removed.split('\n')
  138. def get_row(key):
  139. return [r for r in rows if r.strip().startswith('<{}>'.format(key))][0]
  140. title = str_between(get_row('title'), '<![CDATA[', ']]').strip()
  141. url = str_between(get_row('link'), '<link>', '</link>')
  142. ts_str = str_between(get_row('pubDate'), '<pubDate>', '</pubDate>')
  143. time = datetime.strptime(ts_str, "%a, %d %b %Y %H:%M:%S %z")
  144. info = {
  145. 'url': url,
  146. 'domain': domain(url),
  147. 'base_url': base_url(url),
  148. 'timestamp': str(time.timestamp()),
  149. 'tags': '',
  150. 'title': title or None,
  151. 'sources': [rss_file.name],
  152. }
  153. info['type'] = get_link_type(info)
  154. yield info
  155. def parse_shaarli_rss_export(rss_file):
  156. """Parse Shaarli-specific RSS XML-format files into links"""
  157. rss_file.seek(0)
  158. entries = rss_file.read().split('<entry>')[1:]
  159. for entry in entries:
  160. # example entry:
  161. # <entry>
  162. # <title>Aktuelle Trojaner-Welle: Emotet lauert in gefälschten Rechnungsmails | heise online</title>
  163. # <link href="https://www.heise.de/security/meldung/Aktuelle-Trojaner-Welle-Emotet-lauert-in-gefaelschten-Rechnungsmails-4291268.html" />
  164. # <id>https://demo.shaarli.org/?cEV4vw</id>
  165. # <published>2019-01-30T06:06:01+00:00</published>
  166. # <updated>2019-01-30T06:06:01+00:00</updated>
  167. # <content type="html" xml:lang="en"><![CDATA[<div class="markdown"><p>&#8212; <a href="https://demo.shaarli.org/?cEV4vw">Permalink</a></p></div>]]></content>
  168. # </entry>
  169. trailing_removed = entry.split('</entry>', 1)[0]
  170. leading_removed = trailing_removed.strip()
  171. rows = leading_removed.split('\n')
  172. def get_row(key):
  173. return [r.strip() for r in rows if r.strip().startswith('<{}'.format(key))][0]
  174. title = str_between(get_row('title'), '<title>', '</title>').strip()
  175. url = str_between(get_row('link'), '<link href="', '" />')
  176. ts_str = str_between(get_row('published'), '<published>', '</published>')
  177. time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
  178. info = {
  179. 'url': url,
  180. 'domain': domain(url),
  181. 'base_url': base_url(url),
  182. 'timestamp': str(time.timestamp()),
  183. 'tags': '',
  184. 'title': title or None,
  185. 'sources': [rss_file.name],
  186. }
  187. info['type'] = get_link_type(info)
  188. yield info
  189. def parse_netscape_html_export(html_file):
  190. """Parse netscape-format bookmarks export files (produced by all browsers)"""
  191. html_file.seek(0)
  192. pattern = re.compile("<a href=\"(.+?)\" add_date=\"(\\d+)\"[^>]*>(.+)</a>", re.UNICODE | re.IGNORECASE)
  193. for line in html_file:
  194. # example line
  195. # <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>
  196. match = pattern.search(line)
  197. if match:
  198. url = match.group(1)
  199. time = datetime.fromtimestamp(float(match.group(2)))
  200. info = {
  201. 'url': url,
  202. 'domain': domain(url),
  203. 'base_url': base_url(url),
  204. 'timestamp': str(time.timestamp()),
  205. 'tags': "",
  206. 'title': match.group(3).strip() or None,
  207. 'sources': [html_file.name],
  208. }
  209. info['type'] = get_link_type(info)
  210. yield info
  211. def parse_pinboard_rss_export(rss_file):
  212. """Parse Pinboard RSS feed files into links"""
  213. rss_file.seek(0)
  214. root = etree.parse(rss_file).getroot()
  215. items = root.findall("{http://purl.org/rss/1.0/}item")
  216. for item in items:
  217. url = item.find("{http://purl.org/rss/1.0/}link").text
  218. tags = item.find("{http://purl.org/dc/elements/1.1/}subject").text
  219. title = item.find("{http://purl.org/rss/1.0/}title").text.strip()
  220. ts_str = item.find("{http://purl.org/dc/elements/1.1/}date").text
  221. # = 🌈🌈🌈🌈
  222. # = 🌈🌈🌈🌈
  223. # = 🏆🏆🏆🏆
  224. # Pinboard includes a colon in its date stamp timezone offsets, which
  225. # Python can't parse. Remove it:
  226. if ":" == ts_str[-3:-2]:
  227. ts_str = ts_str[:-3]+ts_str[-2:]
  228. time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
  229. info = {
  230. 'url': url,
  231. 'domain': domain(url),
  232. 'base_url': base_url(url),
  233. 'timestamp': str(time.timestamp()),
  234. 'tags': tags,
  235. 'title': title or None,
  236. 'sources': [rss_file.name],
  237. }
  238. info['type'] = get_link_type(info)
  239. yield info
  240. def parse_medium_rss_export(rss_file):
  241. """Parse Medium RSS feed files into links"""
  242. rss_file.seek(0)
  243. root = etree.parse(rss_file).getroot()
  244. items = root.find("channel").findall("item")
  245. for item in items:
  246. # for child in item:
  247. # print(child.tag, child.text)
  248. url = item.find("link").text
  249. title = item.find("title").text.strip()
  250. ts_str = item.find("pubDate").text
  251. time = datetime.strptime(ts_str, "%a, %d %b %Y %H:%M:%S %Z")
  252. info = {
  253. 'url': url,
  254. 'domain': domain(url),
  255. 'base_url': base_url(url),
  256. 'timestamp': str(time.timestamp()),
  257. 'tags': '',
  258. 'title': title or None,
  259. 'sources': [rss_file.name],
  260. }
  261. info['type'] = get_link_type(info)
  262. yield info
  263. def parse_plain_text_export(text_file):
  264. """Parse raw links from each line in a text file"""
  265. text_file.seek(0)
  266. text_content = text_file.readlines()
  267. for line in text_content:
  268. if line:
  269. urls = re.findall(URL_REGEX, line)
  270. for url in urls:
  271. url = url.strip()
  272. info = {
  273. 'url': url,
  274. 'domain': domain(url),
  275. 'base_url': base_url(url),
  276. 'timestamp': str(datetime.now().timestamp()),
  277. 'tags': '',
  278. 'title': None,
  279. 'sources': [text_file.name],
  280. }
  281. info['type'] = get_link_type(info)
  282. yield info