wtf-p 0.3.0 → 0.4.0
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.
- package/README.md +18 -0
- package/bin/commands/install-logic.js +6 -10
- package/bin/lib/analyze-impact.js +105 -0
- package/bin/lib/bib-format.js +161 -0
- package/bin/lib/bib-index.js +104 -0
- package/bin/lib/citation-fetcher.js +299 -0
- package/bin/lib/citation-ranker.js +133 -0
- package/bin/lib/manifest.js +18 -0
- package/bin/lib/scholar-lookup.js +188 -0
- package/bin/lib/semantic-scholar.js +184 -0
- package/package.json +1 -1
- package/vendors/claude/agents/wtfp/citation-expert.md +45 -0
- package/vendors/claude/agents/wtfp/citation-formatter.md +42 -0
- package/vendors/claude/agents/wtfp/citation-retriever.md +31 -0
- package/vendors/claude/commands/wtfp/analyze-bib.md +41 -52
- package/vendors/claude/commands/wtfp/check-refs.md +11 -9
- package/vendors/claude/commands/wtfp/new-paper.md +42 -81
- package/vendors/claude/commands/wtfp/research-gap.md +3 -4
- package/vendors/claude/mcp/research-server/package.json +13 -0
- package/vendors/claude/mcp/research-server/src/index.js +133 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const querystring = require('querystring');
|
|
3
|
+
const s2 = require('./semantic-scholar');
|
|
4
|
+
const scholar = require('./scholar-lookup');
|
|
5
|
+
const ranker = require('./citation-ranker');
|
|
6
|
+
const bibFormat = require('./bib-format');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WTF-P Citation Fetcher v0.4.0
|
|
10
|
+
*
|
|
11
|
+
* Orchestrates tiered search:
|
|
12
|
+
* 1. Semantic Scholar (Primary, Free)
|
|
13
|
+
* 2. SerpAPI (Optional, Seminal/Paid)
|
|
14
|
+
* 3. CrossRef (Fallback)
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node citation-fetcher.js "<query>" --intent=<intent> --year=<year>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// --- CrossRef Fallback ---
|
|
21
|
+
async function searchCrossRef(query, limit = 5) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const params = {
|
|
24
|
+
query: query,
|
|
25
|
+
rows: limit,
|
|
26
|
+
sort: 'relevance',
|
|
27
|
+
select: 'DOI,title,author,issued,type,container-title,volume,issue,page,abstract'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const options = {
|
|
31
|
+
hostname: 'api.crossref.org',
|
|
32
|
+
path: `/works?${querystring.stringify(params)}`,
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: {
|
|
35
|
+
'User-Agent': 'WTF-P/0.4.0 (citation-expert)'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const req = https.request(options, (res) => {
|
|
40
|
+
let data = '';
|
|
41
|
+
res.on('data', c => data += c);
|
|
42
|
+
res.on('end', () => {
|
|
43
|
+
if (res.statusCode !== 200) return resolve([]);
|
|
44
|
+
try {
|
|
45
|
+
const json = JSON.parse(data);
|
|
46
|
+
const items = json.message.items || [];
|
|
47
|
+
resolve(items.map(mapCrossRefToPaper));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
resolve([]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
req.on('error', () => resolve([]));
|
|
55
|
+
req.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mapCrossRefToPaper(item) {
|
|
60
|
+
const title = item.title ? item.title[0] : 'Untitled';
|
|
61
|
+
const year = item.issued && item.issued['date-parts'] ? item.issued['date-parts'][0][0] : null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
source: 'crossref',
|
|
65
|
+
title: title,
|
|
66
|
+
year: year,
|
|
67
|
+
doi: item.DOI,
|
|
68
|
+
venue: item['container-title'] ? item['container-title'][0] : null,
|
|
69
|
+
authors: (item.author || []).map(a => ({ name: `${a.family}, ${a.given}` })),
|
|
70
|
+
abstract: item.abstract ? item.abstract.replace(/<[^>]*>?/gm, '').trim() : null,
|
|
71
|
+
citationCount: 0, // CrossRef doesn't give citation counts easily
|
|
72
|
+
externalIds: { DOI: item.DOI }
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Mapper ---
|
|
77
|
+
|
|
78
|
+
function mapS2ToPaper(item) {
|
|
79
|
+
return {
|
|
80
|
+
source: 'semantic_scholar',
|
|
81
|
+
paperId: item.paperId,
|
|
82
|
+
title: item.title,
|
|
83
|
+
year: item.year,
|
|
84
|
+
venue: item.venue,
|
|
85
|
+
authors: item.authors || [],
|
|
86
|
+
citationCount: item.citationCount || 0,
|
|
87
|
+
abstract: item.abstract,
|
|
88
|
+
externalIds: item.externalIds || {},
|
|
89
|
+
doi: (item.externalIds && item.externalIds.DOI) || null,
|
|
90
|
+
openAccessPdf: item.openAccessPdf
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapScholarToPaper(item) {
|
|
95
|
+
return {
|
|
96
|
+
source: 'google_scholar',
|
|
97
|
+
scholarClusterId: item.clusterId,
|
|
98
|
+
title: item.title,
|
|
99
|
+
year: item.year,
|
|
100
|
+
venue: item.venue,
|
|
101
|
+
authors: item.authors || [],
|
|
102
|
+
citationCount: item.citationCount || 0,
|
|
103
|
+
abstract: item.snippet, // Use snippet as abstract fallback
|
|
104
|
+
externalIds: {},
|
|
105
|
+
doi: null, // Scholar doesn't reliably give DOIs
|
|
106
|
+
openAccessPdf: item.link ? { url: item.link } : null
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Deduplication ---
|
|
111
|
+
|
|
112
|
+
function fingerprint(title, year) {
|
|
113
|
+
if (!title) return `unknown::${Math.random()}`;
|
|
114
|
+
const normalized = title.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
|
115
|
+
return `${normalized}::${year || '????'}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function deduplicatePapers(papers) {
|
|
119
|
+
const seen = new Map();
|
|
120
|
+
|
|
121
|
+
for (const paper of papers) {
|
|
122
|
+
// Priority: DOI > S2ID > ScholarID > Fingerprint
|
|
123
|
+
const key = paper.doi
|
|
124
|
+
|| paper.paperId
|
|
125
|
+
|| paper.scholarClusterId
|
|
126
|
+
|| fingerprint(paper.title, paper.year);
|
|
127
|
+
|
|
128
|
+
if (!seen.has(key)) {
|
|
129
|
+
seen.set(key, paper);
|
|
130
|
+
} else {
|
|
131
|
+
// Merge: prefer richer metadata
|
|
132
|
+
// S2 usually has best metadata (abstracts, verified authors).
|
|
133
|
+
// Scholar has best citation counts.
|
|
134
|
+
// CrossRef has verified DOIs.
|
|
135
|
+
|
|
136
|
+
const existing = seen.get(key);
|
|
137
|
+
let merged = { ...existing };
|
|
138
|
+
|
|
139
|
+
// If new one is S2, take its metadata but keep higher citation count
|
|
140
|
+
if (paper.source === 'semantic_scholar') {
|
|
141
|
+
merged = { ...paper, citationCount: Math.max(existing.citationCount, paper.citationCount) };
|
|
142
|
+
if (existing.scholarClusterId) merged.scholarClusterId = existing.scholarClusterId;
|
|
143
|
+
}
|
|
144
|
+
// If new one is Scholar, just update citation count and maybe ID
|
|
145
|
+
else if (paper.source === 'google_scholar') {
|
|
146
|
+
merged.citationCount = Math.max(merged.citationCount, paper.citationCount);
|
|
147
|
+
merged.scholarClusterId = paper.clusterId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
seen.set(key, merged);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Array.from(seen.values());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Main Search Logic ---
|
|
158
|
+
|
|
159
|
+
async function search(query, options = {}) {
|
|
160
|
+
const limit = options.limit || 10;
|
|
161
|
+
const intent = options.intent || 'balanced';
|
|
162
|
+
|
|
163
|
+
let papers = [];
|
|
164
|
+
const errors = [];
|
|
165
|
+
|
|
166
|
+
const searchPromises = [];
|
|
167
|
+
|
|
168
|
+
// 1. S2 Search (Always)
|
|
169
|
+
searchPromises.push(
|
|
170
|
+
s2.search(query, { limit: limit * 2, year: options.year })
|
|
171
|
+
.then(res => res.map(mapS2ToPaper))
|
|
172
|
+
.catch(e => {
|
|
173
|
+
errors.push(`S2 Error: ${e.message}`);
|
|
174
|
+
return [];
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// 2. Scholar Search (Conditional)
|
|
179
|
+
if ((intent === 'seminal' || options.useScholar) && scholar.isAvailable()) {
|
|
180
|
+
searchPromises.push(
|
|
181
|
+
scholar.search(query, { limit: limit, yearLow: options.year, yearHigh: options.year })
|
|
182
|
+
.then(res => res.map(mapScholarToPaper))
|
|
183
|
+
.catch(e => {
|
|
184
|
+
errors.push(`Scholar Error: ${e.message}`);
|
|
185
|
+
return [];
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const results = await Promise.all(searchPromises);
|
|
191
|
+
results.forEach(r => papers.push(...r));
|
|
192
|
+
|
|
193
|
+
// 3. CrossRef Fallback (if few results)
|
|
194
|
+
if (papers.length < 5) {
|
|
195
|
+
try {
|
|
196
|
+
const crResults = await searchCrossRef(query, limit);
|
|
197
|
+
papers.push(...crResults);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
errors.push(`CrossRef Error: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 4. Deduplicate
|
|
204
|
+
const unique = deduplicatePapers(papers);
|
|
205
|
+
|
|
206
|
+
// 5. Rank
|
|
207
|
+
const ranked = ranker.rank(unique, intent);
|
|
208
|
+
|
|
209
|
+
// 6. Format to BibTeX
|
|
210
|
+
const formatted = ranked.slice(0, limit).map(p => {
|
|
211
|
+
// Generate key
|
|
212
|
+
const firstAuthor = p.authors && p.authors.length > 0
|
|
213
|
+
? (p.authors[0].name ? p.authors[0].name.split(',')[0].trim().split(' ').pop().toLowerCase() : 'unknown')
|
|
214
|
+
: 'unknown';
|
|
215
|
+
|
|
216
|
+
// Handle S2 author format variants or string parsing
|
|
217
|
+
let familyName = 'unknown';
|
|
218
|
+
if (p.authors && p.authors.length > 0) {
|
|
219
|
+
const nameParts = p.authors[0].name.split(' ');
|
|
220
|
+
familyName = nameParts[nameParts.length - 1].toLowerCase().replace(/[^a-z]/g, '');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const shortTitle = p.title.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
224
|
+
const key = `${familyName}${p.year || '????'}${shortTitle}`;
|
|
225
|
+
|
|
226
|
+
const bibData = {
|
|
227
|
+
key: key,
|
|
228
|
+
entryType: 'article',
|
|
229
|
+
author: p.authors.map(a => a.name).join(' and '),
|
|
230
|
+
title: p.title,
|
|
231
|
+
year: p.year ? p.year.toString() : null,
|
|
232
|
+
venue: p.venue,
|
|
233
|
+
booktitle: p.venue,
|
|
234
|
+
abstract: p.abstract,
|
|
235
|
+
doi: p.doi,
|
|
236
|
+
url: p.openAccessPdf ? p.openAccessPdf.url : (p.doi ? `https://doi.org/${p.doi}` : null),
|
|
237
|
+
google_scholar_id: p.scholarClusterId
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const provenance = {
|
|
241
|
+
wtfp_status: p.doi ? 'official' : 'partial',
|
|
242
|
+
wtfp_source: p.source,
|
|
243
|
+
wtfp_citations: p.citationCount,
|
|
244
|
+
wtfp_velocity: p.wtfp_velocity,
|
|
245
|
+
wtfp_s2_id: p.paperId,
|
|
246
|
+
wtfp_scholar_id: p.scholarClusterId
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
...p,
|
|
251
|
+
bibtex: bibFormat.format(bibData, provenance)
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
results: formatted,
|
|
257
|
+
metadata: {
|
|
258
|
+
query,
|
|
259
|
+
total: unique.length,
|
|
260
|
+
returned: formatted.length,
|
|
261
|
+
errors
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
// --- CLI Handling ---
|
|
268
|
+
|
|
269
|
+
if (require.main === module) {
|
|
270
|
+
const args = process.argv.slice(2);
|
|
271
|
+
const query = args.find(a => !a.startsWith('--'));
|
|
272
|
+
|
|
273
|
+
if (!query) {
|
|
274
|
+
console.error('Usage: node citation-fetcher.js "<query>" [--intent=seminal] [--year=2023]');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const intentArg = args.find(a => a.startsWith('--intent='));
|
|
279
|
+
const yearArg = args.find(a => a.startsWith('--year='));
|
|
280
|
+
const limitArg = args.find(a => a.startsWith('--limit='));
|
|
281
|
+
|
|
282
|
+
const options = {
|
|
283
|
+
intent: intentArg ? intentArg.split('=')[1] : 'balanced',
|
|
284
|
+
year: yearArg ? yearArg.split('=')[1] : null,
|
|
285
|
+
limit: limitArg ? parseInt(limitArg.split('=')[1]) : 10
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
search(query, options).then(result => {
|
|
289
|
+
// console.log(JSON.stringify(result, null, 2));
|
|
290
|
+
// For now, output just the bibtex entries as text for easy reading, or JSON?
|
|
291
|
+
// The previous fetcher output JSON. Let's stick to JSON array of results.
|
|
292
|
+
console.log(JSON.stringify(result.results, null, 2));
|
|
293
|
+
}).catch(e => {
|
|
294
|
+
console.error(e);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = { search };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation Ranker
|
|
3
|
+
*
|
|
4
|
+
* Ranks papers based on multidimensional impact score:
|
|
5
|
+
* - Citation Count (Log scaled)
|
|
6
|
+
* - Velocity (Citations per month)
|
|
7
|
+
* - Recency (Decay over time)
|
|
8
|
+
* - Venue (Tiered scoring)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Venue Tiers
|
|
12
|
+
const VENUE_TIERS = {
|
|
13
|
+
TIER_1: [
|
|
14
|
+
// AI/ML
|
|
15
|
+
"neurips", "nips", "icml", "iclr", "cvpr", "iccv", "eccv", "acl", "emnlp", "naacl",
|
|
16
|
+
"aaai", "ijcai", "kdd", "www", "sigir", "chi",
|
|
17
|
+
// Systems
|
|
18
|
+
"osdi", "sosp", "nsdi", "eurosys", "atc", "fast", "sigcomm", "mobicom",
|
|
19
|
+
// Journals
|
|
20
|
+
"nature", "science", "pnas", "cell", "lancet", "nejm", "jama",
|
|
21
|
+
"ieee transactions", "acm computing surveys", "jmlr", "journal of machine learning research"
|
|
22
|
+
],
|
|
23
|
+
TIER_2: [
|
|
24
|
+
"coling", "wsdm", "cikm", "pakdd", "ijcnn", "icra", "iros",
|
|
25
|
+
"middleware", "cloud", "sc", "hpdc", "cluster"
|
|
26
|
+
],
|
|
27
|
+
PREPRINT: [
|
|
28
|
+
"arxiv", "biorxiv", "medrxiv", "ssrn", "workshop"
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Score a venue name
|
|
34
|
+
* @param {string} venue
|
|
35
|
+
* @returns {number} 0.0 to 1.0
|
|
36
|
+
*/
|
|
37
|
+
function scoreVenue(venue) {
|
|
38
|
+
if (!venue) return 0.2;
|
|
39
|
+
const v = venue.toLowerCase();
|
|
40
|
+
|
|
41
|
+
if (VENUE_TIERS.TIER_1.some(t => v.includes(t))) return 1.0;
|
|
42
|
+
if (VENUE_TIERS.TIER_2.some(t => v.includes(t))) return 0.7;
|
|
43
|
+
if (VENUE_TIERS.PREPRINT.some(t => v.includes(t))) return 0.3;
|
|
44
|
+
|
|
45
|
+
return 0.5; // Unknown venue default
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calculate citation velocity
|
|
50
|
+
* @param {number} citations
|
|
51
|
+
* @param {number} year
|
|
52
|
+
* @returns {number} Citations per month
|
|
53
|
+
*/
|
|
54
|
+
function calculateVelocity(citations, year) {
|
|
55
|
+
if (!citations || !year) return 0;
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const currentYear = now.getFullYear();
|
|
58
|
+
const currentMonth = now.getMonth(); // 0-11
|
|
59
|
+
|
|
60
|
+
// Calculate months since publication (assuming Jan 1st of year)
|
|
61
|
+
// If current year, use months passed. If past year, (diff * 12) + currentMonth.
|
|
62
|
+
let monthsSince = (currentYear - year) * 12 + currentMonth;
|
|
63
|
+
|
|
64
|
+
// Guard against divide by zero or negative (future dates)
|
|
65
|
+
monthsSince = Math.max(1, monthsSince);
|
|
66
|
+
|
|
67
|
+
return citations / monthsSince;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Calculate impact score for a paper
|
|
72
|
+
* @param {Object} paper
|
|
73
|
+
* @param {string} intent "seminal" | "recent" | "specific" | "balanced"
|
|
74
|
+
*/
|
|
75
|
+
function calculateScore(paper, intent = "balanced") {
|
|
76
|
+
const now = new Date().getFullYear();
|
|
77
|
+
const year = paper.year || now;
|
|
78
|
+
const age = Math.max(0, now - year);
|
|
79
|
+
const citations = paper.citationCount || 0;
|
|
80
|
+
const velocity = calculateVelocity(citations, year);
|
|
81
|
+
|
|
82
|
+
// Normalize factors (approximate ranges)
|
|
83
|
+
// Citations: log10(100,000) = 5. So score is 0-1.
|
|
84
|
+
const citationScore = Math.min(1, Math.log10(citations + 1) / 5);
|
|
85
|
+
|
|
86
|
+
// Velocity: 100 cites/month is huge. Normalize to 0-1.
|
|
87
|
+
const velocityScore = Math.min(1, velocity / 100);
|
|
88
|
+
|
|
89
|
+
// Recency: Linear decay over 10 years. 0 if > 10 years old.
|
|
90
|
+
const recencyScore = Math.max(0, 1 - (age / 10));
|
|
91
|
+
|
|
92
|
+
const venueScore = scoreVenue(paper.venue);
|
|
93
|
+
|
|
94
|
+
// Weights based on intent
|
|
95
|
+
const weights = {
|
|
96
|
+
seminal: { citation: 0.6, velocity: 0.2, recency: 0.1, venue: 0.1 },
|
|
97
|
+
recent: { citation: 0.2, velocity: 0.3, recency: 0.4, venue: 0.1 },
|
|
98
|
+
specific: { citation: 0.1, velocity: 0.1, recency: 0.1, venue: 0.1 }, // Ranking less important for specific lookup
|
|
99
|
+
balanced: { citation: 0.4, velocity: 0.3, recency: 0.2, venue: 0.1 }
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const w = weights[intent] || weights.balanced;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
citationScore * w.citation +
|
|
106
|
+
velocityScore * w.velocity +
|
|
107
|
+
recencyScore * w.recency +
|
|
108
|
+
venueScore * w.venue
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rank a list of papers
|
|
114
|
+
* @param {Array} papers
|
|
115
|
+
* @param {string} intent
|
|
116
|
+
* @returns {Array} Sorted papers with score attached
|
|
117
|
+
*/
|
|
118
|
+
function rank(papers, intent = "balanced") {
|
|
119
|
+
const scored = papers.map(p => ({
|
|
120
|
+
...p,
|
|
121
|
+
wtfp_score: calculateScore(p, intent),
|
|
122
|
+
wtfp_velocity: calculateVelocity(p.citationCount, p.year)
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
return scored.sort((a, b) => b.wtfp_score - a.wtfp_score);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
rank,
|
|
130
|
+
calculateScore,
|
|
131
|
+
calculateVelocity,
|
|
132
|
+
scoreVenue
|
|
133
|
+
};
|
package/bin/lib/manifest.js
CHANGED
|
@@ -26,6 +26,24 @@ const MANIFEST = {
|
|
|
26
26
|
dest: 'skills/wtfp',
|
|
27
27
|
type: 'dir'
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
id: 'agents',
|
|
31
|
+
src: path.join(ROOT, 'vendors', 'claude', 'agents', 'wtfp'),
|
|
32
|
+
dest: 'agents/wtfp',
|
|
33
|
+
type: 'dir'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'mcp',
|
|
37
|
+
src: path.join(ROOT, 'vendors', 'claude', 'mcp'),
|
|
38
|
+
dest: 'mcp',
|
|
39
|
+
type: 'dir'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'scripts',
|
|
43
|
+
src: path.join(ROOT, 'bin', 'lib'),
|
|
44
|
+
dest: 'bin',
|
|
45
|
+
type: 'dir'
|
|
46
|
+
},
|
|
29
47
|
{
|
|
30
48
|
id: 'plugin',
|
|
31
49
|
src: path.join(ROOT, 'vendors', 'claude', '.claude-plugin'),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const querystring = require('querystring');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SerpAPI Google Scholar Wrapper
|
|
6
|
+
*
|
|
7
|
+
* Provides access to Google Scholar data via SerpAPI.
|
|
8
|
+
* Used for high-value "seminal" queries to get citation velocity and cluster IDs.
|
|
9
|
+
*
|
|
10
|
+
* Env:
|
|
11
|
+
* - SERPAPI_KEY: Required for operation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HOST = 'serpapi.com';
|
|
15
|
+
const PATH = '/search';
|
|
16
|
+
|
|
17
|
+
const usage = {
|
|
18
|
+
queries: 0,
|
|
19
|
+
// Default budget: 100 queries/month (~$5)
|
|
20
|
+
monthlyBudget: 100,
|
|
21
|
+
|
|
22
|
+
canQuery() {
|
|
23
|
+
return this.queries < this.monthlyBudget;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
recordQuery() {
|
|
27
|
+
this.queries++;
|
|
28
|
+
if (this.queries % 10 === 0) {
|
|
29
|
+
// console.error(`[Scholar] API usage: ${this.queries}/${this.monthlyBudget}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function getApiKey() {
|
|
35
|
+
return process.env.SERPAPI_KEY;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAvailable() {
|
|
39
|
+
return !!getApiKey();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function request(params) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const key = getApiKey();
|
|
45
|
+
if (!key) {
|
|
46
|
+
return reject(new Error('SERPAPI_KEY not set'));
|
|
47
|
+
}
|
|
48
|
+
if (!usage.canQuery()) {
|
|
49
|
+
return reject(new Error('Monthly budget exceeded'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
usage.recordQuery();
|
|
53
|
+
|
|
54
|
+
const q = querystring.stringify({
|
|
55
|
+
...params,
|
|
56
|
+
api_key: key,
|
|
57
|
+
engine: 'google_scholar'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const options = {
|
|
61
|
+
hostname: HOST,
|
|
62
|
+
path: `${PATH}?${q}`,
|
|
63
|
+
method: 'GET'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const req = https.request(options, (res) => {
|
|
67
|
+
let data = '';
|
|
68
|
+
res.on('data', c => data += c);
|
|
69
|
+
res.on('end', () => {
|
|
70
|
+
if (res.statusCode !== 200) {
|
|
71
|
+
return reject(new Error(`SerpAPI Error: ${res.statusCode} ${data}`));
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
resolve(JSON.parse(data));
|
|
75
|
+
} catch (e) {
|
|
76
|
+
reject(e);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
req.on('error', reject);
|
|
82
|
+
req.end();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Search Google Scholar
|
|
88
|
+
* @param {string} query
|
|
89
|
+
* @param {Object} options { yearLow, yearHigh, limit }
|
|
90
|
+
*/
|
|
91
|
+
async function search(query, options = {}) {
|
|
92
|
+
const params = {
|
|
93
|
+
q: query,
|
|
94
|
+
num: options.limit || 10
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (options.yearLow) params.as_ylo = options.yearLow;
|
|
98
|
+
if (options.yearHigh) params.as_yhi = options.yearHigh;
|
|
99
|
+
|
|
100
|
+
const data = await request(params);
|
|
101
|
+
return (data.organic_results || []).map(mapResult);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get paper by Cluster ID
|
|
106
|
+
* @param {string} clusterId
|
|
107
|
+
*/
|
|
108
|
+
async function getByClusterId(clusterId) {
|
|
109
|
+
const data = await request({ cluster: clusterId });
|
|
110
|
+
// When searching by cluster, it returns list of versions.
|
|
111
|
+
// We usually want the first one or metadata about the cluster.
|
|
112
|
+
// SerpAPI "cluster" usually returns "organic_results" which are the versions.
|
|
113
|
+
const results = data.organic_results || [];
|
|
114
|
+
if (results.length > 0) return mapResult(results[0]);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get citing papers
|
|
120
|
+
* @param {string} clusterId
|
|
121
|
+
* @param {number} limit
|
|
122
|
+
*/
|
|
123
|
+
async function getCitingPapers(clusterId, limit = 10) {
|
|
124
|
+
const data = await request({
|
|
125
|
+
cites: clusterId,
|
|
126
|
+
num: limit
|
|
127
|
+
});
|
|
128
|
+
return (data.organic_results || []).map(mapResult);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mapResult(item) {
|
|
132
|
+
// Extract cluster ID
|
|
133
|
+
let clusterId = null;
|
|
134
|
+
// Try to find it in links or specific fields if SerpAPI provides it directly
|
|
135
|
+
// SerpAPI usually puts it in inline_links -> cited_by -> serpapi_link (cites=ID)
|
|
136
|
+
// or versions -> serpapi_link (cluster=ID)
|
|
137
|
+
|
|
138
|
+
if (item.inline_links && item.inline_links.cited_by && item.inline_links.cited_by.serpapi_link) {
|
|
139
|
+
const match = item.inline_links.cited_by.serpapi_link.match(/cites=(\w+)/);
|
|
140
|
+
if (match) clusterId = match[1];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
source: 'google_scholar',
|
|
145
|
+
title: item.title,
|
|
146
|
+
clusterId: clusterId || item.cluster_id, // Sometimes provided directly
|
|
147
|
+
link: item.link,
|
|
148
|
+
snippet: item.snippet,
|
|
149
|
+
publication_info: item.publication_info,
|
|
150
|
+
citationCount: item.inline_links && item.inline_links.cited_by ? item.inline_links.cited_by.total : 0,
|
|
151
|
+
year: extractYear(item.publication_info ? item.publication_info.summary : ''),
|
|
152
|
+
authors: extractAuthors(item.publication_info ? item.publication_info.summary : ''),
|
|
153
|
+
venue: extractVenue(item.publication_info ? item.publication_info.summary : '')
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractYear(summary) {
|
|
158
|
+
const match = summary.match(/\b(19|20)\d{2}\b/);
|
|
159
|
+
return match ? parseInt(match[0]) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractAuthors(summary) {
|
|
163
|
+
// "A Author, B Author - Venue, 2020 - publisher"
|
|
164
|
+
if (!summary) return [];
|
|
165
|
+
const parts = summary.split('-');
|
|
166
|
+
if (parts.length > 0) {
|
|
167
|
+
return parts[0].split(',').map(s => ({ name: s.trim() }));
|
|
168
|
+
}
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractVenue(summary) {
|
|
173
|
+
if (!summary) return null;
|
|
174
|
+
const parts = summary.split('-');
|
|
175
|
+
if (parts.length > 1) {
|
|
176
|
+
// "Venue, Year" usually
|
|
177
|
+
return parts[1].split(',')[0].trim();
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
isAvailable,
|
|
184
|
+
search,
|
|
185
|
+
getByClusterId,
|
|
186
|
+
getCitingPapers,
|
|
187
|
+
usage
|
|
188
|
+
};
|