plin-code / laravel-instagram-digest
Laravel package that scrapes Instagram hashtags via Apify and sends a daily Telegram digest with inline action buttons.
Package info
github.com/plin-code/laravel-instagram-digest
pkg:composer/plin-code/laravel-instagram-digest
Fund package maintenance!
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.0
- illuminate/contracts: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is not auto-updated.
Last update: 2026-04-21 13:20:42 UTC
README
Scrape Instagram hashtags via Apify, filter profiles by keywords and follower threshold, and send a daily Telegram digest with inline action buttons. Classify candidates with one tap.
What it does
- Runs the Apify
apify~instagram-scraperactor against a list of hashtags. - Filters results by bio/username keyword match and a minimum follower count.
- Upserts surviving profiles into
instagram_digest_profiles. - Once a day, sends the next
Npending profiles as Telegram cards with inline buttons: Interesting, Reject, Show again later. Custom actions pluggable. - Handles the callback when you tap a button: updates the profile status and removes the buttons from the message.
Bring your own data sources (hashtags, keywords, min-followers, chat id) via closures or plain config. Extend with custom action buttons and a custom card renderer.
Installation
composer require plin-code/laravel-instagram-digest php artisan migrate
Add to your .env:
APIFY_TOKEN=your-apify-token APIFY_ACTOR_ID=apify~instagram-scraper APIFY_RESULTS_PER_HASHTAG=30 TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_CHAT_ID=-1001234567890 TELEGRAM_WEBHOOK_SECRET=a-long-random-string
Quickstart
In AppServiceProvider@boot:
use PlinCode\InstagramDigest\Facades\InstagramDigest; public function boot(): void { InstagramDigest::hashtagsUsing(fn () => ['trekking', 'hiking', 'guidealpine']); InstagramDigest::keywordsUsing(fn () => ['guida', 'trek', 'outdoor']); InstagramDigest::minFollowersUsing(fn () => 5000); }
Register the Telegram webhook:
php artisan instagram-digest:webhook
Verify your Telegram setup end-to-end:
php artisan instagram-digest:demo
The demo uses a placehold.co URL for the placeholder image, so Telegram must be able to fetch that URL. If your network or bot configuration blocks external image fetches, pass a photo URL explicitly:
php artisan instagram-digest:demo --to=CHAT_ID
(Note: the --to option overrides the configured chat_id but currently uses the same placeholder image. For a full dry-run with your own image, register a custom CardRenderer — see below.)
Data sources: resolvers vs config
Every data source has two equivalent ways to supply it.
Via config (config/instagram-digest.php or env):
'hashtags' => ['trekking', 'hiking'], 'keywords' => ['guida', 'outdoor'], 'min_followers' => 5000,
Via resolver closure (takes precedence when registered):
InstagramDigest::hashtagsUsing(fn () => Hashtag::active()->pluck('name')->all()); InstagramDigest::keywordsUsing(fn () => Keyword::all()->pluck('term')->all()); InstagramDigest::minFollowersUsing(fn () => Setting::get('min_followers', 5000)); InstagramDigest::chatIdUsing(fn () => auth()->user()->telegram_chat_id); InstagramDigest::dailyCountUsing(fn () => 10);
If no resolver is registered, the package falls back to config.
Custom actions
Register your own inline button:
use PlinCode\InstagramDigest\Facades\InstagramDigest; use PlinCode\InstagramDigest\Models\Profile; InstagramDigest::registerAction( key: 'archive', label: 'Archive', handler: fn (Profile $p) => $p->update(['status' => 'archived']), );
Replace the default action set entirely:
InstagramDigest::defaultActions([ new MyYesAction, new MyNoAction, ]);
Any class implementing PlinCode\InstagramDigest\Contracts\DigestAction is accepted.
Custom card rendering
Option A: publish the Blade view and edit it
php artisan vendor:publish --tag=instagram-digest-views
Then edit resources/views/vendor/instagram-digest/card.blade.php.
Option B: register your own renderer
use PlinCode\InstagramDigest\Contracts\CardRenderer; use PlinCode\InstagramDigest\Facades\InstagramDigest; InstagramDigest::renderCardUsing(MyCardRenderer::class);
Your renderer must return a PlinCode\InstagramDigest\Support\CardPayload.
Customizing the webhook route
The webhook is registered by the package at POST /instagram-digest/webhook/{secret?} with the api middleware group. Both the URL prefix and the middleware stack are config-driven — edit config/instagram-digest.php after publishing:
php artisan vendor:publish --tag=instagram-digest-config
Then adjust:
'route' => [ 'prefix' => 'instagram-digest', // appears in the URL: /{prefix}/webhook/{secret?} 'middleware' => ['api'], // any middleware array — e.g. ['api', 'throttle:60,1'] ],
If you need full control (different HTTP verb, route model binding, custom controller), you can bypass the auto-registered route by setting 'middleware' => ['api', 'should-never-match'] (breaks the route) and defining your own pointing at PlinCode\InstagramDigest\Http\Controllers\WebhookController.
Scheduling
The package does NOT register any scheduled tasks. Wire the commands yourself in routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('instagram-digest:scrape')->weekdays()->at('09:30'); Schedule::command('instagram-digest:send')->weekdays()->at('10:00');
Events
Listen to the following events to integrate with your own domain:
| Event | Payload | Use case |
|---|---|---|
ProfileDiscovered |
Profile $profile, bool $isNew |
Sync to your CRM / lead model — $isNew distinguishes first-time discovery from refresh |
ProfileStatusChanged |
Profile $profile, string $from, string $to |
React to user classification |
DigestSent |
array $profileIds |
Metrics, auditing |
ScrapingRunCompleted |
Run $run |
Notifications |
Example listener:
public function handle(ProfileDiscovered $event): void { if (! $event->isNew) { return; } Prospect::firstOrCreate( ['instagram_handle' => $event->profile->instagram_username], ['status' => 'new'], ); }
Testing your integration
The package plays nicely with Laravel's HTTP fakes and event fakes. In your own tests:
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Event; use PlinCode\InstagramDigest\Events\ProfileDiscovered; use PlinCode\InstagramDigest\Jobs\RunHashtagScrapingJob; it('my app reacts to ProfileDiscovered', function () { Event::fake([ProfileDiscovered::class]); Http::fake([ 'api.apify.com/*' => Http::response([/* ... */], 200), ]); dispatch_sync(new RunHashtagScrapingJob); Event::assertDispatched(ProfileDiscovered::class); });
For the Telegram side, fake api.telegram.org/* and assert via Http::assertSent(...).
Commands
| Command | Description |
|---|---|
instagram-digest:scrape [--sync] |
Dispatch the Apify scraping job. |
instagram-digest:send [--count=N] |
Dispatch the Telegram digest job. |
instagram-digest:webhook [url?] |
Register the Telegram webhook with Telegram. |
instagram-digest:demo [--to=id] |
Send one fake card to verify Telegram config. |
Testing
composer test
composer analyse
composer format
License
MIT. See LICENSE.md.