@easysociable/cli 0.1.17 → 0.1.18

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.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // Render every page of a content-pack.v1 with the system Chrome (no npm deps) and check it
3
+ // the way a phone viewer sees it. Exit 0 = pass, 1 = errors (fix before create), 2 = no Chrome.
4
+ //
5
+ // node check-pages.mjs pack.json --out ./shots [--brand brand.json | --theme theme.json]
6
+ //
7
+ // --brand takes `easysociable brands get --id <id> --json` output; without it the Studio
8
+ // default (dark) theme is used, same as a Run without a Brand.
9
+ import { execFileSync } from 'node:child_process'
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { join, resolve } from 'node:path'
12
+ import { pathToFileURL } from 'node:url'
13
+
14
+ const args = process.argv.slice(2)
15
+ const flag = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined }
16
+ const packPath = args.find(a => !a.startsWith('--') && a !== flag('--out') && a !== flag('--brand') && a !== flag('--theme'))
17
+ if (!packPath) { console.error('usage: node check-pages.mjs pack.json --out <dir> [--brand brand.json]'); process.exit(1) }
18
+ const outDir = resolve(flag('--out') ?? 'shots')
19
+ mkdirSync(outDir, { recursive: true })
20
+
21
+ const pack = JSON.parse(readFileSync(packPath, 'utf8'))
22
+ const themeFile = flag('--brand') ?? flag('--theme')
23
+ const brandRaw = themeFile ? JSON.parse(readFileSync(themeFile, 'utf8')) : null
24
+ const brand = brandRaw?.data ?? brandRaw
25
+ const c = brand?.colors ?? brand?.color ?? {}
26
+ // Studio default theme (apps/easysociable/app/lib/themeTokens.ts DEFAULT_AUTHORING_THEME_TOKENS).
27
+ const colors = { canvas: c.canvas ?? '#0f1419', text: c.text ?? '#e8eef5', muted: c.textMuted ?? c.muted ?? '#8b9bb0', accent: c.accent ?? '#5b8cff', primary: c.primary ?? c.accent ?? '#5b8cff' }
28
+
29
+ const chrome = [process.env.CHROME_PATH,
30
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
31
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
32
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
33
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
34
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'].find(p => p && existsSync(p))
35
+ if (!chrome) { console.error('No Chrome/Chromium found (set CHROME_PATH). Render checks skipped.'); process.exit(2) }
36
+
37
+ // ponytail: mirrors Studio's forced semantic colors by data-type; update if themeTokens.ts changes.
38
+ const themeCss = `[data-authoring-theme]{--es-color-canvas:${colors.canvas};--es-color-text:${colors.text};--es-color-muted:${colors.muted};--es-color-accent:${colors.accent};--es-color-primary:${colors.primary}}
39
+ [data-authoring-theme] [data-type="post"]{background-color:var(--es-color-canvas);color:var(--es-color-text)}
40
+ [data-authoring-theme] [data-type="title"],[data-authoring-theme] [data-type="subtitle"]{color:var(--es-color-text)!important}
41
+ [data-authoring-theme] [data-type="kicker"]{color:var(--es-color-accent)!important}
42
+ [data-authoring-theme] [data-type="metric"]{color:var(--es-color-primary)!important}
43
+ [data-authoring-theme] [data-type="caption"]{color:var(--es-color-muted)!important}
44
+ html,body{margin:0;background:#fff}#report{display:none}`
45
+
46
+ // Runs inside the Chrome page: JSX source → DOM, then measure. Source is evaluated in the page sandbox, not in Node.
47
+ const pageScript = String.raw`
48
+ const UNITLESS = new Set(['fontWeight','lineHeight','opacity','zIndex','flex','flexGrow','flexShrink'])
49
+ const toCss = o => Object.entries(o).map(([k,v]) => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()) + ':' + (typeof v === 'number' && !UNITLESS.has(k) ? v + 'px' : v)).join(';')
50
+ const toHtml = jsx => jsx
51
+ .replace(/style=\{\{([\s\S]*?)\}\}/g, (_, body) => 'style="' + toCss(Function('return {' + body + '}')()).replace(/"/g, "'") + '"')
52
+ .replace(/style="([^"]*)"/g, (_, body) => 'style="' + body.replace(/(^|;)\s*([a-z]+[A-Z][A-Za-z]*)\s*:/g, (m, sep, k) => sep + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase()) + ':') + '"')
53
+ .replace(/className=/g, 'class=').replace(/\{' '\}/g, ' ')
54
+ .replace(/<(div|p|h1|h2|h3|span|img)(\s[^>]*?)\s*\/>/g, (_, t, a) => t === 'img' ? '<img' + a + '>' : '<' + t + a + '></' + t + '>')
55
+ const stage = document.getElementById('stage'), GRID = location.hash === '#grid', S = 0.5
56
+ for (const [i, src] of SOURCES.entries()) {
57
+ const box = document.createElement('div')
58
+ box.setAttribute('data-authoring-theme', '')
59
+ box.innerHTML = toHtml(src.json)
60
+ if (GRID) { box.style.cssText = 'width:' + src.width * S + 'px;height:' + src.height * S + 'px;overflow:hidden'; box.firstElementChild.style.transform = 'scale(' + S + ')'; box.firstElementChild.style.transformOrigin = '0 0' }
61
+ stage.appendChild(box)
62
+ }
63
+ if (GRID) stage.style.cssText = 'display:grid;grid-template-columns:repeat(' + COLS + ',max-content);gap:16px;padding:16px;background:#888'
64
+ const lum = rgb => { const [r,g,b] = rgb.map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }); return 0.2126*r + 0.7152*g + 0.0722*b }
65
+ const parse = s => { const m = s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); return { rgb: p.slice(0,3), a: p[3] ?? 1 } }
66
+ const ratio = (a, b) => { const [x,y] = [lum(a), lum(b)].sort((m,n) => n - m); return (x + 0.05) / (y + 0.05) }
67
+ function measure(root) {
68
+ const R = root.getBoundingClientRect(), W = R.width, H = R.height, k = W / 1080
69
+ const errors = [], warnings = []
70
+ const texts = [...root.querySelectorAll('[data-variable^="text"], p, h1, h2, h3')].filter((e, i, all) => e.textContent.trim() && !all.some(o => o !== e && o.contains(e)))
71
+ const label = e => (e.dataset.variable ? e.dataset.variable + ' ' : '') + JSON.stringify(e.textContent.trim().slice(0, 40))
72
+ let words = 0
73
+ for (const e of texts) {
74
+ const cs = getComputedStyle(e), r = e.getBoundingClientRect(), size = parseFloat(cs.fontSize), n = e.textContent.trim().split(/\s+/).length
75
+ if (e.dataset.type !== 'decoration' && e.dataset.type !== 'label') words += n
76
+ if (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2) errors.push('overflow: ' + label(e) + ' needs ' + e.scrollWidth + 'x' + e.scrollHeight + ', box ' + e.clientWidth + 'x' + e.clientHeight)
77
+ if (r.left < R.left - 2 || r.top < R.top - 2 || r.right > R.right + 2 || r.bottom > R.bottom + 2) errors.push('off-canvas: ' + label(e))
78
+ if (n >= 6 && size < 28 * k) warnings.push('small text (' + size + 'px, min ' + Math.round(28 * k) + '): ' + label(e))
79
+ // Contrast: the element and each coloured span against the first opaque background under its centre.
80
+ const stack = document.elementsFromPoint(r.left + r.width / 2, r.top + Math.min(r.height / 2, size))
81
+ let bg = null
82
+ for (const el of stack) { if (el.tagName === 'IMG') break; const p = parse(getComputedStyle(el).backgroundColor); if (p && p.a > 0.5) { bg = p.rgb; break } }
83
+ if (bg) for (const part of [e, ...e.querySelectorAll('span')]) {
84
+ if (part !== e && !part.textContent.trim()) continue
85
+ const pc = getComputedStyle(part), fg = parse(pc.color), fs = parseFloat(pc.fontSize), large = fs >= 36 * k || (fs >= 28 * k && Number(pc.fontWeight) >= 700)
86
+ const cr = fg && ratio(fg.rgb, bg)
87
+ if (cr && cr < 3) errors.push('contrast ' + cr.toFixed(2) + ':1 (min ' + (large ? 3 : 4.5) + '): ' + label(part))
88
+ else if (cr && !large && cr < 4.5) warnings.push('contrast ' + cr.toFixed(2) + ':1 on small text (min 4.5): ' + label(part))
89
+ }
90
+ }
91
+ for (let i = 0; i < texts.length; i++) for (let j = i + 1; j < texts.length; j++) {
92
+ const a = texts[i].getBoundingClientRect(), b = texts[j].getBoundingClientRect()
93
+ const ix = Math.min(a.right, b.right) - Math.max(a.left, b.left), iy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)
94
+ if (ix > 4 && iy > 4) errors.push('text overlap: ' + label(texts[i]) + ' / ' + label(texts[j]))
95
+ }
96
+ if (words > 60) warnings.push(words + ' words on one page (aim <= 45): split it')
97
+ // Largest empty horizontal band, ignoring full-bleed backgrounds.
98
+ const spans = [...root.querySelectorAll('*')].map(e => e.getBoundingClientRect()).filter(r => r.width * r.height > 0 && r.width * r.height < 0.8 * W * H).map(r => [r.top - R.top, r.bottom - R.top]).sort((a, b) => a[0] - b[0])
99
+ let cursor = 0, gap = 0
100
+ for (const [t, b] of spans) { gap = Math.max(gap, t - cursor); cursor = Math.max(cursor, b) }
101
+ gap = Math.max(gap, H - cursor)
102
+ if (gap > 0.25 * H) warnings.push('empty band ' + Math.round(gap) + 'px (' + Math.round(100 * gap / H) + '% of height): rebalance the layout')
103
+ return { errors, warnings }
104
+ }
105
+ if (!GRID) document.fonts.ready.then(() => {
106
+ document.getElementById('report').textContent = JSON.stringify([...stage.children].map(box => { try { return measure(box.firstElementChild) } catch (e) { return { errors: ['render failed: ' + e.message], warnings: [] } } }))
107
+ })
108
+ `
109
+
110
+ const pages = pack.pages.map((page, i) => ({ n: i + 1, role: page.role, json: page.layout?.studioJson, ...(page.layout?.canvas ?? { width: 1080, height: 1350 }) })).filter(p => p.json)
111
+ for (const page of pack.pages) if (!page.layout?.studioJson) console.log(`page ${pack.pages.indexOf(page) + 1}: no layout.studioJson (catalog page) — skipped`)
112
+ const COLS = 4
113
+ const html = `<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=block">
114
+ <style>${themeCss}</style><div id="stage"></div><pre id="report"></pre>
115
+ <script>const SOURCES = ${JSON.stringify(pages).replace(/</g, '\\u003c')}, COLS = ${COLS};${pageScript}</script>`
116
+ const file = join(outDir, 'pages.html')
117
+ writeFileSync(file, html)
118
+ const W = Math.max(...pages.map(p => p.width)), Hs = pages.map(p => p.height)
119
+ const base = ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--virtual-time-budget=5000']
120
+ // Measure at full size (all pages stacked, so every page is inside the viewport).
121
+ const dom = execFileSync(chrome, [...base, `--window-size=${W},${Hs.reduce((a, b) => a + b, 0)}`, '--dump-dom', pathToFileURL(file).href], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 })
122
+ // One contact sheet at 50% for the visual review.
123
+ const rows = Math.ceil(pages.length / COLS)
124
+ execFileSync(chrome, [...base, `--window-size=${Math.min(COLS, pages.length) * (W / 2 + 16) + 16},${rows * (Math.max(...Hs) / 2 + 16) + 16}`, `--screenshot=${join(outDir, 'grid.png')}`, pathToFileURL(file).href + '#grid'], { stdio: 'ignore' })
125
+ const raw = dom.match(/<pre id="report">([\s\S]*?)<\/pre>/)?.[1]?.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
126
+ const reports = raw ? JSON.parse(raw) : pages.map(() => ({ errors: ['no report (page script did not finish)'], warnings: [] }))
127
+ let failed = false
128
+ for (const [i, page] of pages.entries()) {
129
+ const report = reports[i]
130
+ if (report.errors.length) failed = true
131
+ console.log(`page ${page.n} (${page.role}): ${report.errors.length ? 'FAIL' : 'ok'}`)
132
+ for (const e of report.errors) console.log(` ERROR ${e}`)
133
+ for (const w of report.warnings) console.log(` warn ${w}`)
134
+ }
135
+ console.log(`contact sheet: ${join(outDir, 'grid.png')} — look at it before creating the Run.`)
136
+ process.exit(failed ? 1 : 0)
@@ -1,45 +1,14 @@
1
1
  # Create Run
