Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test_tip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
cache-dependency-glob: "pyproject.toml"
- name: Sync dependencies
run: uv sync --group dev
- name: Lint and import order
run: |
uv run ruff format --check paperscraper
uv run ruff check paperscraper
uv run isort --check-only paperscraper
- name: Export AWS secrets
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
Expand Down
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Note that one can skip some hooks e.g. with `SKIP=detect-secrets git commit ...`
repos:
- repo: local
hooks:
- id: Linting
name: Style checks (ruff, isort)
entry: sh -exc
language: system
always_run: true
pass_filenames: false
args:
- |
uv run --frozen isort --check paperscraper
uv run --frozen ruff format --check paperscraper
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,15 +369,15 @@ sizes_2019 = (55402, 11899, 2563)
labels_2020 = ('Medical\nImaging', 'Artificial\nIntelligence', 'COVID-19')
labels_2019 = ['Medical Imaging', 'Artificial\nIntelligence']

plot_venn_two(sizes_2019, labels_2019, title='2019', figname='ai_imaging')
plot_venn_two(sizes_2019, labels_2019, title='2019', figpath='ai_imaging.png')
```

![2019](https://github.com/jannisborn/paperscraper/blob/main/assets/ai_imaging.png?raw=true "2019")


```py
plot_venn_three(
sizes_2020, labels_2020, title='2020', figname='ai_imaging_covid'
sizes_2020, labels_2020, title='2020', figpath='ai_imaging_covid.png'
)
```

Expand All @@ -390,7 +390,7 @@ plot_multiple_venn(
[sizes_2019, sizes_2020], [labels_2019, labels_2020],
titles=['2019', '2020'], suptitle='Keyword search comparison',
gridspec_kw={'width_ratios': [1, 2]}, figsize=(10, 6),
figname='both'
figpath='both.png'
)
```

Expand Down
11 changes: 8 additions & 3 deletions paperscraper/arxiv/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@

from ..utils import get_server_dumps_dir

finalize_disjunction = lambda x: "(" + x[:-4] + ") AND "
finalize_conjunction = lambda x: x[:-5]

EARLIEST_START = "1970-01-01"


def finalize_disjunction(query: str) -> str:
return "(" + query[:-4] + ") AND "


def finalize_conjunction(query: str) -> str:
return query[:-5]


def format_date(date_str: str) -> str:
"""Converts a date in YYYY-MM-DD format to arXiv's YYYYMMDDTTTT format."""
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
Expand Down
32 changes: 24 additions & 8 deletions paperscraper/async_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import logging
import random
import sys
import threading
from functools import wraps
Expand Down Expand Up @@ -64,15 +65,19 @@ def retry_with_exponential_backoff(
base_delay: float = 1.0,
factor: float = 1.3,
constant_delay: float = 0.2,
max_delay: float = 60.0,
jitter_ratio: float = 0.1,
) -> Callable[[F], F]:
"""
Decorator factory that retries an `async def` on HTTP 429, with exponential backoff.
Decorator factory that retries an `async def` on transient HTTP/network errors, with exponential backoff.

Args:
max_retries: how many times to retry before giving up.
base_delay: initial delay in seconds; next delays will be multiplied by `factor`.
factor: multiplier for delay after each retry.
constant_delay: fixed delay before each attempt.
max_delay: maximum backoff delay between attempts.
jitter_ratio: add +/- jitter_ratio * delay seconds of jitter.

Usage:

Expand All @@ -93,23 +98,30 @@ async def wrapper(*args, **kwargs) -> Any:
return await func(*args, **kwargs)
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else None
if status != 429:
retryable = status == 429 or (
status is not None and (status == 408 or 500 <= status <= 599)
)
if not retryable:
raise
last_exception = e
sleep_for = delay
sleep_for = min(delay, max_delay)
if e.response is not None:
ra = e.response.headers.get("Retry-After")
if ra is not None:
try:
sleep_for = float(ra)
sleep_for = min(float(ra), max_delay)
except ValueError:
pass
delay *= factor
delay = min(delay * factor, max_delay)

except (httpx.ReadError, httpx.TimeoutException, httpx.TransportError) as e:
except (
httpx.ReadError,
httpx.TimeoutException,
httpx.TransportError,
) as e:
last_exception = e
sleep_for = delay
delay *= factor
sleep_for = min(delay, max_delay)
delay = min(delay * factor, max_delay)

if attempt == max_retries:
msg = (
Expand All @@ -118,6 +130,10 @@ async def wrapper(*args, **kwargs) -> Any:
)
raise RuntimeError(msg) from last_exception

if jitter_ratio > 0:
jitter = sleep_for * jitter_ratio
sleep_for = max(0.0, sleep_for + random.uniform(-jitter, jitter))

await asyncio.sleep(sleep_for)

return wrapper
Expand Down
30 changes: 21 additions & 9 deletions paperscraper/citations/citations.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import logging
import os
import sys
from time import sleep

from scholarly import scholarly
from semanticscholar import SemanticScholar, SemanticScholarException
from semanticscholar import SemanticScholarException

from .utils import PAPER_URL, semantic_scholar_requests_get

logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logger = logging.getLogger(__name__)
sch = SemanticScholar(api_key=os.getenv("SS_API_KEY"))


def get_citations_by_doi(doi: str) -> int:
Expand All @@ -23,17 +23,29 @@ def get_citations_by_doi(doi: str) -> int:
"""

