Skip to content

Commit 4744655

Browse files
rayketchamclaude
andcommitted
feat: URL-to-idea ingestion + resource tracking (TDD, 31 tests)
New feature: generate project ideas from URLs. Paste a URL (article, RFC, newsletter), Project Forge fetches it, extracts content, and generates a scored project idea. Source domains are tracked as "resources" for future idea generation. New endpoints: - POST /api/ideas/from-url — generate idea from URL content - GET /api/resources — list tracked source domains - POST /api/resources — add a domain as an idea source New modules: - engine/url_ingest.py — URL fetching, HTML parsing, domain extraction, UTM param stripping, URL validation - Resource model + resources DB table with domain dedup - source_url field on Idea model for provenance tracking TDD cycle: 31 tests written first (RED), then implemented (GREEN), lint-fixed (REFACTOR). 185/185 total tests passing, 0 regressions. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9f1594c commit 4744655

6 files changed

Lines changed: 852 additions & 5 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""URL ingestion engine — fetch URLs, extract content, generate ideas."""
2+
3+
import re
4+
from dataclasses import dataclass
5+
from urllib.parse import parse_qs, urlencode, urlparse
6+
7+
import httpx
8+
9+
# Tracking parameters to strip from URLs
10+
TRACKING_PARAMS = {
11+
"utm_source",
12+
"utm_medium",
13+
"utm_campaign",
14+
"utm_content",
15+
"utm_term",
16+
"ref",
17+
"fbclid",
18+
"gclid",
19+
}
20+
21+
22+
class UrlFetchError(Exception):
23+
"""Raised when a URL cannot be fetched successfully."""
24+
25+
26+
@dataclass
27+
class UrlContent:
28+
url: str
29+
domain: str
30+
title: str
31+
text: str
32+
33+
34+
def validate_url(url: str) -> bool:
35+
"""Check if URL is valid http(s)."""
36+
if not url:
37+
return False
38+
try:
39+
parsed = urlparse(url)
40+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
41+
except Exception:
42+
return False
43+
44+
45+
def extract_domain(url: str) -> str:
46+
"""Extract clean domain from URL (strip www.)."""
47+
parsed = urlparse(url)
48+
domain = parsed.netloc
49+
if domain.startswith("www."):
50+
domain = domain[4:]
51+
return domain
52+
53+
54+
def clean_url(url: str) -> str:
55+
"""Remove tracking parameters from URL."""
56+
parsed = urlparse(url)
57+
params = parse_qs(parsed.query)
58+
clean_params = {k: v for k, v in params.items() if k not in TRACKING_PARAMS}
59+
if clean_params:
60+
clean_query = urlencode(clean_params, doseq=True)
61+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{clean_query}"
62+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
63+
64+
65+
async def fetch_url_content(url: str) -> UrlContent:
66+
"""Fetch URL and extract content."""
67+
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
68+
response = await client.get(url)
69+
70+
if response.status_code >= 400:
71+
raise UrlFetchError(f"HTTP {response.status_code} fetching {url}")
72+
73+
text = response.text
74+
domain = extract_domain(url)
75+
76+
# Extract title from HTML and strip tags for text content
77+
title = ""
78+
content_type = response.headers.get("content-type", "")
79+
if "html" in content_type:
80+
title_match = re.search(r"<title[^>]*>(.*?)</title>", text, re.DOTALL | re.IGNORECASE)
81+
if title_match:
82+
title = title_match.group(1).strip()
83+
# Strip script/style blocks first, then all other tags
84+
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
85+
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
86+
text = re.sub(r"<[^>]+>", " ", text)
87+
text = re.sub(r"\s+", " ", text).strip()
88+
89+
if not title:
90+
title = domain # Fallback to domain when no HTML title found
91+
92+
return UrlContent(url=url, domain=domain, title=title, text=text[:5000])
93+
94+
95+
async def generate_idea_from_url(content: UrlContent, category_hint=None):
96+
"""Generate an idea from URL content via IdeaGenerator."""
97+
from project_forge.engine.generator import IdeaGenerator
98+
99+
generator = IdeaGenerator()
100+
idea = await generator.generate_from_content(content, category_hint=category_hint)
101+
return idea

src/project_forge/models.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from datetime import UTC, datetime
44
from enum import StrEnum
55
from typing import Literal
6+
from urllib.parse import urlparse
67
from uuid import uuid4
78

8-
from pydantic import BaseModel, Field
9+
from pydantic import BaseModel, Field, field_validator
910

1011

1112
class IdeaCategory(StrEnum):
@@ -41,6 +42,32 @@ class Idea(BaseModel):
4142
github_issue_url: str | None = None
4243
project_repo_url: str | None = None
4344
content_hash: str | None = None
45+
source_url: str | None = None
46+
47+
48+
class Resource(BaseModel):
49+
id: str = Field(default_factory=lambda: uuid4().hex[:12])
50+
domain: str
51+
name: str
52+
description: str
53+
url: str | None = None
54+
categories: list[str] = Field(default_factory=list)
55+
idea_count: int = 0
56+
added_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
57+
58+
59+
class UrlIngestRequest(BaseModel):
60+
url: str
61+
category: str | None = None
62+
notes: str | None = None
63+
64+
@field_validator("url")
65+
@classmethod
66+
def validate_url_format(cls, v: str) -> str:
67+
parsed = urlparse(v)
68+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
69+
raise ValueError(f"Invalid URL: {v!r} — must be http(s)")
70+
return v
4471

4572

4673
class ScaffoldSpec(BaseModel):

src/project_forge/storage/db.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import aiosqlite
1212

13-
from project_forge.models import GenerationRun, Idea, IdeaCategory, IdeaStatus
13+
from project_forge.models import GenerationRun, Idea, IdeaCategory, IdeaStatus, Resource
1414

1515
SCHEMA = """
1616
CREATE TABLE IF NOT EXISTS ideas (
@@ -53,6 +53,17 @@
5353
used_at TEXT NOT NULL,
5454
PRIMARY KEY (category, concept_idx, domain_idx, direction)
5555
);
56+
57+
CREATE TABLE IF NOT EXISTS resources (
58+
id TEXT PRIMARY KEY,
59+
domain TEXT NOT NULL UNIQUE,
60+
name TEXT NOT NULL,
61+
description TEXT NOT NULL,
62+
url TEXT,
63+
categories TEXT NOT NULL DEFAULT '[]',
64+
idea_count INTEGER NOT NULL DEFAULT 0,
65+
added_at TEXT NOT NULL
66+
);
5667
"""
5768

5869

@@ -74,6 +85,11 @@ async def connect(self):
7485
await self._db.execute("ALTER TABLE ideas ADD COLUMN content_hash TEXT")
7586
except Exception: # noqa: S110
7687
pass # Column already exists on migrated DBs
88+
# Migration: add source_url column if missing
89+
try:
90+
await self._db.execute("ALTER TABLE ideas ADD COLUMN source_url TEXT")
91+
except Exception: # noqa: S110
92+
pass # Column already exists on migrated DBs
7793
# Add indexes (safe to re-run)
7894
await self._db.execute(
7995
"CREATE UNIQUE INDEX IF NOT EXISTS idx_ideas_content_hash "
@@ -107,8 +123,8 @@ async def save_idea(self, idea: Idea) -> Idea:
107123
"""INSERT OR REPLACE INTO ideas
108124
(id, name, tagline, description, category, market_analysis,
109125
feasibility_score, mvp_scope, tech_stack, generated_at, status,
110-
github_issue_url, project_repo_url, content_hash)
111-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
126+
github_issue_url, project_repo_url, content_hash, source_url)
127+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
112128
(
113129
idea.id,
114130
idea.name,
@@ -124,6 +140,7 @@ async def save_idea(self, idea: Idea) -> Idea:
124140
idea.github_issue_url,
125141
idea.project_repo_url,
126142
content_hash,
143+
idea.source_url,
127144
),
128145
)
129146
await self.db.commit()
@@ -300,6 +317,7 @@ async def get_stats(self) -> dict:
300317