2
2
 
3
- 1. Write one arc line (`references/ARC.md`). Forbidden default:
4
- Cover → Point → Steps → Recap → CTA.
5
- 2. Prefer a Content Engine `pack.md` that already has **Page Contracts**,
6
- **Media**, and **Template Selection** filled. If contracts are missing,
7
- stop and hand back to **easysociable-content** — do not invent page
8
- purposes from catalog family names.
9
- 3. Build `content-pack.v1` from that argument. Page count follows the beats
10
- (TikTok often 4–8), not the number of P0 families. Each page carries an
11
- explicit `layoutFamilyId` (explicit — server must not silent-replace):
12
- Formula handoff with a Look → `look.slides[slideNumber] ?? look.layouts[beat.role]`; else the pack's
13
- Template Selection. One Run covers every platform the families support.
14
- 4. Check platform hardMin/hardMax.
15
- 5. No Look and no Template Selection → shortlist with
16
- `easysociable slideshows recommend --file contracts.json --seed <slug> --json`
17
- (MCP `recommend_slideshow_templates`). Input is the Page Contracts:
18
- `{ platform, pages: [{ role, density?, slotSummary?: { text, number, image }, requiresImage? }] }`.
19
- Pick one candidate `familyId` per page and record the reason; `supportedRoles` is a
20
- weak tag. `pages[0]` must be cover/hook class. Do not use that class on
21
- later pages. W- judgments (`fits` / `avoid`) with comparable evidence may
22
- override the recommender's order. Surface `gaps` to the user.
23
- 6. If `contentSlug` already has a Run: update only when topology matches
24
- (same page count and Layout Families). Otherwise create a new Run.
25
- 7. **Before** `create_run` / `slideshows create` (every create except
26
- Formula handoff Runs): check the knowledge hard gate — Identity Role / What I build /
27
- Point of view have no `(fill)` and are non-empty; `boundaries.md` exists
28
- with Not covering and Never claim non-empty; if `needs/` exists, ≥3 `N-`
29
- rows, else skip. Read the files or run
30
- `easysociable engine validate --run <run-dir> --require-knowledge`.
31
- Fail → hand back to **easysociable-content** / refuse create; list missing
32
- files. User must say **强制继续** / **force continue** to call Gateway.
33
- No new Gateway or MCP params.
34
- 7b. **Identity chain (ADR-0036):** confirm CLI auth is the user's Dashboard
35
- Workspace API key (not `workspace_local` / `test-key`). If needed run
36
- `easysociable auth login` (browser approval; `--api-key <key>` only when
37
- headless). Refuse create on fixture
38
- identity unless the user asked for `--allow-local-fixture`.
39
- 8. Call `create_run` / `slideshows create` through Gateway with the explicit
40
- `layoutFamilyId`s from the pack. `LAYOUT_FAMILY_NOT_FOUND` → the family has
41
- no template for this platform; tell the user, do not guess.
42
- 9. Save `runId`, `studioUrl`, and `status=draft`. Tell the user to open
43
- `studioUrl` **while logged into the same Studio account**.
44
- 10. Do not call a Deck API, create a carousel template, or send a product
45
- `pageSequence` / `arc` field.
3
+ Follow the Workflow in `SKILL.md` (login → Brand → facts → hook/structure →
4
+ Deck Style pages → images → `scripts/check-pages.mjs` → create).
5
+
6
+ Notes for updates:
7
+
8
+ - Revise the same Run (SKILL.md step 8): `slideshows get`, then `slideshows
9
+ update` with `baseRevision` and only the changed `pages[]` (`pageIndex`,
10
+ `layout`, `slots`). Page count and order stay fixed; a new Run only for a
11
+ new topic or an alternative the user asked for.
12
+ - Save `runId` and `studioUrl`; the user opens `studioUrl` while logged into
13
+ the same account.
14
+ - Do not call a Deck API or send a product `pageSequence` / `arc` field.
@@ -5,9 +5,7 @@ Fallback commands use the same Gateway client:
5
5
 
