[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6JCVRBW1TKAZ4GASE1W8C27":3,"skill-same:01M6JCVRBW1TKAZ4GASE1W8C27":78},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":30,"publishedAt":51,"linkOnly":52,"markdownContent":53,"markdownBody":54,"frontMatter":55,"refName":56,"commitSha":57,"skillDirectory":58,"forksCount":59,"repositoryHtmlUrl":60,"repositoryHomepageUrl":61,"ownerHtmlUrl":62,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":63,"install":64,"resources":77},"01M6JCVRBW1TKAZ4GASE1W8C27","data-scraper-agent",null,"Build a fully automated AI-powered data collection agent for any public source — job boards, prices, news, GitHub, sports, anything. Scrapes on a schedule, enriches data with a free LLM (Gemini Flash), stores results in Notion\u002FSheets\u002FSupabase, and learns from user feedback. Runs 100% free on GitHub Actions. Use when the user wants to monitor, collect, or track any public data automatically.","S3YED\u002Fappie-kit","S3YED","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F62743867?v=4","01M6JBKBRRVP6KMS0VWZ27T3R6","01M6JBKBX0KG30284PW1DBYJT1",14,"MIT","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fecc\u002Fdata-scraper-agent\u002FSKILL.md",[17,22,25],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000011","运维与部署","SCENARIO","使用场景",{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000023","Skill与智能体开发",{"categoryNo":26,"categoryName":27,"taxonomyType":28,"taxonomyTypeDesc":29},"01JBSEEDCAT000000000000033","数据分析师","PROFESSION","适用职业",[31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50],"scraper","data scraper agent","data","user","for","prices","free","news","community","build","use","gemini","results","github actions","actions","supabase","collect","notion","github","gemini flash","2026-11-10 10:12:30",false,"---\nname: data-scraper-agent\ndescription: Build a fully automated AI-powered data collection agent for any public source — job boards, prices, news, GitHub, sports, anything. Scrapes on a schedule, enriches data with a free LLM (Gemini Flash), stores results in Notion\u002FSheets\u002FSupabase, and learns from user feedback. Runs 100% free on GitHub Actions. Use when the user wants to monitor, collect, or track any public data automatically.\norigin: community\n---\n\n# Data Scraper Agent\n\nBuild a production-ready, AI-powered data collection agent for any public data source.\nRuns on a schedule, enriches results with a free LLM, stores to a database, and improves over time.\n\n**Stack: Python · Gemini Flash (free) · GitHub Actions (free) · Notion \u002F Sheets \u002F Supabase**\n\n## When to Activate\n\n- User wants to scrape or monitor any public website or API\n- User says \"build a bot that checks...\", \"monitor X for me\", \"collect data from...\"\n- User wants to track jobs, prices, news, repos, sports scores, events, listings\n- User asks how to automate data collection without paying for hosting\n- User wants an agent that gets smarter over time based on their decisions\n\n## Core Concepts\n\n### The Three Layers\n\nEvery data scraper agent has three layers:\n\n```\nCOLLECT → ENRICH → STORE\n  │           │        │\nScraper    AI (LLM)  Database\nruns on    scores\u002F   Notion \u002F\nschedule   summarises Sheets \u002F\n           & classifies Supabase\n```\n\n### Free Stack\n\n| Layer | Tool | Why |\n|---|---|---|\n| **Scraping** | `requests` + `BeautifulSoup` | No cost, covers 80% of public sites |\n| **JS-rendered sites** | `playwright` (free) | When HTML scraping fails |\n| **AI enrichment** | Gemini Flash via REST API | 500 req\u002Fday, 1M tokens\u002Fday — free |\n| **Storage** | Notion API | Free tier, great UI for review |\n| **Schedule** | GitHub Actions cron | Free for public repos |\n| **Learning** | JSON feedback file in repo | Zero infra, persists in git |\n\n### AI Model Fallback Chain\n\nBuild agents to auto-fallback across Gemini models on quota exhaustion:\n\n```\ngemini-2.0-flash-lite (30 RPM) →\ngemini-2.0-flash (15 RPM) →\ngemini-2.5-flash (10 RPM) →\ngemini-flash-lite-latest (fallback)\n```\n\n### Batch API Calls for Efficiency\n\nNever call the LLM once per item. Always batch:\n\n```python\n# BAD: 33 API calls for 33 items\nfor item in items:\n    result = call_ai(item)  # 33 calls → hits rate limit\n\n# GOOD: 7 API calls for 33 items (batch size 5)\nfor batch in chunks(items, size=5):\n    results = call_ai(batch)  # 7 calls → stays within free tier\n```\n\n---\n\n## Workflow\n\n### Step 1: Understand the Goal\n\nAsk the user:\n\n1. **What to collect:** \"What data source? URL \u002F API \u002F RSS \u002F public endpoint?\"\n2. **What to extract:** \"What fields matter? Title, price, URL, date, score?\"\n3. **How to store:** \"Where should results go? Notion, Google Sheets, Supabase, or local file?\"\n4. **How to enrich:** \"Do you want AI to score, summarise, classify, or match each item?\"\n5. **Frequency:** \"How often should it run? Every hour, daily, weekly?\"\n\nCommon examples to prompt:\n- Job boards → score relevance to resume\n- Product prices → alert on drops\n- GitHub repos → summarise new releases\n- News feeds → classify by topic + sentiment\n- Sports results → extract stats to tracker\n- Events calendar → filter by interest\n\n---\n\n### Step 2: Design the Agent Architecture\n\nGenerate this directory structure for the user:\n\n```\nmy-agent\u002F\n├── config.yaml              # User customises this (keywords, filters, preferences)\n├── profile\u002F\n│   └── context.md           # User context the AI uses (resume, interests, criteria)\n├── scraper\u002F\n│   ├── __init__.py\n│   ├── main.py              # Orchestrator: scrape → enrich → store\n│   ├── filters.py           # Rule-based pre-filter (fast, before AI)\n│   └── sources\u002F\n│       ├── __init__.py\n│       └── source_name.py   # One file per data source\n├── ai\u002F\n│   ├── __init__.py\n│   ├── client.py            # Gemini REST client with model fallback\n│   ├── pipeline.py          # Batch AI analysis\n│   ├── jd_fetcher.py        # Fetch full content from URLs (optional)\n│   └── memory.py            # Learn from user feedback\n├── storage\u002F\n│   ├── __init__.py\n│   └── notion_sync.py       # Or sheets_sync.py \u002F supabase_sync.py\n├── data\u002F\n│   └── feedback.json        # User decision history (auto-updated)\n├── .env.example\n├── setup.py                 # One-time DB\u002Fschema creation\n├── enrich_existing.py       # Backfill AI scores on old rows\n├── requirements.txt\n└── .github\u002F\n    └── workflows\u002F\n        └── scraper.yml      # GitHub Actions schedule\n```\n\n---\n\n### Step 3: Build the Scraper Source\n\nTemplate for any data source:\n\n```python\n# scraper\u002Fsources\u002Fmy_source.py\n\"\"\"\n[Source Name] — scrapes [what] from [where].\nMethod: [REST API \u002F HTML scraping \u002F RSS feed]\n\"\"\"\nimport requests\nfrom bs4 import BeautifulSoup\nfrom datetime import datetime, timezone\nfrom scraper.filters import is_relevant\n\nHEADERS = {\n    \"User-Agent\": \"Mozilla\u002F5.0 (compatible; research-bot\u002F1.0)\",\n}\n\n\ndef fetch() -> list[dict]:\n    \"\"\"\n    Returns a list of items with consistent schema.\n    Each item must have at minimum: name, url, date_found.\n    \"\"\"\n    results = []\n\n    # ---- REST API source ----\n    resp = requests.get(\"https:\u002F\u002Fapi.example.com\u002Fitems\", headers=HEADERS, timeout=15)\n    if resp.status_code == 200:\n        for item in resp.json().get(\"results\", []):\n            if not is_relevant(item.get(\"title\", \"\")):\n                continue\n            results.append(_normalise(item))\n\n    return results\n\n\ndef _normalise(raw: dict) -> dict:\n    \"\"\"Convert raw API\u002FHTML data to the standard schema.\"\"\"\n    return {\n        \"name\": raw.get(\"title\", \"\"),\n        \"url\": raw.get(\"link\", \"\"),\n        \"source\": \"MySource\",\n        \"date_found\": datetime.now(timezone.utc).date().isoformat(),\n        # add domain-specific fields here\n    }\n```\n\n**HTML scraping pattern:**\n```python\nsoup = BeautifulSoup(resp.text, \"lxml\")\nfor card in soup.select(\"[class*='listing']\"):\n    title = card.select_one(\"h2, h3\").get_text(strip=True)\n    link = card.select_one(\"a\")[\"href\"]\n    if not link.startswith(\"http\"):\n        link = f\"https:\u002F\u002Fexample.com{link}\"\n```\n\n**RSS feed pattern:**\n```python\nimport xml.etree.ElementTree as ET\nroot = ET.fromstring(resp.text)\nfor item in root.findall(\".\u002F\u002Fitem\"):\n    title = item.findtext(\"title\", \"\")\n    link = item.findtext(\"link\", \"\")\n```\n\n---\n\n### Step 4: Build the Gemini AI Client\n\n```python\n# ai\u002Fclient.py\nimport os, json, time, requests\n\n_last_call = 0.0\n\nMODEL_FALLBACK = [\n    \"gemini-2.0-flash-lite\",\n    \"gemini-2.0-flash\",\n    \"gemini-2.5-flash\",\n    \"gemini-flash-lite-latest\",\n]\n\n\ndef generate(prompt: str, model: str = \"\", rate_limit: float = 7.0) -> dict:\n    \"\"\"Call Gemini with auto-fallback on 429. Returns parsed JSON or {}.\"\"\"\n    global _last_call\n\n    api_key = os.environ.get(\"GEMINI_API_KEY\", \"\")\n    if not api_key:\n        return {}\n\n    elapsed = time.time() - _last_call\n    if elapsed \u003C rate_limit:\n        time.sleep(rate_limit - elapsed)\n\n    models = [model] + [m for m in MODEL_FALLBACK if m != model] if model else MODEL_FALLBACK\n    _last_call = time.time()\n\n    for m in models:\n        url = f\"https:\u002F\u002Fgenerativelanguage.googleapis.com\u002Fv1beta\u002Fmodels\u002F{m}:generateContent?key={api_key}\"\n        payload = {\n            \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n            \"generationConfig\": {\n                \"responseMimeType\": \"application\u002Fjson\",\n                \"temperature\": 0.3,\n                \"maxOutputTokens\": 2048,\n            },\n        }\n        try:\n            resp = requests.post(url, json=payload, timeout=30)\n            if resp.status_code == 200:\n                return _parse(resp)\n            if resp.status_code in (429, 404):\n                time.sleep(1)\n                continue\n            return {}\n        except requests.RequestException:\n            return {}\n\n    return {}\n\n\ndef _parse(resp) -> dict:\n    try:\n        text = (\n            resp.json()\n            .get(\"candidates\", [{}])[0]\n            .get(\"content\", {})\n            .get(\"parts\", [{}])[0]\n            .get(\"text\", \"\")\n            .strip()\n        )\n        if text.startswith(\"```\"):\n            text = text.split(\"\\n\", 1)[-1].rsplit(\"```\", 1)[0]\n        return json.loads(text)\n    except (json.JSONDecodeError, KeyError):\n        return {}\n```\n\n---\n\n### Step 5: Build the AI Pipeline (Batch)\n\n```python\n# ai\u002Fpipeline.py\nimport json\nimport yaml\nfrom pathlib import Path\nfrom ai.client import generate\n\ndef analyse_batch(items: list[dict], context: str = \"\", preference_prompt: str = \"\") -> list[dict]:\n    \"\"\"Analyse items in batches. Returns items enriched with AI fields.\"\"\"\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    model = config.get(\"ai\", {}).get(\"model\", \"gemini-2.5-flash\")\n    rate_limit = config.get(\"ai\", {}).get(\"rate_limit_seconds\", 7.0)\n    min_score = config.get(\"ai\", {}).get(\"min_score\", 0)\n    batch_size = config.get(\"ai\", {}).get(\"batch_size\", 5)\n\n    batches = [items[i:i + batch_size] for i in range(0, len(items), batch_size)]\n    print(f\"  [AI] {len(items)} items → {len(batches)} API calls\")\n\n    enriched = []\n    for i, batch in enumerate(batches):\n        print(f\"  [AI] Batch {i + 1}\u002F{len(batches)}...\")\n        prompt = _build_prompt(batch, context, preference_prompt, config)\n        result = generate(prompt, model=model, rate_limit=rate_limit)\n\n        analyses = result.get(\"analyses\", [])\n        for j, item in enumerate(batch):\n            ai = analyses[j] if j \u003C len(analyses) else {}\n            if ai:\n                score = max(0, min(100, int(ai.get(\"score\", 0))))\n                if min_score and score \u003C min_score:\n                    continue\n                enriched.append({**item, \"ai_score\": score, \"ai_summary\": ai.get(\"summary\", \"\"), \"ai_notes\": ai.get(\"notes\", \"\")})\n            else:\n                enriched.append(item)\n\n    return enriched\n\n\ndef _build_prompt(batch, context, preference_prompt, config):\n    priorities = config.get(\"priorities\", [])\n    items_text = \"\\n\\n\".join(\n        f\"Item {i+1}: {json.dumps({k: v for k, v in item.items() if not k.startswith('_')})}\"\n        for i, item in enumerate(batch)\n    )\n\n    return f\"\"\"Analyse these {len(batch)} items and return a JSON object.\n\n# Items\n{items_text}\n\n# User Context\n{context[:800] if context else \"Not provided\"}\n\n# User Priorities\n{chr(10).join(f\"- {p}\" for p in priorities)}\n\n{preference_prompt}\n\n# Instructions\nReturn: {{\"analyses\": [{{\"score\": \u003C0-100>, \"summary\": \"\u003C2 sentences>\", \"notes\": \"\u003Cwhy this matches or doesn't>\"}} for each item in order]}}\nBe concise. Score 90+=excellent match, 70-89=good, 50-69=ok, \u003C50=weak.\"\"\"\n```\n\n---\n\n### Step 6: Build the Feedback Learning System\n\n```python\n# ai\u002Fmemory.py\n\"\"\"Learn from user decisions to improve future scoring.\"\"\"\nimport json\nfrom pathlib import Path\n\nFEEDBACK_PATH = Path(__file__).parent.parent \u002F \"data\" \u002F \"feedback.json\"\n\n\ndef load_feedback() -> dict:\n    if FEEDBACK_PATH.exists():\n        try:\n            return json.loads(FEEDBACK_PATH.read_text())\n        except (json.JSONDecodeError, OSError):\n            pass\n    return {\"positive\": [], \"negative\": []}\n\n\ndef save_feedback(fb: dict):\n    FEEDBACK_PATH.parent.mkdir(parents=True, exist_ok=True)\n    FEEDBACK_PATH.write_text(json.dumps(fb, indent=2))\n\n\ndef build_preference_prompt(feedback: dict, max_examples: int = 15) -> str:\n    \"\"\"Convert feedback history into a prompt bias section.\"\"\"\n    lines = []\n    if feedback.get(\"positive\"):\n        lines.append(\"# Items the user LIKED (positive signal):\")\n        for e in feedback[\"positive\"][-max_examples:]:\n            lines.append(f\"- {e}\")\n    if feedback.get(\"negative\"):\n        lines.append(\"\\n# Items the user SKIPPED\u002FREJECTED (negative signal):\")\n        for e in feedback[\"negative\"][-max_examples:]:\n            lines.append(f\"- {e}\")\n    if lines:\n        lines.append(\"\\nUse these patterns to bias scoring on new items.\")\n    return \"\\n\".join(lines)\n```\n\n**Integration with your storage layer:** after each run, query your DB for items with positive\u002Fnegative status and call `save_feedback()` with the extracted patterns.\n\n---\n\n### Step 7: Build Storage (Notion example)\n\n```python\n# storage\u002Fnotion_sync.py\nimport os\nfrom notion_client import Client\nfrom notion_client.errors import APIResponseError\n\n_client = None\n\ndef get_client():\n    global _client\n    if _client is None:\n        _client = Client(auth=os.environ[\"NOTION_TOKEN\"])\n    return _client\n\ndef get_existing_urls(db_id: str) -> set[str]:\n    \"\"\"Fetch all URLs already stored — used for deduplication.\"\"\"\n    client, seen, cursor = get_client(), set(), None\n    while True:\n        resp = client.databases.query(database_id=db_id, page_size=100, **{\"start_cursor\": cursor} if cursor else {})\n        for page in resp[\"results\"]:\n            url = page[\"properties\"].get(\"URL\", {}).get(\"url\", \"\")\n            if url: seen.add(url)\n        if not resp[\"has_more\"]: break\n        cursor = resp[\"next_cursor\"]\n    return seen\n\ndef push_item(db_id: str, item: dict) -> bool:\n    \"\"\"Push one item to Notion. Returns True on success.\"\"\"\n    props = {\n        \"Name\": {\"title\": [{\"text\": {\"content\": item.get(\"name\", \"\")[:100]}}]},\n        \"URL\": {\"url\": item.get(\"url\")},\n        \"Source\": {\"select\": {\"name\": item.get(\"source\", \"Unknown\")}},\n        \"Date Found\": {\"date\": {\"start\": item.get(\"date_found\")}},\n        \"Status\": {\"select\": {\"name\": \"New\"}},\n    }\n    # AI fields\n    if item.get(\"ai_score\") is not None:\n        props[\"AI Score\"] = {\"number\": item[\"ai_score\"]}\n    if item.get(\"ai_summary\"):\n        props[\"Summary\"] = {\"rich_text\": [{\"text\": {\"content\": item[\"ai_summary\"][:2000]}}]}\n    if item.get(\"ai_notes\"):\n        props[\"Notes\"] = {\"rich_text\": [{\"text\": {\"content\": item[\"ai_notes\"][:2000]}}]}\n\n    try:\n        get_client().pages.create(parent={\"database_id\": db_id}, properties=props)\n        return True\n    except APIResponseError as e:\n        print(f\"[notion] Push failed: {e}\")\n        return False\n\ndef sync(db_id: str, items: list[dict]) -> tuple[int, int]:\n    existing = get_existing_urls(db_id)\n    added = skipped = 0\n    for item in items:\n        if item.get(\"url\") in existing:\n            skipped += 1; continue\n        if push_item(db_id, item):\n            added += 1; existing.add(item[\"url\"])\n        else:\n            skipped += 1\n    return added, skipped\n```\n\n---\n\n### Step 8: Orchestrate in main.py\n\n```python\n# scraper\u002Fmain.py\nimport os, sys, yaml\nfrom pathlib import Path\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom scraper.sources import my_source          # add your sources\n\n# NOTE: This example uses Notion. If storage.provider is \"sheets\" or \"supabase\",\n# replace this import with storage.sheets_sync or storage.supabase_sync and update\n# the env var and sync() call accordingly.\nfrom storage.notion_sync import sync\n\nSOURCES = [\n    (\"My Source\", my_source.fetch),\n]\n\ndef ai_enabled():\n    return bool(os.environ.get(\"GEMINI_API_KEY\"))\n\ndef main():\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    provider = config.get(\"storage\", {}).get(\"provider\", \"notion\")\n\n    # Resolve the storage target identifier from env based on provider\n    if provider == \"notion\":\n        db_id = os.environ.get(\"NOTION_DATABASE_ID\")\n        if not db_id:\n            print(\"ERROR: NOTION_DATABASE_ID not set\"); sys.exit(1)\n    else:\n        # Extend here for sheets (SHEET_ID) or supabase (SUPABASE_TABLE) etc.\n        print(f\"ERROR: provider '{provider}' not yet wired in main.py\"); sys.exit(1)\n\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    all_items = []\n\n    for name, fetch_fn in SOURCES:\n        try:\n            items = fetch_fn()\n            print(f\"[{name}] {len(items)} items\")\n            all_items.extend(items)\n        except Exception as e:\n            print(f\"[{name}] FAILED: {e}\")\n\n    # Deduplicate by URL\n    seen, deduped = set(), []\n    for item in all_items:\n        if (url := item.get(\"url\", \"\")) and url not in seen:\n            seen.add(url); deduped.append(item)\n\n    print(f\"Unique items: {len(deduped)}\")\n\n    if ai_enabled() and deduped:\n        from ai.memory import load_feedback, build_preference_prompt\n        from ai.pipeline import analyse_batch\n\n        # load_feedback() reads data\u002Ffeedback.json written by your feedback sync script.\n        # To keep it current, implement a separate feedback_sync.py that queries your\n        # storage provider for items with positive\u002Fnegative statuses and calls save_feedback().\n        feedback = load_feedback()\n        preference = build_preference_prompt(feedback)\n        context_path = Path(__file__).parent.parent \u002F \"profile\" \u002F \"context.md\"\n        context = context_path.read_text() if context_path.exists() else \"\"\n        deduped = analyse_batch(deduped, context=context, preference_prompt=preference)\n    else:\n        print(\"[AI] Skipped — GEMINI_API_KEY not set\")\n\n    added, skipped = sync(db_id, deduped)\n    print(f\"Done — {added} new, {skipped} existing\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\n---\n\n### Step 9: GitHub Actions Workflow\n\n```yaml\n# .github\u002Fworkflows\u002Fscraper.yml\nname: Data Scraper Agent\n\non:\n  schedule:\n    - cron: \"0 *\u002F3 * * *\"  # every 3 hours — adjust to your needs\n  workflow_dispatch:        # allow manual trigger\n\npermissions:\n  contents: write   # required for the feedback-history commit step\n\njobs:\n  scrape:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.11\"\n          cache: \"pip\"\n\n      - run: pip install -r requirements.txt\n\n      # Uncomment if Playwright is enabled in requirements.txt\n      # - name: Install Playwright browsers\n      #   run: python -m playwright install chromium --with-deps\n\n      - name: Run agent\n        env:\n          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}\n          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}\n          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n        run: python -m scraper.main\n\n      - name: Commit feedback history\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add data\u002Ffeedback.json || true\n          git diff --cached --quiet || git commit -m \"chore: update feedback history\"\n          git push\n```\n\n---\n\n### Step 10: config.yaml Template\n\n```yaml\n# Customise this file — no code changes needed\n\n# What to collect (pre-filter before AI)\nfilters:\n  required_keywords: []      # item must contain at least one\n  blocked_keywords: []       # item must not contain any\n\n# Your priorities — AI uses these for scoring\npriorities:\n  - \"example priority 1\"\n  - \"example priority 2\"\n\n# Storage\nstorage:\n  provider: \"notion\"         # notion | sheets | supabase | sqlite\n\n# Feedback learning\nfeedback:\n  positive_statuses: [\"Saved\", \"Applied\", \"Interested\"]\n  negative_statuses: [\"Skip\", \"Rejected\", \"Not relevant\"]\n\n# AI settings\nai:\n  enabled: true\n  model: \"gemini-2.5-flash\"\n  min_score: 0               # filter out items below this score\n  rate_limit_seconds: 7      # seconds between API calls\n  batch_size: 5              # items per API call\n```\n\n---\n\n## Common Scraping Patterns\n\n### Pattern 1: REST API (easiest)\n```python\nresp = requests.get(url, params={\"q\": query}, headers=HEADERS, timeout=15)\nitems = resp.json().get(\"results\", [])\n```\n\n### Pattern 2: HTML Scraping\n```python\nsoup = BeautifulSoup(resp.text, \"lxml\")\nfor card in soup.select(\".listing-card\"):\n    title = card.select_one(\"h2\").get_text(strip=True)\n    href = card.select_one(\"a\")[\"href\"]\n```\n\n### Pattern 3: RSS Feed\n```python\nimport xml.etree.ElementTree as ET\nroot = ET.fromstring(resp.text)\nfor item in root.findall(\".\u002F\u002Fitem\"):\n    title = item.findtext(\"title\", \"\")\n    link = item.findtext(\"link\", \"\")\n    pub_date = item.findtext(\"pubDate\", \"\")\n```\n\n### Pattern 4: Paginated API\n```python\npage = 1\nwhile True:\n    resp = requests.get(url, params={\"page\": page, \"limit\": 50}, timeout=15)\n    data = resp.json()\n    items = data.get(\"results\", [])\n    if not items:\n        break\n    for item in items:\n        results.append(_normalise(item))\n    if not data.get(\"has_more\"):\n        break\n    page += 1\n```\n\n### Pattern 5: JS-Rendered Pages (Playwright)\n```python\nfrom playwright.sync_api import sync_playwright\n\nwith sync_playwright() as p:\n    browser = p.chromium.launch()\n    page = browser.new_page()\n    page.goto(url)\n    page.wait_for_selector(\".listing\")\n    html = page.content()\n    browser.close()\n\nsoup = BeautifulSoup(html, \"lxml\")\n```\n\n---\n\n## Anti-Patterns to Avoid\n\n| Anti-pattern | Problem | Fix |\n|---|---|---|\n| One LLM call per item | Hits rate limits instantly | Batch 5 items per call |\n| Hardcoded keywords in code | Not reusable | Move all config to `config.yaml` |\n| Scraping without rate limit | IP ban | Add `time.sleep(1)` between requests |\n| Storing secrets in code | Security risk | Always use `.env` + GitHub Secrets |\n| No deduplication | Duplicate rows pile up | Always check URL before pushing |\n| Ignoring `robots.txt` | Legal\u002Fethical risk | Respect crawl rules; use public APIs when available |\n| JS-rendered sites with `requests` | Empty response | Use Playwright or look for the underlying API |\n| `maxOutputTokens` too low | Truncated JSON, parse error | Use 2048+ for batch responses |\n\n---\n\n## Free Tier Limits Reference\n\n| Service | Free Limit | Typical Usage |\n|---|---|---|\n| Gemini Flash Lite | 30 RPM, 1500 RPD | ~56 req\u002Fday at 3-hr intervals |\n| Gemini 2.0 Flash | 15 RPM, 1500 RPD | Good fallback |\n| Gemini 2.5 Flash | 10 RPM, 500 RPD | Use sparingly |\n| GitHub Actions | Unlimited (public repos) | ~20 min\u002Fday |\n| Notion API | Unlimited | ~200 writes\u002Fday |\n| Supabase | 500MB DB, 2GB transfer | Fine for most agents |\n| Google Sheets API | 300 req\u002Fmin | Works for small agents |\n\n---\n\n## Requirements Template\n\n```\nrequests==2.31.0\nbeautifulsoup4==4.12.3\nlxml==5.1.0\npython-dotenv==1.0.1\npyyaml==6.0.2\nnotion-client==2.2.1   # if using Notion\n# playwright==1.40.0   # uncomment for JS-rendered sites\n```\n\n---\n\n## Quality Checklist\n\nBefore marking the agent complete:\n\n- [ ] `config.yaml` controls all user-facing settings — no hardcoded values\n- [ ] `profile\u002Fcontext.md` holds user-specific context for AI matching\n- [ ] Deduplication by URL before every storage push\n- [ ] Gemini client has model fallback chain (4 models)\n- [ ] Batch size ≤ 5 items per API call\n- [ ] `maxOutputTokens` ≥ 2048\n- [ ] `.env` is in `.gitignore`\n- [ ] `.env.example` provided for onboarding\n- [ ] `setup.py` creates DB schema on first run\n- [ ] `enrich_existing.py` backfills AI scores on old rows\n- [ ] GitHub Actions workflow commits `feedback.json` after each run\n- [ ] README covers: setup in \u003C 5 minutes, required secrets, customisation\n\n---\n\n## Real-World Examples\n\n```\n\"Build me an agent that monitors Hacker News for AI startup funding news\"\n\"Scrape product prices from 3 e-commerce sites and alert when they drop\"\n\"Track new GitHub repos tagged with 'llm' or 'agents' — summarise each one\"\n\"Collect Chief of Staff job listings from LinkedIn and Cutshort into Notion\"\n\"Monitor a subreddit for posts mentioning my company — classify sentiment\"\n\"Scrape new academic papers from arXiv on a topic I care about daily\"\n\"Track sports fixture results and keep a running table in Google Sheets\"\n\"Build a real estate listing watcher — alert on new properties under ₹1 Cr\"\n```\n\n---\n\n## Reference Implementation\n\nA complete working agent built with this exact architecture would scrape 4+ sources,\nbatch Gemini calls, learn from Applied\u002FRejected decisions stored in Notion, and run\n100% free on GitHub Actions. Follow Steps 1–9 above to build your own.\n","# Data Scraper Agent\n\nBuild a production-ready, AI-powered data collection agent for any public data source.\nRuns on a schedule, enriches results with a free LLM, stores to a database, and improves over time.\n\n**Stack: Python · Gemini Flash (free) · GitHub Actions (free) · Notion \u002F Sheets \u002F Supabase**\n\n## When to Activate\n\n- User wants to scrape or monitor any public website or API\n- User says \"build a bot that checks...\", \"monitor X for me\", \"collect data from...\"\n- User wants to track jobs, prices, news, repos, sports scores, events, listings\n- User asks how to automate data collection without paying for hosting\n- User wants an agent that gets smarter over time based on their decisions\n\n## Core Concepts\n\n### The Three Layers\n\nEvery data scraper agent has three layers:\n\n```\nCOLLECT → ENRICH → STORE\n  │           │        │\nScraper    AI (LLM)  Database\nruns on    scores\u002F   Notion \u002F\nschedule   summarises Sheets \u002F\n           & classifies Supabase\n```\n\n### Free Stack\n\n| Layer | Tool | Why |\n|---|---|---|\n| **Scraping** | `requests` + `BeautifulSoup` | No cost, covers 80% of public sites |\n| **JS-rendered sites** | `playwright` (free) | When HTML scraping fails |\n| **AI enrichment** | Gemini Flash via REST API | 500 req\u002Fday, 1M tokens\u002Fday — free |\n| **Storage** | Notion API | Free tier, great UI for review |\n| **Schedule** | GitHub Actions cron | Free for public repos |\n| **Learning** | JSON feedback file in repo | Zero infra, persists in git |\n\n### AI Model Fallback Chain\n\nBuild agents to auto-fallback across Gemini models on quota exhaustion:\n\n```\ngemini-2.0-flash-lite (30 RPM) →\ngemini-2.0-flash (15 RPM) →\ngemini-2.5-flash (10 RPM) →\ngemini-flash-lite-latest (fallback)\n```\n\n### Batch API Calls for Efficiency\n\nNever call the LLM once per item. Always batch:\n\n```python\n# BAD: 33 API calls for 33 items\nfor item in items:\n    result = call_ai(item)  # 33 calls → hits rate limit\n\n# GOOD: 7 API calls for 33 items (batch size 5)\nfor batch in chunks(items, size=5):\n    results = call_ai(batch)  # 7 calls → stays within free tier\n```\n\n---\n\n## Workflow\n\n### Step 1: Understand the Goal\n\nAsk the user:\n\n1. **What to collect:** \"What data source? URL \u002F API \u002F RSS \u002F public endpoint?\"\n2. **What to extract:** \"What fields matter? Title, price, URL, date, score?\"\n3. **How to store:** \"Where should results go? Notion, Google Sheets, Supabase, or local file?\"\n4. **How to enrich:** \"Do you want AI to score, summarise, classify, or match each item?\"\n5. **Frequency:** \"How often should it run? Every hour, daily, weekly?\"\n\nCommon examples to prompt:\n- Job boards → score relevance to resume\n- Product prices → alert on drops\n- GitHub repos → summarise new releases\n- News feeds → classify by topic + sentiment\n- Sports results → extract stats to tracker\n- Events calendar → filter by interest\n\n---\n\n### Step 2: Design the Agent Architecture\n\nGenerate this directory structure for the user:\n\n```\nmy-agent\u002F\n├── config.yaml              # User customises this (keywords, filters, preferences)\n├── profile\u002F\n│   └── context.md           # User context the AI uses (resume, interests, criteria)\n├── scraper\u002F\n│   ├── __init__.py\n│   ├── main.py              # Orchestrator: scrape → enrich → store\n│   ├── filters.py           # Rule-based pre-filter (fast, before AI)\n│   └── sources\u002F\n│       ├── __init__.py\n│       └── source_name.py   # One file per data source\n├── ai\u002F\n│   ├── __init__.py\n│   ├── client.py            # Gemini REST client with model fallback\n│   ├── pipeline.py          # Batch AI analysis\n│   ├── jd_fetcher.py        # Fetch full content from URLs (optional)\n│   └── memory.py            # Learn from user feedback\n├── storage\u002F\n│   ├── __init__.py\n│   └── notion_sync.py       # Or sheets_sync.py \u002F supabase_sync.py\n├── data\u002F\n│   └── feedback.json        # User decision history (auto-updated)\n├── .env.example\n├── setup.py                 # One-time DB\u002Fschema creation\n├── enrich_existing.py       # Backfill AI scores on old rows\n├── requirements.txt\n└── .github\u002F\n    └── workflows\u002F\n        └── scraper.yml      # GitHub Actions schedule\n```\n\n---\n\n### Step 3: Build the Scraper Source\n\nTemplate for any data source:\n\n```python\n# scraper\u002Fsources\u002Fmy_source.py\n\"\"\"\n[Source Name] — scrapes [what] from [where].\nMethod: [REST API \u002F HTML scraping \u002F RSS feed]\n\"\"\"\nimport requests\nfrom bs4 import BeautifulSoup\nfrom datetime import datetime, timezone\nfrom scraper.filters import is_relevant\n\nHEADERS = {\n    \"User-Agent\": \"Mozilla\u002F5.0 (compatible; research-bot\u002F1.0)\",\n}\n\n\ndef fetch() -> list[dict]:\n    \"\"\"\n    Returns a list of items with consistent schema.\n    Each item must have at minimum: name, url, date_found.\n    \"\"\"\n    results = []\n\n    # ---- REST API source ----\n    resp = requests.get(\"https:\u002F\u002Fapi.example.com\u002Fitems\", headers=HEADERS, timeout=15)\n    if resp.status_code == 200:\n        for item in resp.json().get(\"results\", []):\n            if not is_relevant(item.get(\"title\", \"\")):\n                continue\n            results.append(_normalise(item))\n\n    return results\n\n\ndef _normalise(raw: dict) -> dict:\n    \"\"\"Convert raw API\u002FHTML data to the standard schema.\"\"\"\n    return {\n        \"name\": raw.get(\"title\", \"\"),\n        \"url\": raw.get(\"link\", \"\"),\n        \"source\": \"MySource\",\n        \"date_found\": datetime.now(timezone.utc).date().isoformat(),\n        # add domain-specific fields here\n    }\n```\n\n**HTML scraping pattern:**\n```python\nsoup = BeautifulSoup(resp.text, \"lxml\")\nfor card in soup.select(\"[class*='listing']\"):\n    title = card.select_one(\"h2, h3\").get_text(strip=True)\n    link = card.select_one(\"a\")[\"href\"]\n    if not link.startswith(\"http\"):\n        link = f\"https:\u002F\u002Fexample.com{link}\"\n```\n\n**RSS feed pattern:**\n```python\nimport xml.etree.ElementTree as ET\nroot = ET.fromstring(resp.text)\nfor item in root.findall(\".\u002F\u002Fitem\"):\n    title = item.findtext(\"title\", \"\")\n    link = item.findtext(\"link\", \"\")\n```\n\n---\n\n### Step 4: Build the Gemini AI Client\n\n```python\n# ai\u002Fclient.py\nimport os, json, time, requests\n\n_last_call = 0.0\n\nMODEL_FALLBACK = [\n    \"gemini-2.0-flash-lite\",\n    \"gemini-2.0-flash\",\n    \"gemini-2.5-flash\",\n    \"gemini-flash-lite-latest\",\n]\n\n\ndef generate(prompt: str, model: str = \"\", rate_limit: float = 7.0) -> dict:\n    \"\"\"Call Gemini with auto-fallback on 429. Returns parsed JSON or {}.\"\"\"\n    global _last_call\n\n    api_key = os.environ.get(\"GEMINI_API_KEY\", \"\")\n    if not api_key:\n        return {}\n\n    elapsed = time.time() - _last_call\n    if elapsed \u003C rate_limit:\n        time.sleep(rate_limit - elapsed)\n\n    models = [model] + [m for m in MODEL_FALLBACK if m != model] if model else MODEL_FALLBACK\n    _last_call = time.time()\n\n    for m in models:\n        url = f\"https:\u002F\u002Fgenerativelanguage.googleapis.com\u002Fv1beta\u002Fmodels\u002F{m}:generateContent?key={api_key}\"\n        payload = {\n            \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n            \"generationConfig\": {\n                \"responseMimeType\": \"application\u002Fjson\",\n                \"temperature\": 0.3,\n                \"maxOutputTokens\": 2048,\n            },\n        }\n        try:\n            resp = requests.post(url, json=payload, timeout=30)\n            if resp.status_code == 200:\n                return _parse(resp)\n            if resp.status_code in (429, 404):\n                time.sleep(1)\n                continue\n            return {}\n        except requests.RequestException:\n            return {}\n\n    return {}\n\n\ndef _parse(resp) -> dict:\n    try:\n        text = (\n            resp.json()\n            .get(\"candidates\", [{}])[0]\n            .get(\"content\", {})\n            .get(\"parts\", [{}])[0]\n            .get(\"text\", \"\")\n            .strip()\n        )\n        if text.startswith(\"```\"):\n            text = text.split(\"\\n\", 1)[-1].rsplit(\"```\", 1)[0]\n        return json.loads(text)\n    except (json.JSONDecodeError, KeyError):\n        return {}\n```\n\n---\n\n### Step 5: Build the AI Pipeline (Batch)\n\n```python\n# ai\u002Fpipeline.py\nimport json\nimport yaml\nfrom pathlib import Path\nfrom ai.client import generate\n\ndef analyse_batch(items: list[dict], context: str = \"\", preference_prompt: str = \"\") -> list[dict]:\n    \"\"\"Analyse items in batches. Returns items enriched with AI fields.\"\"\"\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    model = config.get(\"ai\", {}).get(\"model\", \"gemini-2.5-flash\")\n    rate_limit = config.get(\"ai\", {}).get(\"rate_limit_seconds\", 7.0)\n    min_score = config.get(\"ai\", {}).get(\"min_score\", 0)\n    batch_size = config.get(\"ai\", {}).get(\"batch_size\", 5)\n\n    batches = [items[i:i + batch_size] for i in range(0, len(items), batch_size)]\n    print(f\"  [AI] {len(items)} items → {len(batches)} API calls\")\n\n    enriched = []\n    for i, batch in enumerate(batches):\n        print(f\"  [AI] Batch {i + 1}\u002F{len(batches)}...\")\n        prompt = _build_prompt(batch, context, preference_prompt, config)\n        result = generate(prompt, model=model, rate_limit=rate_limit)\n\n        analyses = result.get(\"analyses\", [])\n        for j, item in enumerate(batch):\n            ai = analyses[j] if j \u003C len(analyses) else {}\n            if ai:\n                score = max(0, min(100, int(ai.get(\"score\", 0))))\n                if min_score and score \u003C min_score:\n                    continue\n                enriched.append({**item, \"ai_score\": score, \"ai_summary\": ai.get(\"summary\", \"\"), \"ai_notes\": ai.get(\"notes\", \"\")})\n            else:\n                enriched.append(item)\n\n    return enriched\n\n\ndef _build_prompt(batch, context, preference_prompt, config):\n    priorities = config.get(\"priorities\", [])\n    items_text = \"\\n\\n\".join(\n        f\"Item {i+1}: {json.dumps({k: v for k, v in item.items() if not k.startswith('_')})}\"\n        for i, item in enumerate(batch)\n    )\n\n    return f\"\"\"Analyse these {len(batch)} items and return a JSON object.\n\n# Items\n{items_text}\n\n# User Context\n{context[:800] if context else \"Not provided\"}\n\n# User Priorities\n{chr(10).join(f\"- {p}\" for p in priorities)}\n\n{preference_prompt}\n\n# Instructions\nReturn: {{\"analyses\": [{{\"score\": \u003C0-100>, \"summary\": \"\u003C2 sentences>\", \"notes\": \"\u003Cwhy this matches or doesn't>\"}} for each item in order]}}\nBe concise. Score 90+=excellent match, 70-89=good, 50-69=ok, \u003C50=weak.\"\"\"\n```\n\n---\n\n### Step 6: Build the Feedback Learning System\n\n```python\n# ai\u002Fmemory.py\n\"\"\"Learn from user decisions to improve future scoring.\"\"\"\nimport json\nfrom pathlib import Path\n\nFEEDBACK_PATH = Path(__file__).parent.parent \u002F \"data\" \u002F \"feedback.json\"\n\n\ndef load_feedback() -> dict:\n    if FEEDBACK_PATH.exists():\n        try:\n            return json.loads(FEEDBACK_PATH.read_text())\n        except (json.JSONDecodeError, OSError):\n            pass\n    return {\"positive\": [], \"negative\": []}\n\n\ndef save_feedback(fb: dict):\n    FEEDBACK_PATH.parent.mkdir(parents=True, exist_ok=True)\n    FEEDBACK_PATH.write_text(json.dumps(fb, indent=2))\n\n\ndef build_preference_prompt(feedback: dict, max_examples: int = 15) -> str:\n    \"\"\"Convert feedback history into a prompt bias section.\"\"\"\n    lines = []\n    if feedback.get(\"positive\"):\n        lines.append(\"# Items the user LIKED (positive signal):\")\n        for e in feedback[\"positive\"][-max_examples:]:\n            lines.append(f\"- {e}\")\n    if feedback.get(\"negative\"):\n        lines.append(\"\\n# Items the user SKIPPED\u002FREJECTED (negative signal):\")\n        for e in feedback[\"negative\"][-max_examples:]:\n            lines.append(f\"- {e}\")\n    if lines:\n        lines.append(\"\\nUse these patterns to bias scoring on new items.\")\n    return \"\\n\".join(lines)\n```\n\n**Integration with your storage layer:** after each run, query your DB for items with positive\u002Fnegative status and call `save_feedback()` with the extracted patterns.\n\n---\n\n### Step 7: Build Storage (Notion example)\n\n```python\n# storage\u002Fnotion_sync.py\nimport os\nfrom notion_client import Client\nfrom notion_client.errors import APIResponseError\n\n_client = None\n\ndef get_client():\n    global _client\n    if _client is None:\n        _client = Client(auth=os.environ[\"NOTION_TOKEN\"])\n    return _client\n\ndef get_existing_urls(db_id: str) -> set[str]:\n    \"\"\"Fetch all URLs already stored — used for deduplication.\"\"\"\n    client, seen, cursor = get_client(), set(), None\n    while True:\n        resp = client.databases.query(database_id=db_id, page_size=100, **{\"start_cursor\": cursor} if cursor else {})\n        for page in resp[\"results\"]:\n            url = page[\"properties\"].get(\"URL\", {}).get(\"url\", \"\")\n            if url: seen.add(url)\n        if not resp[\"has_more\"]: break\n        cursor = resp[\"next_cursor\"]\n    return seen\n\ndef push_item(db_id: str, item: dict) -> bool:\n    \"\"\"Push one item to Notion. Returns True on success.\"\"\"\n    props = {\n        \"Name\": {\"title\": [{\"text\": {\"content\": item.get(\"name\", \"\")[:100]}}]},\n        \"URL\": {\"url\": item.get(\"url\")},\n        \"Source\": {\"select\": {\"name\": item.get(\"source\", \"Unknown\")}},\n        \"Date Found\": {\"date\": {\"start\": item.get(\"date_found\")}},\n        \"Status\": {\"select\": {\"name\": \"New\"}},\n    }\n    # AI fields\n    if item.get(\"ai_score\") is not None:\n        props[\"AI Score\"] = {\"number\": item[\"ai_score\"]}\n    if item.get(\"ai_summary\"):\n        props[\"Summary\"] = {\"rich_text\": [{\"text\": {\"content\": item[\"ai_summary\"][:2000]}}]}\n    if item.get(\"ai_notes\"):\n        props[\"Notes\"] = {\"rich_text\": [{\"text\": {\"content\": item[\"ai_notes\"][:2000]}}]}\n\n    try:\n        get_client().pages.create(parent={\"database_id\": db_id}, properties=props)\n        return True\n    except APIResponseError as e:\n        print(f\"[notion] Push failed: {e}\")\n        return False\n\ndef sync(db_id: str, items: list[dict]) -> tuple[int, int]:\n    existing = get_existing_urls(db_id)\n    added = skipped = 0\n    for item in items:\n        if item.get(\"url\") in existing:\n            skipped += 1; continue\n        if push_item(db_id, item):\n            added += 1; existing.add(item[\"url\"])\n        else:\n            skipped += 1\n    return added, skipped\n```\n\n---\n\n### Step 8: Orchestrate in main.py\n\n```python\n# scraper\u002Fmain.py\nimport os, sys, yaml\nfrom pathlib import Path\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom scraper.sources import my_source          # add your sources\n\n# NOTE: This example uses Notion. If storage.provider is \"sheets\" or \"supabase\",\n# replace this import with storage.sheets_sync or storage.supabase_sync and update\n# the env var and sync() call accordingly.\nfrom storage.notion_sync import sync\n\nSOURCES = [\n    (\"My Source\", my_source.fetch),\n]\n\ndef ai_enabled():\n    return bool(os.environ.get(\"GEMINI_API_KEY\"))\n\ndef main():\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    provider = config.get(\"storage\", {}).get(\"provider\", \"notion\")\n\n    # Resolve the storage target identifier from env based on provider\n    if provider == \"notion\":\n        db_id = os.environ.get(\"NOTION_DATABASE_ID\")\n        if not db_id:\n            print(\"ERROR: NOTION_DATABASE_ID not set\"); sys.exit(1)\n    else:\n        # Extend here for sheets (SHEET_ID) or supabase (SUPABASE_TABLE) etc.\n        print(f\"ERROR: provider '{provider}' not yet wired in main.py\"); sys.exit(1)\n\n    config = yaml.safe_load((Path(__file__).parent.parent \u002F \"config.yaml\").read_text())\n    all_items = []\n\n    for name, fetch_fn in SOURCES:\n        try:\n            items = fetch_fn()\n            print(f\"[{name}] {len(items)} items\")\n            all_items.extend(items)\n        except Exception as e:\n            print(f\"[{name}] FAILED: {e}\")\n\n    # Deduplicate by URL\n    seen, deduped = set(), []\n    for item in all_items:\n        if (url := item.get(\"url\", \"\")) and url not in seen:\n            seen.add(url); deduped.append(item)\n\n    print(f\"Unique items: {len(deduped)}\")\n\n    if ai_enabled() and deduped:\n        from ai.memory import load_feedback, build_preference_prompt\n        from ai.pipeline import analyse_batch\n\n        # load_feedback() reads data\u002Ffeedback.json written by your feedback sync script.\n        # To keep it current, implement a separate feedback_sync.py that queries your\n        # storage provider for items with positive\u002Fnegative statuses and calls save_feedback().\n        feedback = load_feedback()\n        preference = build_preference_prompt(feedback)\n        context_path = Path(__file__).parent.parent \u002F \"profile\" \u002F \"context.md\"\n        context = context_path.read_text() if context_path.exists() else \"\"\n        deduped = analyse_batch(deduped, context=context, preference_prompt=preference)\n    else:\n        print(\"[AI] Skipped — GEMINI_API_KEY not set\")\n\n    added, skipped = sync(db_id, deduped)\n    print(f\"Done — {added} new, {skipped} existing\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\n---\n\n### Step 9: GitHub Actions Workflow\n\n```yaml\n# .github\u002Fworkflows\u002Fscraper.yml\nname: Data Scraper Agent\n\non:\n  schedule:\n    - cron: \"0 *\u002F3 * * *\"  # every 3 hours — adjust to your needs\n  workflow_dispatch:        # allow manual trigger\n\npermissions:\n  contents: write   # required for the feedback-history commit step\n\njobs:\n  scrape:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.11\"\n          cache: \"pip\"\n\n      - run: pip install -r requirements.txt\n\n      # Uncomment if Playwright is enabled in requirements.txt\n      # - name: Install Playwright browsers\n      #   run: python -m playwright install chromium --with-deps\n\n      - name: Run agent\n        env:\n          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}\n          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}\n          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n        run: python -m scraper.main\n\n      - name: Commit feedback history\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add data\u002Ffeedback.json || true\n          git diff --cached --quiet || git commit -m \"chore: update feedback history\"\n          git push\n```\n\n---\n\n### Step 10: config.yaml Template\n\n```yaml\n# Customise this file — no code changes needed\n\n# What to collect (pre-filter before AI)\nfilters:\n  required_keywords: []      # item must contain at least one\n  blocked_keywords: []       # item must not contain any\n\n# Your priorities — AI uses these for scoring\npriorities:\n  - \"example priority 1\"\n  - \"example priority 2\"\n\n# Storage\nstorage:\n  provider: \"notion\"         # notion | sheets | supabase | sqlite\n\n# Feedback learning\nfeedback:\n  positive_statuses: [\"Saved\", \"Applied\", \"Interested\"]\n  negative_statuses: [\"Skip\", \"Rejected\", \"Not relevant\"]\n\n# AI settings\nai:\n  enabled: true\n  model: \"gemini-2.5-flash\"\n  min_score: 0               # filter out items below this score\n  rate_limit_seconds: 7      # seconds between API calls\n  batch_size: 5              # items per API call\n```\n\n---\n\n## Common Scraping Patterns\n\n### Pattern 1: REST API (easiest)\n```python\nresp = requests.get(url, params={\"q\": query}, headers=HEADERS, timeout=15)\nitems = resp.json().get(\"results\", [])\n```\n\n### Pattern 2: HTML Scraping\n```python\nsoup = BeautifulSoup(resp.text, \"lxml\")\nfor card in soup.select(\".listing-card\"):\n    title = card.select_one(\"h2\").get_text(strip=True)\n    href = card.select_one(\"a\")[\"href\"]\n```\n\n### Pattern 3: RSS Feed\n```python\nimport xml.etree.ElementTree as ET\nroot = ET.fromstring(resp.text)\nfor item in root.findall(\".\u002F\u002Fitem\"):\n    title = item.findtext(\"title\", \"\")\n    link = item.findtext(\"link\", \"\")\n    pub_date = item.findtext(\"pubDate\", \"\")\n```\n\n### Pattern 4: Paginated API\n```python\npage = 1\nwhile True:\n    resp = requests.get(url, params={\"page\": page, \"limit\": 50}, timeout=15)\n    data = resp.json()\n    items = data.get(\"results\", [])\n    if not items:\n        break\n    for item in items:\n        results.append(_normalise(item))\n    if not data.get(\"has_more\"):\n        break\n    page += 1\n```\n\n### Pattern 5: JS-Rendered Pages (Playwright)\n```python\nfrom playwright.sync_api import sync_playwright\n\nwith sync_playwright() as p:\n    browser = p.chromium.launch()\n    page = browser.new_page()\n    page.goto(url)\n    page.wait_for_selector(\".listing\")\n    html = page.content()\n    browser.close()\n\nsoup = BeautifulSoup(html, \"lxml\")\n```\n\n---\n\n## Anti-Patterns to Avoid\n\n| Anti-pattern | Problem | Fix |\n|---|---|---|\n| One LLM call per item | Hits rate limits instantly | Batch 5 items per call |\n| Hardcoded keywords in code | Not reusable | Move all config to `config.yaml` |\n| Scraping without rate limit | IP ban | Add `time.sleep(1)` between requests |\n| Storing secrets in code | Security risk | Always use `.env` + GitHub Secrets |\n| No deduplication | Duplicate rows pile up | Always check URL before pushing |\n| Ignoring `robots.txt` | Legal\u002Fethical risk | Respect crawl rules; use public APIs when available |\n| JS-rendered sites with `requests` | Empty response | Use Playwright or look for the underlying API |\n| `maxOutputTokens` too low | Truncated JSON, parse error | Use 2048+ for batch responses |\n\n---\n\n## Free Tier Limits Reference\n\n| Service | Free Limit | Typical Usage |\n|---|---|---|\n| Gemini Flash Lite | 30 RPM, 1500 RPD | ~56 req\u002Fday at 3-hr intervals |\n| Gemini 2.0 Flash | 15 RPM, 1500 RPD | Good fallback |\n| Gemini 2.5 Flash | 10 RPM, 500 RPD | Use sparingly |\n| GitHub Actions | Unlimited (public repos) | ~20 min\u002Fday |\n| Notion API | Unlimited | ~200 writes\u002Fday |\n| Supabase | 500MB DB, 2GB transfer | Fine for most agents |\n| Google Sheets API | 300 req\u002Fmin | Works for small agents |\n\n---\n\n## Requirements Template\n\n```\nrequests==2.31.0\nbeautifulsoup4==4.12.3\nlxml==5.1.0\npython-dotenv==1.0.1\npyyaml==6.0.2\nnotion-client==2.2.1   # if using Notion\n# playwright==1.40.0   # uncomment for JS-rendered sites\n```\n\n---\n\n## Quality Checklist\n\nBefore marking the agent complete:\n\n- [ ] `config.yaml` controls all user-facing settings — no hardcoded values\n- [ ] `profile\u002Fcontext.md` holds user-specific context for AI matching\n- [ ] Deduplication by URL before every storage push\n- [ ] Gemini client has model fallback chain (4 models)\n- [ ] Batch size ≤ 5 items per API call\n- [ ] `maxOutputTokens` ≥ 2048\n- [ ] `.env` is in `.gitignore`\n- [ ] `.env.example` provided for onboarding\n- [ ] `setup.py` creates DB schema on first run\n- [ ] `enrich_existing.py` backfills AI scores on old rows\n- [ ] GitHub Actions workflow commits `feedback.json` after each run\n- [ ] README covers: setup in \u003C 5 minutes, required secrets, customisation\n\n---\n\n## Real-World Examples\n\n```\n\"Build me an agent that monitors Hacker News for AI startup funding news\"\n\"Scrape product prices from 3 e-commerce sites and alert when they drop\"\n\"Track new GitHub repos tagged with 'llm' or 'agents' — summarise each one\"\n\"Collect Chief of Staff job listings from LinkedIn and Cutshort into Notion\"\n\"Monitor a subreddit for posts mentioning my company — classify sentiment\"\n\"Scrape new academic papers from arXiv on a topic I care about daily\"\n\"Track sports fixture results and keep a running table in Google Sheets\"\n\"Build a real estate listing watcher — alert on new properties under ₹1 Cr\"\n```\n\n---\n\n## Reference Implementation\n\nA complete working agent built with this exact architecture would scrape 4+ sources,\nbatch Gemini calls, learn from Applied\u002FRejected decisions stored in Notion, and run\n100% free on GitHub Actions. Follow Steps 1–9 above to build your own.\n",{"name":5,"origin":39,"description":7},"master","8ec37df6d71fb664ea19d32c079e7d3ff931a8e0","skills\u002Fecc\u002Fdata-scraper-agent",3,"https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit","https:\u002F\u002Fweblyfe.ai","https:\u002F\u002Fgithub.com\u002FS3YED","2026-08-27 01:54:02",{"source":8,"skillName":5,"commands":65,"prompt":75,"zipAvailable":76},[66,69,72],{"tool":67,"command":68},"npx","npx skills add S3YED\u002Fappie-kit --skill data-scraper-agent",{"tool":70,"command":71},"bunx","bunx skills add S3YED\u002Fappie-kit --skill data-scraper-agent",{"tool":73,"command":74},"pnpm","pnpm dlx skills add S3YED\u002Fappie-kit --skill data-scraper-agent","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fecc\u002Fdata-scraper-agent\u002FSKILL.md",true,[],[79,111,144,170,199,222],{"skillNo":80,"name":81,"nameZh":6,"description":82,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":83,"categories":84,"keywords":92,"publishedAt":51},"01M6JFD4M6N4DSGC7TXEJ5R3J2","ai-music","AI music generation and audio tools: HeartMuLa lyrics-to-music generation, songwriting craft (+ Suno prompts), and audio spectrogram\u002Ffeature visualization.","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fwethlete\u002Fai-music\u002FSKILL.md",[85,88,91],{"categoryNo":86,"categoryName":87,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000013","分析与可视化",{"categoryNo":89,"categoryName":90,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000016","图像与多媒体",{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},[93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"music","ai music","humanizer","visualization","Tools","Audio","hermes","hermes agent","songwriting","suno","lyrics","prompts","heartmula","craft","generation","feature","ai music generation","music generation",{"skillNo":112,"name":113,"nameZh":6,"description":114,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":115,"categories":116,"keywords":123,"publishedAt":51},"01M6JFCZE4XNFQFMNZDHKHT5V5","agentic-webdesign-design-system-ingestion","Use v0 Design Systems 2.0, Bolt Design System Agents, or Lovable to generate production UI from a client's real design system components — not generic Tailwind. Use when building a client website where brand consistency matters.","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fweb-design\u002Fagentic-webdesign-design-system-ingestion\u002FSKILL.md",[117,120],{"categoryNo":118,"categoryName":119,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000015","视觉设计",{"categoryNo":121,"categoryName":122,"taxonomyType":28,"taxonomyTypeDesc":29},"01JBSEEDCAT000000000000032","设计师",[124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143],"design","agentic","design system","system","ingestion","webdesign","agentic webdesign design system ingestion","components","generate","design systems","website","ui","Tailwind","bolt","lovable","v0","design-system","wed","design system components","appie",{"skillNo":145,"name":146,"nameZh":6,"description":147,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":148,"categories":149,"keywords":152,"publishedAt":51},"01M6JFCVPX3913CFAS6QQJ33ZB","agentic-webdesign-agent-elements","Use 21st.dev Agent Elements — 25 shadcn\u002Fui components purpose-built for AI agent UIs. Tool cards (Bash, Edit, Search, Plan, Subagent, MCP), chat, input, streaming. Open source, shadcn-registry install, no runtime lock-in.","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fweb-design\u002Fagentic-webdesign-agent-elements\u002FSKILL.md",[150,151],{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},{"categoryNo":121,"categoryName":122,"taxonomyType":28,"taxonomyTypeDesc":29},[125,153,129,154,155,156,131,157,158,159,160,161,162,163,164,165,166,167,168,169],"elements","agentic webdesign agent elements","MCP","ai agent","registry","search","streaming","open source","open","Bash","plan","subagent","edit","install","input","lock","source",{"skillNo":171,"name":172,"nameZh":6,"description":173,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":174,"categories":175,"keywords":178,"publishedAt":51},"01M6JFCQC7H65M9QJ6TRE71BS1","vercel-deployment","Deploy static sites and frontends to Vercel — handles both CLI and REST API paths, including the CLI token sanitization workaround.","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fsoftware-development\u002Fvercel-deployment\u002FSKILL.md",[176,177],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},[179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198],"deployment","vercel deployment","vercel","macos","cli","linux","windows","deploy","vercel deploy","REST","api","site","deploy to vercel","token","rest api","host","sites","paths","sanitization","static site",{"skillNo":200,"name":201,"nameZh":6,"description":202,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":203,"categories":204,"keywords":206,"publishedAt":51},"01M6JFCM4SMTXBM5A01BK2YQG1","software-quality-workflows","Umbrella workflow for TDD, debugging, code simplification, spikes, pre-commit review, and debugger-assisted investigation.","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fsoftware-development\u002Fsoftware-quality-workflows\u002FSKILL.md",[205],{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},[207,208,209,210,211,212,213,214,215,216,217,218,219,220,34,221,35],"workflows","quality","software","software quality workflows","review","tdd","workflow","commit","debugging","code","debugger","pre-commit","investigation","code simplification","simplification",{"skillNo":223,"name":224,"nameZh":6,"description":225,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":226,"categories":227,"keywords":235,"publishedAt":51},"01M6JFCGD0M6CEATY01W7YN6DB","react-component-refactoring","Safely refactor large React components into smaller, maintainable pieces using proven patterns","https:\u002F\u002Fgithub.com\u002FS3YED\u002Fappie-kit\u002Fblob\u002Fmaster\u002Fskills\u002Fsoftware-development\u002Freact-component-refactoring\u002FSKILL.md",[228,231,234],{"categoryNo":229,"categoryName":230,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000007","代码审查",{"categoryNo":232,"categoryName":233,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000009","前端开发",{"categoryNo":23,"categoryName":24,"taxonomyType":20,"taxonomyTypeDesc":21},[236,237,238,239,240,241,242,243,182,244,245,131,246,185,247,216,248,249,250,251],"react","component","refactoring","component refactoring","react component","react component refactoring","frontend","writing","safe","typescript","react components","modular","refactor","simplify","patterns","test-driven-development"]