Skip to main content

Command Palette

Search for a command to run...

Building an "Instagram + TikTok Hashtag Search → Auto-DM Generation" Pipeline with n8n × Apify × Claude

Published
13 min read
Building an "Instagram + TikTok Hashtag Search → Auto-DM Generation" Pipeline with n8n × Apify × Claude
M

AI Engineer based in Nara, pivoting from a 20-year career as a hairdresser. Currently building Stylus (AI captioning for salons) and Harmony (Instagram automation). Obsessed with workflow automation using n8n, Python, and LLMs. My mission is to solve inefficiencies in the beauty industry through code. Writing about SaaS development, automation, and my journey from the salon floor to software architecture.


Introduction

Manually creating sales lists and copy-pasting template DMs consumes an enormous amount of time.

This article explains how to fully automate the entire process—from retrieving shop lists via Instagram and TikTok hashtag searches → SNS analysis → AI-generated personalized DM messages for each shop → exporting to Google Sheets—using a single n8n workflow.

Technology Stack

  • Apify: Data collection via Instagram Hashtag Scraper / TikTok Scraper

  • n8n: Flow control, data processing, AI invocation, and Sheets integration

  • Claude (AI Agent): Personalized DM message generation for each shop

  • Google Sheets: Centralized management of sales lists and generated DM messages

Important: This workflow does not include DM sending. For spam prevention and account protection, we recommend manually sending messages based on the generated text in the sheet.


What You Can Achieve (Goals)

This workflow automates the following:

  1. Automatic target list generation from hashtags: Retrieve shops posting on both Instagram and TikTok using hashtags like "#OsakaHairSalon" or "#ShinsakibashiBeautySalon"

  2. Integration of data from both SNS platforms: Shops active on both Instagram and TikTok are marked as "priority targets" with bonus scores

  3. Scoring based on SNS activity: Prioritize shops likely to respond positively based on follower counts, last post date, video count, etc.

  4. Automated personalized DM generation: Pass shop name, area, and SNS status (Instagram/TikTok/both) to AI for customized messages

  5. Automatic export to Google Sheets: Output in a format ready for sales teams to send sequentially


Why Use Both Instagram + TikTok?

  • Instagram: Widely used by hair salons. Easy to discover shop accounts via hashtag search.

  • TikTok: Discover shops leveraging video content for customer acquisition. Target "growing shops" with 500-5,000 followers.

  • Shops using both: Shops active on both Instagram and TikTok are more engaged in SNS marketing, making them receptive to service proposals.


Architecture (Overall Flow)

[Manual Trigger]
    ↓
[Set Search Conditions] e.g., Hashtags: "ShinsakibashiSalon,OsakaSalon", 50 results each
    ↓
[Apify: Instagram Hashtag Scraper] Wait for dataset retrieval
    ↓
[Code] Deduplicate Instagram posts by author, build profile URL list
    ↓
[Apify: TikTok Hashtag Scraper] Dataset retrieval (sequential execution)
    ↓
[Code] Deduplicate TikTok posts by author, build shop list
    ↓
[Code] Merge Instagram + TikTok data, remove duplicates (match by username)
    ↓
[IF] SNS URL exists? (instagramUrl || tiktokUrl)
    ↓ Yes
[Apify: Instagram Scraper] Retrieve profile details
    ↓
[Code] Merge shop × Instagram data, calculate businessScore / stylusFit
    ↓
[IF] Target criteria (score ≥ 50 & stylusFit)
    ↓ Yes
[Split In Batches] Loop through 1 item at a time
    ↓
[AI Agent + Claude] Generate personalized DM (optimized by platform)
    ↓
[Code] Extract DM text
    ↓
[Google Sheets] Append row
    ↓
(Loop to next item → Complete all items)

Key Point: Instagram and TikTok scrapers run sequentially to avoid Apify's concurrent execution limits. TikTok runs only after Instagram completes.


Prerequisites

1. Install Apify Community Nodes in n8n

  • SettingsCommunity NodesInstall

  • Package name: @apify/n8n-nodes-apify

  • Restart n8n after installation (for Docker: docker restart n8n)

2. Get Apify API Token

  • Log in to Apify Console

  • Integrations → Copy Personal API token (or create new)

  • Create Apify API credential in n8n and set the token

