mercury-agent 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/docs/behavior-layers.md +4 -2
  2. package/docs/configuration.md +15 -0
  3. package/docs/goals/feed-watch-social-sources/blocked-sources-research.md +289 -0
  4. package/docs/goals/feed-watch-social-sources/decisions.md +112 -0
  5. package/docs/goals/feed-watch-social-sources/goal.md +217 -0
  6. package/docs/goals/feed-watch-social-sources/roadmap.md +203 -0
  7. package/docs/goals/football-match-day-mode/decisions.md +209 -0
  8. package/docs/goals/football-match-day-mode/goal.md +109 -0
  9. package/docs/goals/football-match-day-mode/roadmap.md +208 -0
  10. package/docs/goals/football-reporter-profile/roadmap.md +4 -4
  11. package/docs/goals/rehearsal-bench/decisions.md +13 -0
  12. package/docs/goals/rehearsal-bench/goal.md +2 -2
  13. package/docs/goals/rehearsal-bench/roadmap.md +1 -1
  14. package/docs/goals/release-gate/goal.md +2 -2
  15. package/docs/goals/release-gate/roadmap.md +17 -4
  16. package/docs/live-testing.md +33 -2
  17. package/docs/memory.md +1 -1
  18. package/docs/pending-verification.md +207 -11
  19. package/docs/profile-guide.md +2 -2
  20. package/docs/subagents.md +60 -9
  21. package/examples/extensions/archive/queue.ts +93 -5
  22. package/examples/extensions/feed-watch/feeds.ts +246 -13
  23. package/examples/extensions/feed-watch/index.ts +84 -15
  24. package/examples/extensions/feed-watch/watch.ts +12 -1
  25. package/examples/extensions/longview/hook.ts +192 -42
  26. package/examples/profiles/football-reporter/AGENTS.md +5 -0
  27. package/examples/profiles/football-reporter/README.md +7 -1
  28. package/examples/profiles/football-reporter/standard.json +23 -1
  29. package/package.json +6 -5
  30. package/resources/agents/explore.md +0 -1
  31. package/resources/agents/worker.md +0 -1
  32. package/resources/pi-extensions/subagent/agents.ts +123 -99
  33. package/resources/pi-extensions/subagent/index.ts +1179 -885
  34. package/resources/pi-extensions/subagent/spawn.ts +229 -0
  35. package/src/agent/container-entry.ts +16 -2
  36. package/src/agent/container-error.ts +13 -1
  37. package/src/agent/container-runner.ts +223 -50
  38. package/src/agent/image-refresh.ts +25 -1
  39. package/src/agent/instance-id.ts +185 -0
  40. package/src/cli/mercury.ts +1265 -245
  41. package/src/config-file.ts +51 -1
  42. package/src/config.ts +1 -1
  43. package/src/core/commands.ts +5 -1
  44. package/src/core/conversation.ts +8 -1
  45. package/src/core/handler.ts +28 -1
  46. package/src/core/operator-alerts.ts +33 -15
  47. package/src/core/router.ts +27 -0
  48. package/src/core/routes/chat.ts +1 -0
  49. package/src/core/routes/dashboard.ts +9 -3
  50. package/src/core/runtime.ts +62 -7
  51. package/src/env-dump/build-deps.ts +128 -0
  52. package/src/env-dump/build.ts +572 -0
  53. package/src/env-dump/deps.ts +84 -0
  54. package/src/env-dump/manifest.ts +430 -0
  55. package/src/extensions/image-builder.ts +66 -2
  56. package/src/logger.ts +13 -0
  57. package/src/main.ts +110 -23
  58. package/src/ops/shadow-service.ts +901 -0
  59. package/src/ops/shadow-snapshot.ts +34 -1
  60. package/src/preflight/build-deps.ts +112 -0
  61. package/src/preflight/checks/credential.ts +8 -4
  62. package/src/preflight/checks/host-deps.ts +46 -21
  63. package/src/preflight/doctor-lines.ts +74 -0
  64. package/src/preflight/platform-path.ts +33 -0
  65. package/src/preflight/probe-container.ts +25 -3
  66. package/src/preflight/report.ts +116 -3
  67. package/src/preflight/run.ts +84 -6
  68. package/src/server.ts +26 -0
  69. package/src/text/reporter-lint.ts +64 -0
@@ -145,8 +145,10 @@ prompt", and it runs as the caller who created it.
145
145
  ## 4. The history window
146
146
 
147
147
  - `context.mode` is a per-space key. `main` and DM auto-spaces are seeded
148
- `context`; **any other space defaults to `clear`** (no history at all — only
149
- the reply chain when someone swipe-replies). Set it explicitly.
148
+ with the host default (`contextMode` / `MERCURY_CONTEXT_MODE`, `context`
149
+ unless set); **any space without a row runs on that same host default**.
150
+ `clear` means no history at all — only the reply chain when someone
151
+ swipe-replies. Set the key explicitly on a space that should differ.
150
152
  - `context.window_size` (default 10 — `config.ts contextWindowSize`) counts
151
153
  **user turns**: `getRecentTurns` takes the newest `turnCount*5` non-ambient
152
154
  rows after the compact boundary and cuts at the Nth user row. Assistant rows
@@ -199,6 +199,21 @@ It compares path *segments*, never string prefixes, so `mercury-shadowy` is
199
199
  not inside `mercury-shadow`. A live process (`MERCURY_SHADOW` unset) is never
200
200
  checked and runs exactly the code it ran before.
201
201
 
