Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/scrapingbee_cli/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ def read_input_file(path: str, *, input_column: str | None = None) -> list[str]:
If path ends with .csv, read as CSV using input_column (name or 0-based index)."""
import sys as _sys

if path != "-":
path = str(Path(path).expanduser())

if path == "-":
lines = [line.strip() for line in _sys.stdin if line.strip()]
elif path.lower().endswith(".csv"):
Expand Down
115 changes: 103 additions & 12 deletions src/scrapingbee_cli/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import re
import sys
from pathlib import Path
from typing import Any

import click
Expand Down Expand Up @@ -243,7 +244,13 @@ def _batch_options(f: Any) -> Any:
default=None,
help="CSV input: column name or 0-based index.",
)(f)
f = click.option("--output-dir", "output_dir", default=None, help="Batch output folder.")(f)
f = click.option(
"--output-dir",
"output_dir",
type=click.Path(),
default=None,
help="Batch output folder.",
)(f)
f = click.option(
"--output-format",
"output_format",
Expand Down Expand Up @@ -313,21 +320,86 @@ def _batch_options(f: Any) -> Any:
return f


def resolve_output_path(path: str) -> str:
"""Expand ``~`` in an output path."""
return str(Path(path).expanduser())


def ensure_output_file_ready(
path: str,
*,
overwrite: bool = False,
skip_overwrite_check: bool = False,
) -> str:
"""Validate an output file path before any API work starts.

Expands ``~``, creates parent directories, and checks overwrite policy.
Returns the resolved path.
"""
resolved = resolve_output_path(path)
parent = Path(resolved).parent
if str(parent) and not parent.exists():
try:
parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
click.echo(f"Cannot create directory '{parent}': {e.strerror}", err=True)
raise SystemExit(1)
if not skip_overwrite_check:
confirm_overwrite(resolved, overwrite)
return resolved


def ensure_output_dir_ready(path: str) -> str:
"""Expand ``~`` and create an output directory before API work."""
resolved = resolve_output_path(path)
try:
Path(resolved).mkdir(parents=True, exist_ok=True)
except OSError as e:
click.echo(f"Cannot create directory '{resolved}': {e.strerror}", err=True)
raise SystemExit(1)
return resolved


def ensure_input_file_ready(path: str) -> str:
"""Validate an input file path before any API work starts.

