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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
SEO_API_KEY=

# Fetch safety and resource budgets.
# Total seconds per fetch, including DNS, queueing, redirects and streaming.
SEO_FETCH_TIMEOUT_SECONDS=12
SEO_MAX_RESPONSE_BYTES=3000000
SEO_MAX_REDIRECTS=5
Expand Down
56 changes: 45 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ on:
permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
Expand All @@ -27,30 +32,59 @@ jobs:
- run: ruff format --check .
- run: mypy seo_analyzer
- run: pytest --cov --cov-report=term-missing --cov-report=xml
- if: matrix.python-version == '3.14'
- if: matrix.python-version == '3.15'
run: pip-audit

package:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
python-version: "3.15"
- run: python -m pip install --upgrade build pip
- run: python -m build
- run: python -m pip install dist/*.whl
- run: python -c "from seo_analyzer.api import create_app; assert create_app().title"
- name: Verify installed wheel outside the source tree
working-directory: ${{ runner.temp }}
run: |
python - <<'PY'
from importlib.metadata import distribution
from seo_analyzer.api import create_app

assert create_app().title
files = {str(path) for path in distribution("saas-seo-analyzer").files}
for license_file in ("LICENSE", "THIRD_PARTY_NOTICES.md", "licenses/link-graph-explorer-oss.LICENSE"):
assert any(path.endswith("/licenses/" + license_file) for path in files), license_file
PY

container:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
push: false
load: true
tags: saas-seo-analyzer:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify container health and bundled licenses
run: |
docker run --detach --name analyzer --publish 127.0.0.1:8000:8000 \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
saas-seo-analyzer:test
trap 'docker logs analyzer; docker rm --force --volumes analyzer' EXIT
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:8000/readyz; then
break
fi
sleep 1
done
curl --fail http://127.0.0.1:8000/healthz
curl --fail http://127.0.0.1:8000/readyz
docker exec analyzer python -c "from importlib.metadata import distribution; files = {str(p) for p in distribution('saas-seo-analyzer').files}; assert any(p.endswith('/licenses/licenses/link-graph-explorer-oss.LICENSE') for p in files)"
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to this fork are documented here.

## 2.0.1 — 2026-11-03

### Changed

- Refreshed runtime and development dependency minimums against current stable
PyPI releases; declared the directly imported Pydantic and Starlette dependencies.
- Added Python 3.12 and 3.15 to CI, covering every supported minor from 3.11 to 3.15.
- Updated the digest-pinned container base to Python 3.15.2, pinned CI actions by
commit, and added workflow cancellation and job timeouts.
- CI now checks the installed wheel outside the checkout and starts the hardened
container to verify health, readiness, and third-party attribution files.
- Updated Windows setup, timeout behavior, scan lifecycle, and contributor docs.

### Fixed

- Explicitly close SQLite connections after commit or rollback instead of waiting
for garbage collection; fail tests on resource warnings.
- Keep explicit scan cancellations cancelled during service shutdown.
- Validate scan ownership and publish results in one SQLite write transaction.
- Enforce a total fetch deadline across DNS, queueing, redirects, and streaming.
- Return structured `502` errors for interrupted, malformed, and timed-out response
streams while closing the upstream response.
- Cancel and drain shared in-flight analyses before closing HTTP clients.
- Include the project's and link-graph component's license notices when building
the wheel inside Docker.

## 2.0.0 — 2026-07-22

### Added
Expand Down
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Contributions should keep the API explainable, safe for caller-controlled public

## Development setup

Python 3.11 or newer is required.
Python 3.11–3.15 is supported. Use a current patch release.

```bash
python -m venv .venv
Expand All @@ -23,7 +23,10 @@ pip-audit
python -m build
```

`make check` runs lint, formatting, types and tests. The CI matrix validates Python 3.11 and 3.13, package installation and the container build.
`make check` runs all six checks above. The CI matrix validates every Python minor
version from 3.11 through 3.15. Separate jobs verify wheel installation outside the
source tree, required license files, and health/readiness of the hardened container.
Resource warnings fail tests so unclosed SQLite connections cannot silently regress.

## Change expectations

Expand Down
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS builder
FROM python:3.15.2-slim@sha256:8c13a3f37a6a5b71498228e902c4b9f51c1f7900d49fabf71f9a650c6120e0d0 AS builder

ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1

WORKDIR /build
COPY pyproject.toml README.md ./
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md ./
COPY licenses ./licenses
COPY seo_analyzer ./seo_analyzer
RUN python -m pip wheel --wheel-dir /wheels .

FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS runtime
FROM python:3.15.2-slim@sha256:8c13a3f37a6a5b71498228e902c4b9f51c1f7900d49fabf71f9a650c6120e0d0 AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include main.py
include tests/conftest.py
recursive-include docs *.md
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Interactive OpenAPI docs are available at `/docs` and `/redoc`.

## Quick start

Python 3.11–3.14 is supported.
Python 3.11–3.15 is supported. Use the latest patch release for your Python minor version.

```bash
python -m venv .venv
Expand All @@ -95,6 +95,17 @@ python -m pip install -e '.[dev]'
uvicorn main:app --reload
```

On Windows PowerShell:

```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e '.[dev]'
.\.venv\Scripts\python.exe -m uvicorn seo_analyzer.api:app --reload
```

`seo_analyzer.api:app` also works when running an installed wheel outside the
repository. `main:app` remains the repository's compatibility entry point.

Analyze a page:

```bash
Expand Down Expand Up @@ -138,7 +149,7 @@ If `SEO_API_KEY` is set, add `-H 'X-API-Key: …'` to protected endpoints.

## Docker

The image runs as a non-root user. The Compose example binds only to loopback,
The image uses a digest-pinned Python 3.15.2 base and runs as a non-root user. The Compose example binds only to loopback,
drops Linux capabilities, uses a read-only root filesystem, and persists SQLite
in the `analyzer-data` volume mounted at `/data`.

Expand All @@ -154,7 +165,7 @@ All settings use the `SEO_` prefix. See [`.env.example`](.env.example) for the c
| Variable | Default | Meaning |
|---|---:|---|
| `SEO_API_KEY` | empty | Optional `X-API-Key` shared secret |
| `SEO_FETCH_TIMEOUT_SECONDS` | `12` | Upstream request timeout |
| `SEO_FETCH_TIMEOUT_SECONDS` | `12` | Total fetch deadline, including DNS, concurrency queue, redirects, and response streaming |
| `SEO_MAX_RESPONSE_BYTES` | `3000000` | Maximum decompressed page/resource body |
| `SEO_MAX_REDIRECTS` | `5` | Redirect budget |
| `SEO_MAX_CONCURRENT_FETCHES` | `8` | Process-wide fetch concurrency |
Expand Down Expand Up @@ -182,17 +193,25 @@ ruff format --check .
mypy seo_analyzer
pytest --cov --cov-report=term-missing
pip-audit
python -m build
```

The suite covers URL security, DNS/redirect validation, parsing, page classification, issue scoring, sitemap/robots behavior, crawl aggregation, PageSpeed normalization, API compatibility, auth, and opportunity ranking.

CI tests every Python minor version from 3.11 through 3.15, installs the built
wheel outside the source tree, and starts the hardened container to check health,
readiness, and bundled attribution files. Dependabot checks Python dependencies,
pinned GitHub Actions, and the Docker base weekly.

## Scan data

SQLite stores projects, scans, normalized page URLs, stable graph node IDs,
links with page zones, SEO reports, status/depth/timing metadata, and the complete
versioned result snapshot. HTML response bodies are not persisted. Running scans
write a worker lease and heartbeat; graceful shutdown requeues active work, while
an abandoned lease is recovered automatically.
an abandoned lease is recovered automatically. An explicit cancellation remains
cancelled during shutdown. Result publication checks ownership in the same SQLite
write transaction, and each database operation closes its connection explicitly.

## Known limitations

Expand Down
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Common statuses are:

FastAPI validation errors use its standard `detail` array. Clients should branch on the HTTP status first and tolerate additional response fields.

`SEO_FETCH_TIMEOUT_SECONDS` bounds the complete fetch, including DNS, waiting for
a fetch slot, redirects, and streaming the body. Each standalone DNS validation is
also bounded. A stalled DNS lookup returns `dns_resolution_failed`; an expired
fetch deadline or an interrupted/malformed response returns `upstream_unreachable`
with HTTP `502`. Client cancellation is propagated to the caller.

## `GET /v1/analyze`

Audits one page.
Expand Down
4 changes: 3 additions & 1 deletion docs/LINK_GRAPH_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ adds persistent link-graph scans on top of the existing safe asynchronous
## Limitations

- Active work is executed in-process. Graceful shutdown immediately requeues it;
process crashes are recovered after `SEO_SCAN_JOB_LEASE_SECONDS`.
explicitly cancelled scans remain cancelled. Process crashes are recovered after
`SEO_SCAN_JOB_LEASE_SECONDS`. Result publication and worker ownership checks
share a SQLite write transaction.
- External links are collected and visualized, but external targets are not fully
crawled.
- The dashboard is intentionally dependency-free and compact; deeper graph
Expand Down
12 changes: 12 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Before every request, including redirects, the fetcher:

Checking every DNS answer is intentionally conservative: a hostname with mixed public/private answers is rejected. Pinning the selected answer for the connection reduces time-of-check/time-of-use DNS-rebinding exposure.

`SEO_FETCH_TIMEOUT_SECONDS` is an overall deadline covering DNS, concurrency
queueing, all redirects, and response streaming. HTTPX's per-operation timeouts
remain in place. A server sending occasional small chunks cannot keep a fetch
running indefinitely. See [HTTPX timeout semantics](https://www.python-httpx.org/advanced/timeouts/).

`SEO_ALLOW_PRIVATE_HOSTS=true` disables the central address boundary and should only be used in a trusted, isolated deployment with an explicit egress policy. Never enable it on a public multi-tenant endpoint.

## Crawl controls
Expand Down Expand Up @@ -51,6 +56,13 @@ bodies are not intentionally persisted. Multi-worker deployments have independen
caches and metrics, while scan ownership and cancellation are coordinated through
SQLite worker leases.

SQLite transaction scopes also explicitly close their connections; the standard
connection context manager alone only commits or rolls back. Result publication
acquires a write transaction before validating worker ownership, preventing a
cancelled or replaced worker from overwriting the current scan. Explicitly
cancelled scans are not requeued during graceful shutdown. See the
[SQLite connection context manager](https://docs.python.org/3/library/sqlite3.html#how-to-use-the-connection-context-manager).

## Container posture

The supplied image runs as a non-root system user. Compose binds to loopback,
Expand Down
33 changes: 19 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[build-system]
requires = ["setuptools>=77"]
requires = ["setuptools>=84.2.1"]
build-backend = "setuptools.build_meta"

[project]
name = "saas-seo-analyzer"
version = "2.0.0"
version = "2.0.1"
description = "Security-first SEO and growth-strategy analyzer for SaaS websites"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -20,28 +20,31 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
]
dependencies = [
"beautifulsoup4>=4.14.3,<5",
"beautifulsoup4>=4.15,<5",
"defusedxml>=0.7.1,<1",
"fastapi>=0.139.2,<1",
"fastapi>=0.162,<1",
"httpx>=0.28.1,<1",
"prometheus-client>=0.25,<1",
"pydantic-settings>=2.14.2,<3",
"uvicorn>=0.51,<1",
"prometheus-client>=0.26,<1",
"pydantic>=2.14.2,<3",
"pydantic-settings>=2.16.2,<3",
"starlette>=1.10,<2",
"uvicorn>=0.54,<1",
]

[project.optional-dependencies]
dev = [
"build>=1.5,<2",
"httpx2>=2.7,<3",
"mypy>=2.3,<3",
"build>=1.6.1,<2",
"httpx2>=2.16,<3",
"mypy>=2.4.1,<3",
"pip-audit>=2.10.1,<3",
"pytest>=9.1.1,<10",
"pytest-asyncio>=1.4,<2",
"pytest-cov>=7,<8",
"ruff>=0.15.22,<1",
"pytest>=9.2,<10",
"pytest-asyncio>=1.4.1.post1,<2",
"pytest-cov>=7.1,<8",
"ruff>=0.17.3,<1",
]

[project.urls]
Expand All @@ -54,6 +57,8 @@ include = ["seo_analyzer*"]
[tool.pytest.ini_options]
addopts = "-ra --strict-config --strict-markers"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"]
testpaths = ["tests"]

[tool.coverage.run]
Expand Down
2 changes: 1 addition & 1 deletion seo_analyzer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""SaaS-focused SEO analysis engine."""

__version__ = "2.0.0"
__version__ = "2.0.1"
Loading
Loading