2
0

__init__.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. __package__ = 'archivebox.extractors'
  2. from typing import Callable, Optional, Dict, List, Iterable, Union, Protocol, cast
  3. import os
  4. import sys
  5. from pathlib import Path
  6. from importlib import import_module
  7. from datetime import datetime, timezone
  8. from django.db.models import QuerySet
  9. from ..config import (
  10. SAVE_ALLOWLIST_PTN,
  11. SAVE_DENYLIST_PTN,
  12. )
  13. from ..core.settings import ERROR_LOG
  14. from ..index.schema import ArchiveResult, Link
  15. from ..index.sql import write_link_to_sql_index
  16. from ..index import (
  17. load_link_details,
  18. write_link_details,
  19. )
  20. from ..util import enforce_types
  21. from ..logging_util import (
  22. log_archiving_started,
  23. log_archiving_paused,
  24. log_archiving_finished,
  25. log_link_archiving_started,
  26. log_link_archiving_finished,
  27. log_archive_method_started,
  28. log_archive_method_finished,
  29. )
  30. from ..search import write_search_index
  31. from .title import should_save_title, save_title
  32. from .favicon import should_save_favicon, save_favicon
  33. from .wget import should_save_wget, save_wget
  34. from .singlefile import should_save_singlefile, save_singlefile
  35. from .readability import should_save_readability, save_readability
  36. from .mercury import should_save_mercury, save_mercury
  37. from .htmltotext import should_save_htmltotext, save_htmltotext
  38. from .pdf import should_save_pdf, save_pdf
  39. from .screenshot import should_save_screenshot, save_screenshot
  40. from .dom import should_save_dom, save_dom
  41. from .git import should_save_git, save_git
  42. from .media import should_save_media, save_media
  43. from .archive_org import should_save_archive_dot_org, save_archive_dot_org
  44. from .headers import should_save_headers, save_headers
  45. ShouldSaveFunction = Callable[[Link, Optional[Path], Optional[bool]], bool]
  46. SaveFunction = Callable[[Link, Optional[Path], int], ArchiveResult]
  47. ArchiveMethodEntry = tuple[str, ShouldSaveFunction, SaveFunction]
  48. def get_default_archive_methods() -> List[ArchiveMethodEntry]:
  49. return [
  50. ('favicon', should_save_favicon, save_favicon),
  51. ('headers', should_save_headers, save_headers),
  52. ('singlefile', should_save_singlefile, save_singlefile),
  53. ('pdf', should_save_pdf, save_pdf),
  54. ('screenshot', should_save_screenshot, save_screenshot),
  55. ('dom', should_save_dom, save_dom),
  56. ('wget', should_save_wget, save_wget),
  57. # keep title, readability, and htmltotext below wget and singlefile, as they depend on them
  58. ('title', should_save_title, save_title),
  59. ('readability', should_save_readability, save_readability),
  60. ('mercury', should_save_mercury, save_mercury),
  61. ('htmltotext', should_save_htmltotext, save_htmltotext),
  62. ('git', should_save_git, save_git),
  63. ('media', should_save_media, save_media),
  64. ('archive_org', should_save_archive_dot_org, save_archive_dot_org),
  65. ]
  66. ARCHIVE_METHODS_INDEXING_PRECEDENCE = [
  67. ('readability', 1),
  68. ('mercury', 2),
  69. ('htmltotext', 3),
  70. ('singlefile', 4),
  71. ('dom', 5),
  72. ('wget', 6)
  73. ]
  74. @enforce_types
  75. def get_archive_methods_for_link(link: Link) -> Iterable[ArchiveMethodEntry]:
  76. DEFAULT_METHODS = get_default_archive_methods()
  77. allowed_methods = {
  78. m for pat, methods in
  79. SAVE_ALLOWLIST_PTN.items()
  80. if pat.search(link.url)
  81. for m in methods
  82. } or { m[0] for m in DEFAULT_METHODS }
  83. denied_methods = {
  84. m for pat, methods in
  85. SAVE_DENYLIST_PTN.items()
  86. if pat.search(link.url)
  87. for m in methods
  88. }
  89. allowed_methods -= denied_methods
  90. return (m for m in DEFAULT_METHODS if m[0] in allowed_methods)
  91. @enforce_types
  92. def ignore_methods(to_ignore: List[str]) -> Iterable[str]:
  93. ARCHIVE_METHODS = get_default_archive_methods()
  94. return [x[0] for x in ARCHIVE_METHODS if x[0] not in to_ignore]
  95. @enforce_types
  96. def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[str]]=None, out_dir: Optional[Path]=None, created_by_id: int | None=None) -> Link:
  97. """download the DOM, PDF, and a screenshot into a folder named after the link's timestamp"""
  98. # TODO: Remove when the input is changed to be a snapshot. Suboptimal approach.
  99. from core.models import Snapshot, ArchiveResult
  100. try:
  101. snapshot = Snapshot.objects.get(url=link.url) # TODO: This will be unnecessary once everything is a snapshot
  102. except Snapshot.DoesNotExist:
  103. snapshot = write_link_to_sql_index(link, created_by_id=created_by_id)
  104. active_methods = get_archive_methods_for_link(link)
  105. if methods:
  106. active_methods = [
  107. method for method in active_methods
  108. if method[0] in methods
  109. ]
  110. out_dir = out_dir or Path(link.link_dir)
  111. try:
  112. is_new = not Path(out_dir).exists()
  113. if is_new:
  114. os.makedirs(out_dir)
  115. link = load_link_details(link, out_dir=out_dir)
  116. write_link_details(link, out_dir=out_dir, skip_sql_index=False)
  117. log_link_archiving_started(link, str(out_dir), is_new)
  118. link = link.overwrite(updated=datetime.now(timezone.utc))
  119. stats = {'skipped': 0, 'succeeded': 0, 'failed': 0}
  120. start_ts = datetime.now(timezone.utc)
  121. for method_name, should_run, method_function in active_methods:
  122. try:
  123. if method_name not in link.history:
  124. link.history[method_name] = []
  125. if should_run(link, out_dir, overwrite):
  126. log_archive_method_started(method_name)
  127. result = method_function(link=link, out_dir=out_dir)
  128. link.history[method_name].append(result)
  129. stats[result.status] += 1
  130. log_archive_method_finished(result)
  131. write_search_index(link=link, texts=result.index_texts)
  132. ArchiveResult.objects.create(snapshot=snapshot, extractor=method_name, cmd=result.cmd, cmd_version=result.cmd_version,
  133. output=result.output, pwd=result.pwd, start_ts=result.start_ts, end_ts=result.end_ts, status=result.status, created_by_id=snapshot.created_by_id)
  134. # bump the updated time on the main Snapshot here, this is critical
  135. # to be able to cache summaries of the ArchiveResults for a given
  136. # snapshot without having to load all the results from the DB each time.
  137. # (we use {Snapshot.pk}-{Snapshot.updated} as the cache key and assume
  138. # ArchiveResults are unchanged as long as the updated timestamp is unchanged)
  139. snapshot.save()
  140. else:
  141. # print('{black} X {}{reset}'.format(method_name, **ANSI))
  142. stats['skipped'] += 1
  143. except Exception as e:
  144. # https://github.com/ArchiveBox/ArchiveBox/issues/984#issuecomment-1150541627
  145. with open(ERROR_LOG, "a", encoding='utf-8') as f:
  146. command = ' '.join(sys.argv)
  147. ts = datetime.now(timezone.utc).strftime('%Y-%m-%d__%H:%M:%S')
  148. f.write(("\n" + 'Exception in archive_methods.save_{}(Link(url={})) command={}; ts={}'.format(
  149. method_name,
  150. link.url,
  151. command,
  152. ts
  153. ) + "\n" + str(e) + "\n"))
  154. #f.write(f"\n> {command}; ts={ts} version={config['VERSION']} docker={config['IN_DOCKER']} is_tty={config['IS_TTY']}\n")
  155. # print(f' ERROR: {method_name} {e.__class__.__name__}: {e} {getattr(e, "hints", "")}', ts, link.url, command)
  156. raise Exception('Exception in archive_methods.save_{}(Link(url={}))'.format(
  157. method_name,
  158. link.url,
  159. )) from e
  160. # print(' ', stats)
  161. try:
  162. latest_title = link.history['title'][-1].output.strip()
  163. if latest_title and len(latest_title) >= len(link.title or ''):
  164. link = link.overwrite(title=latest_title)
  165. except Exception:
  166. pass
  167. write_link_details(link, out_dir=out_dir, skip_sql_index=False)
  168. log_link_archiving_finished(link, out_dir, is_new, stats, start_ts)
  169. except KeyboardInterrupt:
  170. try:
  171. write_link_details(link, out_dir=link.link_dir)
  172. except:
  173. pass
  174. raise
  175. except Exception as err:
  176. print(' ! Failed to archive link: {}: {}'.format(err.__class__.__name__, err))
  177. raise
  178. return link
  179. @enforce_types
  180. def archive_links(all_links: Union[Iterable[Link], QuerySet], overwrite: bool=False, methods: Optional[Iterable[str]]=None, out_dir: Optional[Path]=None, created_by_id: int | None=None) -> List[Link]:
  181. if type(all_links) is QuerySet:
  182. num_links: int = all_links.count()
  183. get_link = lambda x: x.as_link_with_details()
  184. all_links = all_links.iterator()
  185. else:
  186. num_links: int = len(all_links)
  187. get_link = lambda x: x
  188. if num_links == 0:
  189. return []
  190. log_archiving_started(num_links)
  191. idx: int = 0
  192. try:
  193. for link in all_links:
  194. idx += 1
  195. to_archive = get_link(link)
  196. archive_link(to_archive, overwrite=overwrite, methods=methods, out_dir=Path(link.link_dir), created_by_id=created_by_id)
  197. except KeyboardInterrupt:
  198. log_archiving_paused(num_links, idx, link.timestamp)
  199. raise SystemExit(0)
  200. except BaseException:
  201. print()
  202. raise
  203. log_archiving_finished(num_links)
  204. return all_links
  205. EXTRACTORS_DIR = Path(__file__).parent
  206. class ExtractorModuleProtocol(Protocol):
  207. """Type interface for an Extractor Module (WIP)"""
  208. get_output_path: Callable
  209. # TODO:
  210. # get_embed_path: Callable | None
  211. # should_extract(Snapshot)
  212. # extract(Snapshot)
  213. def get_extractors(dir: Path=EXTRACTORS_DIR) -> Dict[str, ExtractorModuleProtocol]:
  214. """iterate through archivebox/extractors/*.py and load extractor modules"""
  215. EXTRACTORS = {}
  216. for filename in EXTRACTORS_DIR.glob('*.py'):
  217. if filename.name.startswith('__'):
  218. continue
  219. extractor_name = filename.name.replace('.py', '')
  220. extractor_module = cast(ExtractorModuleProtocol, import_module(f'.{extractor_name}', package=__package__))
  221. assert getattr(extractor_module, 'get_output_path')
  222. EXTRACTORS[extractor_name] = extractor_module
  223. return EXTRACTORS
  224. EXTRACTORS = get_extractors(EXTRACTORS_DIR)