202
+ **A shadow never fetches an image.** `ensureImage` does not pull a missing
203
+ base image and does not re-pull a floating tag whose host version moved (the
204
+ release-gate R0.4 refresh), and `ensureDerivedImage` does not build: a cache
205
+ miss is fatal at boot, naming the tag. A rehearsal must run the image the live
206
+ bot is running, and a shadow that fell back to the base image would be
207
+ rehearsing a bot with none of the owner's extension CLIs.
208
+
209
+ **Every container carries `mercury.instance=<id>`**, derived from the resolved
210
+ data dir and settable by nothing, and the orphan sweep filters on it — no id,
211
+ no sweep. Two Mercury processes on one Docker daemon is the rehearsal shape,
212
+ and without this each one's boot sweep would force-remove the other's running
213
+ container. `/health` reports the same id in its `instance` field, always, so an
214
+ external probe can tell which process answered. `MERCURY_AGENT_ID` still
215
+ narrows further; it never replaces this.
216
+
202
217
  Snapshot directories are built by `mercury shadow snapshot`, which generates
203
218
  the shadow's `.env` and `mercury.yaml` — nothing here needs to be set by
204
219
  hand. See [live-testing.md](live-testing.md) §2 and §7.
@@ -0,0 +1,289 @@
1
+ # Blocked sources: what a personal poller can still read (research, 2026-09-06)
2
+
3
+ **Goal**: [feed-watch-social-sources](goal.md)
4
+ **Status**: research note — feeds the ladder in `goal.md` and the rung-3 goal; decides nothing by itself
5
+ **Asked**: the owner, 2026-09-06 — "search for methods to bypass the restrictions and use Facebook / Instagram / X / Yad2 / Madlan or any other sources that are blocking … specifically Facebook is a very important source and I think the email notifications won't be good enough."
6
+
7
+ The question is answered per platform: which read paths exist, what each
8
+ costs in money, latency, completeness, account risk and terms, and which
9
+ one this goal should build. The line drawn throughout: paths that use the
10
+ owner's **own account on their own data**, an **official alert channel**, a
11
+ **licensed data provider**, or an **open mirror** are candidates. Defeating
12
+ a bot-protection challenge on a site whose terms forbid automated access
13
+ (Yad2, Madlan) is recorded as "what exists", not planned — see the Yad2
14
+ section for why the sanctioned channels are the better deal anyway.
15
+
16
+ ## Probes run from the WSL box (residential IP, one GET each, 2026-09-06 ~19:00 IDT)
17
+
18
+ Plain `curl`, Chrome desktop UA unless noted. `mercury-feed-watch/1.0` is the
19
+ UA `feeds.ts` sends today.
20
+
21
+ | Endpoint | Result | What it means |
22
+ |----------|--------|---------------|
23
+ | `bsky.app/profile/theathletic.com/rss` | 200, `application/xml`, items with `<pubDate>` (newest 2026-09-06 06:49 UTC), post text in `<description>` | **Bluesky is rung 0, not rung 2**: per-account RSS with no auth, readable by today's `rss` source type with zero code |
24
+ | `public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=theathletic.com` | 200 JSON, `indexedAt` today | The richer API path also works unauthenticated; `searchPosts` returns 403 (auth) |
25
+ | `public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=…` | 200; finds `skysportspl.bsky.social` (1 post — parked), several English football journalists, a handful of Hebrew-language football accounts (small, personal) | Who is *on* Bluesky decides its value. Romano is not (`fabrizioromano.bsky.social` is a 2023 squat). Sky Sports PL is parked. The Athletic is live |
26
+ | `xcancel.com/FabrizioRomano/rss` (Chrome UA) | 400 "This URL only works inside an RSS client" | The instance gates RSS on the User-Agent |
27
+ | `xcancel.com/FabrizioRomano/rss` (UA `mercury-feed-watch/1.0`) | 200 `application/rss+xml`, one placeholder item: "RSS reader not yet whitelisted! … send an email to rss [AT] xcancel [DOT] com with the reason … and this ID: …" | Whitelisted by the *instance operator*, not by X: the instance is under X Corp's cease-and-desist of 2026-08-24 and its terms position is the one in row X6 below. Recorded as what exists, not as a path to build |
28
+ | `nitter.net/…/rss` | 410 Gone | Dead |
29
+ | `nitter.privacydev.net` | connection refused | Dead |
30
+ | `syndication.twitter.com/srv/timeline-profile/screen-name/…` | 429 "Rate limit exceeded" on the first request | Not usable from this IP without more work; not pursued |
31
+ | `cdn.syndication.twimg.com/timeline/profile?screen_name=…` | 200, empty body | Deprecated |
32
+ | `threads.net/@fabrizioromano` → `threads.com` | 200, 268 KB, no post text, no `taken_at`, no post codes in the HTML | Client-rendered behind a login prompt; not readable by fetch |
33
+ | `instagram.com/fabrizioromano/` | 200, 618 KB, no captions, no `shortcode`, no timeline data | Same |
34
+ | `i.instagram.com/api/v1/users/web_profile_info/?username=…` with `x-ig-app-id` | 401 `require_login: true` | The 2023-era unauthenticated endpoint is closed |
35
+ | `facebook.com/groups/telavivapartments/` (public group) | 400, 1.5 KB "Error" page | Meta answers non-browser clients with an error, not a login page |
36
+ | `m.facebook.com/groups/…` | 400 via redirect to `www` | The mobile site no longer exists as a separate surface |
37
+ | `mbasic.facebook.com/groups/…` | 200 → `mbasic.facebook.com/login.php?next=…` (Hebrew login page, 6.5 KB) | **mbasic still serves** a login page in 2026; whether a logged-in session gets a group feed there is unverified (probe 9 in the open list) |
38
+ | `t.me/s/nester_rent_telaviv` | 200, 4 message blocks, last `<time datetime>` 2026-09-06 16:01 UTC | Live; the M1 fixture candidate posted three hours before the probe |
39
+ | `homeless.co.il/rent/`, `rss.app`, `fetchrss.com` | 200 | Reachable |
40
+
41
+ Host tooling on the box: no Chromium, no node on the WSL host; `docker`
42
+ runs without sudo; the derived image `mercury-agent-ext` carries
43
+ Playwright + Chromium (`container/Dockerfile` line 51) and pinchtab. A
44
+ headless page load can therefore run in a throwaway container from the
45
+ home IP without installing anything on the host.
46
+
47
+ Not probed: Yad2 and Madlan (the 2026-09-06 survey in `goal.md` already
48
+ holds their answers for plain fetches, and a real-browser load was not
49
+ run — see the Yad2 section).
50
+
51
+
52
+
53
+ ## Facebook groups
54
+
55
+ The owner's premise holds: the notification-email bridge (rung 1c) is
56
+ *compliant* but not *complete*. Notifications are algorithmically
57
+ selected and bundled into digests, so a listing posted at 14:03 in a
58
+ 53 K-member group may never produce a mail. For the apartment adopter,
59
+ where a good listing lasts ten minutes to an hour, that is the wrong
60
+ tool. What is left is a choice between three complete-feed paths, none of
61
+ them sanctioned by Meta.
62
+
63
+ **Terms, stated once.** Meta's Terms §3.2.3 (effective 2025-01-01) forbid
64
+ automated access or collection "even if that automated access or
65
+ collection happens while you're logged into your Facebook account". The
66
+ 2024 Bright Data ruling (Meta lost on summary judgment, waived appeal)
67
+ covers logged-out public scraping only and gives a logged-in reader
68
+ nothing. No case was found 2024–2026 of Meta pursuing an individual for
69
+ low-volume read-only automation; every action targets commercial
70
+ scraping-for-hire. The realistic exposure is the **account**: temporary
71
+ blocks and ID checkpoints, decided by behaviour and browser fingerprint,
72
+ not by who the account is. A checkpoint on the owner's personal account
73
+ locks them out of the same groups they are trying to read — which is why
74
+ every path below is stated with *which account* it burns.
75
+
76
+ ### Paths, ranked for "private groups I am a member of"
77
+
78
+ | # | Path | How | Complete? | Latency | Cost | Account at risk | Maintenance |
79
+ |---|------|-----|-----------|---------|------|-----------------|-------------|
80
+ | F1 | **Own session in Playwright, reading Facebook's feed JSON** | A persistent Chromium profile logged in as the reader account opens `facebook.com/groups/<id>/?sorting_setting=CHRONOLOGICAL` (desktop only; the parameter still works, the UI button is hidden); a response listener captures the JSON of Facebook's own `GroupsCometFeedRegularStoriesPaginationQuery` instead of parsing the DOM. Precedents: `viseshrp/fbn` (persistent profile, floor 15 min, default 1–3 h randomised) and the `browser-act` skill `facebook-groups-scrape-posts` (updated Aug 2026: 2–5 s between groups, never parallel). The `doc_id` changes on every Meta deploy, which is why the page's own request is *observed*, never constructed | yes, the full chronological feed | poll interval (15–30 min floor) | none | the reader account | medium: Meta frontend churn; the intercept absorbs most of it |
81
+ | F2 | **Groups Watcher Chrome extension → local webhook** | Free extension (v5.13, 2026-06-17, ~1,000 users) in the owner's *everyday* Chrome on the *existing* session; polls every 3–7 min; with keywords disabled it POSTs every new post in batches to a URL: `{group_id, poster_name, post_url, post_id, body, images, timestamp}`. Needs the extension page open in a running Chrome — the same desktop Mercury already runs on | yes | 3–7 min | none | the owner's personal account, in a real browser fingerprint (lower risk than F1) | low, but the feed dies whenever Chrome does, and a 1,000-user extension holds the session and an outbound hook |
82
+ | F3 | **Third-party Apify actor with exported cookies** (`whoareyouanas/facebook-group-scraper`) | Takes `c_user`+`xs`, `onlyPostsNewerThan`, chronological sort; ~20–40 posts/min; `maxConcurrency ≤ 2` | yes | per run | ~$3–10 per 1,000 posts (the page contradicts itself) | **higher**: the session replays from a foreign datacenter ASN, a classic checkpoint trigger | none |
83
+ | F4 | **Israeli aggregators that already ingest the Hebrew groups** (apartments only) | Scoutr (`scoutrbot.com/city/tel-aviv`, no sign-up, plain HTML — 12,217 rentals, 2,596 attributed to Facebook, +191 in 24 h at research time); Dorin (₪19.90/week) and Agent Jeremy (₪29.99/week) Telegram bots claiming hundreds of groups | their coverage, unverifiable; Scoutr's sampled "Facebook" links pointed at Marketplace items, not group posts — check before relying on it | minutes to hours | free to ₪30/week | none | none; it is their scraper |
84
+ | F5 | **Notification mirroring** | A spare Android phone or emulator with the Facebook app and group notifications on, plus a notification-listener app that POSTs to a local webhook (`BigShoots/NotificationWebhookApp`, `ItsAzni/NotificationForwarder`) | **no** — same algorithmic filter as email, just faster | seconds | ~none | none; nothing automates Facebook | low |
85
+ | 1c | Email "All posts" → IMAP (already on the ladder) | — | **no** | minutes to hours | none | none | low |
86
+
87
+ Not viable for private groups: rss.app, FetchRSS, Feedspot (public only by
88
+ construction); Zapier, Make, Zoho, Buffer, Hootsuite (Groups apps removed
89
+ 2024-04-22 with the API); Apify's *official* `apify/facebook-groups-scraper`
90
+ (refuses cookies and private groups by policy); Bright Data, ZenRows,
91
+ ScrapingBee, Data365, Scrapfly (logged-out content only); Meta Content
92
+ Library (academic/NGO enclave, $371/month + setup); "Download your
93
+ information" (the owner's own content, no API).
94
+
95
+ ### Public groups and pages (football)
96
+
97
+ `apify/facebook-groups-scraper` at $2.60–5.00 per 1,000 posts with
98
+ `CHRONOLOGICAL` and `onlyPostsNewerThan`; Bright Data's groups scraper
99
+ with 5,000 records/month free; rss.app / FetchRSS produce a feed URL for
100
+ some public pages and groups ($0–15/month; FetchRSS deletes a free feed
101
+ after 7 unread days). Logged-out browser scraping hits a login wall after
102
+ roughly 1,000 posts per session, so for public content the managed actors
103
+ beat engineering.
104
+
105
+ ### Corrections to what the goal recorded from the Gemini conversation
106
+
107
+ - `rebrowser-playwright` is abandoned (last commit 2024-09). The 2026
108
+ maintained options are **Patchright** (2026-08-05) and **Camoufox**
109
+ (2026-08-12), which patch at the CDP layer; `nodriver` benchmarked best.
110
+ - The *official* Apify actor does not take cookies; private-group support
111
+ is third-party only. The free credit is $5/month and does not roll over.
112
+ - "All posts notifications are filtered" is asserted mainly by Groups
113
+ Watcher, which sells the fix. Probable, unconfirmed by any Meta source.
114
+ - `mbasic.facebook.com` still serves (a login page, probe above); whether a
115
+ logged-in session gets a light group feed there is unverified.
116
+ - No Tel Aviv apartment group mirrors itself to Telegram or WhatsApp;
117
+ `nester_rent_telaviv` carries Nester's own landlord submissions.
118
+ Secret Tel Aviv has no classifieds board, RSS or API.
119
+
120
+ ### What this means for the plan
121
+
122
+ - The email bridge stays on the ladder as the compliant floor, demoted from
123
+ "the Facebook path" to "a Facebook supplement".
124
+ - The rung-3 Facebook goal has a concrete primary design, **F1**, whose
125
+ tooling the *agent image* already ships (Playwright + Chromium + pinchtab
126
+ in `mercury-agent-ext`) — with one cost the goal must carry: feed-watch
127
+ polls **host-side**, the agent container is **per-run**, and F1 needs a
128
+ **persistent** logged-in Chromium profile (its precedent `fbn` is built
129
+ on one). So F1 is either a long-lived container with a mounted profile or
130
+ Chromium on the host, which today has neither Chromium nor node. **F2** is
131
+ the zero-code experiment the owner can run *today* to learn the real post
132
+ rate and whether their account gets checkpointed, before any Mercury code
133
+ exists.
134
+ - The policy question the goal already flagged is now sharper: F1 and F2
135
+ both put a Facebook account at risk, and a burner account has to be
136
+ *admitted* to each private group by its admins. That is a decision for
137
+ the owner, not the plan.
138
+
139
+ ## X (Twitter)
140
+
141
+ The football adopter's want list is a fixed set of ~10–20 accounts:
142
+ clubs, a few journalists (Romano, Ornstein, Pedullà), Israeli outlets.
143
+ The finding that shapes everything: **clubs are easy, journalists are the
144
+ hard part**. Every free path covers the club accounts and fails for exactly
145
+ the journalists and Israeli outlets.
146
+
147
+ | # | Path | How | Complete? | Latency | Cost / month (20 accounts, hourly) | Risk | Terms |
148
+ |---|------|-----|-----------|---------|------------------------------------|------|-------|
149
+ | X1 | **twitterapi.io** (or SocialData search monitors) | One HTTP call per account per poll; pay-as-you-go, $0.15 per 1,000 tweets, no minimum (pricing page 2026-09-06) | full timelines | minutes | ~$2–3 if only returned tweets bill, ~$20 worst case; SocialData monitors ~$5–6 | vendor-side: the vendor gets sued, not the owner — a live risk after X's 2026-08 cease-and-desist to Nitter | the vendor violates, the owner does not |
150
+ | X2 | **Official X API, pay-per-use** | Free tier closed to new sign-ups 2026-02-06; Basic auto-migrated 2026-06-01. `GET /2/users/:id/tweets` with `since_id`; Posts:Read $0.005 per resource, **deduplicated within a 24 h UTC window** (so hourly polling does not multiply cost), 3 M reads/month cap | full, official | minutes | **$50–90** | none | clean — the only fully sanctioned path |
151
+ | X3 | **`syndication.twitter.com/srv/timeline-profile/screen-name/<u>`** (embedded timeline) | Unauthenticated HTML with `__NEXT_DATA__` → `timeline.entries`. **Two modes, deterministic per handle**: ~20 entries = a live reverse-chronological widget (ManUtd, Arsenal, realmadrid, LFC, FCBarcelona all fresh on 2026-09-06); ~100 entries = a **frozen engagement-ranked cache** (FabrizioRomano and Alfredopedulla 2026-09-01, ChampionsLeague 09-05, **David_Ornstein 2025-10-23, sport5il 2025-10-30, MaccabiTLVFC 2025-11-06**); ONE_Sport 0 entries. Intermittent 429 from the same IP (this session's own probe) | clubs only | minutes for clubs, **months** for the stale mode | none | low | grey |
152
+ | X4 | Guest-token GraphQL (`guest/activate.json` → `UserTweets`) | Still issues a token and returns 200 — with the **identical 101 top-ranked tweets** as X3's stale mode. Not a separate data plane | same as X3 | same | none | medium | grey |
153
+ | X5 | **Bluesky** (RSS per handle, open API) | Probe table above. Live: `david-ornstein.bsky.social` (2026-09-01), `arsenalfc.bsky.social` (09-03), The Athletic. Dead or squatted: Romano (abandoned 2024-09-11, bio says "Follow me on X"), `liverpoolfc.bsky.social`, `fcbarcelona.bsky.social`, Sky Sports PL parked. One live hobbyist mirror of Romano: `fabrizioromano.yopro20.com` (22,521 posts, newest 2026-09-06 17:27 UTC, working RSS); the sibling mirror farms died 2026-04 and 2025-02 | partial | minutes | none | none | clean |
154
+ | X6 | **xcancel** (Nitter instance) RSS | Answered this session's probe today with a UA-whitelist request (probe table). X Corp sent Nitter and xcancel a cease-and-desist on **2026-08-24**; both went offline 08-25 and the Nitter repository was archived. Whatever is answering now is on borrowed time; self-hosting Nitter needs a donor account's session tokens and is named in the same letter | full while it lasts | minutes | none | the instance vanishes; a donor account for self-hosting is lost | violates |
155
+ | — | RSSHub `/twitter/user`, RSS-Bridge TwitterBridge | Need a real account's `auth_token`+`ct0`; RSSHub issue #19420 (2025-06) reports empty results with no fix; RSS-Bridge guest-token breakage unresolved since 2023 | — | — | — | account loss | violates |
156
+ | — | Own-session Playwright on x.com | Login wall, hardened fingerprint binding in 2025–26; no report of low-frequency read-only automation surviving long-term | full | — | none | **the owner's account** | violates |
157
+
158
+ Dead ends: Google News `site:x.com`, X e-mail digests via IMAP (no
159
+ evidence either yields timelines). RapidAPI Twitter tiers: pricing pages
160
+ returned nothing, unverified.
161
+
162
+ **A trap for feed-watch, independent of the path chosen.** X3 returns HTTP
163
+ 200 with plausible tweets that are months old. A source adapter that
164
+ trusts status codes would post David Ornstein's 2025-10 tweets as new and
165
+ never fail loudly. This is the same failure shape as the dead-but-healthy
166
+ Telegram channel in `goal.md`: **source liveness must assert the newest
167
+ item's timestamp, not the fetch's success.** That moves the "source
168
+ liveness" candidate from optional to required before any source kind whose
169
+ page can serve a stale body with HTTP 200 — Telegram included; until it
170
+ ships, M1.5's per-channel precondition (newest post inside 7 days) stands
171
+ in by hand.
172
+
173
+ **What this means for the plan.** X gets a rung-2 API adapter after all,
174
+ but for a *paid* vendor (X1 at ~$2–3/month typical, ~$20 worst case, or X2
175
+ at ~$50–90/month if the owner wants a clean-terms path), keyed by a host-side secret, not a
176
+ scraper. Free supplements: Bluesky RSS today (rung 0, zero code) for the
177
+ handles that are live, and X3 for the club handles that demonstrably
178
+ return the 20-entry live widget — verified per handle, with the freshness
179
+ assertion. Nothing on X uses the owner's account.
180
+
181
+ ## Instagram
182
+
183
+ | # | Path | How | Complete? | Cost / month | Risk | Terms |
184
+ |---|------|-----|-----------|--------------|------|-------|
185
+ | I1 | **Business Discovery** (official Graph API) | `?fields=business_discovery.username(<target>){media{caption,timestamp,permalink}}`. Needs the *"Instagram API with Facebook Login"* product (the newer "with Instagram Login" product lacks it), the owner's own Business/Creator account linked to a Facebook Page, `instagram_business_basic` (old scopes deprecated 2025-01-27) and Standard App Review. **The target must also be a Professional account** — clubs and outlets mostly are, individual journalists often are not. 200 calls/hour, ample. Hashtag search: 30 unique hashtags per 7-day window, last 24 h only | Professional targets only | none | none | clean |
186
+ | I2 | **Apify Instagram Scraper** | `apify/instagram-scraper` $1.50 per 1,000 posts (2026-05); ~3,000 posts/month for 20 accounts ≈ $4.50, but the free plan is unusable so the $19 Starter floor applies | full | ~$19–24 | vendor-side | vendor violates |
187
+ | I3 | `i.instagram.com/api/v1/users/web_profile_info/` with `x-ig-app-id` | Reported working from residential IPs by a 2026-08-28 guide; **this session's probe from the box got 401 `require_login`**. `?__a=1&__d=dis` is 404. The `graphql/query` `doc_id` path works but Meta rotates `doc_id` every 2–4 weeks | while it lasts | none | 429 per IP | grey |
188
+ | I4 | **Android push → webhook** | Bell notifications are push-only (no e-mail digest exists for followed accounts). A notification-listener app (MacroDroid webhook trigger, `NotificationForwarder`, `NotificationWebhookApp`) POSTs each one to Mercury | misses whenever the phone sleeps or the listener is killed | none | none | clean |
189
+ | — | Picuki, gramhir, RSSHub `picuki` route | Picuki dropped Instagram in 2025 (TikTok pivot), which killed the most-cited RSSHub route; gramhir dead; Imginn/Dumpor flaky; Pixwox/Picnob/Iganony attested only by affiliate blogs | — | — | — | — |
190
+ | — | RSSHub `private-api`, instaloader with a session file, RSS-Bridge InstagramBridge | Stored credentials, no 2FA; documented temporary locks and forced password resets from frequent polling | full | none | **account** | violates |
191
+ | — | rss.app Instagram feeds | From $8.32/month annual; the free tier refreshes every 24 h | partial | ~$8+ | none | grey |
192
+
193
+ **What this means for the plan.** Instagram is the lowest-value platform
194
+ for both adopters (club posts duplicate X; apartments are not on
195
+ Instagram in volume). If it is ever built: I1 for the club/outlet accounts
196
+ that are Professional, I2 for the rest, and nothing that logs in as the
197
+ owner. It stays in the rung-3 goal.
198
+
199
+ ## Yad2, Madlan and the Tel Aviv rental market
200
+
201
+ The premise to unwind first: Yad2 does not need to be read *from Yad2*.
202
+ Two sanctioned channels carry its listings, one of them as plain RSS.
203
+
204
+ ### Realta — an aggregator with a listing-level RSS feed (verified from the box)
205
+
206
+ `https://realta.co.il/feed.xml`: 200, `application/rss+xml`, **50 items,
207
+ newest `pubDate` 2026-09-06 17:29 GMT, oldest of the 50 at 15:27** — the
208
+ window covers about two hours of national volume at Saturday-evening
209
+ rates. Each item is one listing: title `2 rooms in Jerusalem, Nahlaot —
210
+ 5,500 ₪/mo`, description with type / m² / street, permalink `guid`
211
+ (`realta.co.il/en/<city>/<neighbourhood>/<id>/`). No per-city feed (the
212
+ Tel Aviv path 404s); 7 of the 50 were Tel Aviv, so the term filter does
213
+ the city. `robots.txt` allows everything but `/api/`, build assets and
214
+ `*.json`, and advertises the sitemap — polling the feed is what the site
215
+ invites. The Tel Aviv listing page carries a source icon per row; in one
216
+ page load: **Madlan 7, Yad2 6, Facebook 6, Komo 4, Homeless 1**. The
217
+ listing page links the original via `go.realta.co.il/go/<id>?url=<original>`,
218
+ so the Yad2 or Facebook URL is recoverable from the query string without
219
+ following the redirect. Realta also runs a Telegram channel,
220
+ `t.me/s/realta_rent_il`, live (20 blocks, last 17:30 UTC).
221
+
222
+ What that buys: **Yad2, Madlan and Facebook-group listings arrive as an
223
+ `rss` source today, with zero code**, at rung 0. What it costs: a single
224
+ point of failure run by a small operator whose legal position is the
225
+ interesting one — Yad2 sent Keyz a demand to stop "collecting and
226
+ republishing" its listings in June 2026 (TheMarker 2026-06-07). If Realta
227
+ is next, the feed goes dark and the second leg below is the fallback.
228
+ Unverified: whether Realta's "Facebook" rows are group posts or
229
+ Marketplace items, and how much of the market it misses.
230
+
231
+ ### Yad2's own alerts: a fast alert within 15 minutes
232
+
233
+ Yad2's app listing (v16.0.0, updated 2026-09-06) states that saved-search
234
+ alerts go to e-mail either daily or as a **fast alert within 15 minutes**
235
+ ("התראה מהירה (עד 15 דק')"). That is inside the goal's "minutes" target
236
+ with **zero blocking risk and zero terms exposure** — a logged-in user
237
+ receiving mail they asked for. Mercury already has IMAP (`yahoo-mail`) and
238
+ Gmail (`gws`). Unverified and cheap to settle: whether the mail body
239
+ carries per-listing id, price, rooms, address and link, or only "N new
240
+ results" with a link. One saved search and one raw MIME read answer it.
241
+ This is the same rung-1c adapter the Facebook notifications need, with a
242
+ second mail parser. No official Yad2 Telegram bot, WhatsApp/SMS alert, RSS
243
+ or partner API exists.
244
+
245
+ ### Paths compared
246
+
247
+ | # | Path | Coverage | Latency | Cost | Blocking risk | Terms / legal | Maintenance |
248
+ |---|------|----------|---------|------|---------------|---------------|-------------|
249
+ | Y1 | **Realta `feed.xml`** as an `rss` source | Yad2 + Madlan + Homeless + Komo + OnMap + Facebook; ~3,989 Tel Aviv listings on site | minutes | none | low; the feed is advertised | Realta's exposure, not the owner's | none; one dependency |
250
+ | Y2 | **Yad2 e-mail alerts → IMAP** (rung 1c) | Yad2, exactly the saved search | ≤15 min stated | none | none | **clean** | low; mail format may change silently |
251
+ | Y3 | Commercial Yad2 API (`swerve/yad2-scraper` $5 per 1,000, 96.4 % run success; `solidcode/yad2-scraper` $4 per 1,000; ScrapingBee $19–599/month) | Yad2 complete | per run | $4–5 per 1,000 results | the vendor's; they route through Israeli residential IPs with a real browser fingerprint and a fresh session per retry | the vendor breaches Yad2 §7; the owner is the buyer | none |
252
+ | Y4 | Public Telegram channels | `nester_rent_telaviv` (live, Nester's own no-broker stock, ~4–5 posts/h, appears nowhere else); `realta_rent_il` (live, duplicates Y1). **`ILRentsTLV` is dead: 8 blocks, last 2023-04-12** — this session's probe confirms the survey and corrects a research claim that it was live | narrow, distinct | minutes | none | low | grey | channels die |
253
+ | Y5 | homeless.co.il / komo.co.il direct (rung 1b) | thin slice, both inside Y1 | poll rate | none | 200 from the box, 403 from datacenters | unchecked | medium |
254
+ | — | Own browser against Radware | Yad2 complete | — | proxies | high, adversarial, permanent | **breaches §7 in its own words**; Yad2 has enforced twice (a 2009 demand, litigated in the Petah Tikva Magistrates' Court in 2011; the Keyz demand, June 2026) — always against commercial republishers, never an individual | high, forever |
255
+ | — | Google Alerts RSS | ~none: Yad2 listing pages have no crawl path; alerts fire after indexing | days to never | — | — | fine | — |
256
+ | — | Madlan direct | Cloudflare 403 on HTML; sitemaps serve but are day-granular and over 10 MB; no reachable terms page (404). Madlan is Roztel Knowledge Systems, not Yad2 | — | — | — | — | — |
257
+ | — | Craigslist Tel Aviv | 30 listings total; RSS 403 to datacenter fetchers | — | — | — | — | — |
258
+
259
+ Corrections to the survey in `goal.md`: Yad2's parent is Apax (2026), not
260
+ Coral/Axel Springer; "Yad2 v. Homeless" and "Yad2 v. Madlan" do not
261
+ exist; no Israeli judgment applies the Computers Law §4 to reading an
262
+ unauthenticated public site, and none holds that automated access in
263
+ breach of terms is by itself unlawful — exposure runs through unjust
264
+ enrichment and copyright in arrangement, and the Privacy Law's Amendment
265
+ 13 (in force 2025-08-14) carves out collections for personal,
266
+ non-business use. Two commercial vendors report the Radware challenge is
267
+ passable at 96–100 % with residential IPs and real browser fingerprints;
268
+ the "heavily blocked" quote in circulation is from a 2019 freelancer post.
269
+
270
+ ### What this means for the plan
271
+
272
+ - **Yad2 and Madlan leave the "out of scope" row.** Direct reads stay
273
+ out; their *listings* arrive through Y1 at rung 0 today and through Y2
274
+ at rung 1c. The Radware bypass is not built: it is the one path that is
275
+ high-maintenance, in breach of §7 verbatim, and unnecessary.
276
+ - The Google Alerts spike is dropped from the roadmap.
277
+ - Two empirical checks precede any apartment profile: how many minutes
278
+ the 50-item Realta window covers on a weekday morning (poll at 5 min or
279
+ less until measured), and what a Yad2 fast-alert mail body looks like.
280
+
281
+ ## Summary: the ladder after this research
282
+
283
+ | Platform | Build | Do not build | Owner decision needed |
284
+ |----------|-------|--------------|------------------------|
285
+ | **Facebook groups** | rung 3 goal with **F1** (own session in Playwright reading Facebook's feed JSON; the image already has the tooling) as primary; **F2** (Groups Watcher → webhook) as the zero-code experiment to run first; 1c e-mail as the compliant floor; Realta's Facebook rows for apartments | anything replaying the session from a foreign datacenter (F3 as a poller) | which account F1/F2 burn — the owner's, or a second account admitted to each group |
286
+ | **X** | rung 2 adapter against a **paid vendor** (twitterapi.io ~$2–3/month typical and ~$20 worst case, or the official pay-per-use API ~$50–90/month for a clean-terms path); Bluesky RSS at rung 0 for live handles; syndication for club handles that pass the freshness check | Nitter self-hosting, RSSHub with cookies, own-session Playwright | vendor vs official API; the monthly budget |
287
+ | **Instagram** | I1 Business Discovery for Professional targets, I2 Apify for the rest, if ever | anything logged in as the owner | whether Instagram is worth building at all |
288
+ | **Yad2 / Madlan** | Y1 Realta `rss` at rung 0; Y2 Yad2 fast alerts at rung 1c | a Radware bypass | none: both legs are sanctioned |
289
+ | **All of them** | **source liveness that asserts the newest item's timestamp** — X's stale-but-200 cache and Telegram's dead-but-served channel are the same bug; required before any kind that can serve a stale 200, Telegram included, with M1.5's 7-day precondition as the manual stand-in | — | whether it joins Milestone 1 |
@@ -0,0 +1,112 @@
1
+ # Decisions: feed-watch source adapters — social and messaging platforms as a ladder
2
+
3
+ **Goal**: [feed-watch-social-sources](goal.md)
4
+
5
+ > Inherits every decision in
6
+ > [`whatsapp-bot-hardening/decisions.md`](../whatsapp-bot-hardening/decisions.md)
7
+ > (in particular **D-014**: one instruction, one layer; countable rules) and
8
+ > in [`football-reporter-profile/decisions.md`](../football-reporter-profile/decisions.md)
9
+ > (in particular **D-001**: a space profile is a repo folder applied by
10
+ > `scripts/space-profile.ts`, so `feed-watch.sources` deploys through the
11
+ > manifest and a hand edit on the box is drift). Bare `D-0NN` ids below mean
12
+ > *this* goal's sequence.
13
+ >
14
+ > The goal was decomposed autonomously by `/f-feature-roadmap` on 2026-09-06
15
+ > and approved on 2026-09-07; D-001 to D-004 were recorded when Milestone 1's
16
+ > architecture was filled the same day. No conflict with either inherited
17
+ > goal's tech choices — this goal adds no storage, framework or dependency.
18
+
19
+ ## D-001: The parser seam is a closed `SourceKind` union in `feeds.ts`, dispatched by one exhaustive switch in `index.ts`
20
+ - **Category:** architecture
21
+ - **Decided:** `feeds.ts` exports `type SourceKind = "xml" | …` and stays a
22
+ leaf module; `FetchTarget.kind: SourceKind` is **required**; `fetchFeed`
23
+ gains optional `parse` (a body parser, default `parseFeed`) and `redirect`
24
+ (a `RequestRedirect`, default follow) options; `index.ts` implements
25
+ `PollDeps.fetchFeed` as `switch (target.kind)` with a `never` default, and
26
+ every case returns the **whole fetch** (`Promise<FeedItem[]>`).
27
+ - **Alternatives considered:** (a) an exported `parserFor` table keyed by
28
+ kind — a runtime registry the compiler cannot check, and a fourth mechanism
29
+ for a two-entry table; (b) `SourceKind` on `FetchTarget` in `watch.ts` —
30
+ an import cycle once `feeds.ts` needs it; (c) `kind?: SourceKind` with an
31
+ `?? "xml"` default — an optional discriminator whose absent case allows;
32
+ (d) a `parse` option only, no `kind` — closes the door on a non-HTTP kind
33
+ (the rung-1c mailbox bridge has no URL to fetch).
34
+ - **Reasoning:** the switch is the single place a parser is chosen, guaranteed
35
+ at compile time; a later kind is one union member and one case; the RSS
36
+ path is byte-identical because the `xml` case is today's call verbatim.
37
+ - **Revisit if:** a third HTTP kind wants the same `parse`/`redirect` pair
38
+ with a different transport (per-target user-agent, auth header) — then the
39
+ options bag grows, not the switch.
40
+ - **Used by:** feed-watch-parser-seam, feed-watch-telegram-parser,
41
+ feed-watch-telegram-source-config
42
+
43
+ ## D-002: Fixtures count toward the 500-line story budget
44
+ - **Category:** pattern
45
+ - **Decided:** a fixture is real markup cut to its head plus the 4–6 blocks
46
+ the tests need, with a leading comment naming the source, the capture date
47
+ and the cut; its line count is part of the story's diff budget.
48
+ - **Alternatives considered:** a verbatim page (~958 lines for `t.me/s/…`,
49
+ twice the budget on its own); a hand-written minimal fixture (agrees with
50
+ the parser by construction and proves nothing about the real page).
51
+ - **Reasoning:** the existing feed-watch fixtures are real bodies for the same
52
+ reason; a cut keeps the parser honest against real markup while keeping the
53
+ story reviewable in one session.
54
+ - **Revisit if:** a source's page cannot be cut without losing a shape the
55
+ parser must handle — then split the story rather than grow the fixture.
56
+ - **Used by:** feed-watch-telegram-parser, feed-watch-telegram-source-config
57
+
58
+ ## D-003: `MAX_SOURCES` stays at 20 for Milestone 1
59
+ - **Category:** scope
60
+ - **Decided:** the football manifest holds 14 entries; M1.1 may add at most 3
61
+ and M1.5 at most 3. The cap constant, its validator and its description do
62
+ not move in this milestone.
63
+ - **Alternatives considered:** raising the constant now (a cap the owner has
64
+ not asked to raise, with the `gnews` fan-out warning behind it); a per-space
65
+ override (new config surface for a need nobody has yet).
66
+ - **Reasoning:** the budget fits; the owner ranks the rung-0 candidates into
67
+ 3 slots rather than the code absorbing an unranked list.
68
+ - **Revisit if:** the apartment profile or a second football channel list
69
+ needs more than 6 new entries — then a `config.ts` constant change with a
70
+ test, decided as its own story.
71
+ - **Used by:** feed-watch-rung0-manifest-sources,
72
+ feed-watch-telegram-football-deploy
73
+
74
+ ## D-004: A validator change deploys restart-first, apply-second
75
+ - **Category:** pattern
76
+ - **Decided:** when a story changes what `isValidSourceEntry` accepts, the
77
+ deploy order is sync → restart in a cron gap → verify the **installed copy**
78
+ under `~/whatsapp-bot/.mercury/extensions/feed-watch/` → `space-profile
79
+ apply` → `check`. One restart per milestone, carried by the last story.
80
+ - **Alternatives considered:** apply first and restart after (the manifest
81
+ is stored, the running validator rejects it, and `readSpaceConfig` blanks
82
+ the whole `sources` key on every poll until the restart — all 14 sources
83
+ dark); a config-key version guard (more machinery than the rule it
84
+ replaces).
85
+ - **Reasoning:** `space-profile apply` writes the key without the
86
+ extension's validator and `check` compares the exact string, so nothing on
87
+ the write path stops a shape the running code does not know. M1.4 asserts
88
+ the whole-key fallback with a test so the reason stays countable.
89
+ - **Revisit if:** the profile script starts running extension validators, or
90
+ the reader falls back per entry instead of per key.
91
+ - **Used by:** feed-watch-telegram-source-config,
92
+ feed-watch-telegram-football-deploy
93
+
94
+ ## Open — for the owner, raised by the 2026-09-06 blocked-sources research
95
+
96
+ Recorded in [blocked-sources-research.md](blocked-sources-research.md); none
97
+ is needed for Milestone 1 and each becomes a numbered decision when its rung
98
+ is decomposed:
99
+
100
+ - **which Facebook account** a rung-3 reader burns — the owner's own
101
+ (already a member of every group, checkpoint locks them out of the same
102
+ groups) or a second account that each group's admins must admit;
103
+ - **X: paid vendor or official API** — twitterapi.io at ~$2–3/month typical
104
+ (~$20 worst case) with a vendor whose own terms position is exposed, or the
105
+ official API at ~$50–90/month, clean;
106
+ - **accepting Realta as a single dependency** for the apartment adopter's
107
+ Yad2/Madlan/Facebook coverage, with Yad2's own alert mail as the fallback
108
+ leg;
109
+ - **no bot-protection bypass is built for any site whose terms forbid it**
110
+ (Yad2 §7, Madlan) — proposed as a standing rule; the approved plan builds
111
+ nothing that violates it, and it is recorded as D-005 the first time a
112
+ story would otherwise have to.