extract-cite 1.0.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 +56 -0
- package/dist/extract-cite.cjs.js +2 -0
- package/dist/extract-cite.cjs.js.map +1 -0
- package/dist/extract-cite.es.js +4 -0
- package/dist/extract-cite.es.js.map +1 -0
- package/dist/html-to-cite/extract-author.d.ts +11 -0
- package/dist/html-to-cite/extract-cite.d.ts +33 -0
- package/dist/html-to-cite/extract-date/date-extractors.d.ts +40 -0
- package/dist/html-to-cite/extract-date/date-validators.d.ts +15 -0
- package/dist/html-to-cite/extract-date/extract-date-quick.d.ts +8 -0
- package/dist/html-to-cite/extract-date/extract-date.d.ts +26 -0
- package/dist/html-to-cite/extract-source.d.ts +11 -0
- package/dist/html-to-cite/extract-title.d.ts +11 -0
- package/dist/html-to-cite/human-names-recognize.d.ts +16 -0
- package/dist/html-to-cite/metadata-to-cite.d.ts +16 -0
- package/dist/html-to-cite/url-to-domain.d.ts +20 -0
- package/dist/index.d.ts +14 -0
- package/dist/llm/call-llm.d.ts +33 -0
- package/dist/llm/extract-cite-llm.d.ts +19 -0
- package/dist/llm/format-citation.d.ts +22 -0
- package/dist/llm/index.d.ts +7 -0
- package/dist/llm/page-text.d.ts +21 -0
- package/dist/llm/parse-reply.d.ts +26 -0
- package/dist/llm/prompt.d.ts +14 -0
- package/dist/llm/types.d.ts +115 -0
- package/package.json +61 -0
- package/src/html-to-cite/extract-author.ts +125 -0
- package/src/html-to-cite/extract-cite.ts +97 -0
- package/src/html-to-cite/extract-date/date-extractors.ts +509 -0
- package/src/html-to-cite/extract-date/date-validators.ts +191 -0
- package/src/html-to-cite/extract-date/extract-date-quick.ts +184 -0
- package/src/html-to-cite/extract-date/extract-date.ts +1051 -0
- package/src/html-to-cite/extract-source.ts +35 -0
- package/src/html-to-cite/extract-title.ts +78 -0
- package/src/html-to-cite/human-names-92k.json +1 -0
- package/src/html-to-cite/human-names-recognize.ts +396 -0
- package/src/html-to-cite/metadata-to-cite.ts +77 -0
- package/src/html-to-cite/url-to-domain.ts +50 -0
- package/src/index.ts +14 -0
- package/src/llm/call-llm.ts +112 -0
- package/src/llm/extract-cite-llm.ts +100 -0
- package/src/llm/format-citation.ts +399 -0
- package/src/llm/index.ts +7 -0
- package/src/llm/page-text.ts +106 -0
- package/src/llm/parse-reply.ts +279 -0
- package/src/llm/prompt.ts +95 -0
- package/src/llm/types.ts +174 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Advanced human name recognition and normalization utility.
|
|
3
|
+
* Distinguishes between person names and organizations using a 92k-entry name database.
|
|
4
|
+
*/
|
|
5
|
+
import dataHumanNames from "./human-names-92k.json";
|
|
6
|
+
|
|
7
|
+
// Common organization terms for detection - extended list from provided code
|
|
8
|
+
const TERMS_ORG =
|
|
9
|
+
"abc,ag,ap,academy,advisors,agency,airbnb,amazon,america,american,apple,associated,association,atlantic," +
|
|
10
|
+
"attorneys,authority,axel,bank,baptiste,bbc,bertelsmann,blackrock,bloomberg,bmw,boston,broadcasting,bureau," +
|
|
11
|
+
"business,buzzfeed,cambridge,capital,cbs,center,chase,chicago,china,church,citigroup,clinic,club,cnn,coca-cola," +
|
|
12
|
+
"college,commission,communications,cond\u00e9,consulting,corp,corps,costco,daily,department,der,deutsche,division,dow," +
|
|
13
|
+
"economist,enterprises,eu,european,fabrication,facebook,fargo,ferrari,financial,ford,forbes,fox,france,fund," +
|
|
14
|
+
"gannett,general,global,globe,gm,gmbh,goldman,google,group,guardian,harvard,hearst,herald,hill,holdings,home," +
|
|
15
|
+
"honda,hospital,huffington,ibm,inc,industries,institute,intel,international,investments,jazeera,japan,jones," +
|
|
16
|
+
"jpmorgan,lancet,laboratories,law,legal,linkedin,llc,los,ltd,manufacturing,macy's,mcdonald's,media,medical," +
|
|
17
|
+
"mercedes-benz,meta,microsoft,ministry,mit,morgan,mosque,msnbc,nast,national,nato,nbc,netflix,news,newsweek," +
|
|
18
|
+
"new,nike,nordstrom,npr,ny,organization,oxford,pa\u00eds,partners,pbs,pentagon,plc,politico,porsche,post,press," +
|
|
19
|
+
"productions,r&d,regiment,retail,reuters,research,rt,sachs,school,science,scientific,securities,services,silicon," +
|
|
20
|
+
"society,solutions,south,spacex,spiegel,springer,stanford,stanley,starbucks,straits,studios,sydney,synagogue," +
|
|
21
|
+
"systems,target,team,tech,techcrunch,temple,tesla,the,thomson,times,toronto,toyota,trust,twitter,uber,union," +
|
|
22
|
+
"united,university,usa,valley,vanguard,vice,volkswagen,volvo,vox,wall,walmart,welle,wells,who,white,wired," +
|
|
23
|
+
"worldwide,works,world,wsj,york,yorker";
|
|
24
|
+
|
|
25
|
+
// Qualification terms that might suggest the name belongs to a person rather than an organization
|
|
26
|
+
const TERMS_QUALIFICATIONS =
|
|
27
|
+
"is,senior,associate,professor,fellow,assistant,lecturer,ceo,staff,strategist,specialist,worked,directed," +
|
|
28
|
+
"correspondent,president,author,director,prof,asst,editor,analyst,degree,administrator,served,member," +
|
|
29
|
+
"institute,economist,reporter,head,heads,newspaper,deputy,advocate,colonel,officer,founder,founded,visiting," +
|
|
30
|
+
"journalist,former,retired,expert,executive,manager,doctoral,candidate,chief,contributor,student,blogger," +
|
|
31
|
+
"chair,chairman,major,general,ambassador,phd,secretary,physicist,engineer,research,office,school,department," +
|
|
32
|
+
"writer,teacher,advisor,award,center,commentator,rand,brookings,heritage,cato,un,aei,forbes,nyt,cbo";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses a full name into its component parts:
|
|
36
|
+
* Title, Firstname, Prefix, Middle, Lastname, Honorific, Alias
|
|
37
|
+
* https://en.wikipedia.org/wiki/List_of_family_name_affixes
|
|
38
|
+
* @param {string} input - The full name to parse.
|
|
39
|
+
* @returns {Object}
|
|
40
|
+
*/
|
|
41
|
+
const extractHumanNameParts = (input) => {
|
|
42
|
+
// Initialize the result object
|
|
43
|
+
const result = {
|
|
44
|
+
prefix: "", //van der von de
|
|
45
|
+
firstname: "",
|
|
46
|
+
middle: "",
|
|
47
|
+
lastname: "",
|
|
48
|
+
honorific: "", //Jr Phd II
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Input validation
|
|
52
|
+
if (!input || typeof input !== "string") {
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Trim input and determine case fixing mode
|
|
57
|
+
input = input.trim();
|
|
58
|
+
const shouldFixCase =
|
|
59
|
+
input === input.toUpperCase() || input === input.toLowerCase();
|
|
60
|
+
|
|
61
|
+
// Define lists for parsing
|
|
62
|
+
const lists = {
|
|
63
|
+
honorific: ["esq", "esquire", "jr", "sr", "ii", "iii", "iv", "phd",
|
|
64
|
+
"md", "ms", "mrs", "mr", "miss", "dr"],
|
|
65
|
+
prefix: ["de", "van", "von", "der", "den", "vel", "le", "la", "da"],
|
|
66
|
+
title: ["mr", "mrs", "ms", "miss", "dr", "rev", "prof"],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Extract alias
|
|
70
|
+
const aliasRegex =
|
|
71
|
+
/\s(['']([^'']+)['']|[""]([^""]+)[""]|\[([^\]]+)\]|\(([^\)]+)\)),?\s/g;
|
|
72
|
+
const aliasMatch = input.match(aliasRegex);
|
|
73
|
+
if (aliasMatch) {
|
|
74
|
+
input = input.replace(aliasRegex, " ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Split the name into parts
|
|
78
|
+
let parts = input.split(/\s+/);
|
|
79
|
+
|
|
80
|
+
// Extract honorific
|
|
81
|
+
const honorificIndex = parts.findIndex((part) =>
|
|
82
|
+
lists.honorific.includes(part.toLowerCase().replace(/\.$/, ""))
|
|
83
|
+
);
|
|
84
|
+
if (honorificIndex !== -1) {
|
|
85
|
+
result.honorific = parts.splice(honorificIndex).join(", ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Extract title
|
|
89
|
+
const titleIndex = parts.findIndex((part) =>
|
|
90
|
+
lists.title.includes(part.toLowerCase().replace(/\.$/, ""))
|
|
91
|
+
);
|
|
92
|
+
if (titleIndex !== -1) {
|
|
93
|
+
result.prefix = parts.splice(titleIndex, 1)[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Join prefixes to following name parts
|
|
97
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
98
|
+
if (lists.prefix.includes(parts[i]?.toLowerCase())) {
|
|
99
|
+
parts[i] += " " + parts[i + 1];
|
|
100
|
+
parts.splice(i + 1, 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract lastname name (if comma present)
|
|
105
|
+
const commaIndex = parts.findIndex((part) => part.endsWith(","));
|
|
106
|
+
if (commaIndex !== -1) {
|
|
107
|
+
result.lastname = parts
|
|
108
|
+
.splice(0, commaIndex + 1)
|
|
109
|
+
.join(" ")
|
|
110
|
+
.replace(/,$/, "");
|
|
111
|
+
} else {
|
|
112
|
+
result.lastname = parts.pop();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Assign remaining parts to firstname and middle names
|
|
116
|
+
if (parts.length > 0) {
|
|
117
|
+
result.firstname = parts.shift();
|
|
118
|
+
if (parts.length > 0) {
|
|
119
|
+
result.middle = parts.join(" ");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fix case if needed
|
|
124
|
+
if (shouldFixCase) {
|
|
125
|
+
Object.keys(result).forEach((key) => {
|
|
126
|
+
if (result[key]) {
|
|
127
|
+
result[key] = result[key]
|
|
128
|
+
.split(" ")
|
|
129
|
+
.map(
|
|
130
|
+
(word) => word.charAt(0).toUpperCase() + word.slice(1)?.toLowerCase()
|
|
131
|
+
)
|
|
132
|
+
.join(" ");
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export interface ExtractHumanNameOptions {
|
|
141
|
+
formatCiteShortenAuthor?: boolean;
|
|
142
|
+
maxAuthorsBeforeEtAl?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validates and formats author names properly handling multiple authors and multi-word names
|
|
147
|
+
*
|
|
148
|
+
* @param {string} author - The author name string(s) to be processed
|
|
149
|
+
* @param {ExtractHumanNameOptions} [options={}] - Configuration options
|
|
150
|
+
* @returns {object} Formatted author information for citation
|
|
151
|
+
*/
|
|
152
|
+
export function extractHumanName(
|
|
153
|
+
author: string,
|
|
154
|
+
options: ExtractHumanNameOptions = {}
|
|
155
|
+
) {
|
|
156
|
+
const { formatCiteShortenAuthor = false, maxAuthorsBeforeEtAl = 2 } = options;
|
|
157
|
+
|
|
158
|
+
if (!author || !author.split) {
|
|
159
|
+
return { author_cite: "", author_short: "", author_type: 4 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Clean up the input string
|
|
163
|
+
author = author.trim()
|
|
164
|
+
.replace(/^by:?\s*/i, "") // Remove "by:" prefix
|
|
165
|
+
.replace(/\s{2,}/g, " "); // Normalize spaces
|
|
166
|
+
|
|
167
|
+
// Split into multiple authors if present
|
|
168
|
+
const authorNames = splitMultipleAuthors(author);
|
|
169
|
+
|
|
170
|
+
if (authorNames.length === 0) {
|
|
171
|
+
return { author_cite: "", author_short: "", author_type: 4 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Process each author
|
|
175
|
+
const processedAuthors = authorNames.map(authorName => {
|
|
176
|
+
// Check if name is likely an organization
|
|
177
|
+
const isOrg = isOrganization(authorName);
|
|
178
|
+
|
|
179
|
+
// Extract name parts using the provided function
|
|
180
|
+
const nameParts = extractHumanNameParts(authorName);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
original: authorName,
|
|
184
|
+
nameParts,
|
|
185
|
+
isOrg
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Determine overall author type
|
|
190
|
+
let authorType = 4; // Default to unknown
|
|
191
|
+
|
|
192
|
+
if (processedAuthors.length === 1) {
|
|
193
|
+
authorType = processedAuthors[0].isOrg ? 3 : 0; // 3 = org, 0 = single
|
|
194
|
+
} else if (processedAuthors.length === 2) {
|
|
195
|
+
authorType = 1; // two-author
|
|
196
|
+
} else if (processedAuthors.length > 2) {
|
|
197
|
+
authorType = 2; // more-than-two
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Format authors for citation
|
|
201
|
+
const formattedAuthors = processedAuthors.map(author => {
|
|
202
|
+
if (author.isOrg) {
|
|
203
|
+
// Don't reverse organization names
|
|
204
|
+
const maxOrgNameLength = 60;
|
|
205
|
+
let orgName = author.original;
|
|
206
|
+
if (orgName.length > maxOrgNameLength) {
|
|
207
|
+
orgName = orgName.substring(0, orgName.slice(0, maxOrgNameLength).lastIndexOf(" "));
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
cite: orgName,
|
|
211
|
+
short: orgName
|
|
212
|
+
};
|
|
213
|
+
} else {
|
|
214
|
+
// Format human name using name parts
|
|
215
|
+
const nameParts = author.nameParts;
|
|
216
|
+
|
|
217
|
+
// Handle empty or malformed name parts
|
|
218
|
+
if (!nameParts || !nameParts.lastname) {
|
|
219
|
+
return { cite: author.original, short: author.original };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let formattedFirstName = nameParts.firstname;
|
|
223
|
+
|
|
224
|
+
// Include middle name with first name if present
|
|
225
|
+
if (nameParts.middle) {
|
|
226
|
+
formattedFirstName += " " + nameParts.middle;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Add prefix to last name if present
|
|
230
|
+
let lastName = nameParts.lastname;
|
|
231
|
+
if (nameParts.prefix) {
|
|
232
|
+
lastName = `${nameParts.prefix} ${lastName}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Shorten first name if option is set
|
|
236
|
+
if (formatCiteShortenAuthor && formattedFirstName) {
|
|
237
|
+
formattedFirstName = formattedFirstName
|
|
238
|
+
.split(/\s+/)
|
|
239
|
+
.map(part => part[0] + ".")
|
|
240
|
+
.join(" ");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Add honorific if present
|
|
244
|
+
let cite = `${lastName}, ${formattedFirstName}`.trim();
|
|
245
|
+
if (nameParts.honorific) {
|
|
246
|
+
cite += `, ${nameParts.honorific}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
cite: cite.replace(/,\s*$/, ""),
|
|
251
|
+
short: lastName
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Generate citation strings
|
|
257
|
+
let authorCite, authorShort;
|
|
258
|
+
|
|
259
|
+
if (authorType === 0 || authorType === 3) {
|
|
260
|
+
// Single author or organization
|
|
261
|
+
authorCite = formattedAuthors[0].cite;
|
|
262
|
+
authorShort = formattedAuthors[0].short;
|
|
263
|
+
} else if (authorType === 1) {
|
|
264
|
+
// Two authors
|
|
265
|
+
authorCite = `${formattedAuthors[0].cite} & ${formattedAuthors[1].cite}`;
|
|
266
|
+
authorShort = `${formattedAuthors[0].short} & ${formattedAuthors[1].short}`;
|
|
267
|
+
} else if (authorType === 2) {
|
|
268
|
+
// More than two authors
|
|
269
|
+
if (processedAuthors.length <= maxAuthorsBeforeEtAl) {
|
|
270
|
+
// List all authors with commas and "and" before the last one
|
|
271
|
+
const lastAuthor = formattedAuthors.pop();
|
|
272
|
+
authorCite = formattedAuthors.map(a => a.cite).join(", ");
|
|
273
|
+
if (lastAuthor) {
|
|
274
|
+
authorCite += ` & ${lastAuthor.cite}`;
|
|
275
|
+
}
|
|
276
|
+
authorShort = `${formattedAuthors[0].short} et al.`;
|
|
277
|
+
} else {
|
|
278
|
+
// Use et al. format
|
|
279
|
+
authorCite = `${formattedAuthors[0].cite} et al.`;
|
|
280
|
+
authorShort = `${formattedAuthors[0].short} et al.`;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Unknown/error case
|
|
284
|
+
authorCite = author;
|
|
285
|
+
authorShort = author;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
author_cite: authorCite,
|
|
290
|
+
author_short: authorShort,
|
|
291
|
+
author_type: authorType
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Splits a string containing multiple authors into individual author names
|
|
297
|
+
*
|
|
298
|
+
* @param {string} authorString - String potentially containing multiple authors
|
|
299
|
+
* @returns {string[]} Array of individual author names
|
|
300
|
+
*/
|
|
301
|
+
function splitMultipleAuthors(authorString) {
|
|
302
|
+
if (!authorString) return [];
|
|
303
|
+
|
|
304
|
+
// Remove "et al." since we're parsing actual authors
|
|
305
|
+
authorString = authorString.replace(/\s+et\s+al\.?/gi, "");
|
|
306
|
+
|
|
307
|
+
// Handle common formatting patterns for multiple authors
|
|
308
|
+
|
|
309
|
+
// Pattern 1: Last, First & Last, First
|
|
310
|
+
if (/\w+,\s*\w+\s*&\s*\w+,\s*\w+/.test(authorString)) {
|
|
311
|
+
return authorString.split(/\s*&\s*/);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Pattern 2: Last, First, Last, First, and Last, First
|
|
315
|
+
if (/\w+,\s*\w+,\s*\w+,\s*\w+/.test(authorString)) {
|
|
316
|
+
// Replace the last comma+and with a standard separator
|
|
317
|
+
authorString = authorString.replace(/,\s*(and|&)\s*(?=[^,]*$)/, " & ");
|
|
318
|
+
return authorString.split(/\s*,\s*(?=[^,]*(?:,|$))/).map(s => s.trim());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Pattern 3: First Last, First Last, and First Last
|
|
322
|
+
if (/\w+\s\w+,\s\w+\s\w+/.test(authorString)) {
|
|
323
|
+
// Replace the last comma+and with a standard separator
|
|
324
|
+
authorString = authorString.replace(/,\s*(and|&)\s*(?=[^,]*$)/, " & ");
|
|
325
|
+
return authorString.split(/\s*,\s*/).map(s => s.trim());
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Pattern 4: First Last and First Last
|
|
329
|
+
if (/\w+\s\w+\s(and|&)\s\w+\s\w+/.test(authorString)) {
|
|
330
|
+
return authorString.split(/\s+(and|&)\s+/).map(s => s.trim());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Default pattern - try to split by various separators
|
|
334
|
+
// Replace common author separators with a standard one for easier processing
|
|
335
|
+
authorString = authorString
|
|
336
|
+
.replace(/\s+and\s+/gi, " & ")
|
|
337
|
+
.replace(/\s*[,;]\s*(?!(?:[^(]*\)))/g, " & "); // Replace commas/semicolons outside parentheses
|
|
338
|
+
|
|
339
|
+
// Split by the standard separator
|
|
340
|
+
return authorString.split(/\s*&\s*/).filter(author => author.trim().length > 0);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Determines if a name string represents an organization rather than a person
|
|
345
|
+
*
|
|
346
|
+
* @param {string} nameString - The name to analyze
|
|
347
|
+
* @returns {boolean} True if the name appears to be an organization
|
|
348
|
+
*/
|
|
349
|
+
function isOrganization(nameString) {
|
|
350
|
+
if (!nameString) return false;
|
|
351
|
+
|
|
352
|
+
// Convert organization terms to array
|
|
353
|
+
const orgTerms = TERMS_ORG.split(",");
|
|
354
|
+
const qualTerms = TERMS_QUALIFICATIONS.split(",");
|
|
355
|
+
|
|
356
|
+
// Clean and normalize the name string
|
|
357
|
+
const nameLower = nameString.toLowerCase().replace(/[^\w\s]/g, " ");
|
|
358
|
+
const words = nameLower.split(/\s+/);
|
|
359
|
+
|
|
360
|
+
// Check for organization terms
|
|
361
|
+
for (const word of words) {
|
|
362
|
+
if (orgTerms.includes(word)) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check for qualification terms that suggest it's a person
|
|
368
|
+
for (const word of words) {
|
|
369
|
+
if (qualTerms.includes(word)) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Look for name patterns that suggest it's a person
|
|
375
|
+
if (/,\s*\w+/.test(nameString)) { // Has comma format like "Smith, John"
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// If there are more than 4 words and no commas, it's likely an organization
|
|
380
|
+
if (words.length > 4 && !nameString.includes(",")) {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check if the name contains any human name parts according to our database
|
|
385
|
+
let hasHumanNamePart = false;
|
|
386
|
+
for (const word of words) {
|
|
387
|
+
const nameTitle = word[0]?.toUpperCase() + word.slice(1)?.toLowerCase();
|
|
388
|
+
if (dataHumanNames[nameTitle] === 1 || dataHumanNames[nameTitle] === 2) {
|
|
389
|
+
hasHumanNamePart = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// If no human name parts found and more than 2 words, likely an organization
|
|
395
|
+
return !hasHumanNamePart && words.length > 2;
|
|
396
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Extracts citation metadata (author, date, title, source) from
|
|
3
|
+
* a document's `<meta>` tags using a list of commonly used property/name attributes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CiteMetadata {
|
|
7
|
+
author?: string;
|
|
8
|
+
date?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract cite info from common property names in webpage's metadata
|
|
15
|
+
* @param {Document} doc dom object of document
|
|
16
|
+
* @returns {CiteMetadata} author, date, title, source
|
|
17
|
+
*/
|
|
18
|
+
export function extractCiteFromMetadata(doc: Document): CiteMetadata {
|
|
19
|
+
if (!doc) return null;
|
|
20
|
+
|
|
21
|
+
const commonCiteMetaTags = {
|
|
22
|
+
source: ["application-name", "og:site_name", "twitter:site", "dc.title"],
|
|
23
|
+
title: ["title", "og:title", "twitter:title", "parsely-title"],
|
|
24
|
+
author: [
|
|
25
|
+
"author",
|
|
26
|
+
"creator",
|
|
27
|
+
"og:creator",
|
|
28
|
+
"article:author",
|
|
29
|
+
"dc.creator",
|
|
30
|
+
"parsely-author",
|
|
31
|
+
],
|
|
32
|
+
date: [
|
|
33
|
+
"article:published_time",
|
|
34
|
+
"article:modified_time",
|
|
35
|
+
"og:updated_time",
|
|
36
|
+
"dc.date",
|
|
37
|
+
"dc.date.issued",
|
|
38
|
+
"dc.date.created",
|
|
39
|
+
"dc:created",
|
|
40
|
+
"dcterms.date",
|
|
41
|
+
"datepublished",
|
|
42
|
+
"datemodified",
|
|
43
|
+
"updated_time",
|
|
44
|
+
"modified_time",
|
|
45
|
+
"published_time",
|
|
46
|
+
"release_date",
|
|
47
|
+
"date",
|
|
48
|
+
"parsely-pub-date",
|
|
49
|
+
"article:published",
|
|
50
|
+
"article:published_time",
|
|
51
|
+
"og:pubdate",
|
|
52
|
+
"pubdate",
|
|
53
|
+
"date",
|
|
54
|
+
"dateCreated",
|
|
55
|
+
"pdate",
|
|
56
|
+
"sailthru.date",
|
|
57
|
+
"dcterms.created",
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = {};
|
|
62
|
+
|
|
63
|
+
Array.from(doc.getElementsByTagName("meta")).forEach((metaElem) => {
|
|
64
|
+
const property =
|
|
65
|
+
metaElem.getAttribute("property") || metaElem.getAttribute("itemprop");
|
|
66
|
+
const name = metaElem.getAttribute("name");
|
|
67
|
+
|
|
68
|
+
for (const [key, attrs] of Object.entries(commonCiteMetaTags))
|
|
69
|
+
if (
|
|
70
|
+
metaElem.getAttribute("content") &&
|
|
71
|
+
(attrs.includes(property?.toLowerCase()) ||
|
|
72
|
+
attrs.includes(name?.toLowerCase()))
|
|
73
|
+
)
|
|
74
|
+
result[key] = metaElem.getAttribute("content");
|
|
75
|
+
});
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Utility for extracting and normalizing domain names from URLs.
|
|
4
|
+
* Handles subdomains and TLD cleaning for source attribution.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Extract TLD and hostname from domain in Regex. There's [two or more part
|
|
8
|
+
* TLDs](https://en.wikipedia.org/wiki/List_of_Internet_top-level_domains)
|
|
9
|
+
* so it is hard to tell if host.secondTLD.tld or host.tld is correct way
|
|
10
|
+
* to get root domain (e.g. abc.go.jp, abc.co.uk)
|
|
11
|
+
* @param {string} domain
|
|
12
|
+
* @returns {string} rootDomain
|
|
13
|
+
*/
|
|
14
|
+
export function convertURLToDomain(domain) {
|
|
15
|
+
var tldRegExp = new RegExp(
|
|
16
|
+
"(?=[^^]).(fr|de|cz|at|com|wiki|co|edu|gov|info|mil|id|" +
|
|
17
|
+
"gv|tv|int|name|net|org|pro|ac|me|ltd|parliament)(.|$).*$"
|
|
18
|
+
);
|
|
19
|
+
var match =
|
|
20
|
+
domain.match(tldRegExp) ||
|
|
21
|
+
domain.match(/(?=[^^])\.[^a-z]{1,2}\.[^\.]{2,4}$/) ||
|
|
22
|
+
domain.match(/\.[^\.]{2,}$/);
|
|
23
|
+
var tld = match && match.index;
|
|
24
|
+
var domainWithoutSuffix = domain.substring(0, tld);
|
|
25
|
+
|
|
26
|
+
// Get the main domain part, handling subdomains
|
|
27
|
+
if (domainWithoutSuffix.includes(".")) {
|
|
28
|
+
// Split by dots and get the last two parts for domains like en.wikipedia.org
|
|
29
|
+
const parts = domainWithoutSuffix.split(".");
|
|
30
|
+
if (parts.length >= 2) {
|
|
31
|
+
domainWithoutSuffix = parts.slice(-2).join(".");
|
|
32
|
+
} else {
|
|
33
|
+
domainWithoutSuffix = parts[parts.length - 1];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return domainWithoutSuffix;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks if a string is a valid URL.
|
|
42
|
+
* @param {string} string
|
|
43
|
+
* @returns {boolean} true if the string is a valid URL
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
export function isURLValid(string) {
|
|
47
|
+
return /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/
|
|
48
|
+
.test(string);
|
|
49
|
+
}
|
|
50
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview extract-cite: cite any webpage.
|
|
3
|
+
*
|
|
4
|
+
* - `extractCite` reads author, date, title and source straight off the HTML
|
|
5
|
+
* (meta tags, selectors, JSON-LD, URL, a 92k-name database). Instant, offline,
|
|
6
|
+
* partial.
|
|
7
|
+
* - `extractCiteLLM` then asks a model (OpenRouter by default) to finish the
|
|
8
|
+
* citation, score every part, flag the doubtful ones and read the authors'
|
|
9
|
+
* qualifications, and writes it as APA, MLA, Chicago, Harvard, IEEE and BibTeX.
|
|
10
|
+
*/
|
|
11
|
+
export * from "./html-to-cite/extract-cite";
|
|
12
|
+
export * from "./html-to-cite/url-to-domain";
|
|
13
|
+
export { extractHumanName } from "./html-to-cite/human-names-recognize";
|
|
14
|
+
export * from "./llm";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The one LLM call: an OpenAI-compatible chat-completions
|
|
3
|
+
* request, OpenRouter by default, with a JSON-object reply parsed back out.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
7
|
+
export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
|
8
|
+
|
|
9
|
+
export class CiteLLMError extends Error {
|
|
10
|
+
status?: number;
|
|
11
|
+
constructor(message: string, status?: number) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CiteLLMError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CallLLMOptions {
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
system: string;
|
|
23
|
+
user: string;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The API key from the environment, where there is an environment. */
|
|
30
|
+
export function envApiKey(): string | undefined {
|
|
31
|
+
const env = typeof process !== "undefined" ? process.env : undefined;
|
|
32
|
+
return env?.OPENROUTER_API_KEY || undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sends one system + user prompt and returns the reply parsed as a JSON object.
|
|
37
|
+
* Throws `CiteLLMError` for a missing key, an HTTP error, an empty reply or a
|
|
38
|
+
* reply that is not JSON.
|
|
39
|
+
*/
|
|
40
|
+
export async function callLLM(options: CallLLMOptions): Promise<{ json: unknown; model: string }> {
|
|
41
|
+
const apiKey = options.apiKey || envApiKey();
|
|
42
|
+
if (!apiKey) {
|
|
43
|
+
throw new CiteLLMError(
|
|
44
|
+
"No API key: pass `apiKey`, or set OPENROUTER_API_KEY.",
|
|
45
|
+
401
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const model = options.model || DEFAULT_MODEL;
|
|
49
|
+
const baseUrl = (options.baseUrl || OPENROUTER_BASE_URL).replace(/\/+$/, "");
|
|
50
|
+
const doFetch = options.fetch || fetch;
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 60000);
|
|
54
|
+
let res: Response;
|
|
55
|
+
try {
|
|
56
|
+
res = await doFetch(`${baseUrl}/chat/completions`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
Authorization: `Bearer ${apiKey}`,
|
|
62
|
+
...options.headers,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
model,
|
|
66
|
+
temperature: 0,
|
|
67
|
+
response_format: { type: "json_object" },
|
|
68
|
+
messages: [
|
|
69
|
+
{ role: "system", content: options.system },
|
|
70
|
+
{ role: "user", content: options.user },
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const aborted = (err as Error)?.name === "AbortError";
|
|
76
|
+
throw new CiteLLMError(
|
|
77
|
+
aborted ? "The model call timed out." : `The model call failed: ${(err as Error)?.message}`
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body: any = await res.json().catch(() => null);
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const detail = body?.error?.message || body?.error || `HTTP ${res.status}`;
|
|
86
|
+
throw new CiteLLMError(`The model call failed: ${detail}`, res.status);
|
|
87
|
+
}
|
|
88
|
+
const content = body?.choices?.[0]?.message?.content;
|
|
89
|
+
if (typeof content !== "string" || !content.trim()) {
|
|
90
|
+
throw new CiteLLMError("The model returned an empty reply.", res.status);
|
|
91
|
+
}
|
|
92
|
+
return { json: parseJSONReply(content), model: body?.model || model };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parses a reply that may be wrapped in a code fence or surrounded by prose. */
|
|
96
|
+
export function parseJSONReply(content: string): unknown {
|
|
97
|
+
const text = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
const start = text.indexOf("{");
|
|
102
|
+
const end = text.lastIndexOf("}");
|
|
103
|
+
if (start >= 0 && end > start) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
106
|
+
} catch {
|
|
107
|
+
/* fall through */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new CiteLLMError("The model's reply was not valid JSON.");
|
|
111
|
+
}
|
|
112
|
+
}
|