Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions components/ecoindex/best_practices/default_rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,134 @@ rules:
higher_is_worse: false
thresholds:
fail: 1

- id: stylesheets_number
enabled: true
checker: stylesheets_number
category: network
rweb_id: RWEB_0035
url: https://rweb.greenit.fr/en/fiches/RWEB_0035-limit-the-number-of-css
title: "Limit the number of CSS"
description: >
Limit stylesheet files to avoid multiplying HTTP requests and simplify
browser rendering. Prefer a shared CSS plus a few media-specific sheets.
thresholds:
# GreenIT-Analysis: >7 warn, >10 fail
warn: 7
fail: 10

- id: standard_typefaces
enabled: true
checker: standard_typefaces
category: network
rweb_id: RWEB_0032
url: https://rweb.greenit.fr/en/fiches/RWEB_0032-prefer-standard-fonts
title: "Prefer standard fonts"
description: >
Prefer system fonts already available on the user device. Downloaded
web fonts consume bandwidth and delay text rendering.
thresholds:
warn: 1
fail: 2

- id: social_network_button
enabled: true
checker: social_network_button
category: user-device
rweb_id: RWEB_0059
url: https://rweb.greenit.fr/en/fiches/RWEB_0059-replace-the-official-social-network-sharing-buttons
title: "Replace official social network sharing buttons"
description: >
Official social widgets load heavy third-party scripts. Prefer lightweight
share links that do not pull external SDKs.
thresholds:
fail: 0

- id: animated_gifs
enabled: true
checker: animated_gifs
category: user-device
rweb_id: RWEB_0099
url: https://rweb.greenit.fr/en/fiches/RWEB_0099-limit-the-use-of-animated-gif
title: "Limit the use of animated GIFs"
description: >
Animated GIFs are heavier and less efficient than modern video formats
such as WebM or MP4 for animations.
thresholds:
fail: 0

- id: http_errors
enabled: true
checker: http_errors
category: network
title: "Avoid HTTP error responses"
description: >
Resources returning HTTP status codes greater than or equal to 400 waste
bandwidth and often indicate broken dependencies.
thresholds:
fail: 0

- id: http_redirects
enabled: true
checker: http_redirects
category: network
rweb_id: RWEB_0112
url: https://rweb.greenit.fr/en/fiches/RWEB_0112-avoir-redirections
title: "Avoid redirects"
description: >
Redirects degrade response time and consume resources. Prefer direct URLs
whenever possible.
thresholds:
fail: 1

- id: http2
enabled: true
checker: http2
category: network
rweb_id: RWEB_0083
url: https://rweb.greenit.fr/en/fiches/RWEB_0083-http2-over-http1
title: "Prefer HTTP/2 to HTTP/1"
description: >
HTTP/2 multiplexes resources over a single connection and transfers fewer
bytes than HTTP/1, improving load time and efficiency.
thresholds:
fail: 0

- id: cache_headers
enabled: true
checker: cache_headers
category: network
rweb_id: RWEB_0075
url: https://rweb.greenit.fr/en/fiches/RWEB_0075-add-expires-or-cache-control-headers
title: "Add Expires or Cache-Control headers"
description: >
Static resources should declare cache lifetime via Cache-Control or
Expires so browsers avoid re-downloading unchanged assets.
thresholds:
fail: 0

- id: http_compression
enabled: true
checker: http_compression
category: network
rweb_id: RWEB_0076
url: https://rweb.greenit.fr/en/fiches/RWEB_0076-compressing-text-files-css-js-html-and-svg
title: "Compress CSS, JavaScript, HTML and SVG files"
description: >
Text resources should be served with HTTP compression (gzip, brotli, …)
to reduce transferred bytes.
thresholds:
fail: 0

