Skip to content

feat: add Cloudflare /crawl API client (Phase 1 of Cloudflare migration)#54

Open
mfafore22 wants to merge 8 commits into
mainfrom
cloudflare-migration
Open

feat: add Cloudflare /crawl API client (Phase 1 of Cloudflare migration)#54
mfafore22 wants to merge 8 commits into
mainfrom
cloudflare-migration

Conversation

@mfafore22

@mfafore22 mfafore22 commented Jul 1, 2026

Copy link
Copy Markdown
Member

Part of #2 — migrating custom crawler/parser to Cloudflare Browser Rendering.

What this PR does

Adds tapio/crawler/client.py with three functions for the Cloudflare /crawl API:

  • start_crawl(account_id, api_token, url, ...) — POST to initiate a crawl job, returns job_id
  • wait_for_crawl(account_id, job_id, api_token) — polls the job until it completes (max 5 min)
  • crawl_site(...) — convenience wrapper that combines both

Also fixes a pre-existing issue in tapio/config/config_manager.py where the from_file method used ConfigManager as a return type before the class was fully defined. Added from __future__ import annotations.

Verified working

Tested against real Cloudflare API:

  • Token verification returns "status":"active"
  • crawl_site("https://example.com") returns Status: completed with records

Requires

Environment variables:

  • CLOUDFLARE_ACCOUNT_ID
  • CLOUDFLARE_API_TOKEN (with Account > Browser Rendering > Edit permission)

Not included in this PR (follow-ups)

  • Phase 2: Update CrawlerConfig and site_configs.yaml
  • Phase 3: Rewrite BaseCrawler/CrawlerRunner to use this client
  • Phase 4: Update CLI, delete parse command
  • Phase 5: Remove old crawler/parser code, unused deps

I'll open follow-up PRs for each phase to keep review manageable.

Summary by CodeRabbit

  • New Features
    • Added Cloudflare Browser Rendering crawling with job start, completion polling, and final crawl results.
    • Crawls now save captured content as Markdown files and return page titles in crawl results.
  • Configuration
    • Added Cloudflare crawler options: limit, render (JavaScript rendering), and source (all, sitemaps, links).
    • Updated per-site crawler settings to enable/disable JavaScript rendering and apply Cloudflare options.
  • Refactor
    • Switched crawling execution to a synchronous flow with simplified runner behavior.
  • Bug Fixes / Improvements
    • Added a warning log when a crawl returns zero pages; streamlined final persistence of URL mappings.
  • Tests
    • Removed crawler and runner test coverage modules.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Cloudflare crawl client support, updates crawler configuration and site defaults, rewrites crawling around synchronous Markdown results, simplifies runner execution, and enables postponed type-annotation evaluation.

Changes

Cloudflare crawl migration

Layer / File(s) Summary
Crawler config and annotations
tapio/config/config_models.py, tapio/config/config_manager.py
CrawlerConfig adds Cloudflare crawl fields, updates legacy field descriptions, and config management enables postponed annotation evaluation.
Site crawler defaults
tapio/config/site_configs.yaml
Configured sites receive limit, render, and source crawler parameters.
Crawl request and polling
tapio/crawler/client.py
Adds functions to submit Cloudflare crawl jobs, poll their status, and return completed results.
Cloudflare crawler output
tapio/crawler/crawler.py
BaseCrawler calls the Cloudflare client, saves Markdown content, updates URL mappings, and returns completed crawl records.
Synchronous runner
tapio/crawler/runner.py
Removes asynchronous runner execution and calls BaseCrawler.crawl() directly with updated logging.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant crawl_site
  participant start_crawl
  participant wait_for_crawl
  participant CloudflareAPI

  Caller->>crawl_site: crawl_site(account_id, api_token, url)
  crawl_site->>start_crawl: start_crawl(...)
  start_crawl->>CloudflareAPI: POST crawl request
  CloudflareAPI-->>start_crawl: job_id
  start_crawl-->>crawl_site: job_id
  crawl_site->>wait_for_crawl: wait_for_crawl(job_id)
  loop poll until not "running" or timeout
    wait_for_crawl->>CloudflareAPI: GET crawl status
    CloudflareAPI-->>wait_for_crawl: status/result
  end
  wait_for_crawl-->>crawl_site: crawl result
  crawl_site-->>Caller: crawl result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the new Cloudflare /crawl client and migration phase, though it understates the broader crawler refactor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cloudflare-migration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

👋 Hi @mfafore22, thanks for the pull request! Before this can be merged, please address: Lint (prek), Type Checking (mypy), Type Checking (pyrefly).

Details and local reproduction commands are below, with links to the relevant CONTRIBUTING.md section for each.

CI Failures

