Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

Juicer

Private Craft CMS 5 plugin that periodically synchronises social media posts from the Juicer Data API and stores them as native Craft entries.

Instead of embedding Juicer through a client-side JavaScript widget (legacy JS, unoptimised images, poor Lighthouse scores), posts are fetched server-side and rendered like any other entry, so images run through Craft's native image transforms.

Requirements

  • Craft CMS 5.0+
  • PHP 8.2+
  • A Juicer API key (jcr_…)

How it works

  1. A queue job (or the console command) calls GET /v1/feeds/{feed_id}/posts?status=public and pages through the result using page / per_page / meta.total_pages. Moderated and rejected posts are never requested.
  2. 429 and 5xx responses trigger an exponential backoff. A Retry-After header is honoured when present. Retry count and base delay are configurable.
  3. Each post is matched against the {{%juicer_posts}} lookup table by (feedId, externalId):
    • if the stored content hash still matches, the post is skipped (rotating ?s=… URL signatures are ignored when hashing);
    • otherwise the entry is created or updated. --force (or the Control Panel checkbox) re-imports everything regardless.
  4. Every item in the post's media array is downloaded once into a dedicated asset volume using a deterministic filename, so re-syncs reuse the existing asset instead of downloading again:
    • image items → downloaded as image assets (native transforms apply);
    • video items → the preview image is downloaded when the API provides one, and with downloadVideos on the video file itself is downloaded too (maxMediaFileSizeMb caps the size). Craft serves the video but cannot transform it.
  5. A failing post or media item is logged via Craft::error() (log target juicer) and the sync continues.

Installation

The plugin is wired into the project as a Composer path repository (see the root composer.json).

ddev composer require digitaldiff/juicer:dev-main
ddev craft plugin/install juicer

The install migration writes to the project config, so run it in an environment where admin changes are allowed (local / dev) and deploy the resulting project.yaml to the other environments.

What the install migration creates

Type Handle Notes
Local filesystem juicerPostsMedia @webroot/uploads/juicer-posts, base URL @web/uploads/juicer-posts. Override with JUICER_MEDIA_FS_PATH / JUICER_MEDIA_FS_URL.
Asset volume juicerPostsMedia Dedicated volume for downloaded media.
Channel section juicerPosts enableVersioning is disabled.
Entry type juicerPost Field layout: tab Post (Title, Platform, Permalink, Media), tab Sync data (External ID, Raw Data).
Field juicerExternalId Plain Text. Source platform's post ID, used for the upsert.
Field juicerPlatform Plain Text (instagram, tiktok, youtube, x, …). Plain Text rather than a dropdown so unknown platform values never fail validation.
Field juicerPermalink URL. From the post's url field.
Field juicerMedia Assets, multiple, restricted to the juicerPostsMedia volume, image + video kinds.
Field juicerRawData JSON. The rest of the API payload (poster, message, like_count, comment_count, share_count, view_count, moderation_status, …).
Table {{%juicer_posts}} Maps (feedId, externalId) to an entry ID (unique index), plus a content hash to skip unchanged posts. Foreign key to elements with ON DELETE CASCADE.

The synthetic entry title is Platform · Poster · Date. The Post Date is taken from external_created_at and stored on the native postDate attribute (shown in the entry sidebar, efficiently queryable via .postDate()), so no redundant custom date field is created.

Configuration

1. API key

Create a key on the Developer page of your Juicer dashboard and put it in the environment:

# .env
JUICER_API_KEY=jcr_xxxxxxxxxxxxxxxx

config/juicer.php references it by name (never commit the raw key):

<?php

return [
    'apiKey' => '$JUICER_API_KEY',

    // 'feeds' is intentionally omitted here so feeds can be chosen in the
    // Control Panel. Uncomment to lock the selection in code instead:
    // 'feeds' => [
    //     'Main feed'   => 123456,
    //     'Campaign'    => ['feedId' => 234567, 'apiKey' => '$OTHER_KEY'],
    // ],

    'perPage' => 100,          // Juicer max is 100
    'syncSinceDays' => null,   // e.g. 30 to only request the last 30 days (starting_at)
    'downloadVideoPreviews' => true,

    // Backoff for 429 / 5xx responses
    'maxRetries' => 4,
    'retryBaseDelay' => 3,
    'requestTimeout' => 30,
];

2. Choose feeds

Open Juicer in the Control Panel navigation (or Settings -> Plugins -> Juicer). The page lists every feed the API key can see, with its numeric feed ID and slug. Tick the feeds to sync and click Save selection - the choice is stored in the plugin settings (project config).

The slug shown there is the same identifier the old JavaScript embed used (data-feed-id="agentur_diff"), which makes it easy to match an existing feed to its numeric ID.

From the console the same list is available with:

ddev craft juicer/sync/feeds

If config/juicer.php defines a feeds key it takes precedence and the Control Panel selection is disabled.

Running a sync

Purpose Command
Sync all selected feeds inline ddev craft juicer/sync
Sync a single feed inline ddev craft juicer/sync --feed-id=123456
Queue one background job per selected feed ddev craft juicer/sync --queue
List available feeds ddev craft juicer/sync/feeds
Re-import everything (ignore cache) ddev craft juicer/sync --force
From the Control Panel Juicer -> Sync now (with optional "Force re-import")

Scheduled sync

Run every 30-60 minutes via cron. The --queue variant only enqueues work and returns immediately:

*/30 * * * * cd /var/www/html && php craft juicer/sync --queue >> /dev/null 2>&1

This requires an active queue runner (php craft queue/listen as a daemon, or CRAFT_RUN_QUEUE_AUTOMATICALLY). Use ddev craft juicer/sync directly in cron if you would rather not depend on the queue.

