Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions domain-skills/linkedin/activity.md
Original file line number Diff line number Diff line change
@@ -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/<handle>/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 `<div>`, not `<article>`. The 2 `<article>` 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/<handle>/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.
102 changes: 102 additions & 0 deletions domain-skills/linkedin/post.md
Original file line number Diff line number Diff line change
@@ -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: <first
line>… 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.