301318
@staticmethod
302319
def _row_to_idea(row) -> Idea:
320+
keys = row.keys() if hasattr(row, "keys") else []
303321
return Idea(
304322
id=row["id"],
305323
name=row["name"],
@@ -316,4 +334,67 @@ def _row_to_idea(row) -> Idea:
316334
status=row["status"],
317335
github_issue_url=row["github_issue_url"],
318336
project_repo_url=row["project_repo_url"],
337+
source_url=row["source_url"] if "source_url" in keys else None,
338+
)
339+
340+
# === RESOURCE CRUD ===
341+
342+
async def save_resource(self, resource: Resource) -> Resource:
343+
await self.db.execute(
344+
"""INSERT OR REPLACE INTO resources
345+
(id, domain, name, description, url, categories, idea_count, added_at)
346+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
347+
(
348+
resource.id,
349+
resource.domain,
350+
resource.name,
351+
resource.description,
352+
resource.url,
353+
json.dumps(resource.categories),
354+
resource.idea_count,
355+
resource.added_at.isoformat(),
356+
),
357+
)
358+
await self.db.commit()
359+
return resource
360+
361+
async def get_resource(self, resource_id: str) -> Resource | None:
362+
cursor = await self.db.execute("SELECT * FROM resources WHERE id = ?", (resource_id,))
363+
row = await cursor.fetchone()
364+
if not row:
365+
return None
366+
return self._row_to_resource(row)
367+
368+
async def get_resource_by_domain(self, domain: str) -> Resource | None:
369+
cursor = await self.db.execute("SELECT * FROM resources WHERE domain = ?", (domain,))
370+
row = await cursor.fetchone()
371+
if not row:
372+
return None
373+
return self._row_to_resource(row)
374+
375+
async def list_resources(self) -> list[Resource]:
376+
cursor = await self.db.execute("SELECT * FROM resources ORDER BY added_at DESC")
377+
rows = await cursor.fetchall()
378+
return [self._row_to_resource(row) for row in rows]
379+
380+
async def increment_resource_idea_count(self, domain: str) -> None:
381+
await self.db.execute(
382+
"UPDATE resources SET idea_count = idea_count + 1 WHERE domain = ?",
383+
(domain,),
384+
)
385+
await self.db.commit()
386+
387+
@staticmethod
388+
def _row_to_resource(row) -> Resource:
389+
return Resource(
390+
id=row["id"],
391+
domain=row["domain"],
392+
name=row["name"],
393+
description=row["description"],
394+
url=row["url"],
395+
categories=json.loads(row["categories"]),
396+
idea_count=row["idea_count"],
397+
added_at=datetime.fromisoformat(row["added_at"]).replace(tzinfo=UTC)
398+
if "+" not in row["added_at"]
399+
else datetime.fromisoformat(row["added_at"]),
319400
)

src/project_forge/web/app.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,38 @@ async def lifespan(app: FastAPI):
6666
app.include_router(router)
6767

6868

69+
def create_app(db_path=None):
70+
"""Create a test-friendly app instance with an isolated database."""
71+
from project_forge.storage.db import Database as DB
72+
73+
test_db = DB(db_path or settings.db_path)
74+
75+
@asynccontextmanager
76+
async def test_lifespan(application: FastAPI):
77+
await test_db.connect()
78+
# Swap the module-level db reference so routes use the test DB
79+
import project_forge.web.app as app_mod
80+
81+
old_db = app_mod.db
82+
app_mod.db = test_db
83+
# Also patch into routes module (imported from app)
84+
import project_forge.web.routes as routes_mod
85+
86+
old_routes_db = routes_mod.db
87+
routes_mod.db = test_db
88+
yield
89+
await test_db.close()
90+
app_mod.db = old_db
91+
routes_mod.db = old_routes_db
92+
93+
test_app = FastAPI(lifespan=test_lifespan)
94+
test_app.add_middleware(CSPMiddleware)
95+
from project_forge.web.routes import router as r
96+
97+
test_app.include_router(r)
98+
return test_app
99+
100+
69101
def run():
70102
"""Entry point for forge-serve command."""
71103
import uvicorn

src/project_forge/web/routes.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi.responses import HTMLResponse
55

66
from project_forge.engine.scorer import score_summary
7-
from project_forge.models import IdeaCategory, IdeaStatus
7+
from project_forge.models import IdeaCategory, IdeaStatus, Resource, UrlIngestRequest
88
from project_forge.web.app import db, templates
99

1010
router = APIRouter()
@@ -216,3 +216,37 @@ async def api_ideas(
216216
async def api_search(q: str = Query(min_length=1), limit: int = Query(default=20, ge=1, le=100)):
217217
ideas = await db.search_ideas(q, limit=limit)
218218
return {"ideas": [i.model_dump() for i in ideas], "total": len(ideas)}
219+
220+
221+
# === URL INGESTION & RESOURCE ROUTES ===
222+
223+
224+
async def ingest_idea_from_url(request_body: UrlIngestRequest):
225+
"""Fetch URL, extract content, and generate an idea. Module-level for patching in tests."""
226+
from project_forge.engine.url_ingest import fetch_url_content, generate_idea_from_url
227+
228+
content = await fetch_url_content(request_body.url)
229+
idea = await generate_idea_from_url(content, category_hint=request_body.category)
230+
return idea
231+
232+
233+
@router.post("/api/ideas/from-url")
234+
async def ingest_url(request_body: UrlIngestRequest):
235+
"""Generate a project idea from a URL."""
236+
idea = await ingest_idea_from_url(request_body)
237+
await db.save_idea(idea)
238+
return idea.model_dump()
239+
240+
241+
@router.get("/api/resources")
242+
async def list_resources():
243+
"""List all tracked source resources."""
244+
resources = await db.list_resources()
245+
return {"resources": [r.model_dump() for r in resources]}
246+
247+
248+
@router.post("/api/resources")
249+
async def add_resource(resource: Resource):
250+
"""Add or update a source resource."""
251+
saved = await db.save_resource(resource)
252+
return saved.model_dump()

0 commit comments

Comments
 (0)