❌ prek
est_client.py:86:71
     |
  84 |     with patch("tapio.crawler.client.httpx.get", side_effect=[running, running, completed]) as mock_get:
  85 |         with patch("tapio.crawler.client.time.sleep") as mock_sleep:
  86 |             result = wait_for_crawl(account_id="acc", job_id="job-1", api_token="tok")
     |                                                                       ^^^^^^^^^^^^^^^
  87 |
  88 |     assert result["status"] == "completed"
     |

  SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements
    --> tests/crawler/test_client.py:96:5
     |
  94 |       running = make_fake_response(200, {"success": True, "result": {"status": "running", "records": []}})
  95 |
  96 | /     with patch("tapio.crawler.client.httpx.get", return_value=running), patch("tapio.crawler.client.time.sleep"):
  97 | |         with pytest.raises(TimeoutError):
     | |_________________________________________^
  98 |               wait_for_crawl(account_id="acc", job_id="job-1", api_token="tok")
     |
  help: Combine `with` statements

  S106 Possible hardcoded password assigned to argument: "api_token"
    --> tests/crawler/test_client.py:98:62
     |
  96 |     with patch("tapio.crawler.client.httpx.get", return_value=running), patch("tapio.crawler.client.time.sleep"):
  97 |         with pytest.raises(TimeoutError):
  98 |             wait_for_crawl(account_id="acc", job_id="job-1", api_token="tok")
     |                                                              ^^^^^^^^^^^^^^^
     |

  SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements
     --> tests/crawler/test_client.py:104:5
      |
  102 |       fake_result = {"status": "completed", "records": [{"url": "x"}]}
  103 |
  104 | /     with patch("tapio.crawler.client.start_crawl", return_value="chained-job-id") as mock_start:
  105 | |         with patch("tapio.crawler.client.wait_for_crawl", return_value=fake_result) as mock_wait:
      | |_________________________________________________________________________________________________^
  106 |               returned = crawl_site(
  107 |                   account_id="acc",
      |
  help: Combine `with` statements

  S106 Possible hardcoded password assigned to argument: "api_token"
     --> tests/crawler/test_client.py:108:17
      |
  106 |             returned = crawl_site(
  107 |                 account_id="acc",
  108 |                 api_token="tok",
      |                 ^^^^^^^^^^^^^^^
  109 |                 url="https://x.com",
  110 |                 depth=1,
      |

  S106 Possible hardcoded password assigned to argument: "api_token"
     --> tests/crawler/test_client.py:120:9
      |
  118 |     mock_start.assert_called_once_with(
  119 |         account_id="acc",
  120 |         api_token="tok",
      |         ^^^^^^^^^^^^^^^
  121 |         url="https://x.com",
  122 |         depth=1,
      |

  S106 Possible hardcoded password assigned to argument: "api_token"
     --> tests/crawler/test_client.py:130:9
      |
  128 |         account_id="acc",
  129 |         job_id="chained-job-id",
  130 |         api_token="tok",
      |         ^^^^^^^^^^^^^^^
  131 |     )
      |

  S105 Possible hardcoded password assigned to: "api_token"
    --> tests/crawler/test_crawler.py:61:37
     |
  59 |         assert crawler.site_name == "test-site"
  60 |         assert crawler.account_id == "test-account"
  61 |         assert crawler.api_token == "test-token"
     |                                     ^^^^^^^^^^^^
  62 |         assert crawler.max_depth == 2
  63 |         assert crawler.limit == 50
     |

  SIM117 Use a single `with` statement with multiple contexts instead of nested `with` statements
    --> tests/crawler/test_crawler.py:73:9
     |
  72 |           # Also block .env loading so tests don't accidentally pick up real creds
  73 | /         with patch("tapio.crawler.crawler.load_dotenv"):
  74 | |             with pytest.raises(ValueError, match="CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN"):
     | |___________________________________________________________________________________________________^
  75 |                   BaseCrawler("test-site", make_test_site_config())
     |
  help: Combine `with` statements

  PIE810 Call `endswith` once with a `tuple`
     --> tests/crawler/test_crawler.py:107:16
      |
  105 |         p_query = crawler._get_file_path_from_url("https://example.com/page?x=1&y=2")
  106 |
  107 |         assert p_root.endswith("example.com" + "\\" + "index.md") or p_root.endswith("example.com/index.md")
      |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  108 |         assert p_deep.endswith("en\\about.md") or p_deep.endswith("en/about.md")
  109 |         assert "page_x_1_y_2.md" in p_query
      |
  help: Merge into a single `endswith` call

  PIE810 Call `endswith` once with a `tuple`
     --> tests/crawler/test_crawler.py:108:16
      |
  107 |         assert p_root.endswith("example.com" + "\\" + "index.md") or p_root.endswith("example.com/index.md")
  108 |         assert p_deep.endswith("en\\about.md") or p_deep.endswith("en/about.md")
      |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  109 |         assert "page_x_1_y_2.md" in p_query
      |
  help: Merge into a single `endswith` call

  Found 33 errors (3 fixed, 30 remaining).
  No fixes available (5 hidden fixes can be enabled with the `--unsafe-fixes` option).
ruff format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook

  4 files reformatted, 49 files left unchanged
keep-sorted..............................................................Passed
Lint GitHub Actions workflows............................................Passed
Lint Markdown............................................................Passed

Run uv run prek run --all-files locally to reproduce and fix. See Pre-commit Hooks (prek) in CONTRIBUTING.md.

❌ mypy
tapio/crawler/crawler.py:113:24: error: Argument "account_id" to "crawl_site"
has incompatible type "str | None"; expected "str"  [arg-type]
                account_id=self.account_id,
                           ^~~~~~~~~~~~~~~
tapio/crawler/crawler.py:114:23: error: Argument "api_token" to "crawl_site"
has incompatible type "str | None"; expected "str"  [arg-type]
                api_token=self.api_token,
                          ^~~~~~~~~~~~~~
Found 2 errors in 1 file (checked 29 source files)

Run uv run mypy tapio locally to reproduce and fix. See Type Checking in CONTRIBUTING.md.

❌ pyrefly
 INFO Checking project configured at `/home/runner/work/tapio/tapio/pyrefly.toml`
ERROR Argument `str | None` is not assignable to parameter `account_id` with type `str` in function `tapio.crawler.client.crawl_site` [bad-argument-type]
   --> tapio/crawler/crawler.py:113:24
    |
113 |             account_id=self.account_id,
    |                        ^^^^^^^^^^^^^^^
    |
  The declared type does not allow `None`. Consider narrowing the value with an `is not None` check.
ERROR Argument `str | None` is not assignable to parameter `api_token` with type `str` in function `tapio.crawler.client.crawl_site` [bad-argument-type]
   --> tapio/crawler/crawler.py:114:23
    |
114 |             api_token=self.api_token,
    |                       ^^^^^^^^^^^^^^
    |
  The declared type does not allow `None`. Consider narrowing the value with an `is not None` check.
 INFO 2 errors (1 suppressed)

Run uv run pyrefly check locally to reproduce and fix. See Type Checking in CONTRIBUTING.md.

Test Coverage Report

87% coverage (required: ≥80%)

Coverage meets the project threshold. 🎉

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tapio/crawler/client.py`:
- Around line 31-35: The Cloudflare crawl start/status requests in
wait_for_crawl and the HTTP call block around httpx.post/httpx.get currently let
timeout, network, and status exceptions escape, which can abort the polling loop
on transient failures. Add explicit timeout handling and wrap the external calls
in retry logic that catches httpx.TimeoutException, httpx.NetworkError, and
HTTPStatusError, then continues polling until the overall wait window expires.
Use the wait_for_crawl flow and the request/response parsing around
response.raise_for_status() and job_id/status checks as the main points to
update.
- Around line 34-35: The Cloudflare response parsing assumes `data["result"]`
always exists, so unexpected or empty JSON can raise a `KeyError`; update the
parsing in the relevant `tapio/crawler/client.py` request handling paths that
use `response.json()`, `job_id`, and `data["result"]["status"]` to validate the
payload shape first. Use safe access or explicit checks for `result` and
`status`, and raise a clear, domain-specific error when the response schema is
missing or invalid instead of indexing directly.
- Around line 6-93: Add Google-style docstrings to start_crawl, wait_for_crawl,
and crawl_site, since these public functions currently have none. Document each
function with a brief summary plus :param entries for every argument, :return
for the returned job_id/result, and :raises for the HTTP/timeout failures
surfaced by httpx.raise_for_status and TimeoutError in wait_for_crawl. Keep the
docstrings aligned with the existing function behavior and use the function
names to locate the right spots.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ba741b06-3281-411b-99f0-3c9df95b0030

📥 Commits

Reviewing files that changed from the base of the PR and between a9b78f0 and 2ccda0e.

📒 Files selected for processing (2)
  • tapio/config/config_manager.py
  • tapio/crawler/client.py

Comment thread tapio/crawler/client.py
Comment on lines +6 to +93
def start_crawl(
account_id: str,
api_token: str,
url: str,
depth: int = 2,
limit: int = 100,
render: bool = True,
source: str = "all",
) -> str:
endpoint = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/crawl"

headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}

payload = {
"url": url,
"depth": depth,
"limit": limit,
"formats": ["markdown"],
"render": render,
"source": source,
}

response = httpx.post(endpoint, json=payload, headers=headers)
response.raise_for_status()

data = response.json()
job_id = data["result"]

return job_id


def wait_for_crawl(account_id: str, job_id: str, api_token: str) -> dict:
max_attempts = 60
delay_seconds = 5

for _ in range(max_attempts):
endpoint = (
f"https://api.cloudflare.com/client/v4/accounts/{account_id}"
f"/browser-rendering/crawl/{job_id}?limit=1"
)

headers = {
"Authorization": f"Bearer {api_token}",
}

response = httpx.get(endpoint, headers=headers)
response.raise_for_status()

data = response.json()
status = data["result"]["status"]

if status != "running":
return data["result"]

time.sleep(delay_seconds)

raise TimeoutError("Crawl job did not complete within timeout")


def crawl_site(
account_id: str,
api_token: str,
url: str,
depth: int = 2,
limit: int = 100,
render: bool = True,
source: str = "all",
) -> dict:
job_id = start_crawl(
account_id=account_id,
api_token=api_token,
url=url,
depth=depth,
limit=limit,
render=render,
source=source,
)

result = wait_for_crawl(
account_id=account_id,
job_id=job_id,
api_token=api_token,
)

return result No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Missing docstrings on all three public functions.

start_crawl, wait_for_crawl, and crawl_site have no docstrings at all. Coding guidelines require Google-style docstrings with :param, :return, and :raises documentation for every function.

As per coding guidelines, "Use Google-style docstrings for all Python functions and classes," "Include a docstring summary, parameter documentation, and return-type documentation for Python functions," and "Document Python functions with :param for parameters and :return for return values" / "Document Python exceptions with :raises in docstrings."

📝 Example docstring for start_crawl
 def start_crawl(
     account_id: str,
     api_token: str,
     url: str,
     depth: int = 2,
     limit: int = 100,
     render: bool = True,
     source: str = "all",
 ) -> str:
+    """Start a Cloudflare Browser Rendering crawl job.
+
+    :param account_id: Cloudflare account identifier.
+    :param api_token: Cloudflare API token with Browser Rendering edit permission.
+    :param url: Seed URL to crawl.
+    :param depth: Maximum crawl depth.
+    :param limit: Maximum number of pages to crawl.
+    :param render: Whether to render pages via a headless browser.
+    :param source: Crawl source filter (e.g. "all").
+    :return: The Cloudflare-assigned crawl job id.
+    :raises httpx.HTTPStatusError: If the Cloudflare API returns an error status.
+    """
     endpoint = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/crawl"
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 30-30: Request-controlled URL passed to httpx; validate against an allowlist to prevent SSRF.
Context: httpx.post(endpoint, json=payload, headers=headers)
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(avoid-ssrf)


[warning] 53-53: Request-controlled URL passed to httpx; validate against an allowlist to prevent SSRF.
Context: httpx.get(endpoint, headers=headers)
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(avoid-ssrf)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/client.py` around lines 6 - 93, Add Google-style docstrings to
start_crawl, wait_for_crawl, and crawl_site, since these public functions
currently have none. Document each function with a brief summary plus :param
entries for every argument, :return for the returned job_id/result, and :raises
for the HTTP/timeout failures surfaced by httpx.raise_for_status and
TimeoutError in wait_for_crawl. Keep the docstrings aligned with the existing
function behavior and use the function names to locate the right spots.

Source: Coding guidelines

Comment thread tapio/crawler/client.py
Comment on lines +31 to +35
response = httpx.post(endpoint, json=payload, headers=headers)
response.raise_for_status()

data = response.json()
job_id = data["result"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout errors handled inside the polling loop; unhandled network exceptions from external calls.

httpx.post/httpx.get here rely on httpx's default 5s timeout (confirmed via httpx docs). Cloudflare crawl start/status responses may legitimately take longer than 5s, and any httpx.TimeoutException, httpx.NetworkError, or HTTPStatusError raised will propagate unhandled — inside wait_for_crawl's loop this means a single transient blip aborts the entire up-to-5-minute polling window instead of being retried, defeating the purpose of the loop.

🔁 Add explicit timeout + retry-on-transient-error handling
-        response = httpx.get(endpoint, headers=headers)
-        response.raise_for_status()
+        try:
+            response = httpx.get(endpoint, headers=headers, timeout=30.0)
+            response.raise_for_status()
+        except (httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError):
+            time.sleep(delay_seconds)
+            continue

Also applies to: 54-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/client.py` around lines 31 - 35, The Cloudflare crawl
start/status requests in wait_for_crawl and the HTTP call block around
httpx.post/httpx.get currently let timeout, network, and status exceptions
escape, which can abort the polling loop on transient failures. Add explicit
timeout handling and wrap the external calls in retry logic that catches
httpx.TimeoutException, httpx.NetworkError, and HTTPStatusError, then continues
polling until the overall wait window expires. Use the wait_for_crawl flow and
the request/response parsing around response.raise_for_status() and
job_id/status checks as the main points to update.

Comment thread tapio/crawler/client.py
Comment on lines +34 to +35
data = response.json()
job_id = data["result"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled KeyError if the API response shape differs from expectations.

data["result"] and data["result"]["status"] assume a fixed response shape without validation. If Cloudflare returns an unexpected/empty payload (e.g. a 2xx with a different schema), this raises an unhandled KeyError instead of a meaningful error.

🛡️ Defensive access with clearer error
-    data = response.json()
-    job_id = data["result"]
+    data = response.json()
+    if "result" not in data:
+        raise RuntimeError(f"Unexpected Cloudflare response: {data}")
+    job_id = data["result"]

Also applies to: 57-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/client.py` around lines 34 - 35, The Cloudflare response
parsing assumes `data["result"]` always exists, so unexpected or empty JSON can
raise a `KeyError`; update the parsing in the relevant `tapio/crawler/client.py`
request handling paths that use `response.json()`, `job_id`, and
`data["result"]["status"]` to validate the payload shape first. Use safe access
or explicit checks for `result` and `status`, and raise a clear, domain-specific
error when the response schema is missing or invalid instead of indexing
directly.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tapio/config/config_models.py`:
- Around line 49-51: The config model fields have a lint issue from an overly
long annotation line and a formatting typo. Update the field declarations in
config_models.py around max_concurrent, max_depth, and limit so the long
Field(...) definition is wrapped to satisfy ruff E501, and change the stray
"limit :" spacing to match the existing class attribute style. Keep the fix
localized to the config model field definitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 41638003-f6a6-4769-911d-60737af3ae12

📥 Commits

Reviewing files that changed from the base of the PR and between 2ccda0e and b2e088e.

📒 Files selected for processing (2)
  • tapio/config/config_models.py
  • tapio/config/site_configs.yaml

Comment on lines +49 to +51
max_concurrent: Annotated[int, Field(ge=1, le=50, description="Maximum number of concurrent requests (legacy crawler only)")] = 5
max_depth: Annotated[int, Field(ge=1, le=10, description="Maximum crawling depth from starting URLs")] = 1
limit : Annotated[

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix line-length lint failure and stray formatting.

Line 49 exceeds 120 chars (ruff E501). Line 51 also has an extraneous space before the colon (limit :), inconsistent with the rest of the file.

🔧 Proposed fix
-    max_concurrent: Annotated[int, Field(ge=1, le=50, description="Maximum number of concurrent requests (legacy crawler only)")] = 5
+    max_concurrent: Annotated[
+        int,
+        Field(ge=1, le=50, description="Maximum number of concurrent requests (legacy crawler only)"),
+    ] = 5
     max_depth: Annotated[int, Field(ge=1, le=10, description="Maximum crawling depth from starting URLs")] = 1
-    limit : Annotated[
+    limit: Annotated[
         int,
         Field(ge=1, le=100_000, description="Max pages to crawl (Cloudflare)"),
     ] = 100
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
max_concurrent: Annotated[int, Field(ge=1, le=50, description="Maximum number of concurrent requests (legacy crawler only)")] = 5
max_depth: Annotated[int, Field(ge=1, le=10, description="Maximum crawling depth from starting URLs")] = 1
limit : Annotated[
max_concurrent: Annotated[
int,
Field(ge=1, le=50, description="Maximum number of concurrent requests (legacy crawler only)"),
] = 5
max_depth: Annotated[int, Field(ge=1, le=10, description="Maximum crawling depth from starting URLs")] = 1
limit: Annotated[
int,
Field(ge=1, le=100_000, description="Max pages to crawl (Cloudflare)"),
] = 100
🧰 Tools
🪛 GitHub Check: Lint (prek)

[failure] 49-49: ruff (E501)
tapio/config/config_models.py:49:121: E501 Line too long (133 > 120)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/config/config_models.py` around lines 49 - 51, The config model fields
have a lint issue from an overly long annotation line and a formatting typo.
Update the field declarations in config_models.py around max_concurrent,
max_depth, and limit so the long Field(...) definition is wrapped to satisfy
ruff E501, and change the stray "limit :" spacing to match the existing class
attribute style. Keep the fix localized to the config model field definitions.

Source: Linters/SAST tools

@mfafore22 mfafore22 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it on my local it was fine

@mfafore22 mfafore22 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 3 refactored crawler.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
tapio/crawler/crawler.py (2)

41-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Expand the new docstrings to match the repository standard.

These one-line docstrings omit the required parameter, return, and applicable exception/example/note documentation for the new class and functions. As per coding guidelines, **/*.py: “Use Google-style docstrings for all Python functions and classes” and “Document Python functions with :param for parameters and :return for return values.”

Also applies to: 53-54, 61-62, 70-71, 75-76, 90-91, 100-101, 109-110, 124-125, 128-129, 153-154, 162-163, 171-172, 179-180, 188-189, 196-197

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` at line 41, The new class and functions in
crawler.py use one-line docstrings that do not meet the repository’s Python
docstring standard. Update the docstrings for the class and each affected
function (including the ones around the listed symbols such as the crawler
constructor/methods and helper functions) to Google-style format, adding
parameter descriptions with :param, return documentation with :return:, and any
applicable notes/examples/exceptions where relevant so the public API is fully
documented.

Source: Coding guidelines


55-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assign the Cloudflare credentials to typed attributes after validation. os.getenv() returns str | None, so storing it directly on self leaves self.account_id and self.api_token optional at the later crawl_site() call site. Validate locals first, then assign self.account_id: str and self.api_token: str. tapio/crawler/crawler.py:55-59

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 55 - 59, The Cloudflare credentials
are being stored on self directly from os.getenv(), which leaves self.account_id
and self.api_token typed as optional later in crawl_site(). Validate the
environment values in local variables first, raise the same ValueError if either
is missing, then assign the validated values to typed instance attributes in
Crawler.__init__ so self.account_id and self.api_token are guaranteed to be str.

Source: Coding guidelines

tapio/crawler/runner.py (1)

11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Expand runner docstrings to the required format.

The new class and methods have only summary docstrings; run() in particular should document site_name, site_config, and the returned crawl results. As per coding guidelines, **/*.py: “Use Google-style docstrings for all Python functions and classes” and “Document Python functions with :param for parameters and :return for return values.”

Also applies to: 17-18, 25-26

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/runner.py` at line 11, The Runner class and its run() method
only have summary docstrings and need to be expanded to the required
Google-style format. Update the docstrings on Runner and run() to include :param
entries for site_name and site_config, and a :return entry describing the crawl
results. Make sure the documentation follows the Python docstring standard used
elsewhere in the codebase and covers all newly added class/method docstrings in
runner.py.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tapio/crawler/crawler.py`:
- Around line 167-168: The path construction in the crawler still only validates
the top-level output directory, so URL path/query components can escape or
collide within the host subdirectory. Update the filename generation logic in
the crawler method that builds `full_path` to normalize and sanitize the
URL-derived path and query text, and ensure `_assert_within_output_dir` is
applied against the final `output_dir/<host>` target rather than just
`output_dir`. Apply the same host-directory confinement and sanitization
consistently in the other path-building blocks referenced by the crawler code.
- Around line 198-203: The mapping persistence in Crawler.save_url_mappings()
logs the failure but then swallows it, allowing the crawl to report success even
when url_mappings.json was not written. Update the except block to re-raise the
exception after logger.exception so callers can detect the failure and surface
the crawl artifact error.
- Around line 83-88: The URL mappings load in crawler.py is swallowing
decode/read failures and continuing with an empty self.url_mappings, which can
later overwrite valid data on save. Update the mapping load path in the
crawler’s initialization logic to fail fast when Path(self.mapping_file) cannot
be opened or json.load() fails, rather than defaulting to an empty mapping; keep
the existing logger.exception("Error loading URL mappings") and ensure the
crawler does not proceed with a fresh mapping after a bad existing file.
- Around line 104-105: The mapping persistence in crawler.Crawler around
_process_record() and _save_url_mappings() is skipped when a later record
raises, leaving earlier outputs untracked. Refactor the record-processing flow
to avoid a single list comprehension that can abort early, and ensure
_save_url_mappings() is invoked from a finally block (or equivalent cleanup
path) so accumulated URL mappings are always persisted even if one record fails.
- Around line 102-104: The crawl flow in Crawler.crawl is only processing
raw["records"], so it drops any additional Cloudflare pages returned via cursor
pagination. Update _fetch_from_cloudflare and/or crawl to keep requesting
subsequent pages while a cursor is present, merge all records before calling
_filter_completed and _process_record, and ensure the persisted crawl results
include every page from the job, not just the first response.

In `@tapio/crawler/runner.py`:
- Around line 10-25: The CrawlerRunner API was renamed, but the test still calls
the removed async entrypoint. Update the affected call site in
tests/crawler/test_runner.py to use CrawlerRunner.run() instead of run_async(),
or restore a compatible run_async method on CrawlerRunner if async behavior must
remain. Use the CrawlerRunner class and its run method as the main reference
point so the test and implementation stay aligned.

---

Nitpick comments:
In `@tapio/crawler/crawler.py`:
- Line 41: The new class and functions in crawler.py use one-line docstrings
that do not meet the repository’s Python docstring standard. Update the
docstrings for the class and each affected function (including the ones around
the listed symbols such as the crawler constructor/methods and helper functions)
to Google-style format, adding parameter descriptions with :param, return
documentation with :return:, and any applicable notes/examples/exceptions where
relevant so the public API is fully documented.
- Around line 55-59: The Cloudflare credentials are being stored on self
directly from os.getenv(), which leaves self.account_id and self.api_token typed
as optional later in crawl_site(). Validate the environment values in local
variables first, raise the same ValueError if either is missing, then assign the
validated values to typed instance attributes in Crawler.__init__ so
self.account_id and self.api_token are guaranteed to be str.

In `@tapio/crawler/runner.py`:
- Line 11: The Runner class and its run() method only have summary docstrings
and need to be expanded to the required Google-style format. Update the
docstrings on Runner and run() to include :param entries for site_name and
site_config, and a :return entry describing the crawl results. Make sure the
documentation follows the Python docstring standard used elsewhere in the
codebase and covers all newly added class/method docstrings in runner.py.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 56667a2c-7e02-4a6c-bf90-f9288fa2690d

📥 Commits

Reviewing files that changed from the base of the PR and between b52ff04 and 4a2ec16.

📒 Files selected for processing (2)
  • tapio/crawler/crawler.py
  • tapio/crawler/runner.py

Comment thread tapio/crawler/crawler.py
Comment on lines +83 to +88
try:
with Path(self.mapping_file).open(encoding="utf-8") as f:
self.url_mappings = json.load(f)
logger.info("Loaded %d existing URL mappings", len(self.url_mappings))
except Exception:
logger.exception("Error loading URL mappings")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not overwrite mappings after a failed load.

If url_mappings.json exists but cannot be decoded/read, this logs and continues with an empty mapping; the end-of-crawl save can then replace the previous mappings.

Fail fast on invalid existing mappings
-        except Exception:
-            logger.exception("Error loading URL mappings")
+        except (OSError, json.JSONDecodeError) as exc:
+            logger.exception("Error loading URL mappings")
+            msg = f"Unable to load existing URL mappings from {self.mapping_file}"
+            raise ValueError(msg) from exc
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
with Path(self.mapping_file).open(encoding="utf-8") as f:
self.url_mappings = json.load(f)
logger.info("Loaded %d existing URL mappings", len(self.url_mappings))
except Exception:
logger.exception("Error loading URL mappings")
try:
with Path(self.mapping_file).open(encoding="utf-8") as f:
self.url_mappings = json.load(f)
logger.info("Loaded %d existing URL mappings", len(self.url_mappings))
except (OSError, json.JSONDecodeError) as exc:
logger.exception("Error loading URL mappings")
msg = f"Unable to load existing URL mappings from {self.mapping_file}"
raise ValueError(msg) from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 83 - 88, The URL mappings load in
crawler.py is swallowing decode/read failures and continuing with an empty
self.url_mappings, which can later overwrite valid data on save. Update the
mapping load path in the crawler’s initialization logic to fail fast when
Path(self.mapping_file) cannot be opened or json.load() fails, rather than
defaulting to an empty mapping; keep the existing logger.exception("Error
loading URL mappings") and ensure the crawler does not proceed with a fresh
mapping after a bad existing file.

Comment thread tapio/crawler/crawler.py
Comment on lines +102 to +104
raw = self._fetch_from_cloudflare()
records = self._filter_completed(raw.get("records", []))
results = [self._process_record(r) for r in records]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether crawl-result pagination is handled anywhere in the crawler client.
rg -n 'limit=1|cursor|raw\.get\("records"|records' tapio/crawler

Repository: Finntegrate/tapio

Length of output: 950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the crawler client and crawler implementation around the relevant calls.
sed -n '1,220p' tapio/crawler/client.py
printf '\n---\n'
sed -n '1,220p' tapio/crawler/crawler.py

Repository: Finntegrate/tapio

Length of output: 10060


🌐 Web query:

Cloudflare browser rendering crawl result limit cursor pagination /browser-rendering/crawl/{job_id} records cursor

💡 Result:

To paginate through results for a Cloudflare Browser Rendering crawl job using the GET /accounts/{account_id}/browser-rendering/crawl/{job_id} endpoint, you can utilize the cursor and limit query parameters [1][2][3]. Key details for pagination: - limit: An optional integer that specifies the maximum number of records to return in a single response [1][3]. - cursor: An optional string or number used to navigate through paginated result sets [1][2][4]. If a crawl response exceeds 10 MB, the API will include a cursor value in the response body [5][3]. You should pass this value as a query parameter in your subsequent request to retrieve the next page of results [3]. Example usage: You can request a specific page of results by appending the cursor and limit parameters to your API call [3]: curl -X GET 'https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/crawl/{job_id}?cursor={cursor_value}&limit={limit_value}&status=completed' \ -H 'Authorization: Bearer YOUR_API_TOKEN' When a job reaches a terminal status, you can also fetch all results by omitting the limit parameter, though using pagination is recommended for large datasets [3]. You may also filter the returned records by using the status parameter, which accepts values such as queued, errored, completed, disallowed, skipped, or cancelled [2][3].

Citations:


Follow Cloudflare cursor pagination before persisting crawl results. The /crawl/{job_id} GET path uses limit=1, but Cloudflare paginates crawl records with cursor; crawl() only consumes raw["records"], so later pages are dropped and multi-page crawls are truncated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 102 - 104, The crawl flow in
Crawler.crawl is only processing raw["records"], so it drops any additional
Cloudflare pages returned via cursor pagination. Update _fetch_from_cloudflare
and/or crawl to keep requesting subsequent pages while a cursor is present,
merge all records before calling _filter_completed and _process_record, and
ensure the persisted crawl results include every page from the job, not just the
first response.

Comment thread tapio/crawler/crawler.py
Comment on lines +104 to 105
results = [self._process_record(r) for r in records]
self._save_url_mappings()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist mappings even if a later record fails.

The list comprehension aborts on the first _process_record() exception, and _save_url_mappings() is skipped, leaving already-written Markdown files without persisted URL mappings.

Save accumulated mappings in a finally block
-        results = [self._process_record(r) for r in records]
-        self._save_url_mappings()
+        results: list[CrawlResult] = []
+        try:
+            for record in records:
+                results.append(self._process_record(record))
+        finally:
+            self._save_url_mappings()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
results = [self._process_record(r) for r in records]
self._save_url_mappings()
results: list[CrawlResult] = []
try:
for record in records:
results.append(self._process_record(record))
finally:
self._save_url_mappings()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 104 - 105, The mapping persistence in
crawler.Crawler around _process_record() and _save_url_mappings() is skipped
when a later record raises, leaving earlier outputs untracked. Refactor the
record-processing flow to avoid a single list comprehension that can abort
early, and ensure _save_url_mappings() is invoked from a finally block (or
equivalent cleanup path) so accumulated URL mappings are always persisted even
if one record fails.

Comment thread tapio/crawler/crawler.py
Comment on lines +167 to +168
full_path = Path(self.output_dir) / parsed.netloc / filename.lstrip("/")
self._assert_within_output_dir(full_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Constrain generated paths to the per-host directory.

The guard only checks output_dir, so .. segments or path separators from the URL path/query can resolve outside output_dir/<host> while still staying under output_dir, risking cross-page file collisions.

Constrain to the host directory and sanitize query text
+import re
+
@@
-        full_path = Path(self.output_dir) / parsed.netloc / filename.lstrip("/")
-        self._assert_within_output_dir(full_path)
+        domain_dir = Path(self.output_dir) / parsed.netloc
+        full_path = domain_dir / filename.lstrip("/")
+        self._assert_within_directory(full_path, domain_dir)
         return str(full_path)
@@
-        safe = query.replace("=", "_").replace("&", "_")
+        safe = re.sub(r"[^A-Za-z0-9._-]+", "_", query).strip("._")
+        if not safe:
+            return filename
@@
-    def _assert_within_output_dir(self, path: Path) -> None:
-        """Raise if the resolved path escapes the output directory."""
+    def _assert_within_directory(self, path: Path, directory: Path) -> None:
+        """Raise if the resolved path escapes the target directory."""
         abs_path = path.resolve()
-        abs_out = Path(self.output_dir).resolve()
-        if not abs_path.is_relative_to(abs_out):
-            msg = f"Invalid URL results in path outside output directory: {path}"
+        abs_directory = directory.resolve()
+        if not abs_path.is_relative_to(abs_directory):
+            msg = f"Invalid URL results in path outside target directory: {path}"
             raise ValueError(msg)

Also applies to: 179-186, 188-194

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 167 - 168, The path construction in
the crawler still only validates the top-level output directory, so URL
path/query components can escape or collide within the host subdirectory. Update
the filename generation logic in the crawler method that builds `full_path` to
normalize and sanitize the URL-derived path and query text, and ensure
`_assert_within_output_dir` is applied against the final `output_dir/<host>`
target rather than just `output_dir`. Apply the same host-directory confinement
and sanitization consistently in the other path-building blocks referenced by
the crawler code.

Comment thread tapio/crawler/crawler.py
Comment on lines 198 to +203
try:
with Path(self.mapping_file).open("w", encoding="utf-8") as f:
json.dump(self.url_mappings, f, indent=2, ensure_ascii=False)
logger.debug(
"Saved %d URL mappings to %s",
len(self.url_mappings),
self.mapping_file,
)
logger.debug("Saved %d URL mappings to %s", len(self.url_mappings), self.mapping_file)
except Exception:
logger.exception("Error saving URL mappings")

def _extract_links(self, soup: BeautifulSoup, base_url: str) -> list[str]:
"""Extract valid links to follow from a BeautifulSoup object.

Args:
soup: BeautifulSoup object of the parsed HTML.
base_url: Base URL for resolving relative links.

Returns:
A list of absolute URLs to follow.
"""
links = []

# Extract all href attributes from anchor tags
for anchor in soup.find_all("a", href=True):
# Get href attribute - BeautifulSoup guarantees it exists due to href=True filter
href = anchor["href"] # type: ignore[index]

# Skip if href is None or empty
if not href:
continue

# Convert to string (BeautifulSoup can return different types)
href = str(href)

# Convert relative URLs to absolute URLs
absolute_url = urljoin(base_url, href)

# Filter out non-http(s) schemes and fragments, and check if the domain is allowed
if (
absolute_url.startswith(("http://", "https://"))
and "#" not in absolute_url
and self._is_allowed_domain(absolute_url)
):
links.append(absolute_url)

return links
logger.exception("Error saving URL mappings") No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail the crawl when mappings cannot be saved.

The runner will report success even if url_mappings.json was not written. Re-raise after logging so callers can surface the failed crawl artifact.

Propagate mapping persistence failures
-        except Exception:
+        except Exception:
             logger.exception("Error saving URL mappings")
+            raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
with Path(self.mapping_file).open("w", encoding="utf-8") as f:
json.dump(self.url_mappings, f, indent=2, ensure_ascii=False)
logger.debug(
"Saved %d URL mappings to %s",
len(self.url_mappings),
self.mapping_file,
)
logger.debug("Saved %d URL mappings to %s", len(self.url_mappings), self.mapping_file)
except Exception:
logger.exception("Error saving URL mappings")
def _extract_links(self, soup: BeautifulSoup, base_url: str) -> list[str]:
"""Extract valid links to follow from a BeautifulSoup object.
Args:
soup: BeautifulSoup object of the parsed HTML.
base_url: Base URL for resolving relative links.
Returns:
A list of absolute URLs to follow.
"""
links = []
# Extract all href attributes from anchor tags
for anchor in soup.find_all("a", href=True):
# Get href attribute - BeautifulSoup guarantees it exists due to href=True filter
href = anchor["href"] # type: ignore[index]
# Skip if href is None or empty
if not href:
continue
# Convert to string (BeautifulSoup can return different types)
href = str(href)
# Convert relative URLs to absolute URLs
absolute_url = urljoin(base_url, href)
# Filter out non-http(s) schemes and fragments, and check if the domain is allowed
if (
absolute_url.startswith(("http://", "https://"))
and "#" not in absolute_url
and self._is_allowed_domain(absolute_url)
):
links.append(absolute_url)
return links
logger.exception("Error saving URL mappings")
try:
with Path(self.mapping_file).open("w", encoding="utf-8") as f:
json.dump(self.url_mappings, f, indent=2, ensure_ascii=False)
logger.debug("Saved %d URL mappings to %s", len(self.url_mappings), self.mapping_file)
except Exception:
logger.exception("Error saving URL mappings")
raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tapio/crawler/crawler.py` around lines 198 - 203, The mapping persistence in
Crawler.save_url_mappings() logs the failure but then swallows it, allowing the
crawl to report success even when url_mappings.json was not written. Update the
except block to re-raise the exception after logger.exception so callers can
detect the failure and surface the crawl artifact error.

Comment thread tapio/crawler/runner.py

@mfafore22 mfafore22 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloudlfare is returning 0 results

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant