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

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:
Automatic target list generation from hashtags: Retrieve shops posting on both Instagram and TikTok using hashtags like "#OsakaHairSalon" or "#ShinsakibashiBeautySalon"
Integration of data from both SNS platforms: Shops active on both Instagram and TikTok are marked as "priority targets" with bonus scores
Scoring based on SNS activity: Prioritize shops likely to respond positively based on follower counts, last post date, video count, etc.
Automated personalized DM generation: Pass shop name, area, and SNS status (Instagram/TikTok/both) to AI for customized messages
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
Settings → Community Nodes → Install
Package name:
@apify/n8n-nodes-apifyRestart 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
Obtain API key from Anthropic Console
Create Anthropic API credential in n8n
4. Prepare Google Sheets
Create spreadsheet for output
Set up Google Sheets OAuth2 credential in n8n
Note the Document ID (between
/d/and/editin 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)
| Field | Value | Description |
| hashtagsString | ShinsakibashiSalon,OsakaSalon,OsakaHairSalon | Comma-separated hashtags |
| resultsPerHashtag | 50 | Results per hashtag |
| area | Osaka | Area name (used in DM text and sheet output) |
Node 3: Apify (Instagram Hashtag Scraper)
Important: Change Actor Source to "By ID" before configuration.
| Item | Value |
| Resource | Actor |
| Operation | Run an Actor and Get Dataset |
| Actor Source | By ID |
| Actor ID | apify/instagram-hashtag-scraper (in n8n: apify~instagram-hashtag-scraper with tilde) |
| Custom Body | See 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.
| Item | Value |
| Actor Source | By ID |
| Actor ID | clockworks/tiktok-scraper (in n8n: clockworks~tiktok-scraper with tilde) |
| Custom Body | See 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)
| Condition | Setting |
| Value 1 | `{{ ($json.instagramUrl |
| Operation | Larger Than |
| Value 2 | 0 |
Node 9: Apify (Instagram Scraper)
| Item | Value |
| Actor Source | By ID |
| Actor ID | apify/instagram-scraper |
| Custom Body | See 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)
| Condition | Setting |
| Condition 1 | {{ $json.businessScore }} ≥ 50 |
| Condition 2 | {{ $json.stylusFit }} equals true |
| Combine | AND |
Node 12: Split In Batches
| Setting | Value |
| Batch Size | 1 |
| Options > Reset | Check |
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)
| Setting | Value |
| Resource | Sheet |
| Operation | Append Row |
| Document | (Your spreadsheet ID) |
| Sheet | (Sheet name) |
| Data Mode | Define 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:
| Category | Count (Estimate) | Description |
| Instagram-only leads | 50-100 | Shops using only Instagram |
| TikTok-only leads | 100-200 | Shops using only TikTok |
| Both platforms leads | 20-50 | Shops using both Instagram + TikTok (priority targets) |
| Total leads | 170-350 | Total after deduplication |
| Filtered targets | 50-150 | businessScore ≥ 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

