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¶
https://www.linkedin.com/my-items/saved-jobs/(best current deep link)https://www.linkedin.com/jobs/tracker/saved/→https://www.linkedin.com/jobs/tracker/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)¶
<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.- Semantic HTML / ARIA:
<h1>,role=,aria-label, visible text. - 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. - 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 AIbutton (Premium),Cancel, andSend(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).
Study platforms — deep links, free APIs, ToS¶
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 withtitle,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.
LeetCode — deep links + unofficial GraphQL (read-only, cached)¶
- 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/graphql—activeDailyCodingChallengeQuestion,problemsetQuestionList(filters),question(titleSlug),matchedUser(username). Also legacyGET /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}/.
Coursera — deep links + live courses.v1 catalog API ✓¶
- Search deep link:
https://www.coursera.org/courses?query={kw}&productDifficultyLevel=Beginner(prefer/courses;/searchis 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}.
Udemy — deep links only (no usable API; API discontinued 2025)¶
- Search:
https://www.udemy.com/courses/search/?q={kw}(+ optionalsort=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.
Google Cloud Skills Boost / "Google Skills" — deep links only (no API)¶
- Domain rebranded →
skills.google(cloudskillsboost.google 308-redirects; paths identical). - Search:
https://www.skills.google/catalog?keywords={kw}(optionalformat[]=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.
AWS Skill Builder — deep links only (no API)¶
- Migrated to
skillbuilder.awsSPA (legacyexplore.skillbuilder.aws302-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).
CoderByte — deep links only (no API; scraping forbidden)¶
- Library:
https://coderbyte.com/challenges(client-side filters, no shareable filter URL). - Challenge:
https://coderbyte.com/information/{Name}; editorhttps://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 |