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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to Backlink Intelligence are documented here.

## 1.0.1 - 2026-08-30

### Fixed

- Prevented partial anchor insertion inside larger word forms such as rendering `AI agents` as `[AI Agent]s`.
- Preserved the publisher's existing anchor capitalization when the requested keyword differs only by case.
- Added conservative singular/plural anchor adaptation when the natural grammatical form already exists in source copy.
- Added destination-intent scoring so context specific to the destination topic receives more weight than generic anchor repetition.
- Added destination-fit and actual placed-anchor details to placement CLI output.

## 1.0.0 - 2026-08-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Contributions should preserve the project's core principles:
git clone https://github.com/alok-vibe-code/backlink-intelligence.git
cd backlink-intelligence
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
source .venv/bin/activate # Windows: .venv\\Scripts\\activate
python -m pip install -e .
python -m unittest discover -s tests -v
```
Expand Down
16 changes: 3 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,6 @@ The report includes evidence such as relevance, placement potential, outbound-li
- `manual_review`
- `low_priority`

Bulk commands cache repeated URLs during a run and use a small delay between rows by default. Use `--delay` to adjust that delay responsibly.

## 3. Find contextual link placements

This is the signature workflow.
Expand All @@ -135,8 +133,9 @@ For each recommended paragraph the tool returns:

- paragraph number,
- context-fit level,
- destination-fit level and score,
- requested anchor,
- suggested anchor when the requested wording is awkward,
- actual placed anchor (preserving source capitalization/grammar where possible),
- placement strategy,
- editorial intervention level,
- original-text preservation,
Expand All @@ -145,7 +144,7 @@ For each recommended paragraph the tool returns:
- reasons,
- and review flags.

The deterministic rewrite engine deliberately favors minimal editorial change. Its output is a placement draft for human review, not an instruction to publish automatically.
The deterministic rewrite engine deliberately favors minimal editorial change. Exact phrase matching uses complete word boundaries, preserves the publisher's existing capitalization, and can conservatively adapt a singular/plural anchor to the grammatical form already present in source copy. Destination-intent scoring also helps distinguish a paragraph that matches the target's specific topic from one that merely repeats the requested anchor. Its output is a placement draft for human review, not an instruction to publish automatically.

## 4. Monitor acquired backlinks

Expand Down Expand Up @@ -312,15 +311,6 @@ backlink_intelligence/
└── safety.py
```

## Docker

Build and run the CLI without installing Python packages into your host environment:

```bash
docker build -t backlink-intelligence .
docker run --rm backlink-intelligence --help
```

## Contributing

Contributions, test cases, parser improvements, and evidence-based methodology discussions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first.
Expand Down
24 changes: 13 additions & 11 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,26 @@ Use GitHub's private vulnerability reporting feature when enabled for this repos

## Security model

Backlink Intelligence processes URLs supplied by users. Network-enabled functionality therefore treats URL fetching as security-sensitive.
Backlink Intelligence is expected to process URLs supplied by users. Network-enabled releases therefore treat URL fetching as security-sensitive functionality.

The v1 fetcher blocks or bounds, at minimum:
Future implementations should protect against, at minimum:

- unsupported schemes,
- credentials embedded in URLs,
- localhost and common local hostnames,
- private, loopback, link-local, reserved, multicast, and unspecified IP ranges,
- DNS resolution to non-public addresses,
- redirects to non-public addresses,
- localhost and loopback targets,
- private and link-local IP ranges,
- cloud metadata endpoints,
- DNS rebinding and redirect-to-private-network behavior,
- excessive redirect chains,
- oversized responses,
- unexpectedly slow endpoints through request timeouts,
- and non-HTML content.
- decompression bombs,
- unexpectedly slow endpoints,
- unsupported schemes,
- unsafe local file paths,
- use as a generic open proxy,
- and uncontrolled concurrency during bulk analysis.

## Hosted deployment warning

A command-line tool that fetches user-supplied URLs and an internet-facing web service have different risk profiles. Do not expose the crawler as a public web endpoint without additional validation, centralized rate limiting, caching, abuse prevention, request isolation, logging, and SSRF defenses.
A command-line tool that fetches user-supplied URLs and an internet-facing web service have different risk profiles. Do not expose the crawler as a public web endpoint without additional validation, rate limiting, abuse prevention, request isolation, and SSRF defenses.