try:
paper = sch.get_paper(doi)
citations = len(paper["citations"])
response = semantic_scholar_requests_get(
f"{PAPER_URL}DOI:{doi}",
params={"fields": "citationCount"},
timeout=20,
)
if response.status_code == 404:
logger.warning(f"Could not find paper {doi}, assuming 0 citation.")
return 0
response.raise_for_status()
return response.json()["citationCount"]
except SemanticScholarException.ObjectNotFoundException:
logger.warning(f"Could not find paper {doi}, assuming 0 citation.")
citations = 0
return 0
except ConnectionRefusedError as e:
logger.warning(f"Waiting for 10 sec since {doi} gave: {e}")
sleep(10)
citations = len(sch.get_paper(doi)["citations"])
finally:
return citations
response = semantic_scholar_requests_get(
f"{PAPER_URL}DOI:{doi}",
params={"fields": "citationCount"},
timeout=20,
)
response.raise_for_status()
return response.json()["citationCount"]


def get_citations_from_title(title: str) -> int:
Expand Down
2 changes: 1 addition & 1 deletion paperscraper/citations/entity/paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PaperResult(ReferenceResult, CitationResult):
title: str


ModeType = Literal[tuple(MODES := ("doi", "title", "ss_id", "infer"))]
ModeType = Literal[tuple(MODES := ("doi", "title", "ssid", "infer"))]

BASE_URL: str = "https://api.semanticscholar.org/graph/v1/paper/search"

Expand Down
13 changes: 6 additions & 7 deletions paperscraper/citations/entity/researcher.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import os
from typing import Any, List, Literal, Optional, Tuple

from semanticscholar import SemanticScholar

from ...async_utils import run_sync
from ..orcid import orcid_to_author_name
from ..self_citations import CitationResult, self_citations_paper
from ..self_references import ReferenceResult, self_references_paper
from ..utils import author_name_to_ssaid, get_papers_for_author
from ..utils import (
author_name_to_ssaid,
get_author_name_from_ssaid,
get_papers_for_author,
)
from .core import Entity, EntityResult


Expand Down Expand Up @@ -40,8 +41,6 @@ def __str__(self) -> str:

ModeType = Literal[tuple(MODES := ("name", "orcid", "ssaid", "infer"))]

sch = SemanticScholar(api_key=os.getenv("SS_API_KEY"))


class Researcher(Entity):
name: str
Expand Down Expand Up @@ -77,7 +76,7 @@ def __init__(self, input: str, mode: ModeType = "infer"):
else:
mode = "name"
if mode == "ssaid":
self.name = sch.get_author(input)._name
self.name = get_author_name_from_ssaid(input)
self.ssaid = input
elif mode == "orcid":
orcid_name = orcid_to_author_name(input)
Expand Down
6 changes: 3 additions & 3 deletions paperscraper/citations/self_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
from ..async_utils import optional_async, retry_with_exponential_backoff
from .utils import (
DOI_PATTERN,
HEADERS,
HTTPX_LIMITS,
REQUEST_SEMAPHORE,
REQUEST_TIMEOUT_SECONDS,
find_matching,
semantic_scholar_get,
wait_for_request_slot,
)

