insta-collect-data 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- insta_collect/__init__.py +0 -0
- insta_collect/cli.py +246 -0
- insta_collect/exporter.py +6 -0
- insta_collect/html_parser.py +76 -0
- insta_collect/insta-collect.py +43 -0
- insta_collect/main.py +9 -0
- insta_collect/saver.py +20 -0
- insta_collect/scraper.py +454 -0
- insta_collect_data-0.2.0.dist-info/METADATA +313 -0
- insta_collect_data-0.2.0.dist-info/RECORD +13 -0
- insta_collect_data-0.2.0.dist-info/WHEEL +5 -0
- insta_collect_data-0.2.0.dist-info/entry_points.txt +2 -0
- insta_collect_data-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
insta_collect/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import itertools
|
|
7
|
+
import threading
|
|
8
|
+
import contextlib
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from insta_collect.scraper import scrape_hashtag, scrape_profile, scrape_profiles
|
|
12
|
+
from insta_collect.saver import save_data
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PREVIEW_LIMIT = 5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ================= SPINNER =================
|
|
19
|
+
def spinner_task(message, stop_event):
|
|
20
|
+
spinner = itertools.cycle(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
|
|
21
|
+
while not stop_event.is_set():
|
|
22
|
+
sys.stdout.write(f"\r{message} {next(spinner)}")
|
|
23
|
+
sys.stdout.flush()
|
|
24
|
+
time.sleep(0.1)
|
|
25
|
+
|
|
26
|
+
sys.stdout.write(f"\r{message} done\n")
|
|
27
|
+
sys.stdout.flush()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ================= PREVIEW =================
|
|
31
|
+
def preview_posts(data, n=PREVIEW_LIMIT):
|
|
32
|
+
print("\n--- SAMPLE POSTS ---")
|
|
33
|
+
|
|
34
|
+
for i, post in enumerate(data[:n], start=1):
|
|
35
|
+
url = post.get("url", "[no url]")
|
|
36
|
+
|
|
37
|
+
caption = post.get("caption") or ""
|
|
38
|
+
caption = caption.replace("\n", " ").strip()
|
|
39
|
+
caption_preview = caption[:120] + ("..." if len(caption) > 120 else "")
|
|
40
|
+
|
|
41
|
+
hashtags = post.get("hashtags", [])
|
|
42
|
+
hashtag_preview = " ".join(f"#{h}" for h in hashtags[:5])
|
|
43
|
+
|
|
44
|
+
likes = post.get("likes")
|
|
45
|
+
comments = post.get("comments")
|
|
46
|
+
views = post.get("views")
|
|
47
|
+
|
|
48
|
+
print(f"[{i}] {url}")
|
|
49
|
+
|
|
50
|
+
if caption_preview:
|
|
51
|
+
print(f" caption : {caption_preview}")
|
|
52
|
+
else:
|
|
53
|
+
print(" caption : [no caption]")
|
|
54
|
+
|
|
55
|
+
metrics = []
|
|
56
|
+
if likes is not None:
|
|
57
|
+
metrics.append(f"likes={likes}")
|
|
58
|
+
if comments is not None:
|
|
59
|
+
metrics.append(f"comments={comments}")
|
|
60
|
+
if views is not None:
|
|
61
|
+
metrics.append(f"views={views}")
|
|
62
|
+
if metrics:
|
|
63
|
+
print(f" metrics : {', '.join(metrics)}")
|
|
64
|
+
|
|
65
|
+
if hashtag_preview:
|
|
66
|
+
print(f" hashtags: {hashtag_preview}")
|
|
67
|
+
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def preview_profile(profile):
|
|
72
|
+
print(f"\n--- PROFILE @{profile.get('handle')} ---")
|
|
73
|
+
print(f" followers : {profile.get('followers')}")
|
|
74
|
+
print(f" posts : {profile.get('posts_scraped')} scraped")
|
|
75
|
+
print(f" avg eng : {profile.get('avg_engagement')}")
|
|
76
|
+
print(f" ER % : {profile.get('engagement_rate_pct')}")
|
|
77
|
+
preview_posts(profile.get("posts") or [], n=PREVIEW_LIMIT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextlib.contextmanager
|
|
81
|
+
def suppress_stdout():
|
|
82
|
+
with open(os.devnull, "w") as devnull:
|
|
83
|
+
old_stdout = sys.stdout
|
|
84
|
+
sys.stdout = devnull
|
|
85
|
+
try:
|
|
86
|
+
yield
|
|
87
|
+
finally:
|
|
88
|
+
sys.stdout = old_stdout
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_handles_from_file(path):
|
|
92
|
+
handles = []
|
|
93
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
94
|
+
for line in f:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line or line.startswith("#"):
|
|
97
|
+
continue
|
|
98
|
+
handles.append(line.lstrip("@"))
|
|
99
|
+
return handles
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ================= MAIN =================
|
|
103
|
+
def main():
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
description="Instagram hashtag / profile scraper"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--tag",
|
|
110
|
+
default=None,
|
|
111
|
+
help="Hashtag mode: hashtag without #"
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--user",
|
|
115
|
+
default=None,
|
|
116
|
+
help="Profile mode: single handle/username (with or without @)"
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--users-file",
|
|
120
|
+
default=None,
|
|
121
|
+
help="Profile mode: text file with one handle per line"
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--limit",
|
|
125
|
+
type=int,
|
|
126
|
+
default=10,
|
|
127
|
+
help="Max posts per target (use 6-12 for engagement)"
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--profile",
|
|
131
|
+
default="ig_profile",
|
|
132
|
+
help="Folder profile Playwright (persistent login)"
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--headed",
|
|
136
|
+
action="store_true",
|
|
137
|
+
help="Open a visible browser window (for first-time login / debugging). Default is headless."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
|
|
142
|
+
profile_mode = bool(args.user or args.users_file)
|
|
143
|
+
if not profile_mode and not args.tag:
|
|
144
|
+
parser.error("Provide --tag (hashtag) OR --user / --users-file (profile)")
|
|
145
|
+
if profile_mode and args.tag:
|
|
146
|
+
parser.error("Use either --tag or profile mode (--user/--users-file), not both")
|
|
147
|
+
|
|
148
|
+
handles = []
|
|
149
|
+
if args.user:
|
|
150
|
+
handles = [h.strip() for h in args.user.split(",") if h.strip()]
|
|
151
|
+
if args.users_file:
|
|
152
|
+
handles.extend(load_handles_from_file(args.users_file))
|
|
153
|
+
|
|
154
|
+
mode = "profile" if profile_mode else "hashtag"
|
|
155
|
+
print(f"▶ Mode : {mode}")
|
|
156
|
+
if profile_mode:
|
|
157
|
+
print(f"▶ Handles : {', '.join('@' + h.lstrip('@') for h in handles)}")
|
|
158
|
+
else:
|
|
159
|
+
print(f"▶ Target hashtag : #{args.tag}")
|
|
160
|
+
print(f"▶ Max posts : {args.limit}")
|
|
161
|
+
print(f"▶ Browser : {'headed' if args.headed else 'headless'}\n")
|
|
162
|
+
|
|
163
|
+
stop_event = threading.Event()
|
|
164
|
+
label = "Collecting profiles..." if profile_mode else "Collecting posts..."
|
|
165
|
+
spinner_thread = threading.Thread(
|
|
166
|
+
target=spinner_task,
|
|
167
|
+
args=(f"[SCRAPING] {label}", stop_event),
|
|
168
|
+
daemon=True,
|
|
169
|
+
)
|
|
170
|
+
spinner_thread.start()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
with suppress_stdout():
|
|
174
|
+
if profile_mode:
|
|
175
|
+
if len(handles) == 1:
|
|
176
|
+
data = scrape_profile(
|
|
177
|
+
handles[0],
|
|
178
|
+
limit=args.limit,
|
|
179
|
+
profile_dir=args.profile,
|
|
180
|
+
headless=not args.headed,
|
|
181
|
+
)
|
|
182
|
+
data = [data]
|
|
183
|
+
else:
|
|
184
|
+
data = scrape_profiles(
|
|
185
|
+
handles,
|
|
186
|
+
limit=args.limit,
|
|
187
|
+
profile_dir=args.profile,
|
|
188
|
+
headless=not args.headed,
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
data = scrape_hashtag(
|
|
192
|
+
hashtag=args.tag,
|
|
193
|
+
limit=args.limit,
|
|
194
|
+
profile_dir=args.profile,
|
|
195
|
+
headless=not args.headed,
|
|
196
|
+
)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
stop_event.set()
|
|
199
|
+
spinner_thread.join()
|
|
200
|
+
print(f"[!] {e}")
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
stop_event.set()
|
|
204
|
+
spinner_thread.join()
|
|
205
|
+
|
|
206
|
+
if profile_mode:
|
|
207
|
+
print(f"[DONE] {len(data)} profile(s) processed")
|
|
208
|
+
if not data:
|
|
209
|
+
print("[!] No profiles scraped.")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
for profile in data:
|
|
213
|
+
if profile.get("error"):
|
|
214
|
+
print(f"[!] @{profile['handle']}: {profile['error']}")
|
|
215
|
+
else:
|
|
216
|
+
preview_profile(profile)
|
|
217
|
+
|
|
218
|
+
# Flat summary always useful for engagement work
|
|
219
|
+
print("\n--- ENGAGEMENT SUMMARY ---")
|
|
220
|
+
print(f"{'handle':<20} {'followers':>12} {'posts':>6} {'avg_eng':>12} {'ER%':>10}")
|
|
221
|
+
for profile in data:
|
|
222
|
+
print(
|
|
223
|
+
f"@{profile.get('handle', '?'):<19} "
|
|
224
|
+
f"{str(profile.get('followers') or '-'):>12} "
|
|
225
|
+
f"{profile.get('posts_scraped', 0):>6} "
|
|
226
|
+
f"{str(profile.get('avg_engagement') or '-'):>12} "
|
|
227
|
+
f"{str(profile.get('engagement_rate_pct') or '-'):>10}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
tag = handles[0] if len(handles) == 1 else "profiles"
|
|
231
|
+
filename = f"result_{tag}"
|
|
232
|
+
save_data(data, filename=filename)
|
|
233
|
+
print(f"\n[✓] Data saved in {filename}.json / {filename}.csv")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
print(f"[DONE] {len(data)} posts successfully scraped")
|
|
237
|
+
|
|
238
|
+
if not data:
|
|
239
|
+
print("[!] No data collected.")
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
preview_posts(data)
|
|
243
|
+
|
|
244
|
+
filename = f"result_{args.tag}"
|
|
245
|
+
save_data(data, filename=filename)
|
|
246
|
+
print(f"[✓] Data saved in {filename}.json / {filename}.csv")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from bs4 import BeautifulSoup
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def parse_instagram_html(file_path, min_len=10):
|
|
6
|
+
|
|
7
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
8
|
+
soup = BeautifulSoup(f, "html.parser")
|
|
9
|
+
|
|
10
|
+
results = []
|
|
11
|
+
current_user = None
|
|
12
|
+
caption_done = False
|
|
13
|
+
|
|
14
|
+
UI_TEXTS = {
|
|
15
|
+
"Instagram Lite",
|
|
16
|
+
"Log in",
|
|
17
|
+
"Sign up",
|
|
18
|
+
"Open in app",
|
|
19
|
+
"Reply",
|
|
20
|
+
"Like",
|
|
21
|
+
"See translation",
|
|
22
|
+
"View all replies",
|
|
23
|
+
"View replies",
|
|
24
|
+
"More",
|
|
25
|
+
"Follow",
|
|
26
|
+
"Suggested for you"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def is_username(text):
|
|
30
|
+
return (
|
|
31
|
+
text.islower()
|
|
32
|
+
and " " not in text
|
|
33
|
+
and 3 <= len(text) <= 30
|
|
34
|
+
and text not in UI_TEXTS
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def is_timestamp(text):
|
|
38
|
+
return re.match(r"^\d+[smhdw]$", text) is not None
|
|
39
|
+
|
|
40
|
+
for span in soup.find_all("span"):
|
|
41
|
+
|
|
42
|
+
text = span.get_text(strip=True)
|
|
43
|
+
|
|
44
|
+
if not text:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if text in UI_TEXTS:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
if is_timestamp(text):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if is_username(text):
|
|
54
|
+
current_user = text
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if current_user and len(text) >= min_len:
|
|
58
|
+
|
|
59
|
+
entry_type = "caption" if not caption_done else "comment"
|
|
60
|
+
caption_done = True
|
|
61
|
+
|
|
62
|
+
clean_text = re.sub(r"Verified\d+[smhdw]", "", text)
|
|
63
|
+
|
|
64
|
+
results.append({
|
|
65
|
+
"username": current_user,
|
|
66
|
+
"text": clean_text,
|
|
67
|
+
"type": entry_type,
|
|
68
|
+
"char_len": len(clean_text)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
current_user = None
|
|
72
|
+
|
|
73
|
+
print(f"[DEBUG] Parsed {len(results)} entries")
|
|
74
|
+
|
|
75
|
+
return results
|
|
76
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from html_parser import parse_instagram_html
|
|
5
|
+
from exporter import save_to_xlsx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(description="Instagram HTML Comment Extractor")
|
|
10
|
+
parser.add_argument("html_file", help="Path to Instagram HTML file")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--preview",
|
|
13
|
+
type=int,
|
|
14
|
+
default=0,
|
|
15
|
+
help="Show N comments as preview in terminal"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
args = parser.parse_args()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
data = parse_instagram_html(args.html_file)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
json_output = "instagram_comments.json"
|
|
25
|
+
with open(json_output, "w", encoding="utf-8") as f:
|
|
26
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
xlsx_output = save_to_xlsx(data)
|
|
30
|
+
|
|
31
|
+
print(f"[+] Total entries saved: {len(data)}")
|
|
32
|
+
print(f"[+] JSON output: {json_output}")
|
|
33
|
+
print(f"[+] XLSX output: {xlsx_output}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if args.preview > 0:
|
|
37
|
+
print("\n--- PREVIEW ---")
|
|
38
|
+
for i, d in enumerate(data[:args.preview], 1):
|
|
39
|
+
print(f"{i}. @{d['username']}: {d['text'][:100]}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
insta_collect/main.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from src.scraper import scrape_hashtag
|
|
2
|
+
from src.saver import save_to_csv, save_to_json
|
|
3
|
+
|
|
4
|
+
if __name__ == "__main__":
|
|
5
|
+
hashtag = input("Enter hashtag (without #): ")
|
|
6
|
+
limit = int(input("Limit posts: "))
|
|
7
|
+
results = scrape_hashtag(hashtag, limit)
|
|
8
|
+
save_to_csv(results, hashtag)
|
|
9
|
+
save_to_json(results, hashtag)
|
insta_collect/saver.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import csv
|
|
3
|
+
|
|
4
|
+
def save_data(data, filename="results"):
|
|
5
|
+
json_path = f"{filename}.json"
|
|
6
|
+
csv_path = f"{filename}.csv"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
10
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
keys = data[0].keys()
|
|
14
|
+
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
|
15
|
+
writer = csv.DictWriter(f, fieldnames=keys)
|
|
16
|
+
writer.writeheader()
|
|
17
|
+
writer.writerows(data)
|
|
18
|
+
|
|
19
|
+
print(f"[✓] Saved to {json_path} & {csv_path}")
|
|
20
|
+
|
insta_collect/scraper.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
from playwright.sync_api import sync_playwright
|
|
2
|
+
import time
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# ================= UTIL =================
|
|
7
|
+
def count_sentences(text):
|
|
8
|
+
if not text:
|
|
9
|
+
return 0
|
|
10
|
+
sentences = re.split(r"[.!?]+", text)
|
|
11
|
+
return len([s for s in sentences if s.strip()])
|
|
12
|
+
|
|
13
|
+
def extract_hashtags_mentions(text):
|
|
14
|
+
if not text:
|
|
15
|
+
return [], []
|
|
16
|
+
hashtags = re.findall(r"#(\w+)", text)
|
|
17
|
+
mentions = re.findall(r"@(\w+)", text)
|
|
18
|
+
return hashtags, mentions
|
|
19
|
+
|
|
20
|
+
def parse_count(text):
|
|
21
|
+
"""Parse Instagram-style counts: '1,234', '12K', '1.5M', '2B' -> int."""
|
|
22
|
+
if text is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(text, (int, float)):
|
|
25
|
+
return int(text)
|
|
26
|
+
s = str(text).strip().replace(",", "").replace(" ", "")
|
|
27
|
+
if not s:
|
|
28
|
+
return None
|
|
29
|
+
mult = 1
|
|
30
|
+
if s[-1] in "KkMmBb":
|
|
31
|
+
suffix = s[-1].upper()
|
|
32
|
+
s = s[:-1]
|
|
33
|
+
mult = {"K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[suffix]
|
|
34
|
+
try:
|
|
35
|
+
return int(float(s) * mult)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def parse_post_metrics(meta_desc):
|
|
40
|
+
"""Extract likes/comments/views from og:description prefix like '679K likes, 6,719 comments - user...'."""
|
|
41
|
+
likes = comments = views = None
|
|
42
|
+
if not meta_desc:
|
|
43
|
+
return likes, comments, views
|
|
44
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*likes?", meta_desc, re.I)
|
|
45
|
+
if m:
|
|
46
|
+
likes = parse_count(m.group(1))
|
|
47
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*comments?", meta_desc, re.I)
|
|
48
|
+
if m:
|
|
49
|
+
comments = parse_count(m.group(1))
|
|
50
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*views?", meta_desc, re.I)
|
|
51
|
+
if m:
|
|
52
|
+
views = parse_count(m.group(1))
|
|
53
|
+
return likes, comments, views
|
|
54
|
+
|
|
55
|
+
def parse_profile_og(meta_desc):
|
|
56
|
+
"""Parse '104M Followers, 94 Following, 4,966 Posts - ...' -> followers, following, posts_count."""
|
|
57
|
+
followers = following = posts_count = None
|
|
58
|
+
if not meta_desc:
|
|
59
|
+
return followers, following, posts_count
|
|
60
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*Followers", meta_desc, re.I)
|
|
61
|
+
if m:
|
|
62
|
+
followers = parse_count(m.group(1))
|
|
63
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*Following", meta_desc, re.I)
|
|
64
|
+
if m:
|
|
65
|
+
following = parse_count(m.group(1))
|
|
66
|
+
m = re.search(r"([\d,.]+[KMB]?)\s*Posts", meta_desc, re.I)
|
|
67
|
+
if m:
|
|
68
|
+
posts_count = parse_count(m.group(1))
|
|
69
|
+
return followers, following, posts_count
|
|
70
|
+
|
|
71
|
+
def engagement_rate(posts, followers):
|
|
72
|
+
"""
|
|
73
|
+
Average ER% = mean(likes+comments per post, plus views for posts
|
|
74
|
+
that report them) / followers * 100. Returns None rates when data
|
|
75
|
+
is insufficient.
|
|
76
|
+
"""
|
|
77
|
+
scores = []
|
|
78
|
+
for p in posts:
|
|
79
|
+
likes = p.get("likes") or 0
|
|
80
|
+
comments = p.get("comments") or 0
|
|
81
|
+
views = p.get("views") or 0
|
|
82
|
+
score = likes + comments
|
|
83
|
+
# only fold views in when the post actually reported them
|
|
84
|
+
if p.get("views") is not None:
|
|
85
|
+
score += views
|
|
86
|
+
scores.append(score)
|
|
87
|
+
if not scores:
|
|
88
|
+
return None, None
|
|
89
|
+
avg = sum(scores) / len(scores)
|
|
90
|
+
er = None
|
|
91
|
+
if followers and followers > 0:
|
|
92
|
+
er = round(avg / followers * 100, 4)
|
|
93
|
+
return round(avg, 2), er
|
|
94
|
+
|
|
95
|
+
def is_login_wall(page):
|
|
96
|
+
return (
|
|
97
|
+
page.locator("input[name='username']").count() > 0
|
|
98
|
+
or "login" in page.url
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def dismiss_cookie_banner(page):
|
|
102
|
+
for sel in (
|
|
103
|
+
"button:has-text('Allow all cookies')",
|
|
104
|
+
"button:has-text('Accept all')",
|
|
105
|
+
"button:has-text('Allow all')",
|
|
106
|
+
):
|
|
107
|
+
btn = page.locator(sel)
|
|
108
|
+
if btn.count() > 0:
|
|
109
|
+
try:
|
|
110
|
+
btn.first.click(timeout=3000)
|
|
111
|
+
time.sleep(1)
|
|
112
|
+
return True
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def dismiss_signup_modal(page):
|
|
118
|
+
"""Best-effort close of the 'Sign up / Log in' modal on post pages."""
|
|
119
|
+
for sel in (
|
|
120
|
+
"div[role='dialog'] svg[aria-label='Close']",
|
|
121
|
+
"div[role='dialog'] button:has-text('Not now')",
|
|
122
|
+
"svg[aria-label='Close']",
|
|
123
|
+
):
|
|
124
|
+
el = page.locator(sel)
|
|
125
|
+
if el.count() > 0:
|
|
126
|
+
try:
|
|
127
|
+
el.first.click(timeout=2000)
|
|
128
|
+
time.sleep(0.5)
|
|
129
|
+
return True
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
# ================= DETAIL SCRAPER =================
|
|
135
|
+
def get_post_details(page, url):
|
|
136
|
+
data = {
|
|
137
|
+
"caption": None,
|
|
138
|
+
"caption_status": None,
|
|
139
|
+
"timestamp": None,
|
|
140
|
+
"is_video": False,
|
|
141
|
+
"hashtags": [],
|
|
142
|
+
"mentions": [],
|
|
143
|
+
"likes": None,
|
|
144
|
+
"comments": None,
|
|
145
|
+
"views": None,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
print(f" [..] Visiting: {url}")
|
|
150
|
+
page.goto(url, wait_until="domcontentloaded", timeout=20000)
|
|
151
|
+
time.sleep(1.5)
|
|
152
|
+
|
|
153
|
+
if is_login_wall(page):
|
|
154
|
+
data["caption_status"] = "login_required"
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
# Signup modal is non-blocking for meta extraction; try to close it.
|
|
158
|
+
dismiss_signup_modal(page)
|
|
159
|
+
|
|
160
|
+
# ---------- META ----------
|
|
161
|
+
# og:description looks like:
|
|
162
|
+
# 12K likes, 30 comments - username on January 1, 2026: "caption text".
|
|
163
|
+
caption_text = None
|
|
164
|
+
username = None
|
|
165
|
+
meta_desc = None
|
|
166
|
+
try:
|
|
167
|
+
meta_desc = page.locator('meta[property="og:description"]').get_attribute("content")
|
|
168
|
+
if meta_desc:
|
|
169
|
+
# username sits before " on <date>: "
|
|
170
|
+
m_user = re.search(r"-\s*([A-Za-z0-9._]+)\s+on\s+", meta_desc)
|
|
171
|
+
if m_user:
|
|
172
|
+
username = m_user.group(1)
|
|
173
|
+
likes, comments, views = parse_post_metrics(meta_desc)
|
|
174
|
+
data["likes"] = likes
|
|
175
|
+
data["comments"] = comments
|
|
176
|
+
data["views"] = views
|
|
177
|
+
if ": " in meta_desc:
|
|
178
|
+
caption_text = meta_desc.split(": ", 1)[1].strip()
|
|
179
|
+
# og:description wraps the caption in quotes: ...: "text".
|
|
180
|
+
if caption_text.startswith('"'):
|
|
181
|
+
caption_text = caption_text[1:]
|
|
182
|
+
if caption_text.endswith('".'):
|
|
183
|
+
caption_text = caption_text[:-2]
|
|
184
|
+
elif caption_text.endswith('"'):
|
|
185
|
+
caption_text = caption_text[:-1]
|
|
186
|
+
caption_text = caption_text.strip()
|
|
187
|
+
else:
|
|
188
|
+
caption_text = meta_desc.strip()
|
|
189
|
+
except:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# ---------- DOM FALLBACK ----------
|
|
193
|
+
if not caption_text:
|
|
194
|
+
try:
|
|
195
|
+
candidates = page.locator("article span").all()
|
|
196
|
+
longest = None
|
|
197
|
+
for el in candidates:
|
|
198
|
+
txt = el.inner_text().strip()
|
|
199
|
+
if len(txt) < 80:
|
|
200
|
+
continue
|
|
201
|
+
if not longest or len(txt) > len(longest):
|
|
202
|
+
longest = txt
|
|
203
|
+
caption_text = longest
|
|
204
|
+
except:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# ---------- VALIDASI ----------
|
|
208
|
+
# Accept any non-empty caption (short reel captions often have no sentence punctuation).
|
|
209
|
+
if not caption_text or not caption_text.strip():
|
|
210
|
+
data["caption_status"] = "no_text"
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
sentence_count = count_sentences(caption_text)
|
|
214
|
+
data["caption"] = caption_text
|
|
215
|
+
data["caption_status"] = "long_text" if sentence_count >= 8 else "short_text"
|
|
216
|
+
if username:
|
|
217
|
+
data["username"] = username
|
|
218
|
+
|
|
219
|
+
# ---------- HASHTAG & MENTION ----------
|
|
220
|
+
hashtags, mentions = extract_hashtags_mentions(caption_text)
|
|
221
|
+
data["hashtags"] = hashtags
|
|
222
|
+
data["mentions"] = mentions
|
|
223
|
+
|
|
224
|
+
# ---------- TIMESTAMP ----------
|
|
225
|
+
try:
|
|
226
|
+
time_el = page.locator("time").first
|
|
227
|
+
if time_el.count() > 0:
|
|
228
|
+
data["timestamp"] = time_el.get_attribute("datetime")
|
|
229
|
+
except:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# ---------- VIDEO FLAG ----------
|
|
233
|
+
if page.locator("video").count() > 0 or "/reel/" in url:
|
|
234
|
+
data["is_video"] = True
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
data["caption_status"] = "error"
|
|
238
|
+
print(f" [!] Error: {e}")
|
|
239
|
+
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
# ================= HASHTAG SCRAPER =================
|
|
243
|
+
def scrape_hashtag(hashtag, limit=10, profile_dir="ig_profile", headless=True):
|
|
244
|
+
results = []
|
|
245
|
+
|
|
246
|
+
with sync_playwright() as p:
|
|
247
|
+
context = p.chromium.launch_persistent_context(
|
|
248
|
+
user_data_dir=profile_dir,
|
|
249
|
+
headless=headless,
|
|
250
|
+
viewport={"width": 1280, "height": 800}
|
|
251
|
+
)
|
|
252
|
+
page = context.new_page()
|
|
253
|
+
|
|
254
|
+
print(f"[1/3] Scanning hashtag #{hashtag}")
|
|
255
|
+
page.goto(f"https://www.instagram.com/explore/tags/{hashtag}/", wait_until="domcontentloaded")
|
|
256
|
+
time.sleep(3)
|
|
257
|
+
dismiss_cookie_banner(page)
|
|
258
|
+
|
|
259
|
+
if is_login_wall(page):
|
|
260
|
+
if headless:
|
|
261
|
+
context.close()
|
|
262
|
+
raise RuntimeError(
|
|
263
|
+
"Instagram login wall blocked the hashtag page in headless mode. "
|
|
264
|
+
"Re-run with --headed and log in once (session is saved in --profile), "
|
|
265
|
+
"or try again later."
|
|
266
|
+
)
|
|
267
|
+
print("[!] Login is required. Please log in in an open browser....")
|
|
268
|
+
input("Press Enter after login is complete to continue....")
|
|
269
|
+
|
|
270
|
+
collected_links = set()
|
|
271
|
+
# Logged-out grids often only expose /reel/ links; accept both.
|
|
272
|
+
max_scrolls = 25
|
|
273
|
+
for _ in range(max_scrolls):
|
|
274
|
+
for selector in ("a[href*='/p/']", "a[href*='/reel/']"):
|
|
275
|
+
for a in page.locator(selector).all():
|
|
276
|
+
href = a.get_attribute("href")
|
|
277
|
+
if href:
|
|
278
|
+
# strip query params
|
|
279
|
+
path = href.split("?")[0]
|
|
280
|
+
collected_links.add("https://www.instagram.com" + path)
|
|
281
|
+
if len(collected_links) >= limit * 2:
|
|
282
|
+
break
|
|
283
|
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
284
|
+
time.sleep(1.5)
|
|
285
|
+
|
|
286
|
+
print(f"[INFO] Found {len(collected_links)} links")
|
|
287
|
+
|
|
288
|
+
print("[2/3] Scraping detail posts")
|
|
289
|
+
for i, link in enumerate(collected_links):
|
|
290
|
+
if len(results) >= limit:
|
|
291
|
+
break
|
|
292
|
+
print(f" -> {i+1}/{len(collected_links)}")
|
|
293
|
+
d = get_post_details(page, link)
|
|
294
|
+
# Keep reels/videos: logged-out grids only serve reels.
|
|
295
|
+
if d["caption_status"] in ("login_required", "error"):
|
|
296
|
+
continue
|
|
297
|
+
results.append({"url": link, **d, "source_tag": hashtag})
|
|
298
|
+
time.sleep(random.uniform(1.5, 2.5))
|
|
299
|
+
|
|
300
|
+
context.close()
|
|
301
|
+
|
|
302
|
+
print(f"[DONE] Collected {len(results)} posts from #{hashtag}")
|
|
303
|
+
return results
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ================= PROFILE SCRAPER =================
|
|
307
|
+
def _collect_profile_links(page, handle, limit):
|
|
308
|
+
"""Scroll profile grid; return post URLs in DOM order (recent first)."""
|
|
309
|
+
handle_l = handle.lower().lstrip("@")
|
|
310
|
+
collected = []
|
|
311
|
+
seen = set()
|
|
312
|
+
max_scrolls = 20
|
|
313
|
+
for _ in range(max_scrolls):
|
|
314
|
+
for selector in ("a[href*='/p/']", "a[href*='/reel/']"):
|
|
315
|
+
for a in page.locator(selector).all():
|
|
316
|
+
href = a.get_attribute("href")
|
|
317
|
+
if not href:
|
|
318
|
+
continue
|
|
319
|
+
path = href.split("?")[0]
|
|
320
|
+
if not path.startswith("/"):
|
|
321
|
+
path = "/" + path
|
|
322
|
+
# Prefer posts on this handle's paths; still allow collab posts later.
|
|
323
|
+
url = "https://www.instagram.com" + path
|
|
324
|
+
if url in seen:
|
|
325
|
+
continue
|
|
326
|
+
seen.add(url)
|
|
327
|
+
collected.append(url)
|
|
328
|
+
if len(collected) >= limit * 2:
|
|
329
|
+
break
|
|
330
|
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
331
|
+
time.sleep(1.2)
|
|
332
|
+
|
|
333
|
+
# Prefer same-handle posts first, preserve discovery order within each group
|
|
334
|
+
same, other = [], []
|
|
335
|
+
for u in collected:
|
|
336
|
+
if f"/{handle_l}/" in u.lower():
|
|
337
|
+
same.append(u)
|
|
338
|
+
else:
|
|
339
|
+
other.append(u)
|
|
340
|
+
return (same + other)[: limit * 2]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def scrape_profile(handle, limit=10, profile_dir="ig_profile", headless=True):
|
|
344
|
+
"""
|
|
345
|
+
Fetch recent posts for a profile and compute engagement rate.
|
|
346
|
+
Returns a dict: handle, followers, posts, avg_engagement, engagement_rate_pct, ...
|
|
347
|
+
"""
|
|
348
|
+
handle = handle.strip().lstrip("@")
|
|
349
|
+
|
|
350
|
+
with sync_playwright() as p:
|
|
351
|
+
context = p.chromium.launch_persistent_context(
|
|
352
|
+
user_data_dir=profile_dir,
|
|
353
|
+
headless=headless,
|
|
354
|
+
viewport={"width": 1280, "height": 800},
|
|
355
|
+
)
|
|
356
|
+
page = context.new_page()
|
|
357
|
+
try:
|
|
358
|
+
profile = _scrape_profile_with_page(page, handle, limit, headless)
|
|
359
|
+
finally:
|
|
360
|
+
context.close()
|
|
361
|
+
|
|
362
|
+
print(f"[DONE] @{handle}: {len(profile['posts'])} posts, "
|
|
363
|
+
f"ER={profile.get('engagement_rate_pct')}%")
|
|
364
|
+
return profile
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _scrape_profile_with_page(page, handle, limit, headless):
|
|
368
|
+
handle = handle.strip().lstrip("@")
|
|
369
|
+
posts = []
|
|
370
|
+
print(f"[1/3] Opening profile @{handle}")
|
|
371
|
+
page.goto(f"https://www.instagram.com/{handle}/", wait_until="domcontentloaded", timeout=30000)
|
|
372
|
+
time.sleep(3)
|
|
373
|
+
dismiss_cookie_banner(page)
|
|
374
|
+
|
|
375
|
+
if is_login_wall(page):
|
|
376
|
+
if headless:
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
f"Login wall blocked @{handle} in headless mode. "
|
|
379
|
+
"Re-run with --headed and log in once, or try later."
|
|
380
|
+
)
|
|
381
|
+
print("[!] Login is required. Please log in in an open browser....")
|
|
382
|
+
input("Press Enter after login is complete to continue....")
|
|
383
|
+
|
|
384
|
+
meta_desc = None
|
|
385
|
+
try:
|
|
386
|
+
meta_desc = page.locator('meta[property="og:description"]').get_attribute("content")
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
followers, following, posts_count = parse_profile_og(meta_desc)
|
|
390
|
+
print(f"[INFO] followers={followers} following={following} posts={posts_count}")
|
|
391
|
+
|
|
392
|
+
links = _collect_profile_links(page, handle, limit)
|
|
393
|
+
print(f"[INFO] Found {len(links)} grid links")
|
|
394
|
+
|
|
395
|
+
print("[2/3] Scraping recent posts")
|
|
396
|
+
for i, link in enumerate(links):
|
|
397
|
+
if len(posts) >= limit:
|
|
398
|
+
break
|
|
399
|
+
print(f" -> {i+1}/{len(links)}")
|
|
400
|
+
d = get_post_details(page, link)
|
|
401
|
+
if d["caption_status"] in ("login_required", "error"):
|
|
402
|
+
continue
|
|
403
|
+
posts.append({"url": link, **d, "source_handle": handle})
|
|
404
|
+
time.sleep(random.uniform(1.5, 2.5))
|
|
405
|
+
|
|
406
|
+
avg_eng, er = engagement_rate(posts, followers)
|
|
407
|
+
return {
|
|
408
|
+
"handle": handle,
|
|
409
|
+
"url": f"https://www.instagram.com/{handle}/",
|
|
410
|
+
"followers": followers,
|
|
411
|
+
"following": following,
|
|
412
|
+
"posts_total": posts_count,
|
|
413
|
+
"posts_scraped": len(posts),
|
|
414
|
+
"avg_engagement": avg_eng,
|
|
415
|
+
"engagement_rate_pct": er,
|
|
416
|
+
"posts": posts,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def scrape_profiles(handles, limit=10, profile_dir="ig_profile", headless=True):
|
|
421
|
+
"""Scrape multiple profiles in one browser session."""
|
|
422
|
+
results = []
|
|
423
|
+
handles = [h.strip().lstrip("@") for h in handles if h and h.strip()]
|
|
424
|
+
|
|
425
|
+
with sync_playwright() as p:
|
|
426
|
+
context = p.chromium.launch_persistent_context(
|
|
427
|
+
user_data_dir=profile_dir,
|
|
428
|
+
headless=headless,
|
|
429
|
+
viewport={"width": 1280, "height": 800},
|
|
430
|
+
)
|
|
431
|
+
page = context.new_page()
|
|
432
|
+
try:
|
|
433
|
+
for handle in handles:
|
|
434
|
+
try:
|
|
435
|
+
profile = _scrape_profile_with_page(page, handle, limit, headless)
|
|
436
|
+
results.append(profile)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
print(f"[!] @{handle} failed: {e}")
|
|
439
|
+
results.append({
|
|
440
|
+
"handle": handle,
|
|
441
|
+
"error": str(e),
|
|
442
|
+
"posts": [],
|
|
443
|
+
"posts_scraped": 0,
|
|
444
|
+
"engagement_rate_pct": None,
|
|
445
|
+
"avg_engagement": None,
|
|
446
|
+
"followers": None,
|
|
447
|
+
})
|
|
448
|
+
time.sleep(random.uniform(2.0, 3.5))
|
|
449
|
+
finally:
|
|
450
|
+
context.close()
|
|
451
|
+
|
|
452
|
+
return results
|
|
453
|
+
|
|
454
|
+
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: insta-collect-data
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Modular Instagram Data Collector
|
|
5
|
+
Author-email: Alam Mahadika <alam.mahadika.psm@umy.ac.id>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Alammahadika/insta-collect
|
|
8
|
+
Keywords: instagram,scraper,hashtag,profile,engagement,analytics,comments
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: beautifulsoup4>=4.12.2
|
|
12
|
+
Requires-Dist: lxml>=4.9.3
|
|
13
|
+
Requires-Dist: pandas>=2.1.0
|
|
14
|
+
Requires-Dist: openpyxl>=3.1.2
|
|
15
|
+
Requires-Dist: requests>=2.31.0
|
|
16
|
+
Requires-Dist: tqdm>=4.65.0
|
|
17
|
+
Requires-Dist: playwright>=1.40.0
|
|
18
|
+
|
|
19
|
+
# insta-collect-data: Modular Instagram Data Collector
|
|
20
|
+
|
|
21
|
+
## Project Status: In Development
|
|
22
|
+
|
|
23
|
+
insta-collect-data is a Playwright-based collector for Instagram **hashtags** and **profiles**. It supports photo and carousel posts as well as Reels, extracts post-level engagement metrics, and can compute per-profile engagement rates for influencer research.
|
|
24
|
+
|
|
25
|
+
The live scraper is functional but depends on Instagram's public markup. Expect occasional breakage when Instagram changes its HTML or tightens its login wall. The HTML comment parser is still experimental.
|
|
26
|
+
|
|
27
|
+
***
|
|
28
|
+
|
|
29
|
+
## Project Overview
|
|
30
|
+
|
|
31
|
+
insta-collect-data is a small, modular Python project that automates Instagram in a real browser via **Playwright**. It is intended for structured data collection for analysis and research: hashtag sampling, creator profiling, engagement comparisons, and small-batch archival work.
|
|
32
|
+
|
|
33
|
+
***
|
|
34
|
+
|
|
35
|
+
## Current Key Features
|
|
36
|
+
|
|
37
|
+
* **Hashtag Scraping:** Collect posts from an explore/tag page.
|
|
38
|
+
* **Profile Scraping:** Collect recent posts for one handle or a list of handles in a single browser session.
|
|
39
|
+
* **Engagement Metrics:** Extract `likes`, `comments`, and `views` (when Instagram exposes them), plus profile `followers`, `following`, and total `posts`.
|
|
40
|
+
* **Engagement Rate:** Compute average engagement per post and ER% for each scraped profile.
|
|
41
|
+
* **Reels Included:** Both `/p/` posts and `/reel/` links are collected. Older versions filtered videos out; that is no longer the case.
|
|
42
|
+
* **Rich Post Data:** `url`, `username`, `caption`, `caption_status`, `timestamp`, `is_video`, `hashtags`, `mentions`, `likes`, `comments`, `views`.
|
|
43
|
+
* **Session Reuse:** Log in once with a visible browser and reuse the saved Playwright profile on later headless runs.
|
|
44
|
+
* **Login-Wall Handling:** Detects Instagram login walls, dismisses cookie/sign-up modals when possible, and gives an actionable error in headless mode.
|
|
45
|
+
* **Dual Output:** Every run writes both **JSON** and **CSV**.
|
|
46
|
+
* **HTML Comment Extraction:** Parse comments from a previously saved Instagram HTML page into JSON and XLSX.
|
|
47
|
+
|
|
48
|
+
***
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install insta-collect-data
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### or install directly from GitHub
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install git+https://github.com/Alammahadika/insta-collect.git
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This distribution installs the `insta_collect` Python package and the `insta-collect` CLI command.
|
|
63
|
+
|
|
64
|
+
> `insta-collect-data` is the continuation of the earlier `insta-collect` package (last release 0.1.2) under a new PyPI name.
|
|
65
|
+
|
|
66
|
+
Requirements: **Python 3.10+** and a Chromium browser for Playwright.
|
|
67
|
+
|
|
68
|
+
### Playwright Setup (Required)
|
|
69
|
+
|
|
70
|
+
Install the browser binaries once after installing the package:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
playwright install chromium
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### First-Time Login (Recommended)
|
|
77
|
+
|
|
78
|
+
Instagram often blocks logged-out scraping with a login wall. Log in once using a visible browser; the session is stored in the Playwright profile directory (`ig_profile` by default) and reused afterwards.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
insta-collect --user natgeo --limit 6 --headed
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Log in manually in the window that opens, then let the run finish. Later runs work headless by default.
|
|
85
|
+
|
|
86
|
+
> Do not commit the `ig_profile/` folder — it contains your authenticated session.
|
|
87
|
+
|
|
88
|
+
***
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
The installed CLI is `insta-collect`. It has two modes, and they are mutually exclusive.
|
|
93
|
+
|
|
94
|
+
### Arguments
|
|
95
|
+
|
|
96
|
+
| Argument | Description | Default |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `--tag` | Hashtag to scrape (without `#`) | — |
|
|
99
|
+
| `--user` | One handle, or a comma-separated list (`natgeo,nasa`) | — |
|
|
100
|
+
| `--users-file` | Text file with one handle per line; `#` starts a comment | — |
|
|
101
|
+
| `--limit` | Max posts per target (6–12 is a good range for engagement analysis) | `10` |
|
|
102
|
+
| `--profile` | Playwright persistent profile directory (session storage) | `ig_profile` |
|
|
103
|
+
| `--headed` | Show the browser window (first-time login / debugging) | headless |
|
|
104
|
+
|
|
105
|
+
Provide either `--tag` **or** `--user` / `--users-file`.
|
|
106
|
+
|
|
107
|
+
### Hashtag mode
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
insta-collect --tag donaldtrump --limit 10
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Single profile
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
insta-collect --user natgeo --limit 10
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Multiple profiles
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
insta-collect --user natgeo,nasa --limit 8
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Or from a file (`handles.txt`):
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
# one handle per line
|
|
129
|
+
natgeo
|
|
130
|
+
nasa
|
|
131
|
+
@somecreator
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
insta-collect --users-file handles.txt --limit 8
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Visible browser
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
insta-collect --tag donaldtrump --limit 10 --headed
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
***
|
|
145
|
+
|
|
146
|
+
## Output
|
|
147
|
+
|
|
148
|
+
Each run writes two files in the current directory.
|
|
149
|
+
|
|
150
|
+
| Mode | Files |
|
|
151
|
+
|---|---|
|
|
152
|
+
| Hashtag | `result_<tag>.json`, `result_<tag>.csv` |
|
|
153
|
+
| Single profile | `result_<handle>.json`, `result_<handle>.csv` |
|
|
154
|
+
| Multiple profiles | `result_profiles.json`, `result_profiles.csv` |
|
|
155
|
+
|
|
156
|
+
### Post fields (hashtag mode)
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
[
|
|
160
|
+
{
|
|
161
|
+
"url": "https://www.instagram.com/p/DS3UWMfCRA5/",
|
|
162
|
+
"username": "cdn37",
|
|
163
|
+
"caption": "El presidente de Estados Unidos...",
|
|
164
|
+
"caption_status": "long_text",
|
|
165
|
+
"timestamp": "2025-12-29T22:35:50.000Z",
|
|
166
|
+
"is_video": false,
|
|
167
|
+
"hashtags": ["CDN", "Noticias", "DonaldTrump"],
|
|
168
|
+
"mentions": [],
|
|
169
|
+
"likes": 1200,
|
|
170
|
+
"comments": 34,
|
|
171
|
+
"views": null,
|
|
172
|
+
"source_tag": "donaldtrump"
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`caption_status` is one of `long_text`, `short_text`, `no_text`, `login_required`, or `error`.
|
|
178
|
+
|
|
179
|
+
### Profile fields (profile mode)
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
[
|
|
183
|
+
{
|
|
184
|
+
"handle": "natgeo",
|
|
185
|
+
"url": "https://www.instagram.com/natgeo/",
|
|
186
|
+
"followers": 283000000,
|
|
187
|
+
"following": 120,
|
|
188
|
+
"posts_total": 31000,
|
|
189
|
+
"posts_scraped": 8,
|
|
190
|
+
"avg_engagement": 45210.5,
|
|
191
|
+
"engagement_rate_pct": 0.016,
|
|
192
|
+
"posts": [
|
|
193
|
+
{
|
|
194
|
+
"url": "https://www.instagram.com/p/...",
|
|
195
|
+
"username": "natgeo",
|
|
196
|
+
"caption": "...",
|
|
197
|
+
"likes": 40000,
|
|
198
|
+
"comments": 5210,
|
|
199
|
+
"views": null,
|
|
200
|
+
"source_handle": "natgeo"
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Engagement rate definition
|
|
208
|
+
|
|
209
|
+
For each scraped post, engagement is `likes + comments`, plus `views` when that post reported a view count. Then:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
avg_engagement = mean(per-post engagement)
|
|
213
|
+
engagement_rate_% = avg_engagement / followers * 100
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Instagram displays rounded counts (`12K`, `1.5M`), so parsed metrics are approximations, not exact values.
|
|
217
|
+
|
|
218
|
+
### CSV note
|
|
219
|
+
|
|
220
|
+
CSV output is flat. In profile mode the nested `posts` list is written as a single serialized cell per profile. Use the JSON file when you need post-level analysis.
|
|
221
|
+
|
|
222
|
+
***
|
|
223
|
+
|
|
224
|
+
## How It Works
|
|
225
|
+
|
|
226
|
+
1. **Session resumption:** Opens Chromium with the persistent `--profile` directory, reusing any saved login.
|
|
227
|
+
2. **Link collection:** Navigates to the hashtag or profile page, dismisses the cookie banner, and scrolls until enough `/p/` and `/reel/` links are found (query strings stripped, duplicates removed).
|
|
228
|
+
3. **Post extraction:** Visits each post, closes the sign-up modal when present, and reads `og:description` plus page selectors for caption, username, timestamp, and engagement metrics.
|
|
229
|
+
4. **Filtering:** Skips posts that hit a login wall or throw an error. Reels and videos are kept.
|
|
230
|
+
5. **Aggregation:** In profile mode, computes average engagement and ER% from the scraped posts.
|
|
231
|
+
6. **Export:** Writes JSON and CSV.
|
|
232
|
+
|
|
233
|
+
***
|
|
234
|
+
|
|
235
|
+
## Limitations
|
|
236
|
+
|
|
237
|
+
* Instagram's login wall is the most common failure. If a headless run reports it, rerun once with `--headed` and log in.
|
|
238
|
+
* Logged-out hashtag grids often expose only Reels; results may be Reel-heavy.
|
|
239
|
+
* Metrics are rounded by Instagram (`K`/`M`/`B`) and may be missing entirely.
|
|
240
|
+
* Keep `--limit` modest and expect random delays between requests. This is not a high-volume crawler.
|
|
241
|
+
* The persistent profile directory contains credentials; exclude it from version control.
|
|
242
|
+
|
|
243
|
+
***
|
|
244
|
+
|
|
245
|
+
## Naming Conventions: `_` vs `-` in Python Projects
|
|
246
|
+
|
|
247
|
+
| Context | Use | Example |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| Package name (PyPI / GitHub) | `-` (hyphen) | insta-collect-data |
|
|
250
|
+
| Python package directory | `_` (underscore) | insta_collect |
|
|
251
|
+
| Python import statement | `_` (underscore) | import insta_collect |
|
|
252
|
+
| CLI execution via module | `_` (underscore) | python -m insta_collect.cli |
|
|
253
|
+
| Script / file name | flexible | insta-collect.py |
|
|
254
|
+
|
|
255
|
+
***
|
|
256
|
+
|
|
257
|
+
## Collecting Comments from an HTML File
|
|
258
|
+
|
|
259
|
+
In addition to live scraping, insta-collect-data can parse comments from a saved Instagram HTML page made in a logged-in browser.
|
|
260
|
+
|
|
261
|
+
This is useful when:
|
|
262
|
+
|
|
263
|
+
* You already saved the page and want structured comments.
|
|
264
|
+
* You want to avoid live requests.
|
|
265
|
+
* You need an XLSX deliverable.
|
|
266
|
+
|
|
267
|
+
### Example
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
cd path/to/insta_collect # the package directory in a repo checkout
|
|
271
|
+
python3 insta-collect.py ~/Downloads/obama.html --preview 20
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The script writes `instagram_comments.json` and `instagram_comments.xlsx` to the current directory.
|
|
275
|
+
|
|
276
|
+
### Output
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
[+] Total entries saved: 239
|
|
280
|
+
[+] JSON output: instagram_comments.json
|
|
281
|
+
[+] XLSX output: instagram_comments.xlsx
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Comment fields
|
|
285
|
+
|
|
286
|
+
* `username`
|
|
287
|
+
* `text` (`comment_text` in the README of older releases)
|
|
288
|
+
* `timestamp` when available
|
|
289
|
+
* `source_file`
|
|
290
|
+
|
|
291
|
+
### Notes
|
|
292
|
+
|
|
293
|
+
* Output filenames are fixed; existing files are overwritten.
|
|
294
|
+
* `--preview` only affects terminal output, not the saved data.
|
|
295
|
+
* This feature is experimental and may change.
|
|
296
|
+
|
|
297
|
+
***
|
|
298
|
+
|
|
299
|
+
## Roadmap
|
|
300
|
+
|
|
301
|
+
* Live comment scraping.
|
|
302
|
+
* XLSX export for live post/profile runs.
|
|
303
|
+
* Retry/backoff and proxy support for larger batches.
|
|
304
|
+
* Optional pagination beyond the recent grid.
|
|
305
|
+
|
|
306
|
+
Completed in this release: profile scraping, engagement metrics/ER%, CSV output, Reels support, headless-by-default with persistent login.
|
|
307
|
+
|
|
308
|
+
***
|
|
309
|
+
|
|
310
|
+
## Ethical Use Notice
|
|
311
|
+
|
|
312
|
+
This tool is intended strictly for academic research, journalism, and public-interest analysis.
|
|
313
|
+
Users are responsible for complying with Instagram's Terms of Service and applicable laws.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
insta_collect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
insta_collect/cli.py,sha256=XzgxLfB9nPjwJKD33l5BxN60ZQIvr48y7NRcL9sWXCk,7624
|
|
3
|
+
insta_collect/exporter.py,sha256=RYqxiPublZBcvBcjVxMAGT7AXn0JYk00XULW3dRk7zo,168
|
|
4
|
+
insta_collect/html_parser.py,sha256=2RGJx1OjigrQEarIvQvbdiNM4moO216eu_5SsxfQI3A,1665
|
|
5
|
+
insta_collect/insta-collect.py,sha256=eUcl7jLQixFqirSSutuaddWsW5sf7qliQEn9oRNfcz4,1103
|
|
6
|
+
insta_collect/main.py,sha256=WR1xddYoSTCyNM-bLJ0yU6aCLfH_MAuTybAGmxLQf8A,320
|
|
7
|
+
insta_collect/saver.py,sha256=dwNanzImiRPn0ptoGQa9IeUFJSxSNlrGsB2bjgQkrMs,507
|
|
8
|
+
insta_collect/scraper.py,sha256=xHSFzuSqrfPwyYBb4m1ddxcP0v_GymUkNDsYyJiovis,16011
|
|
9
|
+
insta_collect_data-0.2.0.dist-info/METADATA,sha256=XF4A61iu8A_LX7JBw37FKtcMU1iRlKZobOBDn1uHoaA,10143
|
|
10
|
+
insta_collect_data-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
insta_collect_data-0.2.0.dist-info/entry_points.txt,sha256=fGFkxnBYUF4ZSgOjozw6ZH3KsopfXicuhEDverV8GJE,57
|
|
12
|
+
insta_collect_data-0.2.0.dist-info/top_level.txt,sha256=wtLMtis7WD-MS4fzNzNZbm1iJeNkKp3mX_PZwnEmD1U,14
|
|
13
|
+
insta_collect_data-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
insta_collect
|