Install any skill in seconds. Free to start, no credit card required.
Get Started Free →任意のパブリックソース(ジョブボード、価格、ニュース、GitHub、スポーツなど)用の完全自動化されたAI搭載データ収集エージェントを構築します。スケジュールでスクレイプし、無料LLM(Gemini Flash)でデータを豊かにし、Notion/Sheets/Supabaseに結果を保存し、ユーザーフィードバックから学習します。GitHub Actions上で100%無料で実行。ユーザーがパブリックデータを自動的に監視、収集、または追跡したい場合に使用します。
.claude/skills/affaan-m-data-scraper-agent/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-14 | ✗→✓ | ▲ Improved | 295% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 112% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 153% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 190% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 316% | 0% |
构建一个生产就绪、AI驱动的数据收集代理,适用于任何公共数据源。 按计划运行,使用免费LLM丰富结果,存储到数据库,并随时间推移不断改进。
技术栈:Python · Gemini Flash (免费) · GitHub Actions (免费) · Notion / Sheets / Supabase
每个数据抓取代理都有三层:
COLLECT → ENRICH → STORE
│ │ │
Scraper AI (LLM) Database
runs on scores/ Notion /
schedule summarises Sheets /
& classifies Supabase| 层级 | 工具 | 原因 | |---|---|---| | 抓取 | requests + BeautifulSoup | 无成本,覆盖80%的公共网站 | | JS渲染的网站 | playwright (免费) | 当HTML抓取失败时使用 | | AI丰富 | 通过REST API的Gemini Flash | 500次请求/天,100万令牌/天 — 免费 | | 存储 | Notion API | 免费层级,用于审查的优秀UI | | 调度 | GitHub Actions cron | 对公共仓库免费 | | 学习 | 仓库中的JSON反馈文件 | 零基础设施,在git中持久化 |
构建代理以在配额耗尽时自动在Gemini模型间回退:
gemini-2.0-flash-lite (30 RPM) →
gemini-2.0-flash (15 RPM) →
gemini-2.5-flash (10 RPM) →
gemini-flash-lite-latest (fallback)切勿为每个项目单独调用LLM。始终批量处理:
python# BAD: 33 API calls for 33 items for item in items: result = call_ai(item) # 33 calls → hits rate limit # GOOD: 7 API calls for 33 items (batch size 5) for batch in chunks(items, size=5): results = call_ai(batch) # 7 calls → stays within free tier
询问用户:
常见的提示示例:
为用户生成以下目录结构:
my-agent/
├── config.yaml # 用户自定义此文件(关键词、过滤器、偏好设置)
├── profile/
│ └── context.md # AI 使用的用户上下文(简历、兴趣、标准)
├── scraper/
│ ├── __init__.py
│ ├── main.py # 协调器:抓取 → 丰富 → 存储
│ ├── filters.py # 基于规则的预过滤器(快速,在 AI 处理之前)
│ └── sources/
│ ├── __init__.py
│ └── source_name.py # 每个数据源一个文件
├── ai/
│ ├── __init__.py
│ ├── client.py # Gemini REST 客户端,带模型回退
│ ├── pipeline.py # 批量 AI 分析
│ ├── jd_fetcher.py # 从 URL 获取完整内容(可选)
│ └── memory.py # 从用户反馈中学习
├── storage/
│ ├── __init__.py
│ └── notion_sync.py # 或 sheets_sync.py / supabase_sync.py
├── data/
│ └── feedback.json # 用户决策历史(自动更新)
├── .env.example
├── setup.py # 一次性数据库/模式创建
├── enrich_existing.py # 对旧行进行 AI 分数回填
├── requirements.txt
└── .github/
└── workflows/
└── scraper.yml # GitHub Actions 计划任务适用于任何数据源的模板:
python# scraper/sources/my_source.py """ [Source Name] — scrapes [what] from [where]. Method: [REST API / HTML scraping / RSS feed] """ import requests from bs4 import BeautifulSoup from datetime import datetime, timezone from scraper.filters import is_relevant HEADERS = { "User-Agent": "Mozilla/5.0 (compatible; research-bot/1.0)", } def fetch() -> list[dict]: """ Returns a list of items with consistent schema. Each item must have at minimum: name, url, date_found. """ results = [] # ---- REST API source ---- resp = requests.get("https://api.example.com/items", headers=HEADERS, timeout=15) if resp.status_code == 200: for item in resp.json().get("results", []): if not is_relevant(item.get("title", "")): continue results.append(_normalise(item)) return results def _normalise(raw: dict) -> dict: """Convert raw API/HTML data to the standard schema.""" return { "name": raw.get("title", ""), "url": raw.get("link", ""), "source": "MySource", "date_found": datetime.now(timezone.utc).date().isoformat(), # add domain-specific fields here }
HTML抓取模式:
pythonsoup = BeautifulSoup(resp.text, "lxml") for card in soup.select("[class*='listing']"): title = card.select_one("h2, h3").get_text(strip=True) link = card.select_one("a")["href"] if not link.startswith("http"): link = f"https://example.com{link}"
RSS源模式:
pythonimport xml.etree.ElementTree as ET root = ET.fromstring(resp.text) for item in root.findall(".//item"): title = item.findtext("title", "") link = item.findtext("link", "")
`python# ai/client.py import os, json, time, requests _last_call = 0.0 MODEL_FALLBACK = [ "gemini-2.0-flash-lite", "gemini-2.0-flash", "gemini-2.5-flash", "gemini-flash-lite-latest", ] def generate(prompt: str, model: str = "", rate_limit: float = 7.0) -> dict: """Call Gemini with auto-fallback on 429. Returns parsed JSON or {}.""" global _last_call api_key = os.environ.get("GEMINI_API_KEY", "") if not api_key: return {} elapsed = time.time() - _last_call if elapsed < rate_limit: time.sleep(rate_limit - elapsed) models = [model] + [m for m in MODEL_FALLBACK if m != model] if model else MODEL_FALLBACK _last_call = time.time() for m in models: url = f"https://generativelanguage.googleapis.com/v1beta/models/{m}:generateContent?key={api_key}" payload = { "contents": [{"parts": [{"text": prompt}]}], "generationConfig": { "responseMimeType": "application/json", "temperature": 0.3, "maxOutputTokens": 2048, }, } try: resp = requests.post(url, json=payload, timeout=30) if resp.status_code == 200: return _parse(resp) if resp.status_code in (429, 404): time.sleep(1) continue return {} except requests.RequestException: return {} return {} def _parse(resp) -> dict: try: text = ( resp.json() .get("candidates", [{}])[0] .get("content", {}) .get("parts", [{}])[0] .get("text", "") .strip() ) if text.startswith("```"): text = text.split("\n", 1)[-1].rsplit("```", 1)[0] return json.loads(text) except (json.JSONDecodeError, KeyError): return {}
python# ai/pipeline.py import json import yaml from pathlib import Path from ai.client import generate def analyse_batch(items: list[dict], context: str = "", preference_prompt: str = "") -> list[dict]: """Analyse items in batches. Returns items enriched with AI fields.""" config = yaml.safe_load((Path(__file__).parent.parent / "config.yaml").read_text()) model = config.get("ai", {}).get("model", "gemini-2.5-flash") rate_limit = config.get("ai", {}).get("rate_limit_seconds", 7.0) min_score = config.get("ai", {}).get("min_score", 0) batch_size = config.get("ai", {}).get("batch_size", 5) batches = [items[i:i + batch_size] for i in range(0, len(items), batch_size)] print(f" [AI] {len(items)} items → {len(batches)} API calls") enriched = [] for i, batch in enumerate(batches): print(f" [AI] Batch {i + 1}/{len(batches)}...") prompt = _build_prompt(batch, context, preference_prompt, config) result = generate(prompt, model=model, rate_limit=rate_limit) analyses = result.get("analyses", []) for j, item in enumerate(batch): ai = analyses[j] if j < len(analyses) else {} if ai: score = max(0, min(100, int(ai.get("score", 0)))) if min_score and score < min_score: continue enriched.append({**item, "ai_score": score, "ai_summary": ai.get("summary", ""), "ai_notes": ai.get("notes", "")}) else: enriched.append(item) return enriched def _build_prompt(batch, context, preference_prompt, config): priorities = config.get("priorities", []) items_text = "\n\n".join( f"Item {i+1}: {json.dumps({k: v for k, v in item.items() if not k.startswith('_')})}" for i, item in enumerate(batch) ) return f"""Analyse these {len(batch)} items and return a JSON object. # Items {items_text} # User Context {context[:800] if context else "Not provided"} # User Priorities {chr(10).join(f"- {p}" for p in priorities)} {preference_prompt} # Instructions Return: {{"analyses": [{{"score": <0-100>, "summary": "<2 sentences>", "notes": "<why this matches or doesn't>"}} for each item in order]}} Be concise. Score 90+=excellent match, 70-89=good, 50-69=ok, <50=weak."""
python# ai/memory.py """Learn from user decisions to improve future scoring.""" import json from pathlib import Path FEEDBACK_PATH = Path(__file__).parent.parent / "data" / "feedback.json" def load_feedback() -> dict: if FEEDBACK_PATH.exists(): try: return json.loads(FEEDBACK_PATH.read_text()) except (json.JSONDecodeError, OSError): pass return {"positive": [], "negative": []} def save_feedback(fb: dict): FEEDBACK_PATH.parent.mkdir(parents=True, exist_ok=True) FEEDBACK_PATH.write_text(json.dumps(fb, indent=2)) def build_preference_prompt(feedback: dict, max_examples: int = 15) -> str: """Convert feedback history into a prompt bias section.""" lines = [] if feedback.get("positive"): lines.append("# Items the user LIKED (positive signal):") for e in feedback["positive"][-max_examples:]: lines.append(f"- {e}") if feedback.get("negative"): lines.append("\n# Items the user SKIPPED/REJECTED (negative signal):") for e in feedback["negative"][-max_examples:]: lines.append(f"- {e}") if lines: lines.append("\nUse these patterns to bias scoring on new items.") return "\n".join(lines)
与存储层集成: 每次运行后,从数据库中查询具有正面/负面状态的项,并使用提取的模式调用 save_feedback()。
python# storage/notion_sync.py import os from notion_client import Client from notion_client.errors import APIResponseError _client = None def get_client(): global _client if _client is None: _client = Client(auth=os.environ["NOTION_TOKEN"]) return _client def get_existing_urls(db_id: str) -> set[str]: """Fetch all URLs already stored — used for deduplication.""" client, seen, cursor = get_client(), set(), None while True: resp = client.databases.query(database_id=db_id, page_size=100, **{"start_cursor": cursor} if cursor else {}) for page in resp["results"]: url = page["properties"].get("URL", {}).get("url", "") if url: seen.add(url) if not resp["has_more"]: break cursor = resp["next_cursor"] return seen def push_item(db_id: str, item: dict) -> bool: """Push one item to Notion. Returns True on success.""" props = { "Name": {"title": [{"text": {"content": item.get("name", "")[:100]}}]}, "URL": {"url": item.get("url")}, "Source": {"select": {"name": item.get("source", "Unknown")}}, "Date Found": {"date": {"start": item.get("date_found")}}, "Status": {"select": {"name": "New"}}, } # AI fields if item.get("ai_score") is not None: props["AI Score"] = {"number": item["ai_score"]} if item.get("ai_summary"): props["Summary"] = {"rich_text": [{"text": {"content": item["ai_summary"][:2000]}}]} if item.get("ai_notes"): props["Notes"] = {"rich_text": [{"text": {"content": item["ai_notes"][:2000]}}]} try: get_client().pages.create(parent={"database_id": db_id}, properties=props) return True except APIResponseError as e: print(f"[notion] Push failed: {e}") return False def sync(db_id: str, items: list[dict]) -> tuple[int, int]: existing = get_existing_urls(db_id) added = skipped = 0 for item in items: if item.get("url") in existing: skipped += 1; continue if push_item(db_id, item): added += 1; existing.add(item["url"]) else: skipped += 1 return added, skipped
python# scraper/main.py import os, sys, yaml from pathlib import Path from dotenv import load_dotenv load_dotenv() from scraper.sources import my_source # add your sources # NOTE: This example uses Notion. If storage.provider is "sheets" or "supabase", # replace this import with storage.sheets_sync or storage.supabase_sync and update # the env var and sync() call accordingly. from storage.notion_sync import sync SOURCES = [ ("My Source", my_source.fetch), ] def ai_enabled(): return bool(os.environ.get("GEMINI_API_KEY")) def main(): config = yaml.safe_load((Path(__file__).parent.parent / "config.yaml").read_text()) provider = config.get("storage", {}).get("provider", "notion") # Resolve the storage target identifier from env based on provider if provider == "notion": db_id = os.environ.get("NOTION_DATABASE_ID") if not db_id: print("ERROR: NOTION_DATABASE_ID not set"); sys.exit(1) else: # Extend here for sheets (SHEET_ID) or supabase (SUPABASE_TABLE) etc. print(f"ERROR: provider '{provider}' not yet wired in main.py"); sys.exit(1) config = yaml.safe_load((Path(__file__).parent.parent / "config.yaml").read_text()) all_items = [] for name, fetch_fn in SOURCES: try: items = fetch_fn() print(f"[{name}] {len(items)} items") all_items.extend(items) except Exception as e: print(f"[{name}] FAILED: {e}") # Deduplicate by URL seen, deduped = set(), [] for item in all_items: if (url := item.get("url", "")) and url not in seen: seen.add(url); deduped.append(item) print(f"Unique items: {len(deduped)}") if ai_enabled() and deduped: from ai.memory import load_feedback, build_preference_prompt from ai.pipeline import analyse_batch # load_feedback() reads data/feedback.json written by your feedback sync script. # To keep it current, implement a separate feedback_sync.py that queries your # storage provider for items with positive/negative statuses and calls save_feedback(). feedback = load_feedback() preference = build_preference_prompt(feedback) context_path = Path(__file__).parent.parent / "profile" / "context.md" context = context_path.read_text() if context_path.exists() else "" deduped = analyse_batch(deduped, context=context, preference_prompt=preference) else: print("[AI] Skipped — GEMINI_API_KEY not set") added, skipped = sync(db_id, deduped) print(f"Done — {added} new, {skipped} existing") if __name__ == "__main__": main()
yaml# .github/workflows/scraper.yml name: Data Scraper Agent on: schedule: - cron: "0 */3 * * *" # every 3 hours — adjust to your needs workflow_dispatch: # allow manual trigger permissions: contents: write # required for the feedback-history commit step jobs: scrape: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache: "pip" - run: pip install -r requirements.txt # Uncomment if Playwright is enabled in requirements.txt # - name: Install Playwright browsers # run: python -m playwright install chromium --with-deps - name: Run agent env: NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: python -m scraper.main - name: Commit feedback history run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add data/feedback.json || true git diff --cached --quiet || git commit -m "chore: update feedback history" git push
yaml# Customise this file — no code changes needed # What to collect (pre-filter before AI) filters: required_keywords: [] # item must contain at least one blocked_keywords: [] # item must not contain any # Your priorities — AI uses these for scoring priorities: - "example priority 1" - "example priority 2" # Storage storage: provider: "notion" # notion | sheets | supabase | sqlite # Feedback learning feedback: positive_statuses: ["Saved", "Applied", "Interested"] negative_statuses: ["Skip", "Rejected", "Not relevant"] # AI settings ai: enabled: true model: "gemini-2.5-flash" min_score: 0 # filter out items below this score rate_limit_seconds: 7 # seconds between API calls batch_size: 5 # items per API call
pythonresp = requests.get(url, params={"q": query}, headers=HEADERS, timeout=15) items = resp.json().get("results", [])
pythonsoup = BeautifulSoup(resp.text, "lxml") for card in soup.select(".listing-card"): title = card.select_one("h2").get_text(strip=True) href = card.select_one("a")["href"]
pythonimport xml.etree.ElementTree as ET root = ET.fromstring(resp.text) for item in root.findall(".//item"): title = item.findtext("title", "") link = item.findtext("link", "") pub_date = item.findtext("pubDate", "")
pythonpage = 1 while True: resp = requests.get(url, params={"page": page, "limit": 50}, timeout=15) data = resp.json() items = data.get("results", []) if not items: break for item in items: results.append(_normalise(item)) if not data.get("has_more"): break page += 1
pythonfrom playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto(url) page.wait_for_selector(".listing") html = page.content() browser.close() soup = BeautifulSoup(html, "lxml")
| 反模式 | 问题 | 修复方法 | |---|---|---| | 每个项目调用一次LLM | 立即达到速率限制 | 每次调用批量处理5个项目 | | 代码中硬编码关键字 | 不可重用 | 将所有配置移动到 config.yaml | | 没有速率限制的抓取 | IP被禁止 | 在请求之间添加 time.sleep(1) | | 在代码中存储密钥 | 安全风险 | 始终使用 .env + GitHub Secrets | | 没有去重 | 重复行堆积 | 在推送前始终检查URL | | 忽略 robots.txt | 法律/道德风险 | 遵守爬虫规则;尽可能使用公共API | | 使用 requests 处理JS渲染的网站 | 空响应 | 使用Playwright或查找底层API | | maxOutputTokens 太低 | JSON截断,解析错误 | 对批量响应使用2048+ |
| 服务 | 免费限制 | 典型用法 | |---|---|---| | Gemini Flash Lite | 30 RPM, 1500 RPD | 以3小时间隔约56次请求/天 | | Gemini 2.0 Flash | 15 RPM, 1500 RPD | 良好的后备选项 | | Gemini 2.5 Flash | 10 RPM, 500 RPD | 谨慎使用 | | GitHub Actions | 无限(公共仓库) | 约20分钟/天 | | Notion API | 无限 | 约200次写入/天 | | Supabase | 500MB DB, 2GB传输 | 适用于大多数代理 | | Google Sheets API | 300次请求/分钟 | 适用于小型代理 |
requests==2.31.0
beautifulsoup4==4.12.3
lxml==5.1.0
python-dotenv==1.0.1
pyyaml==6.0.2
notion-client==2.2.1 # 如需使用 Notion
# playwright==1.40.0 # 针对 JS 渲染的站点,请取消注释在将代理标记为完成之前:
config.yaml 控制所有面向用户的设置 — 没有硬编码的值profile/context.md 保存用于AI匹配的用户特定上下文maxOutputTokens ≥ 2048.env 在 .gitignore 中.env.examplesetup.py 在首次运行时创建数据库模式enrich_existing.py 回填旧行的AI分数feedback.json"为我构建一个监控 Hacker News 上 AI 初创公司融资新闻的智能体"
"从 3 家电商网站抓取产品价格并在降价时发出提醒"
"追踪标记有 'llm' 或 'agents' 的新 GitHub 仓库——并为每个仓库生成摘要"
"将 LinkedIn 和 Cutshort 上的首席运营官职位列表收集到 Notion 中"
"监控一个提到我公司的 subreddit 帖子——并进行情感分类"
"每日从 arXiv 抓取我关注主题的新学术论文"
"追踪体育赛事结果并在 Google Sheets 中维护动态更新的表格"
"构建一个房地产房源监控器——在新房源价格低于 1 千万卢比时发出提醒"一个使用此确切架构构建的完整工作代理将抓取4+个数据源, 批量处理Gemini调用,从存储在Notion中的"已应用"/"已拒绝"决策中学习,并且 在GitHub Actions上100%免费运行。按照上述步骤1-9构建您自己的代理。
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-14 | fail→pass | 12,383 | 8,917 | -28% | 1 | 1 | 0% | 2,199 | 8,685 | +295% | 0 | 0 | — |
case-01 | fail→fail | 25,896 | 27,249 | +5% | 1 | 1 | 0% | 6,049 | 13,192 | +118% | 0 | 0 | — |
case-02 | fail→pass | 28,308 | 27,084 | -4% | 1 | 1 | 0% | 6,206 | 13,180 | +112% | 0 | 0 | — |
case-03 | fail→pass | 26,978 | 24,922 | -8% | 1 | 1 | 0% | 5,000 | 12,657 | +153% | 0 | 0 | — |
case-04 | fail→pass | 16,752 | 8,498 | -49% | 1 | 1 | 0% | 3,005 | 8,716 | +190% | 0 | 0 | — |
case-05 | fail→pass | 19,754 | 10,058 | -49% | 1 | 1 | 0% | 2,079 | 8,658 | +316% | 0 | 0 | — |
case-06 | pass→pass | 11,623 | 8,283 | -29% | 1 | 1 | 0% | 1,970 | 8,320 | +322% | 0 | 0 | — |
case-07 | pass→pass | 14,938 | 13,743 | -8% | 1 | 1 | 0% | 2,978 | 9,488 | +219% | 0 | 0 | — |
case-08 | fail→pass | 11,595 | 6,175 | -47% | 1 | 1 | 0% | 2,140 | 8,175 | +282% | 0 | 0 | — |
case-09 | fail→pass | 13,952 | 14,618 | +5% | 1 | 1 | 0% | 2,241 | 9,595 | +328% | 0 | 0 | — |
case-10 | fail→pass | 9,518 | 7,205 | -24% | 1 | 1 | 0% | 1,744 | 8,251 | +373% | 0 | 0 | — |
case-11 | pass→pass | 14,003 | 12,643 | -10% | 1 | 1 | 0% | 2,094 | 8,889 | +324% | 0 | 0 | — |
case-12 | fail→pass | 12,312 | 4,901 | -60% | 1 | 1 | 0% | 2,050 | 7,700 | +276% | 0 | 0 | — |
case-13 | pass→pass | 7,276 | 7,562 | +4% | 1 | 1 | 0% | 1,438 | 8,537 | +494% | 0 | 0 | — |
case-15 | pass→pass | 13,087 | 17,268 | +32% | 1 | 1 | 0% | 2,423 | 10,116 | +317% | 0 | 0 | — |
case-16 | pass→pass | 13,479 | 12,210 | -9% | 1 | 1 | 0% | 2,809 | 9,284 | +231% | 0 | 0 | — |
case-17 | pass→pass | 14,004 | 11,771 | -16% | 1 | 1 | 0% | 2,292 | 9,256 | +304% | 0 | 0 | — |
case-18 | pass→pass | 4,501 | 2,677 | -41% | 1 | 1 | 0% | 689 | 7,348 | +966% | 0 | 0 | — |
case-19 | pass→pass | 14,516 | 15,978 | +10% | 1 | 1 | 0% | 2,307 | 9,583 | +315% | 0 | 0 | — |
case-20 | pass→pass | 18,675 | 17,415 | -7% | 1 | 1 | 0% | 3,079 | 9,859 | +220% | 0 | 0 | — |
case-21 | pass→pass | 17,633 | 16,800 | -5% | 1 | 1 | 0% | 1,984 | 8,801 | +344% | 0 | 0 | — |
case-22 | pass→pass | 13,379 | 19,158 | +43% | 1 | 1 | 0% | 2,273 | 10,239 | +350% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.