Skip to content

Platform automation research (2026-07)

Desk research (public sources only, no credentialed automation) into the automation surfaces WorkWingman touches: LinkedIn saved-jobs scraping and seven study platforms. Full agent reports with citations are archived in the session; this is the distilled, actionable reference the code is wired from.

Governing posture (applies to all): WorkWingman deep-links the user's OWN signed-in browser and, where a free, no-auth content API exists, reads it read-only and cached. It never automates credentialed login and never scrapes platforms whose ToS forbid it.


LinkedIn (saved-jobs scraper)

Saved-jobs list URL — fallback order

  1. https://www.linkedin.com/my-items/saved-jobs/ (best current deep link)
  2. https://www.linkedin.com/jobs/tracker/saved/https://www.linkedin.com/jobs/tracker/
  3. https://www.linkedin.com/jobs/ then click My Jobs / Job tracker → Saved tab (last resort)

The list is an infinite-scroll, virtualized React SPA: scroll incrementally, wait for new rows, harvest each /jobs/view/{id}/ href as it appears (off-screen rows recycle out of the DOM). LinkedIn keeps saved jobs ~6 months, caps ~1,000.

Job detail page (/jobs/view/{id}/) — parse order (most→least durable)

  1. <script type="application/ld+json"> JobPosting — schema.org block served for SEO, rarely changes shape. Fields: title, description (HTML), hiringOrganization.name, jobLocation, datePosted, employmentType, validThrough. Primary source of truth.
  2. Semantic HTML / ARIA: <h1>, role=, aria-label, visible text.
  3. Stable class prefixes via [class*="..."]: jobs-unified-top-card__, job-details-jobs-unified-top-card__, top-card-layout__, jobs-description__content, show-more-less-html__markup. Never depend on exact obfuscated leaf classes.
  4. Guest fallback API (no auth, stable classes, but NO Premium fit summary): https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/{id}.

Premium AI "fit" summary ("Am I a good fit" / "Job Match"): authenticated, Premium-gated, lazily hydrated, most volatile element on the page and NOT in JSON-LD. Anchor on visible prompt text, short timeout, treat as optional/best-effort, absent for free accounts.

Login-wall / checkpoint detection (soft wall — often HTTP 200)

