pubnetwork 0.1.0__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.
pubnet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PubNet — Publication network analyser for researchers."""
2
+
3
+ __version__ = "0.1.0"
pubnet/analyze.py ADDED
@@ -0,0 +1,391 @@
1
+ """Analysis modules for PubNet.
2
+
3
+ Each public function is a pure function:
4
+ (list[Publication], **config) → AnalysisResult
5
+
6
+ Modules:
7
+ - clean_publications: dedup, null-fill, normalise
8
+ - build_coauthor_graph: network analysis
9
+ - compute_citation_trends: yearly aggregation + rolling h-index
10
+ - cluster_topics: TF-IDF + k-means
11
+ - compute_stats: summary statistics
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from collections import Counter
19
+
20
+ from pubnet.models import (
21
+ Author,
22
+ CitationTrends,
23
+ CitationYear,
24
+ CoauthorEdge,
25
+ CoauthorGraph,
26
+ CoauthorNode,
27
+ Publication,
28
+ StatsSummary,
29
+ TopicAnalysis,
30
+ TopicCluster,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Data cleaning
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def clean_publications(publications: list[Publication]) -> list[Publication]:
41
+ """Clean and deduplicate a list of publications.
42
+
43
+ Steps:
44
+ 1. Fuzzy title dedup (rapidfuzz, threshold 90)
45
+ 2. Null-fill missing years/venues
46
+ 3. Normalise author names
47
+ 4. Sort by year descending (most recent first)
48
+ """
49
+ pubs = [p.model_copy() for p in publications] # don't mutate originals
50
+ pubs = _dedup_titles(pubs)
51
+ pubs = _fill_missing(pubs)
52
+ pubs = _normalise_authors(pubs)
53
+ pubs.sort(key=lambda p: (p.year or 0, p.citations), reverse=True)
54
+ return pubs
55
+
56
+
57
+ def _dedup_titles(pubs: list[Publication]) -> list[Publication]:
58
+ """Remove near-duplicate publications by fuzzy title matching.
59
+
60
+ When two titles are >90% similar, keep the one with more citations.
61
+ """
62
+ try:
63
+ from rapidfuzz import fuzz
64
+ except ImportError:
65
+ logger.warning("rapidfuzz not installed — skipping dedup")
66
+ return pubs
67
+
68
+ if len(pubs) <= 1:
69
+ return pubs
70
+
71
+ keep = []
72
+ removed_indices: set[int] = set()
73
+
74
+ for i, pub_a in enumerate(pubs):
75
+ if i in removed_indices:
76
+ continue
77
+ best = pub_a
78
+ for j in range(i + 1, len(pubs)):
79
+ if j in removed_indices:
80
+ continue
81
+ pub_b = pubs[j]
82
+ ratio = fuzz.ratio(
83
+ _normalise_title(pub_a.title),
84
+ _normalise_title(pub_b.title),
85
+ )
86
+ if ratio > 90:
87
+ # Keep the one with more citations
88
+ if pub_b.citations > best.citations:
89
+ best = pub_b
90
+ removed_indices.add(j)
91
+ logger.debug("Dedup: %r ≈ %r (%.0f%%)", pub_a.title, pub_b.title, ratio)
92
+ keep.append(best)
93
+
94
+ if removed_indices:
95
+ logger.info("Dedup removed %d duplicate(s) from %d publications", len(removed_indices), len(pubs))
96
+ return keep
97
+
98
+
99
+ def _normalise_title(title: str) -> str:
100
+ """Lowercase, strip punctuation for fuzzy comparison."""
101
+ return re.sub(r"[^a-z0-9\s]", "", title.lower()).strip()
102
+
103
+
104
+ def _fill_missing(pubs: list[Publication]) -> list[Publication]:
105
+ """Fill None values with sensible defaults."""
106
+ result = []
107
+ for pub in pubs:
108
+ updates = {}
109
+ if pub.venue is None:
110
+ updates["venue"] = "Unknown"
111
+ # Strip whitespace from venue
112
+ if pub.venue and pub.venue.strip() == "":
113
+ updates["venue"] = "Unknown"
114
+ # Year stays None — analysis modules handle it
115
+ if updates:
116
+ pub = pub.model_copy(update=updates)
117
+ result.append(pub)
118
+ return result
119
+
120
+
121
+ def _normalise_authors(pubs: list[Publication]) -> list[Publication]:
122
+ """Normalise author name formats for consistency.
123
+
124
+ Strips extra whitespace. More aggressive normalisation (e.g., merging
125
+ "J. Smith" and "John Smith") would need a name-matching heuristic that
126
+ risks false positives, so we keep it simple for now.
127
+ """
128
+ result = []
129
+ for pub in pubs:
130
+ cleaned = [_clean_author_name(a) for a in pub.authors if a.strip()]
131
+ if cleaned != pub.authors:
132
+ pub = pub.model_copy(update={"authors": cleaned})
133
+ result.append(pub)
134
+ return result
135
+
136
+
137
+ def _clean_author_name(name: str) -> str:
138
+ """Clean up a single author name."""
139
+ # Collapse whitespace
140
+ name = re.sub(r"\s+", " ", name).strip()
141
+ # Remove trailing/leading punctuation
142
+ name = name.strip(".,;:")
143
+ return name
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Co-author graph
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def build_coauthor_graph(
151
+ author: Author,
152
+ publications: list[Publication],
153
+ ) -> CoauthorGraph:
154
+ """Build a co-author network graph.
155
+
156
+ Nodes are people. Edges connect anyone who co-authored at least one paper.
157
+ Edge weight = number of shared papers.
158
+ """
159
+ ego_name = author.name
160
+ edge_map: dict[tuple[str, str], list[str]] = {}
161
+ author_papers: Counter[str] = Counter()
162
+ author_citations: Counter[str] = Counter()
163
+
164
+ for pub in publications:
165
+ authors = pub.authors
166
+ if not authors:
167
+ continue
168
+
169
+ for a in authors:
170
+ author_papers[a] += 1
171
+ author_citations[a] += pub.citations
172
+
173
+ # Build edges between ego and each co-author
174
+ for a in authors:
175
+ if a == ego_name:
176
+ continue
177
+ key = tuple(sorted([ego_name, a]))
178
+ if key not in edge_map:
179
+ edge_map[key] = []
180
+ edge_map[key].append(pub.title)
181
+
182
+ # Build edges between co-authors (not just ego-centric)
183
+ non_ego = [a for a in authors if a != ego_name]
184
+ for i, a in enumerate(non_ego):
185
+ for b in non_ego[i + 1:]:
186
+ key = tuple(sorted([a, b]))
187
+ if key not in edge_map:
188
+ edge_map[key] = []
189
+ edge_map[key].append(pub.title)
190
+
191
+ # Build node list
192
+ all_authors = set()
193
+ for a, b in edge_map:
194
+ all_authors.add(a)
195
+ all_authors.add(b)
196
+ all_authors.add(ego_name)
197
+
198
+ nodes = [
199
+ CoauthorNode(
200
+ name=name,
201
+ paper_count=author_papers.get(name, 0),
202
+ total_citations=author_citations.get(name, 0),
203
+ is_ego=(name == ego_name),
204
+ )
205
+ for name in sorted(all_authors)
206
+ ]
207
+
208
+ edges = [
209
+ CoauthorEdge(source=a, target=b, weight=len(papers), papers=papers)
210
+ for (a, b), papers in edge_map.items()
211
+ ]
212
+
213
+ # Compute average co-authors per paper
214
+ coauthor_counts = [len(p.authors) - 1 for p in publications if len(p.authors) > 1]
215
+ avg = sum(coauthor_counts) / len(coauthor_counts) if coauthor_counts else 0.0
216
+
217
+ return CoauthorGraph(
218
+ nodes=nodes,
219
+ edges=edges,
220
+ total_coauthors=len(all_authors) - 1, # exclude ego
221
+ avg_coauthors_per_paper=round(avg, 1),
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Citation trends
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def compute_citation_trends(publications: list[Publication]) -> CitationTrends:
230
+ """Aggregate citations and publications by year, with rolling h-index."""
231
+ pubs_with_year = [p for p in publications if p.year is not None]
232
+ if not pubs_with_year:
233
+ return CitationTrends()
234
+
235
+ years = sorted({p.year for p in pubs_with_year})
236
+ first_year, last_year = years[0], years[-1]
237
+
238
+ yearly = []
239
+ for year in range(first_year, last_year + 1):
240
+ year_pubs = [p for p in pubs_with_year if p.year == year]
241
+ cumulative_pubs = [p for p in pubs_with_year if p.year <= year]
242
+
243
+ yearly.append(CitationYear(
244
+ year=year,
245
+ citation_count=sum(p.citations for p in year_pubs),
246
+ publication_count=len(year_pubs),
247
+ cumulative_h_index=_compute_h_index(cumulative_pubs),
248
+ ))
249
+
250
+ return CitationTrends(
251
+ yearly=yearly,
252
+ first_year=first_year,
253
+ last_year=last_year,
254
+ )
255
+
256
+
257
+ def _compute_h_index(publications: list[Publication]) -> int:
258
+ """Compute h-index: largest h such that h papers have ≥ h citations."""
259
+ cites = sorted([p.citations for p in publications], reverse=True)
260
+ h = 0
261
+ for i, c in enumerate(cites):
262
+ if c >= i + 1:
263
+ h = i + 1
264
+ else:
265
+ break
266
+ return h
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Topic clustering
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def cluster_topics(
274
+ publications: list[Publication],
275
+ num_clusters: int = 5,
276
+ ) -> TopicAnalysis:
277
+ """Cluster publications by topic using TF-IDF + k-means.
278
+
279
+ Falls back gracefully to title-only if abstracts are missing.
280
+ """
281
+ try:
282
+ from sklearn.cluster import KMeans
283
+ from sklearn.feature_extraction.text import TfidfVectorizer
284
+ except ImportError:
285
+ logger.warning("scikit-learn not installed — skipping topic clustering")
286
+ return TopicAnalysis()
287
+
288
+ if len(publications) < num_clusters:
289
+ num_clusters = max(1, len(publications))
290
+
291
+ # Build text corpus: title + abstract (or title only)
292
+ corpus = []
293
+ valid_indices = []
294
+ for i, pub in enumerate(publications):
295
+ text = pub.title
296
+ if pub.abstract:
297
+ text = f"{pub.title}. {pub.abstract}"
298
+ if text.strip():
299
+ corpus.append(text)
300
+ valid_indices.append(i)
301
+
302
+ if len(corpus) < 2:
303
+ return TopicAnalysis()
304
+
305
+ # Adjust num_clusters if we have fewer documents
306
+ num_clusters = min(num_clusters, len(corpus))
307
+
308
+ vectorizer = TfidfVectorizer(
309
+ max_features=500,
310
+ stop_words="english",
311
+ max_df=0.85,
312
+ min_df=1,
313
+ )
314
+ tfidf_matrix = vectorizer.fit_transform(corpus)
315
+ feature_names = vectorizer.get_feature_names_out()
316
+
317
+ kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init=10)
318
+ labels = kmeans.fit_predict(tfidf_matrix)
319
+
320
+ # Extract top keywords per cluster from centroids
321
+ clusters = []
322
+ for cid in range(num_clusters):
323
+ centroid = kmeans.cluster_centers_[cid]
324
+ top_indices = centroid.argsort()[-5:][::-1]
325
+ keywords = [feature_names[idx] for idx in top_indices]
326
+
327
+ pub_indices = [valid_indices[i] for i, label in enumerate(labels) if label == cid]
328
+ total_cites = sum(publications[idx].citations for idx in pub_indices)
329
+
330
+ clusters.append(TopicCluster(
331
+ cluster_id=cid,
332
+ keywords=keywords,
333
+ publication_indices=pub_indices,
334
+ total_citations=total_cites,
335
+ publication_count=len(pub_indices),
336
+ ))
337
+
338
+ return TopicAnalysis(clusters=clusters, num_clusters=num_clusters)
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Summary statistics
343
+ # ---------------------------------------------------------------------------
344
+
345
+ def compute_stats(
346
+ author: Author,
347
+ publications: list[Publication],
348
+ impact_factors: dict[str, float | None] | None = None,
349
+ ) -> StatsSummary:
350
+ """Compute summary statistics for the profile."""
351
+ years = [p.year for p in publications if p.year is not None]
352
+ first_year = min(years) if years else None
353
+ last_year = max(years) if years else None
354
+
355
+ # Top venue by publication count
356
+ venue_counts = Counter(p.venue for p in publications if p.venue and p.venue != "Unknown")
357
+ top_venue, top_venue_count = venue_counts.most_common(1)[0] if venue_counts else (None, 0)
358
+
359
+ # Unique co-authors
360
+ all_coauthors = set()
361
+ for pub in publications:
362
+ for a in pub.authors:
363
+ if a != author.name:
364
+ all_coauthors.add(a)
365
+
366
+ # Average impact factor (from enriched data)
367
+ avg_if = None
368
+ if impact_factors:
369
+ known_ifs = [v for v in impact_factors.values() if v is not None]
370
+ if known_ifs:
371
+ avg_if = round(sum(known_ifs) / len(known_ifs), 1)
372
+
373
+ total_cites = sum(p.citations for p in publications)
374
+ years_str = ""
375
+ if first_year and last_year:
376
+ years_str = f"{first_year}–{last_year}" if first_year != last_year else str(first_year)
377
+
378
+ return StatsSummary(
379
+ total_publications=len(publications),
380
+ total_citations=total_cites,
381
+ h_index=author.h_index or _compute_h_index(publications),
382
+ i10_index=author.i10_index or sum(1 for p in publications if p.citations >= 10),
383
+ years_active=years_str,
384
+ first_pub_year=first_year,
385
+ last_pub_year=last_year,
386
+ top_venue=top_venue,
387
+ top_venue_count=top_venue_count,
388
+ unique_coauthors=len(all_coauthors),
389
+ avg_impact_factor=avg_if,
390
+ avg_citations_per_paper=round(total_cites / len(publications), 1) if publications else 0.0,
391
+ )
pubnet/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """PubNet CLI — publication network analyser.
2
+
3
+ Entry points:
4
+ pubnet analyze --scholar-url <url>
5
+ pubnet analyze --builtin
6
+ pubnet demo
7
+ pubnet gui
8
+ pubnet cache list | clear
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import logging
16
+
17
+ import click
18
+
19
+ from pubnet import __version__
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Logging
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _setup_logging(verbose: bool) -> None:
27
+ level = logging.DEBUG if verbose else logging.INFO
28
+ logging.basicConfig(
29
+ level=level,
30
+ format="%(levelname)s: %(message)s",
31
+ )
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Main group
36
+ # ---------------------------------------------------------------------------
37
+
38
+ @click.group()
39
+ @click.version_option(__version__, prog_name="pubnet")
40
+ def main():
41
+ """PubNet — Publication network analyser for researchers."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # analyze command
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @main.command()
49
+ @click.option("--scholar-url", default=None, help="Google Scholar profile URL.")
50
+ @click.option("--author-id", default=None, help="Google Scholar author ID.")
51
+ @click.option("--builtin", "use_builtin", is_flag=True, help="Use bundled demo profile.")
52
+ @click.option("--format", "ref_format", default="apa", type=click.Choice(["apa", "mla", "bibtex", "vancouver", "chicago"], case_sensitive=False), help="Reference format.")
53
+ @click.option("--topics", default=5, type=int, help="Number of topic clusters.")
54
+ @click.option("--output", "-o", default=None, type=click.Path(), help="Output HTML path.")
55
+ @click.option("--no-cache", is_flag=True, help="Force fresh Scholar fetch.")
56
+ @click.option("--crossref/--no-crossref", default=True, help="Enrich via Crossref API (corrects venue names, adds DOIs).")
57
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
58
+ def analyze(scholar_url, author_id, use_builtin, ref_format, topics, output, no_cache, crossref, verbose):
59
+ """Analyse a Scholar profile and generate an HTML report."""
60
+ _setup_logging(verbose)
61
+
62
+ from pubnet.fetch import fetch_profile, load_demo, FetchError
63
+ from pubnet.analyze import (
64
+ clean_publications,
65
+ build_coauthor_graph,
66
+ compute_citation_trends,
67
+ cluster_topics,
68
+ compute_stats,
69
+ )
70
+ from pubnet.formatters import format_reference
71
+
72
+ # --- Resolve data source ---
73
+ if use_builtin:
74
+ click.echo("Loading built-in demo profile...")
75
+ author = load_demo()
76
+ elif scholar_url:
77
+ click.echo("Fetching profile: " + scholar_url)
78
+ try:
79
+ author = fetch_profile(scholar_url, use_cache=not no_cache)
80
+ except FetchError as exc:
81
+ click.echo("Error: " + str(exc), err=True)
82
+ sys.exit(1)
83
+ elif author_id:
84
+ click.echo("Fetching profile: " + author_id)
85
+ try:
86
+ author = fetch_profile(author_id, use_cache=not no_cache)
87
+ except FetchError as exc:
88
+ click.echo("Error: " + str(exc), err=True)
89
+ sys.exit(1)
90
+ else:
91
+ click.echo("Error: provide --scholar-url, --author-id, or --builtin", err=True)
92
+ sys.exit(1)
93
+
94
+ # --- Clean ---
95
+ pubs = clean_publications(author.publications)
96
+ click.echo("Loaded %d publications for %s" % (len(pubs), author.name))
97
+
98
+ # --- Crossref enrichment (corrects venue names, adds DOIs) ---
99
+ if crossref:
100
+ from pubnet.crossref import enrich_publications as crossref_enrich
101
+ click.echo("Enriching via Crossref API (corrects venue names, adds DOIs)...")
102
+ cr_results = crossref_enrich(pubs, max_lookups=None)
103
+ corrections = 0
104
+ for idx, cr in cr_results.items():
105
+ if cr.venue_corrected and pubs[idx].venue:
106
+ old = pubs[idx].venue
107
+ if old != cr.venue_corrected and len(cr.venue_corrected) > 3:
108
+ pubs[idx].venue = cr.venue_corrected
109
+ corrections += 1
110
+ if corrections:
111
+ click.echo(" Corrected %d venue names via Crossref" % corrections)
112
+
113
+ # --- Journal IF lookup ---
114
+ from pubnet.journal_if import JournalIFLookup
115
+ click.echo("Looking up journal impact factors...")
116
+ if_lookup = JournalIFLookup()
117
+ impact_factors = if_lookup.enrich_publications(pubs)
118
+
119
+ # --- Analyse ---
120
+ click.echo("Running analysis...")
121
+ graph = build_coauthor_graph(author, pubs)
122
+ trends = compute_citation_trends(pubs)
123
+ topic_result = cluster_topics(pubs, num_clusters=topics)
124
+ stats = compute_stats(author, pubs, impact_factors=impact_factors)
125
+
126
+ # --- Print summary ---
127
+ click.echo()
128
+ click.echo(" " + author.name)
129
+ if author.affiliation:
130
+ click.echo(" " + author.affiliation)
131
+ click.echo(" " + "-" * 39)
132
+ click.echo(" Publications: %d" % stats.total_publications)
133
+ click.echo(" Total citations: %d" % stats.total_citations)
134
+ click.echo(" h-index: %d" % stats.h_index)
135
+ click.echo(" i10-index: %d" % stats.i10_index)
136
+ click.echo(" Years active: %s" % stats.years_active)
137
+ click.echo(" Co-authors: %d" % stats.unique_coauthors)
138
+ click.echo(" Top venue: %s (%d pubs)" % (stats.top_venue, stats.top_venue_count))
139
+ click.echo(" Avg cites/paper: %s" % stats.avg_citations_per_paper)
140
+ click.echo()
141
+
142
+ # --- Top publications ---
143
+ click.echo(" Top publications by citations:")
144
+ for pub in sorted(pubs, key=lambda p: p.citations, reverse=True)[:5]:
145
+ click.echo(" [%4d cites] %s" % (pub.citations, pub.title[:70]))
146
+ click.echo(" %s, %s" % (pub.venue or "Unknown", pub.year or "n.d."))
147
+ click.echo()
148
+
149
+ # --- Topic clusters ---
150
+ if topic_result.clusters:
151
+ click.echo(" Topic clusters (%d):" % topic_result.num_clusters)
152
+ for cluster in topic_result.clusters:
153
+ kw = ", ".join(cluster.keywords[:3])
154
+ click.echo(" Cluster %d: %s (%d pubs, %d cites)" % (
155
+ cluster.cluster_id, kw, cluster.publication_count, cluster.total_citations))
156
+ click.echo()
157
+
158
+ # --- Sample reference ---
159
+ if pubs:
160
+ top_pub = max(pubs, key=lambda p: p.citations)
161
+ click.echo(" Sample reference (%s):" % ref_format.upper())
162
+ click.echo(" %s" % format_reference(top_pub, style=ref_format))
163
+ click.echo()
164
+
165
+ # --- HTML report ---
166
+ from pubnet.report import render_report
167
+ from pathlib import Path
168
+
169
+ if not output:
170
+ safe_name = author.name.lower().replace(" ", "_")
171
+ output = "%s_pubnet.html" % safe_name
172
+
173
+ click.echo(" Generating HTML report -> %s" % output)
174
+ html = render_report(
175
+ author=author,
176
+ publications=pubs,
177
+ stats=stats,
178
+ coauthor_graph=graph,
179
+ citation_trends=trends,
180
+ topic_analysis=topic_result,
181
+ impact_factors=impact_factors,
182
+ )
183
+ Path(output).write_text(html, encoding="utf-8")
184
+ click.echo(" Done! Report saved to %s" % output)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # demo shortcut
189
+ # ---------------------------------------------------------------------------
190
+
191
+ @main.command()
192
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
193
+ @click.pass_context
194
+ def demo(ctx, verbose):
195
+ """Quick demo using the bundled profile (shortcut for analyze --builtin)."""
196
+ ctx.invoke(analyze, use_builtin=True, verbose=verbose)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # gui command
201
+ # ---------------------------------------------------------------------------
202
+
203
+ @main.command()
204
+ @click.option("--port", default=8050, type=int, help="Server port.")
205
+ @click.option("--scholar-url", default=None, help="Pre-load a Scholar profile URL.")
206
+ @click.option("--debug", is_flag=True, help="Enable Dash debug mode.")
207
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
208
+ def gui(port, scholar_url, debug, verbose):
209
+ """Launch the interactive Dash GUI."""
210
+ _setup_logging(verbose)
211
+ click.echo("Starting PubNet GUI on http://localhost:%d" % port)
212
+
213
+ from pubnet.gui.app import create_app
214
+ app = create_app(scholar_url=scholar_url)
215
+ app.run(port=port, debug=debug)
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # cache commands
220
+ # ---------------------------------------------------------------------------
221
+
222
+ @main.group()
223
+ def cache():
224
+ """Manage cached Scholar profiles."""
225
+
226
+
227
+ @cache.command("list")
228
+ def cache_list():
229
+ """List cached profiles."""
230
+ from pubnet.fetch import list_cached_profiles
231
+
232
+ profiles = list_cached_profiles()
233
+ if not profiles:
234
+ click.echo("No cached profiles.")
235
+ return
236
+ for p in profiles:
237
+ click.echo(" %s %s (%d pubs)" % (p["scholar_id"], p["name"], p["publications"]))
238
+
239
+
240
+ @cache.command("clear")
241
+ @click.confirmation_option(prompt="Delete all cached profiles?")
242
+ def cache_clear():
243
+ """Clear all cached profiles."""
244
+ from pubnet.fetch import clear_cache
245
+
246
+ count = clear_cache()
247
+ click.echo("Removed %d cached profile(s)." % count)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Entry point
252
+ # ---------------------------------------------------------------------------
253
+
254
+ if __name__ == "__main__":
255
+ main()