@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.
- package/README.md +2 -1
- package/dist/easysociable.js +560 -451
- package/dist/easysociable.js.map +1 -1
- package/package.json +18 -14
- package/skills/easysociable-slideshow/SKILL.md +202 -155
- package/skills/easysociable-slideshow/references/CONTENT_PACK.md +13 -15
- package/skills/easysociable-slideshow/references/DECK_STYLES.md +132 -0
- package/skills/easysociable-slideshow/references/PLATFORM_CAPS.md +1 -1
- package/skills/easysociable-slideshow/scripts/check-pages.mjs +136 -0
- package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +12 -43
- package/skills/easysociable-slideshow/workflows/MCP_FIRST.md +1 -3
- package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +1 -1
- package/skills/easysociable-template/references/CATALOG_METADATA.md +2 -2
- package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
- package/skills/easysociable-tweet-image/SKILL.md +57 -0
|
@@ -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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
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 |
|
|
File without changes
|
|
@@ -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.
|