# 🎓 LinkedIn Profile — Courses & Recommendations Signal (`scrapier/linkedin-profile-scraper`) Actor

Scrape LinkedIn profile courses and recommendations to uncover skills, interests, expertise, endorsements, and professional signals. Enrich lead profiles, identify qualified prospects, assess candidate fit, personalize outreach, and support B2B sales, recruiting, and talent research.

- **URL**: https://apify.com/scrapier/linkedin-profile-scraper.md
- **Developed by:** [Scrapier](https://apify.com/scrapier) (community)
- **Categories:** Automation, Lead generation, Social media
- **Stats:** 156 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$24.99/month + usage

To use this Actor, you pay a monthly rental fee to the developer. The rent is subtracted from your prepaid usage every month after the free trial period. You also pay for the Apify platform usage, which gets cheaper the higher Apify subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#rental-actors

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

### LinkedIn Profile Scraper — Courses & Recommendation Signals as JSON

LinkedIn Profile — Courses & Recommendations Signal scrapes public LinkedIn profiles — supplied as direct URLs, or discovered from a company's employee list — and returns one structured JSON record per profile: identity fields, current employer, location, plus two derived signal blocks most profile scrapers skip entirely, `course_signal` from the Recommended-courses module and `recommendation_signal` from received Recommendations text. Every response is typed JSON, ready to pass straight into an LLM prompt, a vector store, or a CRM enrichment job. Run it again on the same profile list later and the signal blocks become a trackable read on what a person is learning and who is vouching for them.

### What is LinkedIn Profile — Courses & Recommendations Signal?

LinkedIn Profile — Courses & Recommendations Signal is an Apify Actor that scrapes **public, logged-out LinkedIn profile pages** and layers two derived signal blocks on top of the same base fields a standard profile scraper returns. No LinkedIn account, password, or session cookie is required as input — the Actor reads the public HTML and Open Graph metadata LinkedIn serves to a logged-out visitor, and only optionally uses Playwright to pick up guest cookies that make that fetch more reliable. What makes it different from a generic profile scraper is what happens after the fetch: the scraped **Recommended courses** module and received **Recommendations** text are run through a keyword-theme classifier that only assigns a theme when a keyword genuinely begins a word in the source text, with the matched keyword(s) shipped alongside every theme as provenance.

- Scrape profiles directly from a list of LinkedIn URLs or bare vanity slugs (`mode: "profiles"`)
- Discover and scrape a company's employees from its public company page plus a best-effort Google search fallback (`mode: "company"`)
- Derive `course_signal` — course count, total scraped learning minutes, course titles, and keyword themes from the profile's Recommended-courses module
- Derive `recommendation_signal` — recommendation count, recommender names, and keyword themes from received Recommendation text
- Optionally upgrade both signal blocks into a seniority / skills / lead-quality classification with an LLM (`aiEnhancement`)
- Switch to `rawData` mode to get the plain base profile record with no derived signals at all
- Route every request through Apify Proxy, with Residential recommended for LinkedIn's anti-bot wall

### What data can you get with LinkedIn Profile — Courses & Recommendations Signal?

Every run returns one JSON row per input profile (or per discovered employee, in company mode), built from five distinct pieces of data.

| Result Type | Extracted Fields | Primary Use Case |
| --- | --- | --- |
| Base profile | `public_identifier`, `fullname`, `first_name`, `last_name`, `headline`, `profile_picture_url`, `location` (`full`, `city`, `country`, `country_code`), `current_company`, `companies_detected[]`, `personal_website`, `profile_url`, `created_timestamp` | Contact identification, lead records, CRM enrichment |
| Course signal | `course_signal.course_count`, `.total_learning_minutes`, `.course_titles[]`, `.themes[]`, `.matched_keywords[]` | Skill and learning-interest inference from LinkedIn Learning activity |
| Recommendation signal | `recommendation_signal.count`, `.recommenders[]`, `.themes[]`, `.matched_keywords[]` | Peer-endorsed strengths, social-proof signal for outreach |
| AI classification (opt-in) | `ai.seniority`, `.skills[]`, `.lead_quality`, `.rationale` | LLM-upgraded lead qualification from the same scraped text |
| Run-level signal summary (KV store) | `signal_summary.profiles_enriched`, `.total_courses`, `.total_learning_minutes`, `.total_recommendations`, `.course_theme_frequency`, `.recommendation_theme_frequency` | Batch-level rollup for reporting or alerting |

#### Course & recommendation signal blocks

This is the part a plain profile scraper doesn't do. `course_signal` counts the courses listed in a profile's "Recommended courses" module, sums their parsed durations into `total_learning_minutes`, and classifies the course titles into up to 12 themes (`ai_ml`, `data`, `security`, `software_dev`, `leadership`, `marketing`, `sales`, `design`, `finance`, `product`, `communication`, `career`). `recommendation_signal` does the same over the text of received Recommendations, against 8 themes (`leadership`, `technical`, `collaboration`, `delivery`, `communication`, `mentorship`, `reliability`, `creativity`). A theme is only assigned when one of its keywords matches at a word start (`\bkeyword`) inside the scraped text — so `ship` never matches *leadership* and `api` never matches *capital* — and every assigned theme carries its `matched_keywords` so you can see exactly which words triggered it, not just trust a label:

```json
{
  "course_signal": {
    "course_count": 1,
    "total_learning_minutes": 72,
    "course_titles": ["Product Management Foundations"],
    "themes": ["product"],
    "matched_keywords": ["product management"]
  },
  "recommendation_signal": {
    "count": 1,
    "recommenders": ["Jane Smith"],
    "themes": ["leadership"],
    "matched_keywords": ["leader"]
  }
}
```

Note the field-name asymmetry: `course_signal.course_count` shares its name with the flattened dataset column `course_count`, while `recommendation_signal.count` is flattened to a differently-named column, `recommendation_count` — both are documented in the Input/Output section below exactly as the source names them.

#### AI classification (opt-in)

With `aiEnhancement` on and `rawData` off, each non-error profile is additionally sent to an LLM — headline, recommendation texts, and course titles only — which returns a structured `ai` block: `seniority` (`junior|mid|senior|lead|executive|unknown`), up to 8 `skills`, a `lead_quality` verdict (`low|medium|high`), and a one-sentence `rationale`. The provider is auto-detected from the `aiModel` name you pick (Anthropic, OpenAI, Google, xAI, DeepSeek, Perplexity, or Mistral), and classification silently returns `null` on any provider error or missing API key rather than guessing a value.

### Why not build this yourself?

LinkedIn does not expose a public, self-serve API for searching or bulk-fetching profile data — the official LinkedIn APIs are partner-gated and built for things like "Sign In with LinkedIn" or approved marketing integrations, not for pulling course and recommendation content off arbitrary public profiles. Getting there yourself means reverse-engineering the public profile page's HTML and JSON-LD structure, handling the authwall/login-redirect LinkedIn serves to suspicious traffic, rotating and paying for proxies, and rebuilding your parsing selectors every time LinkedIn changes its markup — all before you've written a single line of theme-extraction logic.

LinkedIn Profile — Courses & Recommendations Signal already does this work: it detects authwall/challenge redirects and retries with a fresh guest-cookie fetch and a longer backoff before giving up and reporting an honest `{profile_url, error}` row; it resolves the proxy from your `proxyConfiguration` input at runtime rather than hardcoding one; and its theme classifier is a deliberately narrow, auditable word-boundary match rather than an opaque score. Maintaining those selectors, the retry/backoff ladder, and the keyword-theme tables as LinkedIn's markup shifts is the ongoing work this Actor takes off your plate.

### What's the difference between a LinkedIn Learning catalog scraper and a profile course-signal actor?

A LinkedIn Learning catalog scraper returns course-catalog data with no person attached to it; a profile course-signal actor ties courses to the specific profile that has them listed and turns that into a per-person signal. The distinction matters because the two answer different questions: a catalog scraper tells you what courses exist on LinkedIn Learning, while a profile course-signal actor tells you what a specific lead, candidate, or contact has been learning. As observed on the Apify Store on 2026-07-26, parseforge's *LinkedIn Learning Catalog Scraper* documents its output fields as `id`, `scrapedAt`, `sourceUrl`, and `title` — course records with no owner. LinkedIn Profile — Courses & Recommendations Signal instead nests the same kind of course data (`title`, `duration`, `url`) under a specific profile's `contact_elements.learning_content.course_details`, and rolls it up into that profile's own `course_signal.themes`, so the output answers "what has this person been studying" rather than "what's in the catalog."

### How to scrape LinkedIn profiles with LinkedIn Profile — Courses & Recommendations Signal?

1. Open **LinkedIn Profile — Courses & Recommendations Signal** on the Apify Store and click **Try for free**, or find it under your Actors in Apify Console if you've run it before.
2. Choose `mode`: `profiles` to scrape a known list of URLs (the reliable path), or `company` to discover a company's employees first.
3. Fill in the matching field — `profileUrls` (one URL or bare slug per line) for `profiles` mode, or `companies` (slugs or company URLs) plus `maxEmployees` for `company` mode.
4. Leave `rawData` off to get the full signal layer, or turn it on for plain base records only. Turn on `aiEnhancement` and supply `aiModel`/`aiApiKey` if you want the LLM classification block.
5. Click **Start**, then open the **Dataset** tab when the run finishes and export as JSON, CSV, Excel, or the other formats Apify supports, or pull results via the Apify API.

A minimal real request in `profiles` mode:

```json
{
  "mode": "profiles",
  "profileUrls": ["https://www.linkedin.com/in/timbakke/", "satyanadella"]
}
```

#### How to run multiple profiles or companies in one job

Batching is the array itself — `profileUrls` accepts one URL or slug per line and every one is deduplicated and scraped in the same run; `companies` works the same way for `company` mode, with `maxEmployees` (default `20`, max `200`) capping how many discovered employees are pulled per company. Profiles and companies are processed one at a time in a sequential loop with a jittered delay between requests, rather than in parallel — there is no concurrency parameter exposed in the input schema, so throughput scales with how many profiles you queue, not with a tunable worker count.

### ⬇️ Input

Every field below is optional — there is no required input, though `mode: profiles` needs `profileUrls` and `mode: company` needs `companies` to actually do anything.

| Parameter | Required | Type | Description | Example Value |
| --- | --- | --- | --- | --- |
| `mode` | No | string (enum) | How to source profiles: `profiles` scrapes the exact URLs you provide (reliable); `company` discovers a company's employees via company pages + Google search, then scrapes them (Google discovery is brittle and usually blocked from datacenter IPs). Default `"profiles"`. | `"profiles"` |
| `profileUrls` | No | array of strings | Used when `mode = profiles`. Full profile links or bare slugs, one per line, e.g. `https://www.linkedin.com/in/timbakke/` or `timbakke`. | `["https://www.linkedin.com/in/timbakke/"]` |
| `companies` | No | array of strings | Used when `mode = company`. Company slugs or LinkedIn company URLs, e.g. `microsoft` or `https://www.linkedin.com/company/microsoft`. Each is expanded to up to `maxEmployees` profiles. | `["microsoft"]` |
| `maxEmployees` | No | integer (min `1`, max `200`) | Used when `mode = company`. Caps how many discovered employee profiles are scraped per company. Default `20`. | `20` |
| `rawData` | No | boolean | Emit the plain base profile records only — no `course_signal`, `recommendation_signal`, `ai`, or `signal_summary`. Turn off to get the full signal layer. Default `false`. | `false` |
| `aiEnhancement` | No | boolean | Adds an `ai` block (`seniority`, `skills`, `lead_quality`, `rationale`) per profile. Ignored in raw-data mode. Default `false`. | `false` |
| `aiModel` | No | string (enum) | AI provider/model for classification. Provider is auto-detected from the name. Default `"claude-haiku-4-5"`. | `"claude-haiku-4-5"` |
| `aiApiKey` | No | string (secret) | API key for the selected provider. Leave blank to fall back to the provider's environment variable. Only used when `aiEnhancement` is on. | *(blank — uses env var)* |
| `proxyConfiguration` | No | object (proxy) | Apify Proxy configuration. RESIDENTIAL strongly recommended for LinkedIn. Default `{"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}`. | `{"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}` |

`aiModel` accepts one of the following 21 values, grouped by the provider the model name maps to:

| Provider | Accepted `aiModel` values |
| --- | --- |
| Anthropic | `claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-4-8`, `claude-fable-5` |
| OpenAI | `gpt-4o-mini`, `gpt-4o`, `gpt-4.1-mini`, `gpt-4.1`, `o3-mini`, `o1` |
| Google | `gemini-2.0-flash-lite`, `gemini-2.0-flash`, `gemini-2.5-flash`, `gemini-2.5-pro` |
| xAI | `grok-3-mini`, `grok-3` |
| DeepSeek | `deepseek-chat`, `deepseek-reasoner` |
| Perplexity | `sonar`, `sonar-pro` |
| Mistral | `mistral-small-latest`, `mistral-large-latest` |

If `aiApiKey` is left blank, the Actor falls back to the matching environment variable for the detected provider: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` (or `GOOGLE_API_KEY`), `XAI_API_KEY` (or `GROK_API_KEY`), `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY` (or `PPLX_API_KEY`), `MISTRAL_API_KEY`.

Example JSON input, company mode with the AI layer on:

```json
{
  "mode": "company",
  "companies": ["microsoft"],
  "maxEmployees": 20,
  "rawData": false,
  "aiEnhancement": true,
  "aiModel": "claude-haiku-4-5",
  "aiApiKey": "",
  "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
```

The most common input mistake is relying on `mode: company` as the primary path: employee discovery combines links found on the company's public pages with a `site:linkedin.com/in/ "<company name>"` Google search, and that Google step is usually blocked (HTTP 429 or a consent wall) from datacenter and even residential proxy IPs — it reports honestly and returns whatever it collected rather than fabricating URLs, but that can mean far fewer employees than `maxEmployees` asks for. If you already know which profiles you want, `mode: profiles` with `profileUrls` is the reliable path.

### ⬆️ Output

Each run pushes one typed, normalized JSON row per input profile (or discovered employee) to the Apify Dataset, exportable as JSON, CSV, Excel, XML, RSS, or an HTML table from Apify Console or the API. Every row carries a `scrapedAt` ISO-8601 timestamp regardless of mode. A profile that couldn't be scraped still gets a row — `{"profile_url": ..., "error": "<reason>", "created_timestamp": ...}` — rather than being silently dropped; observed error strings in the source are `HTTP <status>`, `Empty response from LinkedIn`, `Login required or blocked (authwall)`, `No data extracted`, `Max retries exceeded`, or the raw exception text.

#### Scraped results (enrichment mode — `rawData: false`)

```json
[
  {
    "company_url": "https://www.linkedin.com/company/microsoft",
    "profile_url": "https://www.linkedin.com/in/timbakke/",
    "fullname": "Tim Bakke",
    "first_name": "Tim",
    "last_name": "Bakke",
    "headline": "Product Manager at Microsoft",
    "public_identifier": "timbakke",
    "profile_picture_url": "https://media.licdn.com/dms/image/tim-bakke.jpg",
    "location": { "country": "United States", "city": "Seattle", "full": "Seattle, Washington, United States", "country_code": "US" },
    "is_creator": null,
    "is_influencer": null,
    "is_premium": null,
    "created_timestamp": 1785000000,
    "show_follower_count": null,
    "current_company": "Microsoft",
    "companies_detected": [ { "name": "Microsoft", "slug": "microsoft", "url": "https://www.linkedin.com/company/microsoft" } ],
    "personal_website": "",
    "recommendations_received": [ { "text": "Tim is a fantastic collaborator and always ships on time.", "author": "Jane Smith", "author_url": "https://www.linkedin.com/in/jane-smith/" } ],
    "other_contact_details": { "course_links": ["https://www.linkedin.com/learning/product-management-foundations"] },
    "contact_elements": {
      "profile_identity": { "profile_identifier": "https://www.linkedin.com/in/timbakke/", "profile_picture": "https://media.licdn.com/dms/image/tim-bakke.jpg", "profile_name_display": "Tim Bakke" },
      "auth_points": { "email_phone_input_present": false, "password_input_present": false, "login_form_action": "" },
      "learning_content": {
        "course_details": [ { "title": "Product Management Foundations", "duration": "1h 12m", "url": "https://www.linkedin.com/learning/product-management-foundations", "image": "https://media.licdn.com/dms/image/course.jpg" } ],
        "course_titles": ["Product Management Foundations"],
        "all_courses_link": "https://www.linkedin.com/learning/me"
      },
      "platform": { "corporate_logo_url": "", "copyright": "LinkedIn Corporation © 2026", "policy_links": [], "about_links": [], "privacy_links": [], "user_agreement_links": [] },
      "language": { "selector_present": false, "locales": [] }
    },
    "course_signal": { "course_count": 1, "total_learning_minutes": 72, "course_titles": ["Product Management Foundations"], "themes": ["product"], "matched_keywords": ["product management"] },
    "recommendation_signal": { "count": 1, "recommenders": ["Jane Smith"], "themes": ["leadership"], "matched_keywords": ["leader"] },
    "scrapedAt": "2026-07-26T00:00:00Z",
    "location_display": "Seattle, Washington, United States",
    "course_count": 1,
    "courseThemes": "product",
    "recommendation_count": 1,
    "recThemes": "leadership"
  },
  {
    "company_url": "",
    "profile_url": "https://www.linkedin.com/in/example-noSignals/",
    "fullname": "Alex Rivera",
    "first_name": "Alex",
    "last_name": "Rivera",
    "headline": "Software Engineer",
    "public_identifier": "example-noSignals",
    "profile_picture_url": "",
    "location": { "country": "", "city": "", "full": "", "country_code": "" },
    "is_creator": null,
    "is_influencer": null,
    "is_premium": null,
    "created_timestamp": 1785000010,
    "show_follower_count": null,
    "current_company": "",
    "companies_detected": [],
    "personal_website": "",
    "recommendations_received": [],
    "other_contact_details": { "course_links": [] },
    "contact_elements": {
      "profile_identity": { "profile_identifier": "https://www.linkedin.com/in/example-noSignals/", "profile_picture": "", "profile_name_display": "Alex Rivera" },
      "auth_points": { "email_phone_input_present": false, "password_input_present": false, "login_form_action": "" },
      "learning_content": { "course_details": [], "course_titles": [], "all_courses_link": "" },
      "platform": { "corporate_logo_url": "", "copyright": "", "policy_links": [], "about_links": [], "privacy_links": [], "user_agreement_links": [] },
      "language": { "selector_present": false, "locales": [] }
    },
    "course_signal": { "course_count": 0, "total_learning_minutes": 0, "course_titles": [], "themes": [], "matched_keywords": [] },
    "recommendation_signal": { "count": 0, "recommenders": [], "themes": [], "matched_keywords": [] },
    "scrapedAt": "2026-07-26T00:00:00Z",
    "location_display": "",
    "course_count": 0,
    "courseThemes": "",
    "recommendation_count": 0,
    "recThemes": ""
  },
  {
    "profile_url": "https://www.linkedin.com/in/blocked-example/",
    "error": "Login required or blocked (authwall)",
    "created_timestamp": 1785000020,
    "scrapedAt": "2026-07-26T00:00:00Z",
    "location_display": "",
    "course_count": null,
    "courseThemes": "",
    "recommendation_count": null,
    "recThemes": ""
  }
]
```

That third row is a real behavior worth calling out: an error record still runs through the same row-shaping step as a successful one, so it still picks up `location_display`, `course_count`, `courseThemes`, `recommendation_count`, and `recThemes` — just as `""`/`null` rather than omitted — even though it never had a `course_signal` or `recommendation_signal` block to derive them from.

#### Raw data mode (`rawData: true`)

With `rawData` on, every field above under `contact_elements`, `course_signal`, `recommendation_signal`, and the flattened `location_display`/`course_count`/`courseThemes`/`recommendation_count`/`recThemes` columns is dropped. A row is just the base scraped record plus `scrapedAt`:

```json
{
  "company_url": "https://www.linkedin.com/company/microsoft",
  "profile_url": "https://www.linkedin.com/in/timbakke/",
  "fullname": "Tim Bakke",
  "headline": "Product Manager at Microsoft",
  "public_identifier": "timbakke",
  "location": { "country": "United States", "city": "Seattle", "full": "Seattle, Washington, United States", "country_code": "US" },
  "current_company": "Microsoft",
  "recommendations_received": [ { "text": "Tim is a fantastic collaborator and always ships on time.", "author": "Jane Smith", "author_url": "https://www.linkedin.com/in/jane-smith/" } ],
  "contact_elements": { "learning_content": { "course_details": [ { "title": "Product Management Foundations", "duration": "1h 12m", "url": "https://www.linkedin.com/learning/product-management-foundations" } ] } },
  "scrapedAt": "2026-07-26T00:00:00Z"
}
```

#### Run-level signal summary (Key-Value store)

When `rawData` is off, the Actor also writes one aggregated object to the run's default Key-Value store under the key `signal_summary`, rolling up theme frequencies and totals across every profile in the run:

```json
{
  "variant": "course_recommendation_signals",
  "mode": "profiles",
  "count": 3,
  "errors": 1,
  "generated_at": "2026-07-26T00:00:00Z",
  "signal_summary": {
    "profiles_enriched": 2,
    "total_courses": 1,
    "total_learning_minutes": 72,
    "total_recommendations": 1,
    "course_theme_frequency": { "product": 1 },
    "recommendation_theme_frequency": { "leadership": 1 }
  }
}
```

Each dataset row is pushed with the pay-per-event charge label `row_result` (`Actor.push_data(row, charged_event_name="row_result")` in the source). That event only produces a charge once Pay-Per-Event pricing for `row_result` is configured on this Actor's Store listing — otherwise a run is billed under Apify's standard consumption-based pricing for the compute and proxy usage it consumes. The Actor also checks `event_charge_limit_reached` after every push and stops the run early with `Actor.exit(status_message="User spending limit reached")` if your account's spending limit is hit mid-run.

### How can I use the data extracted with LinkedIn Profile — Courses & Recommendations Signal?

- **🎯 Sales, recruiting, and BD teams:** feed a target list of `profileUrls`, then use `course_signal.themes` and `recommendation_signal.themes` as a first-pass skills/strengths read on a lead or candidate before an outreach or screening call — `current_company` and `headline` give the context, the signal blocks give the substance.
- **🤖 AI engineers and LLM developers:** because every field is typed JSON with stable names, an agent tool can call this Actor, drop a row straight into an LLM's context window, and let the model reason over `course_signal`/`recommendation_signal` instead of parsing raw profile HTML itself.
- **📈 People-analytics and L\&D teams:** `course_theme_frequency` and `total_learning_minutes` in the run-level `signal_summary` give a batch-level read on what a cohort of profiles is actually studying, without manually opening each profile's Recommended-courses module.
- **🔬 Market and competitive researchers:** run the same `companies` list on a schedule and compare `recommendation_theme_frequency` and headcount-adjacent signals like `companies_detected` across runs to track how a company's public-facing talent narrative shifts over time.

### How do you monitor course and recommendation signals over time?

Course and recommendation signals aren't static — a profile's Recommended-courses module and received Recommendations change as a person completes courses or gets endorsed, so the useful discipline here is diffing, not a single snapshot. Run the same `profileUrls` (or `companies`) list on a recurring schedule and compare the new run's `course_signal.themes`, `course_signal.course_count`, `course_signal.total_learning_minutes`, `recommendation_signal.themes`, and `recommendation_signal.count` against the previous run's values for the same `public_identifier`. A new theme appearing in `course_signal.themes` with a fresh `matched_keywords` entry is a concrete, checkable signal that someone has started building a specific skill; a jump in `recommendation_signal.count` is a concrete signal that someone new has publicly vouched for them. The run-level `signal_summary.course_theme_frequency` and `.recommendation_theme_frequency` do the same comparison at the batch level, so you can track whether a theme is trending up or down across an entire target list rather than one person at a time.

The Actor itself has no built-in cron or webhook delivery — the real scheduling mechanism is the Apify platform's own **Schedules** feature (Apify Console → Schedules), which can trigger this Actor on a recurring interval with the same input, and Apify's dataset/run webhooks, which can push a notification or payload to your own endpoint when each scheduled run finishes.

### Integrate LinkedIn Profile — Courses & Recommendations Signal and automate your workflow

LinkedIn Profile — Courses & Recommendations Signal works with any language or tool that can send an HTTP request, since it runs as a standard Apify Actor reachable through the Apify API.

#### REST API with Python

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("linkedin-profile-course-signals").call(
    run_input={"mode": "profiles", "profileUrls": ["https://www.linkedin.com/in/timbakke/"]}
)

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if "error" in row:
        continue
    print(row["fullname"], row["course_signal"]["themes"], row["recommendation_signal"]["themes"])
```

#### MCP for query-grounded AI agents

Any Apify Actor, including this one, is reachable from an MCP-compatible client through Apify's MCP Server at `https://mcp.apify.com`, or by running `npx @apify/actors-mcp-server` locally with `ACTORS=linkedin-profile-course-signals` set. An agent issues a natural-language question, the MCP tool call runs this Actor against the relevant `profileUrls` or `companies`, and the agent receives structured JSON back to ground its answer in — rather than guessing at a lead's skills or background. Compatible with Claude, Cursor, and other MCP clients.

#### Scheduled monitoring and delivery

There is no webhook input field on this Actor, so real-time push delivery isn't a built-in feature — the platform pattern is an Apify **Schedule** that reruns the Actor on an interval, combined with an Apify dataset or run **webhook** pointed at your own endpoint to receive each run's results as they complete.

### Is it legal to scrape LinkedIn profiles?

Yes — scraping publicly accessible web pages is generally legal, and LinkedIn Profile — Courses & Recommendations Signal only accesses public, logged-out LinkedIn profile pages; it does not log in, use a stored session, or access anything behind LinkedIn's authwall. In *hiQ Labs, Inc. v. LinkedIn Corp.* (9th Cir. 2019), the court held that scraping data LinkedIn makes publicly accessible does not violate the Computer Fraud and Abuse Act. Because this Actor's output is built from a person's name, headline, employer, location, and recommendation text, it is personal data, and storing or using it should have a lawful basis under GDPR/CCPA if you operate in or target the EU/California — this is a different framing from a product-catalog or listings scraper, which is governed primarily by terms-of-service and database-rights considerations rather than personal-data law. Scraping for one-off research monitoring and scraping to build a bulk-stored dataset for AI training or resale carry different risk profiles. Consult your legal team for commercial use cases involving bulk data storage.

### ❓ Frequently asked questions

#### Which AI providers can I use for classification?

Seven: Anthropic, OpenAI, Google, xAI, DeepSeek, Perplexity, and Mistral, selected via the `aiModel` parameter — the provider is auto-detected from the model name prefix (`claude-*` = Anthropic, `gpt-*`/`o1`/`o3-*` = OpenAI, `gemini-*` = Google, `grok-*` = xAI, `deepseek-*` = DeepSeek, `sonar*` = Perplexity, `mistral-*`/`ministral-*`/`codestral-*`/`magistral-*`/`pixtral-*` = Mistral). The default, `claude-haiku-4-5`, is one of the cheaper options; the input description recommends a cheap/mini/flash/haiku-tier model for this kind of classification task.

#### What's the difference between `mode: profiles` and `mode: company`?

`mode: profiles` scrapes the exact `profileUrls` you list — it's the reliable path since every URL is fetched directly. `mode: company` instead takes `companies` (slugs or company URLs), finds up to `maxEmployees` employee profile URLs by combining links found on the company's public pages with a best-effort Google search, and then scrapes those discovered profiles the same way. The Google-discovery step is brittle and often blocked by HTTP 429s or a consent wall from cloud IPs, so `company` mode can return fewer employees than `maxEmployees` asks for.

#### How does LinkedIn Profile — Courses & Recommendations Signal handle LinkedIn's anti-bot measures?

It retries a blocked or empty fetch up to 3 times per profile with a jittered delay between attempts that grows with each retry, and optionally refreshes guest cookies via a headless Playwright browser before the first attempt and again before the last. If the response redirects to a login/challenge/authwall page or is too short to be a real profile, the attempt is treated as a failure and retried; if every attempt fails, the profile is returned as an honest `{profile_url, error}` row rather than silently dropped or faked. Requests are also routed through Apify Proxy via the `proxyConfiguration` input, with a Residential proxy group recommended for LinkedIn specifically.

#### Does LinkedIn Profile — Courses & Recommendations Signal extract course and recommendation signals?

Yes — `course_signal` (from the scraped Recommended-courses module) and `recommendation_signal` (from received Recommendation text) are added to every non-error profile row when `rawData` is off. They are absent when `rawData` is on, and an error row (a profile that couldn't be scraped at all) never gets either block, though it still carries the flattened `course_count`/`recommendation_count`/`courseThemes`/`recThemes` columns as `null`/empty strings.

#### How many profiles does LinkedIn Profile — Courses & Recommendations Signal return per run?

In `profiles` mode, exactly one row per unique URL/slug you provide in `profileUrls` (duplicates are normalized and deduplicated before scraping). In `company` mode, up to `maxEmployees` rows per company in `companies` (default `20`, maximum `200`, minimum `1`) — the actual count depends on how many employee URLs the company-page-plus-Google-search discovery step manages to find, which is not guaranteed to reach the cap.

#### How do I use LinkedIn Profile — Courses & Recommendations Signal to monitor signals over time?

Rerun the same `profileUrls` or `companies` list on a recurring Apify Schedule, then compare each new run's `course_signal.themes`, `course_signal.total_learning_minutes`, and `recommendation_signal.themes`/`.count` for the same `public_identifier` against the previous run, and alert on any new theme or a jump in either count — the run-level `signal_summary` gives you the same comparison rolled up across the whole batch.

#### Does LinkedIn Profile — Courses & Recommendations Signal work with Claude, ChatGPT, and AI agent frameworks?

Yes, two ways. It's callable as a standard HTTP endpoint through the Apify API from any agent framework that can make a request, and it's reachable through Apify's MCP Server (`https://mcp.apify.com`, or a local `npx @apify/actors-mcp-server` instance) for MCP-compatible clients like Claude and Cursor, so an agent can call it as a tool and ground its answer in the returned JSON.

#### How does LinkedIn Profile — Courses & Recommendations Signal compare to other LinkedIn scrapers?

As observed on the Apify Store on 2026-07-26, alizarin\_refrigerator-owner's *LinkedIn Jobs Scraper* targets job postings for hiring-intent signals rather than individual profiles, and requires bringing your own Firecrawl API key or exported session cookies to bypass LinkedIn's anti-bot wall. parseforge's *LinkedIn Learning Catalog Scraper* returns LinkedIn Learning course-catalog records (`id`, `scrapedAt`, `sourceUrl`, `title`) with no profile attached. Neither returns a profile record with course and recommendation data tied to that specific person and rolled into a keyword-theme signal, which is what this Actor does, and this Actor needs no bring-your-own scraping API key — only Apify Proxy, which the `proxyConfiguration` input resolves automatically.

#### Why are `is_creator`, `is_influencer`, and `is_premium` always null?

Because they aren't actually present in the public profile HTML this Actor scrapes — they're emitted as `null` rather than guessed or defaulted, so a downstream consumer never mistakes an unknown value for a real "false." `show_follower_count` is `null` for the same reason.

#### Can I use LinkedIn Profile — Courses & Recommendations Signal without managing proxies or LinkedIn credentials?

Yes. No LinkedIn login, password, or session cookie is ever required as input — the Actor only reads public profile pages. Proxy handling is done through the `proxyConfiguration` input, which defaults to Apify Proxy with the RESIDENTIAL group; you don't need to source, rotate, or configure your own proxy IPs.

### 💬 Your feedback

Found a bug, or a field this Actor should be extracting but isn't? Let us know — open an issue on this Actor's Issues tab in Apify Console with a description and, where possible, the profile or company URL that triggered it. Reports like this directly shape what gets fixed and added next.

# Actor input Schema

## `mode` (type: `string`):

How to source profiles.
• profiles — scrape the exact LinkedIn profile URLs you provide (reliable path).
• company — discover employees of a company (via company pages + Google search) then scrape them. Google discovery is brittle and usually blocked from datacenter IPs. Default: profiles.

## `profileUrls` (type: `array`):

Used when mode = profiles. Full profile links or bare slugs (e.g. https://www.linkedin.com/in/timbakke/ or timbakke). One per line.

## `companies` (type: `array`):

Used when mode = company. Company slugs or LinkedIn company URLs (e.g. microsoft or https://www.linkedin.com/company/microsoft). Each is expanded to up to 'Max employees' profiles.

## `maxEmployees` (type: `integer`):

Used when mode = company. Caps how many discovered employee profiles are scraped per company. Example: 20. Default: 20.

## `rawData` (type: `boolean`):

Emit the plain base profile records only (no course\_signal / recommendation\_signal / AI / signal\_summary). Turn off to get the full signal layer. Default: off.

## `aiEnhancement` (type: `boolean`):

Adds an `ai` block (seniority, skills, lead\_quality, rationale) per profile. Default: off.

## `aiModel` (type: `string`):

Provider auto-detected from the name: claude-*=Anthropic, gpt-*/o1/o3=OpenAI, gemini-*=Google, grok-*=xAI, deepseek-*=DeepSeek, sonar*=Perplexity, mistral-\*=Mistral. Cheaper mini/flash/haiku/lite models are recommended for classification.

## `aiApiKey` (type: `string`):

API key for the selected provider. Leave blank to fall back to the provider env var (ANTHROPIC\_API\_KEY / OPENAI\_API\_KEY / GEMINI\_API\_KEY / XAI\_API\_KEY / DEEPSEEK\_API\_KEY / PERPLEXITY\_API\_KEY / MISTRAL\_API\_KEY). Only used when AI classification is on.

## `proxyConfiguration` (type: `object`):

Select Apify Proxy (RESIDENTIAL recommended) or custom proxy URLs.

## Actor input object example

```json
{
  "mode": "profiles",
  "profileUrls": [
    "https://www.linkedin.com/in/timbakke/"
  ],
  "companies": [
    "microsoft"
  ],
  "maxEmployees": 20,
  "rawData": false,
  "aiEnhancement": false,
  "aiModel": "claude-haiku-4-5",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `results` (type: `string`):

All scraped items in the Actor's default dataset.

## `signalSummary` (type: `string`):

Run-level roll-up (profiles enriched, total courses/minutes/recommendations, theme frequencies) written once per run to the key-value store. Present only when `rawData` is off.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "profileUrls": [
        "https://www.linkedin.com/in/timbakke/"
    ],
    "companies": [
        "microsoft"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapier/linkedin-profile-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "profileUrls": ["https://www.linkedin.com/in/timbakke/"],
    "companies": ["microsoft"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapier/linkedin-profile-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "profileUrls": [
    "https://www.linkedin.com/in/timbakke/"
  ],
  "companies": [
    "microsoft"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call scrapier/linkedin-profile-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapier/linkedin-profile-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/ee988XmSotkrjFYI9/builds/a3c3vE9sJ01r2kCLO/openapi.json