6
6
  ```bash
7
7
  easysociable slideshows create --file ./pack.json --json
8
- easysociable slideshows render --id run_xxx --idempotency-key run_xxx-final-1 --json
9
- easysociable renders wait --id job_xxx --json
10
- easysociable renders export --id job_xxx --output ./exports/run_xxx/tiktok --json
8
+ easysociable slideshows get --id run_xxx --json
11
9
  ```
12
10
 
13
11
  Neither path may call a private service, Local API, Directus, R2 or a separate
@@ -1,7 +1,7 @@
1
1
  # Out of scope
2
2
 
3
3
  - DeckTemplate creation, `list_decks`, `CREATE_DECK`, or `BIND_P0_DECKS`.
4
- - Product-level `arc` / Deck `pageSequence` SKUs (ADR-0026). Structure stays in the Content Pack.
4
+ - Product-level `arc` / Deck `pageSequence` SKUs. Structure stays in the Content Pack.
5
5
  - Defaulting every Run to Cover → Point → Steps → Recap → CTA.
6
6
  - Cover/hook class templates on page 2+ (they belong on `pages[0]` only).
7
7
  - ContentDocument as a render input (ADR-0021).
@@ -5,7 +5,7 @@ Private AST/bindings stay server-side; this document is the **public selection c
5
5
 