Expands ``~`` and ensures the file exists and is readable. Stdin (``-``) is
passed through unchanged.
"""
if path == "-":
return path
resolved = resolve_output_path(path)
p = Path(resolved)
if not p.is_file():
if p.exists():
click.echo(f"Not a file: '{resolved}'", err=True)
else:
click.echo(f"Input file not found: '{resolved}'", err=True)
raise SystemExit(1)
try:
with open(resolved, "rb"):
pass
except OSError as e:
click.echo(f"Cannot read from '{resolved}': {e.strerror}", err=True)
raise SystemExit(1)
return resolved


def confirm_overwrite(path: str | None, overwrite: bool = False) -> None:
"""If path exists, prompt for confirmation unless --overwrite is set."""
if not path:
return
from pathlib import Path
resolved = resolve_output_path(path)

if Path(path).exists() and not overwrite:
if Path(resolved).exists() and not overwrite:
# In REPL mode, prompt_toolkit owns the TTY (full-screen / alt-buffer),
# so click.confirm reads from sys.stdin and blocks forever. Surface
# the conflict as an error and tell the user to pass --overwrite.
if is_repl_mode():
raise click.UsageError(
f"'{path}' already exists. Re-run with --overwrite to replace it."
f"'{resolved}' already exists. Re-run with --overwrite to replace it."
)
if not click.confirm(f"'{path}' already exists. Overwrite?"):
if not click.confirm(f"'{resolved}' already exists. Overwrite?"):
click.echo("Cancelled.", err=True)
raise SystemExit(0)

Expand Down Expand Up @@ -380,10 +452,6 @@ def store_common_options(obj: dict, **kwargs: Any) -> None:
has_output_file = bool(obj.get("output_file"))
has_output_dir = bool(obj.get("output_dir"))

# Check if output file already exists (skip for --update-csv which intentionally overwrites)
if has_output_file and not obj.get("update_csv"):
confirm_overwrite(obj["output_file"], obj.get("overwrite", False))

# Mutual exclusion: --output-file and --output-dir
if has_output_file and has_output_dir:
click.echo(
Expand Down Expand Up @@ -520,6 +588,21 @@ def store_common_options(obj: dict, **kwargs: Any) -> None:
)
raise SystemExit(1)

# Resolve paths before any API work (expand ~, mkdir outputs, validate inputs).
input_file_path = obj.get("input_file")
if isinstance(input_file_path, str):
obj["input_file"] = ensure_input_file_ready(input_file_path)
output_file_path = obj.get("output_file")
if isinstance(output_file_path, str):
obj["output_file"] = ensure_output_file_ready(
output_file_path,
overwrite=bool(obj.get("overwrite", False)),
skip_overwrite_check=bool(obj.get("update_csv")),
)
output_dir_path = obj.get("output_dir")
if isinstance(output_dir_path, str) and output_dir_path:
obj["output_dir"] = ensure_output_dir_ready(output_dir_path)


def _parse_path(path: str) -> list[tuple[str, Any]]:
"""Parse a path expression into typed segments.
Expand Down Expand Up @@ -1819,10 +1902,18 @@ def write_output(
elif fields:
data = _filter_fields(data, fields)
if output_path:
resolved = resolve_output_path(output_path)
parent = Path(resolved).parent
if str(parent) and not parent.exists():
try:
parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
click.echo(f"Cannot create directory '{parent}': {e.strerror}", err=True)
raise SystemExit(1)
try:
fh = open(output_path, "wb")
fh = open(resolved, "wb")
except OSError as e:
click.echo(f"Cannot write to '{output_path}': {e.strerror}", err=True)
click.echo(f"Cannot write to '{resolved}': {e.strerror}", err=True)
raise SystemExit(1)
with fh:
fh.write(data)
Expand All @@ -1832,7 +1923,7 @@ def write_output(
# stdout/pipes clean for non-REPL use.
from .theme import BEE_DIM, BEE_YELLOW, err_console

err_console.print(f" [{BEE_DIM}]Saved to[/] [bold {BEE_YELLOW}]{output_path}[/]")
err_console.print(f" [{BEE_DIM}]Saved to[/] [bold {BEE_YELLOW}]{resolved}[/]")
else:
# In REPL mode, truncate large text dumps to a tidy preview and surface
# a path to the full output. Non-REPL invocations (`scrapingbee scrape ...`)
Expand Down
7 changes: 6 additions & 1 deletion src/scrapingbee_cli/commands/crawl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
_validate_json_option,
_validate_range,
build_scrape_kwargs,
ensure_output_dir_ready,
scrape_kwargs_to_api_params,
store_common_options,
)
Expand Down Expand Up @@ -427,7 +428,10 @@ def crawl_cmd(
See https://www.scrapingbee.com/documentation/ for parameter details.
"""
store_common_options(obj, **kwargs)
obj["output_dir"] = output_dir or ""
if output_dir:
obj["output_dir"] = ensure_output_dir_ready(output_dir)
else:
obj["output_dir"] = ""
obj["concurrency"] = concurrency or 0
obj["resume"] = resume
obj["on_complete"] = on_complete
Expand Down Expand Up @@ -562,6 +566,7 @@ def crawl_cmd(
custom_headers[k.strip()] = v.strip()
out_dir = (obj.get("output_dir") or "").strip() or None
out_dir = out_dir or default_crawl_output_dir()
out_dir = ensure_output_dir_ready(out_dir)
allowed_list: list[str] | None = None
if allowed_domains:
allowed_list = [d.strip() for d in allowed_domains.split(",") if d.strip()]
Expand Down
8 changes: 5 additions & 3 deletions src/scrapingbee_cli/commands/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ def export_cmd(
input_path = Path(input_dir).resolve()
output_file = obj.get("output_file")

# Check if output file already exists
from ..cli_utils import confirm_overwrite
# Check if output file already exists and prepare destination before reading inputs.
from ..cli_utils import ensure_output_file_ready

confirm_overwrite(output_file, overwrite)
if output_file:
output_file = ensure_output_file_ready(output_file, overwrite=overwrite)
obj["output_file"] = output_file

# Load manifest for URL → relative-path mapping (optional)
# Supports both old format (string values) and new format (dict values with "file" key).
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/test_repl_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,32 @@ def test_session_default_no_skip_warning_for_supported_command(tmp_path):
)
finally:
child.close(force=True)


@needs_cli
def test_repl_tilde_output_file_validated_before_scrape(tmp_path):
"""REPL expands ~/ output paths and rejects overwrites before any scrape work."""
home = tmp_path / "home"
home.mkdir()
out_dir = home / "Desktop" / "sb-cli-test" / "ss"
out_dir.mkdir(parents=True)
(out_dir / "screenshot.png").write_bytes(b"old")

child, screen, stream = _spawn(home)
try:
assert _pump_until(child, screen, stream, lambda s: "❯" in _text(s)), "no prompt"
child.send(
"scrape https://example.com --render-js false "
"--output-file ~/Desktop/sb-cli-test/ss/screenshot.png\r"
)
assert _pump_until(
child,
screen,
stream,
lambda s: "already exists" in _text(s) and "--overwrite" in _text(s),
timeout=10.0,
), "overwrite error not shown before scrape"
t = _text(screen)
assert "Cannot write to '~/" not in t
finally:
child.close(force=True)
Loading
Loading