DEV Community

Ben
Ben

Posted on

Join LinkedIn jobs to their company pages with Python

LinkedIn job records include an employer URL. You can use that URL to attach a company's public industry, website and size range to a job feed, keeping the source identity through both steps. This is useful when the employer's displayed name is abbreviated, shared by several organizations or belongs to a recruiting agency.

I maintain the LinkedIn Jobs and LinkedIn Company Actors used here. This example joins their exports with Python and SQLite. It saves the jobs before attempting company lookups, so a company failure leaves a recoverable job export.

What the live sample produced

On September 10, 2026, a bounded search for Python jobs in Berlin returned three postings. Their employer URLs led to three verified company records:

Posted employer Company ID Public company-size range LinkedIn-associated members
Flix 2726149 1,001–5,000 employees 2,631
Nelly Solutions 74522753 51–200 employees 206
Berliner Verlag 1178870 201–500 employees 100

These are dated source observations. Nelly's size range and associated-member count disagree because they are different LinkedIn fields; the workflow keeps both. Neither establishes an independently verified workforce size. The employer on a job advertisement can also be an agency, so this join doesn't identify an undisclosed end client.

Two captures of three LinkedIn jobs: three initial company lookups, no repeated lookups, and no duplicate jobs.

The first capture stored three jobs, including their complete public descriptions. Enrichment attached three company profiles. A fresh job run returned the same IDs; the database added zero jobs and the enrichment step made zero API calls. The resulting JSONL file matched byte for byte.

The Jobs run IDs were vM96cCZQDC8v24ltV and 4AyEwm8YDWjMvrQ2v; the Company run was uNaVz3GdaNyn4gXUP. All three used build 1.0.4 of their respective Actor and ended successfully. This sample covers three returned postings, not every Python vacancy in Berlin.

Run the two steps

Download linkedin_hiring_feed.py, test_linkedin_hiring_feed.py and linkedin-hiring-input.json from the public workflow files. Keep the Python files together. They require Python 3.11 or newer and use its standard library.

python3.11 -m venv venv
source venv/bin/activate
python test_linkedin_hiring_feed.py
Enter fullscreen mode Exit fullscreen mode

The checks run without credentials or network access. They exercise repeated imports, exact employer matching, invalid rows, a failed company run and recovery from the saved jobs. Set APIFY_TOKEN through your environment or secret manager before the live commands; keep it out of the input JSON and downloaded scripts.

The supplied search is small:

{
  "keywords": "python",
  "location": "Berlin, Germany",
  "maxResults": 3,
  "maxPages": 2,
  "timeRange": "week",
  "sortBy": "date",
  "strictKeywords": true,
  "onlyNew": false,
  "proxyConfiguration": {"useApifyProxy": false}
}
Enter fullscreen mode Exit fullscreen mode

Capture the jobs, then enrich the pending employer URLs:

python linkedin_hiring_feed.py capture linkedin-hiring-input.json berlin-hiring
python linkedin_hiring_feed.py enrich berlin-hiring
Enter fullscreen mode Exit fullscreen mode

Each command that starts an Actor fixes memory at 512 MB, timeout at 300 seconds and the maximum Actor charge at $0.05. The two starts have separate caps. The example accepts at most ten jobs and five search pages per capture; each enrichment command requests at most ten pending companies. Inspect the printed counts before increasing your search within those bounds.

The folder contains hiring.sqlite3, the persistent database; hiring-feed.jsonl, the joined export; and runs/, the run receipts and successfully downloaded source rows. Keep the input file outside this folder. A changed input requires a different folder, which prevents accidentally mixing two search definitions.

Run the same two commands sequentially on later days. capture stores each job ID once. enrich requests only eligible employer URLs without a saved company snapshot, so several jobs from the same employer share one lookup. The second command exits without contacting Apify when nothing is pending.

Read the joined record

Every JSONL line has an outer job_id, the original job, a company_profile and an enrichment_status. These selected fields come from the saved Nelly record; the full file also retains the job description and the remaining company fields:

