conflens 0.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conflens/__init__.py +8 -0
- conflens/app.py +788 -0
- conflens/bibtex.py +56 -0
- conflens/cache.py +36 -0
- conflens/classifier.py +174 -0
- conflens/cli.py +75 -0
- conflens/dedup.py +100 -0
- conflens/llm.py +336 -0
- conflens/models.py +116 -0
- conflens/pipeline.py +207 -0
- conflens/pptx_export.py +225 -0
- conflens/scraper.py +277 -0
- conflens/sources.py +839 -0
- conflens/topics.py +365 -0
- conflens/view.py +188 -0
- conflens-0.1.14.dist-info/METADATA +349 -0
- conflens-0.1.14.dist-info/RECORD +20 -0
- conflens-0.1.14.dist-info/WHEEL +4 -0
- conflens-0.1.14.dist-info/entry_points.txt +3 -0
- conflens-0.1.14.dist-info/licenses/LICENSE +373 -0
conflens/pptx_export.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Render an :class:`AnalysisResult` to a PowerPoint slide deck.
|
|
2
|
+
|
|
3
|
+
Generated deterministically with ``python-pptx`` (no API call). The styling
|
|
4
|
+
mirrors the web UI: white slides, a single navy accent, clean typography.
|
|
5
|
+
|
|
6
|
+
Deck structure:
|
|
7
|
+
|
|
8
|
+
* a **title** slide (theme, source, headline counts);
|
|
9
|
+
* an **overview** slide with a papers-per-topic bar chart; and
|
|
10
|
+
* one or more **topic** slides, each listing its papers with author lines and
|
|
11
|
+
clickable PDF links (continuation slides are added when a topic overflows).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # avoid importing the model at module load
|
|
20
|
+
from .models import AnalysisResult
|
|
21
|
+
|
|
22
|
+
# Palette shared with the UI -------------------------------------------------
|
|
23
|
+
_NAVY = (0x1F, 0x4E, 0x79)
|
|
24
|
+
_ACCENT = (0x2B, 0x6C, 0xB0)
|
|
25
|
+
_INK = (0x1A, 0x20, 0x2C)
|
|
26
|
+
_MUTED = (0x64, 0x74, 0x8B)
|
|
27
|
+
_LINE = (0xE2, 0xE8, 0xF0)
|
|
28
|
+
_WHITE = (0xFF, 0xFF, 0xFF)
|
|
29
|
+
|
|
30
|
+
_PAPERS_PER_SLIDE = 8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_pptx(result: "AnalysisResult") -> bytes:
|
|
34
|
+
"""Build the deck and return it as ``.pptx`` bytes.
|
|
35
|
+
|
|
36
|
+
Raises ``RuntimeError`` with an actionable message if ``python-pptx`` is
|
|
37
|
+
not installed.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
from pptx import Presentation
|
|
41
|
+
from pptx.chart.data import CategoryChartData
|
|
42
|
+
from pptx.dml.color import RGBColor
|
|
43
|
+
from pptx.enum.chart import XL_CHART_TYPE
|
|
44
|
+
from pptx.enum.text import MSO_ANCHOR
|
|
45
|
+
from pptx.util import Inches, Pt
|
|
46
|
+
except ImportError as e: # pragma: no cover - optional dependency
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"PPTX export needs the 'python-pptx' package. Install it with "
|
|
49
|
+
"`pip install python-pptx`."
|
|
50
|
+
) from e
|
|
51
|
+
|
|
52
|
+
def rgb(c: tuple[int, int, int]) -> "RGBColor":
|
|
53
|
+
return RGBColor(*c)
|
|
54
|
+
|
|
55
|
+
prs = Presentation()
|
|
56
|
+
prs.slide_width = Inches(13.333)
|
|
57
|
+
prs.slide_height = Inches(7.5)
|
|
58
|
+
blank = prs.slide_layouts[6]
|
|
59
|
+
SW, SH = prs.slide_width, prs.slide_height
|
|
60
|
+
|
|
61
|
+
# ---- small helpers ------------------------------------------------ #
|
|
62
|
+
def add_slide():
|
|
63
|
+
return prs.slides.add_slide(blank)
|
|
64
|
+
|
|
65
|
+
def fill_rect(slide, left, top, width, height, color):
|
|
66
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
67
|
+
|
|
68
|
+
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
|
|
69
|
+
shape.fill.solid()
|
|
70
|
+
shape.fill.fore_color.rgb = rgb(color)
|
|
71
|
+
shape.line.fill.background()
|
|
72
|
+
shape.shadow.inherit = False
|
|
73
|
+
return shape
|
|
74
|
+
|
|
75
|
+
def textbox(slide, left, top, width, height):
|
|
76
|
+
box = slide.shapes.add_textbox(left, top, width, height)
|
|
77
|
+
tf = box.text_frame
|
|
78
|
+
tf.word_wrap = True
|
|
79
|
+
return box, tf
|
|
80
|
+
|
|
81
|
+
def style_run(run, text, size, color, bold=False, italic=False):
|
|
82
|
+
run.text = text
|
|
83
|
+
run.font.size = Pt(size)
|
|
84
|
+
run.font.bold = bold
|
|
85
|
+
run.font.italic = italic
|
|
86
|
+
run.font.color.rgb = rgb(color)
|
|
87
|
+
run.font.name = "Calibri"
|
|
88
|
+
|
|
89
|
+
def header_bar(slide, title, subtitle=""):
|
|
90
|
+
fill_rect(slide, 0, 0, SW, Inches(1.0), _NAVY)
|
|
91
|
+
_, tf = textbox(slide, Inches(0.5), Inches(0.12), SW - Inches(1.0), Inches(0.8))
|
|
92
|
+
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
93
|
+
p = tf.paragraphs[0]
|
|
94
|
+
style_run(p.add_run(), title, 24, _WHITE, bold=True)
|
|
95
|
+
if subtitle:
|
|
96
|
+
p2 = tf.add_paragraph()
|
|
97
|
+
style_run(p2.add_run(), subtitle, 12, _WHITE)
|
|
98
|
+
|
|
99
|
+
# ---- 1. title slide ---------------------------------------------- #
|
|
100
|
+
s = add_slide()
|
|
101
|
+
fill_rect(s, 0, 0, SW, SH, _WHITE)
|
|
102
|
+
fill_rect(s, 0, Inches(2.4), SW, Inches(0.06), _ACCENT)
|
|
103
|
+
|
|
104
|
+
_, tf = textbox(s, Inches(0.9), Inches(1.2), SW - Inches(1.8), Inches(1.2))
|
|
105
|
+
style_run(tf.paragraphs[0].add_run(), "Conference Paper Analysis", 20, _MUTED)
|
|
106
|
+
p = tf.add_paragraph()
|
|
107
|
+
style_run(p.add_run(), result.theme, 40, _NAVY, bold=True)
|
|
108
|
+
|
|
109
|
+
_, tf = textbox(s, Inches(0.9), Inches(2.7), SW - Inches(1.8), Inches(3.0))
|
|
110
|
+
line = tf.paragraphs[0]
|
|
111
|
+
style_run(line.add_run(), f"Source: {result.event_url}", 14, _MUTED)
|
|
112
|
+
stats = [
|
|
113
|
+
(str(result.scanned), "papers scanned"),
|
|
114
|
+
(str(len(result.relevant_papers)), f"match “{result.theme}”"),
|
|
115
|
+
(str(len(result.topics)), "topics discovered"),
|
|
116
|
+
]
|
|
117
|
+
for value, label in stats:
|
|
118
|
+
p = tf.add_paragraph()
|
|
119
|
+
p.space_before = Pt(10)
|
|
120
|
+
style_run(p.add_run(), f"{value} ", 28, _ACCENT, bold=True)
|
|
121
|
+
style_run(p.add_run(), label, 16, _INK)
|
|
122
|
+
|
|
123
|
+
# ---- 2. overview / chart ----------------------------------------- #
|
|
124
|
+
if result.topics:
|
|
125
|
+
s = add_slide()
|
|
126
|
+
fill_rect(s, 0, 0, SW, SH, _WHITE)
|
|
127
|
+
header_bar(s, "Papers per topic", result.theme)
|
|
128
|
+
chart_data = CategoryChartData()
|
|
129
|
+
# reversed so the largest topic sits at the top of a horizontal bar chart
|
|
130
|
+
topics_sorted = list(result.topics)
|
|
131
|
+
chart_data.categories = [t.name for t in reversed(topics_sorted)]
|
|
132
|
+
chart_data.add_series("Papers", [t.count for t in reversed(topics_sorted)])
|
|
133
|
+
gframe = s.shapes.add_chart(
|
|
134
|
+
XL_CHART_TYPE.BAR_CLUSTERED,
|
|
135
|
+
Inches(0.5), Inches(1.2), SW - Inches(1.0), SH - Inches(1.6),
|
|
136
|
+
chart_data,
|
|
137
|
+
)
|
|
138
|
+
chart = gframe.chart
|
|
139
|
+
chart.has_legend = False
|
|
140
|
+
chart.has_title = False
|
|
141
|
+
plot = chart.plots[0]
|
|
142
|
+
plot.has_data_labels = True
|
|
143
|
+
plot.data_labels.font.size = Pt(11)
|
|
144
|
+
plot.data_labels.font.color.rgb = rgb(_INK)
|
|
145
|
+
plot.gap_width = 60
|
|
146
|
+
series = chart.series[0]
|
|
147
|
+
series.format.fill.solid()
|
|
148
|
+
series.format.fill.fore_color.rgb = rgb(_ACCENT)
|
|
149
|
+
for axis in (chart.category_axis, chart.value_axis):
|
|
150
|
+
axis.tick_labels.font.size = Pt(11)
|
|
151
|
+
axis.tick_labels.font.color.rgb = rgb(_INK)
|
|
152
|
+
|
|
153
|
+
# ---- 3. one (or more) slide(s) per topic ------------------------- #
|
|
154
|
+
by_id = {p.paper_id: p for p in result.relevant_papers}
|
|
155
|
+
for topic in result.topics:
|
|
156
|
+
papers = [by_id[pid] for pid in topic.paper_ids if pid in by_id]
|
|
157
|
+
papers.sort(key=lambda p: p.confidence or 0, reverse=True)
|
|
158
|
+
|
|
159
|
+
# 3a. topic overview slide: description + common findings
|
|
160
|
+
if topic.description or topic.findings:
|
|
161
|
+
s = add_slide()
|
|
162
|
+
fill_rect(s, 0, 0, SW, SH, _WHITE)
|
|
163
|
+
header_bar(
|
|
164
|
+
s,
|
|
165
|
+
topic.name,
|
|
166
|
+
f"{topic.count} paper{'s' if topic.count != 1 else ''}",
|
|
167
|
+
)
|
|
168
|
+
top = Inches(1.25)
|
|
169
|
+
if topic.description:
|
|
170
|
+
_, tf = textbox(s, Inches(0.5), top, SW - Inches(1.0), Inches(0.9))
|
|
171
|
+
style_run(tf.paragraphs[0].add_run(), topic.description, 14, _INK)
|
|
172
|
+
top = Inches(2.05)
|
|
173
|
+
if topic.findings:
|
|
174
|
+
_, tf = textbox(s, Inches(0.5), top, SW - Inches(1.0), SH - top - Inches(0.4))
|
|
175
|
+
head = tf.paragraphs[0]
|
|
176
|
+
style_run(head.add_run(), "Main findings across this topic", 12, _ACCENT, bold=True)
|
|
177
|
+
for finding in topic.findings[:10]:
|
|
178
|
+
fp = tf.add_paragraph()
|
|
179
|
+
fp.space_before = Pt(5)
|
|
180
|
+
style_run(fp.add_run(), "• " + finding, 13, _INK)
|
|
181
|
+
chunks = [
|
|
182
|
+
papers[i : i + _PAPERS_PER_SLIDE]
|
|
183
|
+
for i in range(0, max(len(papers), 1), _PAPERS_PER_SLIDE)
|
|
184
|
+
] or [[]]
|
|
185
|
+
for idx, chunk in enumerate(chunks):
|
|
186
|
+
s = add_slide()
|
|
187
|
+
fill_rect(s, 0, 0, SW, SH, _WHITE)
|
|
188
|
+
suffix = "" if len(chunks) == 1 else f" ({idx + 1}/{len(chunks)})"
|
|
189
|
+
header_bar(
|
|
190
|
+
s,
|
|
191
|
+
f"{topic.name} — Papers{suffix}",
|
|
192
|
+
f"{topic.count} paper{'s' if topic.count != 1 else ''}",
|
|
193
|
+
)
|
|
194
|
+
top = Inches(1.25)
|
|
195
|
+
_, tf = textbox(s, Inches(0.5), top, SW - Inches(1.0), SH - top - Inches(0.3))
|
|
196
|
+
first = True
|
|
197
|
+
for paper in chunk:
|
|
198
|
+
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
|
199
|
+
first = False
|
|
200
|
+
p.space_after = Pt(8)
|
|
201
|
+
# title (clickable -> landing page when available)
|
|
202
|
+
trun = p.add_run()
|
|
203
|
+
style_run(trun, "• " + paper.title, 14, _NAVY, bold=True)
|
|
204
|
+
if paper.url:
|
|
205
|
+
trun.hyperlink.address = paper.url
|
|
206
|
+
if paper.confidence is not None:
|
|
207
|
+
style_run(p.add_run(), f" ({paper.confidence:.0%})", 11, _MUTED)
|
|
208
|
+
# PDF link (only when a PDF exists)
|
|
209
|
+
if paper.pdf_url:
|
|
210
|
+
style_run(p.add_run(), " ", 14, _INK)
|
|
211
|
+
pdf = p.add_run()
|
|
212
|
+
style_run(pdf, "[PDF]", 12, _ACCENT, bold=True)
|
|
213
|
+
pdf.hyperlink.address = paper.pdf_url
|
|
214
|
+
# authors on a second line
|
|
215
|
+
if paper.authors:
|
|
216
|
+
authors = ", ".join(paper.authors[:6]) + (
|
|
217
|
+
"…" if len(paper.authors) > 6 else ""
|
|
218
|
+
)
|
|
219
|
+
a = tf.add_paragraph()
|
|
220
|
+
a.space_after = Pt(8)
|
|
221
|
+
style_run(a.add_run(), authors, 11, _MUTED)
|
|
222
|
+
|
|
223
|
+
bio = io.BytesIO()
|
|
224
|
+
prs.save(bio)
|
|
225
|
+
return bio.getvalue()
|
conflens/scraper.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Scraping logic for the ACL Anthology (and structurally compatible sites).
|
|
2
|
+
|
|
3
|
+
The Anthology is a static Hugo site with minified, mostly-unquoted HTML, so we
|
|
4
|
+
parse with targeted regexes rather than a full DOM parser to keep dependencies
|
|
5
|
+
light and behaviour predictable.
|
|
6
|
+
|
|
7
|
+
Two levels of pages matter:
|
|
8
|
+
|
|
9
|
+
* an *event* page (e.g. ``/events/acl-2024/``) lists every paper with its
|
|
10
|
+
title, landing-page URL and PDF link, but **not** its abstract; and
|
|
11
|
+
* a *paper* page (e.g. ``/2024.acl-long.1/``) carries the abstract plus clean
|
|
12
|
+
author metadata in ``<meta name="citation_author">`` tags.
|
|
13
|
+
|
|
14
|
+
Abstracts are fetched lazily and cached on disk so re-runs are cheap.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
import hashlib
|
|
21
|
+
import html
|
|
22
|
+
import http.client
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from typing import Callable, Optional
|
|
31
|
+
|
|
32
|
+
from .models import Paper
|
|
33
|
+
|
|
34
|
+
_UA = "conflens/0.1 (+https://github.com/picaultj/conflens)"
|
|
35
|
+
_DEFAULT_BASE = "https://aclanthology.org"
|
|
36
|
+
|
|
37
|
+
# A paper landing-page anchor, e.g. <a ... href=/2024.acl-long.1/>Title</a>
|
|
38
|
+
_PAPER_ANCHOR = re.compile(
|
|
39
|
+
r'<a[^>]*\bhref=(?P<q>["\']?)(?P<path>/(?P<id>\d{4}\.[a-z0-9]+-[a-z0-9]+\.\d+)/)(?P=q)[^>]*>'
|
|
40
|
+
r"(?P<title>.*?)</a>",
|
|
41
|
+
re.IGNORECASE | re.DOTALL,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_ABSTRACT = re.compile(
|
|
45
|
+
r'<div[^>]*class="[^"]*acl-abstract[^"]*"[^>]*>\s*'
|
|
46
|
+
r"(?:<h[0-9][^>]*>.*?</h[0-9]>)?\s*<span>(?P<abs>.*?)</span>",
|
|
47
|
+
re.IGNORECASE | re.DOTALL,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Author meta tags appear as `<meta content="Name" name=citation_author>` — note
|
|
51
|
+
# the content-before-name order and unquoted attribute names in the minified HTML.
|
|
52
|
+
_META_TAG = re.compile(r"<meta\b[^>]*>", re.IGNORECASE)
|
|
53
|
+
_META_CONTENT = re.compile(r'\bcontent=["\'](?P<v>[^"\']*)["\']', re.IGNORECASE)
|
|
54
|
+
_META_NAME = re.compile(r'\bname=["\']?citation_author["\']?', re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_authors(page: str) -> list[str]:
|
|
58
|
+
authors: list[str] = []
|
|
59
|
+
for tag in _META_TAG.findall(page):
|
|
60
|
+
if _META_NAME.search(tag):
|
|
61
|
+
m = _META_CONTENT.search(tag)
|
|
62
|
+
if m:
|
|
63
|
+
authors.append(html.unescape(m.group("v")))
|
|
64
|
+
return authors
|
|
65
|
+
|
|
66
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
67
|
+
_WS = re.compile(r"\s+")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _clean(text: str) -> str:
|
|
71
|
+
"""Strip tags, unescape entities and collapse whitespace."""
|
|
72
|
+
return _WS.sub(" ", html.unescape(_TAG.sub("", text))).strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AnthologyScraper:
|
|
76
|
+
"""Fetches and parses paper listings + abstracts."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
base_url: str = _DEFAULT_BASE,
|
|
81
|
+
cache_dir: Optional[str] = None,
|
|
82
|
+
timeout: int = 60,
|
|
83
|
+
max_workers: int = 8,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.base_url = base_url.rstrip("/")
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self.max_workers = max_workers
|
|
88
|
+
self.cache_dir = cache_dir or os.path.join(
|
|
89
|
+
os.path.expanduser("~"), ".cache", "conflens"
|
|
90
|
+
)
|
|
91
|
+
os.makedirs(self.cache_dir, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ #
|
|
94
|
+
# HTTP helpers
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
def _get(self, url: str) -> str:
|
|
97
|
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
|
98
|
+
last_err: Optional[Exception] = None
|
|
99
|
+
partial_best = ""
|
|
100
|
+
for attempt in range(4):
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
103
|
+
return resp.read().decode("utf-8", "replace")
|
|
104
|
+
except http.client.IncompleteRead as e:
|
|
105
|
+
# Large listing pages occasionally truncate. Retry for a complete
|
|
106
|
+
# read so we don't cache a partial listing; keep the longest
|
|
107
|
+
# partial seen as a last-resort fallback.
|
|
108
|
+
chunk = e.partial.decode("utf-8", "replace")
|
|
109
|
+
if len(chunk) > len(partial_best):
|
|
110
|
+
partial_best = chunk
|
|
111
|
+
last_err = e
|
|
112
|
+
time.sleep(1.0 * (attempt + 1))
|
|
113
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
114
|
+
last_err = e
|
|
115
|
+
time.sleep(1.5 * (attempt + 1))
|
|
116
|
+
if partial_best:
|
|
117
|
+
return partial_best
|
|
118
|
+
raise RuntimeError(f"Failed to fetch {url}: {last_err}")
|
|
119
|
+
|
|
120
|
+
def resolve_url(self, target: str) -> str:
|
|
121
|
+
"""Unified source interface — alias of :meth:`event_url`."""
|
|
122
|
+
return self.event_url(target)
|
|
123
|
+
|
|
124
|
+
def event_url(self, event: str) -> str:
|
|
125
|
+
"""Build an event URL from a slug, or pass through a full URL."""
|
|
126
|
+
event = event.strip()
|
|
127
|
+
if event.startswith("http://") or event.startswith("https://"):
|
|
128
|
+
return event
|
|
129
|
+
event = event.strip("/")
|
|
130
|
+
if event.startswith("events/"):
|
|
131
|
+
return f"{self.base_url}/{event}/"
|
|
132
|
+
return f"{self.base_url}/events/{event}/"
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
# Listing
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
def _listing_cache_path(self, key: str) -> str:
|
|
138
|
+
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:16]
|
|
139
|
+
return os.path.join(self.cache_dir, f"listing_{digest}.json")
|
|
140
|
+
|
|
141
|
+
def list_papers(
|
|
142
|
+
self,
|
|
143
|
+
event: str,
|
|
144
|
+
include_frontmatter: bool = False,
|
|
145
|
+
force_refresh: bool = False,
|
|
146
|
+
) -> list[Paper]:
|
|
147
|
+
"""Return every paper listed on an event page (without abstracts).
|
|
148
|
+
|
|
149
|
+
The parsed listing is cached on disk and keyed by event URL, so running
|
|
150
|
+
several analyses (e.g. for different themes) against the same event does
|
|
151
|
+
not re-download the multi-megabyte listing page. Pass
|
|
152
|
+
``force_refresh=True`` to bypass and rebuild the cache.
|
|
153
|
+
"""
|
|
154
|
+
url = self.event_url(event)
|
|
155
|
+
cache = self._listing_cache_path(url + ("|fm" if include_frontmatter else ""))
|
|
156
|
+
if not force_refresh and os.path.exists(cache):
|
|
157
|
+
try:
|
|
158
|
+
with open(cache, "r", encoding="utf-8") as fh:
|
|
159
|
+
data = json.load(fh)
|
|
160
|
+
return [
|
|
161
|
+
Paper(
|
|
162
|
+
paper_id=p["paper_id"],
|
|
163
|
+
title=p["title"],
|
|
164
|
+
url=p["url"],
|
|
165
|
+
pdf_url=p["pdf_url"],
|
|
166
|
+
)
|
|
167
|
+
for p in data.get("papers", [])
|
|
168
|
+
]
|
|
169
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
|
170
|
+
pass # corrupt/legacy cache — fall through and refetch
|
|
171
|
+
|
|
172
|
+
page = self._get(url)
|
|
173
|
+
papers = self.parse_listing(page, include_frontmatter=include_frontmatter)
|
|
174
|
+
try:
|
|
175
|
+
with open(cache, "w", encoding="utf-8") as fh:
|
|
176
|
+
json.dump(
|
|
177
|
+
{
|
|
178
|
+
"event_url": url,
|
|
179
|
+
"papers": [
|
|
180
|
+
{
|
|
181
|
+
"paper_id": p.paper_id,
|
|
182
|
+
"title": p.title,
|
|
183
|
+
"url": p.url,
|
|
184
|
+
"pdf_url": p.pdf_url,
|
|
185
|
+
}
|
|
186
|
+
for p in papers
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
fh,
|
|
190
|
+
)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
return papers
|
|
194
|
+
|
|
195
|
+
def parse_listing(self, page: str, include_frontmatter: bool = False) -> list[Paper]:
|
|
196
|
+
"""Parse an event listing page's HTML into papers (no network)."""
|
|
197
|
+
seen: set[str] = set()
|
|
198
|
+
papers: list[Paper] = []
|
|
199
|
+
for m in _PAPER_ANCHOR.finditer(page):
|
|
200
|
+
pid = m.group("id")
|
|
201
|
+
if pid in seen:
|
|
202
|
+
continue
|
|
203
|
+
# ``*.0`` entries are the proceedings front-matter, not real papers.
|
|
204
|
+
if pid.rsplit(".", 1)[-1] == "0" and not include_frontmatter:
|
|
205
|
+
continue
|
|
206
|
+
title = _clean(m.group("title"))
|
|
207
|
+
if not title:
|
|
208
|
+
continue
|
|
209
|
+
seen.add(pid)
|
|
210
|
+
papers.append(
|
|
211
|
+
Paper(
|
|
212
|
+
paper_id=pid,
|
|
213
|
+
title=title,
|
|
214
|
+
url=f"{self.base_url}/{pid}/",
|
|
215
|
+
pdf_url=f"{self.base_url}/{pid}.pdf",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return papers
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------ #
|
|
221
|
+
# Abstracts / metadata
|
|
222
|
+
# ------------------------------------------------------------------ #
|
|
223
|
+
@staticmethod
|
|
224
|
+
def parse_detail(page: str) -> tuple[str, list[str]]:
|
|
225
|
+
"""Parse a paper page's HTML into ``(abstract, authors)`` (no network)."""
|
|
226
|
+
m = _ABSTRACT.search(page)
|
|
227
|
+
abstract = _clean(m.group("abs")) if m else ""
|
|
228
|
+
return abstract, _extract_authors(page)
|
|
229
|
+
|
|
230
|
+
def _abstract_cache_path(self, pid: str) -> str:
|
|
231
|
+
safe = pid.replace("/", "_")
|
|
232
|
+
return os.path.join(self.cache_dir, f"{safe}.json")
|
|
233
|
+
|
|
234
|
+
def _fetch_detail(self, paper: Paper, force_refresh: bool = False) -> Paper:
|
|
235
|
+
cache = self._abstract_cache_path(paper.paper_id)
|
|
236
|
+
if not force_refresh and os.path.exists(cache):
|
|
237
|
+
try:
|
|
238
|
+
with open(cache, "r", encoding="utf-8") as fh:
|
|
239
|
+
data = json.load(fh)
|
|
240
|
+
paper.abstract = data.get("abstract", "")
|
|
241
|
+
paper.authors = data.get("authors", [])
|
|
242
|
+
return paper
|
|
243
|
+
except (OSError, json.JSONDecodeError):
|
|
244
|
+
pass
|
|
245
|
+
try:
|
|
246
|
+
page = self._get(paper.url)
|
|
247
|
+
except RuntimeError:
|
|
248
|
+
return paper
|
|
249
|
+
paper.abstract, paper.authors = self.parse_detail(page)
|
|
250
|
+
try:
|
|
251
|
+
with open(cache, "w", encoding="utf-8") as fh:
|
|
252
|
+
json.dump({"abstract": paper.abstract, "authors": paper.authors}, fh)
|
|
253
|
+
except OSError:
|
|
254
|
+
pass
|
|
255
|
+
return paper
|
|
256
|
+
|
|
257
|
+
def enrich_abstracts(
|
|
258
|
+
self,
|
|
259
|
+
papers: list[Paper],
|
|
260
|
+
progress: Optional[Callable[[int, int], None]] = None,
|
|
261
|
+
force_refresh: bool = False,
|
|
262
|
+
) -> list[Paper]:
|
|
263
|
+
"""Fetch abstracts + authors for ``papers`` concurrently (disk-cached)."""
|
|
264
|
+
total = len(papers)
|
|
265
|
+
done = 0
|
|
266
|
+
if progress:
|
|
267
|
+
progress(0, total)
|
|
268
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as ex:
|
|
269
|
+
futures = {
|
|
270
|
+
ex.submit(self._fetch_detail, p, force_refresh): p for p in papers
|
|
271
|
+
}
|
|
272
|
+
for fut in concurrent.futures.as_completed(futures):
|
|
273
|
+
fut.result()
|
|
274
|
+
done += 1
|
|
275
|
+
if progress:
|
|
276
|
+
progress(done, total)
|
|
277
|
+
return papers
|