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.
@@ -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