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 +3 -0
- pubnet/analyze.py +391 -0
- pubnet/cli.py +255 -0
- pubnet/crossref.py +180 -0
- pubnet/data/demo.json +457 -0
- pubnet/data/scimago.csv +4653 -0
- pubnet/fetch.py +277 -0
- pubnet/formatters.py +253 -0
- pubnet/gui/__init__.py +1 -0
- pubnet/gui/app.py +50 -0
- pubnet/gui/assets/style.css +331 -0
- pubnet/gui/assets/toggle_refs.js +19 -0
- pubnet/gui/callbacks.py +478 -0
- pubnet/gui/components/__init__.py +1 -0
- pubnet/gui/components/clusters.py +87 -0
- pubnet/gui/components/network.py +145 -0
- pubnet/gui/components/pub_table.py +164 -0
- pubnet/gui/components/pubs_per_year.py +61 -0
- pubnet/gui/components/stat_cards.py +46 -0
- pubnet/gui/components/trends.py +82 -0
- pubnet/gui/layouts.py +511 -0
- pubnet/journal_if.py +232 -0
- pubnet/models.py +137 -0
- pubnet/report.py +388 -0
- pubnet/templates/report.html +730 -0
- pubnetwork-0.1.0.dist-info/METADATA +144 -0
- pubnetwork-0.1.0.dist-info/RECORD +30 -0
- pubnetwork-0.1.0.dist-info/WHEEL +4 -0
- pubnetwork-0.1.0.dist-info/entry_points.txt +2 -0
- pubnetwork-0.1.0.dist-info/licenses/LICENSE +139 -0
pubnet/__init__.py
ADDED
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()
|