Not-authenticated when the final URL contains /authwall, /login, /uas/login, /checkpoint/, OR a login/join form is present (input[name='session_key'], #username), OR the authenticated global-nav "Me" avatar is missing. Surface "needs manual login" and pause for the user to sign in in the visible browser.

li_at session detection — no network request

li_at is the authenticated session cookie (.www.linkedin.com, HttpOnly). Detect a live session by reading cookies locally (context.cookies('https://www.linkedin.com')) and checking for a non-empty, unexpired li_at — a pure in-process read, no request to LinkedIn, so it can't trip rate limits. Necessary but not sufficient (server can invalidate); fold the real confirmation into the first navigation you were going to do anyway (apply the wall detector on that page).

Persistence

launchPersistentContext(userDataDir) on a DEDICATED profile (not the user's real Chrome) persists li_at/localStorage across restarts → sign in once, observe later. Belt-and-braces: snapshot storageState() after login.

ToS / defensibility (hiQ v. LinkedIn)

The hiQ CFAA win covers public data; it does NOT shield WorkWingman, which operates behind login under LinkedIn's User Agreement (§8.2 forbids automated scraping). Defensibility rests on: never automate login, user's own account + own saved jobs only, visible browser the user drives, human-paced (single session, no concurrency, 2–6s jittered delays, no proxy/fingerprint rotation), cache locally, stop immediately on any checkpoint (no retry storms). Framed as personal data portability / assisted browsing. Not legal advice — flag for real review.


LinkedIn outreach surfaces (verified live 2026-07-05, Premium account)

Four ways to reach a hiring manager / contact, verified by walking each flow on a real signed-in Premium account (flows opened and inspected, nothing sent). Cheapest-first strategy: public email → connect+note → InMail; save InMail credits for high-value targets.

1. Job page "Meet the hiring team" block

  • Lives on the job detail page (/jobs/view/{id}/) below the Premium fit card.
  • Contains: recruiter/HM name link (a[href*="/in/"]), degree badge, headline ("Talent @ {company}"), mutual-connection count, a Message button, and a Show all expander when multiple team members exist.
  • Anchor on the visible "Meet the hiring team" heading text, then harvest the /in/{slug} hrefs inside the block. Message button here usually consumes an InMail credit unless already connected.

2. Connect + invitation note (300 chars)

  • On creator-mode profiles (Follow shown as primary action) Connect is hidden under the "More" dropdown — flow: More → Connect. On standard profiles Connect is a top-level button.
  • Connect opens modal "Add a note to your invitation?" with two buttons: Add a note / Send without a note (send-without is the highlighted default — automation must NOT click the primary button blindly).
  • Add a note → textarea with 300-char counter (0/300), Write with AI button (Premium), Cancel, and Send (disabled until text present).
  • Premium = unlimited notes (modal says "You have unlimited notes with Premium"); free tier is limited to a small monthly quota of personalized invites.
  • Cancel returns to the first modal, not out of the flow — full dismiss is the X in the modal header.

3. InMail (Message to a non-connection)

  • Profile → Message primary button opens a "New message" overlay with: Premium banner "Use 1 of 5 InMail credits" (Career Premium = 5/month, credits roll up to a cap), an InMail-only Subject (optional) field, body textarea, Write with AI, attachment/image/emoji icons, send arrow.
  • The subject field is the reliable discriminator between InMail compose and a regular DM to an existing connection (regular DMs have no subject).
  • Compose deep link (automatable): https://www.linkedin.com/messaging/compose/?profileUrn=urn%3Ali%3Afsd_profile%3A{URN}&recipient={URN}&interop=msgOverlay — both params carry the target's profile URN; harvestable from search-result and profile-page Message links (a[href*="/messaging/compose/"]).

4. Public contact info on the profile itself

  • Some profiles expose email directly: banner-image slides (e.g. rotating creator banners with an email on one slide) and the Contact info link next to location (a[href="#"] with visible text "Contact info" → modal with email/website when the member shares them). Always check before spending an InMail credit.

Outreach ToS note

Same posture as the scraper (§ LinkedIn above): user's own signed-in visible browser, human-paced, WorkWingman drafts text and pre-fills/deep-links but the user clicks Send. Auto-sending invitations/messages is both a ToS risk (automation) and a product decision we've explicitly declined (BYO judgement at the pause point).


Deep-linking the user's signed-in browser is safe and ToS-clean everywhere. A free content API exists for only three (MS Learn, LeetCode, Coursera).

Microsoft Learn — FREE keyless Catalog API ✓

  • Search deep link: https://learn.microsoft.com/training/browse/?terms={kw}&roles=&products=&levels=&resource_type=
  • Item deep links: /training/modules/{slug}/, /training/paths/{slug}/, /credentials/certifications/{slug}/
  • Catalog API (no key, no auth): GET https://learn.microsoft.com/api/catalog/?type=modules,learningPaths&role={r}&product={p}&level={l} → JSON with title, summary, url, duration_in_minutes, rating, tags. Refreshes daily.
  • Caveat: legacy endpoint was slated for June-2026 deprecation → verify live; migrate to the Learn Platform API catalog when needed.
  • Public profile: https://learn.microsoft.com/users/{username}/. Transcript share via ID+access-code.
  • Filtered problemset: https://leetcode.com/problemset/?topicSlugs={tag}&difficulty=MEDIUM&search={kw} (tags lowercase-hyphenated).
  • Problem: https://leetcode.com/problems/{title-slug}/. Public list: /problem-list/{id}/.
  • GraphQL (undocumented, no auth for public reads): POST https://leetcode.com/graphqlactiveDailyCodingChallengeQuestion, problemsetQuestionList(filters), question(titleSlug), matchedUser(username). Also legacy GET /api/problems/all/. No published rate limit; community-safe ≈20 req/10s. Use sparingly + cached; Cloudflare-fronted. ToS forbids bulk scraping.
  • Public profile: https://leetcode.com/u/{username}/.
  • Search deep link: https://www.coursera.org/courses?query={kw}&productDifficultyLevel=Beginner (prefer /courses; /search is robots-disallowed).
  • Item: /learn/{slug}, /specializations/{slug}, /professional-certificates/{slug}.
  • Catalog API (public, no auth, verified live): GET https://api.coursera.org/api/courses.v1?fields=name,slug,description&start=&limit= (~22k courses). Treat as unofficial → deep-link fallback.
  • Credentials: public verify https://www.coursera.org/verify/{CODE} / /account/accomplishments/verify/{CODE}.
  • Search: https://www.udemy.com/courses/search/?q={kw} (+ optional sort=highest-rated, price=, ratings=, instructional_level=).
  • Course: https://www.udemy.com/course/{slug}/. Progress: private /home/my-courses/learning/. Cert verify: https://ude.my/UC-{id}.
  • No free content API (Affiliate API needs approval + discontinued 2025-01-01). Returns 403 to non-browser clients; ToS forbids scraping.
  • Domain rebranded → skills.google (cloudskillsboost.google 308-redirects; paths identical).
  • Search: https://www.skills.google/catalog?keywords={kw} (optional format[]=courses|labs).
  • Items: /course_templates/{id}, /focuses/{id}, /paths/{id}.
  • No public catalog API; 403 to non-browser clients; ToS forbids automation. Public profile: /public_profiles/{uuid} (user opts in). Badges via Credly.
  • Migrated to skillbuilder.aws SPA (legacy explore.skillbuilder.aws 302-redirects; opaque IDs).
  • Search: https://skillbuilder.aws/search?searchText={kw} (verify param against live SPA).
  • Item: https://skillbuilder.aws/learn/{id}/{slug}/{id2} — opaque IDs NOT derivable from name; capture full URL per item.
  • No public API. Certs via Credly: public JSON https://www.credly.com/users/{username}/badges.json (no CORS → fetch server-side).
  • Library: https://coderbyte.com/challenges (client-side filters, no shareable filter URL).
  • Challenge: https://coderbyte.com/information/{Name} ; editor https://coderbyte.com/editor/{Name}:{Language} (URL-encoded).
  • No free API (paid add-on only); no public profile; ToS explicitly forbids crawl/scrape. Deep-link only.

Summary table

Platform Keyword-search deep link Free content API? Public profile
LinkedIn (jobs) my-items/saved-jobs (auth, scrape own session) JSON-LD on page (not an API) n/a
Microsoft Learn /training/browse/?terms= Yes — /api/catalog/ (keyless) /users/{name}/
LeetCode /problemset/?topicSlugs=&search= Unofficial GraphQL (read-only,cached) /u/{name}/
Coursera /courses?query= Yes — courses.v1 (unofficial) verify/{code}
Udemy /courses/search/?q= No (discontinued 2025) /user/{slug}/
Google Skills skills.google/catalog?keywords= No /public_profiles/{uuid}
AWS Skill Builder skillbuilder.aws/search?searchText= No (Credly for certs) Credly
CoderByte /challenges (no filter URL) No None