Skip to content

Commit 92cdca3

Browse files
committed
Use append-only event log checkpoints and keep 'none' in CSV
1 parent b1fa263 commit 92cdca3

7 files changed

Lines changed: 63 additions & 65 deletions

File tree

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ To persist intermediate pipeline data for verification/debugging, set:
6363

6464
- `LEADGEN_CHECKPOINT_DIR` (example: `checkpoints`)
6565

66-
This will write JSON checkpoint files such as `queries.validated.json`, `search_results.<query_id>.json`,
67-
`leads.raw.json`, `leads.deduped.json`, `leads.filtered.json`, and `leads.final.json`.
66+
This will append newline-delimited JSON events to `checkpoints/events.jsonl` (append-only event log).
6867

6968
## Inputs and outputs
7069

@@ -78,4 +77,4 @@ Output CSV columns:
7877
Notes:
7978

8079
- Leads with neither a valid email nor a valid phone are removed.
81-
- Missing `email`/`phone` values are written as empty cells in the CSV.
80+
- Missing `email`/`phone` values are written as the literal `none`.

leadgen/models/schemas.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,12 @@ def merged_with(self, other: ExtractionResult) -> ExtractionResult:
176176

177177

178178
def lead_row(lead: Lead) -> dict[str, Any]:
179-
email = "" if is_missing(lead.email) else lead.email
180-
phone = "" if is_missing(lead.phone) else lead.phone
181179
return {
182180
"title": lead.title or "",
183181
"url": lead.url,
184182
"domain": lead.domain,
185-
"email": email,
186-
"phone": phone,
183+
"email": lead.email,
184+
"phone": lead.phone,
187185
"city": lead.city,
188186
"state": lead.state,
189187
"industry": lead.industry,

leadgen/pipeline/checkpoints.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

leadgen/pipeline/event_log.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
from typing import Any
8+
9+
10+
def _ensure_dir(path: Path) -> None:
11+
path.mkdir(parents=True, exist_ok=True)
12+
13+
14+
@dataclass
15+
class EventLogWriter:
16+
directory: Path
17+
filename: str = "events.jsonl"
18+
_seq: int = 0
19+
20+
def append(self, event: str, data: Any) -> None:
21+
_ensure_dir(self.directory)
22+
self._seq += 1
23+
record = {"seq": self._seq, "event": event, "data": data}
24+
line = json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n"
25+
path = self.directory / self.filename
26+
27+
with path.open("a", encoding="utf-8", newline="\n") as f:
28+
f.write(line)
29+
f.flush()
30+
os.fsync(f.fileno())

leadgen/pipeline/runner.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
lead_row,
2424
normalize_domain_from_url,
2525
)
26-
from leadgen.pipeline.checkpoints import CheckpointWriter
2726
from leadgen.pipeline.dedupe import dedupe_leads
27+
from leadgen.pipeline.event_log import EventLogWriter
2828
from leadgen.scraping.discovery import discover_contact_like_urls, homepage_url
2929
from leadgen.scraping.extractor import extract_contacts
3030
from leadgen.scraping.fetcher import Fetcher
@@ -109,27 +109,27 @@ def __init__(
109109
self._scrape_delay_seconds = scrape_delay_seconds
110110
self._max_output_leads = max_output_leads
111111
self._sleep = sleeper
112-
self._checkpoints = CheckpointWriter(checkpoint_dir) if checkpoint_dir else None
112+
self._event_log = EventLogWriter(checkpoint_dir) if checkpoint_dir else None
113113

114114
def run(self, *, queries_path: Path, output_csv_path: Path) -> PipelineOutput:
115115
queries_file = load_queries(queries_path)
116116

117117
logger.info("Loaded %s queries from %s", len(queries_file.queries), queries_path)
118-
if self._checkpoints:
119-
self._checkpoints.write_json(
120-
"queries.validated",
121-
queries_file.model_dump(),
122-
)
118+
if self._event_log:
119+
self._event_log.append("queries.validated", queries_file.model_dump())
123120
domain_contexts: dict[str, _DomainContext] = {}
124121
leads: list[Lead] = []
125122
for query in queries_file.queries:
126123
logger.info("Searching query_id=%s", query.id)
127124
results = self._search.search(query)
128125
logger.info("Search results query_id=%s count=%s", query.id, len(results))
129-
if self._checkpoints:
130-
self._checkpoints.write_json(
131-
f"search_results.{query.id}",
132-
[r.model_dump() | {"domain": r.domain} for r in results],
126+
if self._event_log:
127+
self._event_log.append(
128+
"search.results",
129+
{
130+
"query_id": query.id,
131+
"results": [r.model_dump() | {"domain": r.domain} for r in results],
132+
},
133133
)
134134
leads.extend(
135135
self._scrape_results(
@@ -139,19 +139,24 @@ def run(self, *, queries_path: Path, output_csv_path: Path) -> PipelineOutput:
139139
domain_contexts=domain_contexts,
140140
)
141141
)
142-
if self._checkpoints:
143-
self._checkpoints.write_json(
144-
f"leads.scraped.{query.id}",
145-
[lead.model_dump() for lead in leads if lead.source_query_id == query.id],
142+
if self._event_log:
143+
self._event_log.append(
144+
"leads.scraped",
145+
{
146+
"query_id": query.id,
147+
"leads": [
148+
lead.model_dump() for lead in leads if lead.source_query_id == query.id
149+
],
150+
},
146151
)
147152

148153
logger.info("Collected %s raw leads (pre-dedupe)", len(leads))
149-
if self._checkpoints:
150-
self._checkpoints.write_json("leads.raw", [lead.model_dump() for lead in leads])
154+
if self._event_log:
155+
self._event_log.append("leads.raw", [lead.model_dump() for lead in leads])
151156
deduped = dedupe_leads(leads)
152157
logger.info("Deduped leads by domain: %s -> %s", len(leads), len(deduped))
153-
if self._checkpoints:
154-
self._checkpoints.write_json("leads.deduped", [lead.model_dump() for lead in deduped])
158+
if self._event_log:
159+
self._event_log.append("leads.deduped", [lead.model_dump() for lead in deduped])
155160

156161
filtered = [
157162
lead for lead in deduped if (is_valid_email(lead.email) or is_valid_phone(lead.phone))
@@ -166,9 +171,9 @@ def run(self, *, queries_path: Path, output_csv_path: Path) -> PipelineOutput:
166171
)
167172
)
168173
limited = filtered[: self._max_output_leads]
169-
if self._checkpoints:
170-
self._checkpoints.write_json("leads.filtered", [lead.model_dump() for lead in filtered])
171-
self._checkpoints.write_json("leads.final", [lead.model_dump() for lead in limited])
174+
if self._event_log:
175+
self._event_log.append("leads.filtered", [lead.model_dump() for lead in filtered])
176+
self._event_log.append("leads.final", [lead.model_dump() for lead in limited])
172177

173178
written = _write_csv(output_csv_path, limited)
174179
logger.info("Wrote output CSV: %s", output_csv_path)

leads.example.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
title,url,domain,email,phone,city,state,industry,source_query_id
2-
Example Domain,https://example.com,example.com,,,Austin,TX,Software,q1
2+
Example Domain,https://example.com,example.com,none,none,Austin,TX,Software,q1

tests/test_pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_pipeline_end_to_end_dedupes_and_writes_csv(tmp_path: Path) -> None:
6363
assert out.written_rows == 2
6464
csv_text = output.read_text(encoding="utf-8")
6565
assert "example.com,[email protected],+14155552671" in csv_text
66-
assert "other.com,[email protected]," in csv_text
66+
assert "other.com,[email protected],none" in csv_text
6767

6868

6969
def test_pipeline_crawls_contact_link_from_homepage(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)