- id: no_cookie_static
enabled: true
checker: no_cookie_static
category: datacenter
rweb_id: RWEB_0081
url: https://rweb.greenit.fr/en/fiches/RWEB_0081-host-static-resources-on-a-cookie-free-domain
title: "Host static resources on a cookie-free domain"
description: >
Cookies attached to static asset requests inflate every transfer. Serve
CSS, JS, images and fonts from a cookie-less domain.
thresholds:
fail: 1
159 changes: 159 additions & 0 deletions components/ecoindex/best_practices/har_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Helpers inspired by GreenIT-Analysis HAR resource classification."""

from __future__ import annotations

import re
from datetime import datetime
from urllib.parse import urlparse

from ecoindex.models.scraper import RequestItem

HTTP_REDIRECT_CODES = frozenset({301, 302, 303, 307})
HTTP_COMPRESSION_TOKENS = frozenset(
{"br", "compress", "deflate", "gzip", "pack200-gzip"}
)

_STATIC_CATEGORIES = frozenset(
{"image", "javascript", "font", "css", "audio", "video"}
)
_COMPRESSIBLE_CATEGORIES = frozenset({"css", "javascript", "html", "font"})

_FONT_MIME_RE = re.compile(
r"(font/|application/(?:x-)?font-|application/vnd\.ms-fontobject|"
r"application/font-sfnt)",
re.I,
)
_COMPRESSIBLE_MIME_RE = re.compile(
r"^(text/|application/(?:javascript|x-javascript|json|xml|xhtml\+xml|"
r"ld\+json|rss\+xml|atom\+xml|manifest\+json)|image/svg\+xml|"
r"font/(?:eot|opentype))",
re.I,
)
_STATIC_MIME_RE = re.compile(
r"^(image/|audio/|video/|text/css|text/javascript|application/javascript|"
r"application/x-javascript|font/|application/(?:x-)?font-|"
r"application/vnd\.ms-fontobject|application/pdf|application/octet-stream|"
r"application/zip|application/x-shockwave-flash|application/manifest\+json)",
re.I,
)

_SOCIAL_PATTERNS: list[tuple[str, str]] = [
("platform.twitter.com/widgets.js", "twitter"),
("platform.linkedin.com/in.js", "linkedin"),
("assets.pinterest.com/js/pinit.js", "pinterest"),
("platform-api.sharethis.com/js/sharethis.js", "sharethis"),
("s7.addthis.com/js/300/addthis_widget.js", "addthis"),
("static.addtoany.com/menu/page.js", "addtoany"),
]


def header_map(headers: list[dict] | None) -> dict[str, str]:
result: dict[str, str] = {}
if not headers:
return result
for header in headers:
name = header.get("name")
value = header.get("value")
if name is None or value is None:
continue
result[str(name).lower()] = str(value)
return result


def is_font_resource(item: RequestItem) -> bool:
if item.category == "font" or _FONT_MIME_RE.search(item.mime_type or ""):
return item.size > 0
mime = (item.mime_type or "").split(";")[0].strip().lower()
if mime in {"", "text/plain", "application/octet-stream"}:
path = urlparse(item.url).path.lower()
if path.endswith((".woff", ".woff2", ".ttf", ".otf", ".eot")):
return item.size > 0
if ".woff?" in item.url.lower() or ".woff2?" in item.url.lower():
return item.size > 0
return False


def is_css_resource(item: RequestItem) -> bool:
return item.category == "css" or "css" in (item.mime_type or "").lower()


def is_gif_resource(item: RequestItem) -> bool:
mime = (item.mime_type or "").lower()
if "gif" in mime:
return True
path = urlparse(item.url).path.lower()
return path.endswith(".gif")


def is_static_resource(item: RequestItem) -> bool:
if item.category in _STATIC_CATEGORIES:
return True
return bool(_STATIC_MIME_RE.search((item.mime_type or "").split(";")[0].strip()))


def is_compressible_resource(item: RequestItem) -> bool:
if item.size <= 150:
return False
if item.category in _COMPRESSIBLE_CATEGORIES:
return True
return bool(
_COMPRESSIBLE_MIME_RE.search((item.mime_type or "").split(";")[0].strip())
)


def is_resource_compressed(item: RequestItem) -> bool:
encoding = (item.content_encoding or "").lower().strip()
if not encoding:
return False
# Content-Encoding can be a comma-separated list.
tokens = {token.strip() for token in encoding.split(",")}
return bool(tokens & HTTP_COMPRESSION_TOKENS)


def has_valid_cache_headers(item: RequestItem) -> bool:
cache_control = item.cache_control or ""
is_valid = False
if cache_control and not re.search(
r"(no-cache)|(no-store)|(max-age\s*=\s*0)", cache_control, re.I
):
is_valid = True

if item.expires:
expires = _parse_http_date(item.expires)
if expires is not None:
now = _parse_http_date(item.response_date) or datetime.now(tz=expires.tzinfo)
if expires < now:
is_valid = False

return is_valid


def _parse_http_date(value: str | None) -> datetime | None:
if not value:
return None
try:
from email.utils import parsedate_to_datetime

return parsedate_to_datetime(value)
except (TypeError, ValueError, IndexError):
return None


def is_http1(item: RequestItem) -> bool:
version = (item.http_version or "").upper()
return "HTTP/1" in version


def is_http_redirect(status: int) -> bool:
return status in HTTP_REDIRECT_CODES


def official_social_network(url: str) -> str | None:
lower = url.lower()
host = (urlparse(url).hostname or "").lower()
if host == "connect.facebook.net" and "sdk.js" in lower:
return "facebook"
for pattern, name in _SOCIAL_PATTERNS:
if pattern in lower:
return name
return None
1 change: 1 addition & 0 deletions components/ecoindex/best_practices/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class RuleCategory(str, Enum):

network = "network"
user_device = "user-device"
datacenter = "datacenter"


class Thresholds(BaseModel):
Expand Down
Loading
Loading