6
6
  ## Design choices (locked)
7
7
 
8
- 1. **One closed `pageRoles` set** (stored as `supportedRoles`) — a **retrieval tag for layout kind**, not page position and not a story recipe. `cover` means poster-like slots, not “must be page 1”. Do not add a parallel `arc` / `pageSequence` taxonomy (ADR-0026).
8
+ 1. **One closed `pageRoles` set** (stored as `supportedRoles`) — a **retrieval tag for layout kind**, not page position and not a story recipe. `cover` means poster-like slots, not “must be page 1”. Do not add a parallel `arc` / `pageSequence` taxonomy.
9
9
  2. **`platforms` is a multi-select array**. Include `generic` when the page fits any platform at that aspect.
10
10
  3. **P0 fields are required to Publish.** P1 should be filled when known; P2 optional.
11
11
  4. **Family `name` describes layout**, not a narrative beat. Prefer Poster / Stack / Count / Stair / Close (plus platform). Do not name families Cover / Point / Steps / Recap / CTA — choosers will glue those five names into one default pack.
@@ -99,7 +99,7 @@ hero | screenshot | avatar | product
99
99
 
100
100
  | Field | Type | Notes |
101
101
  |---|---|---|
102
- | `name` | string | Human title — **layout**, not beat (e.g. `P0 Poster TikTok`, not `P0 Cover TikTok`) |
102
+ | `name` | string | Public card title: what the layout is + style, then the ratio (e.g. `Five-Step How-To List — Quiet Ledger 9:16`). No internal prefixes (`P0`), no single platform name — the family spans platforms; the ratio suffix is stripped on cards. Keep equal to the page `name` |
103
103
  | `shortDescription` | string | One sentence for chooser UI |
