feat: complete channel menu install flow + XiaoHongShu channel
Major install flow polish: - Channels organized by setup difficulty: 🟢 Zero-config (Web, GitHub, RSS) — works immediately 🔵 Cookie-based (Twitter, YouTube, Bilibili, XiaoHongShu) — free, ~2min 🟡 Free API key (Exa Search) — one key, 30 seconds 🟠 Proxy-based (Reddit, Bilibili on server) — $1/month - Every channel explains: what it does, what's needed, what you miss without it - Server vs local affects which channels need proxy New: XiaoHongShu channel (cookie-based, falls back to Jina Reader) New configure commands: twitter-cookies, xhs-cookie, youtube-cookies Each command auto-tests after saving.
This commit is contained in:
@@ -19,6 +19,7 @@ from .reddit import RedditChannel
|
||||
from .rss import RSSChannel
|
||||
from .bilibili import BilibiliChannel
|
||||
from .exa_search import ExaSearchChannel
|
||||
from .xiaohongshu import XiaoHongShuChannel
|
||||
|
||||
|
||||
# Channel registry — order matters (first match wins, web is last as fallback)
|
||||
@@ -28,6 +29,7 @@ ALL_CHANNELS: List[Channel] = [
|
||||
YouTubeChannel(),
|
||||
RedditChannel(),
|
||||
BilibiliChannel(),
|
||||
XiaoHongShuChannel(),
|
||||
RSSChannel(),
|
||||
ExaSearchChannel(),
|
||||
WebChannel(), # Fallback — handles any URL
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""XiaoHongShu (小红书) — via cookie-based API access.
|
||||
|
||||
Backend: XHS web API + cookies
|
||||
Swap to: any XHS access method
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from .base import Channel, ReadResult
|
||||
|
||||
|
||||
class XiaoHongShuChannel(Channel):
|
||||
name = "xiaohongshu"
|
||||
description = "XiaoHongShu (小红书) notes"
|
||||
backends = ["XHS Web API"]
|
||||
requires_config = ["xhs_cookie"]
|
||||
tier = 2
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
domain = urlparse(url).netloc.lower()
|
||||
return "xiaohongshu.com" in domain or "xhslink.com" in domain
|
||||
|
||||
async def read(self, url: str, config=None) -> ReadResult:
|
||||
cookie = config.get("xhs_cookie") if config else None
|
||||
|
||||
if not cookie:
|
||||
# Fallback to Jina Reader (works for some public notes)
|
||||
from agent_eyes.channels.web import WebChannel
|
||||
return await WebChannel().read(url, config)
|
||||
|
||||
# Extract note ID from URL
|
||||
note_id = self._extract_note_id(url)
|
||||
if not note_id:
|
||||
from agent_eyes.channels.web import WebChannel
|
||||
return await WebChannel().read(url, config)
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
"Cookie": cookie,
|
||||
"Referer": "https://www.xiaohongshu.com/",
|
||||
}
|
||||
|
||||
# Fetch note page
|
||||
resp = requests.get(
|
||||
f"https://www.xiaohongshu.com/explore/{note_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# Extract note data from HTML
|
||||
title, content, author = self._parse_html(html)
|
||||
|
||||
return ReadResult(
|
||||
title=title or f"XHS Note {note_id}",
|
||||
content=content or "Could not extract content. Cookie may be expired.",
|
||||
url=url,
|
||||
author=author,
|
||||
platform="xiaohongshu",
|
||||
)
|
||||
|
||||
def _extract_note_id(self, url: str) -> str:
|
||||
"""Extract note ID from various XHS URL formats."""
|
||||
# https://www.xiaohongshu.com/explore/xxxxx
|
||||
# https://www.xiaohongshu.com/discovery/item/xxxxx
|
||||
# https://xhslink.com/xxxxx
|
||||
path = urlparse(url).path
|
||||
parts = path.strip("/").split("/")
|
||||
if parts:
|
||||
return parts[-1]
|
||||
return ""
|
||||
|
||||
def _parse_html(self, html: str):
|
||||
"""Extract title, content, author from XHS HTML."""
|
||||
title = ""
|
||||
content = ""
|
||||
author = ""
|
||||
|
||||
# Try to find JSON data in page
|
||||
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*({.*?})\s*</script>', html, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
# XHS embeds note data in initial state
|
||||
state = json.loads(match.group(1).replace('undefined', 'null'))
|
||||
note_data = state.get("note", {}).get("noteDetailMap", {})
|
||||
if note_data:
|
||||
first_note = list(note_data.values())[0]
|
||||
note = first_note.get("note", {})
|
||||
title = note.get("title", "")
|
||||
content = note.get("desc", "")
|
||||
author = note.get("user", {}).get("nickname", "")
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
pass
|
||||
|
||||
# Fallback: extract from meta tags
|
||||
if not title:
|
||||
m = re.search(r'<title>(.*?)</title>', html)
|
||||
if m:
|
||||
title = m.group(1)
|
||||
|
||||
if not content:
|
||||
m = re.search(r'<meta name="description" content="(.*?)"', html)
|
||||
if m:
|
||||
content = m.group(1)
|
||||
|
||||
return title, content, author
|
||||
+82
-27
@@ -81,9 +81,10 @@ def main():
|
||||
|
||||
# ── configure ──
|
||||
p_conf = sub.add_parser("configure", help="Set a config value")
|
||||
p_conf.add_argument("key", choices=["exa-key", "proxy", "github-token", "groq-key"],
|
||||
p_conf.add_argument("key", choices=["exa-key", "proxy", "github-token", "groq-key",
|
||||
"twitter-cookies", "xhs-cookie", "youtube-cookies"],
|
||||
help="What to configure")
|
||||
p_conf.add_argument("value", help="The value to set")
|
||||
p_conf.add_argument("value", nargs="+", help="The value(s) to set")
|
||||
|
||||
# ── doctor ──
|
||||
sub.add_parser("doctor", help="Check platform availability")
|
||||
@@ -178,28 +179,36 @@ def _cmd_install(args):
|
||||
def _cmd_configure(args):
|
||||
"""Set a config value and test it."""
|
||||
from agent_eyes.config import Config
|
||||
import subprocess
|
||||
|
||||
config = Config()
|
||||
value = " ".join(args.value) if isinstance(args.value, list) else args.value
|
||||
|
||||
key_map = {
|
||||
"exa-key": "exa_api_key",
|
||||
"proxy": ("reddit_proxy", "bilibili_proxy"),
|
||||
"github-token": "github_token",
|
||||
"groq-key": "groq_api_key",
|
||||
}
|
||||
if args.key == "proxy":
|
||||
config.set("reddit_proxy", value)
|
||||
config.set("bilibili_proxy", value)
|
||||
print(f"✅ Proxy configured for Reddit + Bilibili!")
|
||||
|
||||
config_key = key_map.get(args.key)
|
||||
if isinstance(config_key, tuple):
|
||||
for k in config_key:
|
||||
config.set(k, args.value)
|
||||
else:
|
||||
config.set(config_key, args.value)
|
||||
# Auto-test
|
||||
print("Testing Reddit access...", end=" ")
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
"https://www.reddit.com/r/test.json?limit=1",
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
proxies={"http": value, "https": value},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print("✅ Reddit works!")
|
||||
else:
|
||||
print(f"⚠️ Reddit returned {resp.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed: {e}")
|
||||
|
||||
print(f"✅ {args.key} configured!")
|
||||
elif args.key == "exa-key":
|
||||
config.set("exa_api_key", value)
|
||||
print(f"✅ Exa key configured!")
|
||||
|
||||
# Auto-test
|
||||
if args.key == "exa-key":
|
||||
print("Testing search...", end=" ")
|
||||
try:
|
||||
import asyncio
|
||||
@@ -209,27 +218,73 @@ def _cmd_configure(args):
|
||||
if results:
|
||||
print("✅ Search works!")
|
||||
else:
|
||||
print("⚠️ No results, but API connected.")
|
||||
print("⚠️ No results, but API connected.")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed: {e}")
|
||||
|
||||
elif args.key == "proxy":
|
||||
print("Testing Reddit access...", end=" ")
|
||||
elif args.key == "twitter-cookies":
|
||||
# Expect: auth_token ct0
|
||||
parts = value.split()
|
||||
if len(parts) == 2:
|
||||
config.set("twitter_auth_token", parts[0])
|
||||
config.set("twitter_ct0", parts[1])
|
||||
print(f"✅ Twitter cookies configured!")
|
||||
|
||||
print("Testing Twitter access...", end=" ")
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["birdx", "search", "test", "-n", "1",
|
||||
"--auth-token", parts[0], "--ct0", parts[1]],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
print("✅ Twitter Advanced works!")
|
||||
else:
|
||||
print(f"⚠️ Test returned no results (cookies might be wrong)")
|
||||
except FileNotFoundError:
|
||||
print("⚠️ birdx not installed. Run: pip install birdx")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed: {e}")
|
||||
else:
|
||||
print("❌ Usage: agent-eyes configure twitter-cookies AUTH_TOKEN CT0")
|
||||
print(" (two values separated by space)")
|
||||
|
||||
elif args.key == "xhs-cookie":
|
||||
config.set("xhs_cookie", value)
|
||||
print(f"✅ XiaoHongShu cookie configured!")
|
||||
|
||||
print("Testing XHS access...", end=" ")
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
"https://www.reddit.com/r/test.json?limit=1",
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
proxies={"http": args.value, "https": args.value},
|
||||
"https://www.xiaohongshu.com/",
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Cookie": value,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print("✅ Reddit accessible!")
|
||||
if resp.status_code == 200 and "xiaohongshu" in resp.text.lower():
|
||||
print("✅ XiaoHongShu works!")
|
||||
else:
|
||||
print(f"❌ Reddit returned {resp.status_code}")
|
||||
print(f"⚠️ Got status {resp.status_code}, cookie might be expired")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed: {e}")
|
||||
|
||||
elif args.key == "youtube-cookies":
|
||||
config.set("youtube_cookies_from", value)
|
||||
print(f"✅ YouTube cookie source configured: {value}")
|
||||
print(" yt-dlp will use cookies from this browser for age-restricted/member videos.")
|
||||
|
||||
elif args.key == "github-token":
|
||||
config.set("github_token", value)
|
||||
print(f"✅ GitHub token configured!")
|
||||
|
||||
elif args.key == "groq-key":
|
||||
config.set("groq_api_key", value)
|
||||
print(f"✅ Groq key configured!")
|
||||
|
||||
|
||||
def _cmd_doctor():
|
||||
from agent_eyes.config import Config
|
||||
|
||||
Reference in New Issue
Block a user