Skip to content

LinkedIn Sales Navigator connector via PhantomBuster#211

Open
ryderdaniel wants to merge 3 commits into
devfrom
feat/enum-passive-linkedin
Open

LinkedIn Sales Navigator connector via PhantomBuster#211
ryderdaniel wants to merge 3 commits into
devfrom
feat/enum-passive-linkedin

Conversation

@ryderdaniel

Copy link
Copy Markdown
Contributor

Summary

  • Adds enum passive linkedin command — launches a pre-configured PhantomBuster Sales Navigator scraper, polls until completion, downloads the CSV from S3, and parses profiles into structured output
  • New pkg/enum/phantombuster/ package: generic PB API client (launch, poll with exponential backoff, fetch from S3) + Sales Nav CSV parser with verification-ready fields (10T-373 compatible)
  • Infers department (14 categories) and seniority (10 levels) from LinkedIn titles/headlines when CSV columns aren't present, with CSV values taking priority when available
  • Human table output (Name/Title/Dept/Company/LinkedIn) and JSONL output with type: "linkedin" for downstream pipeline consumption
  • API keys never logged or included in error messages (P0-1)

Test plan

  • Unit tests: 25 tests across client, CSV parser, CLI wiring, and output formatting
  • Live test: scraped 2,499 Fox Corporation profiles via PhantomBuster against LinkedIn Sales Navigator
  • Verified department/seniority inference produces meaningful categorization across the result set (14 departments, 10 seniority levels)
  • Verified --skip-launch fetches results from a previous run without re-launching the phantom
  • Verified JSONL output includes all fields including verification-ready fields
  • Verify --json output pipes cleanly into downstream tooling (enum generate)

🤖 Generated with Claude Code

ryderdaniel and others added 3 commits June 30, 2026 11:20
…raper via PhantomBuster

Two-layer implementation: generic PhantomBuster API client (launch/poll/fetch)
in pkg/enum/phantombuster/ and Sales Nav-specific CSV parsing in linkedin.go.
CLI wired under `enum passive linkedin` following the Apollo/Hunter pattern.

Result type includes verification-ready fields (Sources, VerificationStatus,
Confidence) per 10T-373 so confirmation oracles bolt on with no migration.

Implements 10T-436.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CSV columns (department, industry, seniority) are mapped first; when
absent, keyword matching against the title and headline provides
best-effort inference for downstream enrichment workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tment mapping

The v1 output endpoint wraps responses in {"status":"success","data":{...}}.
Parsing the top-level JSON directly left containerStatus empty, causing the
poll loop to exit immediately on the first poll. Now unwraps the data envelope
before parsing OutputStatus.

Also removes the industry CSV column from the department mapping — it contains
the company's industry (e.g. "Production et distribution de médias"), not the
person's department. Department inference from title/headline handles this
correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review

Critical Issues

  • pkg/enum/phantombuster/client.go:223 silently truncates S3 result files at 10 MB with io.LimitReader and never checks whether more data remained. Large Sales Navigator exports can produce incomplete rosters, and if truncation lands on a row boundary ParseSalesNavCSV can return success with missing profiles instead of surfacing data loss.
  • cmd/brutus/cmd_enum_linkedin.go:62 advertises --result-file result.json, but runEnumLinkedin always sends downloaded data to ParseSalesNavCSV at cmd/brutus/cmd_enum_linkedin.go:149. Any JSON result file will fail or be misparsed, so the documented path is broken.

Security

No security concerns flagged.

Suggestions

  • pkg/enum/phantombuster/linkedin.go:66 leaves csv.Reader.FieldsPerRecord at the default, so ragged rows with omitted trailing empty fields error out before getField can apply the intended “missing columns map to empty strings” behavior. Consider FieldsPerRecord = -1 plus a test for rows shorter than the header.

Targeted tests were not run because the sandbox is read-only and Go could not create its module cache.


Reviewed by Codex (gpt-5.5)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Review

Critical issues

  • PollUntilDone (pkg/enum/phantombuster/client.go:726) treats any containerStatus != "running" as finished. Right after Launch, PhantomBuster's output endpoint commonly reports a transient/empty status (e.g. starting, queued, or missing containerStatus) before the container flips to running. The first poll can then exit immediately with exitCode == 0, and RunAndFetch proceeds to download a stale/absent result.csv. Recommend terminating only on status.ContainerStatus == StatusNotRunning and continuing to poll on any other value.
  • Profile.Confidence is populated as a verification-ready field but is dropped from the JSONL output struct linkedinJSON (cmd_enum_linkedin_output.go:274). If downstream (10T-373) expects it on the wire, add it; otherwise it is silently lost.

