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
61 changes: 58 additions & 3 deletions src/pyscrappy/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class ScrapeResult:

``data`` is always a list of dicts — one dict per scraped item.
Call ``.to_dataframe()`` for a pandas DataFrame, ``.to_json()`` for
JSON, ``.to_csv()`` for CSV text, ``.to_markdown()`` for clean,
JSON, ``.to_csv()`` for CSV text, ``.to_ndjson()`` for NDJSON,
``.to_yaml()`` for YAML, ``.to_markdown()`` for clean,
LLM-ready Markdown, or ``.save(path)`` to write by file extension.
"""

Expand Down Expand Up @@ -178,11 +179,60 @@ def to_csv(self) -> str:
writer.writerow({k: row.get(k, "") for k in fieldnames})
return buf.getvalue()

def to_ndjson(self) -> str:
"""Return ``data`` as NDJSON (one JSON object per line).

This is the standard interchange format for LLM fine-tuning sets,
log pipelines, and streaming consumers. Only ``data`` rows are
emitted — no metadata/errors envelope.
"""
import json

if not self.data:
return ""
return "\n".join(
json.dumps(row, default=str, ensure_ascii=False) for row in self.data
)

def to_yaml(self) -> str:
"""Return the full result envelope as YAML.

Dumps ``{data, metadata, errors}`` — the same structure as
:meth:`to_json`.

Raises:
ImportError: If PyYAML is not installed.
"""
try:
import yaml
except ImportError:
raise ImportError(
"PyYAML is required for to_yaml(). "
"Install it with: pip install pyscrappy[yaml]"
) from None

envelope = {
"data": self.data,
"metadata": {
"source_urls": self.metadata.source_urls,
"total_pages": self.metadata.total_pages,
"timestamp": self.metadata.timestamp,
"scraper": self.metadata.scraper,
},
"errors": [
{"url": e.url, "message": e.message, "selector": e.selector}
for e in self.errors
],
}
return yaml.dump(envelope, default_flow_style=False, allow_unicode=True)

def save(self, path: str) -> None:
"""Write the result to ``path``, choosing format from the extension.

Supported extensions: ``.json`` → :meth:`to_json`, ``.csv`` →
:meth:`to_csv`, ``.md`` → :meth:`to_markdown`.
:meth:`to_csv`, ``.md`` → :meth:`to_markdown`, ``.ndjson`` /
``.jsonl`` → :meth:`to_ndjson`, ``.yaml`` / ``.yml`` →
:meth:`to_yaml`.
"""
from pathlib import Path as _Path

Expand All @@ -193,8 +243,13 @@ def save(self, path: str) -> None:
content = self.to_csv()
elif suffix == ".md":
content = self.to_markdown()
elif suffix in (".ndjson", ".jsonl"):
content = self.to_ndjson()
elif suffix in (".yaml", ".yml"):
content = self.to_yaml()
else:
raise ValueError(
f"Unsupported extension {suffix!r} for save(); use .json, .csv, or .md"
f"Unsupported extension {suffix!r} for save(); "
"use .json, .csv, .md, .ndjson, .jsonl, .yaml, or .yml"
)
_Path(path).write_text(content, encoding="utf-8")
95 changes: 95 additions & 0 deletions tests/test_core/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,98 @@ def test_save_unsupported_extension(self, tmp_path):
result = ScrapeResult(data=[{"a": 1}])
with pytest.raises(ValueError, match="Unsupported extension"):
result.save(str(tmp_path / "out.txt"))

# --- to_ndjson --------------------------------------------------------- #

def test_to_ndjson_round_trip(self):
result = ScrapeResult(data=[{"name": "a", "v": 1}, {"name": "b", "v": 2}])
lines = result.to_ndjson().split("\n")
assert len(lines) == 2
assert json.loads(lines[0]) == {"name": "a", "v": 1}
assert json.loads(lines[1]) == {"name": "b", "v": 2}

def test_to_ndjson_unicode_preserved(self):
result = ScrapeResult(data=[{"city": "Zurich"}, {"city": "東京"}])
lines = result.to_ndjson().split("\n")
parsed = [json.loads(line) for line in lines]
assert parsed[0]["city"] == "Zurich"
assert parsed[1]["city"] == "東京"
# ensure_ascii=False means non-ASCII chars appear literally
assert "東京" in result.to_ndjson()

def test_to_ndjson_empty(self):
assert ScrapeResult(data=[]).to_ndjson() == ""

# --- to_yaml ----------------------------------------------------------- #

def test_to_yaml(self):
pytest.importorskip("yaml")
import yaml

result = ScrapeResult(
data=[{"name": "item1"}],
metadata=ScrapeMetadata(source_urls=["https://example.com"], scraper="test"),
errors=[ScrapeError(url="https://example.com", message="warn")],
)
parsed = yaml.safe_load(result.to_yaml())
assert parsed["data"] == [{"name": "item1"}]
assert parsed["metadata"]["source_urls"] == ["https://example.com"]
assert parsed["metadata"]["scraper"] == "test"
assert len(parsed["errors"]) == 1
assert parsed["errors"][0]["message"] == "warn"

def test_to_yaml_empty_data(self):
pytest.importorskip("yaml")
import yaml

result = ScrapeResult(data=[])
parsed = yaml.safe_load(result.to_yaml())
assert parsed["data"] == []

def test_to_yaml_missing_pyyaml(self, monkeypatch):
import builtins

real_import = builtins.__import__

def mock_import(name, *args, **kwargs):
if name == "yaml":
raise ImportError("no yaml")
return real_import(name, *args, **kwargs)

monkeypatch.setattr(builtins, "__import__", mock_import)
result = ScrapeResult(data=[{"a": 1}])
with pytest.raises(ImportError, match="PyYAML is required"):
result.to_yaml()

# --- save() extensions ------------------------------------------------- #

def test_save_ndjson_and_jsonl(self, tmp_path):
result = ScrapeResult(data=[{"x": 1}, {"x": 2}])
ndjson_path = tmp_path / "out.ndjson"
jsonl_path = tmp_path / "out.jsonl"
result.save(str(ndjson_path))
result.save(str(jsonl_path))
for path in (ndjson_path, jsonl_path):
lines = path.read_text().splitlines()
assert len(lines) == 2
assert json.loads(lines[0]) == {"x": 1}
assert json.loads(lines[1]) == {"x": 2}

def test_save_yaml_and_yml(self, tmp_path):
pytest.importorskip("yaml")
import yaml

result = ScrapeResult(data=[{"a": 1}])
yaml_path = tmp_path / "out.yaml"
yml_path = tmp_path / "out.yml"
result.save(str(yaml_path))
result.save(str(yml_path))
for path in (yaml_path, yml_path):
parsed = yaml.safe_load(path.read_text())
assert parsed["data"] == [{"a": 1}]

def test_save_ndjson_empty_data(self, tmp_path):
result = ScrapeResult(data=[])
path = tmp_path / "empty.ndjson"
result.save(str(path))
assert path.read_text() == ""