104
104
  | `platforms` | `Platform[]` | Multi-select; use `generic` when truly cross-platform |
105
105
  | `canvasPreset` | preset enum | Plus exact `canvas` pixels |
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: easysociable-tweet-image
3
+ description: >
4
+ Turn one or many X (Twitter) posts into clean PNG images with the `easysociable tweets render`
5
+ CLI. Use when the user pastes x.com / twitter.com status links (or tweet ids) and wants
6
+ images, screenshots or "cards" of them — e.g. "tweet to image", "turn these tweets into
7
+ pictures", "推文转图片", "把这几条推特做成图". One link → one PNG; never a carousel.
8
+ Do not use for writing new tweets, multi-slide carousels (use easysociable-slideshow), or
9
+ scraping profiles/timelines.
10
+ ---
11
+
12
+ # EasySociable Tweet Image
13
+
14
+ Renders each X post as its own PNG: avatar, display name, verified badge, @handle, text,
15
+ up to 4 media thumbnails, timestamp and reply/like counts on a gradient background.
16
+ Tweets are fetched through the EasySociable gateway (login required, free quota) and
17
+ rendered locally on this machine.
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ easysociable tweets render <link-or-id> [more links…] --json
23
+ easysociable tweets render --file links.txt --json # one link per line
24
+ ```
25
+
26
+ | Flag | Default | Values |
27
+ |---|---|---|
28
+ | `--size` | `auto` | `auto` (card height fits the tweet) · `4:5` · `3:4` · `9:16` · `1:1` |
29
+ | `--theme` | `light` | `light` · `dark` |
30
+ | `--background` | `plum-plate` | `none` · `#hex` · `plum-plate` `sunset` `oceanic` `hyper` `cotton-candy` `gotham-dark` `neon` `emerald` `night-fade` `midnight` |
31
+ | `--hide-metrics` | off | hide reply/like counts |
32
+ | `--out` | `./tweets` | output folder; files are `tweet-<handle>-<id>.png` |
33
+
34
+ If the command says `EASYSOCIABLE_API_KEY is required`, ask the user to run
35
+ `easysociable auth login` (opens the browser), then retry.
36
+
37
+ ## Rules
38
+
39
+ 1. **Never rewrite, translate, summarize or "fix" tweet text.** The image must show the post exactly as published. Same for author name, @handle and avatar — do not swap or hide them.
40
+ 2. **One link → one image.** Do not merge tweets, build threads or carousels. Duplicates are rendered once.
41
+ 3. **Do not ask about size, theme or background up front.** Use the defaults unless the user already said what they want (e.g. "for Instagram" → `--size 4:5`, "for Stories/TikTok" → `--size 9:16`, "dark mode" → `--theme dark`, "no background"/"transparent" → `--background none`).
42
+ 4. **Failures are final, not something to work around.** `TWEET_NOT_FOUND` (deleted), `TWEET_UNAVAILABLE` (protected, age-restricted, suspended) and `INVALID_TWEET_URL` must be reported as failed. Never reconstruct a tweet from memory, search results or another site.
43
+ 5. `RATE_LIMITED` is retried automatically; if it still fails, tell the user to rerun just the failed links in a minute. `UPSTREAM_UNAVAILABLE` → suggest retrying later.
44
+ 6. Pass every link the user gave in one command (batch); don't loop one call per link.
45
+
46
+ ## Report
47
+
48
+ Exit code 1 means at least one item failed; the others still rendered. Reply with a short
49
+ per-link table from the `--json` items:
50
+
51
+ | Link | Result |
52
+ |---|---|
53
+ | x.com/jack/status/20 | `./tweets/tweet-jack-20.png` |
54
+ | x.com/foo/status/123 | failed — deleted (`TWEET_NOT_FOUND`) |
55
+
56
+ Mention `warnings` only if present (e.g. avatar or media could not be downloaded, so a
57
+ placeholder was used). Repost and view counts are not available from X and are never shown.