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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
BI_ENVIRONMENT=development
BI_ALLOWED_ORIGINS=http://localhost:8787,https://alokblog.com
BI_ALLOWED_HOSTS=localhost,127.0.0.1,api.alokblog.com
BI_TURNSTILE_HOSTNAME=alokblog.com
BI_TURNSTILE_SECRET=
BI_MIN_CONTEXT_SCORE=0.15
BI_MIN_DESTINATION_SCORE=0.08
BI_MAX_OPPORTUNITIES=3
BI_RATE_LIMIT_PER_HOUR=5
BI_MAX_CONCURRENCY=2
BI_ANALYSIS_TIMEOUT=35
BI_CONNECT_TIMEOUT=5
BI_READ_TIMEOUT=12
BI_MAX_COMPRESSED_BYTES=1000000
BI_MAX_DECOMPRESSED_BYTES=2000000
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install package
run: python -m pip install .
run: python -m pip install ".[api,test]"
- name: Compile package
run: python -m compileall -q backlink_intelligence
- name: Run tests
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 1.1.0 - 2026-08-30

- Add the shared placement analysis service and FastAPI v1 interface.
- Add genuine no-match and editorial-review outcomes.
- Return Unicode-safe structured text and link segments instead of browser offsets.
- Pin outbound connections to validated public IP addresses across redirects and robots requests.
- Add compressed and decompressed response limits, strict API validation, Turnstile verification, rate limits, concurrency controls, and production Host/origin enforcement.
- Add Render Free deployment configuration and v1 contract tests.

All notable changes to Backlink Intelligence are documented here.

## 1.0.1 - 2026-08-30
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,18 @@ MIT. See [LICENSE](LICENSE).
## Disclaimer

Backlink Intelligence is an independent open-source SEO research and workflow tool. It is not affiliated with Google, Ahrefs, Semrush, Moz, Majestic, or any other search engine or SEO platform. Outputs should be treated as evidence for professional review, not as guarantees of ranking impact, penalties, or search-engine behavior.

## Public beta API

Version 1.1 adds an optional FastAPI service for the public Backlink Placement Analyzer. Install it with:

```bash
pip install ".[api]"
uvicorn backlink_intelligence.api:app --host 127.0.0.1 --port 8000
```

The public endpoint is `POST /v1/place`. It accepts a source URL, target URL, preferred anchor, and Cloudflare Turnstile token. A valid analysis returns either `completed` or `no_suitable_placement`; the latter is a successful HTTP 200 outcome, not an API error.

Generated copy is returned as plain `after_text` plus `after_segments` containing only text and link records. The API never returns executable markup. Numeric scores are internal ranking evidence and must not be presented as probabilities, authority scores, ranking potential, or percentage quality. Beta thresholds are private server configuration and are not included in responses.

Production deployment settings are documented in `render.yaml` and `.env.example`. Free hosting has capacity, cold-start, and bandwidth limits; the service does not require a database, paid SEO data, or an LLM API.
2 changes: 1 addition & 1 deletion backlink_intelligence/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Backlink Intelligence package."""

__version__ = "1.0.1"
__version__ = "1.1.0"
126 changes: 126 additions & 0 deletions backlink_intelligence/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from __future__ import annotations

import os
from dataclasses import dataclass, field

from .fetcher import FetchConfig, fetch_page
from .models import PageEvidence, PlacementSuggestion
from .placement import rank_placements
from .safety import validate_public_url


def _float_setting(
name: str, default: float, minimum: float = 0.0, maximum: float = 1.0
) -> float:
try:
value = float(os.getenv(name, str(default)))
except ValueError:
return default
return value if minimum <= value <= maximum else default


def _int_setting(name: str, default: int, minimum: int, maximum: int) -> int:
try:
value = int(os.getenv(name, str(default)))
except ValueError:
return default
return min(max(value, minimum), maximum)


@dataclass(slots=True)
class AnalysisConfig:
min_context_score: float = 0.15
min_destination_score: float = 0.08
max_opportunities: int = 3
fetch: FetchConfig = field(default_factory=FetchConfig)

@classmethod
def from_environment(cls) -> "AnalysisConfig":
fetch = FetchConfig(
connect_timeout=_float_setting("BI_CONNECT_TIMEOUT", 5.0, 1.0, 15.0),
read_timeout=_float_setting("BI_READ_TIMEOUT", 12.0, 2.0, 30.0),
max_compressed_bytes=_int_setting(
"BI_MAX_COMPRESSED_BYTES", 1_000_000, 64_000, 2_000_000
),
max_decompressed_bytes=_int_setting(
"BI_MAX_DECOMPRESSED_BYTES", 2_000_000, 128_000, 4_000_000
),
max_redirects=_int_setting("BI_MAX_REDIRECTS", 3, 0, 3),
)
return cls(
min_context_score=_float_setting("BI_MIN_CONTEXT_SCORE", 0.15),
min_destination_score=_float_setting("BI_MIN_DESTINATION_SCORE", 0.08),
max_opportunities=_int_setting("BI_MAX_OPPORTUNITIES", 3, 1, 3),
fetch=fetch,
)


@dataclass(slots=True)
class PlacementAnalysis:
status: str
source: PageEvidence
target: PageEvidence
opportunities: list[PlacementSuggestion]
analysis_warnings: list[str] = field(default_factory=list)


class PlacementAnalyzer:
"""One analysis service shared by the API and command-line interface."""

def __init__(self, config: AnalysisConfig | None = None) -> None:
self.config = config or AnalysisConfig.from_environment()

def analyze(
self,
source_url: str,
target_url: str,
preferred_anchor: str,
*,
max_opportunities: int | None = None,
) -> PlacementAnalysis:
source_url = validate_public_url(source_url, resolve_dns=False)
target_url = validate_public_url(target_url, resolve_dns=False)
source = fetch_page(source_url, self.config.fetch)
target = fetch_page(target_url, self.config.fetch)
if source.status_code != 200 or target.status_code != 200:
return PlacementAnalysis(
status="failed",
source=source,
target=target,
opportunities=[],
)

top_n = self.config.max_opportunities
if max_opportunities is not None:
top_n = min(max(max_opportunities, 1), self.config.max_opportunities)
opportunities = rank_placements(
source,
target,
preferred_anchor,
target_url,
top_n=top_n,
min_context_score=self.config.min_context_score,
min_destination_score=self.config.min_destination_score,
)
warnings: list[str] = []
if not source.is_indexable:
warnings.append("source_page_is_not_indexable")
if not target.is_indexable:
warnings.append("target_page_is_not_indexable")
return PlacementAnalysis(
status="completed" if opportunities else "no_suitable_placement",
source=source,
target=target,
opportunities=opportunities,
analysis_warnings=warnings,
)


def analyze_placement(
source_url: str,
target_url: str,
preferred_anchor: str,
*,
config: AnalysisConfig | None = None,
) -> PlacementAnalysis:
return PlacementAnalyzer(config).analyze(source_url, target_url, preferred_anchor)
Loading
Loading