Initial Commit
This commit is contained in:
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
|
||||
#[Signature('app:debug-playwright')]
|
||||
#[Signature('direct:debug-playwright')]
|
||||
#[Description('Command description')]
|
||||
class DebugPlaywright extends RunsPlaywrightScript
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ class ScrapeDirectMessages extends RunsPlaywrightScript
|
||||
'shared_preview_image' => $message['share']['preview_image'] ?? null,
|
||||
'shared_title' => $message['share']['title'] ?? null,
|
||||
'shared_caption' => $message['share']['caption'] ?? null,
|
||||
'attachments' => $message['attachments'] ?? null,
|
||||
'reactions' => $message['reactions'],
|
||||
'replied_to_id' => $message['replied_to_id'],
|
||||
'replied_to_text' => $message['replied_to_text'],
|
||||
|
||||
@@ -69,4 +69,33 @@ class StoryController extends Controller
|
||||
Story::where('id', $id)->update(['viewed_at' => now()]);
|
||||
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,
|
||||
]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,25 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DirectMessage;
|
||||
use App\Models\DirectThread;
|
||||
use App\Models\Story;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
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
|
||||
{
|
||||
$threads = DirectThread::query()
|
||||
@@ -40,10 +55,23 @@ class ThreadController extends Controller
|
||||
{
|
||||
$thread = DirectThread::findOrFail($threadId);
|
||||
|
||||
$messages = $thread->messages()
|
||||
->orderBy('timestamp')
|
||||
->get()
|
||||
->map(fn (DirectMessage $message) => [
|
||||
$messages = $thread->messages()->orderBy('timestamp')->get();
|
||||
|
||||
// Batch-check which story shares actually still exist in our local
|
||||
// `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,
|
||||
'user_id' => $message->user_id,
|
||||
'username' => $message->username,
|
||||
@@ -55,10 +83,17 @@ class ThreadController extends Controller
|
||||
'shared_preview_image' => $message->shared_preview_image,
|
||||
'shared_title' => $message->shared_title,
|
||||
'shared_caption' => $message->shared_caption,
|
||||
'attachments' => $message->attachments,
|
||||
'reactions' => $message->reactions,
|
||||
'replied_to_id' => $message->replied_to_id,
|
||||
'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([
|
||||
'thread_id' => $thread->thread_id,
|
||||
|
||||
@@ -15,7 +15,7 @@ class DirectMessage extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'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',
|
||||
];
|
||||
|
||||
@@ -23,6 +23,7 @@ class DirectMessage extends Model
|
||||
'is_video' => 'boolean',
|
||||
'timestamp' => 'integer',
|
||||
'reactions' => 'array',
|
||||
'attachments' => 'array',
|
||||
'scraped_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
+10
-10
@@ -1,11 +1,12 @@
|
||||
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);
|
||||
|
||||
const interesting = [];
|
||||
const found = [];
|
||||
|
||||
page.on('response', async (response) => {
|
||||
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;
|
||||
if (!t) return;
|
||||
for (const edge of t.slide_messages?.edges || []) {
|
||||
const node = edge.node;
|
||||
const hasReactions = (node.reactions?.length || 0) > 0 || (node.msg_reactions?.length || 0) > 0;
|
||||
if (node.content_type !== 'TEXT' || hasReactions || node.replied_to_message) {
|
||||
interesting.push({ thread: t.thread_title, node });
|
||||
if (edge.node?.content_type === 'IMAGES') {
|
||||
found.push(edge.node);
|
||||
}
|
||||
}
|
||||
} 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.waitForTimeout(6000);
|
||||
|
||||
const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', els =>
|
||||
[...new Set(els.map(el => el.getAttribute('href')))]);
|
||||
const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', (els) => [
|
||||
...new Set(els.map((el) => el.getAttribute('href'))),
|
||||
]);
|
||||
|
||||
for (const href of threadHrefs) {
|
||||
await page.goto(`https://www.instagram.com${href}`);
|
||||
await page.waitForTimeout(3500);
|
||||
}
|
||||
|
||||
console.log('[INTERESTING MESSAGES]', JSON.stringify(interesting, null, 2));
|
||||
console.log('[IMAGES MESSAGES]', JSON.stringify(found, null, 2));
|
||||
});
|
||||
@@ -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) {
|
||||
const senderDict = node.sender?.user_dict;
|
||||
const resolved = senderDict
|
||||
@@ -85,6 +97,7 @@ function extractMessage(node, thread, fbidLookup) {
|
||||
text: node.text_body || node.content?.text_body || null,
|
||||
timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null,
|
||||
share: extractShare(node),
|
||||
attachments: extractAttachments(node),
|
||||
reactions: reactions.length ? reactions : null,
|
||||
replied_to_id: node.replied_to_message_id || null,
|
||||
replied_to_text: node.replied_to_message?.text_body || null,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import Avatar from '@/components/dredge/Avatar.vue';
|
||||
import MessageText from './MessageText.vue';
|
||||
import MessageShare from './MessageShare.vue';
|
||||
import MessageStoryShare from './MessageStoryShare.vue';
|
||||
import MessageMedia from './MessageMedia.vue';
|
||||
import MessageSystem from './MessageSystem.vue';
|
||||
import type { ThreadMessage } from '@/types/dredge.types';
|
||||
|
||||
@@ -26,9 +28,16 @@ defineProps<{
|
||||
{{ message.replied_to_text }}
|
||||
</p>
|
||||
|
||||
<MessageShare v-if="message.shared_url" :message="message" />
|
||||
<MessageText v-else-if="message.text" :message="message" />
|
||||
<MessageSystem v-else :message="message" :is-own="isOwn" />
|
||||
<!-- Text is a universal concern shown alongside whatever else the
|
||||
message carries, not one branch of a type dispatch — a story
|
||||
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">
|
||||
<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<{
|
||||
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<{
|
||||
@@ -30,12 +33,15 @@ async function fetchStory() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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');
|
||||
detail.value = await response.json();
|
||||
currentIndex.value = detail.value?.start_index ?? 0;
|
||||
} 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 {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface ThreadReaction {
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface ThreadStoryShare {
|
||||
username: string;
|
||||
item_id: string;
|
||||
exists_locally: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadMessage {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
@@ -90,9 +96,11 @@ export interface ThreadMessage {
|
||||
shared_preview_image: string | null;
|
||||
shared_title: string | null;
|
||||
shared_caption: string | null;
|
||||
attachments: { url: string; width: number | null; height: number | null }[] | null;
|
||||
reactions: ThreadReaction[] | null;
|
||||
replied_to_id: string | null;
|
||||
replied_to_text: string | null;
|
||||
story_share: ThreadStoryShare | null;
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
|
||||
@@ -9,5 +9,6 @@ Route::get('/posts', [PostController::class, 'index']);
|
||||
Route::get('/stories', [StoryController::class, 'index']);
|
||||
Route::get('/stories/{username}', [StoryController::class, 'show']);
|
||||
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/{threadId}', [ThreadController::class, 'show']);
|
||||
|
||||
Reference in New Issue
Block a user