qa-crawl.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. #!/usr/bin/env python3
  2. """QA crawl: serve the site locally and fetch every subresource of every page.
  3. Asserts that every request made while crawling goes to 127.0.0.1 — no CDN,
  4. no Google Fonts, no picsum leftovers. Pages are discovered by following
  5. local links from / so the crawl covers whatever is linked. CSS url(...)
  6. references (e.g. @font-face src) are fetched too.
  7. data:/mailto:/tel: URIs are self-contained by definition — never fetched,
  8. never counted as external. Fragment-only links are same-page anchors.
  9. Usage: python3 tools/qa-crawl.py [repo-root]
  10. """
  11. import http.server
  12. import os
  13. import pathlib
  14. import re
  15. import socketserver
  16. import sys
  17. import threading
  18. import urllib.parse
  19. import urllib.request
  20. from html.parser import HTMLParser
  21. ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
  22. PORT = int(os.environ.get("QA_CRAWL_PORT", "0")) # 0 = ephemeral
  23. RESOURCE_ATTRS = {"link": ["href"], "script": ["src"], "img": ["src", "srcset"],
  24. "source": ["src", "srcset"], "video": ["src", "poster"],
  25. "audio": ["src"], "track": ["src"], "embed": ["src"],
  26. "object": ["data"], "use": ["href", "xlink:href"]}
  27. NAV_ATTRS = {"a": ["href"], "area": ["href"], "form": ["action"]}
  28. SELF_CONTAINED = ("data:", "mailto:", "tel:", "javascript:")
  29. class Collector(HTMLParser):
  30. def __init__(self):
  31. super().__init__(convert_charrefs=True)
  32. self.resources, self.links = [], []
  33. def handle_starttag(self, tag, attrs):
  34. d = dict(attrs)
  35. if tag in RESOURCE_ATTRS:
  36. for a in RESOURCE_ATTRS[tag]:
  37. v = d.get(a)
  38. if v:
  39. if a == "srcset":
  40. for part in v.split(","):
  41. self.resources.append(part.strip().split()[0])
  42. else:
  43. self.resources.append(v)
  44. if tag in NAV_ATTRS:
  45. v = d.get(NAV_ATTRS[tag][0])
  46. if v:
  47. self.links.append(v)
  48. class QuietHandler(http.server.SimpleHTTPRequestHandler):
  49. def log_message(self, fmt, *args):
  50. pass # the crawler records requests itself
  51. def fetch(url):
  52. req = urllib.request.Request(url, headers={"User-Agent": "qa-crawl/1.0"})
  53. with urllib.request.urlopen(req, timeout=10) as r:
  54. return r.status, r.read(), r.geturl()
  55. def norm(base, ref):
  56. """Resolve ref against base; return (abs_url, fetchable_local)."""
  57. if ref.startswith(SELF_CONTAINED):
  58. return ref, True # self-contained, nothing to fetch
  59. p = urllib.parse.urlparse(ref)
  60. if p.scheme or p.netloc: # absolute — the thing we are hunting
  61. return ref, False
  62. return urllib.parse.urljoin(base, ref), True
  63. def defrag(u):
  64. return u.split("#", 1)[0]
  65. seen_pages, seen_res, external, failures = set(), set(), [], []
  66. def fetch_resource(absu, referer):
  67. if absu in seen_res:
  68. return
  69. seen_res.add(absu)
  70. if absu.startswith(SELF_CONTAINED):
  71. return # self-contained, nothing to fetch
  72. try:
  73. st, body, fin = fetch(absu)
  74. if st != 200:
  75. failures.append(f"asset {absu} (on {referer}): HTTP {st}")
  76. return
  77. if absu.endswith(".css"):
  78. # fetch url(...) references inside the stylesheet (fonts etc.);
  79. # strip data: URIs first (respecting the quote style actually
  80. # used) so nested url(#frag) refs inside them are not mistaken
  81. # for stylesheet references
  82. css = body.decode("utf-8", "replace")
  83. css = re.sub(r'url\(\s*"(?:[^"]*?data:[^"]*?)"\s*\)', "url()", css)
  84. css = re.sub(r"url\(\s*'(?:[^']*?data:[^']*?)'\s*\)", "url()", css)
  85. css = re.sub(r"url\(\s*data:[^'\")]*\s*\)", "url()", css)
  86. for ref in re.findall(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", css):
  87. if ref.startswith(SELF_CONTAINED) or ref.startswith(("#", "%23")):
  88. continue
  89. a, local = norm(absu, ref)
  90. if not local:
  91. external.append(f"css url() {ref} (in {absu})")
  92. continue
  93. fetch_resource(a, absu)
  94. except Exception as e:
  95. failures.append(f"asset {absu} (on {referer}): {e}")
  96. def crawl(start):
  97. queue = [start]
  98. while queue:
  99. url = defrag(queue.pop(0))
  100. if not url or url in seen_pages:
  101. continue
  102. try:
  103. status, body, final = fetch(url)
  104. except Exception as e:
  105. failures.append(f"page {url}: {e}")
  106. continue
  107. if status != 200:
  108. failures.append(f"page {url}: HTTP {status}")
  109. continue
  110. seen_pages.add(final)
  111. final = defrag(final)
  112. if not final.endswith((".html", ".htm", "/")):
  113. continue # non-HTML subresource, nothing to parse
  114. text = body.decode("utf-8", "replace")
  115. c = Collector()
  116. c.feed(text)
  117. for ref in c.resources:
  118. absu, local = norm(final, ref)
  119. if not local:
  120. external.append(f"resource {ref} (on {final})")
  121. continue
  122. fetch_resource(absu, final)
  123. for ref in c.links:
  124. absu, local = norm(final, ref)
  125. if not local:
  126. external.append(f"link {ref} (on {final})")
  127. continue
  128. queue.append(absu)
  129. print(f"pages crawled : {len(seen_pages)}")
  130. for u in sorted(seen_pages):
  131. print(f" {u}")
  132. print(f"assets fetched: {len(seen_res)}")
  133. for u in sorted(seen_res):
  134. print(f" {u}")
  135. if external:
  136. print("\nNON-LOCAL REFERENCES:")
  137. for e in external:
  138. print(f" {e}")
  139. if failures:
  140. print("\nFAILURES:")
  141. for f in failures:
  142. print(f" {f}")
  143. if external or failures:
  144. sys.exit(1)
  145. print("\nOK: every request hit 127.0.0.1 only; all assets resolved 200")
  146. def main():
  147. handler = lambda *a, **kw: QuietHandler(*a, directory=str(ROOT), **kw)
  148. socketserver.TCPServer.allow_reuse_address = True
  149. with socketserver.TCPServer(("127.0.0.1", PORT), handler) as httpd:
  150. port = httpd.server_address[1] # resolves the ephemeral port
  151. threading.Thread(target=httpd.serve_forever, daemon=True).start()
  152. try:
  153. crawl(f"http://127.0.0.1:{port}/index.html")
  154. finally:
  155. httpd.shutdown()
  156. if __name__ == "__main__":
  157. main()