leadgen-ai 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ # LeadGen AI — use ANY OpenAI-compatible API
2
+ # Developed by Gyan Ranjan
3
+
4
+ # --- Pick ONE key (whichever provider you use) ---
5
+ OPENAI_API_KEY=
6
+ # GROQ_API_KEY=
7
+ # GEMINI_API_KEY=
8
+ # DEEPSEEK_API_KEY=
9
+ # OPENROUTER_API_KEY=
10
+ # MISTRAL_API_KEY=
11
+ # TOGETHER_API_KEY=
12
+ # XAI_API_KEY=
13
+ # LEADGEN_API_KEY= # generic alias
14
+ # API_KEY= # generic alias
15
+
16
+ # --- Optional: force provider / model ---
17
+ # LEADGEN_PROVIDER=openai
18
+ # LEADGEN_MODEL=gpt-4o
19
+ # Famous shortcuts: gpt-4o, gpt-4o-mini, gemini-2.0-flash, llama-3.3-70b,
20
+ # deepseek-chat, mistral-large, claude-3.5-sonnet, o3-mini
21
+
22
+ # --- Custom OpenAI-compatible endpoint (Ollama, vLLM, Azure, etc.) ---
23
+ # LEADGEN_PROVIDER=custom
24
+ # LEADGEN_BASE_URL=http://localhost:11434/v1
25
+ # LEADGEN_MODEL=llama3.2
26
+ # LEADGEN_API_KEY=ollama
27
+
28
+ # LEADGEN_WEB_SEARCH=1
29
+ # DEFAULT_LEAD_COUNT=10
30
+ # MIN_LEAD_SCORE=40
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gyan Ranjan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ include README.md
2
+ include LICENSE
3
+ include requirements.txt
4
+ include .env.example
5
+ include leadgen.cmd
6
+ include run.ps1
7
+ include run.bat
8
+ include install.ps1
9
+ recursive-include agent *.py
10
+ prune output
11
+ prune .venv
12
+ global-exclude __pycache__
13
+ global-exclude *.py[cod]
14
+ global-exclude .env
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: leadgen-ai
3
+ Version: 1.2.0
4
+ Summary: Search any keywords and generate real sales leads. Works with any AI API (OpenAI, Groq, Gemini, DeepSeek, OpenRouter, …). Developed by Gyan Ranjan.
5
+ Author: Gyan Ranjan
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Gyan4589/leadgen-ai
8
+ Project-URL: Repository, https://github.com/Gyan4589/leadgen-ai
9
+ Project-URL: Issues, https://github.com/Gyan4589/leadgen-ai/issues
10
+ Keywords: leads,sales,cli,ai,lead-generation,openai,gpt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Office/Business
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: openai>=1.60.0
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: rich>=13.0.0
26
+ Requires-Dist: typer>=0.12.0
27
+ Requires-Dist: ddgs>=9.0.0
28
+ Dynamic: license-file
29
+
30
+ # LeadGen AI
31
+
32
+ **Search any keywords → get real sales leads**
33
+
34
+ **Works with any AI API** — OpenAI, Groq, Gemini, DeepSeek, OpenRouter, Mistral, Together, custom OpenAI-compatible servers, and more.
35
+
36
+ **Developed by Gyan Ranjan**
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install leadgen-ai
44
+ ```
45
+
46
+ From GitHub:
47
+
48
+ ```bash
49
+ pip install git+https://github.com/Gyan4589/leadgen-ai.git
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Setup (any API key)
55
+
56
+ Create a `.env` or set an environment variable. **Use the key for the provider you choose:**
57
+
58
+ ```bash
59
+ # OpenAI
60
+ set OPENAI_API_KEY=sk-...
61
+
62
+ # or Groq (fast + free tier)
63
+ set GROQ_API_KEY=gsk_...
64
+
65
+ # or Google Gemini
66
+ set GEMINI_API_KEY=...
67
+
68
+ # or DeepSeek / OpenRouter / Mistral
69
+ set DEEPSEEK_API_KEY=...
70
+ set OPENROUTER_API_KEY=...
71
+ set MISTRAL_API_KEY=...
72
+
73
+ # or generic
74
+ set LEADGEN_API_KEY=...
75
+ set LEADGEN_BASE_URL=https://api.openai.com/v1
76
+ set LEADGEN_MODEL=gpt-4o
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ```bash
84
+ # list popular models
85
+ leadgen models
86
+
87
+ # search keywords
88
+ leadgen search "dental clinics Mumbai" -n 8
89
+
90
+ # pick a famous model
91
+ leadgen search "real estate agents Texas" -m gpt-4o -n 5
92
+ leadgen search "AI startups Bangalore" -m gemini-2.0-flash
93
+ leadgen search "gyms Delhi" -p groq -m llama-3.3-70b
94
+ leadgen search "HR SaaS USA" -m claude-3.5-sonnet # via OpenRouter key
95
+
96
+ # custom OpenAI-compatible endpoint (Ollama, vLLM, Azure proxy, etc.)
97
+ leadgen search "cafes NYC" --provider custom --base-url http://localhost:11434/v1 --api-key ollama -m llama3.2
98
+
99
+ # interactive
100
+ leadgen
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Popular models
106
+
107
+ | Shortcut | Provider |
108
+ |----------|----------|
109
+ | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `o3-mini` | OpenAI |
110
+ | `gemini-2.0-flash`, `gemini-1.5-pro` | Google |
111
+ | `llama-3.3-70b` | Groq |
112
+ | `deepseek-chat` | DeepSeek |
113
+ | `mistral-large` | Mistral |
114
+ | `claude-3.5-sonnet`, `claude-sonnet-4` | OpenRouter |
115
+
116
+ ```bash
117
+ leadgen models
118
+ ```
119
+
120
+ ---
121
+
122
+ ## How it works
123
+
124
+ 1. You type **keywords**
125
+ 2. Live **web search** finds public company pages
126
+ 3. Your chosen **LLM** scores leads + drafts outreach
127
+ 4. Exports **CSV / JSON** to `output/`
128
+
129
+ ---
130
+
131
+ ## Flags
132
+
133
+ | Flag | Meaning |
134
+ |------|---------|
135
+ | `-n / --count` | Max leads |
136
+ | `-m / --model` | Model name or famous shortcut |
137
+ | `-p / --provider` | openai, groq, gemini, deepseek, openrouter, mistral, custom |
138
+ | `--base-url` | Custom API base URL |
139
+ | `--api-key` | Pass key on CLI (prefer env for safety) |
140
+ | `-g / --geo` | Geography filter |
141
+ | `--min-score` | Drop weak leads |
142
+
143
+ ---
144
+
145
+ ## Ethics
146
+
147
+ - Public sources only
148
+ - No invented private emails
149
+ - Verify before outreach
150
+ - Respect spam / privacy laws
151
+
152
+ ---
153
+
154
+ ## Credits
155
+
156
+ **LeadGen AI**
157
+ **Developed by Gyan Ranjan**
158
+ https://github.com/Gyan4589/leadgen-ai
@@ -0,0 +1,129 @@
1
+ # LeadGen AI
2
+
3
+ **Search any keywords → get real sales leads**
4
+
5
+ **Works with any AI API** — OpenAI, Groq, Gemini, DeepSeek, OpenRouter, Mistral, Together, custom OpenAI-compatible servers, and more.
6
+
7
+ **Developed by Gyan Ranjan**
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install leadgen-ai
15
+ ```
16
+
17
+ From GitHub:
18
+
19
+ ```bash
20
+ pip install git+https://github.com/Gyan4589/leadgen-ai.git
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Setup (any API key)
26
+
27
+ Create a `.env` or set an environment variable. **Use the key for the provider you choose:**
28
+
29
+ ```bash
30
+ # OpenAI
31
+ set OPENAI_API_KEY=sk-...
32
+
33
+ # or Groq (fast + free tier)
34
+ set GROQ_API_KEY=gsk_...
35
+
36
+ # or Google Gemini
37
+ set GEMINI_API_KEY=...
38
+
39
+ # or DeepSeek / OpenRouter / Mistral
40
+ set DEEPSEEK_API_KEY=...
41
+ set OPENROUTER_API_KEY=...
42
+ set MISTRAL_API_KEY=...
43
+
44
+ # or generic
45
+ set LEADGEN_API_KEY=...
46
+ set LEADGEN_BASE_URL=https://api.openai.com/v1
47
+ set LEADGEN_MODEL=gpt-4o
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # list popular models
56
+ leadgen models
57
+
58
+ # search keywords
59
+ leadgen search "dental clinics Mumbai" -n 8
60
+
61
+ # pick a famous model
62
+ leadgen search "real estate agents Texas" -m gpt-4o -n 5
63
+ leadgen search "AI startups Bangalore" -m gemini-2.0-flash
64
+ leadgen search "gyms Delhi" -p groq -m llama-3.3-70b
65
+ leadgen search "HR SaaS USA" -m claude-3.5-sonnet # via OpenRouter key
66
+
67
+ # custom OpenAI-compatible endpoint (Ollama, vLLM, Azure proxy, etc.)
68
+ leadgen search "cafes NYC" --provider custom --base-url http://localhost:11434/v1 --api-key ollama -m llama3.2
69
+
70
+ # interactive
71
+ leadgen
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Popular models
77
+
78
+ | Shortcut | Provider |
79
+ |----------|----------|
80
+ | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `o3-mini` | OpenAI |
81
+ | `gemini-2.0-flash`, `gemini-1.5-pro` | Google |
82
+ | `llama-3.3-70b` | Groq |
83
+ | `deepseek-chat` | DeepSeek |
84
+ | `mistral-large` | Mistral |
85
+ | `claude-3.5-sonnet`, `claude-sonnet-4` | OpenRouter |
86
+
87
+ ```bash
88
+ leadgen models
89
+ ```
90
+
91
+ ---
92
+
93
+ ## How it works
94
+
95
+ 1. You type **keywords**
96
+ 2. Live **web search** finds public company pages
97
+ 3. Your chosen **LLM** scores leads + drafts outreach
98
+ 4. Exports **CSV / JSON** to `output/`
99
+
100
+ ---
101
+
102
+ ## Flags
103
+
104
+ | Flag | Meaning |
105
+ |------|---------|
106
+ | `-n / --count` | Max leads |
107
+ | `-m / --model` | Model name or famous shortcut |
108
+ | `-p / --provider` | openai, groq, gemini, deepseek, openrouter, mistral, custom |
109
+ | `--base-url` | Custom API base URL |
110
+ | `--api-key` | Pass key on CLI (prefer env for safety) |
111
+ | `-g / --geo` | Geography filter |
112
+ | `--min-score` | Drop weak leads |
113
+
114
+ ---
115
+
116
+ ## Ethics
117
+
118
+ - Public sources only
119
+ - No invented private emails
120
+ - Verify before outreach
121
+ - Respect spam / privacy laws
122
+
123
+ ---
124
+
125
+ ## Credits
126
+
127
+ **LeadGen AI**
128
+ **Developed by Gyan Ranjan**
129
+ https://github.com/Gyan4589/leadgen-ai
@@ -0,0 +1,6 @@
1
+ """LeadGen AI — keyword lead search with any AI API. Developed by Gyan Ranjan."""
2
+
3
+ from .pipeline import LeadAgent
4
+ from .branding import APP_NAME, CREDIT, DEVELOPER, VERSION
5
+
6
+ __all__ = ["LeadAgent", "APP_NAME", "CREDIT", "DEVELOPER", "VERSION"]
@@ -0,0 +1,15 @@
1
+ """Product branding — LeadGen AI by Gyan Ranjan."""
2
+
3
+ APP_NAME = "LeadGen AI"
4
+ APP_TAGLINE = "Search any keywords → get real sales leads (any AI API)"
5
+ DEVELOPER = "Gyan Ranjan"
6
+ CREDIT = f"Developed by {DEVELOPER}"
7
+ VERSION = "1.2.0"
8
+
9
+
10
+ def banner_lines() -> str:
11
+ return (
12
+ f"[bold cyan]{APP_NAME}[/] v{VERSION}\n"
13
+ f"[dim]{APP_TAGLINE}[/]\n"
14
+ f"[magenta]{CREDIT}[/]"
15
+ )
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ from .models_catalog import FAMOUS_MODELS, PROVIDERS
10
+
11
+ _ROOT = Path(__file__).resolve().parent.parent
12
+ load_dotenv(_ROOT / ".env")
13
+ load_dotenv()
14
+
15
+
16
+ def _first_env(*names: str) -> str:
17
+ for name in names:
18
+ val = os.getenv(name, "").strip()
19
+ if val:
20
+ return val
21
+ return ""
22
+
23
+
24
+ def resolve_api_key(provider: str) -> str:
25
+ """Accept any common API key env var so users can plug any provider."""
26
+ meta = PROVIDERS.get(provider, PROVIDERS["custom"])
27
+ # provider-specific first, then generic aliases
28
+ return _first_env(
29
+ meta["key_env"],
30
+ "LEADGEN_API_KEY",
31
+ "API_KEY",
32
+ "OPENAI_API_KEY",
33
+ "XAI_API_KEY",
34
+ "GROQ_API_KEY",
35
+ "ANTHROPIC_API_KEY",
36
+ "GEMINI_API_KEY",
37
+ "GOOGLE_API_KEY",
38
+ "DEEPSEEK_API_KEY",
39
+ "OPENROUTER_API_KEY",
40
+ "TOGETHER_API_KEY",
41
+ "MISTRAL_API_KEY",
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Settings:
47
+ api_key: str
48
+ base_url: str
49
+ model: str
50
+ provider: str = "openai"
51
+ default_lead_count: int = 10
52
+ min_score: int = 50
53
+ use_web_search: bool = True
54
+
55
+ @classmethod
56
+ def from_env(
57
+ cls,
58
+ *,
59
+ provider: str | None = None,
60
+ model: str | None = None,
61
+ base_url: str | None = None,
62
+ api_key: str | None = None,
63
+ ) -> "Settings":
64
+ # CLI / env overrides
65
+ model_name = (model or os.getenv("LEADGEN_MODEL") or os.getenv("MODEL") or "").strip()
66
+ provider_name = (provider or os.getenv("LEADGEN_PROVIDER") or os.getenv("PROVIDER") or "").strip().lower()
67
+
68
+ # Resolve famous model shortcuts → provider + model id
69
+ if model_name and model_name.lower() in FAMOUS_MODELS:
70
+ auto_provider, auto_model = FAMOUS_MODELS[model_name.lower()]
71
+ if not provider_name:
72
+ provider_name = auto_provider
73
+ model_name = auto_model
74
+
75
+ if not provider_name:
76
+ # Auto-detect from which key is set
77
+ for p, meta in PROVIDERS.items():
78
+ if p == "custom":
79
+ continue
80
+ if os.getenv(meta["key_env"], "").strip():
81
+ provider_name = p
82
+ break
83
+ if not provider_name:
84
+ provider_name = "openai"
85
+
86
+ if provider_name not in PROVIDERS:
87
+ provider_name = "custom"
88
+
89
+ meta = PROVIDERS[provider_name]
90
+ resolved_base = (
91
+ (base_url or "").strip()
92
+ or os.getenv("LEADGEN_BASE_URL", "").strip()
93
+ or os.getenv("OPENAI_BASE_URL", "").strip()
94
+ or meta["base_url"]
95
+ )
96
+ if not resolved_base:
97
+ resolved_base = "https://api.openai.com/v1"
98
+
99
+ if not model_name:
100
+ model_name = (
101
+ os.getenv("LEADGEN_MODEL", "").strip()
102
+ or meta["default_model"]
103
+ )
104
+
105
+ key = (api_key or "").strip() or resolve_api_key(provider_name)
106
+ if not key:
107
+ raise RuntimeError(
108
+ "Missing API key. Set any of these in .env or environment:\n"
109
+ " OPENAI_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY,\n"
110
+ " OPENROUTER_API_KEY, MISTRAL_API_KEY, XAI_API_KEY, LEADGEN_API_KEY, API_KEY\n\n"
111
+ "Examples:\n"
112
+ " set OPENAI_API_KEY=sk-...\n"
113
+ " leadgen search \"dental clinics Mumbai\" --model gpt-4o\n"
114
+ " leadgen search \"gyms Delhi\" --provider groq --model llama-3.3-70b\n"
115
+ " leadgen models # list popular models\n"
116
+ )
117
+
118
+ web = os.getenv("LEADGEN_WEB_SEARCH", "1").strip().lower() not in {
119
+ "0",
120
+ "false",
121
+ "no",
122
+ "off",
123
+ }
124
+
125
+ return cls(
126
+ api_key=key,
127
+ base_url=resolved_base.rstrip("/"),
128
+ model=model_name,
129
+ provider=provider_name,
130
+ default_lead_count=int(os.getenv("DEFAULT_LEAD_COUNT", "10")),
131
+ min_score=int(os.getenv("MIN_LEAD_SCORE", "50")),
132
+ use_web_search=web,
133
+ )
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from .models import Lead, LeadRunResult
10
+
11
+ CSV_FIELDS = [
12
+ "company_name",
13
+ "website",
14
+ "industry",
15
+ "company_size",
16
+ "location",
17
+ "description",
18
+ "contact_name",
19
+ "contact_title",
20
+ "contact_email",
21
+ "linkedin_url",
22
+ "source_url",
23
+ "buying_signals",
24
+ "fit_score",
25
+ "fit_reason",
26
+ "outreach_subject",
27
+ "outreach_email",
28
+ "next_action",
29
+ ]
30
+
31
+
32
+ def default_output_dir() -> Path:
33
+ root = Path(__file__).resolve().parent.parent / "output"
34
+ root.mkdir(parents=True, exist_ok=True)
35
+ return root
36
+
37
+
38
+ def stamp() -> str:
39
+ return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
40
+
41
+
42
+ def _lead_to_row(lead: Lead) -> dict:
43
+ row = lead.model_dump()
44
+ signals = row.get("buying_signals") or []
45
+ if isinstance(signals, list):
46
+ row["buying_signals"] = "; ".join(str(s) for s in signals)
47
+ return row
48
+
49
+
50
+ def export_csv(leads: list[Lead], path: Path) -> Path:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ with path.open("w", newline="", encoding="utf-8") as f:
53
+ writer = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction="ignore")
54
+ writer.writeheader()
55
+ for lead in leads:
56
+ writer.writerow(_lead_to_row(lead))
57
+ return path
58
+
59
+
60
+ def export_json(result: LeadRunResult, path: Path) -> Path:
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
63
+ return path
64
+
65
+
66
+ def _normalize_key(name: str, website: str | None = None) -> str:
67
+ base = (name or "").strip().lower()
68
+ site = (website or "").strip().lower().removeprefix("https://").removeprefix("http://").removeprefix("www.")
69
+ site = site.rstrip("/")
70
+ return f"{base}|{site}"
71
+
72
+
73
+ def load_csv_rows(path: Path) -> list[dict]:
74
+ if not path.is_file():
75
+ return []
76
+ with path.open(newline="", encoding="utf-8") as f:
77
+ return list(csv.DictReader(f))
78
+
79
+
80
+ def merge_lead_rows(rows: Iterable[dict]) -> list[dict]:
81
+ """Dedupe by company+website, keep highest fit_score."""
82
+ best: dict[str, dict] = {}
83
+ for row in rows:
84
+ name = (row.get("company_name") or "").strip()
85
+ if not name:
86
+ continue
87
+ key = _normalize_key(name, row.get("website"))
88
+ try:
89
+ score = int(float(row.get("fit_score") or 0))
90
+ except (TypeError, ValueError):
91
+ score = 0
92
+ row = {**row, "fit_score": score}
93
+ prev = best.get(key)
94
+ if prev is None:
95
+ best[key] = row
96
+ continue
97
+ try:
98
+ prev_score = int(float(prev.get("fit_score") or 0))
99
+ except (TypeError, ValueError):
100
+ prev_score = 0
101
+ if score >= prev_score:
102
+ best[key] = row
103
+ merged = list(best.values())
104
+ merged.sort(key=lambda r: int(r.get("fit_score") or 0), reverse=True)
105
+ return merged
106
+
107
+
108
+ def write_rows_csv(rows: list[dict], path: Path) -> Path:
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ with path.open("w", newline="", encoding="utf-8") as f:
111
+ writer = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction="ignore")
112
+ writer.writeheader()
113
+ for row in rows:
114
+ out = {k: row.get(k, "") for k in CSV_FIELDS}
115
+ writer.writerow(out)
116
+ return path
117
+
118
+
119
+ def merge_all_csvs(out_dir: Path | None = None) -> Path:
120
+ """Merge every leads_*.csv in output/ into all_leads.csv (deduped)."""
121
+ out = out_dir or default_output_dir()
122
+ rows: list[dict] = []
123
+ for path in sorted(out.glob("leads_*.csv")):
124
+ rows.extend(load_csv_rows(path))
125
+ # Also include existing master if present
126
+ master = out / "all_leads.csv"
127
+ if master.is_file():
128
+ rows.extend(load_csv_rows(master))
129
+ merged = merge_lead_rows(rows)
130
+ return write_rows_csv(merged, master)
131
+
132
+
133
+ def append_to_master(leads: list[Lead], out_dir: Path | None = None) -> Path:
134
+ out = out_dir or default_output_dir()
135
+ master = out / "all_leads.csv"
136
+ existing = load_csv_rows(master)
137
+ new_rows = [_lead_to_row(lead) for lead in leads]
138
+ merged = merge_lead_rows(existing + new_rows)
139
+ return write_rows_csv(merged, master)
140
+
141
+
142
+ def export_all(result: LeadRunResult, out_dir: Path | None = None) -> dict[str, Path]:
143
+ out = out_dir or default_output_dir()
144
+ ts = stamp()
145
+ csv_path = export_csv(result.leads, out / f"leads_{ts}.csv")
146
+ json_path = export_json(result, out / f"leads_{ts}.json")
147
+ master_path = append_to_master(result.leads, out)
148
+ return {"csv": csv_path, "json": json_path, "all": master_path}