Skip to content

Commit af0f937

Browse files
committed
feat: unify crawl engine, harden scan jobs, and enrich the link graph
Share one crawl engine between /v1/site-audit and persistent link-graph scans, then make background scans survive process restarts and represent non-200 pages faithfully in the graph. Crawl engine - Extract SiteCrawler.crawl returning a SiteCrawlSnapshot; link_graph now consumes it instead of running a second BFS, so robots, sitemap seeding, URL normalization, and depth/page budgets behave identically everywhere. - Add cooperative cancellation and progress reporting to SiteCrawler. - Skip non-HTML asset URLs at enqueue time via shared is_html_like_url. - Expose use_sitemap through the scan API and persisted scan options. Scan jobs - Bound concurrent scans with SEO_SCAN_JOB_WORKERS and add worker leases, heartbeats, and a reconcile loop governed by SEO_SCAN_JOB_LEASE_SECONDS. - Coordinate cancellation through SQLite so any process can cancel a scan. - Requeue in-flight work on graceful shutdown; recover abandoned leases. - Enable WAL and a busy timeout; give in-memory storage a shared-cache DSN. Link graph - Model redirects, robots-blocked, fetch-failed, and out-of-scope pages as real graph nodes with redirect edges and preserved chains. - Merge link zones (header/nav/content/footer) per target and carry them into edges, incoming links, and dashboard filters. - Recompute inbound/outbound counts from stats and apply issue penalties incrementally with a matching grade. Interfaces - Escape inline dashboard JSON against script-tag breakout. - Add cancel/rerun/dashboard controls and error surfacing to the browser UI; fetch the dashboard with the API key instead of a bare link. - Persist SQLite in a dedicated /data volume for the read-only container. - Document every link-graph route, scan data retention, and limitations. Tests: 100 passed, 90% coverage; ruff, mypy, and pip-audit clean.
1 parent 75c830b commit af0f937

21 files changed

Lines changed: 1245 additions & 299 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ SEO_CACHE_MAX_ENTRIES=512
1717
SEO_MAX_SITE_PAGES=100
1818
SEO_SCAN_STORAGE_PATH=data/analyzer.db
1919
SEO_SCAN_JOB_WORKERS=2
20+
SEO_SCAN_JOB_LEASE_SECONDS=30
2021

2122
# Optional Google PageSpeed Insights integration. It is off by default because it
2223
# consumes an external quota and makes analysis slower.

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5
1313
ENV PYTHONDONTWRITEBYTECODE=1 \
1414
PYTHONUNBUFFERED=1 \
1515
PORT=8000 \
16-
SEO_SCAN_STORAGE_PATH=/tmp/analyzer.db
16+
SEO_SCAN_STORAGE_PATH=/data/analyzer.db
1717

