Spaces:
Paused
Paused
Peng Ding commited on
Commit ·
8d22106
1
Parent(s): 13e00d9
feat: add outbound request filtering with StevenBlack/hosts blocklist
Browse filesBlock ad networks, trackers, and beacons at the Playwright level to
prevent HF Spaces abuse-flag triggers and reduce attack surface.
Uses 82k-domain blocklist from StevenBlack/hosts (MIT), loaded at
startup with suffix matching via page.route().
- Makefile +9 -1
- pyproject.toml +1 -0
- src/veilrender/browser.py +11 -0
- src/veilrender/config.py +7 -0
- src/veilrender/data/__init__.py +0 -0
- src/veilrender/data/blocklist.txt +0 -0
- src/veilrender/filters.py +101 -0
Makefile
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
.PHONY: dev build run lint typecheck vendor clean build-package push-package deploy-dev deploy-hf help
|
| 2 |
|
| 3 |
REGISTRY_MIRROR ?= docker.io
|
|
|
|
| 4 |
DOCKER_IMAGE := oaklight/veilrender
|
| 5 |
VERSION := $(shell grep -oE '__version__[[:space:]]*=[[:space:]]*"[^"]+"' src/veilrender/__init__.py | grep -oE '"[^"]+"' | tr -d '"' || echo "0.1.0")
|
| 6 |
|
|
@@ -23,6 +24,12 @@ typecheck:
|
|
| 23 |
vendor:
|
| 24 |
cd ~/projects/zerodep && python zerodep.py add httpserver readability soup markdown cache config useragent retry structlog -d $(CURDIR)/src/veilrender/_vendor/ -y -f
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
clean:
|
| 27 |
rm -rf build/ dist/ *.egg-info/ src/*.egg-info/
|
| 28 |
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
@@ -100,6 +107,7 @@ help:
|
|
| 100 |
@echo " lint - Run ruff check and format"
|
| 101 |
@echo " typecheck - Run ty check"
|
| 102 |
@echo " vendor - Re-vendor zerodep modules"
|
|
|
|
| 103 |
@echo " clean - Remove build artifacts"
|
| 104 |
@echo " build-package - Build Python package"
|
| 105 |
@echo " push-package - Push package to PyPI"
|
|
|
|
| 1 |
+
.PHONY: dev build run lint typecheck vendor clean build-package push-package deploy-dev deploy-hf update-blocklist help
|
| 2 |
|
| 3 |
REGISTRY_MIRROR ?= docker.io
|
| 4 |
+
BLOCKLIST_URL := https://cdn.jsdelivr.net/gh/StevenBlack/hosts@master/hosts
|
| 5 |
DOCKER_IMAGE := oaklight/veilrender
|
| 6 |
VERSION := $(shell grep -oE '__version__[[:space:]]*=[[:space:]]*"[^"]+"' src/veilrender/__init__.py | grep -oE '"[^"]+"' | tr -d '"' || echo "0.1.0")
|
| 7 |
|
|
|
|
| 24 |
vendor:
|
| 25 |
cd ~/projects/zerodep && python zerodep.py add httpserver readability soup markdown cache config useragent retry structlog -d $(CURDIR)/src/veilrender/_vendor/ -y -f
|
| 26 |
|
| 27 |
+
update-blocklist:
|
| 28 |
+
curl -sL "$(BLOCKLIST_URL)" \
|
| 29 |
+
| grep "^0.0.0.0 " | awk '{print $$2}' | grep -v "^0.0.0.0$$" \
|
| 30 |
+
> src/veilrender/data/blocklist.txt
|
| 31 |
+
@echo "Updated blocklist: $$(wc -l < src/veilrender/data/blocklist.txt) domains"
|
| 32 |
+
|
| 33 |
clean:
|
| 34 |
rm -rf build/ dist/ *.egg-info/ src/*.egg-info/
|
| 35 |
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
|
|
| 107 |
@echo " lint - Run ruff check and format"
|
| 108 |
@echo " typecheck - Run ty check"
|
| 109 |
@echo " vendor - Re-vendor zerodep modules"
|
| 110 |
+
@echo " update-blocklist - Update ad/tracker blocklist from StevenBlack/hosts"
|
| 111 |
@echo " clean - Remove build artifacts"
|
| 112 |
@echo " build-package - Build Python package"
|
| 113 |
@echo " push-package - Push package to PyPI"
|
pyproject.toml
CHANGED
|
@@ -43,6 +43,7 @@ version = { attr = "veilrender.__version__" }
|
|
| 43 |
|
| 44 |
[tool.setuptools.package-data]
|
| 45 |
"veilrender" = ["py.typed"]
|
|
|
|
| 46 |
|
| 47 |
[tool.ruff]
|
| 48 |
target-version = "py310"
|
|
|
|
| 43 |
|
| 44 |
[tool.setuptools.package-data]
|
| 45 |
"veilrender" = ["py.typed"]
|
| 46 |
+
"veilrender.data" = ["blocklist.txt"]
|
| 47 |
|
| 48 |
[tool.ruff]
|
| 49 |
target-version = "py310"
|
src/veilrender/browser.py
CHANGED
|
@@ -12,6 +12,7 @@ from cloakbrowser import ensure_binary, get_default_stealth_args
|
|
| 12 |
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
| 13 |
|
| 14 |
from veilrender.config import settings
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
@@ -34,6 +35,14 @@ class BrowserManager:
|
|
| 34 |
self._semaphore = asyncio.Semaphore(settings.max_concurrent)
|
| 35 |
self._lock = asyncio.Lock()
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
async def start(self) -> None:
|
| 38 |
"""Launch Chromium with CDP and connect Playwright to it."""
|
| 39 |
executable_path = ensure_binary()
|
|
@@ -173,6 +182,8 @@ class BrowserManager:
|
|
| 173 |
user_agent=None, # use Playwright default
|
| 174 |
)
|
| 175 |
page = await context.new_page()
|
|
|
|
|
|
|
| 176 |
yield context, page
|
| 177 |
finally:
|
| 178 |
if context:
|
|
|
|
| 12 |
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
| 13 |
|
| 14 |
from veilrender.config import settings
|
| 15 |
+
from veilrender.filters import load_blocklist, make_route_handler
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
| 35 |
self._semaphore = asyncio.Semaphore(settings.max_concurrent)
|
| 36 |
self._lock = asyncio.Lock()
|
| 37 |
|
| 38 |
+
# Load blocklist once at init if resource filtering is enabled
|
| 39 |
+
if settings.resource_filter:
|
| 40 |
+
self._blocklist = load_blocklist(settings.blocked_domains_extra)
|
| 41 |
+
self._route_handler = make_route_handler(self._blocklist)
|
| 42 |
+
else:
|
| 43 |
+
self._blocklist = frozenset()
|
| 44 |
+
self._route_handler = None
|
| 45 |
+
|
| 46 |
async def start(self) -> None:
|
| 47 |
"""Launch Chromium with CDP and connect Playwright to it."""
|
| 48 |
executable_path = ensure_binary()
|
|
|
|
| 182 |
user_agent=None, # use Playwright default
|
| 183 |
)
|
| 184 |
page = await context.new_page()
|
| 185 |
+
if self._route_handler:
|
| 186 |
+
await page.route("**/*", self._route_handler)
|
| 187 |
yield context, page
|
| 188 |
finally:
|
| 189 |
if context:
|
src/veilrender/config.py
CHANGED
|
@@ -20,6 +20,13 @@ class Settings:
|
|
| 20 |
os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
|
| 21 |
)
|
| 22 |
self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
settings = Settings()
|
|
|
|
| 20 |
os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
|
| 21 |
)
|
| 22 |
self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
|
| 23 |
+
self.resource_filter: bool = (
|
| 24 |
+
os.environ.get("VEILRENDER_RESOURCE_FILTER", "true").lower() == "true"
|
| 25 |
+
)
|
| 26 |
+
_extra = os.environ.get("VEILRENDER_BLOCKED_DOMAINS_EXTRA", "")
|
| 27 |
+
self.blocked_domains_extra: list[str] = (
|
| 28 |
+
[d.strip() for d in _extra.split(",") if d.strip()] if _extra else []
|
| 29 |
+
)
|
| 30 |
|
| 31 |
|
| 32 |
settings = Settings()
|
src/veilrender/data/__init__.py
ADDED
|
File without changes
|
src/veilrender/data/blocklist.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/veilrender/filters.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Outbound request filtering using community-maintained blocklists.
|
| 2 |
+
|
| 3 |
+
Blocks ad networks, trackers, and beacons at the Playwright level to reduce
|
| 4 |
+
unnecessary outbound connections and prevent abuse of the rendering service.
|
| 5 |
+
|
| 6 |
+
Blocklist source: StevenBlack/hosts (MIT license)
|
| 7 |
+
https://github.com/StevenBlack/hosts
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import importlib.resources
|
| 13 |
+
import logging
|
| 14 |
+
from urllib.parse import urlparse
|
| 15 |
+
|
| 16 |
+
from playwright.async_api import Route
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_blocklist(extra_domains: list[str] | None = None) -> frozenset[str]:
|
| 22 |
+
"""Load the blocklist from the bundled data file.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
extra_domains: Additional domains to block beyond the bundled list.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
A frozen set of blocked domain names for O(1) lookup.
|
| 29 |
+
"""
|
| 30 |
+
domains: set[str] = set()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
ref = importlib.resources.files("veilrender.data").joinpath("blocklist.txt")
|
| 34 |
+
text = ref.read_text(encoding="utf-8")
|
| 35 |
+
for line in text.splitlines():
|
| 36 |
+
line = line.strip()
|
| 37 |
+
if line and not line.startswith("#"):
|
| 38 |
+
domains.add(line.lower())
|
| 39 |
+
except Exception:
|
| 40 |
+
logger.warning("Failed to load bundled blocklist, filtering disabled")
|
| 41 |
+
|
| 42 |
+
if extra_domains:
|
| 43 |
+
for d in extra_domains:
|
| 44 |
+
d = d.strip().lower()
|
| 45 |
+
if d:
|
| 46 |
+
domains.add(d)
|
| 47 |
+
|
| 48 |
+
logger.info("Loaded blocklist with %d domains", len(domains))
|
| 49 |
+
return frozenset(domains)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def should_block(url: str, blocklist: frozenset[str]) -> bool:
|
| 53 |
+
"""Check if a URL should be blocked based on the blocklist.
|
| 54 |
+
|
| 55 |
+
Performs suffix matching: for hostname ``a.b.c.com``, checks
|
| 56 |
+
``a.b.c.com``, ``b.c.com``, ``c.com`` against the set.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
url: The request URL to check.
|
| 60 |
+
blocklist: Set of blocked domain names.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
True if the URL's domain matches the blocklist.
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
hostname = urlparse(url).hostname
|
| 67 |
+
if not hostname:
|
| 68 |
+
return False
|
| 69 |
+
hostname = hostname.lower()
|
| 70 |
+
|
| 71 |
+
# Walk up the domain hierarchy
|
| 72 |
+
parts = hostname.split(".")
|
| 73 |
+
for i in range(len(parts)):
|
| 74 |
+
candidate = ".".join(parts[i:])
|
| 75 |
+
if candidate in blocklist:
|
| 76 |
+
return True
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
return False
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def make_route_handler(blocklist: frozenset[str]):
|
| 84 |
+
"""Create a Playwright route handler that blocks requests to listed domains.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
blocklist: Set of blocked domain names.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
An async route handler function for use with ``page.route()``.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
async def _handle_route(route: Route) -> None:
|
| 94 |
+
url = route.request.url
|
| 95 |
+
if should_block(url, blocklist):
|
| 96 |
+
logger.debug("Blocked: %s", url[:200])
|
| 97 |
+
await route.abort("blockedbyclient")
|
| 98 |
+
else:
|
| 99 |
+
await route.continue_()
|
| 100 |
+
|
| 101 |
+
return _handle_route
|