Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions cli/import-organizer/instagram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { IgApiClient } from 'instagram-private-api'

export type InstagramProfileData = {
username: string
fullName: string
biography: string
photoUrl?: string
externalUrl?: string
followerCount: number
isVerified: boolean
}

export async function getInstagramProfile(
instagramUrl: string
): Promise<InstagramProfileData> {
const username = instagramUrl
.replace('https://', '')
.replace('http://', '')
.replace('www.', '')
.replace('instagram.com/', '')
.replace('instagr.am/', '')
.replace(/\/$/, '')
.split('/')[0]
.split('?')[0]

Comment on lines +16 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use URL-based parsing; handle /_u and validate username.

String replaces are brittle; enforce host, support /_u/{username}, and validate.

Apply:

-  const username = instagramUrl
-    .replace('https://', '')
-    .replace('http://', '')
-    .replace('www.', '')
-    .replace('instagram.com/', '')
-    .replace('instagr.am/', '')
-    .replace(/\/$/, '')
-    .split('/')[0]
-    .split('?')[0]
+  const url = new URL(instagramUrl)
+  const [first, second] = url.pathname.replace(/^\/+|\/+$/g, '').split('/')
+  let username = (first?.toLowerCase() === '_u' ? second : first) || ''
+  username = username.replace(/^@+/, '').split(/[?#]/, 1)[0]?.toLowerCase() || ''
+  const reserved = new Set(['p', 'reel', 'stories', 'explore', 'accounts'])
+  const valid = /^[a-z0-9_]([a-z0-9_.]*[a-z0-9_])?$/i
+  if (!username || username.length > 30 || !valid.test(username) || reserved.has(username) || /\.\./.test(username)) {
+    throw new Error('Invalid Instagram URL: could not extract valid username')
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const username = instagramUrl
.replace('https://', '')
.replace('http://', '')
.replace('www.', '')
.replace('instagram.com/', '')
.replace('instagr.am/', '')
.replace(/\/$/, '')
.split('/')[0]
.split('?')[0]
const url = new URL(instagramUrl)
const [first, second] = url.pathname.replace(/^\/+|\/+$/g, '').split('/')
let username = (first?.toLowerCase() === '_u' ? second : first) || ''
username = username.replace(/^@+/, '').split(/[?#]/, 1)[0]?.toLowerCase() || ''
const reserved = new Set(['p', 'reel', 'stories', 'explore', 'accounts'])
const valid = /^[a-z0-9_]([a-z0-9_.]*[a-z0-9_])?$/i
if (!username || username.length > 30 || !valid.test(username) || reserved.has(username) || /\.\./.test(username)) {
throw new Error('Invalid Instagram URL: could not extract valid username')
}
🤖 Prompt for AI Agents
In cli/import-organizer/instagram.ts around lines 16 to 25, replace the brittle
chain of string.replace calls with URL-based parsing: if instagramUrl lacks a
scheme, prepend "https://", construct a URL object, assert the hostname endsWith
"instagram.com" or "instagr.am", then derive the username from the pathname — if
pathname starts with "/_u/" use the segment after "/_u/", otherwise take the
first non-empty path segment; strip any trailing slashes and ignore search/query
(use URL.pathname), then validate the resulting username against a regex like
/^[A-Za-z0-9._]+$/ and return or error for invalid values. Ensure you handle
plain usernames by detecting when the input is not a URL and fall back to
validating and returning it directly.

if (!username) {
throw new Error('Invalid Instagram URL: could not extract valid username')
}
console.log(`Fetching instagram profile for ${username}`)
const instagram = new IgApiClient()

const IG_USER = process.env.INSTAGRAM_USERNAME
const IG_PASS = process.env.INSTAGRAM_PASSWORD
if (!IG_USER || !IG_PASS) {
throw new Error('INSTAGRAM_USERNAME and INSTAGRAM_PASSWORD must be set')
}

instagram.state.generateDevice(IG_USER)
await instagram.account.login(IG_USER, IG_PASS)
const userInfo = await instagram.user.usernameinfo(username)

console.log(`Successfully fetched data for @${username}`)
return {
username: userInfo.username,
fullName: userInfo.full_name || username,
biography: userInfo.biography || '',
photoUrl: userInfo.profile_pic_url || '',
externalUrl: userInfo.external_url || '',
followerCount: userInfo.follower_count || 0,
isVerified: userInfo.is_verified || false,
}
}
87 changes: 81 additions & 6 deletions components/inputs/ProfileInput.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,74 @@
<script setup lang="ts">
const model = defineModel() as Ref<any>
import { toast } from 'vue-sonner'

const model = defineModel<any>()
const { $client } = useNuxtApp()

const searchQuery = ref('')
const queryKey = computed(() => ['profiles.search', searchQuery.value.trim()])
const { data } = useQuery<any>({
queryKey: ['profiles.search', searchQuery],
queryFn: () => $client.profiles.search.query({ query: searchQuery.value }),
queryKey,
queryFn: ({ queryKey: [, term] }) =>
$client.profiles.search.query({ query: term as string }),
enabled: computed(() => !!(queryKey.value[1] as string)),
retry: false,
})

const isOpen = ref(false)

const instagramUsername = computed(() => {
return extractInstagramUsername(searchQuery.value)
})

const importFromInstagram = async () => {
if (!instagramUsername.value) return

const promise = $client.profiles.createFromInstagram.mutate({
instagramUrl: `https://instagram.com/${instagramUsername.value}`,
})

toast.promise(promise, {
loading: 'Scheduling profile import...',
success: (profile: any) => {
if (profile) {
model.value = profile
isOpen.value = false
searchQuery.value = ''
return 'Profile import scheduled successfully!'
}
return 'Profile import scheduled but no profile returned'
},
error: (error: any) => (error as Error).message,
})
}

// Browser-safe duplication of the backend parsing logic.
function isValidInstagramUsername(username: string): boolean {
if (!username || username.length === 0) return false
if (username.length > 30) return false // Instagram username limit

const validPattern = /^[a-zA-Z0-9_]([a-zA-Z0-9_.]*[a-zA-Z0-9_])?$/
const hasConsecutivePeriods = /\.\./.test(username)

return validPattern.test(username) && !hasConsecutivePeriods
}

function extractInstagramUsername(input: string): string {
const raw = input.trim()

if (raw.startsWith('@')) {
const username = raw.replace(/^@+/, '').split(/[/?#]/, 1)[0].toLowerCase()
return isValidInstagramUsername(username) ? username : ''
}
const match = raw.match(/instagram\.com\/([a-zA-Z0-9_.]+)/)
const candidate = match ? match[1] : ''

// A guard for the most common non-profile routes
if (/^(p|reel|stories)$/i.test(candidate)) {
return ''
}

const normalized = candidate.toLowerCase()
return isValidInstagramUsername(normalized) ? normalized : ''
}
Comment on lines +54 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Broaden extraction: support instagr.am and /_u; strengthen guards.

Current regex misses instagr.am and mis-parses /_u links (e.g., imports “u”). Normalize and validate.

Apply:

 function extractInstagramUsername(input: string): string {
   const raw = input.trim()

   if (raw.startsWith('@')) {
     const username = raw.replace(/^@+/, '').split(/[/?#]/, 1)[0].toLowerCase()
     return isValidInstagramUsername(username) ? username : ''
   }
-  const match = raw.match(/instagram\.com\/([a-zA-Z0-9_.]+)/)
-  const candidate = match ? match[1] : ''
-
-  // A guard for the most common non-profile routes
-  if (/^(p|reel|stories)$/i.test(candidate)) {
-    return ''
-  }
-
-  const normalized = candidate.toLowerCase()
-  return isValidInstagramUsername(normalized) ? normalized : ''
+  const hostRe = /^(?:https?:\/\/)?(?:www\.)?(instagram\.com|instagr\.am)\//i
+  const afterHost = hostRe.test(raw)
+    ? raw.replace(hostRe, '')
+    : raw
+  // Handle /_u/username deep links
+  const parts = afterHost.split(/[/?#]/).filter(Boolean)
+  let candidate = parts[0] && parts[0].toLowerCase() === '_u' ? parts[1] || '' : parts[0] || ''
+  candidate = candidate.replace(/^@+/, '').toLowerCase()
+  const reserved = new Set(['p', 'reel', 'stories', 'explore', 'accounts'])
+  if (!candidate || reserved.has(candidate)) return ''
+  return isValidInstagramUsername(candidate) ? candidate : ''
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function extractInstagramUsername(input: string): string {
const raw = input.trim()
if (raw.startsWith('@')) {
const username = raw.replace(/^@+/, '').split(/[/?#]/, 1)[0].toLowerCase()
return isValidInstagramUsername(username) ? username : ''
}
const match = raw.match(/instagram\.com\/([a-zA-Z0-9_.]+)/)
const candidate = match ? match[1] : ''
// A guard for the most common non-profile routes
if (/^(p|reel|stories)$/i.test(candidate)) {
return ''
}
const normalized = candidate.toLowerCase()
return isValidInstagramUsername(normalized) ? normalized : ''
}
function extractInstagramUsername(input: string): string {
const raw = input.trim()
if (raw.startsWith('@')) {
const username = raw.replace(/^@+/, '').split(/[/?#]/, 1)[0].toLowerCase()
return isValidInstagramUsername(username) ? username : ''
}
const hostRe = /^(?:https?:\/\/)?(?:www\.)?(instagram\.com|instagr\.am)\//i
const afterHost = hostRe.test(raw)
? raw.replace(hostRe, '')
: raw
// Handle /_u/username deep links
const parts = afterHost.split(/[/?#]/).filter(Boolean)
let candidate = parts[0] && parts[0].toLowerCase() === '_u' ? parts[1] || '' : parts[0] || ''
candidate = candidate.replace(/^@+/, '').toLowerCase()
const reserved = new Set(['p', 'reel', 'stories', 'explore', 'accounts'])
if (!candidate || reserved.has(candidate)) return ''
return isValidInstagramUsername(candidate) ? candidate : ''
}
🤖 Prompt for AI Agents
In components/inputs/ProfileInput.vue around lines 54 to 71, the Instagram
extraction logic should be expanded to recognize both instagram.com and
instagr.am hosts and to correctly handle URLs using the '/_u/username' path
(avoiding returning just "u"); update the regex to match both domains and
capture the username as the first actual profile path segment (allowing an
optional '_u/' prefix), then normalize to lower case and run the existing
isValidInstagramUsername check; also strengthen the guard to reject common
non-profile segments (p, reel, stories, _u, direct, explore, about, etc.) before
validating so only real usernames are returned.

</script>

<template>
Expand Down Expand Up @@ -57,9 +115,26 @@ const isOpen = ref(false)
</span>
</div>
</ComboboxAnchor>
<p class="text-xs text-muted-foregorund mt-1 px-1">
Or paste a link for an Instagram profile
</p>

<ComboboxList class="w-[260px]">
<ComboboxEmpty> No profiles found. </ComboboxEmpty>
<div v-if="instagramUsername">
<div
class="cursor-pointer p-2 flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md"
@click="importFromInstagram"
>
<Icon name="heroicons:link" class="h-4 w-4" />
<span>Import @{{ instagramUsername }} from Instagram</span>
</div>
</div>

<ComboboxEmpty
v-if="!data?.length && !instagramUsername && searchQuery"
>
No profiles found.
</ComboboxEmpty>

<ComboboxGroup>
<ComboboxItem
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"html-entities": "^2.6.0",
"htmlparser2": "^10.0.0",
"ical": "^0.8.0",
"instagram-private-api": "^1.46.1",
"lucide-vue-next": "^0.487.0",
"mailgun.js": "^12.0.3",
"markdown-it": "^14.1.0",
Expand Down
Loading
Loading