3. Get Anthropic API Key

4. Prepare Google Sheets

  • Create spreadsheet for output

  • Set up Google Sheets OAuth2 credential in n8n

  • Note the Document ID (between /d/ and /edit in URL) and Sheet Name


Node Configuration (Detailed)

Node 1: Manual Trigger

Manual execution trigger for testing and pre-production validation.

Node 2: Set (Search Conditions)

FieldValueDescription
hashtagsStringShinsakibashiSalon,OsakaSalon,OsakaHairSalonComma-separated hashtags
resultsPerHashtag50Results per hashtag
areaOsakaArea name (used in DM text and sheet output)

Node 3: Apify (Instagram Hashtag Scraper)

Important: Change Actor Source to "By ID" before configuration.

ItemValue
ResourceActor
OperationRun an Actor and Get Dataset
Actor SourceBy ID
Actor IDapify/instagram-hashtag-scraper (in n8n: apify~instagram-hashtag-scraper with tilde)
Custom BodySee below

Custom Body:

{
  "hashtags": {{ JSON.stringify($json.hashtagsString.split(',').map(s => s.trim().replace(/^#/, ''))) }},
  "resultsLimit": {{ $json.resultsPerHashtag ?? 50 }}
}

Node 4: Code (Deduplicate Instagram Authors)

// Deduplicate Instagram Hashtag Scraper authors and build profile URL list
const raw = $input.all().map(i => i.json);

// Error handling
if (raw.length > 0 && raw[0].error === 'no_items') {
  return { json: { shops: [], directUrls: [] } };
}

const config = $('2. Search Conditions').first().json;
const area = config.area || '';
const keywordLabel = config.hashtagsString || area;
const seen = new Set();
const shops = [];

for (const p of raw) {
  const username = p.ownerUsername || p.owner?.username;
  if (!username) continue;

  const key = username.toLowerCase();
  if (seen.has(key)) continue;
  seen.add(key);

  const igUrl = `https://www.instagram.com/${username}/`;
  shops.push({
    storeName: p.ownerFullName || p.owner?.fullName || username || 'Unknown',
    address: '',
    area,
    instagramUrl: igUrl,
    reviewCount: 0,
    totalScore: null,
    website: '',
    phone: '',
    keywordLabel,
    source: 'instagram'
  });
}

return { json: { shops, directUrls: shops.map(s => s.instagramUrl) } };

Node 5: Apify (TikTok Hashtag Scraper)

Important: For sequential execution, reference directly from the search conditions node.

ItemValue
Actor SourceBy ID
Actor IDclockworks/tiktok-scraper (in n8n: clockworks~tiktok-scraper with tilde)
Custom BodySee below

Custom Body:

{
  "hashtags": {{ JSON.stringify($node["2. Search Conditions"].json.hashtagsString.split(',').map(s => s.trim().replace(/^#/, ''))) }},
  "resultsPerPage": {{ $node["2. Search Conditions"].json.resultsPerHashtag ?? 50 }},
  "shouldDownloadVideos": false,
  "shouldDownloadCovers": false,
  "shouldDownloadAvatars": false
}

Note: Use $node["2. Search Conditions"] instead of $json because sequential execution means the previous node's output doesn't contain hashtagsString.

Node 6: Code (Integrate TikTok Profiles)

// Deduplicate TikTok Hashtag Scraper profiles and build shop list
const raw = $input.all().map(i => i.json);

// Error handling
if (raw.length === 0 || (raw.length > 0 && raw[0] && raw[0].error === 'no_items')) {
  return { json: { shops: [], profileUrls: [] } };
}

const config = $('2. Search Conditions').first().json;
const area = config.area || '';
const keywordLabel = config.hashtagsString || area;
const seen = new Set();
const shops = [];

for (const profile of raw) {
  const username = profile.authorMeta?.name || profile.author;
  if (!username) continue;

  const key = username.toLowerCase();
  if (seen.has(key)) continue;
  seen.add(key);

  const tiktokUrl = `https://www.tiktok.com/@${username}`;
  const bio = profile.authorMeta?.signature || '';
  const followers = profile.authorMeta?.fans || 0;

  // Filter: 500-5000 followers, salon-related bio
  const isSalonRelated = bio.includes('salon') || bio.includes('beauty') || 
                         bio.includes('hair') || bio.includes('stylist');
  const hasLineLink = bio.includes('LINE') || bio.includes('lin.ee');

  if (!isSalonRelated) continue;
  if (followers < 500 || followers > 5000) continue;

  shops.push({
    storeName: profile.authorMeta?.nickName || username || 'Unknown',
    address: '',
    area,
    tiktokUrl,
    instagramUrl: '',
    reviewCount: 0,
    totalScore: null,
    website: '',
    phone: '',
    keywordLabel,
    platform: 'tiktok',
    username,
    followersCount: followers,
    biography: bio.slice(0, 200),
    videoCount: profile.authorMeta?.video || 0,
    totalLikes: profile.authorMeta?.heart || 0,
    hasLineLink,
    source: 'tiktok'
  });
}

return { json: { shops, profileUrls: shops.map(s => s.tiktokUrl) } };

Node 7: Code (Merge Instagram + TikTok)

// Merge Instagram and TikTok data, remove duplicates (sequential execution version)
let igData = [];
let ttData = [];

try {
  const igNode = $('4. Filter Shops with Instagram');
  if (igNode && igNode.first()) {
    igData = igNode.first().json.shops || [];
  }
} catch (e) {
  igData = [];
}

try {
  const ttNode = $('6. Integrate TikTok Profiles');
  if (ttNode && ttNode.first()) {
    ttData = ttNode.first().json.shops || [];
  }
} catch (e) {
  ttData = [];
}

const seen = new Map();
const merged = [];

// Instagram data
for (const shop of igData) {
  const key = (shop.username || shop.instagramUrl || '').toLowerCase().replace(/https?:\/\//, '').replace(/\/$/, '');
  if (!key) continue;
  const username = key.split('/').pop() || key;
  seen.set(username, { ...shop, source: shop.source || 'instagram' });
}

// TikTok data (merge if duplicate with Instagram)
for (const shop of ttData) {
  const key = (shop.username || '').toLowerCase();
  if (!key) continue;

  if (seen.has(key)) {
    const existing = seen.get(key);
    existing.tiktokUrl = shop.tiktokUrl;
    existing.videoCount = shop.videoCount;
    existing.totalLikes = shop.totalLikes;
    existing.hasLineLink = existing.hasLineLink || shop.hasLineLink;
    existing.source = 'both';
  } else {
    seen.set(key, { ...shop, source: 'tiktok' });
  }
}

// Convert to array
for (const shop of seen.values()) {
  merged.push(shop);
}

if (merged.length === 0) {
  return [{ json: { shops: [], directUrls: [] } }];
}

return merged.map(j => ({ json: j }));

Node 8: IF (SNS URL Check)

ConditionSetting
Value 1`{{ ($json.instagramUrl
OperationLarger Than
Value 20

Node 9: Apify (Instagram Scraper)

ItemValue
Actor SourceBy ID
Actor IDapify/instagram-scraper
Custom BodySee below

Custom Body:

{
  "directUrls": {{ JSON.stringify(($json.instagramUrl ? [$json.instagramUrl] : []).filter(u => u)) }},
  "resultsType": "details",
  "resultsLimit": 5
}

Node 10: Code (Merge & Scoring)

// Merge shop data with Instagram data and calculate scoring
const igItems = $input.all().map(i => i.json);
const shops = $('7. Merge Instagram + TikTok').all().map(i => i.json);

const norm = (u) => (u || '').replace(/\/$/, '').toLowerCase();
const merged = [];

for (const shop of shops) {
  let businessScore = 0;
  let stylusFit = false;

  // If Instagram data exists
  if (shop.source === 'instagram' || shop.source === 'both') {
    const profile = igItems.find(p => {
      const pu = p.url || p.profileUrl || (p.username ? `https://www.instagram.com/${p.username}/` : '');
      return norm(pu) === norm(shop.instagramUrl);
    });

    if (profile) {
      const followers = profile.followersCount ?? profile.followers ?? 0;
      const posts = profile.latestPosts || [];
      const lastPost = posts[0];
      const lastPostDate = lastPost?.timestamp ? new Date(lastPost.timestamp) : null;
      const monthsSincePost = lastPostDate ? (Date.now() - lastPostDate) / (30 * 24 * 60 * 60 * 1000) : 99;
      const hasReel = (profile.latestPosts || []).some(p => (p.type || '').toLowerCase().includes('reel') || (p.url || '').includes('/reel/'));
      const bio = (profile.biography || '').toLowerCase();
      const hasLineLink = (profile.externalUrl || '').includes('lin.ee') || bio.includes('line');

      businessScore += (profile.isBusinessAccount ? 30 : 0) +
                       (bio.includes('booking') || bio.includes('salon') || bio.includes('beauty') ? 25 : 0) +
                       (profile.externalUrl ? 15 : 0) +
                       (followers <= 5000 ? 15 : 0) +
                       (monthsSincePost <= 0.5 ? 20 : monthsSincePost <= 1 ? 10 : 0) +
                       (hasReel ? 10 : 0) +
                       (hasLineLink ? 15 : 0);

      stylusFit = followers >= 500 && followers <= 3000 && monthsSincePost <= 1;

      Object.assign(shop, {
        username: profile.username || shop.username,
        followersCount: followers,
        postsCount: profile.postsCount ?? shop.postsCount ?? 0,
        biography: (profile.biography || shop.biography || '').slice(0, 200),
        lastPostAt: lastPostDate ? lastPostDate.toISOString() : null,
        monthsSincePost: Math.round(monthsSincePost * 10) / 10,
        hasReel,
        hasLineLink: hasLineLink || shop.hasLineLink
      });
    }
  }

  // If TikTok data exists (additional score)
  if (shop.source === 'tiktok' || shop.source === 'both') {
    businessScore += (shop.hasLineLink ? 20 : 0) +
                     (shop.videoCount >= 10 ? 15 : 0) +
                     (shop.followersCount >= 500 && shop.followersCount <= 5000 ? 20 : 0);

    if (shop.source === 'tiktok') {
      stylusFit = shop.followersCount >= 500 && shop.followersCount <= 5000;
    }
  }

  // Bonus for shops using both SNS platforms
  if (shop.source === 'both') {
    businessScore += 25;
  }

  merged.push({
    ...shop,
    businessScore,
    stylusFit
  });
}

return merged.map(j => ({ json: j }));

Node 11: IF (Target Filter)

ConditionSetting
Condition 1{{ $json.businessScore }}50
Condition 2{{ $json.stylusFit }} equals true
CombineAND

Node 12: Split In Batches

SettingValue
Batch Size1
Options > ResetCheck

Node 13: AI Agent (DM Generation)

Type: Tools Agent
Chat Model: Anthropic Chat Model (connect via subnode)

System Message:

You are an Instagram/TikTok marketing consultant for beauty salons.
Create one friendly and sincere DM message for the requested shop.
Avoid being pushy and focus on empathetic proposals that align with their situation.

User Message:

Create one DM message proposing "Stylus," an Instagram/TikTok management improvement tool for beauty salons in {{ $json.area }}.

【Shop】{{ $json.storeName }}
【Area】{{ $json.area }}
【Platform】{{ $json.source }}
{{ $json.instagramUrl ? '【Instagram】' + $json.instagramUrl : '' }}
{{ $json.tiktokUrl ? '【TikTok】' + $json.tiktokUrl : '' }}
【Followers】{{ $json.followersCount }}
{{ $json.monthsSincePost ? '【Last Post】' + $json.monthsSincePost + ' months ago' : '' }}
{{ $json.videoCount ? '【TikTok Videos】' + $json.videoCount : '' }}
【Bio Excerpt】{{ $json.biography }}

Requirements: 
- Optimize message based on platform (Instagram/TikTok/both)
- For TikTok, emphasize "converting video engagement into bookings"
- Friendly, not sales-y, under 200 characters
- Sign as "Osugi, a hairstylist in Nara"
- Output DM text only, no extra explanation

Node 14: Code (Extract DM Text)

// Extract DM text from AI Agent output
const items14 = $('11. Target Filter').all();
const items16 = $input.all();
const result = [];

for (let i = 0; i < items16.length; i++) {
  const item = (items14[i] || items14[0]).json;
  const res = items16[i].json;

  // Adjust based on AI Agent output structure
  let dmText = (res.output !== undefined ? res.output : res.text) || '';
  if (typeof dmText !== 'string') dmText = String(dmText);
  dmText = dmText.trim();

  result.push({ 
    json: { 
      ...item, 
      dmText 
    } 
  });
}

return result;

Node 15: Google Sheets (Append)

SettingValue
ResourceSheet
OperationAppend Row
Document(Your spreadsheet ID)
Sheet(Sheet name)
Data ModeDefine Below

Column Mapping Example:

  • title{{ $json.storeName }}

  • address{{ $json.address || '' }}

  • instagram_url{{ $json.instagramUrl }}

  • tiktok_url{{ $json.tiktokUrl || '' }}

  • platform_source{{ $json.source || 'instagram' }}

  • video_count{{ $json.videoCount ?? '' }}

  • total_likes{{ $json.totalLikes ?? '' }}

  • has_line_link{{ $json.hasLineLink ? 'true' : 'false' }}

  • ig_followers{{ $json.followersCount }}

  • ig_days_since_last_post{{ $json.monthsSincePost != null ? Math.round(Number($json.monthsSincePost) * 30) : '' }}

  • businessScore{{ $json.businessScore ?? '' }}

  • stylusFit{{ $json.stylusFit }}

  • notes{{ $json.dmText || '' }} (Generated DM text)

  • scrapedAt{{ $now.toISO() }}


Troubleshooting

TikTok Scraper Returns 404 Error

Cause: Incorrect Actor ID format
Solution: Use clockworks~tiktok-scraper (tilde format) in n8n, not clockworks/tiktok-scraper

"Could not parse custom body" Error in TikTok Node

Cause: $json.hashtagsString doesn't exist (not included in previous node's output due to sequential execution)
Solution: Reference directly from search conditions node using $node["2. Search Conditions"].json.hashtagsString

TikTok Doesn't Execute After Instagram Completes

Cause: Concurrent execution hitting Apify's simultaneous execution limit
Solution: Switch to sequential execution (Instagram → TikTok one at a time). Change connection to "7. Filter Instagram Shops" → "5. Apify TikTok"

Workflow Stops on Error

Solution: Enable "Continue On Fail" in the Options tab of each Apify node. This allows the workflow to continue with empty data on error.


Expected Output

For search conditions: hashtagsString: "OsakaSalon,ShinsakibashiSalon", resultsPerHashtag: 50:

CategoryCount (Estimate)Description
Instagram-only leads50-100Shops using only Instagram
TikTok-only leads100-200Shops using only TikTok
Both platforms leads20-50Shops using both Instagram + TikTok (priority targets)
Total leads170-350Total after deduplication
Filtered targets50-150businessScore ≥ 50 and stylusFit === true

Operational Recommendations

Cost Management

  • Apify uses volume-based pricing

  • Instagram Hashtag Scraper: ~$2.60 / 1,000 results

  • TikTok Scraper: Pay per event (varies by hashtag count and results)

  • Test with small volumes before production to verify costs

Compliance

  • Comply with Instagram and TikTok Terms of Service

  • Respect rate limits (recommend ≤10 requests per minute)

  • Send DMs manually to avoid spam detection

Scheduled Execution

  • Use n8n's Schedule Trigger for automatic weekly execution

  • Add "sent" flag in Google Sheets to prevent duplicate messages to same shops


Summary

This workflow enables:

  • Retrieve shop lists from Instagram and TikTok hashtag searches, using sequential execution to avoid Apify's concurrent execution limits

  • Merge data from both SNS platforms, deduplicate by username, assign bonus scores to shops using both platforms (source: 'both')

  • Retrieve profile details with Apify's Instagram Scraper, use scoring to select targets

  • Generate personalized DM messages with n8n AI Agent (Claude), optimized by platform

  • Export to Google Sheets, enabling sales teams to copy and send messages sequentially

Key construction points: Set Apify node Actor Source to "By ID", use sequential execution to avoid concurrent limits, reference search conditions node directly in TikTok node.

The same configuration can be adapted for other industries (restaurants, massage clinics, esthetics, etc.) by changing industry, area, and scoring conditions.


Recommended Versions (as of February 2026):

  • n8n: 2.7.4 or later

  • @apify/n8n-nodes-apify: Latest version

  • Claude API: claude-sonnet-4-5-20250929 recommended