Expand Down Expand Up @@ -50,10 +50,10 @@ async def _fetch_citation_data(
"""
await wait_for_request_slot()

response = await client.get(
response = await semantic_scholar_get(
client,
f"https://api.semanticscholar.org/graph/v1/paper/{suffix}",
params={"fields": "title,authors,citations.authors"},
headers=HEADERS,
)
response.raise_for_status()
return response.json()
Expand Down
6 changes: 3 additions & 3 deletions paperscraper/citations/self_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
from ..async_utils import optional_async, retry_with_exponential_backoff
from .utils import (
DOI_PATTERN,
HEADERS,
HTTPX_LIMITS,
REQUEST_SEMAPHORE,
REQUEST_TIMEOUT_SECONDS,
find_matching,
semantic_scholar_get,
wait_for_request_slot,
)

Expand Down Expand Up @@ -51,10 +51,10 @@ async def _fetch_paper_with_references(
"""
await wait_for_request_slot()

response = await client.get(
response = await semantic_scholar_get(
client,
f"https://api.semanticscholar.org/graph/v1/paper/{suffix}",
params={"fields": "title,authors,references.authors"},
headers=HEADERS,
)
response.raise_for_status()
return response.json()
Expand Down
39 changes: 34 additions & 5 deletions paperscraper/citations/tests/test_citations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

from paperscraper.citations import get_citations_by_doi
from paperscraper.citations.utils import check_overlap, author_name_to_ssaid
from paperscraper.citations import get_citations_by_doi, utils
from paperscraper.citations.utils import author_name_to_ssaid, check_overlap

logging.disable(logging.INFO)

Expand All @@ -17,9 +17,38 @@ def test_citations(self):

def test_author_name_to_ssid(self):

ssaid, name = author_name_to_ssaid('Fabian H Sinz')
assert ssaid == '50095217'
assert name == 'Fabian H Sinz'
ssaid, name = author_name_to_ssaid("Fabian H Sinz")
assert ssaid == "50095217"
assert name == "Fabian H Sinz"

def test_semantic_scholar_403_disables_api_key(self, monkeypatch):
class Response:
def __init__(self, status_code):
self.status_code = status_code

original_headers = utils.HEADERS.copy()
original_disabled = utils.semantic_scholar_key_disabled()
utils.HEADERS.clear()
utils.HEADERS["x-api-key"] = "bad-key"
utils._SEMANTIC_SCHOLAR_KEY_DISABLED = False

calls = []

def mock_get(url, headers=None, **kwargs):
calls.append(dict(headers or {}))
return Response(403 if len(calls) == 1 else 200)

monkeypatch.setattr(utils.requests, "get", mock_get)
try:
response = utils.semantic_scholar_requests_get("https://example.test")
assert response.status_code == 200
assert calls == [{"x-api-key": "bad-key"}, {}]
assert utils.HEADERS == {}
assert utils.semantic_scholar_key_disabled()
finally:
utils.HEADERS.clear()
utils.HEADERS.update(original_headers)
utils._SEMANTIC_SCHOLAR_KEY_DISABLED = original_disabled

def test_name_overlap(self):
assert check_overlap("John Smith", "J. Smith")
Expand Down
7 changes: 1 addition & 6 deletions paperscraper/citations/tests/test_self_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ def test_multiple_dois(self, dois):
f"Synchronous execution time (independent calls): {sync_duration:.2f} seconds"
)

assert async_duration*0.8 <= sync_duration, (
f"Async execution ({async_duration:.2f}s) is slower than sync execution "
f"({sync_duration:.2f}s)"
)

for a, s in zip(
sorted(result, key=lambda r: r.ssid),
sorted(sync_result, key=lambda r: r.ssid),
Expand Down Expand Up @@ -134,7 +129,7 @@ def test_whole_researcher(self):
assert result.num_citations > 0
assert isinstance(result.self_citations, Dict)
assert isinstance(result.self_references, Dict)
assert len(result.self_citations) > 5
assert len(result.self_citations) >= 5
assert len(result.self_references) >= 3
for title, ratio in result.self_citations.items():
assert isinstance(title, str)
Expand Down
1 change: 0 additions & 1 deletion paperscraper/citations/tests/test_self_references.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import time
from typing import Dict

import pytest
Expand Down
Loading
Loading