Security

  • API key handling looks correct: sent only via the X-Phantombuster-Key-1 header through the doJSON choke point, never logged, and scrubbed from classified errors. --api-key even warns about process-list exposure.
  • S3 download URL is built from a fixed s3BaseURL const plus API-returned folder paths (not user input) and bounded by maxResponseBytes (10MB) — no SSRF/unbounded-read concern.

Test coverage

  • DownloadResult and RunAndFetch are never actually exercised — TestDownloadResult_Success bypasses them because s3BaseURL is a const it cannot override, so it hits the test server directly. Making s3BaseURL a Client field (like baseURL) would let the real S3 path (URL construction, non-200 handling, byte limit) be tested.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini Review

Critical Issues

  • Logic Error in inferDeptSeniority Role Inference: strings.Contains is used without word boundaries to match short acronyms in seniorityKeywords. Consequently, any title containing "Director" will match the "cto" keyword (dire**cto**r) and be misclassified as c-suite. Similarly, "Coordinator" matches "coo", and "Production" matches "cto" or "cro". A word-boundary-aware search (like regex) or space-padding is required to prevent widespread misclassification.
  • Premature Polling Exit on Unexpected API Responses: In PollUntilDone, if the PhantomBuster API returns a 200 OK with an unexpected payload structure (such as a missing containerStatus field or an unhandled { "status": "error" } envelope), status.ContainerStatus evaluates to "". The loop checks status.ContainerStatus != StatusRunning ("" != "running"), which is true, causing it to falsely return success and attempt to download results before the job is finished.
  • Silent Truncation of Large Result CSVs: In DownloadResult, io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) is used. If an S3 result file exceeds the 10MB limit, LimitReader returns an EOF which io.ReadAll treats as a successful read. The function will silently return a truncated byte slice with a nil error, leading to malformed CSV parsing and silently dropped data. You must explicitly verify if the limit was reached (e.g., checking byte length) and return an error.

Security

No security concerns flagged.

Suggestions

  • Clear Terminal Line on Progress: When logging polling progress updates with \r in runEnumLinkedin, trailing characters from longer, previous status labels will remain visible on the terminal. Consider appending the ANSI escape code \033[K (clear to end of line) to the format string to ensure the line is fully overwritten.
  • Handle UTF-8 BOM in CSVs: If PhantomBuster exports CSVs with a UTF-8 Byte Order Mark (\xef\xbb\xbf), the first header column will silently fail to map in colIndex because strings.TrimSpace ignores it. Consider explicitly stripping the BOM from the data slice before passing it to csv.NewReader.
  • Safely Encode URLs with Spaces: In DownloadResult, fmt.Sprintf is used to construct the S3 URL. If the user-provided --result-file contains spaces, http.NewRequestWithContext will fail. Consider using url.JoinPath(s3BaseURL, info.OrgS3Folder, info.S3Folder, filename) which automatically handles URL encoding for the path segments.

Reviewed by Gemini (gemini-3.1-pro-preview)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85ab44c9a3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +93 to +96
FullName: getField(record, colIndex, "fullname"),
Title: coalesce(getField(record, colIndex, "jobtitle"), getField(record, colIndex, "job"), getField(record, colIndex, "title")),
Company: coalesce(getField(record, colIndex, "companyname"), getField(record, colIndex, "company")),
CompanyURL: coalesce(getField(record, colIndex, "companylinkedinurl"), getField(record, colIndex, "companyurl")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map the Profile Scraper's actual columns

When this command is pointed at the documented Sales Navigator Profile Scraper output, the main fields come back blank: PhantomBuster lists name, currentJob, currentCompanyUrl, and currentCompanyName as output columns (docs), but this mapping only accepts fullName, jobTitle/job/title, and companyName/company. The default result.csv from that scraper therefore loses the name/title/company fields that the human/JSON roster is supposed to emit.

Useful? React with 👍 / 👎.

}
}

result, err := pb.ParseSalesNavCSV(resultData)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse JSON result files before emitting profiles

The help explicitly suggests --result-file result.json, and PhantomBuster documents both CSV and JSON result files (docs), but after downloading any file this line always runs the CSV parser. For a JSON result file, the data is parsed as CSV headers/rows (often yielding zero profiles or a parse error), so users selecting the documented JSON export won't get their profiles.

Useful? React with 👍 / 👎.

return nil, fmt.Errorf("S3 download failed (HTTP %d)", resp.StatusCode)
}

data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect oversized S3 results instead of truncating

When the S3 result is larger than 10 MiB, io.LimitReader just stops at the cap and io.ReadAll still returns nil error. If the cut happens at a CSV row boundary, the parser will emit a plausible but incomplete roster, silently dropping all later profiles; read maxResponseBytes+1 or stream with an explicit overflow error instead of returning truncated data.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant