@sprid/cli 0.1.12 → 0.1.13
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/CHANGELOG.md +13 -0
- package/README-post.md +52 -1
- package/package.json +1 -1
- package/src/docs/commands.mjs +1 -1
- package/src/docs/guides.generated.mjs +31 -25
- package/src/docs/queries.d.mts +1 -1
- package/src/docs/queries.mjs +12 -5
- package/src/post/cli.mjs +335 -14
- package/src/post/rules/platform-rules.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
sprid follows semver: a breaking change to a command's arguments, its output
|
|
4
4
|
shape or its exit codes is a major release.
|
|
5
5
|
|
|
6
|
+
## 0.1.13 - 2026-09-27
|
|
7
|
+
|
|
8
|
+
- `sprid post` lanes can target Pinterest: a `pinterest` block (board, link with
|
|
9
|
+
`<slug>`, optional title, description, alt text and UTM) is composed into each
|
|
10
|
+
post at `register`, re-sent by `sync` when it changes, and gated by `check`,
|
|
11
|
+
which refuses a Pin with no board or link, a deck of more than one slide, an
|
|
12
|
+
image taller than 2:3, or a cover frame past the end of the video.
|
|
13
|
+
- `sprid marketing-review query` gains `pinterest` `posts`: stored Pin outcomes
|
|
14
|
+
with collected daily impressions, saves, Pin clicks and outbound clicks up to
|
|
15
|
+
the exclusive end, with observed-day coverage and Pin age.
|
|
16
|
+
- `draft` no longer reports `pinterestOptions` as dropped when the server
|
|
17
|
+
returns the same fields in a different key order.
|
|
18
|
+
|
|
6
19
|
## 0.1.12 - 2026-09-26
|
|
7
20
|
|
|
8
21
|
- `sprid docs` quotes credits as Sprid credits (one to a cent) instead of cents.
|
package/README-post.md
CHANGED
|
@@ -483,6 +483,57 @@ content and each platform's title, caption and cover before publishing.
|
|
|
483
483
|
- A `.tiktok.txt` sibling provides a separate TikTok caption; otherwise it uses
|
|
484
484
|
the shared caption. YouTube's description uses the shared caption.
|
|
485
485
|
|
|
486
|
+
## Pinterest
|
|
487
|
+
|
|
488
|
+
A reel publishes as a video Pin, its cover the poster `push` cuts at the lane's
|
|
489
|
+
`coverAtMs`. A one-image post publishes as an image Pin. A Pin also needs a
|
|
490
|
+
board and a destination link, and those are the two things nothing can guess,
|
|
491
|
+
so a lane names them:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
lanes: {
|
|
495
|
+
reels: {
|
|
496
|
+
media: "out/<slug>.mp4",
|
|
497
|
+
platforms: ["instagram", "tiktok", "youtube", "pinterest"],
|
|
498
|
+
pinterest: {
|
|
499
|
+
boardId: "1234567890123456789", // quoted: longer than a number holds exactly
|
|
500
|
+
boardSectionId: "9876543210", // optional
|
|
501
|
+
link: "https://example.com/p/<slug>", // <slug> is filled per post
|
|
502
|
+
// title, description, altText, aiDisclosures: optional
|
|
503
|
+
// utm: { campaign: "autumn" } // optional, see below
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`sprid pinterest boards --account <slug>` lists board ids. A post's spec can
|
|
510
|
+
override any field with its own `pinterest: { ... }`. `register` writes the
|
|
511
|
+
finished slice into the manifest as `post.pinterestOptions`, `check` gates it
|
|
512
|
+
and `draft` sends it. With no block in the lane or the spec, whatever the
|
|
513
|
+
manifest already held is kept.
|
|
514
|
+
|
|
515
|
+
- **Title** defaults to the post's title, cut at a word inside 100 characters,
|
|
516
|
+
and is left out rather than filled with anything that reads as a slug or a
|
|
517
|
+
production label.
|
|
518
|
+
- **Description** defaults to the caption with its hashtag-only lines removed,
|
|
519
|
+
cut at a sentence inside 800 characters.
|
|
520
|
+
- **Link tagging is Sprid's by default.** At publish time Sprid appends its own
|
|
521
|
+
`utm_*` and publish id, which is what traces a visit to the one Pin that sent
|
|
522
|
+
it. It does that only when the link carries no `utm_source`, so setting `utm`
|
|
523
|
+
(source, medium, campaign, content; defaults `pinterest`, `social`, the slug)
|
|
524
|
+
gives your own campaign names and gives up that per-Pin attribution. Tags
|
|
525
|
+
already written into the link are never overwritten; `utm: false` in a spec
|
|
526
|
+
turns a lane's tags off for one post.
|
|
527
|
+
|
|
528
|
+
The `crosspost` check fails a Pin that cannot publish as reviewed: no board, no
|
|
529
|
+
absolute link, a title, description or alt text over 100, 800 or 500
|
|
530
|
+
characters, **a deck of more than one slide** (a Pin is exactly one image or one
|
|
531
|
+
video, so the rest would never reach it), **an image taller than 2:3** (the feed
|
|
532
|
+
cuts it short; 2:3, 1000x1500, or wider publishes whole), and a `coverAtMs` past
|
|
533
|
+
the end of the video. A composed image post's shape is its template's, so that
|
|
534
|
+
part reports a skip. `sync` re-sends a slice that changed after the draft, and
|
|
535
|
+
`status` shows it as `pin changed`.
|
|
536
|
+
|
|
486
537
|
## `sync`: a re-render, into posts that already exist
|
|
487
538
|
|
|
488
539
|
**A booked post is never edited. The source is re-rendered and the queue catches
|
|
@@ -603,7 +654,7 @@ instagram,tiktok` overrides the selection for that check. `register --platforms
|
|
|
603
654
|
instagram` records a post's selection. With no selection, the existing Instagram,
|
|
604
655
|
TikTok and YouTube checks remain the default. This selects validation targets;
|
|
605
656
|
publishing destinations are still chosen when scheduling in Sprid. Local projection
|
|
606
|
-
rules
|
|
657
|
+
rules cover Instagram, TikTok, YouTube and Pinterest; other platforms report a
|
|
607
658
|
skip, and `--deep` reads the booked post’s server projection. Media checks retain
|
|
608
659
|
their existing defaults and can be customized with `lane.expect`.
|
|
609
660
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprid/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
package/src/docs/commands.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export const COMMAND_GROUPS = [
|
|
|
99
99
|
['queue', 'sprid queue', 'Show what is scheduled and what failed.'],
|
|
100
100
|
['queue', 'sprid queue retry <publishId>', 'Retry a failed or missed publish.'],
|
|
101
101
|
['queue', 'sprid queue cancel <publishId>', 'Cancel a booking. The post itself is unchanged and can be scheduled again.'],
|
|
102
|
-
['post', 'sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>]', 'Book a post: at a time, the next free slot, or after everything already booked. --shift-later moves later bookings down a slot. TikTok needs its privacy and interaction answers under "tiktok" in --options every time.'],
|
|
102
|
+
['post', 'sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>]', 'Book a post: at a time, the next free slot, or after everything already booked. --shift-later moves later bookings down a slot. TikTok needs its privacy and interaction answers under "tiktok" in --options every time. A Pinterest Pin needs its board and destination link saved first with sprid pinterest set.'],
|
|
103
103
|
['post', 'sprid post schedule-batch --account <slug> --ids 1,2,3 [--cadence daily|every-other-day|weekdays] [--times 09:00] [--start YYYY-MM-DD] [--platforms a,b] [--skip-busy] [--dry-run]', 'Place a list of posts on a cadence in one call. --dry-run returns the plan without booking anything.'],
|
|
104
104
|
['post', 'sprid post publish <postId> [--platforms a,b] [--options <options.json>] [--yes]', 'Publish now. Public and not reversible, so it asks unless --yes. Read the receipts with sprid post deliveries.'],
|
|
105
105
|
['inbox', 'sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--intent <intent>] [--limit N]', 'Comments on posts and ads plus store reviews, as one ranked queue.'],
|
|
@@ -61,7 +61,7 @@ export const GUIDE_ALIASES = {
|
|
|
61
61
|
"tiktok_accounts": "tiktok-comments",
|
|
62
62
|
"tiktok-accounts": "tiktok-comments"
|
|
63
63
|
};
|
|
64
|
-
export const METRIC_EVIDENCE = "# Metric evidence\n\nSprid’s review packet includes a versioned `dataset`: observations, checked results and hashes of the source receipts. The CLI saves complete review/query packets under `marketing-reports/evidence/` with immutable content-hash filenames and owner-only file permissions. The plugin runner preserves that dataset alongside its local evidence and git context. An explicit runner `--output` refuses to overwrite an existing file.\n\n## Read a number\n\n- `definition` names the entity, measurement kind, unit, calculation basis and implementation version. An active subscription is not a unique customer; a store download is not an activated person.\n- `window` uses an exclusive end and retains its calendar. `asOf` belongs to a snapshot; `fetchedAt` records collection. Historical revenue and current MRR are separate observations.\n- `quality` separates coverage, pagination, sampling, finality and trust. A successful HTTP request does not prove complete coverage. Unknown, pending, suppressed and unavailable values remain missing.\n- `value.kind: money` stores an exact decimal coefficient and scale with a currency and conversion policy. Do not assume every provider amount is cents. Keep gross, refunded revenue and proceeds separate.\n\n## Respond to a blocked calculation\n\n| Reason | Continue with |\n| --- | --- |\n| `currency_mismatch` | Separate currency rows; convert only with explicit dated FX evidence and a declared policy. |\n| `overlap_unresolved` | Source subtotals and the identity check. Do not add RevenueCat and a rail it already ingests. |\n| `definition_mismatch` | Name the provider definitions separately, including MRR policy and revenue basis. |\n| `window_mismatch` | Request matching periods/calendars. A daily Pacific aggregate cannot be relabeled UTC. |\n| `population_mismatch` | Verify numerator/denominator attribution with an actual identity mapping. All-app revenue cannot stand for website-cohort revenue. |\n| `incomplete_sources`, `incomplete_coverage` | Keep usable readings; complete pagination or narrow the period. Missing dates are not automatically zero. |\n| `unverified_calculation` | Inspect emitting code and the query’s population, ordering and exclusions. Preserve a local attestation for local database work. |\n| `non_additive` | Query period-level distinct people, select the relevant snapshot, or recompute a rate from compatible numerators and denominators. |\n\n## Investigate through Sprid\n\nRun `sprid marketing-review capabilities --app <slug>` to discover operations. The `revenuecat` / `revenue` operation reads an explicit period total, with `revenue_type` set to `revenue`, `revenue_net_of_taxes` or `proceeds`. Its scope is the bound RevenueCat project, which can contain multiple apps. Use `chart_options` before selecting chart dimensions. Cohort-chart period zero may describe customer count, not the month-zero metric.\n\nCustom query results carry `measurement` provenance and retain provider-native data. They remain investigative; the server does not certify arbitrary SQL’s business meaning. The shared calculation library supports sequential/strict ordered funnels and fixed elapsed-time retention from complete event evidence. These helpers are not additional public query operation names. For provider-side investigations, use the discovered custom query operation and preserve the exact SQL.\n\nStripe MRR is a current active/past-due price estimate before discounts. Paddle’s adapter reports gross completed transactions before adjustments. Lemon Squeezy combines orders with renewal/update invoices and excludes duplicate initial invoices; a complete priced schedule is required for MRR. Separate source readings survive when combined totals cannot be justified.\n\nRevenue observations and aggregate Search Console observations are normalized in the review dataset. Other source receipts retain native definitions and coverage; they are not silently converted into a universal business scorecard. App-specific activation, cross-source identity and attribution still require repository evidence. Local fallback results do not automatically inherit the server dataset’s verification.\n\nSave the full packet and exact query beside the report. Hashes identify evidence but cannot recreate it. Link corrected reports to their earlier dataset and retain the earlier packet; provider backfills and refund revisions must remain inspectable. Sprid does not upload repository database rows or provide hosted dataset-history storage through this contract.\n";
|
|
64
|
+
export const METRIC_EVIDENCE = "# Metric evidence\n\nSprid’s review packet includes a versioned `dataset`: observations, checked results and hashes of the source receipts. The CLI saves complete review/query packets under `marketing-reports/evidence/` with immutable content-hash filenames and owner-only file permissions. The plugin runner preserves that dataset alongside its local evidence and git context. An explicit runner `--output` refuses to overwrite an existing file.\n\n## Read a number\n\n- `definition` names the entity, measurement kind, unit, calculation basis and implementation version. An active subscription is not a unique customer; a store download is not an activated person.\n- `window` uses an exclusive end and retains its calendar. `asOf` belongs to a snapshot; `fetchedAt` records collection. Historical revenue and current MRR are separate observations.\n- `quality` separates coverage, pagination, sampling, finality and trust. A successful HTTP request does not prove complete coverage. Unknown, pending, suppressed and unavailable values remain missing.\n- Social post metrics are cumulative snapshots sampled before the end. Pinterest Pin metrics are dated daily activity summed over the days actually observed, with that coverage attached; a missing day is unmeasured, not zero. Never subtract one rolling total from another, and compare Pins at equal ages.\n- `value.kind: money` stores an exact decimal coefficient and scale with a currency and conversion policy. Do not assume every provider amount is cents. Keep gross, refunded revenue and proceeds separate.\n\n## Respond to a blocked calculation\n\n| Reason | Continue with |\n| --- | --- |\n| `currency_mismatch` | Separate currency rows; convert only with explicit dated FX evidence and a declared policy. |\n| `overlap_unresolved` | Source subtotals and the identity check. Do not add RevenueCat and a rail it already ingests. |\n| `definition_mismatch` | Name the provider definitions separately, including MRR policy and revenue basis. |\n| `window_mismatch` | Request matching periods/calendars. A daily Pacific aggregate cannot be relabeled UTC. |\n| `population_mismatch` | Verify numerator/denominator attribution with an actual identity mapping. All-app revenue cannot stand for website-cohort revenue. |\n| `incomplete_sources`, `incomplete_coverage` | Keep usable readings; complete pagination or narrow the period. Missing dates are not automatically zero. |\n| `unverified_calculation` | Inspect emitting code and the query’s population, ordering and exclusions. Preserve a local attestation for local database work. |\n| `non_additive` | Query period-level distinct people, select the relevant snapshot, or recompute a rate from compatible numerators and denominators. |\n\n## Investigate through Sprid\n\nRun `sprid marketing-review capabilities --app <slug>` to discover operations. The `revenuecat` / `revenue` operation reads an explicit period total, with `revenue_type` set to `revenue`, `revenue_net_of_taxes` or `proceeds`. Its scope is the bound RevenueCat project, which can contain multiple apps. Use `chart_options` before selecting chart dimensions. Cohort-chart period zero may describe customer count, not the month-zero metric.\n\nCustom query results carry `measurement` provenance and retain provider-native data. They remain investigative; the server does not certify arbitrary SQL’s business meaning. The shared calculation library supports sequential/strict ordered funnels and fixed elapsed-time retention from complete event evidence. These helpers are not additional public query operation names. For provider-side investigations, use the discovered custom query operation and preserve the exact SQL.\n\nStripe MRR is a current active/past-due price estimate before discounts. Paddle’s adapter reports gross completed transactions before adjustments. Lemon Squeezy combines orders with renewal/update invoices and excludes duplicate initial invoices; a complete priced schedule is required for MRR. Separate source readings survive when combined totals cannot be justified.\n\nRevenue observations and aggregate Search Console observations are normalized in the review dataset. Other source receipts retain native definitions and coverage; they are not silently converted into a universal business scorecard. App-specific activation, cross-source identity and attribution still require repository evidence. Local fallback results do not automatically inherit the server dataset’s verification.\n\nSave the full packet and exact query beside the report. Hashes identify evidence but cannot recreate it. Link corrected reports to their earlier dataset and retain the earlier packet; provider backfills and refund revisions must remain inspectable. Sprid does not upload repository database rows or provide hosted dataset-history storage through this contract.\n";
|
|
65
65
|
export const GUIDES = [
|
|
66
66
|
{
|
|
67
67
|
"id": "social-accounts",
|
|
@@ -106,11 +106,17 @@ export const GUIDES = [
|
|
|
106
106
|
"kind": "steps",
|
|
107
107
|
"markdown": "Create or sign in to each account in TikTok, confirm the handle and the owner’s recovery access, and complete any verification yourself. Then follow the [TikTok connection guide](https://sprid.studio/docs/connect/tiktok)."
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"id": "set-up-pinterest",
|
|
111
|
+
"title": "Set up Pinterest",
|
|
112
|
+
"kind": "steps",
|
|
113
|
+
"markdown": "Create a free business account at [Pinterest for business](https://www.pinterest.com/business/create/), or convert an existing personal account in its settings; a private personal account has to be made public first. Pinterest Analytics needs a business account. Claim your website there if Pins will link to it, then create one or two boards named for topics people search. Then follow the [Pinterest connection guide](https://sprid.studio/docs/connect/pinterest)."
|
|
114
|
+
},
|
|
109
115
|
{
|
|
110
116
|
"id": "then-run",
|
|
111
117
|
"title": "Then run",
|
|
112
118
|
"kind": "command",
|
|
113
|
-
"markdown": "```sh\nsprid connect instagram --account yourbrand\n```\n\nUse your Sprid publishing-account slug and the platform: `instagram`, `facebook`, `youtube` or `
|
|
119
|
+
"markdown": "```sh\nsprid connect instagram --account yourbrand\n```\n\nUse your Sprid publishing-account slug and the platform: `instagram`, `facebook`, `youtube`, `tiktok` or `pinterest`. Connect only accounts that already exist.\n\nIn an agent-controlled browser, add `--no-browser` and open the returned authorization link in the session signed in to the correct account. Keep that link private."
|
|
114
120
|
},
|
|
115
121
|
{
|
|
116
122
|
"id": "how-to-check-it-worked",
|
|
@@ -128,11 +134,11 @@ export const GUIDES = [
|
|
|
128
134
|
"id": "sources",
|
|
129
135
|
"title": "Sources",
|
|
130
136
|
"kind": "sources",
|
|
131
|
-
"markdown": "- [Instagram signup](https://www.instagram.com/accounts/emailsignup/)\n- [Instagram Business Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login/)\n- [Meta app modes](https://developers.facebook.com/docs/development/build-and-test/app-modes)\n- [Meta Accounts Center](https://www.facebook.com/help/943858526073065)\n- [Create a Facebook Page](https://www.facebook.com/pages/create/)\n- [YouTube channel list](https://www.youtube.com/channel_switcher)\n- [YouTube feature eligibility](https://support.google.com/youtube/answer/9891124?hl=en)"
|
|
137
|
+
"markdown": "- [Instagram signup](https://www.instagram.com/accounts/emailsignup/)\n- [Instagram Business Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login/)\n- [Meta app modes](https://developers.facebook.com/docs/development/build-and-test/app-modes)\n- [Meta Accounts Center](https://www.facebook.com/help/943858526073065)\n- [Create a Facebook Page](https://www.facebook.com/pages/create/)\n- [YouTube channel list](https://www.youtube.com/channel_switcher)\n- [YouTube feature eligibility](https://support.google.com/youtube/answer/9891124?hl=en)\n- [Get a Pinterest business account](https://help.pinterest.com/en/business/article/get-a-business-account)"
|
|
132
138
|
}
|
|
133
139
|
],
|
|
134
|
-
"markdown": "# Create your social accounts\n\n**What Sprid does with this:** Connect your brand, creator and other social accounts to their own publishing destinations, so approved content reaches the intended audience.\n\n## You need\n\n- The email address that should own the accounts, access to its inbox, and your phone for verification.\n- The display name, desired handle and purpose of each account. A handle is only a proposal until the platform accepts it.\n- Access to your Meta business portfolio, if it should own the Facebook Pages and Instagram accounts.\n\nPasswords, verification codes and identity documents stay in the platform’s own browser or app. An agent can prepare forms and continue after you sign in.\n\n## Set up your account map\n\nConnect one main account, or several with different purposes. Choose only the accounts and platforms your audience needs.\n\n| Account purpose | Example display name | Example handle | Sprid publishing account |\n|---|---|---|---|\n| Main brand or product | Your Brand | yourbrand | yourbrand |\n| Founder or creator | Alex from Your Brand | alexfromyourbrand | brand-founder |\n| Character or persona | Everyday Alex | everydayalex | everyday-alex |\n| Topic or community | Small Space Ideas | smallspaceideas | small-space-ideas |\n| Campaign or project | The Weekend Project | theweekendproject | weekend-project |\n| Language or regional audience | Your Brand Español | yourbrand.espanol | brand-spanish |\n\nThe examples are naming ideas, not availability checks. The purpose is separate from platform settings such as Instagram’s Business or Creator type or Google’s Brand Account. A character account can represent a fictional persona; keep that clear in its profile.\n\nFor each identity, write down the owner, purpose, handle and platforms. Group the same identity’s profiles under one Sprid publishing account: your brand’s Instagram and YouTube share one, a founder’s account gets its own.\n\nAn Instagram login, a Facebook Page, a Meta business portfolio and a Sprid publishing account are separate objects. Connecting a channel to Sprid does not move its ownership into a portfolio, and a Sprid destination does not reserve a handle or create a social account.\n\nYour agent can list existing publishing accounts with `sprid apps` and create missing ones with `sprid account create --file account.json` (see the [CLI reference](https://sprid.studio/docs/cli)).\n\n## Set up Instagram\n\n1. Open [Instagram signup](https://www.instagram.com/accounts/emailsignup/), or reuse an existing account that already serves the purpose.\n2. Enter the owner email, display name and desired handle. Complete password, birthday and verification in Instagram.\n3. Check the profile’s actual handle and contact email.\n4. Switch to a professional account: **Business** for a brand, **Creator** for a creator-led account, with a matching category.\n5. If a Meta business portfolio should own it, its administrator adds the account there. This is separate from Sprid authorization.\n6. Follow the [Instagram connection guide](https://sprid.studio/docs/connect/instagram), one account at a time.\n\nThe Sprid connection may offer the professional switch itself: **Change → Business → Next → category → Done → Continue** (labels as of September 2026). If signing up an additional account sends you back to login, finish signup in a separate browser session or the Instagram app and keep the working account signed in.\n\n## Set up Facebook\n\nReuse an existing Page where you can. Otherwise open [Create a Page](https://www.facebook.com/pages/create/) under the profile that should manage it, using the public display name. If a business portfolio should own the Page, its administrator adds or claims it there. Then follow the [Facebook connection guide](https://sprid.studio/docs/connect/facebook).\n\n## Set up YouTube\n\nSign in to [YouTube’s channel list](https://www.youtube.com/channel_switcher) with the owner’s Google account. Review existing channels, then choose **Create a channel** for each additional identity. Check both the channel name and handle.\n\nIf YouTube asks for advanced-feature verification, the owner completes it in YouTube Studio (video, ID or channel history). Review can stay pending: continue other platforms and recheck eligibility before retrying. Approval on one channel does not unlock every new channel. See [YouTube eligibility and owner verification](https://support.google.com/youtube/answer/9891124?hl=en).\n\nThen follow the [YouTube connection guide](https://sprid.studio/docs/connect/youtube) and pick that exact channel in Google’s authorization screen.\n\n## Set up TikTok\n\nCreate or sign in to each account in TikTok, confirm the handle and the owner’s recovery access, and complete any verification yourself. Then follow the [TikTok connection guide](https://sprid.studio/docs/connect/tiktok).\n\n## Then run\n\n```sh\nsprid connect instagram --account yourbrand\n```\n\nUse your Sprid publishing-account slug and the platform: `instagram`, `facebook`, `youtube` or `
|
|
135
|
-
"revision": "
|
|
140
|
+
"markdown": "# Create your social accounts\n\n**What Sprid does with this:** Connect your brand, creator and other social accounts to their own publishing destinations, so approved content reaches the intended audience.\n\n## You need\n\n- The email address that should own the accounts, access to its inbox, and your phone for verification.\n- The display name, desired handle and purpose of each account. A handle is only a proposal until the platform accepts it.\n- Access to your Meta business portfolio, if it should own the Facebook Pages and Instagram accounts.\n\nPasswords, verification codes and identity documents stay in the platform’s own browser or app. An agent can prepare forms and continue after you sign in.\n\n## Set up your account map\n\nConnect one main account, or several with different purposes. Choose only the accounts and platforms your audience needs.\n\n| Account purpose | Example display name | Example handle | Sprid publishing account |\n|---|---|---|---|\n| Main brand or product | Your Brand | yourbrand | yourbrand |\n| Founder or creator | Alex from Your Brand | alexfromyourbrand | brand-founder |\n| Character or persona | Everyday Alex | everydayalex | everyday-alex |\n| Topic or community | Small Space Ideas | smallspaceideas | small-space-ideas |\n| Campaign or project | The Weekend Project | theweekendproject | weekend-project |\n| Language or regional audience | Your Brand Español | yourbrand.espanol | brand-spanish |\n\nThe examples are naming ideas, not availability checks. The purpose is separate from platform settings such as Instagram’s Business or Creator type or Google’s Brand Account. A character account can represent a fictional persona; keep that clear in its profile.\n\nFor each identity, write down the owner, purpose, handle and platforms. Group the same identity’s profiles under one Sprid publishing account: your brand’s Instagram and YouTube share one, a founder’s account gets its own.\n\nAn Instagram login, a Facebook Page, a Meta business portfolio and a Sprid publishing account are separate objects. Connecting a channel to Sprid does not move its ownership into a portfolio, and a Sprid destination does not reserve a handle or create a social account.\n\nYour agent can list existing publishing accounts with `sprid apps` and create missing ones with `sprid account create --file account.json` (see the [CLI reference](https://sprid.studio/docs/cli)).\n\n## Set up Instagram\n\n1. Open [Instagram signup](https://www.instagram.com/accounts/emailsignup/), or reuse an existing account that already serves the purpose.\n2. Enter the owner email, display name and desired handle. Complete password, birthday and verification in Instagram.\n3. Check the profile’s actual handle and contact email.\n4. Switch to a professional account: **Business** for a brand, **Creator** for a creator-led account, with a matching category.\n5. If a Meta business portfolio should own it, its administrator adds the account there. This is separate from Sprid authorization.\n6. Follow the [Instagram connection guide](https://sprid.studio/docs/connect/instagram), one account at a time.\n\nThe Sprid connection may offer the professional switch itself: **Change → Business → Next → category → Done → Continue** (labels as of September 2026). If signing up an additional account sends you back to login, finish signup in a separate browser session or the Instagram app and keep the working account signed in.\n\n## Set up Facebook\n\nReuse an existing Page where you can. Otherwise open [Create a Page](https://www.facebook.com/pages/create/) under the profile that should manage it, using the public display name. If a business portfolio should own the Page, its administrator adds or claims it there. Then follow the [Facebook connection guide](https://sprid.studio/docs/connect/facebook).\n\n## Set up YouTube\n\nSign in to [YouTube’s channel list](https://www.youtube.com/channel_switcher) with the owner’s Google account. Review existing channels, then choose **Create a channel** for each additional identity. Check both the channel name and handle.\n\nIf YouTube asks for advanced-feature verification, the owner completes it in YouTube Studio (video, ID or channel history). Review can stay pending: continue other platforms and recheck eligibility before retrying. Approval on one channel does not unlock every new channel. See [YouTube eligibility and owner verification](https://support.google.com/youtube/answer/9891124?hl=en).\n\nThen follow the [YouTube connection guide](https://sprid.studio/docs/connect/youtube) and pick that exact channel in Google’s authorization screen.\n\n## Set up TikTok\n\nCreate or sign in to each account in TikTok, confirm the handle and the owner’s recovery access, and complete any verification yourself. Then follow the [TikTok connection guide](https://sprid.studio/docs/connect/tiktok).\n\n## Set up Pinterest\n\nCreate a free business account at [Pinterest for business](https://www.pinterest.com/business/create/), or convert an existing personal account in its settings; a private personal account has to be made public first. Pinterest Analytics needs a business account. Claim your website there if Pins will link to it, then create one or two boards named for topics people search. Then follow the [Pinterest connection guide](https://sprid.studio/docs/connect/pinterest).\n\n## Then run\n\n```sh\nsprid connect instagram --account yourbrand\n```\n\nUse your Sprid publishing-account slug and the platform: `instagram`, `facebook`, `youtube`, `tiktok` or `pinterest`. Connect only accounts that already exist.\n\nIn an agent-controlled browser, add `--no-browser` and open the returned authorization link in the session signed in to the correct account. Keep that link private.\n\n## How to check it worked\n\nRun `sprid status --json` and match each platform identity to its Sprid publishing account. Track per platform:\n\n- Account created, with its confirmed handle or Page/channel ID.\n- Owner and business-portfolio access checked.\n- Provider verification or tester invitation still pending.\n- Sprid connection saved to the correct destination.\n- Publishing tested with approved content, or untested.\n\n“You allowed access” on the authorization screen does not prove Sprid saved the connection, and a connected channel does not prove a post was delivered. Check the platform itself after a publishing test.\n\n## If it fails\n\n- **Another account appears in authorization:** cancel, sign in to the intended account and restart. Do not move a working channel to clear the error.\n- **Several Facebook Pages are listed:** rerun with `--page <id>` using the intended Page’s returned ID.\n- **YouTube verification is pending:** leave that destination pending and continue with another platform.\n- **Instagram authorizes but Sprid reports a token-exchange error:** send the error to [Sprid support](mailto:hello@sprid.studio). Sprid checks its Meta permissions and tester restrictions; any tester invitation comes from Sprid and must be accepted by the intended Instagram account. You do not need your own developer app.\n- **The CLI points to an unavailable local server:** check `sprid whoami --json`, then retry with `SPRID_URL=https://api.sprid.studio` for that command. Do not change credentials or workspaces to fix a server-address mismatch.\n\n## Sources\n\n- [Instagram signup](https://www.instagram.com/accounts/emailsignup/)\n- [Instagram Business Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login/)\n- [Meta app modes](https://developers.facebook.com/docs/development/build-and-test/app-modes)\n- [Meta Accounts Center](https://www.facebook.com/help/943858526073065)\n- [Create a Facebook Page](https://www.facebook.com/pages/create/)\n- [YouTube channel list](https://www.youtube.com/channel_switcher)\n- [YouTube feature eligibility](https://support.google.com/youtube/answer/9891124?hl=en)\n- [Get a Pinterest business account](https://help.pinterest.com/en/business/article/get-a-business-account)\n",
|
|
141
|
+
"revision": "4cf588e2af0f2b01"
|
|
136
142
|
},
|
|
137
143
|
{
|
|
138
144
|
"id": "app-store-connect",
|
|
@@ -1358,7 +1364,7 @@ export const GUIDES = [
|
|
|
1358
1364
|
{
|
|
1359
1365
|
"id": "pinterest",
|
|
1360
1366
|
"title": "Pinterest",
|
|
1361
|
-
"summary": "Publish the Pins you select to the right boards, keep their destination links intact and read their results
|
|
1367
|
+
"summary": "Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily results.",
|
|
1362
1368
|
"command": "sprid connect pinterest --account <slug>",
|
|
1363
1369
|
"url": "https://sprid.studio/docs/connect/pinterest",
|
|
1364
1370
|
"sections": [
|
|
@@ -1386,12 +1392,6 @@ export const GUIDES = [
|
|
|
1386
1392
|
"kind": "verify",
|
|
1387
1393
|
"markdown": "Run `sprid status` and confirm Pinterest shows the intended account. Open a draft Pin, select **Publish → Pinterest** and confirm the intended board appears before approving any schedule.\n\nAfter the first Pin publishes, open Pinterest signed out or from another account and check the Pin, board, visual, title and destination. A successful API response does not prove public visibility."
|
|
1388
1394
|
},
|
|
1389
|
-
{
|
|
1390
|
-
"id": "trial-and-standard-access",
|
|
1391
|
-
"title": "Trial and Standard access",
|
|
1392
|
-
"kind": "detail",
|
|
1393
|
-
"markdown": "Public Pins need **Sprid’s** Pinterest app to have Standard access. This is Sprid’s approval, not something you apply for. Until then Sprid runs Pinterest in test mode: you can connect, pick or create boards and publish image Pins, but every Pin and board it creates is visible only to you, on your own profile. Video Pins are not available in test mode. When Standard access arrives, reconnect once: a test-mode connection cannot publish public Pins."
|
|
1394
|
-
},
|
|
1395
1395
|
{
|
|
1396
1396
|
"id": "prepare-the-account",
|
|
1397
1397
|
"title": "Prepare the account",
|
|
@@ -1402,17 +1402,23 @@ export const GUIDES = [
|
|
|
1402
1402
|
"id": "if-it-fails",
|
|
1403
1403
|
"title": "If it fails",
|
|
1404
1404
|
"kind": "troubleshooting",
|
|
1405
|
-
"markdown": "-
|
|
1405
|
+
"markdown": "- **Sprid says Pinterest is unavailable:** Pinterest is not enabled on the Sprid server you are using. Contact [Sprid support](mailto:hello@sprid.studio); you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **“Reconnect Pinterest to publish public Pins”:** the saved connection cannot publish. Reconnect once with the same Pinterest account.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero."
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
"id": "investigate-with-this-connection",
|
|
1409
|
+
"title": "Investigate with this connection",
|
|
1410
|
+
"kind": "detail",
|
|
1411
|
+
"markdown": "Operation `posts`, read from Sprid’s stored Pin publishes and collected daily Pin metrics: impressions, saves, Pin clicks, outbound clicks and video views, summed up to the exclusive end with the days actually observed. There is no `comments` operation: Sprid does not collect Pinterest comments. Compare Pins at equal ages, such as 30, 60 and 90 days. An outbound click is someone leaving Pinterest, not a website session or an install. Live results for one Pin come from `get_pinterest_metrics` (MCP) or `GET /api/metrics/pinterest`.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source pinterest --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries)."
|
|
1406
1412
|
},
|
|
1407
1413
|
{
|
|
1408
1414
|
"id": "sources",
|
|
1409
1415
|
"title": "Sources",
|
|
1410
1416
|
"kind": "sources",
|
|
1411
|
-
"markdown": "- [Pinterest developer
|
|
1417
|
+
"markdown": "- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Pinterest Analytics](https://help.pinterest.com/en/business/article/pinterest-analytics)"
|
|
1412
1418
|
}
|
|
1413
1419
|
],
|
|
1414
|
-
"markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the Pins you select to the right boards, keep their destination links intact and read their results
|
|
1415
|
-
"revision": "
|
|
1420
|
+
"markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily results.\n\n## You need\n\n- A Pinterest account. A free business account is recommended, because Pinterest Analytics requires one.\n- Access to the Sprid account that will own this channel.\n- A board topic in mind. Sprid can create the board during publishing.\n\nYou do **not** create a Pinterest developer app, request API access or copy a token. You only authorize your own Pinterest account.\n\n## Then run\n\n```sh\nsprid connect pinterest --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug, or use **Settings → Accounts → [account] → Pinterest → Continue with Pinterest** in Sprid.\n\n## Click path (the connect)\n\n1. Check the Pinterest identity in the browser that opens. Sign out first if it is the wrong account.\n2. Review the requested access and authorize Sprid.\n3. Back in Sprid, confirm the connected Pinterest name.\n4. Open a post, select **Publish → Pinterest** and pick the destination in the **Board** picker. If none fits, select **Create board**, name it and choose public or secret; Sprid selects it automatically.\n\nEach Sprid account needs its own connect, even when several Pinterest accounts are signed in to the same browser. Check the identity every time.\n\n## Check the connection\n\nRun `sprid status` and confirm Pinterest shows the intended account. Open a draft Pin, select **Publish → Pinterest** and confirm the intended board appears before approving any schedule.\n\nAfter the first Pin publishes, open Pinterest signed out or from another account and check the Pin, board, visual, title and destination. A successful API response does not prove public visibility.\n\n## Prepare the account\n\nCreate a few specific boards around topics people search for, such as “Small apartment viewing checklist” rather than “Inspiration”. Board names, descriptions and saved Pins give Pinterest context.\n\nClaim your website in Pinterest where possible: it links Pins from that site to the profile and expands website analytics. A website can be claimed by only one Pinterest account, so decide which market or brand owns it before connecting several.\n\nRead [Pinterest content and publishing](https://sprid.studio/docs/pinterest) before preparing the first batch.\n\n## If it fails\n\n- **Sprid says Pinterest is unavailable:** Pinterest is not enabled on the Sprid server you are using. Contact [Sprid support](mailto:hello@sprid.studio); you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **“Reconnect Pinterest to publish public Pins”:** the saved connection cannot publish. Reconnect once with the same Pinterest account.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero.\n\n## Investigate with this connection\n\nOperation `posts`, read from Sprid’s stored Pin publishes and collected daily Pin metrics: impressions, saves, Pin clicks, outbound clicks and video views, summed up to the exclusive end with the days actually observed. There is no `comments` operation: Sprid does not collect Pinterest comments. Compare Pins at equal ages, such as 30, 60 and 90 days. An outbound click is someone leaving Pinterest, not a website session or an install. Live results for one Pin come from `get_pinterest_metrics` (MCP) or `GET /api/metrics/pinterest`.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source pinterest --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources\n\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Pinterest Analytics](https://help.pinterest.com/en/business/article/pinterest-analytics)\n",
|
|
1421
|
+
"revision": "7adfb46a92aa6042"
|
|
1416
1422
|
},
|
|
1417
1423
|
{
|
|
1418
1424
|
"id": "meta-ads",
|
|
@@ -1608,8 +1614,8 @@ export const CONTENT_GUIDES = [
|
|
|
1608
1614
|
"title": "Review marketing from connected evidence",
|
|
1609
1615
|
"summary": "Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.",
|
|
1610
1616
|
"url": "https://sprid.studio/docs/marketing-review",
|
|
1611
|
-
"markdown": "# Review marketing from connected evidence\n\n**What this guide does:** Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.\n\n## Start with the question\n\nName the app, the date range and the decision the review should inform.\n\n1. `list_apps` and `list_app_profiles` to find the app.\n2. `get_marketing_review_context` for shared definitions, required investigations, earlier decisions and corrections.\n3. `get_marketing_review` for the app over two comparable periods. The summary links to full evidence per source (`view: \"full\", sources: [<source>]`); omit `sources` to include social history, store reviews and milestones.\n4. Re-read an exact saved packet with `get_marketing_review_evidence` and its `evidence.id` (CLI: `sprid marketing-review evidence --id <id> --app <slug>`). It still works after a connection changes.\n\n`refresh: true` asks for live reads; `cachedOnly: true` reads only stored evidence. Keep returned errors, coverage, population definitions and sample counts in the report.\n\nIn a browser chat there is no repository. For a question about a shipped change, ask for a release summary or public changelog and label it as supplied. Never claim to have read a repository or private database. Local users can add repo diffs and database reads through their own tools.\n\n## Follow the evidence\n\n`list_marketing_queries` lists supported reads and their schemas; `query_marketing_source` runs them with Sprid’s saved credentials. Follow pagination and keep truncation visible. `get_documentation` with `metrics` covers definitions and refused calculations; with `queries`, source-specific reads.\n\n- Keep acquisition apart from activation, and people apart from events. A post impression is not an install; a download is not an activated user.\n- Compare equal windows and comparable populations.\n- No onboarding events means recommending instrumentation, not guessing a funnel.\n- Keep revenue sources separate until the overlap check says they can be added.\n\nFor each finding, test another explanation: tracking changes, traffic exclusions, attribution window, seasonality, a different audience mix. A before/after comparison doesn’t prove cause. If a difference is within normal variation or the sample is small, say so and don’t rank it.\n\n## Report and save\n\nLead with the decision, then the evidence, the limits and what would change the answer. Keep failed reads apart from reads that returned nothing. `next_actions` gives the follow-up. Check [remaining setup](../../../references/setup-continuation.md#keep-setup-current), including the saved app icon, voice and relevant connections. Recommend the next useful integration step without repeating completed or explicitly declined setup. A missing credential goes to the secure App Profile form or a CLI key-file command; no provider MCP is needed for a supported query.\n\n- **Shared context:** when authorized, save non-secret definitions, investigations, decisions, corrections and release references with `save_marketing_review_context`, sending `baseRevision` from the latest read. A conflict returns both versions; reconcile before retrying. Saved context is a claim with references, not verification. CLI: `sprid marketing-review context --app <slug>` reads, `--file <context.json>` imports `{baseRevision,context}`. Private notes stay local.\n- **Changes:** log confirmed product or marketing changes with `add_event`, naming the areas they affect and why.\n- **The review itself:** save it as an app-scoped `kind: \"note\"` event with the date, conclusion, evidence references, coverage and next hypothesis in `meta`. Other clients read it with `list_events`.\n\nNever store secrets or signed attachment URLs in any of these.\n\n## Collection and coverage\n\n- Sprid refreshes two rolling 30-day windows daily for configured services on active plans, and re-reads late provider exports. No model calls, publishing or paid social reads. Other windows are read on demand.\n- Pause with the App Profile’s `metricsCollectionEnabled: false` or `sprid marketing-review collection --enabled false`. Stored evidence stays readable.\n- Snapshots are private to the app, tied to its configuration and kept 90 days. Save local evidence exports for a lasting archive.\n- Missing days stay unknown. Incomplete store or Search Console windows get no percentage change. Never sum daily unique people into a monthly count.\n- `configuration_only` means identifiers and a key are saved, not that access works. Errors return a safe code, the HTTP status when known, whether to retry and the next step. Retry transient errors before recommending a reconnect. Unsupported metrics and provider privacy thresholds are stated as limits.\n",
|
|
1612
|
-
"revision": "
|
|
1617
|
+
"markdown": "# Review marketing from connected evidence\n\n**What this guide does:** Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.\n\n## Start with the question\n\nName the app, the date range and the decision the review should inform.\n\n1. `list_apps` and `list_app_profiles` to find the app.\n2. `get_marketing_review_context` for shared definitions, required investigations, earlier decisions and corrections.\n3. `get_marketing_review` for the app over two comparable periods. The summary links to full evidence per source (`view: \"full\", sources: [<source>]`); omit `sources` to include social history, store reviews and milestones.\n4. Re-read an exact saved packet with `get_marketing_review_evidence` and its `evidence.id` (CLI: `sprid marketing-review evidence --id <id> --app <slug>`). It still works after a connection changes.\n\n`refresh: true` asks for live reads; `cachedOnly: true` reads only stored evidence. Keep returned errors, coverage, population definitions and sample counts in the report.\n\nIn a browser chat there is no repository. For a question about a shipped change, ask for a release summary or public changelog and label it as supplied. Never claim to have read a repository or private database. Local users can add repo diffs and database reads through their own tools.\n\n## Follow the evidence\n\n`list_marketing_queries` lists supported reads and their schemas; `query_marketing_source` runs them with Sprid’s saved credentials. Follow pagination and keep truncation visible. `get_documentation` with `metrics` covers definitions and refused calculations; with `queries`, source-specific reads.\n\n- Keep acquisition apart from activation, and people apart from events. A post impression is not an install; a download is not an activated user.\n- Pinterest Pins resurface for months: compare them at equal ages (30, 60 and 90 days). Saves are interest on Pinterest and outbound clicks are people leaving it; neither is a site session or an install. The `pinterest` source has `posts` only, with no comments. See [Pinterest content and publishing](pinterest.md).\n- Compare equal windows and comparable populations.\n- No onboarding events means recommending instrumentation, not guessing a funnel.\n- Keep revenue sources separate until the overlap check says they can be added.\n\nFor each finding, test another explanation: tracking changes, traffic exclusions, attribution window, seasonality, a different audience mix. A before/after comparison doesn’t prove cause. If a difference is within normal variation or the sample is small, say so and don’t rank it.\n\n## Report and save\n\nLead with the decision, then the evidence, the limits and what would change the answer. Keep failed reads apart from reads that returned nothing. `next_actions` gives the follow-up. Check [remaining setup](../../../references/setup-continuation.md#keep-setup-current), including the saved app icon, voice and relevant connections. Recommend the next useful integration step without repeating completed or explicitly declined setup. A missing credential goes to the secure App Profile form or a CLI key-file command; no provider MCP is needed for a supported query.\n\n- **Shared context:** when authorized, save non-secret definitions, investigations, decisions, corrections and release references with `save_marketing_review_context`, sending `baseRevision` from the latest read. A conflict returns both versions; reconcile before retrying. Saved context is a claim with references, not verification. CLI: `sprid marketing-review context --app <slug>` reads, `--file <context.json>` imports `{baseRevision,context}`. Private notes stay local.\n- **Changes:** log confirmed product or marketing changes with `add_event`, naming the areas they affect and why.\n- **The review itself:** save it as an app-scoped `kind: \"note\"` event with the date, conclusion, evidence references, coverage and next hypothesis in `meta`. Other clients read it with `list_events`.\n\nNever store secrets or signed attachment URLs in any of these.\n\n## Collection and coverage\n\n- Sprid refreshes two rolling 30-day windows daily for configured services on active plans, and re-reads late provider exports. No model calls, publishing or paid social reads. Other windows are read on demand.\n- Pause with the App Profile’s `metricsCollectionEnabled: false` or `sprid marketing-review collection --enabled false`. Stored evidence stays readable.\n- Snapshots are private to the app, tied to its configuration and kept 90 days. Save local evidence exports for a lasting archive.\n- Missing days stay unknown. Incomplete store or Search Console windows get no percentage change. Never sum daily unique people into a monthly count.\n- `configuration_only` means identifiers and a key are saved, not that access works. Errors return a safe code, the HTTP status when known, whether to retry and the next step. Retry transient errors before recommending a reconnect. Unsupported metrics and provider privacy thresholds are stated as limits.\n",
|
|
1618
|
+
"revision": "f4339d5f9f97297b"
|
|
1613
1619
|
},
|
|
1614
1620
|
{
|
|
1615
1621
|
"id": "traffic",
|
|
@@ -1624,16 +1630,16 @@ export const CONTENT_GUIDES = [
|
|
|
1624
1630
|
"title": "Pinterest content and publishing",
|
|
1625
1631
|
"summary": "Plan searchable Pins, approve a useful batch, let Sprid publish the selected Pins on schedule and judge the result by qualified traffic and activation.",
|
|
1626
1632
|
"url": "https://sprid.studio/docs/pinterest",
|
|
1627
|
-
"markdown": "# Pinterest content and publishing\n\n**What this guide does:** Plan searchable Pins, approve a useful batch, let Sprid publish the selected Pins on schedule and judge the result by qualified traffic and activation.\n\nChecked against Pinterest’s guidance on 21 September 2026. Anything labelled a test is a starting hypothesis, not a claim about what will win for your account.\n\n## How Pinterest discovery works\n\nPinterest is visual search and recommendation. A Pin can show up in search, home feeds and related Pins long after it is published, mostly regardless of followers.\n\nRelevance comes from the Pin itself and how people interact with it: clear keywords, original imagery, link quality and board context. Pinterest publishes no weighting. Make the visual, title, description, board and landing page all answer the same “what is this about?”, in the words a person would search, without repeating a keyword unnaturally.\n\nBecause Pins keep getting found, compare them at equal ages and keep older Pins in the analysis.\n\n## Start with the destination\n\nPick the page and the action before making the visual:\n\n1. One useful guide, tool, feature page or store destination.\n2. The concrete question it answers.\n3. One next action that continues the same task, such as opening that feature.\n4. Campaign parameters added without removing existing ones, with a stable Pin or creative ID so visits trace back to the exact Pin.\n\nThe page must deliver the pictured promise immediately and work on a phone. A specific Pin that lands on a generic homepage breaks the promise. A store visit is not an install, and an outbound click is not activation: report each step separately.\n\n## Prepare the website\n\n- **Claim your website** in Pinterest to link the profile to Pins from that domain and get website analytics. A website can be claimed by only one Pinterest account.\n- **Rich Pins:** for articles, products or recipes, add Open Graph or Schema.org metadata. Pinterest syncs it from the page; it is not a Sprid export. Check how it appears before a batch, because editing a Pin by hand can override synced article or recipe details.\n- Make sure Pinterestbot can read the page, links work and it loads fast on mobile.\n\n## Choose topics and boards\n\nBuild the topic list from Pinterest Trends, Pinterest search suggestions, customer language and site queries that already work. Search Console is a useful seed, but Google demand doesn’t prove Pinterest demand.\n\nMix evergreen questions with relevant seasonal moments. Use focused boards whose name and description explain the topic: a Pin about apartment-viewing costs belongs on a board about buying an apartment, not a broad “Ideas” board. Only create a board when the topic can fill it.\n\n## Build the Pin\n\n| | Ratio | Ideal size | Notes |\n|---|---|---|---|\n| Image | 2:3 | 1000 × 1500 px | |\n| Video | 9:16 | 1080 × 1920 px | 4 seconds to 5 minutes, H.264 or H.265 |\n\nSprid publishes one image or one finished video per Pin. It never turns an Instagram carousel into a Pin, so make a dedicated asset and check its crop in the preview.\n\nAt thumbnail size the subject and benefit should still be clear: one focus, strong contrast, short readable overlay copy. Check the real phone preview. The Pin must stand alone; an opener that only makes sense after swiping is incomplete here.\n\nFormats worth testing:\n\n- an editorial cover for a specific guide\n- a reference card with a concrete checklist or answer\n- a real app demo showing one task\n- a coherent visual collection for design, travel or architecture\n- a short captioned video when motion explains the task better\n\nPinterest can label detected or declared AI imagery. Keep provenance, use accurate licensed assets and disclose where required. Never publish an image that misrepresents the linked place, product or result.\n\n## Write the metadata\n\n| Field | Limit | Notes |\n|---|---|---|\n| Title | 100 characters | Put the subject early; feeds may cut it. |\n| Description | 800 characters | Often hidden in feeds, but Pinterest reads it for relevance. |\n| Alt text | 500 characters | Describe what the image shows. Not a keyword field. |\n\nWrite a natural title and description naming the topic, audience or situation and what the destination provides. Choose a relevant board, add a working destination, and set the AI disclosure in Sprid when it applies. No stuffed keyword variants, unrelated trends or promises the page doesn’t keep.\n\nThe visual earns attention, the metadata gives context, the destination completes the task. Keep all three aligned.\n\n## Publish your first Pin\n\nPublishing and scheduling need Sprid’s Pinterest app to have Standard access. With Trial access you can connect, browse boards and prepare drafts, but Sprid refuses to publish or schedule. That access belongs to Sprid’s integration; you don’t apply for a developer app.\n\n1. Connect Pinterest, run `sprid status` and confirm the account.\n2. Create or open a post with exactly one image (ideally 2:3) or one finished video (ideally 9:16).\n3. Open **Publish**, select **Pinterest**, then pick a **Board** and optional section.\n4. Add the title, description, destination, alt text and any AI disclosure.\n5. Check the rendered creative and every public field. Publish now or pick a time.\n6. After delivery, open the Pin signed out or from another account and follow its link.\n\n**CLI:** `sprid pinterest boards --account <slug>` lists board IDs, or says there are none; create one with `sprid pinterest create --account <slug> --name <name> [--privacy public|secret]`. `sprid pinterest set <postId> --board <id> --link <url>` saves the draft (optional metadata flags in `sprid docs cli`); review and schedule it in Sprid. `sprid pinterest metrics <publishId> --account <slug>` reads results.\n\n**MCP:** read `get_documentation` topic `pinterest-content`, pick the exact channel from `list_connections`, then `list_pinterest_boards`. If there are none, say so, and call `create_pinterest_board` only with a name and privacy the user approved. Save `pinterestOptions` with `update_post`, preferring `aspectRatio: \"2:3\"` for a new image Pin. Show the user the rendered Pin or dry-run batch, then call `schedule_post` or commit `schedule_batch` only for the Pins they chose. `get_pinterest_metrics` reads results.\n\n## Choose a cadence to test\n\nStart with **2 original Pins a day** in separate slots. With a broad catalog of useful destinations and genuinely distinct assets, test **5 a day** against that. These are Sprid’s proposed tests, not Pinterest limits or proven optimums. A small destination library needs more useful content, not cosmetic duplicates in the queue.\n\nRotate destinations and topics. Start with two distinct treatments per destination, on different days. No exact spacing or best hour is established.\n\nWhat the evidence says: Pinterest recommends original content at least weekly. [Sarah Hanford](https://www.sarahhanford.com/blog/pinterest-growth-organically-60-days) went from three Pins a day to five alongside keyword research and a new website, so frequency isn’t isolated as the cause. [Tailwind’s benchmark](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-1) of about 1.2 million organic Pins (2024 data) found results concentrated in a small share of Pins; its English-speaking, observational sample can’t set a universal cadence.\n\n## Review and schedule the batch\n\nFor a two-a-day test, choose **Daily** and two times in the batch scheduler, for example **09:00 and 17:00** (convenient, not researched best times). Check the account’s timezone and the actual dates before confirming; they may differ from your device. Keep a cadence the user already chose unless they ask to change it.\n\nOrder Pins so destinations alternate, and check the calendar preview for busy days, gaps and unplaced Pins. Preparing a large batch at once is fine; publication spreads across the schedule.\n\n**MCP:** `schedule_batch` with `cadence: \"daily\"`, `times: [\"09:00\", \"17:00\"]`, the account/channel and the selected post IDs. Run `dryRun: true` first and check `placements`, `unplaced` and `timezone`. Busy days are skipped by default, so read the returned plan instead of promising an end date. Commit only the reviewed Pins and report what was booked. CLI users read this guide with `sprid docs pinterest-content` and the queue with `sprid queue`; place batches in the scheduling screen or over MCP.\n\nBefore approving, check every Pin’s creative, metadata, destination, board and time, plus factual claims, rights and disclosure. Pinterest’s developer guidelines require the user to choose each Pin that publishes: Sprid shows the concrete Pins and schedule and records the selection. Those Pins then publish on time without asking again.\n\n- Moving only the time keeps the reviewed Pin. Changing content, board or link means reviewing and rescheduling it; changed drafts can be blocked at publish until reviewed.\n- After an unclear publish failure, check Pinterest before retrying; the Pin may already exist.\n- After the first Pin publishes, check it and its link from outside the connected account. A draft, preview or accepted schedule is not delivery.\n\n## Measure what happened\n\n- **Impressions:** times the Pin was on screen.\n- **Saves:** times people saved it to a board.\n- **Pin clicks:** opens of the Pin in close-up.\n- **Outbound clicks:** clicks through to a destination outside Pinterest.\n- **Video views:** at least 2 seconds with at least 50% of the video in view.\n\nFor app growth the path is **impression → outbound click → qualified visit → app action → activation**. Saves are a diagnostic, not the goal: a Pin with many saves and few outbound clicks may be a good reference on Pinterest and bring little acquisition.\n\nCompare Pins of similar age within the same topic, language and market. Judge creative by outbound-click rate and qualified visits, the whole path by activations per Pin and revenue where available. Pinterest’s outbound clicks and your own site sessions are measured differently; show both rather than forcing them to match.\n\nReview cohorts at 30, 60 and 90 days of Pin age. Report counts and coverage, including results with the top Pin removed, so one outlier can’t set the strategy. Keep a higher frequency while extra Pins keep adding qualified visits or activations; a lower click rate alone doesn’t cancel growth in total qualified traffic.\n\n- Visits but no activation: check page speed, message match and the next app action before making more Pins.\n- Low impressions: check topic relevance, board, metadata and visual clarity.\n- Negligible qualified traffic and activation across repeated relevant cohorts: stop scaling Pinterest for that app.\n\n## Improve the next batch\n\nChange one meaningful thing per comparison (visual treatment, question, format or destination angle) and repeat across comparable destinations. Organic distribution isn’t randomized, so call it a directional test, not an A/B test.\n\nMake fresh, useful treatments, not cosmetic duplicates. [Tailwind’s freshness study](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-three) found new images for an existing URL kept distribution better than reusing the same image and URL (an association, not a guaranteed lift). Pinterest advises against re-uploading the same Pin, and repetitive or irrelevant commercial content can be treated as spam. Space treatments of the same destination apart and keep every Pin on a relevant board.\n\nFor a worked example, [Elaine Timms’s recipe-client case](https://elainetimms.com/evergreen-growth-how-food-creator-pins-for-profit/) combines search-specific overlays, creative variants and email opt-ins. Its growth can’t be credited to cadence alone.\n\n## Sources\n\n- [How Pinterest discovery works](https://create.pinterest.com/blog/how-to-increase-discoverability-seo-pinterest/)\n- [Growing through search, boards and keywords](https://create.pinterest.com/blog/best-ways-to-grow-on-pinterest/)\n- [Pin specifications](https://help.pinterest.com/en/article/review-pin-specs)\n- [Pin performance and distribution](https://help.pinterest.com/en/business/article/pin-performance-and-distribution)\n- [Pinterest Analytics definitions](https://help.pinterest.com/en/business/article/pinterest-analytics)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Pinterest API schema](https://github.com/pinterest/api-description/blob/main/v5/openapi.json)\n- [Pinterest community guidelines](https://policy.pinterest.com/en/community-guidelines)\n- [Pinterest labels for AI-generated or modified images](https://help.pinterest.com/en/article/gen-ai-labels)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Rich Pins](https://help.pinterest.com/en-gb/business/article/rich-pins)\n",
|
|
1628
|
-
"revision": "
|
|
1633
|
+
"markdown": "# Pinterest content and publishing\n\n**What this guide does:** Plan searchable Pins, approve a useful batch, let Sprid publish the selected Pins on schedule and judge the result by qualified traffic and activation.\n\nChecked against Pinterest’s guidance on 21 September 2026. Anything labelled a test is a starting hypothesis, not a claim about what will win for your account.\n\n## How Pinterest discovery works\n\nPinterest is visual search and recommendation. A Pin can show up in search, home feeds and related Pins long after it is published, mostly regardless of followers.\n\nRelevance comes from the Pin itself and how people interact with it: clear keywords, original imagery, link quality and board context. Pinterest publishes no weighting. Make the visual, title, description, board and landing page all answer the same “what is this about?”, in the words a person would search, without repeating a keyword unnaturally.\n\nBecause Pins keep getting found, compare them at equal ages and keep older Pins in the analysis.\n\n## Start with the destination\n\nPick the page and the action before making the visual:\n\n1. One useful guide, tool, feature page or store destination.\n2. The concrete question it answers.\n3. One next action that continues the same task, such as opening that feature.\n4. The plain destination URL. When it publishes, Sprid adds `utm_source=pinterest` and the Pin’s publish ID, so visits trace back to the exact Pin. A link that already carries its own `utm_source` goes out as written and loses that per-Pin tagging; add your own only for your own campaign names.\n\nThe page must deliver the pictured promise immediately and work on a phone. A specific Pin that lands on a generic homepage breaks the promise. A store visit is not an install, and an outbound click is not activation: report each step separately.\n\n## Prepare the website\n\n- **Claim your website** in Pinterest to link the profile to Pins from that domain and get website analytics. A website can be claimed by only one Pinterest account.\n- **Rich Pins:** for articles, products or recipes, add Open Graph or Schema.org metadata. Pinterest syncs it from the page; it is not a Sprid export. Check how it appears before a batch, because editing a Pin by hand can override synced article or recipe details.\n- Make sure Pinterestbot can read the page, links work and it loads fast on mobile.\n\n## Choose topics and boards\n\nBuild the topic list from Pinterest Trends, Pinterest search suggestions, customer language and site queries that already work. Search Console is a useful seed, but Google demand doesn’t prove Pinterest demand.\n\nMix evergreen questions with relevant seasonal moments. Use focused boards whose name and description explain the topic: a Pin about apartment-viewing costs belongs on a board about buying an apartment, not a broad “Ideas” board. Only create a board when the topic can fill it.\n\n## Build the Pin\n\n| | Ratio | Ideal size | Notes |\n|---|---|---|---|\n| Image | 2:3 | 1000 × 1500 px | |\n| Video | 9:16 | 1080 × 1920 px | 4 seconds to 5 minutes, H.264 or H.265 |\n\nSprid publishes one image or one finished video per Pin. It never turns an Instagram carousel into a Pin, so make a dedicated asset and check its crop in the preview.\n\nAt thumbnail size the subject and benefit should still be clear: one focus, strong contrast, short readable overlay copy. Check the real phone preview. The Pin must stand alone; an opener that only makes sense after swiping is incomplete here.\n\nFormats worth testing:\n\n- an editorial cover for a specific guide\n- a reference card with a concrete checklist or answer\n- a real app demo showing one task\n- a coherent visual collection for design, travel or architecture\n- a short captioned video when motion explains the task better\n\nPinterest can label detected or declared AI imagery. Keep provenance, use accurate licensed assets and disclose where required. Never publish an image that misrepresents the linked place, product or result.\n\n## Write the metadata\n\n| Field | Limit | Notes |\n|---|---|---|\n| Title | 100 characters | Put the subject early; feeds may cut it. |\n| Description | 800 characters | Often hidden in feeds, but Pinterest reads it for relevance. |\n| Alt text | 500 characters | Describe what the image shows. Not a keyword field. |\n\nWrite a natural title and description naming the topic, audience or situation and what the destination provides. Choose a relevant board, add a working destination, and set the AI disclosure in Sprid when it applies. No stuffed keyword variants, unrelated trends or promises the page doesn’t keep.\n\nThe visual earns attention, the metadata gives context, the destination completes the task. Keep all three aligned.\n\n## Publish your first Pin\n\n1. Connect Pinterest, run `sprid status` and confirm the account.\n2. Create or open a post with exactly one image (ideally 2:3) or one finished video (ideally 9:16).\n3. Open **Publish**, select **Pinterest**, then pick a **Board** and optional section.\n4. Add the title, description, destination, alt text and any AI disclosure.\n5. Check the rendered creative and every public field. Publish now or pick a time.\n6. After delivery, open the Pin signed out or from another account and follow its link.\n\n**CLI:** `sprid pinterest boards --account <slug>` lists board IDs, or says there are none; create one with `sprid pinterest create --account <slug> --name <name> [--privacy public|secret]`. `sprid pinterest set <postId> --board <id> --link <url>` saves the draft (optional metadata flags in `sprid docs cli`); review and schedule it in Sprid. `sprid pinterest metrics <publishId> --account <slug>` reads results.\n\n**MCP:** read `get_documentation` topic `pinterest-content`, pick the exact channel from `list_connections`, then `list_pinterest_boards`. If there are none, say so, and call `create_pinterest_board` only with a name and privacy the user approved. Save `pinterestOptions` with `update_post`, preferring `aspectRatio: \"2:3\"` for a new image Pin. Show the user the rendered Pin or dry-run batch, then call `schedule_post` or commit `schedule_batch` only for the Pins they chose. `get_pinterest_metrics` reads results.\n\n## Choose a cadence to test\n\nStart with **2 original Pins a day** in separate slots. With a broad catalog of useful destinations and genuinely distinct assets, test **5 a day** against that. These are Sprid’s proposed tests, not Pinterest limits or proven optimums. A small destination library needs more useful content, not cosmetic duplicates in the queue.\n\nRotate destinations and topics. Start with two distinct treatments per destination, on different days. No exact spacing or best hour is established.\n\nWhat the evidence says: Pinterest recommends original content at least weekly. [Sarah Hanford](https://www.sarahhanford.com/blog/pinterest-growth-organically-60-days) went from three Pins a day to five alongside keyword research and a new website, so frequency isn’t isolated as the cause. [Tailwind’s benchmark](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-1) of about 1.2 million organic Pins (2024 data) found results concentrated in a small share of Pins; its English-speaking, observational sample can’t set a universal cadence.\n\n## Review and schedule the batch\n\nFor a two-a-day test, choose **Daily** and two times in the batch scheduler, for example **09:00 and 17:00** (convenient, not researched best times). Check the account’s timezone and the actual dates before confirming; they may differ from your device. Keep a cadence the user already chose unless they ask to change it.\n\nOrder Pins so destinations alternate, and check the calendar preview for busy days, gaps and unplaced Pins. Preparing a large batch at once is fine; publication spreads across the schedule.\n\n**MCP:** `schedule_batch` with `cadence: \"daily\"`, `times: [\"09:00\", \"17:00\"]`, the account/channel and the selected post IDs. Run `dryRun: true` first and check `placements`, `unplaced` and `timezone`. Busy days are skipped by default, so read the returned plan instead of promising an end date. Commit only the reviewed Pins and report what was booked. CLI users read this guide with `sprid docs pinterest-content` and the queue with `sprid queue`; place batches in the scheduling screen or over MCP.\n\nBefore approving, check every Pin’s creative, metadata, destination, board and time, plus factual claims, rights and disclosure. Pinterest’s developer guidelines require the user to choose each Pin that publishes: Sprid shows the concrete Pins and schedule and records the selection. Those Pins then publish on time without asking again.\n\n- Moving only the time keeps the reviewed Pin. Changing content, board or link means reviewing and rescheduling it; changed drafts can be blocked at publish until reviewed.\n- After an unclear publish failure, check Pinterest before retrying; the Pin may already exist.\n- After the first Pin publishes, check it and its link from outside the connected account. A draft, preview or accepted schedule is not delivery.\n\n## Measure what happened\n\n- **Impressions:** times the Pin was on screen.\n- **Saves:** times people saved it to a board.\n- **Pin clicks:** opens of the Pin in close-up.\n- **Outbound clicks:** clicks through to a destination outside Pinterest.\n- **Video views:** at least 2 seconds with at least 50% of the video in view.\n\nEach is dated daily activity, kept as its own number. Pinterest reports no likes, comments or shares here, and Sprid does not collect Pin comments, so there is nothing for a Pin in the Inbox.\n\nFor app growth the path is **impression → outbound click → qualified visit → app action → activation**. Saves are a diagnostic, not the goal: a Pin with many saves and few outbound clicks may be a good reference on Pinterest and bring little acquisition.\n\nCompare Pins of similar age within the same topic, language and market. Judge creative by outbound-click rate and qualified visits, the whole path by activations per Pin and revenue where available. Pinterest’s outbound clicks and your own site sessions are measured differently; show both rather than forcing them to match.\n\nReview cohorts at 30, 60 and 90 days of Pin age. Report counts and coverage, including results with the top Pin removed, so one outlier can’t set the strategy. Keep a higher frequency while extra Pins keep adding qualified visits or activations; a lower click rate alone doesn’t cancel growth in total qualified traffic.\n\n- Visits but no activation: check page speed, message match and the next app action before making more Pins.\n- Low impressions: check topic relevance, board, metadata and visual clarity.\n- Negligible qualified traffic and activation across repeated relevant cohorts: stop scaling Pinterest for that app.\n\n## Improve the next batch\n\nChange one meaningful thing per comparison (visual treatment, question, format or destination angle) and repeat across comparable destinations. Organic distribution isn’t randomized, so call it a directional test, not an A/B test.\n\nMake fresh, useful treatments, not cosmetic duplicates. [Tailwind’s freshness study](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-three) found new images for an existing URL kept distribution better than reusing the same image and URL (an association, not a guaranteed lift). Pinterest advises against re-uploading the same Pin, and repetitive or irrelevant commercial content can be treated as spam. Space treatments of the same destination apart and keep every Pin on a relevant board.\n\nFor a worked example, [Elaine Timms’s recipe-client case](https://elainetimms.com/evergreen-growth-how-food-creator-pins-for-profit/) combines search-specific overlays, creative variants and email opt-ins. Its growth can’t be credited to cadence alone.\n\n## Sources\n\n- [How Pinterest discovery works](https://create.pinterest.com/blog/how-to-increase-discoverability-seo-pinterest/)\n- [Growing through search, boards and keywords](https://create.pinterest.com/blog/best-ways-to-grow-on-pinterest/)\n- [Pin specifications](https://help.pinterest.com/en/article/review-pin-specs)\n- [Pin performance and distribution](https://help.pinterest.com/en/business/article/pin-performance-and-distribution)\n- [Pinterest Analytics definitions](https://help.pinterest.com/en/business/article/pinterest-analytics)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Pinterest API schema](https://github.com/pinterest/api-description/blob/main/v5/openapi.json)\n- [Pinterest community guidelines](https://policy.pinterest.com/en/community-guidelines)\n- [Pinterest labels for AI-generated or modified images](https://help.pinterest.com/en/article/gen-ai-labels)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Rich Pins](https://help.pinterest.com/en-gb/business/article/rich-pins)\n",
|
|
1634
|
+
"revision": "39dac00685dfe5f8"
|
|
1629
1635
|
},
|
|
1630
1636
|
{
|
|
1631
1637
|
"id": "distribution",
|
|
1632
1638
|
"title": "Why a post travels",
|
|
1633
|
-
"summary": "Explains what Instagram and TikTok actually reward, and turns each mechanic into a rule you can build a post against. Read it before deciding a format, a slide count or a caption length, and when a post that looked good got no reach.",
|
|
1639
|
+
"summary": "Explains what Instagram and TikTok actually reward, and turns each mechanic into a rule you can build a post against. Read it before deciding a format, a slide count or a caption length, and when a post that looked good got no reach. Pinterest works on search and has its own guide, Pinterest content and publishing.",
|
|
1634
1640
|
"url": "https://sprid.studio/docs/distribution",
|
|
1635
|
-
"markdown": "# Why a post travels\n\n**What this guide does:** Explains what Instagram and TikTok actually reward,\nand turns each mechanic into a rule you can build a post against. Read it before\ndeciding a format, a slide count or a caption length, and when a post that\nlooked good got no reach.\n\nWhether a post is *good* and whether it *travels* are two different questions.\nThis one is about the second. Every rule here exists because of a specific\nmechanic, and if the mechanic changes the rule goes with it, so each one carries\nits reason.\n\n**Verified against published sources 2026-08-04.** Platform mechanics decay.\nTreat anything here as a claim about a moving system, re-check it quarterly, and\nprefer your own numbers the moment you have them.\n\n## Instagram\n\n**The three named ranking signals are watch time, sends per reach, and likes per\nreach.** Sends are the heaviest, reported at roughly three to five times the\nweight of a like. Sends per reach is specifically the signal that reaches people\nwho do not follow you, which is the only reach a new account can grow on.\n\n**Feed, Reels, Stories and Explore rank separately** and weight those signals\ndifferently. Feed leans on how close you already are to the viewer; Explore on\nengagement velocity and interest match. A post that does well with your existing\nfollowers is not automatically a post that travels.\n\n**Why carousels, specifically:**\n\n- Every swipe is engagement, and dwell time accumulates across the slides.\n Carousels are reported at two to three times the reach of a single image for\n the same content.\n- **The platform re-serves a carousel to people who did not swipe, starting from\n the second slide.** This is the most actionable mechanic available: slide two\n gets an independent second chance to be someone's first impression.\n- Carousels out-save single images by a wide margin, and saves compound, because\n saved posts get resurfaced.\n- Completion - how many people reach the last slide - is what pushes a post out\n of your followers and into Explore.\n\n**Hashtags do not drive distribution.** The platform's own position is that they\ncategorise rather than distribute. Discovery comes from the words in your\ncaption: captions, alt text, bios and on-screen text are indexed and served into\nin-app search. Keyword-rich captions have been measured at around 30% more reach\nthan hashtag-heavy ones. Keep three to five hashtags as labels and spend the\neffort on the prose.\n\n## TikTok, photo mode\n\n- Ranking is swipe-through rate, dwell time and reverse swipes, with completion\n rate as the primary signal.\n- Distribution starts with a **small test batch of roughly 200-500 viewers**,\n mostly followers and people who engage with adjacent content. What that batch\n does decides everything afterwards. This is why the first hour matters, and\n why a weak second slide can end the post’s reach.\n- Saves are weighted and photo posts save well. A carousel with fewer views but\n a high save-and-comment share is doing better than a higher-view post that\n bounces on slide one.\n- Interest-based distribution means an outlier is possible from your first post.\n TikTok is spikier; Instagram grinds.\n\n## What follows for how you build a post\n\n| Rule | Because |\n|---|---|\n| **Slide 2 is a second hook.** It delivers on slide 1 *and* opens a new thread, and it has to work cold | the platform re-serves from slide 2; TikTok's test batch dies there |\n| **Seven slides for a narrative format** (hook, five body, closer) | seven to ten is the reported dwell-time sweet spot; under five reads as a short post, over ten causes mid-carousel fatigue |\n| **No slide may be skippable.** If a body slide can be removed without breaking the post, you wrote a list, and people can stop anywhere in a list | completion is the ranking signal, and a list lets people stop anywhere |\n| **The peak lands in the last third** | a middle peak makes the tail a letdown, and the tail is where completion is won |\n| **Design for sends.** At least one slide should make someone think of a specific person | sends per reach is the non-follower signal, worth several likes |\n| **The send prompt lives in the caption**, naming one kind of person, never \"share if you relate\" | it belongs where sends are earned, and it keeps the last slide screenshottable |\n| **At least two slides hand over something usable** | recognition earns likes; recognition plus something doable earns saves, and saves compound |\n| **Captions carry the audience's own search phrases, in prose** | captions are indexed; hashtags are not distribution |\n| **Caption length 400-600 characters on Instagram, 150-300 on TikTok**, first line an independent hook under 125 characters | that is where the \"more\" cut falls; past roughly 300 characters TikTok needs a tap and reach drops |\n| **Three to five hashtags, never the generic feed tag** | labels, not reach |\n| **One canvas at 4:5, content clear of the top and bottom edges** | survives the 3:4 grid crop, letterboxes acceptably on TikTok |\n\n## Cadence, and the first hour\n\n**Three to five posts a week, sustained.** Three a week for twelve weeks beats\nseven a week for three. The failure mode is not low quality, it is stopping - and\nthe documented version of it is 34 drafts and 3 published posts.\n\n**The first hour is the test batch.** Being there to answer early comments is the\ncheapest intervention available on either platform; a comment answered in the\nfirst hour is worth more than the same reply a day later.\n\n**Post at a fixed time** your audience is awake for, set once on the account's\nposting schedule rather than decided per post.\n\n**Expect silence for two months.** A new account with no face takes three to six\nmonths to reach a thousand followers on Instagram. Distribution is a power law:\na few posts carry most of the reach. Judging before 90 days is judging noise.\n\n## When something works, fan it out\n\nWhen a post breaks out, the work starts: more like it, and the best one promoted.\n\n1. Make five to ten variants of the winner with the structure fixed and **exactly\n one variable changed** - the hook phrasing, the register, the opening image.\n2. One variable per variant, or the result teaches nothing.\n3. Log which variant won *and* which source line its hook came from. That tells\n you which well to keep digging.\n4. A losing variant tells you something. Stop it and write down what.\n\n## What is worth not believing\n\nEverything above is published guidance and platform statements, not our\nmeasurements. Before you treat any of it as settled for **your** audience, these\nare the clean one-variable tests: caption length judged on saves and sends per\nreach; a quiet text card against a photo behind text; a native 9:16 crop against\na letterboxed 4:5; whether a screenshot of your app costs reach or buys installs.\n\nLog the real numbers per post at seven days - views, completion, saves, sends,\ncomments, profile taps. The moment you have your own numbers they outrank every\nsource below.\n\n## Sources\n\n- [Instagram algorithm ranking signals (Buffer)](https://buffer.com/resources/instagram-algorithms/) - watch time, sends per reach, likes per reach, per-surface ranking\n- [The ranking signals that matter (Clixie)](https://www.clixie.ai/blog/instagram-algorithm) - sends weighted three to five times a like\n- [How carousels beat Reels for engagement (Storrito)](https://storrito.com/resources/how-instagram-carousels-beat-reels-for-engagement-in-2026-and-when-to-use-each/) - re-serving from slide 2, reach multiple\n- [Carousel best practices (Adpicto)](https://www.adpicto.com/en/blog/instagram-carousel-best-practices-2026) - slide count, dwell time, saves\n- [Carousel algorithm (TryMyPost)](https://www.trymypost.com/blog/instagram-carousel-algorithm-strategy-2026) - dwell time and completion\n- [Do hashtags still work (Kontentino)](https://www.kontentino.com/q-and-a/instagram-hashtags-reach/) - hashtags do not drive reach\n- [Keywords versus hashtags (Dive Media)](https://www.divemedia.com.au/marketing-tips-and-insights/social-seo-keywords-vs-hashtags) - caption indexing and the reach lift\n- [TikTok photo mode algorithm (ReelBase)](https://reelbase.io/blog/tiktok-photo-mode-algorithm-explained) - photo mode reach against video\n- [TikTok carousel algorithm (PostWaffle)](https://www.postwaffle.com/blog/tiktok-carousel-algorithm) - swipe-through, reverse swipes, test batch size, saves\n",
|
|
1636
|
-
"revision": "
|
|
1641
|
+
"markdown": "# Why a post travels\n\n**What this guide does:** Explains what Instagram and TikTok actually reward,\nand turns each mechanic into a rule you can build a post against. Read it before\ndeciding a format, a slide count or a caption length, and when a post that\nlooked good got no reach. Pinterest works on search and has its own guide,\n[Pinterest content and publishing](pinterest.md).\n\nWhether a post is *good* and whether it *travels* are two different questions.\nThis one is about the second. Every rule here exists because of a specific\nmechanic, and if the mechanic changes the rule goes with it, so each one carries\nits reason.\n\n**Verified against published sources 2026-08-04.** Platform mechanics decay.\nTreat anything here as a claim about a moving system, re-check it quarterly, and\nprefer your own numbers the moment you have them.\n\n## Instagram\n\n**The three named ranking signals are watch time, sends per reach, and likes per\nreach.** Sends are the heaviest, reported at roughly three to five times the\nweight of a like. Sends per reach is specifically the signal that reaches people\nwho do not follow you, which is the only reach a new account can grow on.\n\n**Feed, Reels, Stories and Explore rank separately** and weight those signals\ndifferently. Feed leans on how close you already are to the viewer; Explore on\nengagement velocity and interest match. A post that does well with your existing\nfollowers is not automatically a post that travels.\n\n**Why carousels, specifically:**\n\n- Every swipe is engagement, and dwell time accumulates across the slides.\n Carousels are reported at two to three times the reach of a single image for\n the same content.\n- **The platform re-serves a carousel to people who did not swipe, starting from\n the second slide.** This is the most actionable mechanic available: slide two\n gets an independent second chance to be someone's first impression.\n- Carousels out-save single images by a wide margin, and saves compound, because\n saved posts get resurfaced.\n- Completion - how many people reach the last slide - is what pushes a post out\n of your followers and into Explore.\n\n**Hashtags do not drive distribution.** The platform's own position is that they\ncategorise rather than distribute. Discovery comes from the words in your\ncaption: captions, alt text, bios and on-screen text are indexed and served into\nin-app search. Keyword-rich captions have been measured at around 30% more reach\nthan hashtag-heavy ones. Keep three to five hashtags as labels and spend the\neffort on the prose.\n\n## TikTok, photo mode\n\n- Ranking is swipe-through rate, dwell time and reverse swipes, with completion\n rate as the primary signal.\n- Distribution starts with a **small test batch of roughly 200-500 viewers**,\n mostly followers and people who engage with adjacent content. What that batch\n does decides everything afterwards. This is why the first hour matters, and\n why a weak second slide can end the post’s reach.\n- Saves are weighted and photo posts save well. A carousel with fewer views but\n a high save-and-comment share is doing better than a higher-view post that\n bounces on slide one.\n- Interest-based distribution means an outlier is possible from your first post.\n TikTok is spikier; Instagram grinds.\n\n## Pinterest\n\nThe rules below are for Instagram and TikTok and do not apply to a Pin.\nPinterest is visual search: a Pin is found through search, home feeds and\nrelated Pins for months, mostly regardless of followers, so there is no\nfirst-hour test batch and no second slide. Relevance comes from the image, the\ntitle and description, the board and the page the link opens. Judge Pins at\nequal ages (30, 60 and 90 days) by outbound clicks and what they lead to on\nyour site.\n\n## What follows for how you build a post\n\n| Rule | Because |\n|---|---|\n| **Slide 2 is a second hook.** It delivers on slide 1 *and* opens a new thread, and it has to work cold | the platform re-serves from slide 2; TikTok's test batch dies there |\n| **Seven slides for a narrative format** (hook, five body, closer) | seven to ten is the reported dwell-time sweet spot; under five reads as a short post, over ten causes mid-carousel fatigue |\n| **No slide may be skippable.** If a body slide can be removed without breaking the post, you wrote a list, and people can stop anywhere in a list | completion is the ranking signal, and a list lets people stop anywhere |\n| **The peak lands in the last third** | a middle peak makes the tail a letdown, and the tail is where completion is won |\n| **Design for sends.** At least one slide should make someone think of a specific person | sends per reach is the non-follower signal, worth several likes |\n| **The send prompt lives in the caption**, naming one kind of person, never \"share if you relate\" | it belongs where sends are earned, and it keeps the last slide screenshottable |\n| **At least two slides hand over something usable** | recognition earns likes; recognition plus something doable earns saves, and saves compound |\n| **Captions carry the audience's own search phrases, in prose** | captions are indexed; hashtags are not distribution |\n| **Caption length 400-600 characters on Instagram, 150-300 on TikTok**, first line an independent hook under 125 characters | that is where the \"more\" cut falls; past roughly 300 characters TikTok needs a tap and reach drops |\n| **Three to five hashtags, never the generic feed tag** | labels, not reach |\n| **One canvas at 4:5, content clear of the top and bottom edges** | survives the 3:4 grid crop, letterboxes acceptably on TikTok |\n\n## Cadence, and the first hour\n\n**Three to five posts a week, sustained.** Three a week for twelve weeks beats\nseven a week for three. The failure mode is not low quality, it is stopping - and\nthe documented version of it is 34 drafts and 3 published posts.\n\n**The first hour is the test batch.** Being there to answer early comments is the\ncheapest intervention available on either platform; a comment answered in the\nfirst hour is worth more than the same reply a day later.\n\n**Post at a fixed time** your audience is awake for, set once on the account's\nposting schedule rather than decided per post.\n\n**Expect silence for two months.** A new account with no face takes three to six\nmonths to reach a thousand followers on Instagram. Distribution is a power law:\na few posts carry most of the reach. Judging before 90 days is judging noise.\n\n## When something works, fan it out\n\nWhen a post breaks out, the work starts: more like it, and the best one promoted.\n\n1. Make five to ten variants of the winner with the structure fixed and **exactly\n one variable changed** - the hook phrasing, the register, the opening image.\n2. One variable per variant, or the result teaches nothing.\n3. Log which variant won *and* which source line its hook came from. That tells\n you which well to keep digging.\n4. A losing variant tells you something. Stop it and write down what.\n\n## What is worth not believing\n\nEverything above is published guidance and platform statements, not our\nmeasurements. Before you treat any of it as settled for **your** audience, these\nare the clean one-variable tests: caption length judged on saves and sends per\nreach; a quiet text card against a photo behind text; a native 9:16 crop against\na letterboxed 4:5; whether a screenshot of your app costs reach or buys installs.\n\nLog the real numbers per post at seven days - views, completion, saves, sends,\ncomments, profile taps. The moment you have your own numbers they outrank every\nsource below.\n\n## Sources\n\n- [Instagram algorithm ranking signals (Buffer)](https://buffer.com/resources/instagram-algorithms/) - watch time, sends per reach, likes per reach, per-surface ranking\n- [The ranking signals that matter (Clixie)](https://www.clixie.ai/blog/instagram-algorithm) - sends weighted three to five times a like\n- [How carousels beat Reels for engagement (Storrito)](https://storrito.com/resources/how-instagram-carousels-beat-reels-for-engagement-in-2026-and-when-to-use-each/) - re-serving from slide 2, reach multiple\n- [Carousel best practices (Adpicto)](https://www.adpicto.com/en/blog/instagram-carousel-best-practices-2026) - slide count, dwell time, saves\n- [Carousel algorithm (TryMyPost)](https://www.trymypost.com/blog/instagram-carousel-algorithm-strategy-2026) - dwell time and completion\n- [Do hashtags still work (Kontentino)](https://www.kontentino.com/q-and-a/instagram-hashtags-reach/) - hashtags do not drive reach\n- [Keywords versus hashtags (Dive Media)](https://www.divemedia.com.au/marketing-tips-and-insights/social-seo-keywords-vs-hashtags) - caption indexing and the reach lift\n- [TikTok photo mode algorithm (ReelBase)](https://reelbase.io/blog/tiktok-photo-mode-algorithm-explained) - photo mode reach against video\n- [TikTok carousel algorithm (PostWaffle)](https://www.postwaffle.com/blog/tiktok-carousel-algorithm) - swipe-through, reverse swipes, test batch size, saves\n",
|
|
1642
|
+
"revision": "1fdc948323095b06"
|
|
1637
1643
|
},
|
|
1638
1644
|
{
|
|
1639
1645
|
"id": "ads",
|
|
@@ -1648,8 +1654,8 @@ export const CONTENT_GUIDES = [
|
|
|
1648
1654
|
"title": "The first seconds of a reel",
|
|
1649
1655
|
"summary": "Explains why a vertical video is watched or skipped in its opening seconds, and gives the timing arithmetic to build one that gets past it. Read it before writing a hook, choosing a length, or diagnosing a reel that got no views.",
|
|
1650
1656
|
"url": "https://sprid.studio/docs/reel-first-seconds",
|
|
1651
|
-
"markdown": "# The first seconds of a reel\n\n**What this guide does:** Explains why a vertical video is watched or skipped in\nits opening seconds, and gives the timing arithmetic to build one that gets past\nit. Read it before writing a hook, choosing a length, or diagnosing a reel that\ngot no views.\n\nBoth platforms decide a video's reach from a small first batch of viewers and\nfrom what each of them does in the first seconds. Nothing after second three\nmatters to someone who left at second two. Everything below follows from that.\n\nEach claim carries how we know it: **measured** (we ran it and wrote the number\ndown), **reproduced** (we made the mistake and watched it happen), **published**\n(the platform or a cited source says so, we have not tested it).\n\n## Frame 0 is the cover, so it must already be readable\n\n**Reproduced, twice, on two different accounts.** The feed shows the video's\nfirst frame as its poster image. A hook that fades in from black has a black\ncover. A hook that animates up from 0.42 opacity reads as washed-out grey for\nthe first half second - which is inside the window that decides whether anyone\nstays.\n\nPlace the first card. Do not animate it in. Light it at 0.7 opacity or more, and\nmake it true at t=0.\n\n## The opener has to end before second three\n\n**Reproduced.** A hook card held for 3.3 seconds puts the first cut at 3.1\nseconds, so the entire skip window contains one motionless card. Nothing has\nhappened yet when the viewer decides.\n\nSize the opener to its own reading load with a floor of 1.6 seconds, and check\nthat the first cut lands under three. A cut inside the window is the cheapest\nsignal you have that this video is going somewhere.\n\n## Length: one measured result, and its confound\n\n**Measured, on one of our own accounts, 2026-08-29.** A batch cut to 15.5\nseconds drew 113-162 views on an account with a single follower. The 29-32\nsecond cuts that followed on the same account drew 6-22, and one sat at 6 views\nafter 29 hours - a distribution collapse rather than a slow start.\n\nState the confound honestly: three things changed in that batch at once\n(background, word density, length). Length is the reversible one, not the proven\none. That account now runs a 10-13 second test. **Neither band is a benchmark**,\nand anyone quoting 15.5 seconds as an optimum - including us - is over-reading a\nsingle comparison.\n\n## The timing arithmetic\n\nReading load, not taste. These are the holds that stop a card from reading faster\nthan it is shown.\n\n| | hold | why |\n|---|---|---|\n| any word | 300 ms | silent reading speed on a phone |\n| any character | 52 ms | a compound noun is one word and twenty letters, so take `max(words, chars)` |\n| opener | 1.6-2.8 s | reading load + 0.5 s, and the first cut must land under 3 s |\n| body or turn | 2.4-4.2 s | reading load + 0.7 s |\n| a big number | 2.6-3.8 s | the number reads at a glance, its label does not |\n| closer | 3.6-5.0 s | reading load + 1.3 s; this is the card people screenshot |\n| overlap | 200 ms | each card starts before the previous one leaves |\n\nWord budgets per card, never totalled: hook 3-11, body 3-14, closer 4-16. A\n3.5-second card holds roughly 10 words or 55 characters at a comfortable size.\nPast that the card reads faster than it holds, and the viewer waits - which is\nthe same experience as being bored.\n\n## What a hook is, and is not\n\n- A sentence your buyer has actually thought, or the sharpest fact stated flat.\n A scene or a number. It opens a loop the closer shuts.\n- **Not a \"how to\".** That creates an expectation of information, and information\n can be postponed. Not a question. Not \"did you know\".\n- **Recognition stops a scroll; tempo does not.** A harvested ten-word hook beat\n an invented four-word one on our own account (**reproduced**). Hooks come from\n what people actually said - forum threads, reviews, support mail - not from the\n bank of phrases that sound like hooks.\n- **The second card is a second hook.** It has to work cold, with the first card\n gone. On carousels the platform literally re-serves from the second slide; on\n video, the first cut is the same second chance.\n- The closer is flat and screenshottable, and it lets the product do the work\n rather than issuing an install instruction.\n\n## The kill rule\n\n**Published**, from platform averages rather than our own numbers: under 25% of\nviewers still watching past three seconds, keep the body and replace the hook.\nAround 30% is healthy and 40% is exceptional on TikTok.\n\nTwo guards on that rule. Never judge a format before ten posts, and never judge\na new account before 90 days - distribution is a power law, and a handful of\nposts carry most of the reach. Reading the first three posts of a new account\nis reading noise.\n\n## Where that number actually lives\n\nThis matters more than it sounds, because the metric the kill rule needs is the\none hardest to get:\n\n- Views, likes, comments, shares, saves and reach come back through the\n platforms' public APIs, and Sprid stores them per post.\n- **Three-second hook rate and the retention curve exist only inside the native\n Instagram and TikTok insights screens.** There is no API field for them as an\n organic number. If you need them, you open the app.\n- Average watch time is available through both business APIs, which makes\n `average watch ÷ duration` the honest proxy you can automate.\n- Taps through to your site or store are the number that pays. The rest are\n directional.\n\n## One picture, many languages\n\nThe reason a demo video is usually made once is that the work is a person\nholding a phone. Split it instead: the wordless part (the footage, the gesture\nscript, the photographs) and the worded part (the captions and cards, bound to\nmarks in the footage rather than to timestamps).\n\nThe rule that makes it hold: **no word is ever inside a picture.** A wordless\ncutaway serves every language you have a string table for, so a seventh language\ncosts its translation and one render rather than a second shoot.\n",
|
|
1652
|
-
"revision": "
|
|
1657
|
+
"markdown": "# The first seconds of a reel\n\n**What this guide does:** Explains why a vertical video is watched or skipped in\nits opening seconds, and gives the timing arithmetic to build one that gets past\nit. Read it before writing a hook, choosing a length, or diagnosing a reel that\ngot no views.\n\nBoth platforms decide a video's reach from a small first batch of viewers and\nfrom what each of them does in the first seconds. Nothing after second three\nmatters to someone who left at second two. Everything below follows from that.\n\nEach claim carries how we know it: **measured** (we ran it and wrote the number\ndown), **reproduced** (we made the mistake and watched it happen), **published**\n(the platform or a cited source says so, we have not tested it).\n\n## Frame 0 is the cover, so it must already be readable\n\n**Reproduced, twice, on two different accounts.** The feed shows the video's\nfirst frame as its poster image. A hook that fades in from black has a black\ncover. A hook that animates up from 0.42 opacity reads as washed-out grey for\nthe first half second - which is inside the window that decides whether anyone\nstays.\n\nPlace the first card. Do not animate it in. Light it at 0.7 opacity or more, and\nmake it true at t=0.\n\n## The opener has to end before second three\n\n**Reproduced.** A hook card held for 3.3 seconds puts the first cut at 3.1\nseconds, so the entire skip window contains one motionless card. Nothing has\nhappened yet when the viewer decides.\n\nSize the opener to its own reading load with a floor of 1.6 seconds, and check\nthat the first cut lands under three. A cut inside the window is the cheapest\nsignal you have that this video is going somewhere.\n\n## Length: one measured result, and its confound\n\n**Measured, on one of our own accounts, 2026-08-29.** A batch cut to 15.5\nseconds drew 113-162 views on an account with a single follower. The 29-32\nsecond cuts that followed on the same account drew 6-22, and one sat at 6 views\nafter 29 hours - a distribution collapse rather than a slow start.\n\nState the confound honestly: three things changed in that batch at once\n(background, word density, length). Length is the reversible one, not the proven\none. That account now runs a 10-13 second test. **Neither band is a benchmark**,\nand anyone quoting 15.5 seconds as an optimum - including us - is over-reading a\nsingle comparison.\n\n## The timing arithmetic\n\nReading load, not taste. These are the holds that stop a card from reading faster\nthan it is shown.\n\n| | hold | why |\n|---|---|---|\n| any word | 300 ms | silent reading speed on a phone |\n| any character | 52 ms | a compound noun is one word and twenty letters, so take `max(words, chars)` |\n| opener | 1.6-2.8 s | reading load + 0.5 s, and the first cut must land under 3 s |\n| body or turn | 2.4-4.2 s | reading load + 0.7 s |\n| a big number | 2.6-3.8 s | the number reads at a glance, its label does not |\n| closer | 3.6-5.0 s | reading load + 1.3 s; this is the card people screenshot |\n| overlap | 200 ms | each card starts before the previous one leaves |\n\nWord budgets per card, never totalled: hook 3-11, body 3-14, closer 4-16. A\n3.5-second card holds roughly 10 words or 55 characters at a comfortable size.\nPast that the card reads faster than it holds, and the viewer waits - which is\nthe same experience as being bored.\n\n## What a hook is, and is not\n\n- A sentence your buyer has actually thought, or the sharpest fact stated flat.\n A scene or a number. It opens a loop the closer shuts.\n- **Not a \"how to\".** That creates an expectation of information, and information\n can be postponed. Not a question. Not \"did you know\".\n- **Recognition stops a scroll; tempo does not.** A harvested ten-word hook beat\n an invented four-word one on our own account (**reproduced**). Hooks come from\n what people actually said - forum threads, reviews, support mail - not from the\n bank of phrases that sound like hooks.\n- **The second card is a second hook.** It has to work cold, with the first card\n gone. On carousels the platform literally re-serves from the second slide; on\n video, the first cut is the same second chance.\n- The closer is flat and screenshottable, and it lets the product do the work\n rather than issuing an install instruction.\n\n## The kill rule\n\n**Published**, from platform averages rather than our own numbers: under 25% of\nviewers still watching past three seconds, keep the body and replace the hook.\nAround 30% is healthy and 40% is exceptional on TikTok.\n\nTwo guards on that rule. Never judge a format before ten posts, and never judge\na new account before 90 days - distribution is a power law, and a handful of\nposts carry most of the reach. Reading the first three posts of a new account\nis reading noise.\n\n## Where that number actually lives\n\nThis matters more than it sounds, because the metric the kill rule needs is the\none hardest to get:\n\n- Views, likes, comments, shares, saves and reach come back through the\n platforms' public APIs, and Sprid stores them per post.\n- **Three-second hook rate and the retention curve exist only inside the native\n Instagram and TikTok insights screens.** There is no API field for them as an\n organic number. If you need them, you open the app.\n- Average watch time is available through both business APIs, which makes\n `average watch ÷ duration` the honest proxy you can automate.\n- Taps through to your site or store are the number that pays. The rest are\n directional.\n\n## A reel as a Pinterest video Pin\n\nThe same file can go out as a video Pin, with its own board, destination link,\ntitle and description. The timing arithmetic still holds for whoever presses\nplay. The kill rule does not carry over: a Pin is found through search and\nrelated Pins for months, with no first test batch, so compare video Pins at\nequal ages (30, 60 and 90 days). Pinterest reports daily impressions, saves, Pin\nclicks, outbound clicks and video views (2 seconds with half the video in view),\nwith no hook rate, retention curve, likes, comments or shares. The number that\npays is outbound clicks followed through to visits and activation.\n\n## One picture, many languages\n\nThe reason a demo video is usually made once is that the work is a person\nholding a phone. Split it instead: the wordless part (the footage, the gesture\nscript, the photographs) and the worded part (the captions and cards, bound to\nmarks in the footage rather than to timestamps).\n\nThe rule that makes it hold: **no word is ever inside a picture.** A wordless\ncutaway serves every language you have a string table for, so a seventh language\ncosts its translation and one render rather than a second shoot.\n",
|
|
1658
|
+
"revision": "b773194337f31090"
|
|
1653
1659
|
},
|
|
1654
1660
|
{
|
|
1655
1661
|
"id": "store-listing",
|
package/src/docs/queries.d.mts
CHANGED
|
@@ -4,7 +4,7 @@ export interface QueryField {
|
|
|
4
4
|
properties?: Record<string, QueryField>; required?: string[]; additionalProperties?: boolean | QueryField; maxProperties?: number;
|
|
5
5
|
}
|
|
6
6
|
export interface QueryOperation { source: string; operation: string; description: string; inputSchema: QueryField; example: Record<string, unknown>; identifiers?: string[]; }
|
|
7
|
-
export interface QuerySource { source: string; title: string; identifiers: string[]; secrets: string[]; coverage: string; docs: string; stored?: boolean; }
|
|
7
|
+
export interface QuerySource { source: string; title: string; identifiers: string[]; secrets: string[]; coverage: string; docs: string; stored?: boolean; comments?: boolean; }
|
|
8
8
|
export const QUERY_SOURCES: Record<string, QuerySource>;
|
|
9
9
|
export const QUERY_OPERATIONS: QueryOperation[];
|
|
10
10
|
|
package/src/docs/queries.mjs
CHANGED
|
@@ -26,10 +26,15 @@ const sources = {
|
|
|
26
26
|
paddle: ['Paddle', [], ['paddleApiKey'], 'Credential-scoped merchant account, with sandbox/live from the profile. Dates filter billed or created time as described; monetary values are minor-unit strings.', 'https://developer.paddle.com/api-reference/overview'],
|
|
27
27
|
};
|
|
28
28
|
export const QUERY_SOURCES = Object.fromEntries(Object.entries(sources).map(([source, [title, identifiers, secrets, coverage, docs]]) => [source, { source, title, identifiers, secrets, coverage, docs }]));
|
|
29
|
-
for (const source of ['instagram', 'tiktok', 'youtube', 'facebook', 'linkedin', 'x']) QUERY_SOURCES[source] = {
|
|
30
|
-
source, title: ({ instagram: 'Instagram', tiktok: 'TikTok', youtube: 'YouTube', facebook: 'Facebook', linkedin: 'LinkedIn', x: 'X' })[source],
|
|
29
|
+
for (const source of ['instagram', 'tiktok', 'youtube', 'facebook', 'linkedin', 'x', 'pinterest']) QUERY_SOURCES[source] = {
|
|
30
|
+
source, title: ({ instagram: 'Instagram', tiktok: 'TikTok', youtube: 'YouTube', facebook: 'Facebook', linkedin: 'LinkedIn', x: 'X', pinterest: 'Pinterest' })[source],
|
|
31
31
|
identifiers: [], secrets: [], stored: true,
|
|
32
|
-
coverage:
|
|
32
|
+
coverage: source === 'pinterest'
|
|
33
|
+
? 'Queries Sprid’s stored Pin publishes and collected daily Pin metrics for the linked content account. Impressions, saves, Pin clicks and outbound clicks stay separate; outbound clicks are not website sessions or installs. Pins resurface for months, so compare them at equal Pin ages. Sprid does not collect Pinterest comments. No live platform sync; per-Pin live results are get_pinterest_metrics.'
|
|
34
|
+
: 'Queries Sprid’s stored publishes, metric snapshots and inbox for the linked content account. No live platform sync or paid API read. Missing metrics are unmeasured; this does not expose the platform’s entire API.',
|
|
35
|
+
// Pinterest's API has no comment read, so there is no inbox to query. The
|
|
36
|
+
// operation is absent rather than always empty, which would read as silence.
|
|
37
|
+
comments: source !== 'pinterest',
|
|
33
38
|
docs: 'https://sprid.studio/docs/queries',
|
|
34
39
|
};
|
|
35
40
|
export const QUERY_OPERATIONS = [];
|
|
@@ -63,8 +68,10 @@ op('lemonsqueezy', 'subscription-invoices', 'Read initial, renewal and update in
|
|
|
63
68
|
op('paddle', 'transactions', 'Read billed transactions by time, status, customer or subscription.', { ...dates, status: array(choice('Status.', ['draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due']), 7), customer_id: id('Paddle customer ID.'), subscription_id: id('Paddle subscription ID.'), limit, cursor }, [], { start: '2026-08-01', end: '2026-09-01', status: ['completed'], limit: 100 });
|
|
64
69
|
op('paddle', 'subscriptions', 'Read subscription lifecycles by status or price; optional creation dates filter each returned page locally.', { ...dates, status: array(choice('Status.', ['active', 'canceled', 'past_due', 'paused', 'trialing']), 5), customer_id: id('Paddle customer ID.'), price_id: id('Paddle price ID.'), limit, cursor }, [], { status: ['trialing'], limit: 100 });
|
|
65
70
|
for (const source of Object.keys(QUERY_SOURCES).filter(s => QUERY_SOURCES[s].stored)) {
|
|
66
|
-
op(source, 'posts',
|
|
67
|
-
|
|
71
|
+
op(source, 'posts', source === 'pinterest'
|
|
72
|
+
? 'Filter stored Pin publishing outcomes with collected daily Pin metrics summed from publication to the exclusive end: impressions, saves, Pin clicks, outbound clicks and video views, each with its observed-day coverage.'
|
|
73
|
+
: 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing']), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
|
|
74
|
+
if (QUERY_SOURCES[source].comments) op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: integer('Sprid post ID.', 1000000000), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
export function renderQueryReference() {
|
package/src/post/cli.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import { watch } from "node:fs";
|
|
42
42
|
import { createServer } from "node:http";
|
|
43
43
|
import { previewHandler } from "./preview-server.mjs";
|
|
44
|
-
import { projectPost } from "./rules/platform-rules.mjs";
|
|
44
|
+
import { internalTitleProblem, looksLikeSlug, projectPost, youtubeTitle } from "./rules/platform-rules.mjs";
|
|
45
45
|
import { homedir, tmpdir } from "node:os";
|
|
46
46
|
import { basename, dirname, join, resolve } from "node:path";
|
|
47
47
|
import { pathToFileURL } from "node:url";
|
|
@@ -180,6 +180,8 @@ const LANE_KEYS = new Set([
|
|
|
180
180
|
"coverAtMs",
|
|
181
181
|
// composed lanes: what Sprid is asked to render, and what may not be said
|
|
182
182
|
"template", "contentType", "neverSay", "sourcesRequired", "platforms",
|
|
183
|
+
// Where every post in the lane goes on Pinterest. See `composePinterestOptions`.
|
|
184
|
+
"pinterest",
|
|
183
185
|
]);
|
|
184
186
|
|
|
185
187
|
/**
|
|
@@ -205,6 +207,21 @@ export function normalizeConfig(cfg, cwd) {
|
|
|
205
207
|
for (const k of Object.keys(lane)) if (!LANE_KEYS.has(k)) complaints.push(`lane "${name}": unknown key \`${k}\``);
|
|
206
208
|
const tpl = lane.kind === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : (lane.slides ?? lane.media);
|
|
207
209
|
if (!tpl.includes("<slug>")) complaints.push(`lane "${name}": the path has no <slug>, so nothing can be discovered in it`);
|
|
210
|
+
if (lane.pinterest !== undefined) {
|
|
211
|
+
// A wrong TYPE is thrown, because it can only ever produce a Pin the
|
|
212
|
+
// server refuses. A missing board or link is said, because each post's
|
|
213
|
+
// spec may still supply it.
|
|
214
|
+
const { errors, unknown } = pinterestBlockProblems(lane.pinterest);
|
|
215
|
+
if (errors.length) throw new Error(`sprid.config: lane "${name}" pinterest: ${errors.join("; ")}`);
|
|
216
|
+
for (const k of unknown) complaints.push(`lane "${name}": unknown key \`pinterest.${k}\``);
|
|
217
|
+
if (!lane.pinterest.boardId)
|
|
218
|
+
complaints.push(
|
|
219
|
+
`lane "${name}": pinterest has no boardId, so no Pin from it can publish unless each spec names one ` +
|
|
220
|
+
`(\`sprid pinterest boards --account ${cfg.account}\` lists them)`,
|
|
221
|
+
);
|
|
222
|
+
if (!lane.pinterest.link)
|
|
223
|
+
complaints.push(`lane "${name}": pinterest has no link, and Pinterest refuses a Pin with nowhere to send the click`);
|
|
224
|
+
}
|
|
208
225
|
}
|
|
209
226
|
for (const c of cfg.checks ?? []) if (!ALL_CHECKS.includes(c)) complaints.push(`unknown check "${c}"`);
|
|
210
227
|
for (const c of complaints) console.log(` ${C.warn("config")} ${c}`);
|
|
@@ -442,6 +459,243 @@ function laneOf(cfg, slug) {
|
|
|
442
459
|
return null;
|
|
443
460
|
}
|
|
444
461
|
|
|
462
|
+
// ─── Pinterest ───────────────────────────────────────────────────────────────
|
|
463
|
+
//
|
|
464
|
+
// **One Pin is one image or one video, and it carries its own destination.**
|
|
465
|
+
// Every other platform here takes the caption and the media; a Pin also needs a
|
|
466
|
+
// board to live on and a link to send the click to, and those are the two
|
|
467
|
+
// things nobody can guess. So a lane names them once, a spec can override any
|
|
468
|
+
// of them for one post, and `register` writes the finished slice into the
|
|
469
|
+
// manifest, where `check` gates it and `draft` sends it.
|
|
470
|
+
|
|
471
|
+
const PINTEREST_KEYS = new Set(["boardId", "boardSectionId", "link", "utm", "title", "description", "altText", "aiDisclosures"]);
|
|
472
|
+
const UTM_KEYS = new Set(["source", "medium", "campaign", "content"]);
|
|
473
|
+
const PINTEREST_AI_DISCLOSURES = new Set(["AI_MODIFIED", "SYNTHETIC_PERFORMER"]);
|
|
474
|
+
const PINTEREST_LIMITS = { title: 100, description: 800, altText: 500 };
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* What is wrong with a `pinterest:` block, in a lane or a spec.
|
|
478
|
+
*
|
|
479
|
+
* `errors` are shapes the server refuses outright; `unknown` are keys nothing
|
|
480
|
+
* reads, which is how a misspelled `boardID` becomes a Pin with no board.
|
|
481
|
+
*/
|
|
482
|
+
export function pinterestBlockProblems(block) {
|
|
483
|
+
const errors = [];
|
|
484
|
+
const unknown = [];
|
|
485
|
+
if (block === undefined) return { errors, unknown };
|
|
486
|
+
if (!block || typeof block !== "object" || Array.isArray(block))
|
|
487
|
+
return { errors: ["must be an object: { boardId, link, ... }"], unknown };
|
|
488
|
+
for (const k of Object.keys(block)) if (!PINTEREST_KEYS.has(k)) unknown.push(k);
|
|
489
|
+
for (const k of ["boardId", "boardSectionId"]) {
|
|
490
|
+
const v = block[k];
|
|
491
|
+
if (v === undefined) continue;
|
|
492
|
+
// **A board id is longer than a JavaScript number can hold exactly**, so an
|
|
493
|
+
// unquoted one arrives rounded and names a board that does not exist.
|
|
494
|
+
if (typeof v === "number") errors.push(`${k} must be a quoted string ("${k}": "1234..."), because a board id is longer than a number can hold exactly`);
|
|
495
|
+
else if (typeof v !== "string" || !/^\d+$/.test(v)) errors.push(`${k} must be a string of digits, got ${JSON.stringify(v)}`);
|
|
496
|
+
}
|
|
497
|
+
if (block.link !== undefined) {
|
|
498
|
+
let ok = false;
|
|
499
|
+
try {
|
|
500
|
+
const u = new URL(fill(String(block.link), "slug"));
|
|
501
|
+
ok = typeof block.link === "string" && (u.protocol === "http:" || u.protocol === "https:");
|
|
502
|
+
} catch {}
|
|
503
|
+
if (!ok) errors.push(`link must be an absolute http or https URL (it may contain <slug>), got ${JSON.stringify(block.link)}`);
|
|
504
|
+
}
|
|
505
|
+
if (block.utm !== undefined && block.utm !== false) {
|
|
506
|
+
if (!block.utm || typeof block.utm !== "object" || Array.isArray(block.utm))
|
|
507
|
+
errors.push("utm must be { source, medium, campaign, content } or false. Sprid already tags every Pin's link with its own attribution, so leave it out unless you want your own names");
|
|
508
|
+
else
|
|
509
|
+
for (const [k, v] of Object.entries(block.utm)) {
|
|
510
|
+
if (!UTM_KEYS.has(k)) unknown.push(`utm.${k}`);
|
|
511
|
+
else if (typeof v !== "string" || !v.trim()) errors.push(`utm.${k} must be a non-empty string`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const [k, max] of Object.entries(PINTEREST_LIMITS)) {
|
|
515
|
+
const v = block[k];
|
|
516
|
+
if (v === undefined) continue;
|
|
517
|
+
if (typeof v !== "string") errors.push(`${k} must be a string`);
|
|
518
|
+
else if (v.length > max) errors.push(`${k} is ${v.length} characters and Pinterest takes ${max}`);
|
|
519
|
+
}
|
|
520
|
+
const ai = block.aiDisclosures;
|
|
521
|
+
if (ai !== undefined && (!Array.isArray(ai) || ai.length > 2 || ai.some((d) => !PINTEREST_AI_DISCLOSURES.has(d))))
|
|
522
|
+
errors.push("aiDisclosures must be a list of AI_MODIFIED and/or SYNTHETIC_PERFORMER");
|
|
523
|
+
return { errors, unknown };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The owner's own UTM tags, lane then spec, field by field.
|
|
528
|
+
*
|
|
529
|
+
* **Off unless asked for, because Sprid already tags every Pin.** At publish
|
|
530
|
+
* time the server appends utm_source=pinterest, utm_medium=social and the
|
|
531
|
+
* publish id to the link (`publishTrackingLink`), which is what lets a visit be
|
|
532
|
+
* traced to the one Pin that sent it. It does that only when the link carries
|
|
533
|
+
* no `utm_source` of its own, so an owner-tagged link goes out exactly as
|
|
534
|
+
* written and gives that per-Pin attribution up. `utm` exists for an owner who
|
|
535
|
+
* wants their own campaign names more than that. `false` in a spec turns the
|
|
536
|
+
* lane's tagging off for one post.
|
|
537
|
+
*/
|
|
538
|
+
function pinterestUtm(laneUtm, specUtm, slug) {
|
|
539
|
+
if (specUtm === false) return null;
|
|
540
|
+
const obj = (u) => (u && typeof u === "object" ? u : null);
|
|
541
|
+
if (!obj(laneUtm) && !obj(specUtm)) return null;
|
|
542
|
+
const merged = { source: "pinterest", medium: "social", campaign: "<slug>", ...obj(laneUtm), ...obj(specUtm) };
|
|
543
|
+
return Object.fromEntries(Object.entries(merged).map(([k, v]) => [k, fill(String(v), slug)]));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The link with the UTM tags appended.
|
|
548
|
+
*
|
|
549
|
+
* **Nothing already in the link is touched.** A `utm_source` somebody wrote
|
|
550
|
+
* into the link by hand wins over the lane's, and the rest of the query is
|
|
551
|
+
* kept byte for byte - re-serialising it through URLSearchParams would turn
|
|
552
|
+
* `%20` into `+` and `?flag` into `?flag=`, which some sites read differently.
|
|
553
|
+
*/
|
|
554
|
+
export function appendUtm(link, utm) {
|
|
555
|
+
if (!utm) return link;
|
|
556
|
+
let parsed;
|
|
557
|
+
try {
|
|
558
|
+
parsed = new URL(link);
|
|
559
|
+
} catch {
|
|
560
|
+
return link; // `check` names a link that is not a URL; this is not the place.
|
|
561
|
+
}
|
|
562
|
+
const add = Object.entries(utm)
|
|
563
|
+
.filter(([k]) => !parsed.searchParams.has(`utm_${k}`))
|
|
564
|
+
.map(([k, v]) => `utm_${k}=${encodeURIComponent(v)}`)
|
|
565
|
+
.join("&");
|
|
566
|
+
if (!add) return link;
|
|
567
|
+
const hashAt = link.indexOf("#");
|
|
568
|
+
const base = hashAt >= 0 ? link.slice(0, hashAt) : link;
|
|
569
|
+
const hash = hashAt >= 0 ? link.slice(hashAt) : "";
|
|
570
|
+
const joiner = !base.includes("?") ? "?" : /[?&]$/.test(base) ? "" : "&";
|
|
571
|
+
return `${base}${joiner}${add}${hash}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A caption with its hashtag-only lines removed: a Pin is searched on its words. */
|
|
575
|
+
function pinDescriptionFrom(caption) {
|
|
576
|
+
const text = (caption ?? "")
|
|
577
|
+
.split("\n")
|
|
578
|
+
.filter((l) => !/^\s*(#[\p{L}\p{M}\p{N}_]+[\s,]*)+$/u.test(l))
|
|
579
|
+
.join("\n")
|
|
580
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
581
|
+
.trim();
|
|
582
|
+
if (text.length <= PINTEREST_LIMITS.description) return text || undefined;
|
|
583
|
+
// Too long for a Pin. Cut at the last full sentence that fits, or a word,
|
|
584
|
+
// because a description ending mid-word reads as broken.
|
|
585
|
+
const room = text.slice(0, PINTEREST_LIMITS.description);
|
|
586
|
+
const sentence = Math.max(...[". ", "! ", "? ", ".\n", "!\n", "?\n"].map((s) => room.lastIndexOf(s)));
|
|
587
|
+
if (sentence > PINTEREST_LIMITS.description * 0.5) return room.slice(0, sentence + 1).trim();
|
|
588
|
+
return room.slice(0, room.lastIndexOf(" ") > 0 ? room.lastIndexOf(" ") : room.length).trim();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The post title, when it is fit to be a Pin's. An identifier or a production
|
|
593
|
+
* label is not: a Pin with no title is legal and one titled with a slug is
|
|
594
|
+
* public, so the default leaves it out rather than publish either.
|
|
595
|
+
*/
|
|
596
|
+
function pinTitleFrom(title, slug) {
|
|
597
|
+
const t = (title ?? "").trim();
|
|
598
|
+
if (!t || t === slug || looksLikeSlug(t) || internalTitleProblem(t)) return undefined;
|
|
599
|
+
return youtubeTitle(t); // the first line, cut at a word inside 100 characters
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The `pinterestOptions` a post is drafted with, or null when nothing here says
|
|
604
|
+
* where its Pin goes.
|
|
605
|
+
*
|
|
606
|
+
* The lane is the default and the spec overrides it field by field. What is
|
|
607
|
+
* not configured is derived: the title from the post's title, the description
|
|
608
|
+
* from the caption. With no block in either, whatever the record already held
|
|
609
|
+
* is kept - an options slice written by hand, or by `sprid pinterest set`, is
|
|
610
|
+
* not something a re-register should erase.
|
|
611
|
+
*/
|
|
612
|
+
export function composePinterestOptions({ lane, spec, slug, title, caption, prev }) {
|
|
613
|
+
if (!lane && !spec) return prev ?? null;
|
|
614
|
+
const pick = (k) => (spec?.[k] !== undefined ? spec[k] : lane?.[k]);
|
|
615
|
+
const utm = pinterestUtm(lane?.utm, spec?.utm, slug);
|
|
616
|
+
const rawLink = pick("link");
|
|
617
|
+
const link = typeof rawLink === "string" ? appendUtm(fill(rawLink, slug), utm) : undefined;
|
|
618
|
+
// The server's order, and only the keys that have a value: a missing key and
|
|
619
|
+
// an `undefined` one read the same to it, and not to a diff.
|
|
620
|
+
const out = {
|
|
621
|
+
boardId: pick("boardId"),
|
|
622
|
+
boardSectionId: pick("boardSectionId"),
|
|
623
|
+
title: pick("title") ?? pinTitleFrom(title, slug),
|
|
624
|
+
description: pick("description") ?? pinDescriptionFrom(caption),
|
|
625
|
+
link,
|
|
626
|
+
altText: pick("altText"),
|
|
627
|
+
aiDisclosures: pick("aiDisclosures"),
|
|
628
|
+
};
|
|
629
|
+
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Whether this post's media can be one Pin, from what the manifest measured.
|
|
634
|
+
*
|
|
635
|
+
* Returns problems (it cannot) and skips (the manifest cannot say). A Pin is
|
|
636
|
+
* exactly one image or one video; the server refuses anything else, and a
|
|
637
|
+
* repo that quietly sent the first slide of a deck would publish a Pin nobody
|
|
638
|
+
* designed as one.
|
|
639
|
+
*/
|
|
640
|
+
export function pinterestMediaCheck(m, lane = {}) {
|
|
641
|
+
const kind = m.kind ?? "reel";
|
|
642
|
+
const problems = [];
|
|
643
|
+
const skips = [];
|
|
644
|
+
const many = (n) =>
|
|
645
|
+
`${n} slides cannot be one Pin: Pinterest publishes exactly one image or one video, so ${n - 1} of them would never reach it. ` +
|
|
646
|
+
"Take pinterest out of this post's platforms, or give Pinterest a one-image post of its own";
|
|
647
|
+
|
|
648
|
+
if (kind === "composed") {
|
|
649
|
+
// A composed reel is rendered by Sprid, and its poster with it.
|
|
650
|
+
if ((lane.contentType ?? "image") === "reel") return { problems, skips };
|
|
651
|
+
const n = m.media?.slideCount ?? m.copy?.length ?? 0;
|
|
652
|
+
if (n > 1) problems.push(many(n));
|
|
653
|
+
else skips.push("the Pin's shape is the template's, so 2:3 is not checked here");
|
|
654
|
+
return { problems, skips };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (kind === "carousel") {
|
|
658
|
+
const n = m.slides?.length || m.media?.slideCount || 0;
|
|
659
|
+
if (n > 1) {
|
|
660
|
+
problems.push(many(n));
|
|
661
|
+
return { problems, skips };
|
|
662
|
+
}
|
|
663
|
+
const { width: w, height: h } = m.slides?.[0] ?? {};
|
|
664
|
+
if (!w || !h) skips.push("the image was never measured, so its shape is unknown - re-register it");
|
|
665
|
+
// **Pinterest's feed truncates anything taller than 2:3**, so a 9:16 story
|
|
666
|
+
// image goes out with its bottom third hidden behind "see more". 2:3 is
|
|
667
|
+
// the shape it recommends; wider publishes whole.
|
|
668
|
+
else if (h * 2 > w * 3)
|
|
669
|
+
problems.push(
|
|
670
|
+
`${w}x${h} (${ratioLabel(w, h)}) is taller than 2:3, and Pinterest cuts a taller image short in the feed. ` +
|
|
671
|
+
"Make it 2:3 (1000x1500) or wider",
|
|
672
|
+
);
|
|
673
|
+
return { problems, skips };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// A reel is a video Pin, and its cover is the poster `push` cuts at the
|
|
677
|
+
// lane's `coverAtMs`. That frame has to exist.
|
|
678
|
+
const at = lane.coverAtMs ?? 0;
|
|
679
|
+
const dur = m.media?.durationMs;
|
|
680
|
+
if (typeof dur === "number" && at >= dur)
|
|
681
|
+
problems.push(
|
|
682
|
+
`coverAtMs ${at} is past the end of this ${(dur / 1000).toFixed(1)}s video, so push has no frame to make the video Pin's cover from`,
|
|
683
|
+
);
|
|
684
|
+
return { problems, skips };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** JSON with object keys sorted, because Postgres jsonb hands keys back in its own order. */
|
|
688
|
+
const stableJson = (v) =>
|
|
689
|
+
JSON.stringify(v ?? null, (_k, x) =>
|
|
690
|
+
x && typeof x === "object" && !Array.isArray(x)
|
|
691
|
+
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
692
|
+
: x,
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
/** A short fingerprint of a post's Pin slice, so `sync` can tell it changed. */
|
|
696
|
+
const pinSha = (m) =>
|
|
697
|
+
m.post?.pinterestOptions ? createHash("sha256").update(stableJson(m.post.pinterestOptions)).digest("hex").slice(0, 12) : null;
|
|
698
|
+
|
|
445
699
|
// ─── commands ────────────────────────────────────────────────────────────────
|
|
446
700
|
|
|
447
701
|
const STARTER_OWN = `// sprid post. \`sprid post status\` to see where everything stands.
|
|
@@ -463,6 +717,18 @@ export default {
|
|
|
463
717
|
build: "your-renderer --slug <slug> --out <out>",
|
|
464
718
|
// What \`check\` enforces. Ranges are inclusive.
|
|
465
719
|
expect: { durationMs: [5000, 90000], lufs: [-16, -12] },
|
|
720
|
+
// Pinterest: every reel here becomes a video Pin, its cover the poster
|
|
721
|
+
// \`push\` cuts at \`coverAtMs\`. The board id is a quoted string
|
|
722
|
+
// (\`sprid pinterest boards --account ACCOUNT_SLUG\` lists them); the link
|
|
723
|
+
// is where the click goes, and \`<slug>\` is filled per post. Title and
|
|
724
|
+
// description default to the post's title and caption. Sprid tags the
|
|
725
|
+
// link for per-Pin attribution unless you set \`utm\` yourself. The Pin
|
|
726
|
+
// is gated by the \`crosspost\` check, so add it to \`checks\` below.
|
|
727
|
+
// pinterest: {
|
|
728
|
+
// boardId: "1234567890123456789",
|
|
729
|
+
// link: "https://example.com/<slug>",
|
|
730
|
+
// },
|
|
731
|
+
// platforms: ["instagram", "tiktok", "youtube", "pinterest"],
|
|
466
732
|
},
|
|
467
733
|
decks: {
|
|
468
734
|
// A carousel: the slug is the directory, the slides are its files, and
|
|
@@ -470,6 +736,8 @@ export default {
|
|
|
470
736
|
slides: "path/to/decks/<slug>/*.jpg",
|
|
471
737
|
caption: "path/to/decks/<slug>/caption.txt",
|
|
472
738
|
expect: { slides: [3, 20], aspect: "4:5", minWidth: 1080 },
|
|
739
|
+
// A Pin is ONE image, so a deck cannot be one. For Pinterest, give it a
|
|
740
|
+
// lane of single 2:3 images (1000x1500) with its own \`pinterest:\` block.
|
|
473
741
|
},
|
|
474
742
|
},
|
|
475
743
|
// Drop any you do not want. \`batchSpread\` is measured across a lane rather
|
|
@@ -495,6 +763,9 @@ export default {
|
|
|
495
763
|
caption: "tmp/reels/<slug>.caption.txt",
|
|
496
764
|
// Example limits. Set these for your format and audio delivery requirements.
|
|
497
765
|
expect: { durationMs: [8_000, 20_000], lufs: [-16, -12] },
|
|
766
|
+
// Also a video Pin on Pinterest: a quoted board id and a destination.
|
|
767
|
+
// The Pin is gated by the \`crosspost\` check, so add it to \`checks\`.
|
|
768
|
+
// pinterest: { boardId: "1234567890123456789", link: "https://example.com/<slug>" },
|
|
498
769
|
},
|
|
499
770
|
},
|
|
500
771
|
checks: ["duration", "loudness", "batchSpread", "caption"],
|
|
@@ -714,6 +985,10 @@ function readSpec(cfg, laneName, slug) {
|
|
|
714
985
|
// is how a post ships without its tags and nobody finds out until it is up.
|
|
715
986
|
if (spec.hashtags !== undefined)
|
|
716
987
|
throw new Error(`${p}: \`hashtags\` is not a field. Put the tags at the end of \`caption\`.`);
|
|
988
|
+
// The same rule for a Pin: a key nothing reads is a Pin missing what it names.
|
|
989
|
+
const pin = pinterestBlockProblems(spec.pinterest);
|
|
990
|
+
const pinProblems = [...pin.errors, ...pin.unknown.map((k) => `unknown key \`${k}\``)];
|
|
991
|
+
if (pinProblems.length) throw new Error(`${p}: pinterest: ${pinProblems.join("; ")}`);
|
|
717
992
|
return spec;
|
|
718
993
|
}
|
|
719
994
|
|
|
@@ -935,6 +1210,25 @@ async function cmdRegister(cfg, argv) {
|
|
|
935
1210
|
const authored = existsSync(specPath(cfg, lane, slug)) ? readSpec(cfg, lane, slug) : {};
|
|
936
1211
|
if (authored.remotePostId !== undefined && (!Number.isInteger(authored.remotePostId) || authored.remotePostId <= 0))
|
|
937
1212
|
throw new Error(`${slug}: remotePostId must be a positive integer`);
|
|
1213
|
+
const platforms = checkedPlatforms(flag(argv, "platforms")?.split(",") ?? authored.platforms ?? prev.post?.platforms);
|
|
1214
|
+
const title = authored.title ?? prev.post?.title;
|
|
1215
|
+
const pinterestOptions = composePinterestOptions({
|
|
1216
|
+
lane: cfg.lanes[lane].pinterest,
|
|
1217
|
+
spec: authored.pinterest,
|
|
1218
|
+
slug,
|
|
1219
|
+
title: title ?? postTitle(caption, slug),
|
|
1220
|
+
caption,
|
|
1221
|
+
prev: prev.post?.pinterestOptions,
|
|
1222
|
+
});
|
|
1223
|
+
const pinTargets = platforms ?? cfg.lanes[lane].platforms ?? cfg.platforms;
|
|
1224
|
+
if (pinTargets?.includes("pinterest") && !pinterestOptions)
|
|
1225
|
+
console.log(` ${C.warn("note")} ${slug}: pinterest is a target, but no lane or spec says which board or link its Pin gets`);
|
|
1226
|
+
let pinLinkTagged = false;
|
|
1227
|
+
try {
|
|
1228
|
+
pinLinkTagged = !!new URL(pinterestOptions?.link ?? "").searchParams.get("utm_source");
|
|
1229
|
+
} catch {}
|
|
1230
|
+
if (pinLinkTagged)
|
|
1231
|
+
console.log(` ${C.dim(`note ${slug}: the Pin's link carries its own utm_source, so Sprid will not tag it and cannot trace a visit to this Pin`)}`);
|
|
938
1232
|
const m = {
|
|
939
1233
|
slug,
|
|
940
1234
|
lane,
|
|
@@ -943,14 +1237,13 @@ async function cmdRegister(cfg, argv) {
|
|
|
943
1237
|
source: fill(mediaTemplate(cfg.lanes[lane]), slug),
|
|
944
1238
|
...measured.record,
|
|
945
1239
|
post: {
|
|
946
|
-
platforms
|
|
947
|
-
|
|
948
|
-
prev.post?.platforms),
|
|
949
|
-
title: authored.title ?? prev.post?.title,
|
|
1240
|
+
platforms,
|
|
1241
|
+
title,
|
|
950
1242
|
captionFile: caption ? `${basename(cfg.registry)}/captions/${slug}.txt` : null,
|
|
951
1243
|
captionSha: caption ? createHash("sha256").update(caption + (captionTiktok ?? "")).digest("hex").slice(0, 12) : null,
|
|
952
1244
|
captionTiktokFile: captionTiktok ? `${basename(cfg.registry)}/captions/${slug}.tiktok.txt` : null,
|
|
953
1245
|
instagramLocationId: prev.post?.instagramLocationId ?? null,
|
|
1246
|
+
...(pinterestOptions ? { pinterestOptions } : {}),
|
|
954
1247
|
},
|
|
955
1248
|
// A record is only true of the bytes it measured, so a changed digest
|
|
956
1249
|
// drops the check result with it.
|
|
@@ -989,17 +1282,27 @@ export function checkedPlatforms(platforms) {
|
|
|
989
1282
|
return [...new Set(platforms)];
|
|
990
1283
|
}
|
|
991
1284
|
|
|
992
|
-
export function crosspostCheck(m, caption, slug, platforms) {
|
|
1285
|
+
export function crosspostCheck(m, caption, slug, platforms, lane = {}) {
|
|
993
1286
|
const title = m.post?.title ?? postTitle(caption, slug);
|
|
994
|
-
|
|
1287
|
+
// With no selection, a post that carries a Pin slice is checked as a Pin
|
|
1288
|
+
// too: it was configured for Pinterest, and the default three would never
|
|
1289
|
+
// look at it.
|
|
1290
|
+
const selected = checkedPlatforms(platforms ?? m.post?.platforms) ??
|
|
1291
|
+
(m.post?.pinterestOptions ? ["instagram", "tiktok", "youtube", "pinterest"] : undefined);
|
|
995
1292
|
const modeled = selected?.filter(p => ["instagram", "tiktok", "youtube", "pinterest"].includes(p));
|
|
996
1293
|
const unmodeled = selected?.filter(p => !["instagram", "tiktok", "youtube", "pinterest"].includes(p)) ?? [];
|
|
997
1294
|
const problems = projectPost({ title, captionInstagram: caption,
|
|
998
1295
|
pinterestOptions: m.post?.pinterestOptions,
|
|
999
1296
|
platforms: modeled })
|
|
1000
1297
|
.flatMap((p) => p.problems.map((x) => `${p.platform}: ${x}`));
|
|
1001
|
-
|
|
1002
|
-
|
|
1298
|
+
const skips = unmodeled.length ? [`local projection rules unavailable for ${unmodeled.join(", ")}; use --deep for booked posts`] : [];
|
|
1299
|
+
// What the options slice cannot say: whether the MEDIA can be one Pin.
|
|
1300
|
+
if (modeled?.includes("pinterest")) {
|
|
1301
|
+
const media = pinterestMediaCheck(m, lane);
|
|
1302
|
+
problems.push(...media.problems.map((x) => `pinterest: ${x}`));
|
|
1303
|
+
skips.push(...media.skips.map((x) => `pinterest: ${x}`));
|
|
1304
|
+
}
|
|
1305
|
+
return problems.length ? `fail: ${problems.join("; ")}` : skips.length ? `skip: ${skips.join("; ")}` : "pass";
|
|
1003
1306
|
}
|
|
1004
1307
|
|
|
1005
1308
|
|
|
@@ -1143,7 +1446,7 @@ export function runChecks(cfg, m, batch) {
|
|
|
1143
1446
|
// and is asked for by `check --deep`, which projects the actual post - see
|
|
1144
1447
|
// `deepCrosspost`.
|
|
1145
1448
|
out.crosspost = existsSync(cp)
|
|
1146
|
-
? crosspostCheck(m, readFileSync(cp, "utf8"), m.slug, cfg.checkPlatforms ?? m.post?.platforms ?? lane.platforms ?? cfg.platforms)
|
|
1449
|
+
? crosspostCheck(m, readFileSync(cp, "utf8"), m.slug, cfg.checkPlatforms ?? m.post?.platforms ?? lane.platforms ?? cfg.platforms, lane)
|
|
1147
1450
|
: "fail: no caption in the registry";
|
|
1148
1451
|
}
|
|
1149
1452
|
|
|
@@ -1532,7 +1835,11 @@ async function cmdDraft(cfg, argv) {
|
|
|
1532
1835
|
captionInstagram: caption,
|
|
1533
1836
|
captionTiktok,
|
|
1534
1837
|
...(m.post.instagramLocationId ? { instagramLocationId: m.post.instagramLocationId } : {}),
|
|
1535
|
-
|
|
1838
|
+
// A Pin slice that was sent once and has since been removed here is
|
|
1839
|
+
// cleared there too, or the old board keeps receiving the post.
|
|
1840
|
+
...(m.post.pinterestOptions
|
|
1841
|
+
? { pinterestOptions: m.post.pinterestOptions }
|
|
1842
|
+
: m.sprid.pinterestShaAtDraft ? { pinterestOptions: null } : {}),
|
|
1536
1843
|
// **Silence is a reel's problem only.** A carousel has no audio to
|
|
1537
1844
|
// double up, and Sprid picks its own track for one if it wants.
|
|
1538
1845
|
// **Silence is a rendered reel's problem only.** It says "the music is
|
|
@@ -1558,6 +1865,7 @@ async function cmdDraft(cfg, argv) {
|
|
|
1558
1865
|
postId: post.id,
|
|
1559
1866
|
status: "drafted",
|
|
1560
1867
|
captionShaAtDraft: m.post.captionSha ?? null,
|
|
1868
|
+
pinterestShaAtDraft: pinSha(m),
|
|
1561
1869
|
// Kept, so `status` can still say it a week later and so a re-run after
|
|
1562
1870
|
// the server is updated has something to compare against.
|
|
1563
1871
|
dropped: dropped.length ? dropped.map(([f]) => f) : null,
|
|
@@ -1592,7 +1900,9 @@ export function verifyDraft(back, wanted) {
|
|
|
1592
1900
|
out.push(["captionInstagram", "the caption in Sprid is not the one in the registry"]);
|
|
1593
1901
|
if (wanted.title && back.title !== wanted.title)
|
|
1594
1902
|
out.push(["title", "YouTube publishes this as the Short's title; Sprid is holding a different one"]);
|
|
1595
|
-
|
|
1903
|
+
// Compared with keys sorted: the column is jsonb, which returns keys in its
|
|
1904
|
+
// own order, and a plain stringify reported every multi-field slice dropped.
|
|
1905
|
+
if (wanted.pinterestOptions && stableJson(back.pinterestOptions) !== stableJson(wanted.pinterestOptions))
|
|
1596
1906
|
out.push(["pinterestOptions", "the Pin will lose its reviewed board, destination or metadata"]);
|
|
1597
1907
|
return out;
|
|
1598
1908
|
}
|
|
@@ -1698,21 +2008,25 @@ async function cmdSync(cfg, argv) {
|
|
|
1698
2008
|
: m.media?.sha256 ?? null;
|
|
1699
2009
|
const media = isComposed(m) ? false : pushedSha == null ? "unknown" : pushedSha !== localSha;
|
|
1700
2010
|
const caption = (m.post?.captionSha ?? null) !== (m.sprid.captionShaAtDraft ?? null);
|
|
2011
|
+
// The Pin's board, link and words. Unrecorded counts as changed, same as
|
|
2012
|
+
// media: a record that cannot say what was sent cannot say it is current.
|
|
2013
|
+
const pin = pinSha(m) !== (m.sprid.pinterestShaAtDraft ?? null);
|
|
1701
2014
|
const cp = captionPath(cfg, m.slug);
|
|
1702
2015
|
const want = m.post?.title ?? (existsSync(cp) ? postTitle(readFileSync(cp, "utf8"), m.slug) : null);
|
|
1703
2016
|
const title = remoteTitle.has(m.slug) && want !== null && remoteTitle.get(m.slug) !== want;
|
|
1704
2017
|
const unreadable = remoteTitle.get(m.slug) === UNREADABLE;
|
|
1705
|
-
if (media || caption || title) plan.push({ m, media, caption, title, unreadable });
|
|
2018
|
+
if (media || caption || title || pin) plan.push({ m, media, caption, title, pin, unreadable });
|
|
1706
2019
|
}
|
|
1707
2020
|
|
|
1708
2021
|
if (!plan.length) { console.log("\n in sync - nothing in the registry differs from Sprid\n"); return { plan: [], execute }; }
|
|
1709
2022
|
|
|
1710
2023
|
console.log(`\n ${C.bold(execute ? "syncing" : "would sync")} ${plan.length} of ${all.length}\n`);
|
|
1711
|
-
for (const { m, media, caption, title, unreadable } of plan) {
|
|
2024
|
+
for (const { m, media, caption, title, pin, unreadable } of plan) {
|
|
1712
2025
|
const what = [
|
|
1713
2026
|
media === "unknown" ? C.warn("media (never recorded, assumed stale)") : media ? C.warn("media") : null,
|
|
1714
2027
|
caption ? C.warn("caption") : null,
|
|
1715
2028
|
title ? C.warn(unreadable ? "title (could not read Sprid's, assumed stale)" : "title") : null,
|
|
2029
|
+
pin ? C.warn("pin") : null,
|
|
1716
2030
|
].filter(Boolean).join(" + ");
|
|
1717
2031
|
console.log(` ${m.slug.padEnd(42)}${what}`);
|
|
1718
2032
|
}
|
|
@@ -1927,6 +2241,9 @@ async function cmdStatus(cfg) {
|
|
|
1927
2241
|
// edited here after the draft went over.
|
|
1928
2242
|
if (state === "drafted" && m.sprid.captionShaAtDraft && m.post.captionSha !== m.sprid.captionShaAtDraft)
|
|
1929
2243
|
state = C.warn("caption changed");
|
|
2244
|
+
// The Pin slice was re-composed (a new board, link or title) after the draft.
|
|
2245
|
+
else if (state === "drafted" && m.sprid.pinterestShaAtDraft !== undefined && pinSha(m) !== m.sprid.pinterestShaAtDraft)
|
|
2246
|
+
state = C.warn("pin changed");
|
|
1930
2247
|
// The other way they drift, and the one that had no name until 2026-09-09:
|
|
1931
2248
|
// the media was rebuilt and re-registered, so Sprid is still serving the
|
|
1932
2249
|
// file from before. `sync` is the answer to both.
|
|
@@ -2251,6 +2568,10 @@ const USAGE = `
|
|
|
2251
2568
|
--json one JSON result; diagnostics and builder output on stderr
|
|
2252
2569
|
check --platforms instagram,tiktok override crosspost validation targets
|
|
2253
2570
|
register --platforms instagram save a post's intended validation targets
|
|
2571
|
+
Pinterest a lane's pinterest: { boardId, link } (a spec may override any field)
|
|
2572
|
+
makes each post a Pin; register writes it, check gates it, draft sends it.
|
|
2573
|
+
A Pin is one image or one video: a multi-slide deck, or an image taller
|
|
2574
|
+
than 2:3, fails check. \`sprid pinterest boards\` lists board ids.
|
|
2254
2575
|
preview --serve [port] a local server (needed to scrub, and for Safari)
|
|
2255
2576
|
--open file:// straight into the browser · --lane <name> · --copy
|
|
2256
2577
|
--verify sha every file instead of trusting its size
|
|
@@ -113,12 +113,14 @@ export function projectPost(post) {
|
|
|
113
113
|
const parsed = new URL(link);
|
|
114
114
|
validLink = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
115
115
|
} catch {}
|
|
116
|
+
const pinTitle = typeof pin.title === "string" && pin.title.trim() ? pin.title.trim() : null;
|
|
116
117
|
out.push({
|
|
117
118
|
platform,
|
|
118
|
-
title:
|
|
119
|
+
title: pinTitle,
|
|
119
120
|
body: typeof pin.description === "string" ? pin.description.trim() : "",
|
|
120
121
|
problems: [
|
|
121
122
|
...(pin.boardId ? [] : ["choose a Pinterest board"]),
|
|
123
|
+
...(pinTitle && looksLikeSlug(pinTitle) ? [`the Pin's title is a slug: "${pinTitle}"`] : []),
|
|
122
124
|
...(validLink ? [] : ["add an absolute http or https destination link"]),
|
|
123
125
|
...((pin.title?.length ?? 0) <= 100 ? [] : ["Pinterest title exceeds 100 characters"]),
|
|
124
126
|
...((pin.description?.length ?? 0) <= 800 ? [] : ["Pinterest description exceeds 800 characters"]),
|