Rendering posts

Query the section like any other channel. juicerMedia can contain image and video assets, so split them:

{% set posts = craft.entries()
    .section('juicerPosts')
    .orderBy('postDate DESC')
    .with(['juicerMedia'])
    .limit(12)
    .all() %}

{% set transform = { width: 600, height: 600, mode: 'crop', format: 'webp' } %}

{% for post in posts %}
    {% set raw    = post.juicerRawData ? post.juicerRawData.getValue() : {} %}
    {% set images = post.juicerMedia.all()|filter(a => a.kind != 'video')|values %}
    {% set video  = post.juicerMedia.all()|filter(a => a.kind == 'video')|first %}
    {% set cover  = images|first %}

    <article>
        {% if video %}
            <video controls playsinline preload="none"
                   {% if cover %}poster="{{ cover.getUrl(transform) }}"{% endif %}>
                <source src="{{ video.url }}" type="{{ video.mimeType ?? 'video/mp4' }}">
            </video>
        {% elseif cover %}
            {{ tag('img', {
                src: cover.getUrl(transform),
                srcset: cover.getSrcset(['1x', '2x'], transform),
                alt: cover.alt ?: post.title,
                loading: 'lazy',
                width: 600, height: 600,
            }) }}
        {% endif %}

        <p>{{ raw.message ?? '' | nl2br }}</p>
        <footer>
            {{ post.juicerPlatform }} &middot; {{ post.postDate|date('d.m.Y') }}
            {% if post.juicerPermalink %}
                &middot; <a href="{{ post.juicerPermalink }}" target="_blank" rel="noopener">source</a>
            {% endif %}
        </footer>
    </article>
{% endfor %}

A complete, styled example lives in this repo's templates/index.twig: a card grid where the whole card links to the original post, multi-image posts render as a Swiper gallery, videos show a poster (SVG placeholder when Juicer has none), and the full caption slides up in a panel. Two things worth copying into your own project:

  • The whole card is the link – the media and the meta bar are <a> elements pointing at post.juicerPermalink; the "show caption" button sits on top with its own handler.
  • Gallery images load on demand. Swiper clips off-screen slides, so a plain loading="lazy" never fires for slides 2+. The example keeps the URLs in data-src and a Swiper slideChange handler swaps them in for the current and adjacent slides.

Known limitations (Juicer-side)

  • Galleries are usually incomplete. Juicer's media proxy (www.juicer.io/api/media/…) returns 404 for most carousel images past the first, even with a freshly fetched, correctly signed URL — Juicer's backend simply doesn't have the files. A carousel post therefore normally ends up with 1 usable asset (sometimes a few). The plugin downloads everything the API lists, imports what resolves, and logs the rest; there is no alternative URL. Re-run with --force after Juicer fixes their end.
  • Some videos have no preview image. Newer video posts include a preview_image_url that is downloaded as the poster. A few older standalone videos don't, so the example template falls back to an inline SVG placeholder poster (a play badge on a dark card) instead of showing a black <video>. There is no server-side frame extraction (no ffmpeg in the container); add ffmpeg to .ddev/config.yaml (webimage_extra_packages) and generate a poster in MediaService if you need real thumbnails.
  • Old text-only posts. For posts more than ~1–2 years old the v1 Data API often returns media: [] (the original CDN URLs expired). Those entries are still created, just without media — render them as text cards or filter them out with .juicerMedia(':notempty:'). (The undocumented embed endpoint still returns a cover image for many of these — see the note below.)

Free embed endpoint (not used, kept for reference)

The v1 Data API is metered/paid. Juicer also exposes the undocumented endpoint the JavaScript embed uses:

GET https://www.juicer.io/api/feeds/<slug>-<uuid>?page=1&per=100

No auth header (the <uuid> in the slug is the token), no Data API quota, paginated (posts.items, page until empty), returns only public/approved posts, and it still carries a cover image for the old posts the v1 API drops. Downsides: undocumented and unsupported (can change without notice), counts toward the feed's monthly view limit, and it has the same broken carousel media proxy. Switching would mean a JuicerEmbedClient behind an apiMode setting — not implemented yet.

Project structure

src/
├── Plugin.php                          Registers services + CP screen, sync-timestamp helpers
├── migrations/
│   ├── Install.php                     Creates volume, section, entry type, fields, lookup table
│   └── m260827_101500_allow_video_media.php   Adds the video kind to juicerMedia (1.0.1)
├── models/
│   ├── Settings.php                    Plugin settings (resolved from config/juicer.php)
│   └── FeedConfig.php                  One feed definition, with env-var resolution
├── records/PostRecord.php              ActiveRecord for {{%juicer_posts}}
├── services/
│   ├── JuicerApiClient.php             Bearer auth, pagination, 429/5xx backoff
│   ├── MediaService.php                Downloads image + video media, deduplicated
│   ├── SyncService.php                 Upsert logic, content-hash skipping, title/date mapping
│   └── FeedCatalog.php                 Fetches + caches the feed list for the CP picker
├── jobs/SyncFeedJob.php                Queue job, one per feed
├── console/controllers/SyncController.php   juicer/sync, --feed-id, --queue, --force, /feeds
├── controllers/CpController.php        Feed picker + "Sync now" actions
└── templates/index.twig               Control Panel screen

Logging

All messages are written to the juicer log target. In dev mode they also appear in storage/logs/web-*.log / storage/logs/console-*.log. A per-post failure is logged and skipped; a feed-level failure aborts that feed only.

Uninstalling

ddev craft plugin/uninstall juicer

The uninstall migration removes the section, entry type, fields, volume, filesystem and the {{%juicer_posts}} table. Downloaded files in web/uploads/juicer-posts are left in place.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages