oto-cli 1.0.0__py3-none-any.whl
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.
- oto/__init__.py +3 -0
- oto/cli.py +74 -0
- oto/commands/__init__.py +0 -0
- oto/commands/anthropic.py +64 -0
- oto/commands/audio.py +91 -0
- oto/commands/browser.py +257 -0
- oto/commands/company.py +23 -0
- oto/commands/enrichment.py +152 -0
- oto/commands/folk.py +238 -0
- oto/commands/google.py +355 -0
- oto/commands/notion.py +58 -0
- oto/commands/pennylane.py +272 -0
- oto/commands/search.py +36 -0
- oto/commands/sirene.py +201 -0
- oto/commands/skills.py +91 -0
- oto/commands/whatsapp.py +47 -0
- oto/config.py +167 -0
- oto/tools/.cache/notion/07a366cf2d7a0f77e7a1736805e94786e6053bb581b6b7179591b71d34843ce8.json +98 -0
- oto/tools/.cache/notion/0b21878dbd4bf5a67ff52104361a07ee575b03a075b7b81cbc4a0d5d4045f759.json +142 -0
- oto/tools/.cache/notion/0bf47e753ec29da03776bca010122393b4fc188af7f28dd11b456a74e5cae8f4.json +98 -0
- oto/tools/.cache/notion/1b2c924688fdcef8b7e3748f92ed4fdb232cadb387a7e9e157c438d32374ff97.json +4356 -0
- oto/tools/.cache/notion/2a66512043d3531dc900b8244fc13972c0abfe7760c10fbb7fc21f309238ad0b.json +132 -0
- oto/tools/.cache/notion/2b59aed15d0c747ec2cae70a1eb3f554b3e170875110b82f8e4614b761c84dcf.json +142 -0
- oto/tools/.cache/notion/2f7d1284021d30e842cc9a2b506e87b4a32c61e8e9abb616aec37828c441231d.json +220 -0
- oto/tools/.cache/notion/33eb548bce70fb7b0e8eb09211ba6c03ad22b6e7a289606b05c5d690f724d8e4.json +1659 -0
- oto/tools/.cache/notion/364d3a5e790f3d8b5137aae0a5a9ee151cd8c9f7dacb4fd669220f968405ea36.json +88 -0
- oto/tools/.cache/notion/3e7e836a6bf18f951d4887fefeacb146473491ad2cbaa1793c79af08b7ef45b6.json +132 -0
- oto/tools/.cache/notion/3f95b2d8bdf3d47e8f6c167d6d9a0889f27b64e8c78f0afdad538fecae070858.json +98 -0
- oto/tools/.cache/notion/46c38d10065fb68ca912692df53b6e54eb1f6f9cdff342de563020917e27ccf5.json +98 -0
- oto/tools/.cache/notion/51fa4d44044d751ca2319d87d607bd58a01004a24a8065808d65c273b35d3a10.json +210 -0
- oto/tools/.cache/notion/59888e9c297cda6836afb560fb4048b100f98eb3abead8b175fe92fbf858210b.json +186 -0
- oto/tools/.cache/notion/60df8d17a4e08fdbafb0ea398ff68d45cb79da7c01bd129e98264be1bff7ed1d.json +176 -0
- oto/tools/.cache/notion/62a44af3f86b5841d905789565a0208f80c1a304bcfc8889ccd55f61f29508b2.json +132 -0
- oto/tools/.cache/notion/69ec094fc12a40d1591abbd4f21295af672389963f900ea421a6d75b6f21f03b.json +176 -0
- oto/tools/.cache/notion/703fc49f07e73447e176fa69f31c7ff7bbb7c8c95325af4ca1a8664385bfeb0b.json +98 -0
- oto/tools/.cache/notion/7e247cf818c2a930de8a5a8599716900cb5af90570a9f701699dd8897fe43c8d.json +88 -0
- oto/tools/.cache/notion/86741d020ca89789bda8156315ead5e980068c19754baf6e611ffc618557dc06.json +115 -0
- oto/tools/.cache/notion/8cc6e5e1d955310412df09c9179ec67d42767d1b557262e28bc2afea05ab7c24.json +210 -0
- oto/tools/.cache/notion/989d88c22dd009e5683987e70671d1e20fd6a5a3e9ee7670db9ea602fe8fbded.json +4605 -0
- oto/tools/.cache/notion/999d01d80c8ef0c856572028e31e701cad8c5704b8886388b756d32ef50cbfaf.json +132 -0
- oto/tools/.cache/notion/9f10e5fa6404211b156b2f5c22a0644e5eb0c49774d2b4f8d768211f0c6aea22.json +3380 -0
- oto/tools/.cache/notion/9f950bdfc2aeb9ae5e1b185e827636f83fa44a9d7de0d1462d87926508b26907.json +142 -0
- oto/tools/.cache/notion/a4719c76c9ff4c4b056e87689e2f487770c35d82d70acb098ead7712e0d3be8d.json +142 -0
- oto/tools/.cache/notion/a808160a7352fa1a3029ef1e2929dfa99b5021b0d22ce1cfb9efe3c68f7e7af1.json +98 -0
- oto/tools/.cache/notion/a9c29806556409fbfcd7622b876244caed085c63c70380dc6caacd162e6d05a9.json +132 -0
- oto/tools/.cache/notion/b3142c5509452a15382cade5cb81324a26d1946d128a1b4342a143872f9354fe.json +149 -0
- oto/tools/.cache/notion/b516a0820a66ae04229bfcb7f0463be0867f31a8dc1b875e57aba02b970a7a31.json +98 -0
- oto/tools/.cache/notion/c175f8c35c81ebd2fb9e4bf4d2f1cc29f98b45006f59354c9f33ee780609d896.json +247 -0
- oto/tools/.cache/notion/c950254390b901d475937c868e27e74960720fe65e2d8506bd39e30c634e619d.json +98 -0
- oto/tools/.cache/notion/cac99fb54fa178e1a7f72e5212e1e99eff5fe72a1fa52f65fcee432bcf528b53.json +166 -0
- oto/tools/.cache/notion/e38237abecf19ce5b9a92ed90723d2642a11b0f1ef189b1a1fc10b7ebcd3d4a4.json +88 -0
- oto/tools/.cache/notion/e58e933def9b7000d791c83f6660adbecac59ab61e54f8d855a52d80200eb929.json +292 -0
- oto/tools/.cache/notion/f8088377a1049850b0ec1acabf09da8504fb1f194479cc43ce4c31db3895afcd.json +176 -0
- oto/tools/.cache/notion/fc2ccb6edbe2020010e036a69e7ece3ab3f007c9a4fed39066d6792d332e65af.json +142 -0
- oto/tools/.cache/notion/ff117c74176f8fc26aba7f06649c7794dc4a0c51194033f453b16c8af8ac4201.json +36 -0
- oto/tools/__init__.py +1 -0
- oto/tools/anthropic/__init__.py +5 -0
- oto/tools/anthropic/client.py +354 -0
- oto/tools/anthropic_batch/__init__.py +5 -0
- oto/tools/anthropic_batch/client.py +352 -0
- oto/tools/apollo/__init__.py +5 -0
- oto/tools/apollo/client.py +191 -0
- oto/tools/attio/__init__.py +5 -0
- oto/tools/attio/client.py +242 -0
- oto/tools/audio/__init__.py +5 -0
- oto/tools/audio/client.py +116 -0
- oto/tools/browser/__init__.py +26 -0
- oto/tools/browser/crunchbase.py +423 -0
- oto/tools/browser/g2.py +236 -0
- oto/tools/browser/indeed.py +282 -0
- oto/tools/browser/linkedin.py +821 -0
- oto/tools/browser/pappers.py +344 -0
- oto/tools/clearbit/__init__.py +5 -0
- oto/tools/clearbit/client.py +126 -0
- oto/tools/collective/__init__.py +5 -0
- oto/tools/collective/client.py +333 -0
- oto/tools/common/__init__.py +5 -0
- oto/tools/common/rate_limiter.py +457 -0
- oto/tools/figma/__init__.py +5 -0
- oto/tools/figma/client.py +254 -0
- oto/tools/folk/__init__.py +5 -0
- oto/tools/folk/client.py +203 -0
- oto/tools/gemini/__init__.py +5 -0
- oto/tools/gemini/client.py +264 -0
- oto/tools/google/__init__.py +1 -0
- oto/tools/google/calendar/__init__.py +0 -0
- oto/tools/google/calendar/lib/__init__.py +0 -0
- oto/tools/google/calendar/lib/calendar_client.py +155 -0
- oto/tools/google/credentials.py +177 -0
- oto/tools/google/docs/get_section.py +53 -0
- oto/tools/google/docs/insert_section.py +56 -0
- oto/tools/google/docs/insert_text.py +99 -0
- oto/tools/google/docs/lib/__init__.py +0 -0
- oto/tools/google/docs/lib/docs_client.py +341 -0
- oto/tools/google/docs/list_headings.py +44 -0
- oto/tools/google/docs/move_section.py +37 -0
- oto/tools/google/docs/replace_section.py +47 -0
- oto/tools/google/docs/sync/__init__.py +1 -0
- oto/tools/google/docs/sync/gdoc_sync.py +427 -0
- oto/tools/google/drive/README.md +308 -0
- oto/tools/google/drive/check_quota.py +31 -0
- oto/tools/google/drive/copy_file.py +76 -0
- oto/tools/google/drive/create_folder.py +58 -0
- oto/tools/google/drive/download_file.py +60 -0
- oto/tools/google/drive/export_doc.py +64 -0
- oto/tools/google/drive/export_slides.py +95 -0
- oto/tools/google/drive/extract_slides_content.py +113 -0
- oto/tools/google/drive/lib/drive_client.py +454 -0
- oto/tools/google/drive/list_files.py +97 -0
- oto/tools/google/drive/list_shared_drives.py +32 -0
- oto/tools/google/drive/move_file.py +57 -0
- oto/tools/google/drive/requirements.txt +4 -0
- oto/tools/google/drive/tool.yaml +210 -0
- oto/tools/google/drive/upload_file.py +90 -0
- oto/tools/google/gmail/__init__.py +1 -0
- oto/tools/google/gmail/get_message.py +33 -0
- oto/tools/google/gmail/lib/__init__.py +1 -0
- oto/tools/google/gmail/lib/gmail_client.py +385 -0
- oto/tools/google/gmail/list_messages.py +37 -0
- oto/tools/google/gmail/requirements.txt +3 -0
- oto/tools/google/gmail/search.py +34 -0
- oto/tools/google/gmail/send.py +39 -0
- oto/tools/google/gmail/tool.yaml +107 -0
- oto/tools/google/keep/__init__.py +0 -0
- oto/tools/google/keep/create_note.py +42 -0
- oto/tools/google/keep/get_master_token.py +101 -0
- oto/tools/google/keep/get_note.py +33 -0
- oto/tools/google/keep/lib/__init__.py +0 -0
- oto/tools/google/keep/lib/keep_client.py +273 -0
- oto/tools/google/keep/list_notes.py +42 -0
- oto/tools/google/keep/search.py +34 -0
- oto/tools/google/sheets/create_sheet_direct.py +157 -0
- oto/tools/google/sheets/create_sheet_from_csv.py +167 -0
- oto/tools/google/sheets/create_sheet_in_folder.py +165 -0
- oto/tools/google/sheets/create_sheet_via_drive.py +157 -0
- oto/tools/google/sheets/tool.yaml +74 -0
- oto/tools/google/slides/.folders +19 -0
- oto/tools/google/slides/321-LAYOUTS.md +203 -0
- oto/tools/google/slides/README.md +210 -0
- oto/tools/google/slides/USAGE-EDIT.md +626 -0
- oto/tools/google/slides/USAGE.md +158 -0
- oto/tools/google/slides/create-demo-presentation.py +348 -0
- oto/tools/google/slides/generate_slides.py +437 -0
- oto/tools/google/slides/lib/__init__.py +1 -0
- oto/tools/google/slides/lib/content_filler.py +579 -0
- oto/tools/google/slides/lib/layout_mappings.py +182 -0
- oto/tools/google/slides/lib/slides_client.py +1239 -0
- oto/tools/google/slides/requirements.txt +5 -0
- oto/tools/google/slides/test-copy-and-edit.py +187 -0
- oto/tools/google/slides/test-copy-slide.py +148 -0
- oto/tools/google/slides/test-edit.py +183 -0
- oto/tools/google/slides/tool.yaml +37 -0
- oto/tools/groq/__init__.py +5 -0
- oto/tools/groq/client.py +165 -0
- oto/tools/hithorizons/__init__.py +5 -0
- oto/tools/hithorizons/client.py +168 -0
- oto/tools/hunter/__init__.py +5 -0
- oto/tools/hunter/client.py +104 -0
- oto/tools/kaspr/__init__.py +5 -0
- oto/tools/kaspr/client.py +80 -0
- oto/tools/lemlist/__init__.py +5 -0
- oto/tools/lemlist/client.py +486 -0
- oto/tools/mistral/__init__.py +5 -0
- oto/tools/mistral/client.py +149 -0
- oto/tools/naf/__init__.py +5 -0
- oto/tools/naf/suggester.py +140 -0
- oto/tools/notion/README.md +310 -0
- oto/tools/notion/append_blocks.py +204 -0
- oto/tools/notion/append_blocks_chunked.py +296 -0
- oto/tools/notion/count_database_entries.py +47 -0
- oto/tools/notion/create_database_from_csv.py +269 -0
- oto/tools/notion/create_page.py +65 -0
- oto/tools/notion/get_database.py +67 -0
- oto/tools/notion/get_page.py +78 -0
- oto/tools/notion/known-pages.md +75 -0
- oto/tools/notion/lib/markdown_converter.py +287 -0
- oto/tools/notion/lib/notion_client.py +313 -0
- oto/tools/notion/list_teamspaces.py +244 -0
- oto/tools/notion/notion +122 -0
- oto/tools/notion/query_database.py +77 -0
- oto/tools/notion/requirements.txt +1 -0
- oto/tools/notion/search.py +93 -0
- oto/tools/notion/tool.yaml +235 -0
- oto/tools/notion/update_page.py +65 -0
- oto/tools/pennylane/__init__.py +5 -0
- oto/tools/pennylane/client.py +386 -0
- oto/tools/phantombuster/__init__.py +5 -0
- oto/tools/phantombuster/client.py +177 -0
- oto/tools/resend/__init__.py +5 -0
- oto/tools/resend/client.py +149 -0
- oto/tools/serpapi/__init__.py +5 -0
- oto/tools/serpapi/client.py +128 -0
- oto/tools/serper/__init__.py +5 -0
- oto/tools/serper/client.py +210 -0
- oto/tools/sirene/__init__.py +34 -0
- oto/tools/sirene/client.py +474 -0
- oto/tools/sirene/data/naf_codes.txt +732 -0
- oto/tools/sirene/entreprises.py +175 -0
- oto/tools/sirene/stock.py +377 -0
- oto/tools/slack/__init__.py +5 -0
- oto/tools/slack/client.py +202 -0
- oto/tools/unsplash/__init__.py +5 -0
- oto/tools/unsplash/client.py +197 -0
- oto/tools/whatsapp/__init__.py +3 -0
- oto/tools/whatsapp/client.py +68 -0
- oto/tools/whatsapp/node/package-lock.json +1564 -0
- oto/tools/whatsapp/node/package.json +11 -0
- oto/tools/whatsapp/node/whatsapp.mjs +357 -0
- oto/tools/wttj/__init__.py +5 -0
- oto/tools/wttj/client.py +251 -0
- oto/tools/zerobounce/__init__.py +5 -0
- oto/tools/zerobounce/client.py +93 -0
- oto_cli-1.0.0.dist-info/METADATA +171 -0
- oto_cli-1.0.0.dist-info/RECORD +217 -0
- oto_cli-1.0.0.dist-info/WHEEL +4 -0
- oto_cli-1.0.0.dist-info/entry_points.txt +2 -0
- oto_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
oto/__init__.py
ADDED
oto/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Oto CLI - composable toolkit for AI agents."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="oto",
|
|
11
|
+
help="CLI toolkit for AI agents. JSON on stdout, composable with pipes.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Auto-discover commands from oto/commands/*.py
|
|
16
|
+
_commands_dir = Path(__file__).parent / "commands"
|
|
17
|
+
for _cmd_file in sorted(_commands_dir.glob("*.py")):
|
|
18
|
+
if _cmd_file.name.startswith("_"):
|
|
19
|
+
continue
|
|
20
|
+
_module_name = _cmd_file.stem
|
|
21
|
+
try:
|
|
22
|
+
_module = importlib.import_module(f"oto.commands.{_module_name}")
|
|
23
|
+
except ImportError:
|
|
24
|
+
continue
|
|
25
|
+
if hasattr(_module, "app"):
|
|
26
|
+
app.add_typer(_module.app, name=_module_name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("config")
|
|
30
|
+
def show_config():
|
|
31
|
+
"""Show current configuration and detected secrets."""
|
|
32
|
+
from oto.config import _find_project_secrets, _get_user_secrets, get_secret
|
|
33
|
+
|
|
34
|
+
project_secrets = _find_project_secrets()
|
|
35
|
+
user_secrets = _get_user_secrets()
|
|
36
|
+
|
|
37
|
+
print("Secrets files:")
|
|
38
|
+
print(f" Project: {project_secrets or '.otomata/secrets.env (not found)'}")
|
|
39
|
+
print(f" User: {user_secrets}{' (exists)' if user_secrets.exists() else ' (not found)'}")
|
|
40
|
+
print()
|
|
41
|
+
print("Secrets status:")
|
|
42
|
+
secrets = [
|
|
43
|
+
"GOOGLE_SERVICE_ACCOUNT",
|
|
44
|
+
"GOOGLE_OAUTH_CLIENT",
|
|
45
|
+
"NOTION_API_KEY",
|
|
46
|
+
"LINKEDIN_COOKIE",
|
|
47
|
+
"SIRENE_API_KEY",
|
|
48
|
+
"SERPER_API_KEY",
|
|
49
|
+
"KASPR_API_KEY",
|
|
50
|
+
"HUNTER_API_KEY",
|
|
51
|
+
"LEMLIST_API_KEY",
|
|
52
|
+
"PENNYLANE_API_KEY",
|
|
53
|
+
"GROQ_API_KEY",
|
|
54
|
+
"ANTHROPIC_ADMIN_API_KEY",
|
|
55
|
+
"FOLK_API_KEY",
|
|
56
|
+
"TULS_API_TOKEN",
|
|
57
|
+
]
|
|
58
|
+
for name in secrets:
|
|
59
|
+
status = "+" if get_secret(name) else "-"
|
|
60
|
+
print(f" {status} {name}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
try:
|
|
65
|
+
app()
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
if "not found. Set it via:" in str(e):
|
|
68
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
main()
|
oto/commands/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Anthropic Admin API commands (usage & cost tracking)."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Anthropic Admin API (usage & cost tracking)")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.command("usage")
|
|
10
|
+
def usage(
|
|
11
|
+
days: int = typer.Option(7, "--days", "-d", help="Number of days to look back"),
|
|
12
|
+
bucket: str = typer.Option("1d", "--bucket", "-b", help="Bucket width: 1m, 1h, 1d"),
|
|
13
|
+
group_by: Optional[str] = typer.Option("model", "--group-by", "-g", help="Group by: model, api_key_id, workspace_id, service_tier"),
|
|
14
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="Filter to specific model"),
|
|
15
|
+
):
|
|
16
|
+
"""Get token usage report."""
|
|
17
|
+
import json
|
|
18
|
+
from oto.tools.anthropic import AnthropicAdminClient
|
|
19
|
+
|
|
20
|
+
client = AnthropicAdminClient()
|
|
21
|
+
groups = [g.strip() for g in group_by.split(",")] if group_by else None
|
|
22
|
+
models = [model] if model else None
|
|
23
|
+
data = client.get_usage(bucket_width=bucket, group_by=groups, models=models,
|
|
24
|
+
limit=days if bucket == "1d" else None)
|
|
25
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("cost")
|
|
29
|
+
def cost(
|
|
30
|
+
days: int = typer.Option(30, "--days", "-d", help="Number of days to look back"),
|
|
31
|
+
group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help="Group by: workspace_id, description"),
|
|
32
|
+
):
|
|
33
|
+
"""Get cost report (daily, USD)."""
|
|
34
|
+
import json
|
|
35
|
+
from oto.tools.anthropic import AnthropicAdminClient
|
|
36
|
+
|
|
37
|
+
client = AnthropicAdminClient()
|
|
38
|
+
groups = [g.strip() for g in group_by.split(",")] if group_by else None
|
|
39
|
+
data = client.get_costs(group_by=groups)
|
|
40
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("summary")
|
|
44
|
+
def summary(
|
|
45
|
+
days: int = typer.Option(7, "--days", "-d", help="Number of days to look back"),
|
|
46
|
+
):
|
|
47
|
+
"""Daily usage summary with estimated costs by model."""
|
|
48
|
+
import json
|
|
49
|
+
from oto.tools.anthropic import AnthropicAdminClient
|
|
50
|
+
|
|
51
|
+
client = AnthropicAdminClient()
|
|
52
|
+
result = client.get_daily_summary(days=days)
|
|
53
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("today")
|
|
57
|
+
def today():
|
|
58
|
+
"""Today's usage and estimated cost."""
|
|
59
|
+
import json
|
|
60
|
+
from oto.tools.anthropic import AnthropicAdminClient
|
|
61
|
+
|
|
62
|
+
client = AnthropicAdminClient()
|
|
63
|
+
result = client.get_today_cost()
|
|
64
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
oto/commands/audio.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Audio recorder commands — push transcripts to tuls.me."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Audio recorder — transcripts & summaries on tuls.me")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _out(data):
|
|
13
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client():
|
|
17
|
+
from oto.tools.audio import AudioClient
|
|
18
|
+
return AudioClient()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("push")
|
|
22
|
+
def push(
|
|
23
|
+
folder: Optional[str] = typer.Argument(None, help="Recording folder path (default: latest in ~/Recordings)"),
|
|
24
|
+
with_audio: bool = typer.Option(False, "--with-audio", help="Include audio.mp3 in upload"),
|
|
25
|
+
):
|
|
26
|
+
"""Push a local recording to tuls.me."""
|
|
27
|
+
path = _resolve_folder(folder)
|
|
28
|
+
try:
|
|
29
|
+
result = _client().push(path, with_audio=with_audio)
|
|
30
|
+
_out(result)
|
|
31
|
+
except FileNotFoundError as e:
|
|
32
|
+
print(f"Error: {e}")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
if "409" in str(e):
|
|
36
|
+
print(f"Already pushed: {path.name}")
|
|
37
|
+
raise typer.Exit(0)
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("list")
|
|
42
|
+
def list_recordings():
|
|
43
|
+
"""List all recordings."""
|
|
44
|
+
_out(_client().list())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("get")
|
|
48
|
+
def get(recording_id: str = typer.Argument(..., help="Recording ID")):
|
|
49
|
+
"""Get a recording with transcript and summary."""
|
|
50
|
+
_out(_client().get(recording_id))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("delete")
|
|
54
|
+
def delete(recording_id: str = typer.Argument(..., help="Recording ID")):
|
|
55
|
+
"""Delete a recording."""
|
|
56
|
+
_client().delete(recording_id)
|
|
57
|
+
print(f"Deleted {recording_id}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("summarize")
|
|
61
|
+
def summarize(
|
|
62
|
+
recording_id: str = typer.Argument(..., help="Recording ID"),
|
|
63
|
+
prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Custom summary prompt"),
|
|
64
|
+
):
|
|
65
|
+
"""Trigger AI summary on a transcribed recording."""
|
|
66
|
+
_out(_client().summarize(recording_id, prompt=prompt))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_folder(folder: Optional[str]) -> Path:
|
|
70
|
+
"""Resolve recording folder path (default: latest in ~/Recordings)."""
|
|
71
|
+
if folder:
|
|
72
|
+
path = Path(folder).expanduser()
|
|
73
|
+
if not path.is_absolute():
|
|
74
|
+
recordings_dir = Path.home() / "Recordings"
|
|
75
|
+
path = recordings_dir / folder
|
|
76
|
+
if not path.is_dir():
|
|
77
|
+
print(f"Error: Not a directory: {path}")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
recordings_dir = Path.home() / "Recordings"
|
|
82
|
+
if not recordings_dir.is_dir():
|
|
83
|
+
print(f"Error: {recordings_dir} not found")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
folders = sorted(recordings_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
87
|
+
folders = [f for f in folders if f.is_dir()]
|
|
88
|
+
if not folders:
|
|
89
|
+
print("Error: No recordings found")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
return folders[0]
|
oto/commands/browser.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Browser automation commands (LinkedIn, Crunchbase, Pappers, Indeed, G2)."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Browser automation tools (LinkedIn, Crunchbase, Indeed, etc.)")
|
|
7
|
+
|
|
8
|
+
# LinkedIn subcommands
|
|
9
|
+
linkedin_app = typer.Typer(help="LinkedIn scraping (profile, company, employees, search)")
|
|
10
|
+
app.add_typer(linkedin_app, name="linkedin")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _linkedin_client(**kwargs):
|
|
14
|
+
"""Create LinkedInClient with common options."""
|
|
15
|
+
from oto.tools.browser import LinkedInClient
|
|
16
|
+
return LinkedInClient(**kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@linkedin_app.command("profile")
|
|
20
|
+
def linkedin_profile(
|
|
21
|
+
url: str = typer.Argument(..., help="LinkedIn profile URL"),
|
|
22
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
23
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
24
|
+
identity: str = typer.Option("default", help="Identity for rate limiting"),
|
|
25
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
26
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
27
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
28
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
29
|
+
):
|
|
30
|
+
"""Scrape LinkedIn profile page."""
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
async def run():
|
|
35
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, identity=identity, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
36
|
+
return await client.scrape_profile(url)
|
|
37
|
+
|
|
38
|
+
result = asyncio.run(run())
|
|
39
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@linkedin_app.command("company")
|
|
43
|
+
def linkedin_company(
|
|
44
|
+
url: str = typer.Argument(..., help="LinkedIn company URL"),
|
|
45
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
46
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
47
|
+
identity: str = typer.Option("default", help="Identity for rate limiting"),
|
|
48
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
49
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
50
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
51
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
52
|
+
):
|
|
53
|
+
"""Scrape LinkedIn company page."""
|
|
54
|
+
import asyncio
|
|
55
|
+
import json
|
|
56
|
+
|
|
57
|
+
async def run():
|
|
58
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, identity=identity, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
59
|
+
return await client.scrape_company(url)
|
|
60
|
+
|
|
61
|
+
result = asyncio.run(run())
|
|
62
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@linkedin_app.command("search")
|
|
66
|
+
def linkedin_search(
|
|
67
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
68
|
+
limit: int = typer.Option(5, help="Max results"),
|
|
69
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
70
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
71
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
72
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
73
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
74
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
75
|
+
):
|
|
76
|
+
"""Search LinkedIn companies."""
|
|
77
|
+
import asyncio
|
|
78
|
+
import json
|
|
79
|
+
|
|
80
|
+
async def run():
|
|
81
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
82
|
+
return await client.search_companies(query, limit=limit)
|
|
83
|
+
|
|
84
|
+
result = asyncio.run(run())
|
|
85
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@linkedin_app.command("people")
|
|
89
|
+
def linkedin_people(
|
|
90
|
+
slug: str = typer.Argument(..., help="LinkedIn company slug"),
|
|
91
|
+
limit: int = typer.Option(20, help="Max results"),
|
|
92
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
93
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
94
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
95
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
96
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
97
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
98
|
+
):
|
|
99
|
+
"""List people from a LinkedIn company page."""
|
|
100
|
+
import asyncio
|
|
101
|
+
import json
|
|
102
|
+
|
|
103
|
+
async def run():
|
|
104
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
105
|
+
return await client.get_company_people(slug, limit=limit)
|
|
106
|
+
|
|
107
|
+
result = asyncio.run(run())
|
|
108
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@linkedin_app.command("employees")
|
|
112
|
+
def linkedin_employees(
|
|
113
|
+
company: str = typer.Argument(..., help="LinkedIn company slug"),
|
|
114
|
+
keywords: Optional[str] = typer.Option(None, help="Title keywords (comma-separated)"),
|
|
115
|
+
limit: int = typer.Option(10, help="Max results"),
|
|
116
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
117
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
118
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
119
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
120
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
121
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
122
|
+
):
|
|
123
|
+
"""Search company employees on LinkedIn."""
|
|
124
|
+
import asyncio
|
|
125
|
+
import json
|
|
126
|
+
|
|
127
|
+
async def run():
|
|
128
|
+
kw_list = keywords.split(",") if keywords else None
|
|
129
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
130
|
+
return await client.search_employees(company, keywords=kw_list, limit=limit)
|
|
131
|
+
|
|
132
|
+
result = asyncio.run(run())
|
|
133
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@linkedin_app.command("search-people")
|
|
137
|
+
def linkedin_search_people(
|
|
138
|
+
keywords: str = typer.Argument(..., help="Search keywords (e.g., 'credit manager')"),
|
|
139
|
+
geo: Optional[str] = typer.Option("105015875", help="Geo URN ID (default: France)"),
|
|
140
|
+
limit: int = typer.Option(50, help="Max results"),
|
|
141
|
+
pages: int = typer.Option(5, help="Max pages to scrape"),
|
|
142
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
143
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
144
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
145
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
146
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
147
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
148
|
+
):
|
|
149
|
+
"""Search people on LinkedIn by keywords and location."""
|
|
150
|
+
import asyncio
|
|
151
|
+
import json
|
|
152
|
+
|
|
153
|
+
async def run():
|
|
154
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
155
|
+
return await client.search_people(keywords, geo=geo, limit=limit, pages=pages)
|
|
156
|
+
|
|
157
|
+
result = asyncio.run(run())
|
|
158
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@linkedin_app.command("posts")
|
|
162
|
+
def linkedin_posts(
|
|
163
|
+
url: str = typer.Argument(..., help="LinkedIn profile URL"),
|
|
164
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Max posts"),
|
|
165
|
+
cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
|
|
166
|
+
cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
|
|
167
|
+
profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
|
|
168
|
+
channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
|
|
169
|
+
no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
|
|
170
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
171
|
+
):
|
|
172
|
+
"""Scrape posts from a LinkedIn profile."""
|
|
173
|
+
import asyncio
|
|
174
|
+
import json
|
|
175
|
+
|
|
176
|
+
async def run():
|
|
177
|
+
async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
|
|
178
|
+
return await client.scrape_profile_posts(url, max_posts=limit)
|
|
179
|
+
|
|
180
|
+
result = asyncio.run(run())
|
|
181
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command("crunchbase-company")
|
|
185
|
+
def crunchbase_company(
|
|
186
|
+
slug: str = typer.Argument(..., help="Company slug or URL"),
|
|
187
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
188
|
+
):
|
|
189
|
+
"""Get company from Crunchbase."""
|
|
190
|
+
import asyncio
|
|
191
|
+
import json
|
|
192
|
+
from oto.tools.browser import CrunchbaseClient
|
|
193
|
+
|
|
194
|
+
async def run():
|
|
195
|
+
async with CrunchbaseClient(headless=headless) as client:
|
|
196
|
+
return await client.get_company(slug)
|
|
197
|
+
|
|
198
|
+
result = asyncio.run(run())
|
|
199
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("pappers-siren")
|
|
203
|
+
def pappers_siren(
|
|
204
|
+
siren: str = typer.Argument(..., help="SIREN number"),
|
|
205
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
206
|
+
):
|
|
207
|
+
"""Get French company data from Pappers."""
|
|
208
|
+
import asyncio
|
|
209
|
+
import json
|
|
210
|
+
from oto.tools.browser import PappersClient
|
|
211
|
+
|
|
212
|
+
async def run():
|
|
213
|
+
async with PappersClient(headless=headless) as client:
|
|
214
|
+
return await client.get_company_by_siren(siren)
|
|
215
|
+
|
|
216
|
+
result = asyncio.run(run())
|
|
217
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("indeed-search")
|
|
221
|
+
def indeed_search(
|
|
222
|
+
query: str = typer.Argument(..., help="Job search query"),
|
|
223
|
+
location: str = typer.Option("", help="Location"),
|
|
224
|
+
country: str = typer.Option("fr", help="Country code (fr, us, uk, de)"),
|
|
225
|
+
limit: int = typer.Option(25, help="Max results"),
|
|
226
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
227
|
+
):
|
|
228
|
+
"""Search jobs on Indeed."""
|
|
229
|
+
import asyncio
|
|
230
|
+
import json
|
|
231
|
+
from oto.tools.browser import IndeedClient
|
|
232
|
+
|
|
233
|
+
async def run():
|
|
234
|
+
async with IndeedClient(country=country, headless=headless) as client:
|
|
235
|
+
return await client.search_jobs(query, location=location, max_results=limit)
|
|
236
|
+
|
|
237
|
+
result = asyncio.run(run())
|
|
238
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command("g2-reviews")
|
|
242
|
+
def g2_reviews(
|
|
243
|
+
url: str = typer.Argument(..., help="G2 product reviews URL"),
|
|
244
|
+
limit: int = typer.Option(50, help="Max reviews"),
|
|
245
|
+
headless: bool = typer.Option(True, help="Run headless"),
|
|
246
|
+
):
|
|
247
|
+
"""Scrape product reviews from G2."""
|
|
248
|
+
import asyncio
|
|
249
|
+
import json
|
|
250
|
+
from oto.tools.browser import G2Client
|
|
251
|
+
|
|
252
|
+
async def run():
|
|
253
|
+
async with G2Client(headless=headless) as client:
|
|
254
|
+
return await client.get_product_reviews(url, max_reviews=limit)
|
|
255
|
+
|
|
256
|
+
result = asyncio.run(run())
|
|
257
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
oto/commands/company.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Company lookup command (SIREN multi-source)."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="Company lookup")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command("info")
|
|
9
|
+
def info(
|
|
10
|
+
siren: str = typer.Argument(..., help="SIREN number (9 digits)"),
|
|
11
|
+
):
|
|
12
|
+
"""Get French company info by SIREN (directors, finances, address). No API key needed."""
|
|
13
|
+
import json
|
|
14
|
+
from oto.tools.sirene import EntreprisesClient
|
|
15
|
+
|
|
16
|
+
client = EntreprisesClient()
|
|
17
|
+
result = client.get_by_siren(siren)
|
|
18
|
+
|
|
19
|
+
if not result:
|
|
20
|
+
print(f"Company not found: {siren}")
|
|
21
|
+
raise typer.Exit(1)
|
|
22
|
+
|
|
23
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Contact enrichment commands (Kaspr, Hunter, Lemlist)."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Contact enrichment tools")
|
|
7
|
+
|
|
8
|
+
# Kaspr subcommands
|
|
9
|
+
kaspr_app = typer.Typer(help="Kaspr contact enrichment")
|
|
10
|
+
app.add_typer(kaspr_app, name="kaspr")
|
|
11
|
+
|
|
12
|
+
# Hunter subcommands
|
|
13
|
+
hunter_app = typer.Typer(help="Hunter.io email tools")
|
|
14
|
+
app.add_typer(hunter_app, name="hunter")
|
|
15
|
+
|
|
16
|
+
# Lemlist subcommands
|
|
17
|
+
lemlist_app = typer.Typer(help="Lemlist campaign & lead management")
|
|
18
|
+
app.add_typer(lemlist_app, name="lemlist")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@kaspr_app.command("enrich")
|
|
22
|
+
def kaspr_enrich(
|
|
23
|
+
linkedin_slug: str = typer.Argument(..., help="LinkedIn profile slug"),
|
|
24
|
+
name: Optional[str] = typer.Option(None, "--name", help="Person full name"),
|
|
25
|
+
):
|
|
26
|
+
"""Enrich a LinkedIn profile with Kaspr (email, phone)."""
|
|
27
|
+
import json
|
|
28
|
+
from oto.tools.kaspr import KasprClient
|
|
29
|
+
|
|
30
|
+
client = KasprClient()
|
|
31
|
+
result = client.enrich_linkedin(linkedin_slug, name=name)
|
|
32
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@hunter_app.command("domain")
|
|
36
|
+
def hunter_domain(
|
|
37
|
+
domain: str = typer.Argument(..., help="Domain to search"),
|
|
38
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Max results"),
|
|
39
|
+
):
|
|
40
|
+
"""Search emails for a domain via Hunter."""
|
|
41
|
+
import json
|
|
42
|
+
from oto.tools.hunter import HunterClient
|
|
43
|
+
|
|
44
|
+
client = HunterClient()
|
|
45
|
+
result = client.domain_search(domain, limit=limit)
|
|
46
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@hunter_app.command("find")
|
|
50
|
+
def hunter_find(
|
|
51
|
+
domain: str = typer.Argument(..., help="Domain"),
|
|
52
|
+
name: str = typer.Option(..., "--name", help="Full name"),
|
|
53
|
+
):
|
|
54
|
+
"""Find email for a person at a domain via Hunter."""
|
|
55
|
+
import json
|
|
56
|
+
from oto.tools.hunter import HunterClient
|
|
57
|
+
|
|
58
|
+
client = HunterClient()
|
|
59
|
+
result = client.email_finder(domain, full_name=name)
|
|
60
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@hunter_app.command("verify")
|
|
64
|
+
def hunter_verify(
|
|
65
|
+
email: str = typer.Argument(..., help="Email to verify"),
|
|
66
|
+
):
|
|
67
|
+
"""Verify an email address via Hunter."""
|
|
68
|
+
import json
|
|
69
|
+
from oto.tools.hunter import HunterClient
|
|
70
|
+
|
|
71
|
+
client = HunterClient()
|
|
72
|
+
result = client.email_verifier(email)
|
|
73
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@lemlist_app.command("campaigns")
|
|
77
|
+
def lemlist_campaigns():
|
|
78
|
+
"""List all Lemlist campaigns."""
|
|
79
|
+
import json
|
|
80
|
+
from oto.tools.lemlist import LemlistClient
|
|
81
|
+
|
|
82
|
+
client = LemlistClient()
|
|
83
|
+
campaigns = client.list_campaigns()
|
|
84
|
+
result = [{"id": c.id, "name": c.name, "status": c.status, "senders": c.senders} for c in campaigns]
|
|
85
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lemlist_app.command("leads")
|
|
89
|
+
def lemlist_leads(
|
|
90
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
91
|
+
):
|
|
92
|
+
"""List leads in a campaign."""
|
|
93
|
+
import json
|
|
94
|
+
from oto.tools.lemlist import LemlistClient
|
|
95
|
+
|
|
96
|
+
client = LemlistClient()
|
|
97
|
+
leads = client.get_all_leads(campaign_id)
|
|
98
|
+
print(json.dumps(leads, indent=2, ensure_ascii=False))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@lemlist_app.command("add-lead")
|
|
102
|
+
def lemlist_add_lead(
|
|
103
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
104
|
+
email: str = typer.Option(..., "--email", "-e", help="Lead email"),
|
|
105
|
+
first_name: str = typer.Option(None, "--first-name", help="First name"),
|
|
106
|
+
last_name: str = typer.Option(None, "--last-name", help="Last name"),
|
|
107
|
+
company: str = typer.Option(None, "--company", help="Company name"),
|
|
108
|
+
phone: str = typer.Option(None, "--phone", help="Phone number"),
|
|
109
|
+
linkedin: str = typer.Option(None, "--linkedin", help="LinkedIn URL"),
|
|
110
|
+
):
|
|
111
|
+
"""Add a lead to a Lemlist campaign."""
|
|
112
|
+
import json
|
|
113
|
+
from oto.tools.lemlist import LemlistClient
|
|
114
|
+
from oto.tools.lemlist.client import Lead
|
|
115
|
+
|
|
116
|
+
client = LemlistClient()
|
|
117
|
+
lead = Lead(
|
|
118
|
+
email=email,
|
|
119
|
+
firstName=first_name,
|
|
120
|
+
lastName=last_name,
|
|
121
|
+
companyName=company,
|
|
122
|
+
phone=phone,
|
|
123
|
+
linkedinUrl=linkedin,
|
|
124
|
+
)
|
|
125
|
+
result = client.add_lead(campaign_id, lead)
|
|
126
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@lemlist_app.command("delete-lead")
|
|
130
|
+
def lemlist_delete_lead(
|
|
131
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
132
|
+
email: str = typer.Argument(..., help="Lead email to remove"),
|
|
133
|
+
):
|
|
134
|
+
"""Remove a lead from a Lemlist campaign."""
|
|
135
|
+
import json
|
|
136
|
+
from oto.tools.lemlist import LemlistClient
|
|
137
|
+
|
|
138
|
+
client = LemlistClient()
|
|
139
|
+
result = client.delete_lead(campaign_id, email)
|
|
140
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@lemlist_app.command("export")
|
|
144
|
+
def lemlist_export(
|
|
145
|
+
campaign_id: str = typer.Argument(..., help="Campaign ID"),
|
|
146
|
+
):
|
|
147
|
+
"""Export leads from a campaign as CSV."""
|
|
148
|
+
from oto.tools.lemlist import LemlistClient
|
|
149
|
+
|
|
150
|
+
client = LemlistClient()
|
|
151
|
+
csv_data = client.export_leads(campaign_id)
|
|
152
|
+
print(csv_data)
|