How to Scrape Google Scholar: Papers, Citations, Authors
Scrape Google Scholar for papers, citations, and author profiles. Parse gs_ri result blocks and h-index data, page with start=, and survive the CAPTCHA wall.

To scrape Google Scholar you have to accept one hard fact first: there is no official API, and Google guards Scholar more aggressively than almost any other surface it runs. Hit it with a plain requests.get from a datacenter IP and you will see the CAPTCHA wall inside a dozen requests, then a temporary IP ban. This guide treats Scholar as what it is, one of the hardest targets on the public web, and shows the defensive way to pull papers, cited-by counts, version clusters, and full author profiles with the h-index and i10-index, all through residential exits paced slowly enough to stay under the radar.
Key Takeaways
- Google Scholar has no public API and its Terms prohibit automated querying. For anything you can get legitimately, the Semantic Scholar, OpenAlex, and Crossref APIs return the same papers and citation graph without the fight. Scrape Scholar only for what those cannot give you.
- Every result lives in a
div.gs_riblock:h3.gs_rtis the title,div.gs_ais the author and venue line,div.gs_rsis the snippet, anddiv.gs_flholds the "Cited by", "Related", and "All versions" links. The wrappingdiv.gs_rcarries adata-cidcluster id.- That cluster id is the join key for the whole graph.
cites=walks forward citations,cluster=walks every version of a paper. Grab it or you lose the part that makes Scholar worth scraping.- Residential IPs are not optional here. Datacenter ranges get the
/sorry/redirect fast. Route every request through the SparkProxy Scraping API withpremium_proxy=true, pace at one request every 10 to 30 seconds, and run a single worker.
Is scraping Google Scholar legal?
Scholar is a special case, so start here before you write any code. Google offers no Scholar API, and its Terms of Service prohibit sending automated queries to the service. That is a contractual restriction, not a criminal one, but it sets the tone: you are not a welcome guest.
Two things matter in practice. First, the data itself. Bibliographic facts such as a paper's title, authors, venue, year, and citation count are facts, and in the United States facts are not copyrightable under Feist Publications v. Rural Telephone (1991). Abstract text is expression owned by the publisher, so treat snippets as short quotations, not content to republish. Second, access. The Ninth Circuit's hiQ Labs v. LinkedIn line of rulings held that scraping publicly visible pages generally does not violate the Computer Fraud and Abuse Act, but Scholar's own Terms still say no, and violating them can get your access cut. None of this is legal advice. For commercial use, get counsel.
Here is the honest engineering call almost no tutorial makes: for most citation and metadata needs, you should not scrape Scholar at all. Semantic Scholar, OpenAlex, and Crossref publish free, documented APIs that cover the same papers, authors, and citation edges, with rate limits you can actually plan around. Reach for scraping only when you need a field that lives on the Scholar page and nowhere else, such as Scholar's own cited-by number or a public profile's exact h-index. If you are collecting academic data at scale, our guide to using proxies for academic research data collection walks through those API-first sources, and the guide on ethical scraping and rate limiting covers the pacing rules that keep you inside acceptable use.
Why Scholar is the hardest Google surface
Scraping Google Search results is already work. Scholar is harder, and it helps to know why before you fight it.
- No API and no intent to build one. Google has never shipped a Scholar API and has said it will not. There is no rate-limited-but-supported path. Every request is the front end or nothing.
- A hair-trigger CAPTCHA. Scholar counts requests per IP over a short window. Cross the threshold and it stops serving results, redirecting to
google.com/sorry/with a reCAPTCHA. Solve it or that IP is cold for a while. - Datacenter ranges are pre-flagged. Whole cloud subnets are treated as suspect on arrival. A fresh datacenter IP can get the block page on request one. Residential exits look like real readers and last far longer.
- Thin session tolerance. Scholar is stateless HTML with no login for public search, so you cannot warm a session to earn trust. Each IP is judged almost entirely on its request rate and network reputation.
The upside is that the pages themselves are simple. Scholar renders server-side HTML, no client-side framework, so once a request gets through you can parse it with nothing more than an HTML parser. The whole difficulty is getting the bytes, not reading them.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Anatomy of a result: the gs_ri block
Open any Scholar search page and every result follows the same shape. The outer wrapper is div.gs_r.gs_or.gs_scl and it carries a data-cid attribute, which is the cluster id for that paper. Inside sits div.gs_ri, the block you actually parse.
Attention is all you need
A Vaswani, N Shazeer, N Parmar - Advances in neural, 2017 - proceedings.org
The dominant sequence transduction models are based on complex recurrent ...
</div>
</div>
Notice that the data-cid on the wrapper and the number inside the cites= and cluster= links are the same value. That single id is the most important thing on the page. Keep it, and you can walk outward to everyone who cited the paper and inward to every version of it. Most tutorials extract the title and stop, which throws away the entire reason Scholar is interesting.
Here is the selector reference for a parser:
Target Selector Result wrapper (holds cluster id) `div.gs_r` (read `data-cid`) Result body `div.gs_ri` Title and link `h3.gs_rt a` Author, venue, year line `div.gs_a` Snippet `div.gs_rs` Footer links (cited by, versions) `div.gs_fl a`
Set up the SparkProxy Scraping API
You need residential exits and you need them rotating. The SparkProxy Scraping API gives you both behind one call, so IP rotation and geo-targeting are parameters instead of infrastructure. If you are new to why residential IPs beat datacenter ranges on a target like this, our explainer on what a residential proxy is has the background.
The base is https://scrape.sparkproxy.io/api/v1 and auth is the X-API-Key header. A raw fetch of a Scholar page looks like this:
curl -X POST "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: sk-your-api-key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://scholar.google.com/scholar?q=graph+neural+networks&hl=en",
"render_js": false,
"premium_proxy": true,
"country_code": "US"
}'
Two parameter choices matter for Scholar specifically. Set render_js to false, because Scholar is server-rendered and skipping the headless browser makes each request cheaper and faster. Set premium_proxy to true, because the residential tier is the only thing that survives here. Add stealth: true on the toughest queries when the plain residential fetch still trips the wall.
SparkProxy parameter Value for Scholar Why `render_js` `false` Pages are static HTML, no browser needed `premium_proxy` `true` Residential exits, mandatory for Scholar `country_code` `"US"` or your target locale Stable region, consistent result set `stealth` `true` only when blocked Extra layers cost more credits
Wrap it once in Python and every later step reuses it:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "sk-your-api-key" # from the SparkProxy dashboard at https://www.sparkproxy.io
def scholar_get(target_url, country="US", stealth=False):
payload = {
"url": target_url,
"render_js": False, # Scholar is server-rendered
"premium_proxy": True, # residential exit is non-negotiable
"country_code": country,
"stealth": stealth,
}
r = requests.post(API, headers={"X-API-Key": API_KEY}, json=payload, timeout=90)
r.raise_for_status()
return r.text
Parse the search results page
With the HTML in hand, parsing is straightforward. Iterate the wrappers so you can read data-cid, then pull each field from the inner block. This function returns one clean dict per paper, including the cluster id and the forward-citations id.
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, parse_qs
BASE = "https://scholar.google.com"
def parse_results(html):
soup = BeautifulSoup(html, "html.parser")
items = []
for wrap in soup.select("div.gs_r"):
block = wrap.select_one("div.gs_ri")
if not block:
continue
title_el = block.select_one("h3.gs_rt a")
byline = block.select_one("div.gs_a")
snippet = block.select_one("div.gs_rs")
cited_by, cites_id, versions_url = None, None, None
for a in block.select("div.gs_fl a"):
text = a.get_text(strip=True)
href = a.get("href", "")
if text.startswith("Cited by"):
cited_by = int(text.replace("Cited by", "").strip() or 0)
cites_id = parse_qs(urlparse(href).query).get("cites", [None])[0]
elif "version" in text:
versions_url = urljoin(BASE, href)
items.append({
"cluster_id": wrap.get("data-cid"),
"title": title_el.get_text(" ", strip=True) if title_el else None,
"url": title_el["href"] if title_el else None,
"byline": byline.get_text(" ", strip=True) if byline else None,
"snippet": snippet.get_text(" ", strip=True) if snippet else None,
"cited_by": cited_by,
"cites_id": cites_id,
"versions_url": versions_url,
})
return items
The div.gs_a line packs three fields into one string, formatted as authors - venue, year - domain. Split it when you need structured columns:
def split_byline(byline):
parts = [p.strip() for p in byline.split(" - ")]
authors = parts[0] if parts else ""
venue_year = parts[1] if len(parts) > 1 else ""
year = next((tok for tok in venue_year.replace(",", " ").split()
if tok.isdigit() and len(tok) == 4), None)
return {"authors": authors, "venue": venue_year, "year": year}
If you would rather have the API return structured rows and skip BeautifulSoup, SparkProxy also accepts an extract_rules object that maps selectors to fields on the server. Point it at div.gs_ri as a list and read the JSON back. Check the Scraping API docs for the exact nested shape, since a hand-rolled parser gives you more control over the cluster id and the footer links.
Follow "Cited by" and "All versions"
This is where the cluster id pays off. Once you have cites_id (or the cluster_id from data-cid), you rebuild the URLs Scholar uses for its own links and fetch them like any other search page. The response is the same gs_ri layout, so parse_results handles it unchanged.
def cited_by(cites_id, start=0):
url = f"{BASE}/scholar?cites={cites_id}&hl=en&start={start}"
return parse_results(scholar_get(url))
def all_versions(cluster_id, start=0):
url = f"{BASE}/scholar?cluster={cluster_id}&hl=en&start={start}"
return parse_results(scholar_get(url))
That is the entire citation graph in two functions. Start from a seed query, collect each paper's cites_id, and cited_by gives you every paper that cited it. Feed those results' ids back in and you can traverse forward citations as deep as you are willing to pace. all_versions collapses preprints, publisher copies, and mirrors of the same work into one set, which is how you dedupe a corpus.
Paginate with start=
Scholar shows 10 results per page and paginates with the start offset, not a page number. Page two is start=10, page three is start=20, and so on. Two ceilings matter: Scholar rarely honors more than 10 results per page even if you ask for num=20, and it stops serving new results at roughly start=990, so about 1000 results per query is the practical wall. Narrow with a year filter (as_ylo and as_yhi) when you need to go deeper than that.
import time, random
from urllib.parse import quote_plus
def search(query, pages=5, per_page=10):
out = []
for p in range(pages):
start = p * per_page
url = f"{BASE}/scholar?q={quote_plus(query)}&hl=en&start={start}"
html = scholar_get(url)
if is_blocked(html): # defined in the pacing section
time.sleep(random.uniform(60, 120))
continue
page_items = parse_results(html)
if not page_items:
break # ran out of results
out.extend(page_items)
time.sleep(random.uniform(10, 25)) # never hammer
return out
Notice the sleep is inside the loop and randomized. That single line does more to keep you unblocked than any header trick.
Use the scholarly library
If you would rather not maintain selectors, the open-source scholarly package (version 1.x) wraps all of this behind Python objects. It parses the same pages, so it hits the same wall, which is why its own docs say you must run it behind a proxy for anything beyond a couple of lookups. Point its ProxyGenerator at a SparkProxy residential endpoint and it routes every request through a rotating exit.
from scholarly import scholarly, ProxyGenerator
pg = ProxyGenerator()
# Your SparkProxy residential gateway (host, port, and credentials from the dashboard)
gateway = "http://USER:PASS@gateway.sparkproxy.io:11000"
pg.SingleProxy(http=gateway, https=gateway)
scholarly.use_proxy(pg)
# A paper search
pub = next(scholarly.search_pubs("graph neural networks"))
scholarly.pprint(pub) # title, author, venue, num_citations, cites_id
# An author profile by id
author = scholarly.search_author_id("SOME_USER_ID")
scholarly.fill(author, sections=["basics", "indices", "publications"])
print(author["hindex"], author["i10index"], author["citedby"])
Because the SparkProxy residential gateway rotates the exit per request, one SingleProxy URL still gives you IP rotation under the hood. If you switch proxy methods mid-run, build a fresh ProxyGenerator first, which the library notes to avoid stale-state bugs. The tradeoff: scholarly is convenient but a moving target, since it breaks whenever Scholar reshuffles a class name. Rolling your own parser, as above, means you fix one selector instead of waiting for a release.
Pace it or get blocked
Every technique above fails without pacing. Scholar's defense is rate-based, so your job is to look slow and human. The rules that actually work:
- One worker. Concurrency is the fastest way to the CAPTCHA. Run a single sequential loop, not a thread pool.
- Slow, randomized delays. Sleep 10 to 30 seconds between requests, jittered. Steady intervals are themselves a bot signal.
- Rotate residential exits. Let SparkProxy hand you a new IP per request or per short session. A single IP, however clean, burns out under repeated Scholar queries.
- Detect the wall and back off. When a response is the block page, stop, wait a minute or more, and rotate. Do not retry into a ban.
Detecting the block page is a two-line check. Scholar redirects to /sorry/ and returns a reCAPTCHA with tell-tale markers:
def is_blocked(html):
markers = ("gs_captcha", "/sorry/", "unusual traffic",
"not a robot", 'id="captcha')
low = html.lower()
return any(m in low for m in markers)
When is_blocked fires, the right move is patience, not force. Sleep, rotate to a fresh exit, and only then retry, optionally with stealth: true. Solving CAPTCHAs in a loop just trains the filter on you. For the wider playbook on staying under detection thresholds, see how to avoid CAPTCHAs when web scraping and how to scrape high-volume data without rate limiting. On Scholar the honest ceiling is low: a few thousand careful requests a day, not the millions you might run against a friendlier site. Plan the project around that, and lean on the Semantic Scholar and OpenAlex APIs for the bulk so Scholar only serves the fields nothing else has.
Frequently asked questions
FAQ
No. Google has never released a Scholar API and has stated it does not plan to. Programmatic access means either scraping the HTML front end or using a third-party dataset. For most citation and metadata needs, the free Semantic Scholar, OpenAlex, and Crossref APIs cover the same papers and citation graph with documented rate limits, so reach for them first.
Scholar's Terms of Service prohibit sending automated queries, so scraping it breaches those Terms even though bibliographic facts themselves are not copyrightable. Courts in the United States have generally held that scraping public pages does not violate the Computer Fraud and Abuse Act, but that does not override Google's Terms. This is not legal advice, and for any commercial project you should get counsel and prefer the licensed APIs.
Scholar counts requests per IP over a short window and redirects to a google.com/sorry/ reCAPTCHA once you cross the threshold, especially from datacenter IP ranges that are pre-flagged. The fixes are structural: use rotating residential exits, run a single worker, and sleep 10 to 30 seconds between requests. When the block page appears, back off for a minute and rotate rather than retrying immediately.
Per-paper citation counts come from the "Cited by N" link inside each div.gs_fl on a search results page. An author's total citations, h-index, and i10-index live in the #gsc_rsb_st table on their profile page at citations?user=, with All-time and recent columns. Parse that table's three rows to read all three metrics in one request.
Effectively yes. Scholar treats datacenter subnets as suspect and can serve the block page on the first request from a cloud IP. Residential exits look like ordinary readers and last far longer, which is why routing through the SparkProxy Scraping API with premium_proxy=true is the practical baseline for Scholar rather than an optimization.
scholarly is an open-source Python package that retrieves author and publication data from Google Scholar through Pythonic objects. It parses the same public HTML, so it does not bypass CAPTCHAs on its own. Its documentation tells you to configure a ProxyGenerator with a proxy for anything beyond light use, and pointing that at a SparkProxy residential gateway is what keeps it answering at volume.
Special Discount ยท 20% offGet 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
CodeSPARK20Claim Discount Related articles

XPath and CSS Selectors: Scrapers That Don't Break
Scrape data with XPath and CSS selectors that survive redesigns: label-anchored XPath, stable attributes, namespace fixes, and a selector-drift detector.

Stealth Plugins for Puppeteer and Playwright: What Works
Stealth plugins patch the known automation tells in Puppeteer and Playwright. Which ones still matter, why the patch set is a signature, and how to test yours.

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.
