diff --git a/domain-skills/linkedin/activity.md b/domain-skills/linkedin/activity.md new file mode 100644 index 00000000..24484685 --- /dev/null +++ b/domain-skills/linkedin/activity.md @@ -0,0 +1,114 @@ +# LinkedIn — recent activity feed + +Reading the activity feed of a candidate's profile (posts, comments, +reposts) for outreach hook generation. Used by PhilOS's +`scripts/routines/linkedin_activity.py` to feed `draft_outreach`'s +hook cascade. + +**Read-only.** Never use harness sessions to send messages, like, or +follow from someone else's profile — that violates LinkedIn ToS and +PhilOS's `PHILOS_LINKEDIN_READ_ONLY` kill switch is the second line of +defense. + +## URL pattern + +``` +https://www.linkedin.com/in//recent-activity/all/ +``` + +Variants (not used by PhilOS yet but documented for future): +- `/recent-activity/posts/` — posts only +- `/recent-activity/comments/` — comments only +- `/recent-activity/reactions/` — reactions only + +The `all/` view is enough for most outreach hooks since recent comments +often produce the strongest "non-fakeable" signal (someone defending a +position, asking a question, contributing to a discussion). + +## Required wait + +`wait_for_load()` returns before the SPA finishes rendering activity +items. Scroll-driven lazy-load is the trigger. Reliable sequence: + +```python +new_tab(URL) +wait_for_load() +for _ in range(3): + js("window.scrollBy(0, 800)") + time.sleep(1.0) +# By now ~10 items are rendered. More scrolls give marginal returns; +# the freshest signals are at the top anyway. +``` + +## Stable selectors (validated 2026-04-27) + +| Selector | Count observed | What it gets you | +|---|---|---| +| `div[data-urn^="urn:li:activity:"]` | 10 | **Canonical item wrapper.** Use this. | +| `div.feed-shared-update-v2` | 10 | Parallel selector; same set. Either works. | +| `.update-components-text` | 11 | Post body text (the thing you want to hook on). | +| `.update-components-actor__sub-description` | 11 | Timestamp + visibility (e.g. "4d • Edited • Public"). | +| `time[datetime]` | 27 (multi/card) | ISO datetime when present. | +| `a[href*="/feed/update/"]` | 1 (sparse) | "View post" link — **don't rely on this**, build from data-urn. | + +The `data-urn` is the most reliable identifier: every item carries it, +and you can build a public post URL from it: + +```js +const urn = item.getAttribute("data-urn"); // "urn:li:activity:7453131213281730560" +const url = `https://www.linkedin.com/feed/update/${urn}/`; +``` + +## Selectors that don't work (do not use) + +These look reasonable but match zero elements on the current LinkedIn +DOM (2026-04-27). They were valid in older versions; LinkedIn renamed +or restructured them. + +| Selector | Why it fails | +|---|---| +| `article.feed-shared-update-v2` | Items are `
`, not `
`. The 2 `
` elements on the page are non-feed (profile header). | +| `.feed-shared-text` | Old class — renamed to `.update-components-text`. | +| `.update-components-actor` | Base class doesn't render; only the `__sub-description` modifier matches anything. | +| `a[href*="/posts/"]` | LinkedIn uses `/feed/update/` URLs now, not `/posts/`. | + +## Kind detection (post vs comment vs reaction vs share) + +Heuristic via `.update-components-header` inner text. When present, +it contains phrasing like "Addy commented on", "Addy reposted", +"Addy liked". When ABSENT (the most common case for original posts), +treat as `kind: "post"`. + +The hook quality doesn't materially depend on getting kind right — +the model uses `content_text` regardless — so a misclassified comment +labeled "post" is fine. `linkedin_activity.py` falls back to `"post"` +on ambiguity. + +## Empty / login-redirect detection + +If `data-urn` matches zero items after the scroll sequence, you're +probably hitting the auth wall (Phil's session expired) or the +candidate has no public activity. The harness script returns `[]` and +PhilOS treats empty scrapes silently — no error, no critique, just +"this candidate has no activity hook available, fall back to +work_history." + +## Trap: Activity tab on `/talent/profile/` + +LinkedIn Recruiter (`/talent/profile/...`) has its own activity surface +that uses different selectors. PhilOS only scrapes the public +(`/in//recent-activity/...`) view. Don't substitute the +Recruiter URL — selectors above won't match. + +## When LinkedIn breaks this + +LinkedIn renames CSS classes ~quarterly. Symptoms when this doc goes +stale: +- `linkedin_activity.py` cron writes empty rows night after night. +- `reflect_health` may flag draft_outreach as stale (no activity hooks + feeding the cascade). +- `LearningCard.tsx` shows zero accepted/rejected. + +Re-run the probe in `scripts/routines/linkedin_activity.py`'s docstring +(or the inline `extract` JS) to refresh counts. Update this doc with +the new selectors and the date of validation. diff --git a/domain-skills/linkedin/post.md b/domain-skills/linkedin/post.md new file mode 100644 index 00000000..a9b61af9 --- /dev/null +++ b/domain-skills/linkedin/post.md @@ -0,0 +1,102 @@ +# LinkedIn — publishing & scheduling your OWN posts + +Posting Phil's own text posts to his own feed, and scheduling them with +LinkedIn's **native** scheduler. Used by the LinkedIn post-queue automation +(`~/Projects/linkedin-content/queue/queue.json`). + +**This is your OWN feed.** Distinct from `activity.md`, which is read-only +scraping of *other people's* profiles for outreach hooks. The read-only ToS +rule (no liking/following/messaging from someone else's profile) is about +acting on third parties. Publishing your own content to your own account is a +normal account action. Still: keep cadence human (3x/week, mornings), never +auto-fire without a veto window at first. + +## The one hard gotcha: the composer is an IFRAME + +The "Start a post" composer body — the editor, the toolbar, the schedule +clock, the Post/Schedule button — renders **inside an iframe**. Top-document +`document.querySelector` sees `[role=dialog]` wrappers but **zero** of the +controls (validated 2026-06-19: 4 dialogs, 0 editors, 0 buttons found via DOM). + +So **do not drive this with CSS selectors.** Drive it with coordinate clicks + +keyboard + screenshots — which pass through the iframe at the compositor level +(the harness's whole design). Re-screenshot after every action to verify state. +Because positions shift with viewport, **locate each control from a fresh +screenshot at post time** rather than hardcoding pixels. Reference window is +1920x992; the composer is a centered modal. + +The ONE stable top-document anchor (outside the iframe) is the trigger button: +```python +# "Start a post" — findable by aria/text in the top document +js(r"""(() => { for (const b of document.querySelectorAll('button,[role=button]')) { + const a=(b.getAttribute('aria-label')||'')+'|'+(b.textContent||'').trim(); + if(/start a post/i.test(a) && b.offsetParent){const r=b.getBoundingClientRect(); + return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}} return null; })()""") +# ~ (940, 111) at 1920x992. Click it to open the composer. +``` + +## Validated flow — schedule a post (2026-06-19) + +1. **Open composer.** Click the "Start a post" trigger (anchor above). Wait ~2s. + Screenshot to confirm the modal ("Share your thoughts...") is open. +2. **Type the post.** Click the editor body (center of the modal, "Share your + thoughts..." placeholder), then `type_text(post)`. Newlines in the text + become line breaks. Screenshot — confirm the text and that the char counter + moved off 0. +3. **Open the scheduler.** Click the **clock icon** immediately to the LEFT of + the Post button, bottom-right of the modal. A **"Schedule post"** dialog opens. +4. **Set date + time.** The dialog has: + - a **Date** field formatted `M/D/YYYY` (e.g. `6/23/2026`), + - a **Time** dropdown (e.g. `10:00 AM`, 30-min granularity, local tz — + it states "...Central Daylight Time, based on your location"), + - a "View all scheduled posts →" link, and **Back / Next** buttons. + Set the date and a morning time. LinkedIn requires the time be in the future + (schedule >= ~1hr out for the veto window). +5. **Next.** Click **Next** — returns to the composer; the **Post** button is + now a **Schedule** button. +6. **Schedule.** Click **Schedule**. The post is now in LinkedIn's server-side + scheduled queue — **it fires even if this Mac is asleep.** +7. **Verify.** Reopen composer → clock → "View all scheduled posts" to confirm + it's queued. Or just screenshot the success toast. + +To **abort/map without publishing:** opening the schedule dialog does NOT +publish. Press `Escape` twice (schedule dialog, then composer). An empty editor +closes with no discard prompt. + +## Schedule-with-veto pattern (the automation's default "notify" mode) + +Goal: a human can stop a bad post, but doing nothing still ships it. + +- On post morning, schedule the next `pending` queue item ~1 hour out (steps + above), then send Phil an iMessage preview: *"Scheduling for 9:30am: … reply STOP to cancel."* Mark the item `queued` in queue.json. +- If Phil replies STOP within the window, reopen "View all scheduled posts" and + delete that scheduled post; mark it `skipped`. +- Otherwise LinkedIn fires it natively. Mark `posted`. + +**Graduate to full auto:** set `queue.json -> _meta.mode = "auto"` to skip the +iMessage/veto step entirely. Circuit breaker for auto mode: the runner checks +`_meta.paused` (a one-line kill switch) before scheduling anything. + +## Why native scheduler over "post immediately" + +Scheduling ~1hr out (vs posting on the spot) gives the veto window AND means a +mid-run harness crash can't lose or double-post the content — once it's in +LinkedIn's queue, it's safe. Mac only needs to be awake at *scheduling* time, +not at *post* time. + +## Runner shape (cheap, model-in-the-loop, DOM-change resilient) + +The trigger (Mac launchd on Tue/Wed/Thu, or a cloud routine) invokes a small +Claude run that *follows this file* via the harness: it screenshots, clicks, and +verifies adaptively. That absorbs LinkedIn's ~quarterly DOM/layout churn without +a code change — no brittle hardcoded coordinates to maintain. The expensive, +fragile alternative (a pure pixel-coordinate Python script) is deliberately NOT +used here. + +## When LinkedIn breaks this + +Symptoms: the "Start a post" anchor returns null (renamed aria), or the modal +layout moves so the clock/Post click misses. Fix: re-run the map (open composer, +screenshot, relocate controls), update the "validated" date above. The iframe +fact and the step order are stable; only positions drift.