Skip to content

Commit c778abe

Browse files
authored
Merge pull request MarcellM01#14 from benmaster82/feature/scrape-endpoint
Add /scrape endpoint and scrape_url MCP tool for direct URL inspection
2 parents 5f9dbaa + eb0bbdf commit c778abe

14 files changed

Lines changed: 2098 additions & 147 deletions

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,16 @@ Stop and remove the containers later with:
6060
docker compose -f "https://github.com/MarcellM01/TinySearch.git#main:compose.quickstart.yaml" down
6161
```
6262

63-
TinySearch exposes one MCP tool:
63+
TinySearch exposes two MCP tools:
6464

6565
```text
6666
research(query)
67+
scrape_url(url, query, max_tokens=4000)
6768
```
6869

69-
Pass the user's question as-is. TinySearch searches, crawls, reranks, and
70+
Pass the user's question as-is. `research` searches, crawls, reranks, and
71+
returns the grounded prompt in `answer`. `scrape_url` inspects a specific URL
72+
the caller already knows, applies the same ranking and token budget, and
7073
returns the grounded prompt in `answer`.
7174

7275
## Community
@@ -279,8 +282,38 @@ Endpoints:
279282
- `GET /health`
280283
- `GET /web_search?query=...`
281284
- `POST /site_crawl`
285+
- `POST /scrape`
282286
- `POST /research`
283287

288+
`POST /scrape` accepts a JSON body with `url` (required), `query` (required,
289+
non-empty), `max_tokens` (optional, default 4000) and `include_metadata`
290+
(optional, default true). The response includes a `URL-GROUNDED ANSWER PROMPT`
291+
in `answer`, plus `content_tokens`, `answer_tokens`, `truncated`, `url`,
292+
`title`, `retrieved_at` (aware UTC) and best-effort `metadata`
293+
(`description`, `author`, `published_date`).
294+
295+
Errors return `{"detail": {"code", "message"}}` with stable codes:
296+
`invalid_url` (400), `blocked_url` (403), `unsupported_document` (415),
297+
`empty_content` (422), `fetch_failed` (502), `fetch_timeout` (504).
298+
299+
### URL safety
300+
301+
`/scrape` and `scrape_url` accept arbitrary user-supplied URLs and enforce
302+
the following checks before fetching:
303+
304+
- only `http` and `https` schemes
305+
- URLs with embedded credentials are rejected
306+
- IP literals and resolved addresses that are loopback, private, link-local,
307+
multicast, reserved or unspecified are rejected (DNS rebinding is mitigated
308+
by rejecting if **any** resolved address is non-public, not just one)
309+
- the configured `blocked_domains` list is applied to both the initial URL
310+
and the final URL reported by the crawler after redirects
311+
312+
Crawl4AI does not expose intermediate redirect hops, so the safety check runs
313+
on the initial URL and the final URL. If you need stricter handling for
314+
redirect chains, run TinySearch behind an egress proxy that enforces your
315+
policy.
316+
284317
## Configuration
285318

286319
Tune research defaults in `configs/research_config.json`. Set

pipelines/agentic_research.py

Lines changed: 5 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
resolve_embedding_tokenizer_name,
3737
)
3838
from services.chunk_pool_selection_service import select_chunks_with_quota_and_fill
39+
from services.grounded_prompt_service import format_search_grounded_prompt
3940
from services.hybrid_embed_search_service import EmbeddingFn, rank_chunks_hybrid
4041
from services.research_config_service import (
4142
config_trace_path,
@@ -59,8 +60,6 @@
5960
_HTTP_URL = re.compile(r"^https?://", re.IGNORECASE)
6061
DEFAULT_DENSE_QUERY_PREFIX = "task: search result | query: "
6162
DEFAULT_DENSE_DOCUMENT_PREFIX = "title: none | text: "
62-
PROMPT_RULE = "=" * 88
63-
FIELD_RULE = "======"
6463

6564

6665
@dataclass(frozen=True)
@@ -130,119 +129,6 @@ def _search_chunk(result: SearchResult) -> dict[str, Any]:
130129
}
131130

132131

133-
def _format_relevant_text(chunks: Sequence[dict[str, Any]]) -> str:
134-
blocks: list[str] = []
135-
for ordinal, chunk in enumerate(chunks, start=1):
136-
text = str(chunk.get("text") or "").strip()
137-
if not text:
138-
continue
139-
blocks.extend(
140-
[
141-
f"----- RELEVANT CHUNK {ordinal} -----",
142-
text,
143-
]
144-
)
145-
return "\n".join(blocks).strip()
146-
147-
148-
def _format_results_prompt(
149-
*,
150-
question: str,
151-
results: Sequence[dict[str, Any]],
152-
today: str | None = None,
153-
) -> str:
154-
clean_question = question.strip()
155-
today_text = today or datetime.now(UTC).date().isoformat()
156-
lines = [
157-
PROMPT_RULE,
158-
"SEARCH-GROUNDED ANSWER PROMPT",
159-
PROMPT_RULE,
160-
"",
161-
"QUESTION",
162-
PROMPT_RULE,
163-
clean_question,
164-
PROMPT_RULE,
165-
"",
166-
"TODAY",
167-
PROMPT_RULE,
168-
today_text,
169-
PROMPT_RULE,
170-
"",
171-
"CRITICAL INSTRUCTIONS",
172-
PROMPT_RULE,
173-
"You are answering the QUESTION using only the text under RESULTS.",
174-
"First resolve any relative date in the QUESTION using TODAY.",
175-
f"TODAY is {today_text!r}.",
176-
f"For example, 'last year' means calendar year {int(today_text.split('-')[0]) - 1}.",
177-
"Use only facts directly supported by RESULTS.",
178-
"Do not use your own knowledge.",
179-
"Do not add extra historical claims unless directly supported by RESULTS.",
180-
"Do not infer 'first ever', 'most recent', 'record', or franchise history unless RESULTS explicitly support it.",
181-
"If RESULTS contain conflicting information, prefer the result that directly matches the resolved date and question.",
182-
"If the conflict cannot be resolved, say the results conflict.",
183-
"Cite the source URL after each factual claim.",
184-
"If the answer is not directly supported by RESULTS, say the results are not enough.",
185-
PROMPT_RULE,
186-
"",
187-
PROMPT_RULE,
188-
"RESULTS",
189-
PROMPT_RULE,
190-
"",
191-
]
192-
193-
for ordinal, result in enumerate(results, start=1):
194-
relevant_text = _format_relevant_text(result.get("ranked_chunks") or [])
195-
lines.extend(
196-
[
197-
PROMPT_RULE,
198-
f"RESULT {ordinal}",
199-
PROMPT_RULE,
200-
f"TITLE {ordinal}",
201-
FIELD_RULE,
202-
str(result["title"]).strip(),
203-
FIELD_RULE,
204-
f"URL {ordinal}",
205-
FIELD_RULE,
206-
str(result["url"]).strip(),
207-
FIELD_RULE,
208-
f"SEARCH PREVIEW {ordinal}",
209-
FIELD_RULE,
210-
str(result.get("snippet") or "").strip(),
211-
FIELD_RULE,
212-
]
213-
)
214-
if relevant_text:
215-
lines.extend(
216-
[
217-
f"RELEVANT TEXT {ordinal}",
218-
FIELD_RULE,
219-
relevant_text,
220-
FIELD_RULE,
221-
]
222-
)
223-
lines.append("")
224-
225-
lines.extend(
226-
[
227-
PROMPT_RULE,
228-
"QUESTION",
229-
PROMPT_RULE,
230-
clean_question,
231-
PROMPT_RULE,
232-
"",
233-
"TODAY",
234-
PROMPT_RULE,
235-
today_text,
236-
PROMPT_RULE,
237-
"",
238-
PROMPT_RULE,
239-
"SEARCH-GROUNDED ANSWER PROMPT",
240-
PROMPT_RULE,
241-
]
242-
)
243-
return "\n".join(lines).strip()
244-
245-
246132
async def _rank(
247133
*,
248134
query: str,
@@ -427,7 +313,7 @@ def finish(status: str, answer: str, crawl_errors: Sequence[str]) -> AgenticResu
427313
except SearchBackendError as exc:
428314
_agentic_log(f"search backend error: {exc}")
429315
await emit("search_backend_error", error=str(exc))
430-
prompt = _format_results_prompt(question=query, results=[])
316+
prompt = format_search_grounded_prompt(question=query, results=[])
431317
return finish("search_backend_error", prompt, [])
432318
results = [result for result in raw_results if _is_http_url(result.url)]
433319
results = filter_blocked_search_results(results, blocked_domains or [])
@@ -436,7 +322,7 @@ def finish(status: str, answer: str, crawl_errors: Sequence[str]) -> AgenticResu
436322
await emit("search_results", results_count=len(results))
437323

438324
if not results:
439-
prompt = _format_results_prompt(question=query, results=[])
325+
prompt = format_search_grounded_prompt(question=query, results=[])
440326
return finish("no_search_results", prompt, [])
441327

442328
tokenizer_name = (
@@ -593,13 +479,13 @@ async def crawl_result(search_doc: dict[str, Any]) -> dict[str, Any]:
593479
chunks_in_prompt=len(ranked_chunk_pool),
594480
crawl_errors_count=len(crawl_errors),
595481
)
596-
prompt = _format_results_prompt(question=query, results=crawled_results)
482+
prompt = format_search_grounded_prompt(question=query, results=crawled_results)
597483
await emit("done", results_count=len(crawled_results), crawl_errors_count=len(crawl_errors))
598484
_agentic_log(f"done results={len(crawled_results)} crawl_errors={len(crawl_errors)}")
599485
return finish("ok", prompt, crawl_errors)
600486
except TimeoutError:
601487
_agentic_log(f"timeout query={query!r} limit_s={pipeline_timeout_seconds}")
602-
prompt = _format_results_prompt(question=query, results=[])
488+
prompt = format_search_grounded_prompt(question=query, results=[])
603489
return finish("timeout", prompt, [])
604490

605491

servers/fastapi_server.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
if str(_PROJECT_ROOT) not in sys.path:
1212
sys.path.insert(0, str(_PROJECT_ROOT))
1313

14-
from fastapi import FastAPI
14+
from fastapi import FastAPI, HTTPException
1515
from pydantic import BaseModel, Field, HttpUrl
1616

1717
from pipelines.agentic_research import agentic_run
@@ -23,7 +23,14 @@
2323
research_run_kwargs,
2424
research_tokenizer_name,
2525
)
26+
from services.scrape_service import (
27+
DEFAULT_SCRAPE_MAX_TOKENS,
28+
SCRAPE_ERROR_MAP,
29+
ScrapeError,
30+
scrape_url,
31+
)
2632
from services.site_crawl_service import crawl_search
33+
from services.url_safety_service import BlockedUrlError, InvalidUrlError
2734
from services.web_search_service import (
2835
filter_blocked_search_results,
2936
search,
@@ -76,6 +83,13 @@ class SiteCrawlRequest(BaseModel):
7683
encoding_name: str | None = None
7784

7885

86+
class ScrapeRequest(BaseModel):
87+
url: HttpUrl
88+
query: str = Field(..., min_length=1)
89+
max_tokens: int = Field(default=DEFAULT_SCRAPE_MAX_TOKENS, ge=1, le=200_000)
90+
include_metadata: bool = True
91+
92+
7993
class ResearchRequest(BaseModel):
8094
query: str = Field(..., min_length=1)
8195
search_top_k: int | None = Field(default=None, ge=1, le=50)
@@ -178,6 +192,56 @@ async def site_crawl_get(
178192
)
179193

180194

195+
def _raise_scrape_http_error(exc: Exception) -> None:
196+
mapping = SCRAPE_ERROR_MAP.get(type(exc))
197+
if mapping is None:
198+
raise HTTPException(
199+
status_code=500,
200+
detail={"code": "internal_error", "message": "internal error"},
201+
) from exc
202+
code, status_code = mapping
203+
raise HTTPException(
204+
status_code=status_code,
205+
detail={"code": code, "message": str(exc)},
206+
) from exc
207+
208+
209+
@app.post("/scrape")
210+
async def scrape_endpoint(request: ScrapeRequest) -> dict[str, Any]:
211+
config = load_research_config()
212+
await _ensure_local_bundle_for_config(config)
213+
tokenizer = research_tokenizer_name(config)
214+
try:
215+
result = await scrape_url(
216+
str(request.url),
217+
request.query,
218+
max_tokens=request.max_tokens,
219+
include_metadata=request.include_metadata,
220+
config=config,
221+
tokenizer_name=tokenizer,
222+
)
223+
except (InvalidUrlError, BlockedUrlError, ScrapeError) as exc:
224+
_raise_scrape_http_error(exc)
225+
return result.to_response(include_metadata=request.include_metadata)
226+
227+
228+
@app.get("/scrape")
229+
async def scrape_get(
230+
url: HttpUrl,
231+
query: str,
232+
max_tokens: int = DEFAULT_SCRAPE_MAX_TOKENS,
233+
include_metadata: bool = True,
234+
) -> dict[str, Any]:
235+
return await scrape_endpoint(
236+
ScrapeRequest(
237+
url=url,
238+
query=query,
239+
max_tokens=max_tokens,
240+
include_metadata=include_metadata,
241+
)
242+
)
243+
244+
181245
@app.post("/research")
182246
async def research_endpoint(request: ResearchRequest) -> dict[str, Any]:
183247
config = load_research_config()

0 commit comments

Comments
 (0)