Initial Commit
linter / quality (push) Canceled after 0s
tests / ci (8.3) (push) Canceled after 0s
tests / ci (8.4) (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 0s

This commit is contained in:
2026-08-28 23:53:45 +10:00
parent 3ce70b3701
commit 37ff8e0a88
13 changed files with 292 additions and 23 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Attributes\Signature;
#[Signature('app:debug-playwright')] #[Signature('direct:debug-playwright')]
#[Description('Command description')] #[Description('Command description')]
class DebugPlaywright extends RunsPlaywrightScript class DebugPlaywright extends RunsPlaywrightScript
{ {
@@ -55,6 +55,7 @@ class ScrapeDirectMessages extends RunsPlaywrightScript
'shared_preview_image' => $message['share']['preview_image'] ?? null, 'shared_preview_image' => $message['share']['preview_image'] ?? null,
'shared_title' => $message['share']['title'] ?? null, 'shared_title' => $message['share']['title'] ?? null,
'shared_caption' => $message['share']['caption'] ?? null, 'shared_caption' => $message['share']['caption'] ?? null,
'attachments' => $message['attachments'] ?? null,
'reactions' => $message['reactions'], 'reactions' => $message['reactions'],
'replied_to_id' => $message['replied_to_id'], 'replied_to_id' => $message['replied_to_id'],
'replied_to_text' => $message['replied_to_text'], 'replied_to_text' => $message['replied_to_text'],
+29
View File
@@ -69,4 +69,33 @@ class StoryController extends Controller
Story::where('id', $id)->update(['viewed_at' => now()]); Story::where('id', $id)->update(['viewed_at' => now()]);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
/**
* Fetch a single story item by its own id, regardless of whether it's
* still active used for opening a story someone shared in a DM long
* after it expired from the live tray.
*/
public function showItem(string $id): JsonResponse
{
$item = Story::find($id);
if (! $item) {
return response()->json(['message' => 'Story not found.'], 404);
}
return response()->json([
'username' => $item->username,
'profile_pic_url' => $item->profile_pic_url,
'start_index' => 0,
'items' => [[
'id' => $item->id,
'code' => $item->code,
'is_video' => $item->is_video,
'image_url' => $item->image_url,
'video_url' => $item->video_url,
'accessibility_caption' => $item->accessibility_caption,
'taken_at' => $item->taken_at,
]],
]);
}
} }
+40 -5
View File
@@ -4,10 +4,25 @@ namespace App\Http\Controllers;
use App\Models\DirectMessage; use App\Models\DirectMessage;
use App\Models\DirectThread; use App\Models\DirectThread;
use App\Models\Story;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class ThreadController extends Controller class ThreadController extends Controller
{ {
/**
* Instagram's own story permalinks embed both the poster's username and
* the story item's numeric pk: instagram.com/stories/{username}/{pk}.
* That pk is the same id our story scraper stores.
*/
private function parseStorySharePk(?string $url): ?array
{
if (! $url || ! preg_match('#/stories/([^/?]+)/(\d+)#', $url, $matches)) {
return null;
}
return ['username' => $matches[1], 'item_id' => $matches[2]];
}
public function index(): JsonResponse public function index(): JsonResponse
{ {
$threads = DirectThread::query() $threads = DirectThread::query()
@@ -40,10 +55,23 @@ class ThreadController extends Controller
{ {
$thread = DirectThread::findOrFail($threadId); $thread = DirectThread::findOrFail($threadId);
$messages = $thread->messages() $messages = $thread->messages()->orderBy('timestamp')->get();
->orderBy('timestamp')
->get() // Batch-check which story shares actually still exist in our local
->map(fn (DirectMessage $message) => [ // `stories` table, so we only need one query for the whole thread
// rather than one per message.
$storyPks = $messages
->map(fn (DirectMessage $m) => $this->parseStorySharePk($m->shared_url)['item_id'] ?? null)
->filter()
->unique()
->values();
$existingStoryIds = Story::whereIn('id', $storyPks)->pluck('id')->all();
$messages = $messages->map(function (DirectMessage $message) use ($existingStoryIds) {
$storyShare = $this->parseStorySharePk($message->shared_url);
return [
'id' => $message->id, 'id' => $message->id,
'user_id' => $message->user_id, 'user_id' => $message->user_id,
'username' => $message->username, 'username' => $message->username,
@@ -55,10 +83,17 @@ class ThreadController extends Controller
'shared_preview_image' => $message->shared_preview_image, 'shared_preview_image' => $message->shared_preview_image,
'shared_title' => $message->shared_title, 'shared_title' => $message->shared_title,
'shared_caption' => $message->shared_caption, 'shared_caption' => $message->shared_caption,
'attachments' => $message->attachments,
'reactions' => $message->reactions, 'reactions' => $message->reactions,
'replied_to_id' => $message->replied_to_id, 'replied_to_id' => $message->replied_to_id,
'replied_to_text' => $message->replied_to_text, 'replied_to_text' => $message->replied_to_text,
]); 'story_share' => $storyShare ? [
'username' => $storyShare['username'],
'item_id' => $storyShare['item_id'],
'exists_locally' => in_array($storyShare['item_id'], $existingStoryIds, true),
] : null,
];
});
return response()->json([ return response()->json([
'thread_id' => $thread->thread_id, 'thread_id' => $thread->thread_id,
+2 -1
View File
@@ -15,7 +15,7 @@ class DirectMessage extends Model
protected $fillable = [ protected $fillable = [
'id', 'thread_id', 'user_id', 'username', 'full_name', 'item_type', 'text', 'id', 'thread_id', 'user_id', 'username', 'full_name', 'item_type', 'text',
'media_url', 'is_video', 'timestamp', 'shared_url', 'shared_preview_image', 'media_url', 'is_video', 'attachments', 'timestamp', 'shared_url', 'shared_preview_image',
'shared_title', 'shared_caption', 'reactions', 'replied_to_id', 'replied_to_text', 'shared_title', 'shared_caption', 'reactions', 'replied_to_id', 'replied_to_text',
]; ];
@@ -23,6 +23,7 @@ class DirectMessage extends Model
'is_video' => 'boolean', 'is_video' => 'boolean',
'timestamp' => 'integer', 'timestamp' => 'integer',
'reactions' => 'array', 'reactions' => 'array',
'attachments' => 'array',
'scraped_at' => 'datetime', 'scraped_at' => 'datetime',
]; ];
+10 -10
View File
@@ -1,11 +1,12 @@
import { test } from '@playwright/test'; import { test } from '@playwright/test';
import path from 'path';
test.use({ storageState: '/home/pwuser/ig-session.json' }); test.use({ storageState: path.resolve(__dirname, '.auth', 'ig-session.json') });
test('inspect non-text message shapes', async ({ page }) => { test('inspect IMAGES message shape', async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
const interesting = []; const found = [];
page.on('response', async (response) => { page.on('response', async (response) => {
const url = response.url(); const url = response.url();
@@ -17,10 +18,8 @@ test('inspect non-text message shapes', async ({ page }) => {
const t = json?.data?.get_slide_thread_nullable?.as_ig_direct_thread; const t = json?.data?.get_slide_thread_nullable?.as_ig_direct_thread;
if (!t) return; if (!t) return;
for (const edge of t.slide_messages?.edges || []) { for (const edge of t.slide_messages?.edges || []) {
const node = edge.node; if (edge.node?.content_type === 'IMAGES') {
const hasReactions = (node.reactions?.length || 0) > 0 || (node.msg_reactions?.length || 0) > 0; found.push(edge.node);
if (node.content_type !== 'TEXT' || hasReactions || node.replied_to_message) {
interesting.push({ thread: t.thread_title, node });
} }
} }
} catch (e) { /* skip */ } } catch (e) { /* skip */ }
@@ -29,13 +28,14 @@ test('inspect non-text message shapes', async ({ page }) => {
await page.goto('https://www.instagram.com/direct/inbox/'); await page.goto('https://www.instagram.com/direct/inbox/');
await page.waitForTimeout(6000); await page.waitForTimeout(6000);
const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', els => const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', (els) => [
[...new Set(els.map(el => el.getAttribute('href')))]); ...new Set(els.map((el) => el.getAttribute('href'))),
]);
for (const href of threadHrefs) { for (const href of threadHrefs) {
await page.goto(`https://www.instagram.com${href}`); await page.goto(`https://www.instagram.com${href}`);
await page.waitForTimeout(3500); await page.waitForTimeout(3500);
} }
console.log('[INTERESTING MESSAGES]', JSON.stringify(interesting, null, 2)); console.log('[IMAGES MESSAGES]', JSON.stringify(found, null, 2));
}); });
+13
View File
@@ -61,6 +61,18 @@ function extractShare(node) {
}; };
} }
// A raw photo/video message has content_type 'IMAGES' with one or more
// attachments (Instagram allows sending several photos in a single message).
function extractAttachments(node) {
const attachments = node.content?.attachments;
if (!attachments?.length) return null;
return attachments.map((a) => ({
url: a.attachment_cdn_url || a.preview_cdn_url || null,
width: a.preview_width || null,
height: a.preview_height || null,
})).filter((a) => a.url);
}
function extractMessage(node, thread, fbidLookup) { function extractMessage(node, thread, fbidLookup) {
const senderDict = node.sender?.user_dict; const senderDict = node.sender?.user_dict;
const resolved = senderDict const resolved = senderDict
@@ -85,6 +97,7 @@ function extractMessage(node, thread, fbidLookup) {
text: node.text_body || node.content?.text_body || null, text: node.text_body || node.content?.text_body || null,
timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null, timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null,
share: extractShare(node), share: extractShare(node),
attachments: extractAttachments(node),
reactions: reactions.length ? reactions : null, reactions: reactions.length ? reactions : null,
replied_to_id: node.replied_to_message_id || null, replied_to_id: node.replied_to_message_id || null,
replied_to_text: node.replied_to_message?.text_body || null, replied_to_text: node.replied_to_message?.text_body || null,
@@ -2,6 +2,8 @@
import Avatar from '@/components/dredge/Avatar.vue'; import Avatar from '@/components/dredge/Avatar.vue';
import MessageText from './MessageText.vue'; import MessageText from './MessageText.vue';
import MessageShare from './MessageShare.vue'; import MessageShare from './MessageShare.vue';
import MessageStoryShare from './MessageStoryShare.vue';
import MessageMedia from './MessageMedia.vue';
import MessageSystem from './MessageSystem.vue'; import MessageSystem from './MessageSystem.vue';
import type { ThreadMessage } from '@/types/dredge.types'; import type { ThreadMessage } from '@/types/dredge.types';
@@ -26,9 +28,16 @@ defineProps<{
{{ message.replied_to_text }} {{ message.replied_to_text }}
</p> </p>
<MessageShare v-if="message.shared_url" :message="message" /> <!-- Text is a universal concern shown alongside whatever else the
<MessageText v-else-if="message.text" :message="message" /> message carries, not one branch of a type dispatch a story
<MessageSystem v-else :message="message" :is-own="isOwn" /> reply, for instance, has both a caption you typed AND a
share preview, and previously only one or the other rendered. -->
<MessageText v-if="message.text" :message="message" />
<MessageStoryShare v-if="message.story_share" :message="message" />
<MessageShare v-else-if="message.shared_url" :message="message" />
<MessageMedia v-else-if="message.attachments?.length" :message="message" />
<MessageSystem v-else-if="!message.text" :message="message" :is-own="isOwn" />
<div v-if="message.reactions?.length" class="bubble__reactions"> <div v-if="message.reactions?.length" class="bubble__reactions">
<span v-for="(reaction, i) in message.reactions" :key="i" class="bubble__reaction"> <span v-for="(reaction, i) in message.reactions" :key="i" class="bubble__reaction">
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { proxyImage } from '@/lib/proxyImage';
import type { ThreadMessage } from '@/types/dredge.types';
defineProps<{
message: ThreadMessage;
}>();
</script>
<template>
<div
v-if="message.attachments?.length"
class="message-media"
:class="{ 'message-media--grid': message.attachments.length > 1 }"
>
<a
v-for="(attachment, i) in message.attachments"
:key="i"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer"
>
<img :src="proxyImage(attachment.url)" alt="" class="message-media__image" />
</a>
</div>
</template>
<style scoped>
.message-media {
display: flex;
}
.message-media > a {
display: block;
line-height: 0;
}
.message-media__image {
display: block;
max-width: 220px;
max-height: 320px;
border-radius: 10px;
object-fit: cover;
}
.message-media--grid {
flex-wrap: wrap;
gap: 0.3rem;
max-width: 220px;
}
.message-media--grid .message-media__image {
width: 105px;
height: 105px;
max-width: none;
max-height: none;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref } from 'vue';
import { proxyImage } from '@/lib/proxyImage';
import StoryViewer from '@/components/dredge/Stories/StoryViewer.vue';
import type { ThreadMessage } from '@/types/dredge.types';
const props = defineProps<{
message: ThreadMessage;
}>();
const isOpen = ref(false);
</script>
<template>
<component
:is="message.story_share!.exists_locally ? 'button' : 'a'"
class="story-share"
:class="{ 'story-share--large': message.story_share!.exists_locally }"
v-bind="
message.story_share!.exists_locally
? { type: 'button' }
: { href: message.shared_url ?? undefined, target: '_blank', rel: 'noopener noreferrer' }
"
@click="message.story_share!.exists_locally && (isOpen = true)"
>
<img
v-if="message.shared_preview_image"
:src="proxyImage(message.shared_preview_image)"
alt=""
class="story-share__image"
/>
<span v-if="message.shared_title || message.shared_caption" class="story-share__body">
<span v-if="message.shared_title" class="story-share__title">
{{ message.shared_title }}
</span>
<span v-if="message.shared_caption" class="story-share__caption">
{{ message.shared_caption }}
</span>
</span>
</component>
<StoryViewer
v-if="isOpen && message.story_share"
:username="message.story_share.username"
:item-id="message.story_share.item_id"
@close="isOpen = false"
/>
</template>
<style scoped>
.story-share {
display: flex;
gap: 0.6rem;
text-decoration: none;
color: inherit;
background: rgba(0, 0, 0, 0.15);
border: none;
border-radius: 8px;
padding: 0.4rem;
font: inherit;
text-align: left;
width: 100%;
cursor: pointer;
}
.story-share__image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.story-share__body {
display: flex;
flex-direction: column;
min-width: 0;
justify-content: center;
}
.story-share__title {
font-weight: 600;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-share__caption {
font-size: 0.75rem;
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A story we actually have locally: worth a much bigger, tappable preview
instead of the compact link-card treatment used for everything else. */
.story-share--large {
flex-direction: column;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem;
}
.story-share--large .story-share__image {
width: 160px;
height: 284px; /* ~9:16, matching Instagram's own story aspect ratio */
border-radius: 10px;
}
.story-share--large .story-share__body {
width: 160px;
}
.story-share--large .story-share__caption,
.story-share--large .story-share__title {
white-space: normal;
}
</style>
@@ -6,6 +6,9 @@ import type { StoryDetail } from '@/types/dredge.types';
const props = defineProps<{ const props = defineProps<{
username: string; username: string;
// When set, opens this specific (possibly expired) story item directly
// instead of the user's current live tray.
itemId?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -30,12 +33,15 @@ async function fetchStory() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await fetch(`/api/stories/${props.username}`); const url = props.itemId ? `/api/story-items/${props.itemId}` : `/api/stories/${props.username}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load story'); if (!response.ok) throw new Error('Failed to load story');
detail.value = await response.json(); detail.value = await response.json();
currentIndex.value = detail.value?.start_index ?? 0; currentIndex.value = detail.value?.start_index ?? 0;
} catch (e) { } catch (e) {
error.value = 'Could not load this story.'; error.value = props.itemId
? 'This story is no longer available.'
: 'Could not load this story.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
+8
View File
@@ -78,6 +78,12 @@ export interface ThreadReaction {
username: string | null; username: string | null;
} }
export interface ThreadStoryShare {
username: string;
item_id: string;
exists_locally: boolean;
}
export interface ThreadMessage { export interface ThreadMessage {
id: string; id: string;
user_id: string | null; user_id: string | null;
@@ -90,9 +96,11 @@ export interface ThreadMessage {
shared_preview_image: string | null; shared_preview_image: string | null;
shared_title: string | null; shared_title: string | null;
shared_caption: string | null; shared_caption: string | null;
attachments: { url: string; width: number | null; height: number | null }[] | null;
reactions: ThreadReaction[] | null; reactions: ThreadReaction[] | null;
replied_to_id: string | null; replied_to_id: string | null;
replied_to_text: string | null; replied_to_text: string | null;
story_share: ThreadStoryShare | null;
} }
export interface ThreadDetail { export interface ThreadDetail {
+1
View File
@@ -9,5 +9,6 @@ Route::get('/posts', [PostController::class, 'index']);
Route::get('/stories', [StoryController::class, 'index']); Route::get('/stories', [StoryController::class, 'index']);
Route::get('/stories/{username}', [StoryController::class, 'show']); Route::get('/stories/{username}', [StoryController::class, 'show']);
Route::post('/story-items/{id}/view', [StoryController::class, 'markViewed']); Route::post('/story-items/{id}/view', [StoryController::class, 'markViewed']);
Route::get('/story-items/{id}', [StoryController::class, 'showItem']);
Route::get('/threads', [ThreadController::class, 'index']); Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{threadId}', [ThreadController::class, 'show']); Route::get('/threads/{threadId}', [ThreadController::class, 'show']);