18-
RUN groupadd --system app && useradd --system --gid app --home-dir /app app
18+
RUN groupadd --system app && useradd --system --gid app --home-dir /app app \
19+
&& mkdir -p /data && chown app:app /data
1920
WORKDIR /app
2021
COPY --from=builder /wheels /wheels
2122
RUN python -m pip install --no-cache-dir /wheels/* && rm -rf /wheels
2223
COPY --chown=app:app main.py ./main.py
2324
USER app
25+
VOLUME ["/data"]
2426
EXPOSE 8000
2527
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
2628
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','8000')+'/healthz', timeout=2)"

README.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ This fork is a ground-up v2 implementation of [KovalDenys1/SEO-Analyzer-API](htt
1717
- Ranks supplied Search Console/conversion rows by traffic and revenue opportunity without inventing external keyword or SERP data.
1818
- Optionally enriches a page with Google PageSpeed Insights v5 lab/field data.
1919

20+
## Architecture
21+
22+
```mermaid
23+
flowchart LR
24+
Client[API or browser UI] --> API[FastAPI]
25+
API --> Jobs[Bounded scan manager]
26+
Jobs --> Crawler[Shared SiteCrawler]
27+
Crawler --> Fetcher[SafeFetcher]
28+
Crawler --> Parser[HTML parser and SEO scoring]
29+
Parser --> Graph[Link graph and site-level issues]
30+
Graph --> Storage[(SQLite)]
31+
Storage --> API
32+
API --> Dashboard[Interactive graph dashboard]
33+
```
34+
35+
`/v1/site-audit` and persistent link-graph scans use the same crawler engine.
36+
Each fetched document is parsed once, then reused for SEO scoring, internal and
37+
external edges, duplicate/orphan/broken-link checks, storage, and visualization.
38+
2039
## Safety by default
2140

2241
The service fetches user-supplied URLs, so URL handling is part of the security boundary:
@@ -43,8 +62,20 @@ Private-network access can be enabled for a trusted internal deployment, but it
4362
| `POST` | `/v1/opportunities` | First-party traffic/revenue opportunity ranking |
4463
| `GET` | `/app` | Minimal browser UI for unified link-graph scans |
4564
| `POST` | `/api/projects` | Create a persistent crawl project |
65+
| `GET` | `/api/projects` | List projects |
66+
| `GET` | `/api/projects/{project_id}` | Get one project |
4667
| `POST` | `/api/projects/{project_id}/scans` | Start a background crawl + graph + SEO scan |
68+
| `GET` | `/api/projects/{project_id}/scans` | List project scans |
69+
| `GET` | `/api/scans/{scan_id}/status` | Get progress and terminal status |
70+
| `POST` | `/api/scans/{scan_id}/cancel` | Cancel pending or running work |
71+
| `POST` | `/api/scans/{scan_id}/rerun` | Create a new scan with the same options |
72+
| `GET` | `/api/scans/{scan_id}/pages` | List page records and attached SEO data |
73+
| `GET` | `/api/scans/{scan_id}/page?url=...` | Resolve a page by normalized URL |
74+
| `GET` | `/api/scans/{scan_id}/pages/{graph_node_id}` | Resolve a page by graph node ID |
75+
| `GET` | `/api/scans/{scan_id}/links` | List internal, external, or redirect links |
4776
| `GET` | `/api/scans/{scan_id}/graph` | Graph nodes/edges with SEO data attached |
77+
| `GET` | `/api/scans/{scan_id}/seo/issues` | List page and site-level SEO issues |
78+
| `GET` | `/api/scans/{scan_id}/stats` | Site totals, duplicates, cycles, orphans, and failures |
4879
| `GET` | `/api/scans/{scan_id}/dashboard` | Interactive graph dashboard for a completed scan |
4980
| `GET` | `/analyze` | Backwards-compatible original full-analysis shape plus `v2` data |
5081
| `GET` | `/quick-score` | Score, warnings, and top recommendations |
@@ -107,7 +138,9 @@ If `SEO_API_KEY` is set, add `-H 'X-API-Key: …'` to protected endpoints.
107138

108139
## Docker
109140

110-
The image runs as a non-root user. The Compose example binds only to loopback, drops Linux capabilities, uses a read-only filesystem, and adds a health check.
141+
The image runs as a non-root user. The Compose example binds only to loopback,
142+
drops Linux capabilities, uses a read-only root filesystem, and persists SQLite
143+
in the `analyzer-data` volume mounted at `/data`.
111144

112145
```bash
113146
cp .env.example .env
@@ -129,7 +162,8 @@ All settings use the `SEO_` prefix. See [`.env.example`](.env.example) for the c
129162
| `SEO_CACHE_TTL_SECONDS` | `300` | Analysis cache TTL; `0` disables cache |
130163
| `SEO_MAX_SITE_PAGES` | `100` | Server-side hard cap for a site audit |
131164
| `SEO_SCAN_STORAGE_PATH` | `data/analyzer.db` | SQLite storage for persistent link-graph scans |
132-
| `SEO_SCAN_JOB_WORKERS` | `2` | Reserved scan worker budget for deployments |
165+
| `SEO_SCAN_JOB_WORKERS` | `2` | Concurrent link-graph scans per API process |
166+
| `SEO_SCAN_JOB_LEASE_SECONDS` | `30` | Time before an abandoned running scan is requeued |
133167
| `SEO_ENABLE_PAGESPEED` | `false` | Permit quota-consuming PageSpeed calls |
134168
| `SEO_PAGESPEED_API_KEY` | empty | Optional Google API key |
135169
| `SEO_CORS_ORIGINS` | empty | Comma-separated browser origins |
@@ -152,6 +186,23 @@ pip-audit
152186

153187
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.
154188

189+
## Scan data
190+
191+
SQLite stores projects, scans, normalized page URLs, stable graph node IDs,
192+
links with page zones, SEO reports, status/depth/timing metadata, and the complete
193+
versioned result snapshot. HTML response bodies are not persisted. Running scans
194+
write a worker lease and heartbeat; graceful shutdown requeues active work, while
195+
an abandoned lease is recovered automatically.
196+
197+
## Known limitations
198+
199+
- Crawls are bounded samples and do not execute client-side JavaScript.
200+
- External targets are represented in the graph but are not fetched or checked.
201+
- SQLite is intended for local and small-team deployments; high-volume distributed
202+
deployments should replace the storage/job backend.
203+
- Worker limits apply per API process. Global fetch concurrency is still bounded
204+
by `SEO_MAX_CONCURRENT_FETCHES` in each process.
205+
155206
## Documentation
156207

157208
- [API guide](docs/API.md)
@@ -163,4 +214,7 @@ The suite covers URL security, DNS/redirect validation, parsing, page classifica
163214

164215
## License and attribution
165216

166-
MIT. The upstream project is copyright Denys Koval; this fork preserves the original license and history. See [LICENSE](LICENSE).
217+
MIT. The upstream project is copyright Denys Koval. Link-graph functionality is
218+
derived from [ruslan2027/link-graph-explorer-oss](https://github.com/ruslan2027/link-graph-explorer-oss).
219+
See [LICENSE](LICENSE), [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), and
220+
[licenses/link-graph-explorer-oss.LICENSE](licenses/link-graph-explorer-oss.LICENSE).

compose.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ services:
66
env_file:
77
- path: .env
88
required: false
9+
environment:
10+
SEO_SCAN_STORAGE_PATH: /data/analyzer.db
911
ports:
1012
- "127.0.0.1:8000:8000"
1113
read_only: true
1214
tmpfs:
1315
- /tmp:size=32m,mode=1777
16+
volumes:
17+
- analyzer-data:/data
1418
security_opt:
1519
- no-new-privileges:true
1620
cap_drop:
@@ -19,3 +23,6 @@ services:
1923
resources:
2024
limits:
2125
memory: 512M
26+
27+
volumes:
28+
analyzer-data:

docs/LINK_GRAPH_INTEGRATION.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ adds persistent link-graph scans on top of the existing safe asynchronous
99
- The existing `SafeFetcher` remains the only network layer. The graph scan does
1010
not add a second crawler with `requests`, so SSRF protections, DNS validation,
1111
redirect validation, response budgets, and concurrency limits remain intact.
12+
- `/v1/site-audit` and persistent graph scans share `SiteCrawler.crawl`, including
13+
robots handling, sitemap discovery, URL normalization, redirect deduplication,
14+
and page/depth/concurrency budgets.
1215
- Each fetched HTML page is analyzed once by `Analyzer.analyze_artifact`. The
1316
resulting parsed links and SEO report are reused for graph nodes, edges, page
1417
details, issue lists, stats, and the dashboard.
@@ -23,13 +26,15 @@ adds persistent link-graph scans on top of the existing safe asynchronous
2326
- `seo_analyzer.link_graph`: async BFS graph scan, page normalization, graph
2427
stats, cycles, duplicates, redirects, and SEO-to-node mapping.
2528
- `seo_analyzer.storage`: SQLite persistence.
26-
- `seo_analyzer.jobs`: background scan tasks, progress, cancellation, and rerun.
29+
- `seo_analyzer.jobs`: bounded background scans, SQLite worker leases, heartbeat,
30+
cross-worker cancellation, automatic recovery, and rerun.
2731
- `seo_analyzer.dashboard`: completed-scan graph dashboard renderer.
2832
- `seo_analyzer.frontend`: minimal browser UI for starting scans.
2933

3034
## Limitations
3135

32-
- Jobs are in-process and are not resumed after an API process restart.
36+
- Active work is executed in-process. Graceful shutdown immediately requeues it;
37+
process crashes are recovered after `SEO_SCAN_JOB_LEASE_SECONDS`.
3338
- External links are collected and visualized, but external targets are not fully
3439
crawled.
3540
- The dashboard is intentionally dependency-free and compact; deeper graph

docs/SECURITY.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,21 @@ Analysis responses add a request ID, `nosniff`, a no-referrer policy and `Cache-
4444

4545
PageSpeed is disabled by default. When enabled and requested, the final validated public URL is sent to Google PageSpeed Insights. That creates a third-party data and quota dependency; document it in your privacy/processing model and protect `SEO_PAGESPEED_API_KEY` in the environment.
4646

47-
Page HTML is processed in memory and cached in summarized analysis form for a bounded TTL. This project has no database and does not intentionally persist fetched HTML. Multi-worker deployments have independent caches and metrics.
47+
Page HTML is processed in memory and cached in summarized analysis form for a
48+
bounded TTL. Persistent scans store normalized URLs, extracted metadata, links,
49+
SEO findings, graph data, and status/timing summaries in SQLite; raw HTML response
50+
bodies are not intentionally persisted. Multi-worker deployments have independent
51+
caches and metrics, while scan ownership and cancellation are coordinated through
52+
SQLite worker leases.
4853

4954
## Container posture
5055

51-
The supplied image runs as a non-root system user. Compose binds to loopback, drops all Linux capabilities, sets `no-new-privileges`, uses a read-only root filesystem, provides a small `/tmp` tmpfs and declares a memory limit. Put a production reverse proxy in front; do not expose the Uvicorn development topology as a complete security perimeter.
56+
The supplied image runs as a non-root system user. Compose binds to loopback,
57+
drops all Linux capabilities, sets `no-new-privileges`, uses a read-only root
58+
filesystem, persists scan data in a dedicated `/data` volume, provides a small
59+
`/tmp` tmpfs, and declares a memory limit. Put a production reverse proxy in
60+
front; do not expose the Uvicorn development topology as a complete security
61+
perimeter.
5262

5363
Container restrictions do not replace outbound firewall rules. For higher-risk deployments, allow egress only to public HTTP(S), run in a dedicated network/namespace, configure DNS deliberately and set infrastructure-level CPU/request/time limits.
5464

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ description = "Security-first SEO and growth-strategy analyzer for SaaS websites
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = "MIT"
12+
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "licenses/*.LICENSE"]
1213
authors = [{ name = "hlibsuslov", email = "glebsuslov720@gmail.com" }]
1314
keywords = ["seo", "saas", "fastapi", "crawler", "technical-seo"]
1415
classifiers = [

seo_analyzer/api.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class ScanCreate(BaseModel):
109109
respect_robots: bool = True
110110
include_subdomains: bool = False
111111
include_query_parameters: bool = False
112+
use_sitemap: bool = True
112113

113114

114115
def create_app(settings: Settings | None = None, analyzer: Analyzer | None = None) -> FastAPI:
@@ -129,9 +130,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
129130
app.state.scan_storage,
130131
app.state.analyzer,
131132
)
132-
yield
133-
if owns_analyzer:
134-
await app.state.analyzer.close()
133+
await app.state.scan_manager.startup()
134+
try:
135+
yield
136+
finally:
137+
await app.state.scan_manager.shutdown()
138+
app.state.scan_storage.close()
139+
if owns_analyzer:
140+
await app.state.analyzer.close()
135141

136142
app = FastAPI(
137143
title="SaaS SEO Analyzer API",
@@ -484,6 +490,7 @@ async def start_scan(
484490
respect_robots=payload.respect_robots,
485491
include_subdomains=payload.include_subdomains,
486492
include_query_parameters=payload.include_query_parameters,
493+
use_sitemap=payload.use_sitemap,
487494
)
488495
return manager.create_and_start(project_id, start_url, options)
489496

@@ -557,6 +564,7 @@ async def scan_pages(
557564
pages = storage.list_pages(scan_id, include_redirects=include_redirects)
558565
return _filter_pages(
559566
pages,
567+
include_redirects=include_redirects,
560568
status=status,
561569
max_depth=max_depth,
562570
issue=issue,
@@ -573,8 +581,14 @@ async def scan_page_by_url(
573581
url: str = Query(min_length=4, max_length=2_048),
574582
storage: ScanStorage = Depends(get_scan_storage),
575583
) -> dict[str, Any]:
576-
_require_scan(scan_id, storage)
577-
page = storage.get_page_by_url(scan_id, normalize_url(url, keep_query=True))
584+
scan = _require_scan(scan_id, storage)
585+
page = storage.get_page_by_url(
586+
scan_id,
587+
normalize_url(
588+
url,
589+
keep_query=bool(scan["options"].get("include_query_parameters", False)),
590+
),
591+
)
578592
if not page:
579593
raise HTTPException(status_code=404, detail="Page not found")
580594
return page
@@ -602,7 +616,7 @@ async def scan_page_by_node(
602616
)
603617
async def scan_links(
604618
scan_id: str,
605-
type: str | None = Query(default=None, pattern="^(internal|external)$"),
619+
type: str | None = Query(default=None, pattern="^(internal|external|redirect)$"),
606620
storage: ScanStorage = Depends(get_scan_storage),
607621
) -> list[dict[str, Any]]:
608622
_require_scan(scan_id, storage)
@@ -625,6 +639,7 @@ async def scan_graph(
625639
if any(value is not None for value in (status, max_depth, issue, min_score)):
626640
pages = _filter_pages(
627641
list(result["crawl"]["pages"].values()),
642+
include_redirects=True,
628643
status=status,
629644
max_depth=max_depth,
630645
issue=issue,
@@ -739,18 +754,19 @@ def _require_result(scan_id: str, storage: ScanStorage) -> dict[str, Any]:
739754
def _filter_pages(
740755
pages: list[dict[str, Any]],
741756
*,
757+
include_redirects: bool = False,
742758
status: int | None = None,
743759
max_depth: int | None = None,
744760
issue: str | None = None,
745761
min_score: float | None = None,
746762
) -> list[dict[str, Any]]:
747763
filtered = []
748764
for page in pages:
749-
if page.get("redirected_to"):
765+
if page.get("redirected_to") and not include_redirects:
750766
continue
751767
if status is not None and int(page.get("status") or 0) != status:
752768
continue
753-
if max_depth is not None and int(page.get("depth") or 0) > max_depth:
769+
if max_depth is not None and (page.get("depth") is None or int(page["depth"]) > max_depth):
754770
continue
755771
seo = page.get("seo") or {}
756772
if min_score is not None and float(seo.get("score") or 0) < min_score:

seo_analyzer/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class Settings(BaseSettings):
3131
max_site_pages: int = Field(default=100, ge=1, le=1_000)
3232
scan_storage_path: str = "data/analyzer.db"
3333
scan_job_workers: int = Field(default=2, ge=1, le=16)
34+
scan_job_lease_seconds: int = Field(default=30, ge=10, le=600)
3435

3536
enable_pagespeed: bool = False
3637
pagespeed_api_key: SecretStr | None = None

0 commit comments

Comments
 (0)