" not in detail["descriptionText"] # tags stripped
+ assert detail["title"]
+ assert detail["company"]
+ assert detail["formattedLocation"]
+ assert detail["jobTypes"] == ["Full-time"]
+ assert detail["benefits"] == ["401(k)", "Health insurance"]
+ assert detail["isRemote"] is True
+ assert detail["remoteType"] == "HYBRID"
+ sal = detail["salary"]
+ assert (sal["salaryMin"], sal["salaryMax"], sal["period"]) == (60000, 90000, "year")
+
+
+def test_parse_job_detail_reads_rootprops_shape():
+ # Live /viewjob assigns window._rootProps (JSON) with the model under
+ # preloadedVJData; window._initialData is now a non-JSON JS literal that
+ # references other globals and must be skipped, not misparsed.
+ html = (
+ ""
+ ''
+ )
+ detail = parse_job_detail(html)
+ assert detail["title"] == "Data Analyst"
+ assert detail["company"] == "Acme"
+ assert detail["descriptionHtml"] == "
Full JD text
"
+ assert detail["descriptionText"] == "Full JD text"
+
+
+def test_parse_job_detail_omits_blank_fields():
+ # A page with a header but no salary/description must not emit those keys,
+ # so a merge won't clobber listing values with blanks.
+ html = (
+ "'
+ )
+ detail = parse_job_detail(html)
+ assert detail == {"title": "Analyst"}
+
+
+def test_parse_job_detail_not_a_job_page_returns_empty():
+ assert parse_job_detail("just a moment...") == {}
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
new file mode 100644
index 000000000..1d815ce98
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
@@ -0,0 +1,155 @@
+"""Offline orchestration tests: pagination, dedupe, and caps via a fake session."""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from app.proprietary.platforms.indeed_jobs.fetch import IndeedAccessBlockedError
+from app.proprietary.platforms.indeed_jobs.schemas import IndeedScrapeInput
+from app.proprietary.platforms.indeed_jobs.scraper import iter_indeed, scrape_indeed
+
+
+def _page_html(job_keys: list[str]) -> str:
+ """Wrap job keys in the ``mosaic-provider-jobcards`` assignment shape."""
+ results = [
+ {"jobkey": k, "displayTitle": f"Job {k}", "company": "Acme"} for k in job_keys
+ ]
+ model = {"metaData": {"mosaicProviderJobCardsModel": {"results": results}}}
+ return (
+ f'window.mosaic.providerData["mosaic-provider-jobcards"]={json.dumps(model)};'
+ )
+
+
+def _detail_html(job_key: str) -> str:
+ """A minimal /viewjob page carrying a full description for ``job_key``."""
+ data = {
+ "jobInfoWrapperModel": {
+ "jobInfoModel": {
+ "sanitizedJobDescription": f"
Full description for {job_key}
",
+ "jobInfoHeaderModel": {"jobTitle": f"Detailed {job_key}"},
+ }
+ }
+ }
+ return f""
+
+
+class _FakeSession:
+ """Returns per-``start`` search pages (or a /viewjob detail) and records URLs."""
+
+ def __init__(
+ self, pages: dict[int, list[str]], blocked_starts: set[int] | None = None
+ ) -> None:
+ self._pages = pages
+ self._blocked = blocked_starts or set()
+ self.fetched: list[str] = []
+
+ async def fetch_html(self, url: str, *, max_rotations: int | None = None) -> str:
+ self.fetched.append(url)
+ query = parse_qs(urlparse(url).query)
+ if "/viewjob" in url:
+ return _detail_html(query.get("jk", [""])[0])
+ start = int(query.get("start", ["0"])[0])
+ if start in self._blocked:
+ raise IndeedAccessBlockedError(f"gated at start={start}")
+ return _page_html(self._pages.get(start, []))
+
+
+async def _collect(input_model, session) -> list[dict]:
+ return [item async for item in iter_indeed(input_model, session)]
+
+
+@pytest.mark.asyncio
+async def test_dedupes_within_page():
+ session = _FakeSession({0: ["k1", "k2", "k2", "k3"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2", "k3"]
+ assert all(i["scrapedAt"] for i in items) # stamped by the orchestrator
+
+
+@pytest.mark.asyncio
+async def test_does_not_fetch_deeper_pages():
+ # First page only; ``start>=10`` must never be requested.
+ session = _FakeSession({0: ["k1", "k2"], 10: ["k3"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+ assert all("start=" not in u for u in session.fetched)
+
+
+@pytest.mark.asyncio
+async def test_page_block_propagates():
+ # Nothing yielded before the block, so it surfaces as an error.
+ session = _FakeSession({}, blocked_starts={0})
+ with pytest.raises(IndeedAccessBlockedError):
+ await _collect(IndeedScrapeInput(queries=["dev"]), session)
+
+
+@pytest.mark.asyncio
+async def test_respects_max_items_per_query():
+ session = _FakeSession({0: ["k1", "k2", "k3", "k4"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=2), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+
+
+@pytest.mark.asyncio
+async def test_global_dedupe_across_queries():
+ # Both queries hit page 0 (same fake pages) and return the same keys.
+ session = _FakeSession({0: ["k1", "k2"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev", "engineer"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+
+
+@pytest.mark.asyncio
+async def test_start_urls_scrape_search_and_job_url_detail():
+ session = _FakeSession({0: ["k1"]})
+ input_model = IndeedScrapeInput(
+ startUrls=[
+ {"url": "https://www.indeed.com/jobs?q=dev"},
+ {"url": "https://www.indeed.com/viewjob?jk=abc"},
+ ],
+ maxItemsPerQuery=100,
+ )
+ items = await _collect(input_model, session)
+ assert len(items) == 2
+ search_item, job_item = items
+ assert search_item["jobKey"] == "k1"
+ # The /viewjob URL is scraped from its detail page alone.
+ assert job_item["jobUrl"].endswith("jk=abc")
+ assert job_item["title"] == "Detailed abc"
+ assert "Full description for abc" in job_item["descriptionText"]
+
+
+@pytest.mark.asyncio
+async def test_scrape_job_details_enriches_listing_items():
+ session = _FakeSession({0: ["k1", "k2"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100, scrapeJobDetails=True),
+ session,
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+ for it in items:
+ assert it["descriptionHtml"].startswith("
Full description for")
+ assert "Full description for" in it["descriptionText"]
+ # One extra /viewjob load per listing item.
+ assert sum("/viewjob" in u for u in session.fetched) == 2
+
+
+@pytest.mark.asyncio
+async def test_scrape_indeed_limit_with_injected_session():
+ session = _FakeSession({0: ["k1", "k2", "k3"]})
+ items = await scrape_indeed(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100),
+ limit=2,
+ session=session,
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py
new file mode 100644
index 000000000..b995b3d0b
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py
@@ -0,0 +1,87 @@
+"""Offline tests for Indeed URL classification and search-URL building."""
+
+from __future__ import annotations
+
+from urllib.parse import parse_qs, urlparse
+
+from app.proprietary.platforms.indeed_jobs.url_resolver import (
+ build_search_url,
+ country_domain,
+ resolve_url,
+)
+
+
+def test_resolve_search_url():
+ r = resolve_url(
+ "https://www.indeed.com/jobs?q=software+engineer&l=Remote&sort=date"
+ )
+ assert r is not None
+ assert r.kind == "search"
+ assert r.value == "software engineer"
+ assert r.location == "Remote"
+ assert r.domain == "www.indeed.com"
+ assert r.params.get("sort") == "date"
+
+
+def test_resolve_company_url():
+ r = resolve_url("https://www.indeed.com/cmp/Google/jobs")
+ assert r is not None
+ assert r.kind == "company"
+ assert r.value == "Google"
+
+
+def test_resolve_viewjob_url():
+ r = resolve_url("https://uk.indeed.com/viewjob?jk=abc123&from=serp")
+ assert r is not None
+ assert r.kind == "job"
+ assert r.value == "abc123"
+ assert r.domain == "uk.indeed.com"
+
+
+def test_resolve_country_subdomain_host():
+ r = resolve_url("https://de.indeed.com/jobs?q=entwickler")
+ assert r is not None
+ assert r.kind == "search"
+ assert r.domain == "de.indeed.com"
+
+
+def test_resolve_rejects_non_indeed():
+ assert resolve_url("https://www.linkedin.com/jobs?q=dev") is None
+ assert resolve_url("https://notindeed.com.evil.com/jobs") is None
+
+
+def test_country_domain_map():
+ assert country_domain("us") == "www.indeed.com"
+ assert country_domain("gb") == "uk.indeed.com"
+ assert country_domain("de") == "de.indeed.com"
+ assert country_domain("") == "www.indeed.com"
+
+
+def test_build_search_url_basic():
+ url = build_search_url(
+ "data analyst",
+ country="us",
+ location="New York, NY",
+ sort="date",
+ start=20,
+ )
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ assert parsed.netloc == "www.indeed.com"
+ assert parsed.path == "/jobs"
+ assert qs["q"] == ["data analyst"]
+ assert qs["l"] == ["New York, NY"]
+ assert qs["sort"] == ["date"]
+ assert qs["start"] == ["20"]
+
+
+def test_build_search_url_remote_keyword_fallback_and_jobtype():
+ url = build_search_url(
+ "developer", country="gb", remote="remote", job_type="fulltime", from_days=7
+ )
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ assert parsed.netloc == "uk.indeed.com"
+ assert qs["q"] == ["developer remote"]
+ assert qs["jt"] == ["fulltime"]
+ assert qs["fromage"] == ["7"]
diff --git a/surfsense_mcp/mcp_server/features/scrapers/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
index 524250060..c74626d41 100644
--- a/surfsense_mcp/mcp_server/features/scrapers/__init__.py
+++ b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
@@ -1,8 +1,8 @@
"""Scraper tools: one MCP surface per SurfSense platform capability.
-Web crawl, Google Search, Reddit, YouTube, Google Maps, Amazon, and Walmart each
-get a tool that maps a natural-language request to the workspace's scraper. Two
-run-history tools
+Web crawl, Google Search, Reddit, YouTube, Google Maps, Amazon, Indeed, and
+Walmart each get a tool that maps a natural-language request to the workspace's
+scraper. Two run-history tools
list and fetch past runs, so a large result truncated inline can be retrieved in
full later. Each platform lives in its own module under platforms/.
"""
@@ -18,6 +18,7 @@ from .platforms import (
amazon,
google_maps,
google_search,
+ indeed,
instagram,
reddit,
tiktok,
@@ -34,6 +35,7 @@ _REGISTRARS = (
instagram,
tiktok,
google_maps,
+ indeed,
amazon,
walmart,
run_history,
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py
new file mode 100644
index 000000000..6ea0007f9
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py
@@ -0,0 +1,135 @@
+"""Indeed scraper tool."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+IndeedSort = Literal["relevance", "date"]
+IndeedJobType = Literal[
+ "fulltime",
+ "parttime",
+ "contract",
+ "internship",
+ "temporary",
+ "permanent",
+ "seasonal",
+ "freelance",
+]
+IndeedLevel = Literal["entry_level", "mid_level", "senior_level"]
+IndeedRemote = Literal["remote", "hybrid"]
+
+
+def register(mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext) -> None:
+ """Register the Indeed tool."""
+
+ @mcp.tool(
+ name="surfsense_indeed_scrape",
+ title="Search or scrape Indeed jobs",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def indeed_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Indeed URLs: a search page "
+ "('https://www.indeed.com/jobs?q=data+analyst'), a company jobs "
+ "page ('/cmp/
/jobs'), or a single job ('/viewjob?jk=...'). "
+ "Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Job search terms, e.g. ['data analyst', 'ml engineer']. "
+ "Provide search_queries OR urls."
+ ),
+ ] = None,
+ country: Annotated[
+ str,
+ Field(description="Country code selecting the Indeed domain, e.g. 'us', 'gb'."),
+ ] = "us",
+ location: Annotated[
+ str | None,
+ Field(description="Where to search, e.g. 'Remote', 'New York, NY'."),
+ ] = None,
+ radius: Annotated[
+ int | None,
+ Field(description="Search radius in miles/km around location."),
+ ] = None,
+ job_type: Annotated[
+ IndeedJobType | None,
+ Field(description="Employment type filter."),
+ ] = None,
+ level: Annotated[
+ IndeedLevel | None,
+ Field(description="Experience level filter."),
+ ] = None,
+ remote: Annotated[
+ IndeedRemote | None,
+ Field(description="Work model filter: remote or hybrid."),
+ ] = None,
+ from_days: Annotated[
+ int | None,
+ Field(description="Only return jobs posted within the last N days."),
+ ] = None,
+ sort: Annotated[
+ IndeedSort, Field(description="Result ordering: relevance or date.")
+ ] = "relevance",
+ scrape_job_details: Annotated[
+ bool,
+ Field(
+ description="True fetches each job's detail page for the full "
+ "description (slower); False returns the listing snippet only."
+ ),
+ ] = False,
+ max_items: Annotated[
+ int, Field(ge=1, description="Maximum jobs to return in total.")
+ ] = 25,
+ max_items_per_query: Annotated[
+ int, Field(ge=0, description="Max jobs per search/company target.")
+ ] = 25,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape public Indeed job postings.
+
+ Use this for ANY Indeed job research — openings for a role, who is hiring
+ at a company, salaries for a title in a location, or remote roles —
+ instead of a generic web search. Returns jobs with title, company,
+ location, salary, job types, and description; set scrape_job_details for
+ the full description per job.
+ Example: search_queries=['data analyst'], location='Remote', max_items=30.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="indeed",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "country": country,
+ "location": location,
+ "radius": radius,
+ "job_type": job_type,
+ "level": level,
+ "remote": remote,
+ "from_days": from_days,
+ "sort": sort,
+ "scrape_job_details": scrape_job_details,
+ "max_items": max_items,
+ "max_items_per_query": max_items_per_query,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
diff --git a/surfsense_mcp/mcp_server/selfcheck.py b/surfsense_mcp/mcp_server/selfcheck.py
index ffce29058..986caf9a5 100644
--- a/surfsense_mcp/mcp_server/selfcheck.py
+++ b/surfsense_mcp/mcp_server/selfcheck.py
@@ -32,6 +32,7 @@ EXPECTED_TOOLS = {
"surfsense_amazon_scrape",
"surfsense_instagram_scrape",
"surfsense_instagram_details",
+ "surfsense_indeed_scrape",
"surfsense_list_scraper_runs",
"surfsense_get_scraper_run",
# knowledge-base management
diff --git a/surfsense_mcp/mcp_server/server.py b/surfsense_mcp/mcp_server/server.py
index f801aeb15..ef5f15c8a 100644
--- a/surfsense_mcp/mcp_server/server.py
+++ b/surfsense_mcp/mcp_server/server.py
@@ -38,7 +38,8 @@ def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
"task involves Reddit (posts, comments, finding subreddits or "
"communities), YouTube (videos, transcripts, comments), Instagram "
"(posts, reels, profile details), TikTok (videos by hashtag, "
- "search, or URL), Google Maps (places, reviews), Google Search "
+ "search, or URL), Google Maps (places, reviews), Indeed (job "
+ "postings by role, company, or location), Google Search "
"results, or reading "
"specific web pages. Scraper results are persisted as runs; if an "
"inline result is truncated, fetch it in full with "
diff --git a/surfsense_web/content/docs/connectors/index.mdx b/surfsense_web/content/docs/connectors/index.mdx
index eb3f28052..feab71819 100644
--- a/surfsense_web/content/docs/connectors/index.mdx
+++ b/surfsense_web/content/docs/connectors/index.mdx
@@ -12,7 +12,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
}
title="Native Connectors"
- description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, and Web Crawl — usable in chat, the API Playground, or via REST"
+ description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, Amazon, Walmart, and Web Crawl — usable in chat, the API Playground, or via REST"
href="/docs/connectors/native"
/>
/jobs`), or a single job (`/viewjob?jk=`) (max 20 sources per call, combined with queries) |
+| `search_queries` | — | Job search terms, e.g. `["data analyst"]` |
+| `country` | `us` | Country code selecting the Indeed domain, e.g. `us`, `gb`, `de` |
+| `location` | — | Where to search, e.g. `Remote`, `New York, NY` |
+| `radius` | — | Search radius in miles/km around `location` |
+| `job_type` | — | `fulltime`, `parttime`, `contract`, `internship`, `temporary`, `permanent`, `seasonal`, or `freelance` |
+| `level` | — | `entry_level`, `mid_level`, or `senior_level` |
+| `remote` | — | `remote` or `hybrid` |
+| `from_days` | — | Only return jobs posted within the last N days |
+| `sort` | `relevance` | `relevance` or `date` |
+| `scrape_job_details` | `false` | Fetch each job's detail page for the full description (slower: one extra page load per job) |
+| `max_items` | `25` | Max total jobs returned across all sources (hard cap 100) |
+| `max_items_per_query` | `25` | Max jobs per search/company target |
+
+## Example
+
+```bash
+curl -X POST "$BASE_URL/api/v1/workspaces/1/scrapers/indeed/scrape" \
+ -H "Authorization: Bearer $SURFSENSE_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "search_queries": ["data analyst"],
+ "location": "Remote",
+ "remote": "remote",
+ "sort": "date",
+ "max_items": 30
+ }'
+```
+
+The response is `{ "items": [...] }` — one item per job posting. Billing is per returned job.
+
+For the full input and output JSON schemas and generated code snippets in your language, open **API Playground → Indeed → Scrape** in your workspace.
diff --git a/surfsense_web/content/docs/connectors/native/index.mdx b/surfsense_web/content/docs/connectors/native/index.mdx
index 36d7e036b..4daf55682 100644
--- a/surfsense_web/content/docs/connectors/native/index.mdx
+++ b/surfsense_web/content/docs/connectors/native/index.mdx
@@ -1,6 +1,6 @@
---
title: Native Connectors
-description: SurfSense's built-in scraper APIs for Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, and the web
+description: SurfSense's built-in scraper APIs for Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, Amazon, Walmart, and the web
---
import { Card, Cards } from 'fumadocs-ui/components/card';
@@ -38,6 +38,11 @@ Native connectors are SurfSense's own scraper APIs — built into the platform,
description="Structured SERPs: organic results, people-also-ask, AI overviews"
href="/docs/connectors/native/google-search"
/>
+
/jobs), or a single job (/viewjob?jk=...). Max 20.",
+ },
+ {
+ name: "search_queries",
+ type: "string[]",
+ defaultValue: "[]",
+ description:
+ "Job search terms. Each returns up to max_items_per_query results, shaped by the filters below. Max 20.",
+ },
+ {
+ name: "country",
+ type: "string",
+ defaultValue: '"us"',
+ description: "Country code selecting the Indeed domain, e.g. 'us', 'gb', 'de'.",
+ },
+ {
+ name: "location",
+ type: "string",
+ description: "Where to search, e.g. 'Remote', 'New York, NY'.",
+ },
+ {
+ name: "radius",
+ type: "integer",
+ description: "Search radius in miles or km around location.",
+ },
+ {
+ name: "job_type",
+ type: "string",
+ description: "Employment type: fulltime, parttime, contract, internship, and more.",
+ },
+ {
+ name: "level",
+ type: "string",
+ description: "Experience level: entry_level, mid_level, or senior_level.",
+ },
+ {
+ name: "remote",
+ type: "string",
+ description: "Work model filter: remote or hybrid.",
+ },
+ {
+ name: "from_days",
+ type: "integer",
+ description: "Only return jobs posted within the last N days.",
+ },
+ {
+ name: "sort",
+ type: "string",
+ defaultValue: '"relevance"',
+ description: "Result ordering: relevance or date.",
+ },
+ {
+ name: "scrape_job_details",
+ type: "boolean",
+ defaultValue: "false",
+ description:
+ "Fetch each job's detail page for the full description. Slower: one extra page load per job.",
+ },
+ {
+ name: "max_items",
+ type: "integer",
+ defaultValue: "25",
+ description: "Max total jobs to return across all sources. 1 to 100.",
+ },
+ {
+ name: "max_items_per_query",
+ type: "integer",
+ defaultValue: "25",
+ description: "Max jobs to pull per search or company target.",
+ },
+ ],
+ responseNote:
+ "The response is { items: [...] } with one flat item per job. Fields Indeed omits are null. One returned job is one billable unit.",
+ response: [
+ {
+ name: "jobKey / jobUrl / applyUrl",
+ type: "string",
+ description: "Indeed job key, listing URL, and third-party apply URL.",
+ },
+ {
+ name: "title",
+ type: "string",
+ description: "The job title as posted.",
+ },
+ {
+ name: "company / companyUrl",
+ type: "string",
+ description: "Company name and its Indeed profile URL.",
+ },
+ {
+ name: "companyRating / companyReviewCount",
+ type: "number / integer",
+ description: "Employer star rating and number of reviews, where Indeed shows them.",
+ },
+ {
+ name: "formattedLocation / isRemote / remoteType",
+ type: "string / boolean",
+ description: "Location string plus remote and hybrid flags.",
+ },
+ {
+ name: "salary",
+ type: "object",
+ description:
+ "salaryText, salaryMin, salaryMax, currency, period, and isEstimated when the pay is an Indeed estimate.",
+ },
+ {
+ name: "jobTypes / benefits",
+ type: "string[]",
+ description: "Employment types and listed benefits parsed from the posting.",
+ },
+ {
+ name: "descriptionText / descriptionHtml",
+ type: "string",
+ description: "Snippet by default; the full description when scrape_job_details is set.",
+ },
+ {
+ name: "sponsored / urgentlyHiring / isNew / expired",
+ type: "boolean",
+ description: "Listing flags for ranking and filtering.",
+ },
+ {
+ name: "age / datePublished / scrapedAt",
+ type: "string",
+ description: "Relative post age, ISO publish date, and when the job was scraped.",
+ },
+ ],
+ },
+
+ faq: [
+ {
+ question: "Is scraping Indeed legal?",
+ answer:
+ "SurfSense reads only public Indeed job postings, the same listings any logged-out visitor can see. It never logs in and cannot access private or applicant data. As always, review Indeed's terms and your own compliance needs before you run at scale.",
+ },
+ {
+ question: "Does Indeed have an official jobs API?",
+ answer:
+ "Indeed retired its public Publisher jobs API and now gates job data behind partner and approval programs. SurfSense is an independent alternative: you call one API, or add the MCP server to your agent, and get structured public postings back.",
+ },
+ {
+ question: "Can I get the full job description?",
+ answer:
+ "Yes. By default each job returns the listing snippet, which is fast. Set scrape_job_details to true and SurfSense fetches each job's detail page for the full description text and HTML, at the cost of one extra page load per job.",
+ },
+ {
+ question: "What are the rate limits?",
+ answer:
+ "Each call returns up to 100 jobs across all sources, with up to 20 URLs or search queries per request. SurfSense manages the anti-bot request budget for you, so you scale reads without running proxies or a headless browser yourself.",
+ },
+ ],
+
+ related: [
+ { label: "Reddit API", href: "/reddit" },
+ { label: "YouTube API", href: "/youtube" },
+ { label: "Google Maps API", href: "/google-maps" },
+ { label: "SERP API", href: "/google-search" },
+ { label: "Web Crawl API", href: "/web-crawl" },
+ { label: "SurfSense MCP Server", href: "/mcp-server" },
+ ],
+};
diff --git a/surfsense_web/lib/connectors-marketing/index.ts b/surfsense_web/lib/connectors-marketing/index.ts
index 888b9852a..81fa98c54 100644
--- a/surfsense_web/lib/connectors-marketing/index.ts
+++ b/surfsense_web/lib/connectors-marketing/index.ts
@@ -1,6 +1,7 @@
import { amazon } from "./amazon";
import { googleMaps } from "./google-maps";
import { googleSearch } from "./google-search";
+import { indeed } from "./indeed";
import { instagram } from "./instagram";
import { reddit } from "./reddit";
import { tiktok } from "./tiktok";
@@ -19,6 +20,7 @@ const CONNECTOR_LIST: ConnectorPageContent[] = [
tiktok,
googleMaps,
googleSearch,
+ indeed,
amazon,
walmart,
webCrawl,
diff --git a/surfsense_web/lib/connectors-marketing/instagram.tsx b/surfsense_web/lib/connectors-marketing/instagram.tsx
index 17864cdba..8923fc9ab 100644
--- a/surfsense_web/lib/connectors-marketing/instagram.tsx
+++ b/surfsense_web/lib/connectors-marketing/instagram.tsx
@@ -288,6 +288,7 @@ export const instagram: ConnectorPageContent = {
{ label: "Reddit API", href: "/reddit" },
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/reddit.tsx b/surfsense_web/lib/connectors-marketing/reddit.tsx
index a300102d0..c10d9d201 100644
--- a/surfsense_web/lib/connectors-marketing/reddit.tsx
+++ b/surfsense_web/lib/connectors-marketing/reddit.tsx
@@ -315,6 +315,7 @@ export const reddit: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/tiktok.tsx b/surfsense_web/lib/connectors-marketing/tiktok.tsx
index dfaa4de73..89e352736 100644
--- a/surfsense_web/lib/connectors-marketing/tiktok.tsx
+++ b/surfsense_web/lib/connectors-marketing/tiktok.tsx
@@ -284,6 +284,7 @@ export const tiktok: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/web-crawl.tsx b/surfsense_web/lib/connectors-marketing/web-crawl.tsx
index 305698350..d7f3c4340 100644
--- a/surfsense_web/lib/connectors-marketing/web-crawl.tsx
+++ b/surfsense_web/lib/connectors-marketing/web-crawl.tsx
@@ -283,6 +283,7 @@ export const webCrawl: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "Reddit API", href: "/reddit" },
{ label: "Instagram API", href: "/instagram" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
{ label: "Read the docs", href: "/docs" },
],
diff --git a/surfsense_web/lib/connectors-marketing/youtube.tsx b/surfsense_web/lib/connectors-marketing/youtube.tsx
index 2dc9b8a88..b6206e618 100644
--- a/surfsense_web/lib/connectors-marketing/youtube.tsx
+++ b/surfsense_web/lib/connectors-marketing/youtube.tsx
@@ -275,6 +275,7 @@ export const youtube: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/playground/catalog.ts b/surfsense_web/lib/playground/catalog.ts
index c32a24a20..a837c098e 100644
--- a/surfsense_web/lib/playground/catalog.ts
+++ b/surfsense_web/lib/playground/catalog.ts
@@ -3,6 +3,7 @@ import {
AmazonIcon,
GoogleMapsIcon,
GoogleSearchIcon,
+ IndeedIcon,
InstagramIcon,
RedditIcon,
TikTokIcon,
@@ -91,6 +92,12 @@ export const PLAYGROUND_PLATFORMS: PlaygroundPlatform[] = [
icon: GoogleSearchIcon,
verbs: [{ name: "google_search.scrape", verb: "scrape", label: "Scrape" }],
},
+ {
+ id: "indeed",
+ label: "Indeed",
+ icon: IndeedIcon,
+ verbs: [{ name: "indeed.scrape", verb: "scrape", label: "Scrape" }],
+ },
{
id: "amazon",
label: "Amazon",
diff --git a/surfsense_web/lib/playground/platform-icons.tsx b/surfsense_web/lib/playground/platform-icons.tsx
index c0afc91ef..5f98833b4 100644
--- a/surfsense_web/lib/playground/platform-icons.tsx
+++ b/surfsense_web/lib/playground/platform-icons.tsx
@@ -30,4 +30,5 @@ export const InstagramIcon = brandIcon("/connectors/instagram.svg", "Instagram")
export const TikTokIcon = brandIcon("/connectors/tiktok.svg", "TikTok");
export const GoogleMapsIcon = brandIcon("/connectors/google-maps.svg", "Google Maps");
export const GoogleSearchIcon = brandIcon("/connectors/google-search.svg", "Google Search");
+export const IndeedIcon = brandIcon("/connectors/indeed.svg", "Indeed");
export const WebIcon = brandIcon("/connectors/web.svg", "Web");
diff --git a/surfsense_web/public/connectors/indeed.svg b/surfsense_web/public/connectors/indeed.svg
new file mode 100644
index 000000000..9265a1934
--- /dev/null
+++ b/surfsense_web/public/connectors/indeed.svg
@@ -0,0 +1 @@
+