mercury-agent 0.20.0 → 0.22.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.
- package/container/Dockerfile +1 -0
- package/container/Dockerfile.base +1 -0
- package/docs/behavior-layers.md +4 -2
- package/docs/configuration.md +57 -0
- package/docs/container-lifecycle.md +6 -0
- package/docs/deployment.md +21 -0
- package/docs/extensions.md +33 -0
- package/docs/goals/feed-watch-social-sources/blocked-sources-research.md +289 -0
- package/docs/goals/feed-watch-social-sources/decisions.md +332 -0
- package/docs/goals/feed-watch-social-sources/goal.md +217 -0
- package/docs/goals/feed-watch-social-sources/roadmap.md +212 -0
- package/docs/goals/football-match-day-mode/decisions.md +378 -0
- package/docs/goals/football-match-day-mode/goal.md +109 -0
- package/docs/goals/football-match-day-mode/roadmap.md +267 -0
- package/docs/goals/football-reporter-profile/roadmap.md +4 -4
- package/docs/goals/rehearsal-bench/decisions.md +13 -0
- package/docs/goals/rehearsal-bench/goal.md +2 -2
- package/docs/goals/rehearsal-bench/roadmap.md +9 -7
- package/docs/goals/release-gate/README.md +4 -2
- package/docs/goals/release-gate/goal.md +2 -2
- package/docs/goals/release-gate/roadmap.md +34 -10
- package/docs/live-testing.md +64 -11
- package/docs/memory.md +1 -1
- package/docs/pending-verification.md +619 -40
- package/docs/profile-guide.md +2 -2
- package/docs/subagents.md +64 -9
- package/examples/extensions/archive/queue.ts +93 -5
- package/examples/extensions/feed-watch/config.ts +56 -3
- package/examples/extensions/feed-watch/digest.ts +180 -24
- package/examples/extensions/feed-watch/feeds.ts +246 -13
- package/examples/extensions/feed-watch/index.ts +137 -24
- package/examples/extensions/feed-watch/skill/SKILL.md +1 -1
- package/examples/extensions/feed-watch/watch.ts +443 -10
- package/examples/extensions/longview/hook.ts +192 -42
- package/examples/profiles/football-reporter/AGENTS.md +14 -0
- package/examples/profiles/football-reporter/README.md +60 -1
- package/examples/profiles/football-reporter/config.yaml +458 -15
- package/examples/profiles/football-reporter/standard.json +23 -1
- package/examples/profiles/football-reporter/tasks/roundup.md +45 -0
- package/package.json +8 -6
- package/resources/agents/explore.md +7 -0
- package/resources/agents/worker.md +0 -1
- package/resources/pi-extensions/subagent/agents.ts +123 -99
- package/resources/pi-extensions/subagent/index.ts +1179 -885
- package/resources/pi-extensions/subagent/spawn.ts +251 -0
- package/resources/templates/mercury.example.yaml +5 -0
- package/src/adapters/slack.ts +2 -0
- package/src/adapters/whatsapp-db-lookups.ts +95 -0
- package/src/adapters/whatsapp-egress.ts +135 -0
- package/src/adapters/whatsapp-ingress.ts +62 -13
- package/src/adapters/whatsapp-mentions.ts +307 -0
- package/src/adapters/whatsapp-thread.ts +54 -0
- package/src/adapters/whatsapp.ts +100 -28
- package/src/agent/container-entry.ts +36 -3
- package/src/agent/container-error.ts +13 -1
- package/src/agent/container-runner.ts +454 -137
- package/src/agent/image-contract.ts +1 -0
- package/src/agent/image-refresh.ts +25 -1
- package/src/agent/instance-id.ts +185 -0
- package/src/bridges/whatsapp.ts +110 -30
- package/src/cli/mercury.ts +1358 -246
- package/src/config-file.ts +74 -1
- package/src/config.ts +65 -1
- package/src/core/commands.ts +5 -1
- package/src/core/conversation.ts +8 -1
- package/src/core/handler.ts +222 -13
- package/src/core/operator-alerts.ts +33 -15
- package/src/core/permissions.ts +19 -0
- package/src/core/router.ts +27 -0
- package/src/core/routes/capability.ts +23 -1
- package/src/core/routes/chat.ts +1 -0
- package/src/core/routes/connections.ts +52 -4
- package/src/core/routes/dashboard.ts +9 -3
- package/src/core/runtime.ts +402 -60
- package/src/core/shadow-bridge.ts +554 -0
- package/src/core/shadow-outbox.ts +257 -0
- package/src/core/simulate-ingress.ts +949 -0
- package/src/env-dump/build-deps.ts +128 -0
- package/src/env-dump/build.ts +572 -0
- package/src/env-dump/deps.ts +84 -0
- package/src/env-dump/diff.ts +721 -0
- package/src/env-dump/manifest.ts +430 -0
- package/src/extensions/api.ts +29 -1
- package/src/extensions/bash-timeout.ts +112 -0
- package/src/extensions/image-builder.ts +66 -2
- package/src/extensions/loader.ts +15 -0
- package/src/extensions/types.ts +10 -0
- package/src/logger.ts +13 -0
- package/src/main.ts +153 -34
- package/src/ops/shadow-root.ts +60 -0
- package/src/ops/shadow-service.ts +932 -0
- package/src/ops/shadow-snapshot.ts +40 -1
- package/src/preflight/boot.ts +118 -0
- package/src/preflight/build-deps.ts +112 -0
- package/src/preflight/checks/credential.ts +8 -4
- package/src/preflight/checks/host-deps.ts +46 -21
- package/src/preflight/checks/roundtrip.ts +4 -0
- package/src/preflight/doctor-lines.ts +74 -0
- package/src/preflight/platform-path.ts +33 -0
- package/src/preflight/probe-container.ts +25 -3
- package/src/preflight/report.ts +116 -3
- package/src/preflight/run.ts +84 -6
- package/src/profile/space-profile.ts +16 -1
- package/src/server.ts +26 -0
- package/src/storage/db.ts +36 -0
- package/src/text/reporter-lint.ts +64 -0
|
@@ -0,0 +1,332 @@
|
|
|
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 — **superseded 2026-09-10 by D-006 and D-007**: the
|
|
60
|
+
cap is 150 and the rationing it implied is gone. Kept as written; the
|
|
61
|
+
revisit clause it names is exactly what fired.
|
|
62
|
+
- **Decided:** the football manifest holds 14 entries; M1.1 may add at most 3
|
|
63
|
+
and M1.5 at most 3. The cap constant, its validator and its description do
|
|
64
|
+
not move in this milestone.
|
|
65
|
+
- **Alternatives considered:** raising the constant now (a cap the owner has
|
|
66
|
+
not asked to raise, with the `gnews` fan-out warning behind it); a per-space
|
|
67
|
+
override (new config surface for a need nobody has yet).
|
|
68
|
+
- **Reasoning:** the budget fits; the owner ranks the rung-0 candidates into
|
|
69
|
+
3 slots rather than the code absorbing an unranked list.
|
|
70
|
+
- **Revisit if:** the apartment profile or a second football channel list
|
|
71
|
+
needs more than 6 new entries — then a `config.ts` constant change with a
|
|
72
|
+
test, decided as its own story.
|
|
73
|
+
- **Used by:** feed-watch-rung0-manifest-sources,
|
|
74
|
+
feed-watch-telegram-football-deploy
|
|
75
|
+
|
|
76
|
+
## D-004: A validator change deploys restart-first, apply-second
|
|
77
|
+
- **Category:** pattern
|
|
78
|
+
- **Decided:** when a story changes what `isValidSourceEntry` accepts, the
|
|
79
|
+
deploy order is sync → restart in a cron gap → verify the **installed copy**
|
|
80
|
+
under `~/whatsapp-bot/.mercury/extensions/feed-watch/` → `space-profile
|
|
81
|
+
apply` → `check`. One restart per milestone, carried by the last story.
|
|
82
|
+
- **Alternatives considered:** apply first and restart after (the manifest
|
|
83
|
+
is stored, the running validator rejects it, and `readSpaceConfig` blanks
|
|
84
|
+
the whole `sources` key on every poll until the restart — all 14 sources
|
|
85
|
+
dark); a config-key version guard (more machinery than the rule it
|
|
86
|
+
replaces).
|
|
87
|
+
- **Reasoning:** `space-profile apply` writes the key without the
|
|
88
|
+
extension's validator and `check` compares the exact string, so nothing on
|
|
89
|
+
the write path stops a shape the running code does not know. M1.4 asserts
|
|
90
|
+
the whole-key fallback with a test so the reason stays countable.
|
|
91
|
+
- **Revisit if:** the profile script starts running extension validators, or
|
|
92
|
+
the reader falls back per entry instead of per key.
|
|
93
|
+
- **Used by:** feed-watch-telegram-source-config,
|
|
94
|
+
feed-watch-telegram-football-deploy
|
|
95
|
+
|
|
96
|
+
## D-006: `MAX_SOURCES` moves and the poll fetches concurrently, as one story
|
|
97
|
+
- **Category:** scope (revisits D-003; D-005 is reserved for the no-bypass
|
|
98
|
+
rule named under "Open" below, so this is D-006)
|
|
99
|
+
- **Decided:** 2026-09-10, by the owner: the football space is to watch
|
|
100
|
+
many sources, and `football-match-day-mode` D-010 dropped its fixtures
|
|
101
|
+
provider in favour of watched channels, so D-003's revisit clause fires.
|
|
102
|
+
One story, `feed-watch-source-cap-and-concurrent-poll` (M1.6 on the
|
|
103
|
+
roadmap; idea filed at
|
|
104
|
+
`docs/ideas/feed-watch-source-cap-and-concurrent-poll.md`; architecture
|
|
105
|
+
via `/f-feature-planning`), raises the constant and its description, makes
|
|
106
|
+
`pollSpace` fetch targets through a bounded pool instead of one after
|
|
107
|
+
another, and measures the tick against the poll interval at the new cap.
|
|
108
|
+
The number is decided in that story from the measurement, not here.
|
|
109
|
+
- **Alternatives considered:** rationing the six free slots between the
|
|
110
|
+
two goals (the 2026-09-07 shape); a per-space override of the cap (a
|
|
111
|
+
config surface for a bound that should simply be higher).
|
|
112
|
+
- **Reasoning:** the cap guards the tick, not the bill — sequential fetches
|
|
113
|
+
at `FETCH_TIMEOUT_MS = 5000` inside a 3-minute interval on the host's
|
|
114
|
+
single thread, and `gnews` fan-out per term. `verify_max_items`,
|
|
115
|
+
`digest_max_items`, `BUFFER_CAP` and `max_per_hour` bound everything that
|
|
116
|
+
costs tokens, so more sources cannot make a run dearer. A concurrent fetch
|
|
117
|
+
removes the reason the bound was low.
|
|
118
|
+
- **Revisit if:** the measured tick at the new cap still overruns the
|
|
119
|
+
interval — then the interval, not the cap, is the next knob.
|
|
120
|
+
- **Used by:** feed-watch-source-cap-and-concurrent-poll,
|
|
121
|
+
feed-watch-rung0-manifest-sources (its "stay within 20" clause relaxes
|
|
122
|
+
once M1.6 lands), football-match-day-mode M1.7
|
|
123
|
+
|
|
124
|
+
## D-007: 150 sources, a pool of 8, and one Reddit fetch per tick
|
|
125
|
+
- **Category:** implementation (settles the numbers D-006 left to the story)
|
|
126
|
+
- **Decided:** 2026-09-10, in `feed-watch-source-cap-and-concurrent-poll`.
|
|
127
|
+
`MAX_SOURCES = 150`; `FETCH_POOL_SIZE = 8`, a module constant and not a
|
|
128
|
+
config key; `BUFFER_CAP = 1500`; hosts in `ONE_PER_TICK_HOSTS` (the three
|
|
129
|
+
Reddit spellings) get **one** fetch per tick, round-robin from
|
|
130
|
+
`host_cursor:<spaceId>`, the rest counted as `hostDeferred`.
|
|
131
|
+
- **Alternatives considered:** a pool of 6 (the idea file's first guess — the
|
|
132
|
+
ratio to the interval is what matters, and 8 makes the worst case a third
|
|
133
|
+
of it rather than half); making the pool a config key (a property of the
|
|
134
|
+
host process, not of a space, and no admin has the measurement to set it);
|
|
135
|
+
spacing same-host fetches 60 s apart *inside* the tick instead of rationing
|
|
136
|
+
across ticks (three Reddit feeds would put the tick's floor at 120 s, past
|
|
137
|
+
the slow-tick threshold, so every tick would report itself slow); giving
|
|
138
|
+
Reddit its own `SourceKind` (a rate limit is a property of the host, not of
|
|
139
|
+
the parser).
|
|
140
|
+
- **Reasoning:** the cap always guarded the tick, and the pool is what moves
|
|
141
|
+
the tick's worst case from `due x 5 s` to `ceil(due / 8) x 5 s`. 150 clears
|
|
142
|
+
the round's 85-entry manifest (§7.I of
|
|
143
|
+
`docs/notes/2026-09-10-feed-watch-round.md`) with room for the bench tier.
|
|
144
|
+
1,500 buffered items is a *digest* bound: the daily article is built from
|
|
145
|
+
the buffer, so an overflow silently drops the oldest matched items of a
|
|
146
|
+
match day. Reddit's 60 s per-IP ceiling was measured on 2026-09-10 from the
|
|
147
|
+
box's own network, for both user-agents.
|
|
148
|
+
- **Revisit if:** the `feed-watch: slow poll tick` line appears on a healthy
|
|
149
|
+
box (then the interval or the pool, in that order, not the cap); or a
|
|
150
|
+
second space starts watching (see D-008).
|
|
151
|
+
- **Used by:** feed-watch-source-cap-and-concurrent-poll,
|
|
152
|
+
football-match-day-sources, feed-watch-rung0-manifest-sources
|
|
153
|
+
|
|
154
|
+
## D-008: `inFlight` stays process-wide, and the tick's cost is now readable
|
|
155
|
+
- **Category:** scope
|
|
156
|
+
- **Decided:** 2026-09-10. The `inFlight` guard in `index.ts` keeps its
|
|
157
|
+
present meaning — **one tick at a time across every space** — and does not
|
|
158
|
+
become per space in this story. The reader that would justify changing it
|
|
159
|
+
ships instead: `feed-watch: slow poll tick` at `info` past half the
|
|
160
|
+
interval, and an unconditional `last_tick` store row carrying `lastTickMs`
|
|
161
|
+
and `lastTickAt`.
|
|
162
|
+
- **Alternatives considered:** a per-space `inFlight` map (one slow space
|
|
163
|
+
would stop costing the others their poll — but the fetch phase is now
|
|
164
|
+
concurrent *within* a space, so the sum of ticks is far smaller than it
|
|
165
|
+
was, and a per-space guard would let N spaces put N pools on the host's
|
|
166
|
+
single thread with nothing bounding the total).
|
|
167
|
+
- **Reasoning:** the football space is the only watcher on the box, so the
|
|
168
|
+
guard has never actually fired for the reason a per-space version would
|
|
169
|
+
fix. Splitting it now would trade a bound nobody has hit for an unbounded
|
|
170
|
+
total, and would do it without a measurement. The measurement is what this
|
|
171
|
+
story adds.
|
|
172
|
+
- **Revisit if:** a second space enables `feed-watch.enabled`, or the
|
|
173
|
+
`lastTickMs` row on the box climbs past half the interval.
|
|
174
|
+
- **Used by:** feed-watch-source-cap-and-concurrent-poll
|
|
175
|
+
|
|
176
|
+
## D-009: liveness is `newestItemAt` plus a once-per-streak pair, and nothing else
|
|
177
|
+
- **Category:** implementation
|
|
178
|
+
- **Decided:** 2026-09-10, in `feed-watch-source-liveness` (M1.7).
|
|
179
|
+
`SourceStatus` gains `newestItemAt` — the running **maximum** of every
|
|
180
|
+
item's `publishedAt` a source has served — and `silentAt`. Past
|
|
181
|
+
`STALE_AFTER_MS = 7 days` with the fetch still succeeding, one
|
|
182
|
+
`feed-watch: source silent` at `warn`; a fresh item writes one
|
|
183
|
+
`feed-watch: source active again`, also at `warn`. Both fields are carried
|
|
184
|
+
through the failure branch untouched.
|
|
185
|
+
- **Alternatives considered:** recording only this tick's newest date (a feed
|
|
186
|
+
that rotates its window would read as freshly stale every time it dropped
|
|
187
|
+
its newest item); a boolean `silent` flag (the *time* the silence started is
|
|
188
|
+
the fact an operator wants, and a boolean cannot be read as a duration);
|
|
189
|
+
`info` for the recovery line (the pair it mirrors is both-at-warn, and two
|
|
190
|
+
levels for two ends of one streak makes `grep -c` return a number that reads
|
|
191
|
+
as unresolved incidents).
|
|
192
|
+
- **Reasoning:** `lastOkAt` is about the fetch, so a page serving a 2023 body
|
|
193
|
+
with HTTP 200 writes the same row as a live one — `t.me/s/ILRentsTLV` does
|
|
194
|
+
exactly this, and X's syndication widget did it for months. At 14 sources a
|
|
195
|
+
person notices; at the ~85 the round ships, the only symptom is an article
|
|
196
|
+
missing something nobody knew to expect.
|
|
197
|
+
- **Revisit if:** a low-volume source flaps silent/active often enough to be
|
|
198
|
+
noise — then a per-source threshold, which is config surface nobody needs
|
|
199
|
+
yet.
|
|
200
|
+
- **Used by:** feed-watch-source-liveness
|
|
201
|
+
|
|
202
|
+
## D-010: a stale source is **not** treated as failed, for any kind
|
|
203
|
+
- **Category:** scope (answers the roadmap's "Source liveness" row, which
|
|
204
|
+
asked for stale-as-failed "for kinds known to serve stale caches")
|
|
205
|
+
- **Decided:** 2026-09-10. No backoff, no `nextAttemptAt`, no removal, no
|
|
206
|
+
exception for Telegram or for any other kind. Visibility only.
|
|
207
|
+
- **Alternatives considered:** treating a stale Telegram channel as failed
|
|
208
|
+
(the row's own wording); a per-kind flag on `SourceKind` saying whether its
|
|
209
|
+
transport can serve a stale 200.
|
|
210
|
+
- **Reasoning:** the roadmap row's premise does not survive contact with the
|
|
211
|
+
data. A *quiet* channel and a *dead* one are byte-identical from outside —
|
|
212
|
+
the same 200, the same well-formed body, the same old newest post — so
|
|
213
|
+
"known to serve stale caches" describes the transport, not the source, and
|
|
214
|
+
every Telegram channel would qualify including the ones that simply post
|
|
215
|
+
twice a month. Backing one off costs the story it eventually publishes, and
|
|
216
|
+
at the 96-minute backoff ceiling it would miss the return as well. The
|
|
217
|
+
failure the row is really about is *nobody noticing*, and a log line plus a
|
|
218
|
+
store field closes that without touching the poll.
|
|
219
|
+
- **Consequence:** the manual per-channel precondition in
|
|
220
|
+
`feed-watch-telegram-football-deploy` (M1.5 — "newest `<time datetime>`
|
|
221
|
+
inside the last 7 days", checked by hand before adding a channel) is
|
|
222
|
+
**redundant** the moment this deploys: the same week, checked every tick, by
|
|
223
|
+
the running code. The edit to that document is **not** made here —
|
|
224
|
+
`football-match-day-sources` owns it this round — and is owed to whoever
|
|
225
|
+
lands it.
|
|
226
|
+
- **Revisit if:** a watched kind appears whose transport can serve a stale
|
|
227
|
+
body *and* whose real publication rate is known to be daily, so that
|
|
228
|
+
silence is unambiguous.
|
|
229
|
+
- **Used by:** feed-watch-source-liveness, feed-watch-telegram-football-deploy,
|
|
230
|
+
football-match-day-sources
|
|
231
|
+
|
|
232
|
+
> **Numbering note (2026-09-10).** These three were written as D-007 to D-009
|
|
233
|
+
> by S2 of the 2026-09-10 feed-watch round while S1 claimed D-007 to D-010 in
|
|
234
|
+
> a parallel worktree; the coordinator renumbered them to D-011 to D-013 on
|
|
235
|
+
> landing, together with the citations in `feed-watch-roundup-lane`'s spec.
|
|
236
|
+
|
|
237
|
+
## D-011: The digest is lane-addressed, and the default lane is the unnamed one
|
|
238
|
+
|
|
239
|
+
- **Category:** architecture
|
|
240
|
+
- **Decided:** `[feed-watch:digest]` keeps its exact behaviour and its exact
|
|
241
|
+
cursor key `last_digest_at:<spaceId>`. A second reader carries
|
|
242
|
+
`[feed-watch:digest:<lane>]` (`lane` matching `[a-z0-9-]{1,32}`) and reads
|
|
243
|
+
and advances `last_digest_at:<spaceId>:<lane>`, with its own pending mark
|
|
244
|
+
`digest_until:<spaceId>:<lane>`. The block of a named lane carries
|
|
245
|
+
`lane="…"`; the default block is byte-for-byte unchanged, so the article's
|
|
246
|
+
prompt does not move. A marker whose lane will not parse yields **no**
|
|
247
|
+
digest, no mark and no cursor movement — never a fallback to the default
|
|
248
|
+
lane — and is warned about once per hook.
|
|
249
|
+
- **Alternatives considered:** a second task on the plain marker (the reason
|
|
250
|
+
this story exists: `handleAfterContainer` advances the one cursor for any
|
|
251
|
+
run carrying the marker, so the 09:00 article would see only what arrived
|
|
252
|
+
since the last roundup — silently, reading as a thin news day); a per-task
|
|
253
|
+
cursor keyed on the task id (the id is a live row that does not exist until
|
|
254
|
+
the task is created, so the prompt could not name its own cursor, and a
|
|
255
|
+
re-created task would silently start from the beginning of the buffer); a
|
|
256
|
+
config key listing lanes (a second surface to keep in step with the prompts
|
|
257
|
+
that actually carry the markers).
|
|
258
|
+
- **Reasoning:** the marker is already the contract between a task prompt and
|
|
259
|
+
the extension, and it is the only thing both hooks can see. Extending it is
|
|
260
|
+
a suffix, not a mechanism. Making the unnamed lane the default is what keeps
|
|
261
|
+
the migration empty: nothing on the box has to be renamed.
|
|
262
|
+
- **Revisit if:** a lane ever needs to *read without consuming* (a preview
|
|
263
|
+
lane). Today every lane advances its own cursor, and that is stated as the
|
|
264
|
+
point rather than an accident.
|
|
265
|
+
- **Used by:** feed-watch-roundup-lane
|
|
266
|
+
|
|
267
|
+
## D-012: Each lane owns its window file and its archive
|
|
268
|
+
|
|
269
|
+
- **Category:** architecture
|
|
270
|
+
- **Decided:** the default lane keeps `knowledge/feed-digest-window.md` and
|
|
271
|
+
`knowledge/feed-digest.md`; a named lane writes
|
|
272
|
+
`knowledge/feed-digest-window.<lane>.md` and
|
|
273
|
+
`knowledge/feed-digest.<lane>.md`. The trailer names the lane's own path.
|
|
274
|
+
- **Alternatives considered:** one shared pair of files with the lane written
|
|
275
|
+
inside each section. Rejected on two counts: the window file is *named to
|
|
276
|
+
the model in prose*, so it has to hold the window that model was handed
|
|
277
|
+
rather than whichever lane wrote last; and the archive is trimmed to
|
|
278
|
+
`DIGEST_FILE_WINDOWS = 30` sections, so six writers a day would cut the
|
|
279
|
+
article's readable history from thirty days to five without anything saying
|
|
280
|
+
so.
|
|
281
|
+
- **Reasoning:** the cost is one string; the failure it removes is silent.
|
|
282
|
+
- **Revisit if:** a space runs enough lanes for the workspace listing to
|
|
283
|
+
matter — then a `knowledge/feed-digest/<lane>.md` subdirectory, decided as
|
|
284
|
+
its own change.
|
|
285
|
+
- **Used by:** feed-watch-roundup-lane
|
|
286
|
+
|
|
287
|
+
## D-013: The roundup releases what has not been reported, on five slots a day
|
|
288
|
+
|
|
289
|
+
- **Category:** scope
|
|
290
|
+
- **Decided:** the football space's roundup runs
|
|
291
|
+
`0 10,13,16,19,22 * * *` `Asia/Jerusalem` — five slots, all outside
|
|
292
|
+
`feed-watch.quiet_hours` (`00:30-07:30`) and none at 09:00, which is the
|
|
293
|
+
article's. Its window **overlaps** what the immediate verify runs already
|
|
294
|
+
posted, and that overlap is resolved by the editorial rule that already
|
|
295
|
+
exists (`AGENTS.md § מניעת כפילויות`: an item already in the notebook's
|
|
296
|
+
`## Current State` is not reported again unless a concrete detail changed),
|
|
297
|
+
not by a second cursor. The roundup is not subject to
|
|
298
|
+
`feed-watch.max_per_hour`, which governs verify runs only.
|
|
299
|
+
- **Alternatives considered:** a cursor that skips items a verify run
|
|
300
|
+
consumed (feed-watch does not record which items a run posted — only which
|
|
301
|
+
it was handed — so this would be a new bookkeeping surface for a rule the
|
|
302
|
+
standard already states); hourly slots (five was chosen against a `NO_UPDATE`
|
|
303
|
+
run's own cost, below); making the roundup obey `max_per_hour` (it is a
|
|
304
|
+
`tasks` row, not a batch, and a scheduled release nobody can predict is
|
|
305
|
+
worse than one that says nothing).
|
|
306
|
+
- **Reasoning:** cost is `runs/day × prefix × $2.50/M` and a run that answers
|
|
307
|
+
`NO_UPDATE` still pays its whole prefix — five slots is roughly
|
|
308
|
+
$0.75–1.75/day against the space's $16–20/day, and the slot count is the
|
|
309
|
+
lever if the 50-source manifest pushes the window up.
|
|
310
|
+
- **Revisit if:** the real group finds five too many or too few after the
|
|
311
|
+
link — the cron is a live row, so changing it needs no deploy.
|
|
312
|
+
- **Used by:** feed-watch-roundup-lane
|
|
313
|
+
|
|
314
|
+
## Open — for the owner, raised by the 2026-09-06 blocked-sources research
|
|
315
|
+
|
|
316
|
+
Recorded in [blocked-sources-research.md](blocked-sources-research.md); none
|
|
317
|
+
is needed for Milestone 1 and each becomes a numbered decision when its rung
|
|
318
|
+
is decomposed:
|
|
319
|
+
|
|
320
|
+
- **which Facebook account** a rung-3 reader burns — the owner's own
|
|
321
|
+
(already a member of every group, checkpoint locks them out of the same
|
|
322
|
+
groups) or a second account that each group's admins must admit;
|
|
323
|
+
- **X: paid vendor or official API** — twitterapi.io at ~$2–3/month typical
|
|
324
|
+
(~$20 worst case) with a vendor whose own terms position is exposed, or the
|
|
325
|
+
official API at ~$50–90/month, clean;
|
|
326
|
+
- **accepting Realta as a single dependency** for the apartment adopter's
|
|
327
|
+
Yad2/Madlan/Facebook coverage, with Yad2's own alert mail as the fallback
|
|
328
|
+
leg;
|
|
329
|
+
- **no bot-protection bypass is built for any site whose terms forbid it**
|
|
330
|
+
(Yad2 §7, Madlan) — proposed as a standing rule; the approved plan builds
|
|
331
|
+
nothing that violates it, and it is recorded as D-005 the first time a
|
|
332
|
+
story would otherwise have to.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# feed-watch source adapters — social and messaging platforms as a ladder, not a feature
|
|
2
|
+
|
|
3
|
+
**Status**: Active — approved by the owner on 2026-09-07 after two critic rounds (decomposed by `/f-feature-roadmap` on 2026-09-06)
|
|
4
|
+
**Slug**: feed-watch-social-sources
|
|
5
|
+
**Created**: 2026-09-06
|
|
6
|
+
**Last updated**: 2026-09-07
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Goal Statement
|
|
11
|
+
|
|
12
|
+
> Autonomously decomposed from the idea `docs/ideas/feed-watch-social-sources.md` (deleted on approval, 2026-09-07; its content lives in the survey below). Approved by the owner on 2026-09-07 after two critic rounds.
|
|
13
|
+
|
|
14
|
+
> "Track and search social media and Telegram groups — useful for other
|
|
15
|
+
> profiles too, but a big feature to defer." — the owner, 2026-09-01, on the
|
|
16
|
+
> football space. And on 2026-09-06: "I also plan on using the social-sources
|
|
17
|
+
> ability for finding apartments in Tel Aviv."
|
|
18
|
+
|
|
19
|
+
`feed-watch` knows two source types, `rss` and `gnews`. For the football
|
|
20
|
+
space that misses where the news appears first — Israeli clubs and
|
|
21
|
+
journalists post on Instagram, Facebook and public Telegram channels before
|
|
22
|
+
any outlet writes it up. For an apartment hunter in Tel Aviv the gap is the
|
|
23
|
+
whole market: listings live on Facebook groups, WhatsApp groups, Telegram
|
|
24
|
+
channels and a handful of listing sites, none of which is an RSS feed.
|
|
25
|
+
|
|
26
|
+
The goal is **source adapters, one rung at a time**: each adapter is a new
|
|
27
|
+
`type` in `feed-watch.sources` that yields the same `{title, link,
|
|
28
|
+
publishedAt, source}` items the RSS adapter yields, so the seen-set, the term
|
|
29
|
+
match, the batching, the source status and the verify run are untouched and
|
|
30
|
+
every profile gets every rung for free. Milestone 1 opens the seam and climbs
|
|
31
|
+
the two cheapest rungs — plain-RSS sources that need only config, and public
|
|
32
|
+
Telegram channels through their server-rendered preview page — with the
|
|
33
|
+
football space as the first adopter. The apartment-hunting profile is the
|
|
34
|
+
**second adopter** and is what makes the later rungs (a page watcher with a
|
|
35
|
+
first-seen clock, a notification-email bridge for Facebook groups) worth
|
|
36
|
+
their place on the roadmap; it is not built in this milestone, but nothing
|
|
37
|
+
in this milestone may close a door it needs.
|
|
38
|
+
|
|
39
|
+
Two research inputs shaped the plan and are summarised under **Sources**
|
|
40
|
+
below: the owner's 2026-09-05 Gemini conversation on polling Facebook groups
|
|
41
|
+
(printed to PDF, kept outside the repo), and a 2026-09-06 web survey of the
|
|
42
|
+
Tel Aviv rental sources a host-side poller can actually read.
|
|
43
|
+
|
|
44
|
+
## Constraints
|
|
45
|
+
|
|
46
|
+
- **No new dependencies.** `feeds.ts` is dependency-free by design; every
|
|
47
|
+
adapter parses with regex tag extraction over raw text, no cheerio/jsdom.
|
|
48
|
+
`feeds.ts` stays a leaf module (watch.ts and index.ts import it; it imports
|
|
49
|
+
neither).
|
|
50
|
+
- **The governing rule of `feeds.ts` holds for every adapter:** a malformed
|
|
51
|
+
body yields zero items and never throws; a transport failure (non-2xx,
|
|
52
|
+
including a manual-redirect 3xx, or a timeout) throws so `pollSpace`
|
|
53
|
+
backs the source off; an **oversize body is truncated, not thrown** — it
|
|
54
|
+
is cut to the head and reported once per key through the `bytes` warn,
|
|
55
|
+
because a throw would drop a feed that grew past the cap out of the
|
|
56
|
+
watchlist permanently (the comment in `feeds.ts` says so, and two football
|
|
57
|
+
feeds sit within 2× of the cap). A source `type` the validator does not
|
|
58
|
+
know is rejected, not ignored.
|
|
59
|
+
- **Read-only, public, no account.** Milestone 1 touches nothing that needs a
|
|
60
|
+
login, a cookie, a burner account, a proxy or a scraping API. Those belong
|
|
61
|
+
to the rung-3 goal with its own account, credential and stored-data policy.
|
|
62
|
+
- **Profile-owned keys deploy through the manifest.** `feed-watch.sources`
|
|
63
|
+
lands in `examples/profiles/football-reporter/config.yaml` and deploys with
|
|
64
|
+
`space-profile apply`; a hand edit on the box is drift. `feed-watch.terms`
|
|
65
|
+
and `feed-watch.enabled` are chat-seeded and are not touched.
|
|
66
|
+
- **Deploy order for a validator change is fixed:** sync → restart in a gap of
|
|
67
|
+
the active task crons → verify the installed copy → `space-profile apply` →
|
|
68
|
+
`space-profile check`. Applying a manifest the running validator rejects
|
|
69
|
+
makes `readSpaceConfig` fall back to `[]` for the whole key — all 14
|
|
70
|
+
existing sources go dark until the restart. One restart per milestone.
|
|
71
|
+
- **`MAX_SOURCES = 20` is a hard cap with 14 slots used.** The milestone fits
|
|
72
|
+
in the remaining 6 or explicitly decides to move the cap.
|
|
73
|
+
- **Every rule is countable by a test** (`whatsapp-bot-hardening` D-014).
|
|
74
|
+
Each story < 500 lines of change **including tests and fixtures** — a
|
|
75
|
+
verbatim Telegram preview page is ~958 lines, so the fixture is a real page
|
|
76
|
+
cut to its head plus 4–6 untouched message blocks.
|
|
77
|
+
- **Verification is against the running bot, never the diff.** The only
|
|
78
|
+
operator-visible status surfaces today are the extension store row
|
|
79
|
+
`source_status:<spaceId>` (keyed by `target.key`; fields `failures`,
|
|
80
|
+
`lastOkAt`, `nextAttemptAt`, `lastError`; table `extension_state`) and
|
|
81
|
+
the warn lines `feed-watch: source failed, backing off` /
|
|
82
|
+
`feed-watch: source recovered` / `feed-watch: source hit a cap, older
|
|
83
|
+
items dropped`. The `feed-watch: poll tick` line with its `failed` count
|
|
84
|
+
is **debug** and the live box runs `log_level: info`, so it is not a
|
|
85
|
+
live check. There is **no source-status widget or dashboard view**; no
|
|
86
|
+
story in this milestone adds one.
|
|
87
|
+
- **Extension deploys are a service restart plus the startup whole-tree sync
|
|
88
|
+
of `.mercury/extensions/feed-watch/`**, not an image rebuild. The
|
|
89
|
+
Dockerfile does `COPY examples/extensions/` into a temp path, but the
|
|
90
|
+
`RUN` after it installs only the names in
|
|
91
|
+
`resources/builtin-extensions.txt` (`yahoo-mail`, `gws`, `tradestation`)
|
|
92
|
+
— `feed-watch` is not among them, so nothing of it lives in the image.
|
|
93
|
+
Verified by grepping the installed copy under `~/whatsapp-bot`.
|
|
94
|
+
|
|
95
|
+
## Preferences
|
|
96
|
+
|
|
97
|
+
- Regex-over-raw-text parsing in the house style of `feeds.ts` (required —
|
|
98
|
+
the header comment is explicit about not pulling a parser in).
|
|
99
|
+
- One transport-and-parser dispatch on `FetchTarget.kind` in `index.ts`, an
|
|
100
|
+
exhaustive `switch` with a `never` default (preferred — it is the single
|
|
101
|
+
place a parser is chosen today, and the closed union is the guarantee
|
|
102
|
+
actually available; the plan review rejected a runtime table).
|
|
103
|
+
- The dispatch chooses a **whole fetch**, not only a body parser, so a later
|
|
104
|
+
kind that is not an HTTP GET (the notification-email bridge reads an IMAP
|
|
105
|
+
folder, not a URL) plugs into the same switch (preferred).
|
|
106
|
+
- A per-channel `outlet` label on the Telegram source, so a journalist's own
|
|
107
|
+
channel and a repost channel can be told apart at the attribution rule
|
|
108
|
+
(preferred — the idea's own open question, adopted by the plan).
|
|
109
|
+
- Restart the live bot once per milestone, in a cron gap (required —
|
|
110
|
+
`CLAUDE.local.md`: a restart kills the running container and loses the
|
|
111
|
+
in-memory retry).
|
|
112
|
+
|
|
113
|
+
## Target Users
|
|
114
|
+
|
|
115
|
+
- **The football-space owner and admins** (first adopter, Milestone 1): the
|
|
116
|
+
space is a one-member test bed until the real group is linked, so a noisy
|
|
117
|
+
new source costs ledger rows only while it is tuned. They name the Telegram
|
|
118
|
+
channels and the rung-0 feeds.
|
|
119
|
+
- **An apartment hunter in Tel Aviv** (second adopter, later milestones — the
|
|
120
|
+
owner, 2026-09-06): wants new rental posts from Telegram channels, listing
|
|
121
|
+
sites and (eventually) Facebook groups in the buffer within minutes, with
|
|
122
|
+
price, rooms and neighbourhood extracted from free Hebrew text by the verify
|
|
123
|
+
run. This is a new profile, not feed-watch code, except for the rungs it
|
|
124
|
+
needs: the page watcher (1b) with a first-seen clock, and the
|
|
125
|
+
notification-email bridge (1c).
|
|
126
|
+
- **Any other profile** that watches a topic: every rung is a `sources` entry,
|
|
127
|
+
so the value compounds across profiles (inferred).
|
|
128
|
+
|
|
129
|
+
## Success Criteria
|
|
130
|
+
|
|
131
|
+
1. A public Telegram channel can be watched by configuration alone —
|
|
132
|
+
`{"type":"telegram","channel":"<name>","outlet":"<label>"}` in
|
|
133
|
+
`feed-watch.sources` — and a matched post reaches the buffer and a verify
|
|
134
|
+
run with the channel's own publication time and the `https://t.me/<ch>/<id>`
|
|
135
|
+
permalink, never the poll time. Countable in tests over a real trimmed
|
|
136
|
+
fixture and observed once on the running bot.
|
|
137
|
+
2. A dead or private channel backs off alone: a 302 from `t.me/s/<name>` is a
|
|
138
|
+
source failure for that key only, the other sources stay fresh and the
|
|
139
|
+
service stays up. Countable in a `pollSpace` test and drilled once live.
|
|
140
|
+
3. The RSS/gnews path is byte-identical after the seam opens: every existing
|
|
141
|
+
fixture test passes unchanged, and every rss/gnews target carries
|
|
142
|
+
`kind: "xml"`.
|
|
143
|
+
4. Rung 0 is measured, not assumed: Reddit's unauthenticated RSS and the
|
|
144
|
+
hard-coded `user-agent` are probed from the box and the verdict is written
|
|
145
|
+
in the manifest comment before any entry is committed.
|
|
146
|
+
5. Nothing in Milestone 1 closes a door the apartment adopter needs: the
|
|
147
|
+
dispatch chooses a whole fetch per kind, `FetchTarget.url` is not
|
|
148
|
+
load-bearing outside HTTP kinds, and `seenAt` remains a first-class item
|
|
149
|
+
field (the page watcher's only clock).
|
|
150
|
+
|
|
151
|
+
## Sources
|
|
152
|
+
|
|
153
|
+
### The owner's Gemini conversation, 2026-09-05 ("Automated Facebook Group Polling")
|
|
154
|
+
|
|
155
|
+
Read from the PDF the owner printed on 2026-09-06 (kept outside the repo).
|
|
156
|
+
What it established, and what the plan took from it:
|
|
157
|
+
|
|
158
|
+
| Finding | Effect on this goal |
|
|
159
|
+
|---------|---------------------|
|
|
160
|
+
| Meta removed the Groups API (all versions, 2024-04-22); there is no official read path for group posts, public or private. Group RSS is long gone. | Facebook groups stay **rung 3**, a separate goal. Nothing in Milestone 1 pretends otherwise. |
|
|
161
|
+
| Unofficial reads are browser automation (Playwright with stealth patches such as `rebrowser-playwright`, a dedicated "burner" account, saved `storageState`, home IP, throttled to 15–30 min) or a managed scraper API (Apify Facebook Group Scraper — ~$5/month free credit, "a check every 30 minutes on 3–5 groups"; Scrape.do / ZenRows / ScraperAPI, 1,000–5,000 free requests/month). Free public proxy lists are near 0 % effective against Meta. `kevinzg/facebook-scraper` is unmaintained (last release 2022), cookie-walled and not recommended. | The rung-3 goal has two candidate designs to weigh — **managed API as a host-side capability** (delegates anti-bot, usage-billed, external dependency, clean JSON) versus **own-session Playwright in the container** (the image already ships Playwright + Chromium; the `pinchtab` extension drives it) — and a policy question it must answer first: which account, what is stored, and Meta's terms §3.2 (no automated collection without permission). Recorded here so the later goal does not re-derive it. *Corrected 2026-09-06:* `rebrowser-playwright` is abandoned (last commit 2024-09); the maintained 2026 options are Patchright and Camoufox; Apify's official groups actor refuses cookies and private groups, so managed APIs read public groups only; the maintained precedent for the own-session design captures Facebook's own feed query instead of parsing the DOM. |
|
|
162
|
+
| **"All Posts" group notifications → email → an IMAP parser** is a zero-risk read of groups the account is already a member of. Limits: new posts only, no history, and Facebook bundles high-volume groups into digests or skips mails. | A new rung the idea did not have, **1c — notification-email bridge**: a `sources` entry that reads a mailbox folder and yields one item per notification (post link, text, timestamp). Mercury already has host-side IMAP (`examples/extensions/yahoo-mail/lib/ymail.ts`, `imapflow`) and Gmail API scope (`gws`). This is the apartment adopter's cheapest Facebook path and the reason Milestone 1's dispatch must be able to choose a non-HTTP transport. Not built in M1. |
|
|
163
|
+
| Historical search is a different problem from live tracking: `facebook.com/groups/<id>/search/?q=…` under a browser, or an Apify historical run, backfills; notifications catch new posts. | Confirms the idea's split: **"track"** is feed-watch's job; **"search on demand"** (a member asks the bot to search a platform) is a separate tool, out of this goal. |
|
|
164
|
+
| Unstructured post text: price, rooms and neighbourhood are extracted by regex or an LLM; dedupe on the post id; notify with a direct link. | Maps one-to-one onto feed-watch: substring terms select, the verify run extracts and judges, the seen-set dedupes on the permalink. No new matching engine. |
|
|
165
|
+
|
|
166
|
+
### Tel Aviv rental sources a host-side poller can read (web survey, 2026-09-06)
|
|
167
|
+
|
|
168
|
+
| Source | Verdict | Rung |
|
|
169
|
+
|--------|---------|------|
|
|
170
|
+
| **Yad2** | Unreachable *directly*: Radware "Verifying your browser" JS challenge even from a residential IP with a Chrome UA (plain fetch; commercial vendors report real browsers on Israeli residential IPs pass at 96–100 %); the feed gateway 302s to a ShieldSquare validator; sitemaps list only search pages; no RSS. Terms §7 forbids "תוכנות אוטומטיות (ובכלל זאת רובוטים, spiders, scrapers)" verbatim, and Yad2 has enforced against commercial republishers (2009, and Keyz in June 2026). Sanctioned signals: **Yad2's own saved-search e-mail alerts, "fast" within 15 minutes** (app listing, 2026-09-06), and **Realta's aggregator RSS** below. | Direct reads **out of scope**; listings via **0** (Realta) and **1c** (alert mail). See [blocked-sources-research.md](blocked-sources-research.md). |
|
|
171
|
+
| **Realta** (`realta.co.il/feed.xml`) | Listing-level RSS, 50 items ≈ two hours of national volume, `pubDate` minutes old, permalink `guid`, title with rooms / neighbourhood / price; sources per row on the Tel Aviv page: Madlan 7, Yad2 6, Facebook 6, Komo 4, Homeless 1; original URL recoverable from the `go.realta.co.il` redirect's query string; `robots.txt` invites crawling. Single small operator — the same exposure Yad2 just pressed on Keyz. | **0**, zero code, the apartment adopter's first source. Measure the window on a weekday before trusting it. |
|
|
172
|
+
| **Facebook groups** (e.g. `telavivapartments`, 53 K members) | Non-browser clients get a 400 error page, not a login wall; API removed; terms §3.2.3 (2025-01-01) forbid automated collection *even while logged in*. "All posts" notifications are algorithm-filtered, not exhaustive (vendor claim, unverified). Complete reads exist only through an account's own session: Playwright reading Facebook's feed JSON (F1), or the Groups Watcher extension posting every new post to a webhook (F2) — see the research note. Realta already carries Facebook-sourced apartment listings. | 3 for a complete feed; 1c and Realta meanwhile. |
|
|
173
|
+
| **Telegram channels** | `t.me/s/nester_rent_telaviv` serves message blocks (title "תל אביב דירות להשכרה ללא תיווך", ISO `<time datetime>`, `data-post`, price and rooms in the text, outbound link; Nester's own no-broker stock, ~4–5 posts/hour, appears nowhere else). `t.me/s/ILRentsTLV` also serves blocks **but its last post is 2023-04-12** — directory-listed yet dead (re-confirmed 2026-09-06 19:00 IDT). `t.me/s/realta_rent_il` is live and duplicates Realta's RSS. `t.me/s/plotlv` (a *group*) returns the landing page: `/s/` is channel-only. Scoutr, Shushu and Jeremy deliver via *bots*, which have no public preview. | **1** — and a live channel is a per-channel fact; see the liveness item under future milestones. `nester_rent_telaviv` is a candidate fixture with real RTL Hebrew. |
|
|
174
|
+
| **homeless.co.il** | Server-rendered; 200 from the WSL box with any UA (403 from Anthropic egress); 53 `viewad,<id>.aspx` links per city page with a per-row `dd/mm/yyyy` date. No RSS. | **1b** — has a per-item date. |
|
|
175
|
+
| **komo.co.il** | Server-rendered, no block; 999 items with price/rooms/m²/floor; permalink `?modaaNum=<id>`; **no per-item date**. | **1b** — first-seen is the only clock. |
|
|
176
|
+
| **madlan.co.il** | Cloudflare 403 on HTML regardless of UA; sitemaps serve but are day-granular and over 10 MB; no reachable terms page; Apify reads its GraphQL API. Madlan is Roztel Knowledge Systems, not Yad2. | None directly; its listings are the largest share of Realta's Tel Aviv rows. |
|
|
177
|
+
| **onmap.co.il** | JS-rendered ("0 apartments" in the HTML). **WinWin** closed 2020. | None. |
|
|
178
|
+
| **Google News RSS** | Works as RSS but carries no listings — `site:yad2.co.il` returns landing pages, Hebrew queries return newspaper articles. | 0, useless for listings; the football success does not generalise. |
|
|
179
|
+
| **Google Alerts RSS** | "Deliver to RSS feed" still exists (2026-06-17), but Yad2 listing pages have no crawl path and alerts fire only after indexing: days to never. | **Dropped** — Realta's feed does what the spike hoped for. |
|
|
180
|
+
| **WhatsApp groups** | The bot *is* a WhatsApp client — a group the owner's number joins arrives as ordinary inbound messages. Scoutr (2026-04): WhatsApp is "the primary market", a good listing lasts 10 min–1 h. | **In-band, not a feed-watch source.** If wanted, matching must accept inbound messages, not poll. |
|
|
181
|
+
| **Reddit r/telaviv** | `/new/.rss` 200 from a residential IP with a custom UA; 403 from datacenter IPs is widely reported. Low volume. | 0, fragile — the same UA/rate question as r/soccer. |
|
|
182
|
+
| **Instagram** | Login wall on hashtag pages; profile HTML carries no post data; the 2023-era `web_profile_info` endpoint answers 401 from the box. Official Business Discovery reads Professional accounts only. | 3, lowest value of the three. |
|
|
183
|
+
| **X** | Nitter and xcancel received X Corp's cease-and-desist on 2026-08-24 (an xcancel host answered the box on 09-06 with a whitelist-by-e-mail placeholder — borrowed time); the unauthenticated syndication widget serves a live 20-entry timeline for big clubs and a **months-stale engagement cache with HTTP 200** for journalists and Israeli outlets (Ornstein 2025-10, sport5il 2025-10, Maccabi 2025-11); the official API is pay-per-use only since 2026-02 (~$50–90/month for 20 accounts hourly, reads deduplicated per 24 h); twitterapi.io ~$5/month. Romano is on none of the free surfaces; one hobbyist Bluesky mirror of him is live. | **2, paid vendor**; Bluesky RSS at 0 for the handles that exist. |
|
|
184
|
+
|
|
185
|
+
Not verified by the survey: homeless's date-column semantics (update vs.
|
|
186
|
+
entry date), Telegram's terms on reading previews, the body format of a
|
|
187
|
+
Yad2 fast-alert mail, how many minutes Realta's 50-item window covers on a
|
|
188
|
+
weekday, whether Realta's "Facebook" rows are group posts or Marketplace.
|
|
189
|
+
|
|
190
|
+
### Blocked platforms, researched 2026-09-06 at the owner's request
|
|
191
|
+
|
|
192
|
+
The owner asked for every way past the walls on Facebook, Instagram, X,
|
|
193
|
+
Yad2 and Madlan, Facebook first, because the notification-email bridge
|
|
194
|
+
"won't be good enough". The answer — per platform, with probes from the
|
|
195
|
+
box, costs, completeness, account risk and terms — is in
|
|
196
|
+
[blocked-sources-research.md](blocked-sources-research.md). The ladder
|
|
197
|
+
below already reflects it: Bluesky and Realta move to rung 0, X becomes a
|
|
198
|
+
paid-vendor rung 2, Facebook's rung-3 goal gets a primary design that
|
|
199
|
+
uses the owner's own session in the image's Chromium, Yad2 and Madlan
|
|
200
|
+
listings arrive through Realta and Yad2's own 15-minute alerts, and no
|
|
201
|
+
bot-protection bypass is built anywhere. One cross-cutting rule came out
|
|
202
|
+
of it: **a source is live when its newest item is recent, not when its
|
|
203
|
+
fetch returned 200** — X's stale cache and Telegram's dead channel fail
|
|
204
|
+
the same way.
|
|
205
|
+
|
|
206
|
+
## The ladder, revised
|
|
207
|
+
|
|
208
|
+
| Rung | What | Cost | Status |
|
|
209
|
+
|------|------|------|--------|
|
|
210
|
+
| 0 | Reddit `/new/.rss`, YouTube channel feeds, **Bluesky per-account RSS** (`bsky.app/profile/<handle>/rss`, verified live for The Athletic, Ornstein, Arsenal), and for the apartment adopter **Realta's listing feed** (`realta.co.il/feed.xml` — Yad2, Madlan, Homeless, Komo and Facebook-group listings as one RSS, verified 2026-09-06) — already RSS; config plus a probe of UA and rate | config | **M1.1** (football); Realta waits for the apartment profile |
|
|
211
|
+
| 1 | Public Telegram channels via `t.me/s/<channel>` — server-rendered, no account, no dependency | one adapter + fixture | **M1.2–M1.5** |
|
|
212
|
+
| 1b | Generic page watcher — poll a server-rendered page, diff its link list against the seen-set; `seenAt` is the clock when the page has no per-item date (homeless.co.il has one, komo.co.il has none) | one adapter | future milestone, demoted: both sites are inside Realta's feed |
|
|
213
|
+
| 1c | Notification-email bridge — read a mailbox folder over IMAP (or Gmail API) and yield one item per Facebook "All Posts" notification and per **Yad2 fast alert** (Yad2 states e-mail alerts within 15 minutes for a saved search — a sanctioned, complete Yad2 read); member's own groups, new posts only, digest bundling as a known gap on the Facebook side | one adapter on existing host-side mail code, two mail parsers | future milestone |
|
|
214
|
+
| 2 | **X through a paid vendor** (twitterapi.io at $0.15 per 1,000 tweets — ~$2–3/month typical, ~$20 worst case; or the official pay-per-use API at ~$50–90/month for a clean-terms path), keyed by a host-side secret. Free paths cover **club handles only**: the unauthenticated syndication widget's live 20-entry mode, verified per handle behind the liveness check. Journalists and Israeli outlets get a months-stale cache with HTTP 200, Nitter is under a cease-and-desist (2026-08-24), and own-session automation burns an account. Bluesky moved to rung 0 | one API adapter, plus the syndication sub-item | future milestone, after the owner picks vendor and budget |
|
|
215
|
+
| 3 | Facebook groups (and Instagram, if ever) — the [blocked-sources research](blocked-sources-research.md) gives the rung-3 goal a primary design: the owner's **own session in Playwright reading Facebook's feed JSON** in the image that already ships Chromium (F1), with the Groups Watcher extension → webhook (F2) as the zero-code experiment to run first, and 1c as the compliant floor. The tooling is in the *agent* image, but feed-watch polls host-side and that container is per-run, while F1 needs a persistent logged-in profile — a long-lived container with a mounted profile, or Chromium on the host, is part of the goal's cost. Which account is put at risk is the owner's decision, not the plan's | large | **separate goal**, not this one |
|
|
216
|
+
| — | WhatsApp groups | none | in-band; not a source |
|
|
217
|
+
| — | Yad2, Madlan *direct* | — | out of scope: a Radware bypass breaches Yad2's terms verbatim and is unnecessary — their listings arrive through rung 0 (Realta) and rung 1c (Yad2's own alerts) |
|