## Secrets

Expand Down
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.0"
__version__ = "1.0.1"
120 changes: 98 additions & 22 deletions backlink_intelligence/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,47 @@


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="backlink-intelligence", description="Evidence-first backlink intelligence for auditing, qualification, contextual placement, monitoring, and portfolio review.")
parser = argparse.ArgumentParser(
prog="backlink-intelligence",
description=(
"Evidence-first backlink intelligence for auditing, qualification, "
"contextual placement, monitoring, and portfolio review."
),
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = parser.add_subparsers(dest="command")

sub.add_parser("status", help="Show implementation status.")

audit = sub.add_parser("audit", help="Audit an existing backlink.")
audit.add_argument("source_url"); audit.add_argument("target_url"); audit.add_argument("--json", action="store_true", dest="as_json"); audit.add_argument("--output", help="Optional JSON output file.")
audit.add_argument("source_url")
audit.add_argument("target_url")
audit.add_argument("--json", action="store_true", dest="as_json")
audit.add_argument("--output", help="Optional JSON output file.")

qualify = sub.add_parser("qualify", help="Qualify backlink prospects from CSV.")
qualify.add_argument("input_csv"); qualify.add_argument("--output", default="qualification-report.csv"); qualify.add_argument("--delay", type=float, default=0.5, help="Delay between bulk rows in seconds.")
qualify.add_argument("input_csv")
qualify.add_argument("--output", default="qualification-report.csv")
qualify.add_argument("--delay", type=float, default=0.5, help="Delay between bulk rows in seconds.")

place = sub.add_parser("place", help="Find contextual link placement opportunities.")
place.add_argument("source_url"); place.add_argument("target_url"); place.add_argument("--anchor", required=True, help="Preferred anchor / keyword."); place.add_argument("--top", type=int, default=3); place.add_argument("--json", action="store_true", dest="as_json"); place.add_argument("--output", help="Optional JSON output file.")
place.add_argument("source_url")
place.add_argument("target_url")
place.add_argument("--anchor", required=True, help="Preferred anchor / keyword.")
place.add_argument("--top", type=int, default=3)
place.add_argument("--json", action="store_true", dest="as_json")
place.add_argument("--output", help="Optional JSON output file.")

monitor = sub.add_parser("monitor", help="Monitor backlinks listed in a CSV file.")
monitor.add_argument("input_csv"); monitor.add_argument("--state", default="backlink-state.json"); monitor.add_argument("--output", default="monitor-report.csv"); monitor.add_argument("--delay", type=float, default=0.5, help="Delay between monitoring rows in seconds.")
monitor.add_argument("input_csv")
monitor.add_argument("--state", default="backlink-state.json")
monitor.add_argument("--output", default="monitor-report.csv")
monitor.add_argument("--delay", type=float, default=0.5, help="Delay between monitoring rows in seconds.")

portfolio = sub.add_parser("portfolio", help="Analyze anchor/destination/placement distributions.")
portfolio.add_argument("input_csv"); portfolio.add_argument("--output", help="Optional JSON output file.")
portfolio.add_argument("input_csv")
portfolio.add_argument("--output", help="Optional JSON output file.")

return parser


Expand All @@ -37,37 +64,86 @@ def _placement_text(items) -> str:
return "No suitable placement opportunities could be generated."
chunks: list[str] = []
for item in items:
chunks.extend([f"PLACEMENT OPPORTUNITY #{item.rank}", f"Paragraph: {item.paragraph_index}", f"Context fit: {item.context_level}", f"Similarity score: {item.score:.3f}", f"Strategy: {item.strategy}", f"Editorial intervention:{item.intervention}", f"Text preservation: {item.preservation_percent:.1f}%", "", "BEFORE", item.before, "", "AFTER", item.after])
if item.reasons: chunks.extend(["", "Why this placement:", *[f" + {v}" for v in item.reasons]])
if item.warnings: chunks.extend(["", "Review flags:", *[f" ! {v}" for v in item.warnings]])
chunks.extend(
[
f"PLACEMENT OPPORTUNITY #{item.rank}",
f"Paragraph: {item.paragraph_index}",
f"Context fit: {item.context_level}",
f"Similarity score: {item.score:.3f}",
f"Destination fit: {item.destination_fit} ({item.destination_score:.3f})",
f"Requested anchor: {item.requested_anchor}",
f"Placed anchor: {item.suggested_anchor}",
f"Strategy: {item.strategy}",
f"Editorial intervention:{item.intervention}",
f"Text preservation: {item.preservation_percent:.1f}%",
"",
"BEFORE",
item.before,
"",
"AFTER",
item.after,
]
)
if item.reasons:
chunks.extend(["", "Why this placement:", *[f" + {v}" for v in item.reasons]])
if item.warnings:
chunks.extend(["", "Review flags:", *[f" ! {v}" for v in item.warnings]])
chunks.extend(["", "-" * 72, ""])
return "\n".join(chunks).rstrip()


def main(argv: list[str] | None = None) -> int:
parser = build_parser(); args = parser.parse_args(argv)
parser = build_parser()
args = parser.parse_args(argv)

try:
if args.command == "status":
print("Backlink Intelligence is installed and functional."); print(f"Version: {__version__}"); print("Available: audit, qualify, place, monitor, portfolio"); print("Workflow: Discover -> Qualify -> Place -> Monitor -> Analyze"); return 0
print("Backlink Intelligence is installed and functional.")
print(f"Version: {__version__}")
print("Available: audit, qualify, place, monitor, portfolio")
print("Workflow: Discover -> Qualify -> Place -> Monitor -> Analyze")
return 0

if args.command == "audit":
result = audit_backlink(args.source_url, args.target_url)
if args.output: write_json(result.to_dict(), args.output)
print(write_json(result.to_dict()) if args.as_json else audit_text(result)); return 0 if result.source.status_code else 2
if args.output:
write_json(result.to_dict(), args.output)
print(write_json(result.to_dict()) if args.as_json else audit_text(result))
return 0 if result.source.status_code else 2

if args.command == "qualify":
rows = qualify_csv(args.input_csv, args.output, delay_seconds=args.delay); print(f"Qualified {len(rows)} prospect(s). Report: {args.output}"); return 0
rows = qualify_csv(args.input_csv, args.output, delay_seconds=args.delay)
print(f"Qualified {len(rows)} prospect(s). Report: {args.output}")
return 0

if args.command == "place":
items = suggest_placements(args.source_url, args.target_url, args.anchor, top_n=args.top); payload = [item.to_dict() for item in items]
if args.output: write_json(payload, args.output)
print(write_json(payload) if args.as_json else _placement_text(items)); return 0 if items else 3
items = suggest_placements(args.source_url, args.target_url, args.anchor, top_n=args.top)
payload = [item.to_dict() for item in items]
if args.output:
write_json(payload, args.output)
print(write_json(payload) if args.as_json else _placement_text(items))
return 0 if items else 3

if args.command == "monitor":
rows = monitor_csv(args.input_csv, args.state, args.output, delay_seconds=args.delay); changed = sum("unchanged" not in row["changes"] and "baseline_created" not in row["changes"] for row in rows); print(f"Checked {len(rows)} link(s). Changed: {changed}. Report: {args.output}"); return 0
rows = monitor_csv(args.input_csv, args.state, args.output, delay_seconds=args.delay)
changed = sum("unchanged" not in row["changes"] and "baseline_created" not in row["changes"] for row in rows)
print(f"Checked {len(rows)} link(s). Changed: {changed}. Report: {args.output}")
return 0

if args.command == "portfolio":
result = analyze_portfolio(args.input_csv); print(write_json(result, args.output)); return 0
parser.print_help(); return 0
result = analyze_portfolio(args.input_csv)
text = write_json(result, args.output)
print(text)
return 0

parser.print_help()
return 0
except KeyboardInterrupt:
print("Cancelled.", file=sys.stderr); return 130
print("Cancelled.", file=sys.stderr)
return 130
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr); return 1
print(f"Error: {exc}", file=sys.stderr)
return 1


if __name__ == "__main__":
Expand Down
2 changes: 2 additions & 0 deletions backlink_intelligence/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ class PlacementSuggestion:
paragraph_index: int
score: float
context_level: str
destination_score: float
destination_fit: str
requested_anchor: str
suggested_anchor: str
strategy: str
Expand Down
Loading
Loading