Scrape GitHub Issue Search Results into a Triage Queue

GitHub has an API, and if you already have an authenticated integration you should use it.

But there are still good reasons to scrape GitHub issue-search pages:

  • you want to prototype without tokens
  • you want one script that works from a bookmarked search URL
  • you want a quick CSV for weekly bug triage

In this guide we will turn GitHub's issue-search UI into a lightweight triage queue.

The scraper will collect:

  • title
  • issue URL and number
  • labels
  • author
  • opened timestamp
  • comment count
  • a simple priority bucket based on labels and activity

Mandatory screenshot of the page we will parse:

GitHub issue search results for psf/requests bugs

Keep repo triage exports stable when search pages get noisy

GitHub issue search is friendly at small volume, but repeated org-wide exports can still hit rate and reliability friction. ProxiesAPI gives you a clean fetch wrapper so your parser stays unchanged.


The kind of URL we want

GitHub search pages are already expressive, so the easiest pattern is:

  • build the search in the browser
  • copy the URL
  • let the scraper follow pagination

Example:

https://github.com/search?q=is%3Aissue+label%3Abug+state%3Aopen+repo%3Apsf%2Frequests&type=issues

That means product managers, support teams, and engineers can share the search definition without touching code.


Setup

python3 -m venv .venv
source .venv/bin/activate
pip install requests beautifulsoup4 lxml pandas

We will use:

  • requests for HTTP
  • BeautifulSoup for parsing
  • pandas for CSV export

Step 1: Build a fetch helper

The parser should not care whether you go direct or through ProxiesAPI. Keep that decision in one place.

from __future__ import annotations

import os
import random
import time
from urllib.parse import quote

import requests

PROXIESAPI_KEY = os.getenv("PROXIESAPI_KEY", "")
TIMEOUT = (10, 40)

session = requests.Session()
session.headers.update({
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "en-US,en;q=0.9",
})


def proxiesapi_url(target_url: str) -> str:
    if not PROXIESAPI_KEY:
        raise RuntimeError("Set PROXIESAPI_KEY before enabling ProxiesAPI")
    return (
        "https://api.proxiesapi.com/?auth_key="
        + quote(PROXIESAPI_KEY, safe="")
        + "&url="
        + quote(target_url, safe="")
    )


def fetch(url: str, *, use_proxiesapi: bool = False, max_retries: int = 4) -> str:
    last_err = None
    for attempt in range(1, max_retries + 1):
        try:
            final_url = proxiesapi_url(url) if use_proxiesapi else url
            resp = session.get(final_url, timeout=TIMEOUT)
            resp.raise_for_status()
            html = resp.text or ""
            if "search-results-page" not in html and "ListItem-module__listItem" not in html:
                raise RuntimeError("Unexpected search HTML; login wall or layout change suspected")
            return html
        except Exception as exc:
            last_err = exc
            time.sleep((1.6 ** attempt) + random.random())
    raise RuntimeError(f"Failed to fetch {url}: {last_err}")

Step 2: Understand the current GitHub markup

GitHub's issue-search page is React-heavy, but the server-rendered HTML still contains the fields we need.

The stable hooks I found on the current page are:

  • result title links: a[data-testid="issue-pr-title-link"]
  • each result row: nearest <li role="listitem">
  • labels: span.prc-Token-IssueLabel-2IazM
  • timestamps: relative-time

That is enough to avoid brittle text scraping.


Step 3: Parse a page of issue search results

import re
from typing import Any
from urllib.parse import urljoin

from bs4 import BeautifulSoup, Tag

BASE_URL = "https://github.com"


def issue_number_from_href(href: str | None) -> int | None:
    if not href:
        return None
    match = re.search(r"/issues/(\\d+)", href)
    return int(match.group(1)) if match else None


def parse_comment_count(row: Tag) -> int:
    text = row.get_text(" ", strip=True)
    match = re.search(r"\\b(\\d+)\\b\\s*$", text)
    return int(match.group(1)) if match else 0


def parse_results_page(html: str) -> tuple[list[dict[str, Any]], str | None]:
    soup = BeautifulSoup(html, "lxml")
    rows: list[dict[str, Any]] = []

    for title_link in soup.select('a[data-testid="issue-pr-title-link"]'):
        card = title_link.find_parent("li")
        if not card:
            continue

        href = title_link.get("href")
        url = urljoin(BASE_URL, href) if href else None

        author_link = card.select_one('a[data-testid="github-avatar"]')
        author = author_link.get("aria-label") if author_link else None
        if author and author.startswith("@"):
            author = author[1:]

        labels = [
            span.get_text(" ", strip=True)
            for span in card.select("span.prc-Token-IssueLabel-2IazM")
        ]

        opened = None
        time_tag = card.select_one("relative-time")
        if time_tag and time_tag.get("datetime"):
            opened = time_tag["datetime"]

        rows.append({
            "number": issue_number_from_href(href),
            "title": title_link.get_text(" ", strip=True),
            "url": url,
            "labels": labels,
            "author": author,
            "opened_at": opened,
            "comments": parse_comment_count(card),
        })

    next_link = soup.select_one('a[rel="next"]')
    next_url = urljoin(BASE_URL, next_link.get("href")) if next_link and next_link.get("href") else None
    return rows, next_url

