remove(instagram): 移除 Instagram 渠道
Instagram 反爬封杀导致所有开源工具(instaloader 等)失效, 无论有无 cookies 都无法正常使用。 - 删除 instagram.py 渠道文件 - 移除 CLI 中 search-instagram、configure instagram-cookies 等命令 - 移除 setup/doctor 中 instaloader 依赖检查 - 更新 README、docs、SKILL.md、pyproject.toml 上游 issue: instaloader#2585, instaloader#2648 Relates to: #13
This commit is contained in:
@@ -20,7 +20,6 @@ from .rss import RSSChannel
|
||||
from .bilibili import BilibiliChannel
|
||||
from .exa_search import ExaSearchChannel
|
||||
from .xiaohongshu import XiaoHongShuChannel
|
||||
from .instagram import InstagramChannel
|
||||
from .linkedin import LinkedInChannel
|
||||
from .bosszhipin import BossZhipinChannel
|
||||
|
||||
@@ -33,7 +32,6 @@ ALL_CHANNELS: List[Channel] = [
|
||||
RedditChannel(),
|
||||
BilibiliChannel(),
|
||||
XiaoHongShuChannel(),
|
||||
InstagramChannel(),
|
||||
LinkedInChannel(),
|
||||
BossZhipinChannel(),
|
||||
RSSChannel(),
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Instagram — via instaloader (free, open source).
|
||||
|
||||
Backend: instaloader (9.8K stars, Python CLI + library)
|
||||
Swap to: any Instagram access tool
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from .base import Channel, ReadResult, SearchResult
|
||||
from typing import List
|
||||
|
||||
|
||||
class InstagramChannel(Channel):
|
||||
name = "instagram"
|
||||
description = "Instagram 帖子和 Profile"
|
||||
backends = ["instaloader"]
|
||||
tier = 2 # Needs login for full access
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
domain = urlparse(url).netloc.lower()
|
||||
return "instagram.com" in domain or "instagr.am" in domain
|
||||
|
||||
def check(self, config=None):
|
||||
# Check both CLI and Python module
|
||||
has_cli = shutil.which("instaloader")
|
||||
has_module = False
|
||||
try:
|
||||
import instaloader
|
||||
has_module = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not has_cli and not has_module:
|
||||
return "off", (
|
||||
"需要安装 instaloader:pip install instaloader\n"
|
||||
" 安装后可读取 Instagram 帖子和 Profile\n"
|
||||
" 登录: agent-reach configure instagram-cookies \"sessionid=xxx; csrftoken=yyy; ...\""
|
||||
)
|
||||
|
||||
# Check if cookies are configured
|
||||
cookie_file = Path.home() / ".agent-reach" / "instagram-cookies.txt"
|
||||
if cookie_file.exists():
|
||||
return "ok", "已登录,可读取 Instagram 帖子和 Profile"
|
||||
return "ok", "可读取公开帖子和 Profile。登录可访问更多内容:\n agent-reach configure instagram-cookies \"sessionid=xxx; csrftoken=yyy; ...\""
|
||||
|
||||
async def read(self, url: str, config=None) -> ReadResult:
|
||||
# Try instaloader (module or CLI)
|
||||
try:
|
||||
import instaloader
|
||||
return await self._read_instaloader(url, config)
|
||||
except ImportError:
|
||||
pass
|
||||
# Fallback: Jina Reader
|
||||
return await self._read_jina(url)
|
||||
|
||||
async def _read_instaloader(self, url: str, config=None) -> ReadResult:
|
||||
"""Read Instagram content using instaloader Python API."""
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
def _sync_read():
|
||||
import instaloader
|
||||
L = instaloader.Instaloader(
|
||||
download_pictures=False,
|
||||
download_videos=False,
|
||||
download_video_thumbnails=False,
|
||||
download_geotags=False,
|
||||
download_comments=False,
|
||||
save_metadata=False,
|
||||
compress_json=False,
|
||||
max_connection_attempts=1, # Don't retry on rate limit
|
||||
)
|
||||
|
||||
# Try to load session: cookie file > saved session
|
||||
cookie_file = Path.home() / ".agent-reach" / "instagram-cookies.txt"
|
||||
if cookie_file.exists():
|
||||
try:
|
||||
cookie_str = cookie_file.read_text().strip()
|
||||
cookies = {}
|
||||
for part in cookie_str.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
cookies[k.strip()] = v.strip()
|
||||
if "sessionid" in cookies and "csrftoken" in cookies:
|
||||
# Extract username from ds_user_id or use generic
|
||||
username = cookies.get("ds_user_id", "user")
|
||||
L.context.load_session(username, cookies)
|
||||
except Exception:
|
||||
pass
|
||||
elif config and config.get("instagram_username"):
|
||||
try:
|
||||
L.load_session_from_file(config.get("instagram_username"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
path = urlparse(url).path.strip("/")
|
||||
|
||||
if "/p/" in url or "/reel/" in url:
|
||||
return self._read_post_sync(L, url, path)
|
||||
else:
|
||||
return self._read_profile_sync(L, url, path)
|
||||
|
||||
try:
|
||||
# Run with 15s timeout to avoid instaloader's 30-min retry
|
||||
loop = asyncio.get_event_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(pool, _sync_read),
|
||||
timeout=15,
|
||||
)
|
||||
return result
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
# Any error or timeout → Jina fallback
|
||||
return await self._read_jina(url)
|
||||
|
||||
def _read_post_sync(self, L, url: str, path: str) -> ReadResult:
|
||||
"""Read a single Instagram post (sync, runs in executor)."""
|
||||
import instaloader
|
||||
|
||||
# Extract shortcode from URL
|
||||
match = re.search(r"/(?:p|reel)/([A-Za-z0-9_-]+)", url)
|
||||
if not match:
|
||||
raise ValueError("Cannot extract shortcode from URL")
|
||||
|
||||
shortcode = match.group(1)
|
||||
try:
|
||||
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
||||
|
||||
lines = []
|
||||
if post.caption:
|
||||
lines.append(post.caption)
|
||||
lines.append("")
|
||||
lines.append(f"👤 @{post.owner_username}")
|
||||
lines.append(f"❤️ {post.likes} likes")
|
||||
if post.comments:
|
||||
lines.append(f"💬 {post.comments} comments")
|
||||
lines.append(f"📅 {post.date_utc.strftime('%Y-%m-%d %H:%M')}")
|
||||
if post.location:
|
||||
lines.append(f"📍 {post.location}")
|
||||
if post.hashtags:
|
||||
lines.append(f"#️⃣ {' '.join('#' + h for h in post.hashtags)}")
|
||||
|
||||
return ReadResult(
|
||||
title=f"@{post.owner_username}: {(post.caption or '')[:80]}",
|
||||
content="\n".join(lines),
|
||||
url=url,
|
||||
author=f"@{post.owner_username}",
|
||||
date=post.date_utc.strftime("%Y-%m-%d"),
|
||||
platform="instagram",
|
||||
extra={"likes": post.likes, "comments": post.comments},
|
||||
)
|
||||
except Exception:
|
||||
raise # Let executor timeout handle fallback
|
||||
|
||||
def _read_profile_sync(self, L, url: str, path: str) -> ReadResult:
|
||||
"""Read an Instagram profile (sync, runs in executor)."""
|
||||
import instaloader
|
||||
|
||||
# Extract username from path
|
||||
username = path.split("/")[0] if path else ""
|
||||
if not username or username in ("p", "reel", "stories", "explore"):
|
||||
raise ValueError("Cannot extract username from URL")
|
||||
|
||||
try:
|
||||
profile = instaloader.Profile.from_username(L.context, username)
|
||||
|
||||
lines = []
|
||||
lines.append(f"👤 {profile.full_name} (@{profile.username})")
|
||||
if profile.biography:
|
||||
lines.append(f"📝 {profile.biography}")
|
||||
if profile.external_url:
|
||||
lines.append(f"🔗 {profile.external_url}")
|
||||
lines.append("")
|
||||
lines.append(f"📊 {profile.mediacount} posts · "
|
||||
f"{profile.followers} followers · "
|
||||
f"{profile.followees} following")
|
||||
if profile.is_verified:
|
||||
lines.append("✅ Verified")
|
||||
if profile.is_business_account and profile.business_category_name:
|
||||
lines.append(f"🏢 {profile.business_category_name}")
|
||||
|
||||
# Get recent posts (up to 5)
|
||||
lines.append("")
|
||||
lines.append("📸 Recent posts:")
|
||||
count = 0
|
||||
for post in profile.get_posts():
|
||||
if count >= 5:
|
||||
break
|
||||
caption = (post.caption or "")[:100].replace("\n", " ")
|
||||
lines.append(f" • ❤️{post.likes} | {post.date_utc.strftime('%m-%d')} | {caption}")
|
||||
count += 1
|
||||
|
||||
return ReadResult(
|
||||
title=f"{profile.full_name} (@{profile.username}) - Instagram",
|
||||
content="\n".join(lines),
|
||||
url=url,
|
||||
author=f"@{profile.username}",
|
||||
platform="instagram",
|
||||
extra={
|
||||
"followers": profile.followers,
|
||||
"posts": profile.mediacount,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
raise # Let executor timeout handle fallback
|
||||
|
||||
async def _read_jina(self, url: str) -> ReadResult:
|
||||
"""Fallback: use Jina Reader."""
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://r.jina.ai/{url}",
|
||||
headers={"Accept": "text/markdown"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.text
|
||||
return ReadResult(
|
||||
title=text[:100] if text else url,
|
||||
content=text,
|
||||
url=url,
|
||||
platform="instagram",
|
||||
)
|
||||
except Exception:
|
||||
return ReadResult(
|
||||
title="Instagram",
|
||||
content=(
|
||||
f"⚠️ 无法读取此 Instagram 内容: {url}\n\n"
|
||||
"提示:\n"
|
||||
"- 确保 URL 正确\n"
|
||||
"- 安装 instaloader: pip install instaloader\n"
|
||||
"- 登录以访问更多内容: instaloader --login YOUR_USERNAME"
|
||||
),
|
||||
url=url,
|
||||
platform="instagram",
|
||||
)
|
||||
|
||||
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
|
||||
"""Search Instagram via Exa."""
|
||||
limit = kwargs.get("limit", 10)
|
||||
from agent_reach.channels.exa_search import ExaSearchChannel
|
||||
exa = ExaSearchChannel()
|
||||
return await exa.search(f"site:instagram.com {query}", config=config, limit=limit)
|
||||
+1
-55
@@ -89,11 +89,6 @@ def main():
|
||||
p_sx.add_argument("query", nargs="+", help="Search query")
|
||||
p_sx.add_argument("-n", "--num", type=int, default=10, help="Number of results")
|
||||
|
||||
# ── search-instagram ──
|
||||
p_si = sub.add_parser("search-instagram", help="Search Instagram")
|
||||
p_si.add_argument("query", nargs="+", help="Search query")
|
||||
p_si.add_argument("-n", "--num", type=int, default=10, help="Number of results")
|
||||
|
||||
# ── search-linkedin ──
|
||||
p_sl = sub.add_parser("search-linkedin", help="Search LinkedIn")
|
||||
p_sl.add_argument("query", nargs="+", help="Search query")
|
||||
@@ -122,8 +117,7 @@ def main():
|
||||
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
|
||||
p_conf.add_argument("key", nargs="?", default=None,
|
||||
choices=["proxy", "github-token", "groq-key",
|
||||
"twitter-cookies", "youtube-cookies",
|
||||
"instagram-cookies"],
|
||||
"twitter-cookies", "youtube-cookies"],
|
||||
help="What to configure (omit if using --from-browser)")
|
||||
p_conf.add_argument("value", nargs="*", help="The value(s) to set")
|
||||
p_conf.add_argument("--from-browser", metavar="BROWSER",
|
||||
@@ -436,23 +430,6 @@ def _install_system_deps():
|
||||
except Exception:
|
||||
print(" ⬜ undici install failed (optional — bird may not work behind proxies)")
|
||||
|
||||
# ── instaloader (for Instagram) ──
|
||||
if shutil.which("instaloader"):
|
||||
print(" ✅ instaloader already installed")
|
||||
else:
|
||||
print(" 📥 Installing instaloader...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "instaloader"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if shutil.which("instaloader"):
|
||||
print(" ✅ instaloader installed (Instagram reading)")
|
||||
else:
|
||||
print(" ⬜ instaloader install failed (optional — try: pip install instaloader)")
|
||||
except Exception:
|
||||
print(" ⬜ instaloader install failed (optional — try: pip install instaloader)")
|
||||
|
||||
|
||||
def _install_system_deps_safe():
|
||||
"""Safe mode: check what's installed, print instructions for what's missing."""
|
||||
@@ -464,7 +441,6 @@ def _install_system_deps_safe():
|
||||
("gh", ["gh"], "GitHub CLI", "https://cli.github.com — or: apt install gh / brew install gh"),
|
||||
("node", ["node", "npm"], "Node.js", "https://nodejs.org — or: apt install nodejs npm"),
|
||||
("bird", ["bird", "birdx"], "bird CLI (Twitter)", "npm install -g @steipete/bird"),
|
||||
("instaloader", ["instaloader"], "instaloader (Instagram)", "pip install instaloader"),
|
||||
]
|
||||
|
||||
missing = []
|
||||
@@ -495,7 +471,6 @@ def _install_system_deps_dryrun():
|
||||
("gh CLI", ["gh"], "apt install gh / brew install gh"),
|
||||
("Node.js", ["node"], "curl NodeSource setup | bash + apt install nodejs"),
|
||||
("bird CLI", ["bird", "birdx"], "npm install -g @steipete/bird"),
|
||||
("instaloader", ["instaloader"], "pip install instaloader"),
|
||||
]
|
||||
|
||||
for label, binaries, method in checks:
|
||||
@@ -764,9 +739,6 @@ def _cmd_configure(args):
|
||||
config.set("groq_api_key", value)
|
||||
print(f"✅ Groq key configured!")
|
||||
|
||||
elif args.key == "instagram-cookies":
|
||||
_configure_instagram_cookies(value)
|
||||
|
||||
|
||||
def _cmd_doctor():
|
||||
from agent_reach.config import Config
|
||||
@@ -787,30 +759,6 @@ def _parse_cookie_header(cookie_str: str) -> dict:
|
||||
return cookies
|
||||
|
||||
|
||||
def _configure_instagram_cookies(value: str):
|
||||
"""Save Instagram cookies from Cookie-Editor Header String."""
|
||||
from pathlib import Path
|
||||
|
||||
cookies = _parse_cookie_header(value)
|
||||
if "sessionid" not in cookies:
|
||||
print("❌ Cookie 里缺少 sessionid。")
|
||||
print(" 确保你已登录 Instagram,然后用 Cookie-Editor 导出 Header String。")
|
||||
print(' 格式: agent-reach configure instagram-cookies "sessionid=xxx; csrftoken=yyy; ..."')
|
||||
return
|
||||
|
||||
cookie_dir = Path.home() / ".agent-reach"
|
||||
cookie_dir.mkdir(parents=True, exist_ok=True)
|
||||
cookie_file = cookie_dir / "instagram-cookies.txt"
|
||||
cookie_file.write_text(value.strip())
|
||||
cookie_file.chmod(0o600)
|
||||
|
||||
print(f"✅ Instagram cookies 已保存!")
|
||||
print(f" sessionid: {cookies['sessionid'][:8]}...")
|
||||
if "csrftoken" in cookies:
|
||||
print(f" csrftoken: ✅")
|
||||
if "ds_user_id" in cookies:
|
||||
print(f" ds_user_id: {cookies['ds_user_id']}")
|
||||
print(f" 文件: {cookie_file}")
|
||||
|
||||
|
||||
def _cmd_setup():
|
||||
@@ -952,8 +900,6 @@ async def _cmd_search(args):
|
||||
results = await eyes.search_bilibili(query, limit=num)
|
||||
elif args.command == "search-xhs":
|
||||
results = await eyes.search_xhs(query, limit=num)
|
||||
elif args.command == "search-instagram":
|
||||
results = await eyes.search_instagram(query, limit=num)
|
||||
elif args.command == "search-linkedin":
|
||||
results = await eyes.search_linkedin(query, limit=num)
|
||||
elif args.command == "search-bosszhipin":
|
||||
|
||||
@@ -101,12 +101,6 @@ class AgentReach:
|
||||
results = await ch.search(query, config=self.config, limit=limit)
|
||||
return [r.to_dict() for r in results]
|
||||
|
||||
async def search_instagram(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search Instagram via Exa."""
|
||||
ch = get_channel("instagram")
|
||||
results = await ch.search(query, config=self.config, limit=limit)
|
||||
return [r.to_dict() for r in results]
|
||||
|
||||
async def search_linkedin(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search LinkedIn via MCP or Exa."""
|
||||
ch = get_channel("linkedin")
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
name: agent-reach
|
||||
description: >
|
||||
Give your AI agent eyes to see the entire internet. Read and search across
|
||||
Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu, Instagram, LinkedIn,
|
||||
Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu, LinkedIn,
|
||||
Boss直聘, RSS, and any web page — all from a single CLI.
|
||||
Use when: (1) reading content from URLs (tweets, Reddit posts, articles, videos),
|
||||
(2) searching across platforms (web, Twitter, Reddit, GitHub, YouTube, Bilibili,
|
||||
XiaoHongShu, Instagram, LinkedIn, Boss直聘),
|
||||
XiaoHongShu, LinkedIn, Boss直聘),
|
||||
(3) user asks to configure/enable a platform channel,
|
||||
(4) checking channel health or updating Agent Reach.
|
||||
Triggers: "search Twitter/Reddit/YouTube", "read this URL", "find posts about",
|
||||
@@ -31,7 +31,7 @@ pip install https://github.com/Panniantong/agent-reach/archive/main.zip
|
||||
agent-reach install --env=auto
|
||||
```
|
||||
|
||||
`install` auto-detects your environment and installs core dependencies (Node.js, mcporter, bird CLI, gh CLI, instaloader). Read the output and run `agent-reach doctor` to see what's active.
|
||||
`install` auto-detects your environment and installs core dependencies (Node.js, mcporter, bird CLI, gh CLI). Read the output and run `agent-reach doctor` to see what's active.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -40,7 +40,7 @@ agent-reach install --env=auto
|
||||
agent-reach read <url>
|
||||
agent-reach read <url> --json # structured output
|
||||
```
|
||||
Handles: tweets, Reddit posts, articles, YouTube/Bilibili (transcripts), GitHub repos, Instagram posts, LinkedIn profiles, Boss直聘 jobs, XiaoHongShu notes, RSS feeds, and any web page.
|
||||
Handles: tweets, Reddit posts, articles, YouTube/Bilibili (transcripts), GitHub repos, LinkedIn profiles, Boss直聘 jobs, XiaoHongShu notes, RSS feeds, and any web page.
|
||||
|
||||
### Search
|
||||
|
||||
@@ -52,7 +52,6 @@ agent-reach search-github "query" # GitHub (--lang <language>)
|
||||
agent-reach search-youtube "query" # YouTube
|
||||
agent-reach search-bilibili "query" # Bilibili (B站)
|
||||
agent-reach search-xhs "query" # XiaoHongShu (小红书)
|
||||
agent-reach search-instagram "query" # Instagram
|
||||
agent-reach search-linkedin "query" # LinkedIn
|
||||
agent-reach search-bosszhipin "query" # Boss直聘
|
||||
```
|
||||
@@ -71,7 +70,6 @@ agent-reach check-update # check for new versions
|
||||
|
||||
```bash
|
||||
agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy"
|
||||
agent-reach configure instagram-cookies "sessionid=xxx; csrftoken=yyy; ..."
|
||||
agent-reach configure proxy http://user:pass@ip:port
|
||||
agent-reach configure --from-browser chrome # auto-extract cookies from local browser
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user