{
  "job_id": "4434332752",
  "job": {
    "title": "Senior Software Engineer, Python",
    "company": "Nelly Solutions",
    "company_url": "https://de.linkedin.com/company/nelly-solutions"
  },
  "company_profile": {
    "company_id": "74522753",
    "url": "https://www.linkedin.com/company/nelly-solutions",
    "industry": "Software Development",
    "company_size": "51-200 employees",
    "linkedin_employee_count": 206,
    "identity_verified": true
  },
  "enrichment_status": "verified"
}
Enter fullscreen mode Exit fullscreen mode

The join normalizes country-specific LinkedIn hostnames, case, trailing slashes and tracking parameters. It then requires the Company Actor's verified requested URL to equal that normalized employer URL. Company names never serve as a fallback key. Related organizations or a profile returned for a different URL fail validation.

Missing or unsupported employer links keep their jobs in the export with missing_company_url or unsupported_company_url. A supported URL awaiting a successful lookup has pending. Only a matched, verified company row receives verified; the other states have a null company_profile. The Company Actor currently accepts named company slugs containing letters, digits, underscores and hyphens. Numeric redirect URLs and other forms need separate source review; this example doesn't rewrite them into guessed company pages.

Use the outer job_id as the destination key when importing into a sheet or database. Retain job.scraped_at and company_profile.scraped_at so readers can see when each observation was collected. The saved profile also has the source-published website, when available.

Recover without collecting the jobs again

The first live capture stopped because a separate dataset-statistics read disagreed with the item response. A follow-up read confirmed all three jobs. The released script validates the total from the same paginated response as the items, removing that separate statistics dependency. I resumed the existing successful run:

python linkedin_hiring_feed.py capture linkedin-hiring-input.json berlin-hiring --run-id vM96cCZQDC8v24ltV
Enter fullscreen mode Exit fullscreen mode

For your own recovery, substitute your own run ID. The script checks the Actor identity, successful terminal status, input and complete bounded export before importing it. A failed run cannot pass as a successful empty feed. If a start request times out before returning an ID, inspect your Apify Runs page before starting another; the server may already have accepted it. The script never retries a start automatically.

When a company batch fails, the captured jobs remain saved and their supported employer URLs remain pending. Inspect that run and the source issue before retrying enrich; no new Jobs run is needed. A successful Company run whose input still matches the pending batch can also be imported with enrich berlin-hiring --run-id YOUR_COMPANY_RUN_ID. Failed batches are not partially imported. A successful spending-limited batch can leave companies pending, and the printed count makes that visible.

SQLite rolls back an import if a later row has a duplicate or mismatched identity. The JSONL file replaces its predecessor only after writing finishes. If export is interrupted, regenerate it locally:

python linkedin_hiring_feed.py export berlin-hiring
Enter fullscreen mode Exit fullscreen mode

That command makes no network requests. Avoid overlapping commands on one folder, and retain the database as the source of truth.

Costs and observation limits

At the verified Free-tier rates, Jobs costs $0.002 per exported posting and Company costs $0.004 per exported company. Each run at this memory setting adds a $0.00005 start event.

Step Calculated Actor charge
Initial three-job capture $0.00605
Three company profiles $0.01205
Fresh three-job repeat $0.00605
Repeat enrichment with no pending companies $0.00000
Total for the demonstrated sequence $0.02415

These are customer-price equivalents calculated from the recorded events, not revenue from my owner tests. Apify's current pricing panel is authoritative, and plan discounts may apply. Repeated job captures still export and charge for the snapshot; local ID deduplication prevents duplicate destination records. An existing-run import makes API reads but starts no new Actor run.

This example keeps the first observation of each job and the first successful company snapshot per URL. Later captures won't update their descriptions or company metrics. It doesn't detect job closure, refresh company headcount, send alerts or activate a schedule. The dates inside the saved records remain meaningful precisely because they're preserved. For a fresh research snapshot, use a new folder; that starts a separate history and incurs fresh lookups.

The source search and text filtering bound which jobs enter this feed. An absent result does not establish that a vacancy closed, and a public profile may become unavailable. Exports load the local feed into memory; use batched or streaming exports if the accumulated archive becomes large. The workflow contains no personal-profile collection or employee enumeration. Follow source terms and your organization's data-use requirements.

You can inspect the Berlin Jobs example and the separate verified Company example before running the workflow in your own account. If you need an account, this Apify signup link is a referral link; I may earn a commission at no extra cost to you.

Top comments (0)