Why parse from the search page instead of issue detail pages?

Because triage usually starts with breadth, not depth.

You want to answer:

  • which bugs are still open
  • which ones have lots of discussion
  • which labels keep appearing

That is exactly what the list page is for.


Step 4: Turn results into a triage queue

Once we have rows, we can add a simple priority classifier.

def triage_bucket(labels: list[str], comments: int) -> str:
    label_text = " ".join(labels).lower()

    if "security" in label_text or "regression" in label_text:
        return "urgent"
    if "bug" in label_text and comments >= 5:
        return "high"
    if comments >= 2:
        return "medium"
    return "normal"


def crawl_issue_search(start_url: str, *, max_pages: int = 5, use_proxiesapi: bool = False) -> list[dict]:
    all_rows: list[dict] = []
    seen: set[str] = set()
    url = start_url
    pages = 0

    while url and pages < max_pages:
        pages += 1
        html = fetch(url, use_proxiesapi=use_proxiesapi)
        batch, next_url = parse_results_page(html)

        for row in batch:
            key = row.get("url")
            if not key or key in seen:
                continue
            seen.add(key)
            row["priority"] = triage_bucket(row["labels"], row["comments"])
            row["labels"] = ",".join(row["labels"])
            all_rows.append(row)

        url = next_url
        time.sleep(0.8 + random.random())

    return all_rows

Step 5: Export the queue to CSV

import pandas as pd


if __name__ == "__main__":
    search_url = (
        "https://github.com/search?"
        "q=is%3Aissue+label%3Abug+state%3Aopen+repo%3Apsf%2Frequests"
        "&type=issues"
    )

    rows = crawl_issue_search(search_url, max_pages=3, use_proxiesapi=False)
    df = pd.DataFrame(rows).sort_values(["priority", "comments"], ascending=[True, False])
    df.to_csv("github-triage-queue.csv", index=False)

    print(df[["number", "priority", "comments", "title"]].head(10).to_string(index=False))

The CSV is now immediately useful for:

  • weekly triage reviews
  • bug-bash prep
  • backlog cleanup
  • finding old bug clusters by label

A practical extension: group by label

If you want a fast “what keeps breaking?” report:

label_counts = (
    df.assign(label=df["labels"].str.split(","))
      .explode("label")
      .query("label != ''")
      .groupby("label")
      .size()
      .sort_values(ascending=False)
)

print(label_counts.head(10))

That tells you where the queue is accumulating:

  • Bug
  • needs-repro
  • planned
  • docs

...or whatever labels the repo actually uses.


Where ProxiesAPI fits

For one repo and one search, GitHub usually behaves well.

ProxiesAPI becomes more useful when you:

  • scrape many saved searches across an org
  • refresh the queue on a schedule
  • run from multiple workers
  • want a cleaner retry layer without touching parser code

In that case, this stays the same:

rows = crawl_issue_search(search_url, max_pages=5, use_proxiesapi=True)

That is the pattern you want in production: transport changes, parser stays stable.


Common failure modes

1) GitHub changes CSS class names

Prefer data hooks and semantic tags where available:

  • data-testid
  • relative-time
  • rel="next"

2) Empty results because the query changed

Always print the first page count before assuming the parser broke.

3) You need authenticated results

Private repos and personalized filters are a different problem. Use the API or a logged-in browser session instead.

4) Comment count parsing feels fragile

If GitHub changes the trailing metadata layout, fall back to zero and keep the export running. Do not let one decorative field crash the crawl.


Final takeaway

GitHub issue search pages are a great source for quick operational exports because the URL already contains the triage logic.

The pattern is simple:

  1. save the search URL
  2. parse the current result cards
  3. follow pagination
  4. attach a lightweight priority heuristic
  5. export a CSV your team can sort immediately

That gets you from “someone should clean up this bug queue” to a real triage artifact in one script.

Keep repo triage exports stable when search pages get noisy

GitHub issue search is friendly at small volume, but repeated org-wide exports can still hit rate and reliability friction. ProxiesAPI gives you a clean fetch wrapper so your parser stays unchanged.

Related guides

Scrape GitHub Trending Repositories with Python
Build a daily GitHub Trending dataset with Python: collect repository names, languages, star counts, and URLs, then export clean CSV or JSON with an optional ProxiesAPI fetch layer.
tutorial#python#github#web-scraping
Scrape GitHub Repository Data
Collect GitHub repository metadata, stars, forks, topics, and README-linked context from the public HTML with Python. Includes defensive selectors, CSV export, and a screenshot.
tutorial#python#github#web-scraping
Scrape GitHub Issues (Labels, States, Pagination) Into CSV
Build a practical GitHub Issues scraper in Python: parse issue rows, collect labels + state + dates, follow pagination, and export a triage-ready CSV. Includes screenshot + working code.
tutorial#python#github#issues
Scrape Stack Overflow Newest Questions into CSV with Python
Collect Stack Overflow's newest questions with Python: titles, tags, votes, answers, timestamps, and URLs exported into clean CSV files with an optional ProxiesAPI request layer.
tutorial#python#stack-overflow#web-scraping