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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ No key is needed. `PIT_URL` points it at another pit.world instance (default `ht
- **The pit.world ring (`/ring`).** The core sites (pit.world, seo.rank, family.ancestry) and then every claimed listing, in the order they joined: **claiming is joining**, and owners can leave from the edit page. Hops: `/ring/next|prev|random?from=<site>` (also `?host=`, `?url=`, `?via=`, or the Referer), `302` with `no-store`; `/api/ring` for members that draw the bar themselves; `/ring/opml` for every member's feed. The verified and edit pages show the links to paste.
- **Mirrored to rssamplifier.com.** pit.world reads each claimed site's homepage for `<link rel="alternate">` RSS/Atom on claim and daily, and keeps the ring `pit-world` on [rssamplifier.com/ring/pit-world](https://rssamplifier.com/ring/pit-world) in step: submit new feeds, add members, remove ones that left. The sync is a diff (idempotent), runs a few seconds after a claim or edit and once a day, and needs `RSSAMPLIFIER_API_KEY` (an rssamplifier API key of the account that owns the ring; off without it). Moshpit-only names are not mirrored, since rssamplifier cannot reach them. New claims are also a feed: `/b/feed.xml`.

## Promotion campaigns: promote first, then show the results

A promotion campaign lists and promotes the businesses an [OutreachGraph](https://outreachgraph.com) campaign targets, measures what that did for each one over its own 30-day window, and only then writes to them, with the numbers. It is how the Los Gatos builders campaign runs ([pit.world/roundup/los-gatos-builders](https://pit.world/roundup/los-gatos-builders)).

- **Import.** `scripts/outreachgraph-campaign-export.sh cmp_...` writes a complete, read-only snapshot of the OutreachGraph campaign (OutreachGraph has no API that lists every member with its addresses). `pit promo import ID snapshot.json` gives every source row exactly one disposition (matched, merged into the same business, unresolved, excluded) and the totals must add up. Leads at one domain are one business with all their lead ids. Re-importing the same snapshot changes nothing; only a complete snapshot can mark a business as having left (its listing stays, its priority goes). Businesses without social profiles are kept.
- **The roster first.** `pit promo roster ID`: lead ids, business, website, listing, verification, planned action. A member is publishable when its listing is in the directory with a name, category, place, description and a checked source (a license checked at the issuer, or an owner claim).
- **Outreach hold.** A member only launches when every way the upstream could send to it is held: each lead suppressed in OutreachGraph (the `los-gatos-promotion-hold` suppress list) or only in campaigns that wait for approval. Otherwise it is blocked with the reason; an operator can attest a hold (`pit promo set ID DOMAIN --attest TEXT`).
- **Priority.** Verified members of a running campaign come first in `/pros`, in SQL before pagination, after filters, so pages, the API and the mobile view agree. Turning priority off or stopping the campaign leaves the listing alone. Campaign membership is never shown publicly.
- **Roundup and spotlights.** `pit promo preview ID` shows every planned post with its text and time and a version; `pit promo launch ID --version V` publishes `/roundup/<slug>` and creates the calendar: a roundup announcement now, then one spotlight per business across the configured accounts, `perAccountPerDay` each, from `postHour` local time. `pit promo worker ID` (run where myna is logged in) posts due ones with `myna post <account> --front`, reconciles every post against myna's history and queue by its tracking id (`?via=plc_...`) before posting so a crash never posts twice, and verifies each through the network's public API (Bluesky `getPostThread`, Mastodon `statuses`). Scheduled is not published, published is not verified; only verified posts count. LinkedIn has no public read, so its posts never count. Accounts on networks where myna buys a CrawlProof ad per post are refused: promotion is organic, and a paid budget defaults to zero.
- **Measurement.** Listing views, website clicks (`/b/<domain>/go`, a redirect to the listing's own site only), phone taps (a beacon from `/listing.js`, deduplicated by its id), quote requests, and roundup views (campaign-level, never credited to each business). Crawlers, link previews, prefetches, `PIT_INTERNAL_IPS` and marked tests (`?pt=test`) are stored as excluded. No cookie, no IP, no user agent: a visitor is a hash under a salt deleted when its UTC day ends, so "visitor-days" exist and unique visitors over a window do not (reported as unavailable). Only businesses in a campaign are counted.
- **Windows.** Each business's window starts when its listing is live, its tracking passed a marked test (`pit promo test-tracking ID`) and a placement naming it was verified, and lasts `periodDays` (30) elapsed days. The hourly pass marks ended windows due and makes one review job per window (a restart never makes a second), which writes a versioned report and a draft. Late additions get their own window; gaps are recorded (`pit promo gap`), never hidden by moving dates.
- **Reports.** Private and frozen per version (`pit promo report ID --md`); shareable at `/r/<token>` (HTML, `.md`, `.json`) without the operator's costs. Unavailable is not zero. Late data makes a new version and voids unsent drafts that quoted the old one. `pit promo csv ID` compares the campaign.
- **Drafts and sending.** Each report gets one draft from its own numbers: an introduction unless a contact is on record (then a follow-up), an honest note when nothing was measured, blocked when there is no address. Sending is off until `sendAuthorized`; `deliver` re-checks the window, the report version and upstream suppression at send time (unknown means no) and keeps one delivery per recipient, message version and window.
- **Surfaces.** API `/api/v1/promotion/*` (Bearer `PIT_ADMIN_TOKEN`), dashboard `/admin/promo` (a signed-in pit.world admin), `pit promo ...`, `pit promo tui ID`, and `promo_*` MCP tools when `PIT_ADMIN_TOKEN` is set.

## One account for every pit.world site: social.<name> and bbs.<name>

Every Moshpit name gets two more addresses, with nothing to set up:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pitworld",
"version": "0.4.0",
"version": "0.5.0",
"mcpName": "world.pit/pit",
"description": "pit.world MCP server: private web search and Moshpit name lookups for AI agents (stdio, no API key). Also a web app, API, CLI and TUI.",
"license": "MIT",
Expand Down
9 changes: 9 additions & 0 deletions public/listing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Counts taps on a listing's phone link (for promotion campaigns): one beacon per tap, with an id so a
// retried beacon is counted once. Sends the listing's domain and the campaign link it came from, nothing else.
document.addEventListener('click', (event) => {
const a = event.target instanceof Element ? event.target.closest('a[data-ev="phone"]') : null
if (!a || !navigator.sendBeacon) return
const id = (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now()).replace(/[^A-Za-z0-9_-]/g, '')
const body = JSON.stringify({ type: 'phone_click', id, via: a.dataset.via || null })
navigator.sendBeacon(`/b/${encodeURIComponent(a.dataset.domain)}/e`, new Blob([body], { type: 'application/json' }))
})
71 changes: 71 additions & 0 deletions scripts/outreachgraph-campaign-export.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# outreachgraph-campaign-export.sh: a complete, read-only snapshot of one OutreachGraph campaign's
# members, as the JSON `pit promo import` takes. OutreachGraph has no API that lists every member of
# a campaign (GET /campaigns/:id/accounts leaves out people without a company, and carries no
# addresses), so this reads its Postgres directly, in one SELECT: nothing is written.
#
# scripts/outreachgraph-campaign-export.sh cmp_... > snapshot.json
#
# DATABASE_URL comes from the environment, else from OG_APP_ENV (default ~/www/outreachgraph.com/app.env,
# only its DATABASE_URL line). Runs psql inside the Supabase container on dev2 (PSQL_CONTAINER).
#
# Every member row carries what the promotion hold needs to be checked against the upstream state:
# the other campaigns the person is in and how each one sends, whether they are suppressed, and their
# contact history (interactions, by direction). Rows keep OutreachGraph's own order (discovered_at, id).
set -euo pipefail

CAMPAIGN="${1:?usage: $0 <campaign id>}"
[[ "$CAMPAIGN" =~ ^cmp_[a-z0-9]+$ ]] || { echo "not a campaign id: $CAMPAIGN" >&2; exit 2; }
CONTAINER="${PSQL_CONTAINER:-logicsrc-com-supabase-db}"
if [ -z "${DATABASE_URL:-}" ]; then
DATABASE_URL="$(grep '^DATABASE_URL=' "${OG_APP_ENV:-$HOME/www/outreachgraph.com/app.env}" | cut -d= -f2-)"
fi

docker exec -i "$CONTAINER" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -At -v campaign="$CAMPAIGN" <<'SQL'
WITH c AS (SELECT * FROM campaigns WHERE id = :'campaign'),
members AS (
SELECT cp.*, row_number() OVER (ORDER BY cp.discovered_at, cp.person_id) AS ord
FROM campaign_people cp JOIN c ON c.id = cp.campaign_id
)
SELECT json_build_object(
'source', 'outreachgraph',
'campaignId', (SELECT id FROM c),
'campaignName', (SELECT name FROM c),
'workspaceId', (SELECT workspace_id FROM c),
'approvalMode', (SELECT approval_mode FROM c),
'snapshotAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'complete', true,
'total', (SELECT count(*) FROM members),
'rows', COALESCE((SELECT json_agg(json_build_object(
'leadId', m.person_id,
'order', m.ord,
'memberStatus', m.status,
'interactionState', m.interaction_state,
'name', p.display_name,
'kind', p.kind,
'personStatus', p.status,
'title', p.current_title,
'company', co.name,
'companyId', co.id,
'domain', co.domain,
'location', COALESCE(co.location, p.location),
'emails', COALESCE((SELECT json_agg(e.address ORDER BY e.address) FROM person_emails e WHERE e.person_id = p.id), '[]'::json),
'socials', COALESCE((SELECT json_agg(json_build_object('network', s.network, 'url', s.profile_url)) FROM social_identities s WHERE s.person_id = p.id), '[]'::json),
'suppressed', p.status = 'suppressed' OR m.status = 'suppressed' OR EXISTS (
SELECT 1 FROM suppression_keys k
WHERE k.match_key IN (SELECT 'person:' || p.id UNION SELECT 'email:' || lower(e.address) FROM person_emails e WHERE e.person_id = p.id)
AND (k.scope = 'global' OR k.workspace_id = m.workspace_id)),
'suppressLists', COALESCE((SELECT json_agg(DISTINCT se.name) FROM suppression_keys k JOIN suppression_entries se ON se.id = k.suppression_id
WHERE se.name IS NOT NULL AND (k.scope = 'global' OR k.workspace_id = m.workspace_id)
AND k.match_key IN (SELECT 'person:' || p.id UNION SELECT 'email:' || lower(e.address) FROM person_emails e WHERE e.person_id = p.id)), '[]'::json),
'campaigns', COALESCE((SELECT json_agg(json_build_object('id', oc.id, 'name', oc.name, 'approvalMode', oc.approval_mode, 'status', oc.status, 'memberStatus', ocp.status))
FROM campaign_people ocp JOIN campaigns oc ON oc.id = ocp.campaign_id WHERE ocp.person_id = p.id), '[]'::json),
'contacts', json_build_object(
'outbound', (SELECT count(*) FROM interactions i WHERE i.person_id = p.id AND i.direction = 'outbound'),
'inbound', (SELECT count(*) FROM interactions i WHERE i.person_id = p.id AND i.direction = 'inbound'),
'lastAt', (SELECT max(i.occurred_at) FROM interactions i WHERE i.person_id = p.id)),
'discoveredAt', m.discovered_at
) ORDER BY m.ord)
FROM members m JOIN people p ON p.id = m.person_id LEFT JOIN companies co ON co.id = p.current_company_id), '[]'::json)
);
SQL
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
"url": "https://github.com/moshcoder/pit.world",
"source": "github"
},
"version": "0.4.0",
"version": "0.5.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "pitworld",
"version": "0.4.0",
"version": "0.5.0",
"runtimeHint": "bunx",
"transport": {
"type": "stdio"
Expand Down
11 changes: 11 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const HELP = `pit: private search, and the Moshpit network (https://pit.world)
pit social NAME | pit bbs NAME the social feed or board of a Moshpit name
pit subscribe EMAIL get the pit.world newsletter (double opt-in)
pit open WORDS open the results page in your browser
pit promo ... promotion campaigns, for operators (pit promo help)
pit mcp MCP server on stdio: claude mcp add pit -- pit mcp
pit serve [--port N] run your own instance (SERPER_API_KEY etc.)

Expand Down Expand Up @@ -264,6 +265,16 @@ export async function main(argv: string[], out: Out = { log: (t) => console.log(
if (!json) spawn(argv[0]!, argv.slice(1), { detached: true, stdio: 'ignore' }).on('error', () => {}).unref()
return 0
}
case 'promo': {
const { promoMain } = await import('./promotion-cli.js')
return await promoMain(rest.slice(1), out, client, json).catch((error) => {
if (error?.usage) {
out.error(error.message)
return 64
}
throw error
})
}
case 'mcp':
await (await import('./mcp.js')).runMcp()
// Serve until the client hangs up: returning would let bin/pit exit mid-session.
Expand Down
24 changes: 24 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ export class PitClient {
return { ok: Boolean(data?.ok), status: String(data?.status ?? `HTTP ${response.status}`) }
}

/**
* The promotion API (/api/v1/promotion), for operators: PIT_ADMIN_TOKEN is the bearer. Text responses
* (CSV, Markdown) come back as strings.
*/
async promo<T = any>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
const token = process.env.PIT_ADMIN_TOKEN
if (!token) throw new Error('set PIT_ADMIN_TOKEN (the pit.world admin token) for promotion commands')
const response = await this.fetchImpl(`${this.base}/api/v1/promotion${path}`, {
method,
headers: { accept: 'application/json', authorization: `Bearer ${token}`, 'x-pit-actor': process.env.PIT_ACTOR ?? 'cli', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(60_000),
})
const type = response.headers.get('content-type') ?? ''
if (!type.includes('json')) {
const text = await response.text()
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return text as T
}
const data = (await response.json().catch(() => ({}))) as any
if (!response.ok) throw new Error(`${data?.error ?? `HTTP ${response.status}`}${data?.code ? ` (${data.code})` : ''}`)
return data as T
}

/** Start a newsletter signup; the address only joins after the emailed link is clicked. */
async subscribe(email: string): Promise<{ ok: boolean; status: string }> {
const response = await this.fetchImpl(`${this.base}/api/subscribe`, {
Expand Down
2 changes: 2 additions & 0 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ export function privacyPage(): string {
<p><code>!gh hqtui</code> sends you to GitHub's own search. From that moment you are on their site, under their privacy policy; pit.world only redirects you there without a referrer.</p>
<h2 id="newsletter">The newsletter, only if you ask</h2>
<p>The newsletter is the one thing pit.world keeps, and only after you ask twice. When you sign up, pit.world stores your address beside a fingerprint of a one-time link and emails you that link. Nothing else about you: no IP address, no browser, no referrer. Click it and your address joins the list; don't, and both are deleted within 48 hours. Issues are sent by our newsletter tool through our email provider, and every one has a one-click unsubscribe that takes you off for good.</p>
<h2 id="listings">Business listings: what we count</h2>
<p>For businesses pit.world is featuring in a promotion (like a local roundup), pit.world counts visits to their listing page, clicks on the link to their website, taps on their phone number and quote requests, so it can tell each business honestly what the listing did for them. Search is never counted. A visit is stored as a type, a time, the listing, which of our posts linked you there (if one did) and the site you came from (its name only, never the full address). To count distinct visitors in a day, pit.world keeps a hash of your IP address and browser under a random key that is deleted when that day ends: nothing that can be turned back into an address, and nothing that links two days. No cookie is set, and nothing from a third party is loaded. The phone-tap count comes from a few lines of pit.world's own script on the listing page.</p>
<h2 id="accounts">Your account, only if you sign in</h2>
<p>One pit.world account signs you in on every <code>social.&lt;name&gt;.pit.world</code> feed and <code>bbs.&lt;name&gt;.pit.world</code> board, so you never make an account per site. You sign in with CoinPay (OAuth 2.1). We keep your CoinPay id, the name and email CoinPay gives us, a session cookie for <code>pit.world</code> only, and the list of pit.world sites you signed in to, which you can see and clear on <a href="/account">your account page</a>. A site gets your CoinPay id and name, nothing else. Search never reads the cookie. Delete the account from the same page and it is gone; what you posted on a board or feed stays there until you delete it on that site.</p>
<h2>Source</h2>
Expand Down
Loading
Loading