extract-cite 1.0.0 → 1.0.1
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 +11 -2
- package/dist/extract-cite.cjs.js +1 -1
- package/dist/extract-cite.cjs.js.map +1 -1
- package/dist/extract-cite.es.js +1 -1
- package/dist/extract-cite.es.js.map +1 -1
- package/dist/llm/content-check.d.ts +36 -0
- package/dist/llm/extract-cite-llm.d.ts +2 -0
- package/dist/llm/index.d.ts +2 -1
- package/dist/llm/prompt.d.ts +10 -0
- package/dist/llm/types.d.ts +66 -1
- package/package.json +2 -2
- package/src/llm/content-check.ts +187 -0
- package/src/llm/extract-cite-llm.ts +32 -5
- package/src/llm/index.ts +2 -1
- package/src/llm/prompt.ts +47 -5
- package/src/llm/types.ts +99 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ContentCheck } from './types';
|
|
2
|
+
export interface ContentInput {
|
|
3
|
+
/** The first `words` words of the content, for the prompt. */
|
|
4
|
+
text: string;
|
|
5
|
+
/** How many words that is. */
|
|
6
|
+
words: number;
|
|
7
|
+
/** One line per block of the page: selector, word count, opening words. */
|
|
8
|
+
outline: string;
|
|
9
|
+
/** The parsed page, to check the model's selectors against. */
|
|
10
|
+
document: any;
|
|
11
|
+
}
|
|
12
|
+
/** The first `max` words of `text`. */
|
|
13
|
+
export declare function firstWords(text: string, max: number): string;
|
|
14
|
+
/** HTML or text → plain text. */
|
|
15
|
+
export declare function contentToText(content: string): string;
|
|
16
|
+
/** `tag#id.class.class`, from parts that need no escaping. */
|
|
17
|
+
export declare function selectorFor(el: any): string;
|
|
18
|
+
/**
|
|
19
|
+
* The page's blocks as an indented list the model can pick selectors from:
|
|
20
|
+
* landmarks (`header`, `nav`, `aside`, `footer`, `main`, `article`, …) and
|
|
21
|
+
* elements with an id, class or role, with their word count and first words.
|
|
22
|
+
*/
|
|
23
|
+
export declare function pageOutline(document: any, maxLines?: number): string;
|
|
24
|
+
/** What the model is shown for the content check. */
|
|
25
|
+
export declare function prepareContent(html: string, options?: {
|
|
26
|
+
content?: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
words?: number;
|
|
29
|
+
}): ContentInput;
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes and checks the model's `contentCheck`: an unknown verdict becomes
|
|
32
|
+
* `"unknown"`, signals and examples not in the text are dropped, and selectors
|
|
33
|
+
* that are invalid or match nothing on the page are dropped.
|
|
34
|
+
* @param haystack the content and page text the model was shown
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseContentCheck(reply: unknown, input: ContentInput, haystack: string): ContentCheck;
|
|
@@ -11,6 +11,8 @@ import { ExtractCiteLLMOptions, ExtractCiteLLMResult } from './types';
|
|
|
11
11
|
* cite.formatted.apa?.text; // full APA 7 entry
|
|
12
12
|
* cite.needsReview; // fields to check by hand
|
|
13
13
|
* cite.citation.authors[0].qualifications; // bio facts, quoted from the page
|
|
14
|
+
* cite.contentCheck?.verdict; // "full", "paywalled", "truncated", …
|
|
15
|
+
* cite.contentCheck?.tips; // header, nav, sidebar, footer blocks to cut
|
|
14
16
|
* ```
|
|
15
17
|
*
|
|
16
18
|
* Throws when the page cannot be fetched or the model call fails; a missing
|
package/dist/llm/index.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export * from './extract-cite-llm';
|
|
|
4
4
|
export * from './format-citation';
|
|
5
5
|
export { preparePage, fetchPageHTML, clipText } from './page-text';
|
|
6
6
|
export { parseCitationReply, normalizeDate } from './parse-reply';
|
|
7
|
-
export { SYSTEM_PROMPT, buildUserPrompt, missingFields } from './prompt';
|
|
7
|
+
export { SYSTEM_PROMPT, CONTENT_CHECK_PROMPT, systemPrompt, buildUserPrompt, missingFields } from './prompt';
|
|
8
|
+
export { prepareContent, parseContentCheck, pageOutline, contentToText, firstWords } from './content-check';
|
package/dist/llm/prompt.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { ExtractCiteResult } from '../html-to-cite/extract-cite';
|
|
2
2
|
import { CitationField } from './types';
|
|
3
3
|
import { PageInput } from './page-text';
|
|
4
|
+
import { ContentInput } from './content-check';
|
|
4
5
|
export declare const SYSTEM_PROMPT: string;
|
|
6
|
+
/**
|
|
7
|
+
* Appended to `SYSTEM_PROMPT` when the content check is on: `<content>` is the
|
|
8
|
+
* start of the extracted text, `<outline>` the page's blocks as selectors.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CONTENT_CHECK_PROMPT: string;
|
|
11
|
+
/** The system prompt, with the content check appended when it is on. */
|
|
12
|
+
export declare function systemPrompt(checkContent?: boolean): string;
|
|
5
13
|
/** Which candidate fields the regex pass filled, for the prompt. */
|
|
6
14
|
export declare function describeCandidates(partial: ExtractCiteResult): Record<string, string | null>;
|
|
7
15
|
/** The citation fields the regex pass cannot supply at all. */
|
|
@@ -11,4 +19,6 @@ export declare function buildUserPrompt(args: {
|
|
|
11
19
|
partial: ExtractCiteResult;
|
|
12
20
|
missing: CitationField[];
|
|
13
21
|
page: PageInput;
|
|
22
|
+
/** The content check's input; leave out to skip the check. */
|
|
23
|
+
content?: ContentInput;
|
|
14
24
|
}): string;
|
package/dist/llm/types.d.ts
CHANGED
|
@@ -55,11 +55,65 @@ export type CitationField = "authors" | "title" | "containerTitle" | "publisher"
|
|
|
55
55
|
export declare const CITATION_FIELDS: CitationField[];
|
|
56
56
|
/** A field (or one author's qualifications) a human should check. */
|
|
57
57
|
export interface ReviewItem {
|
|
58
|
-
/** A citation field,
|
|
58
|
+
/** A citation field, `qualifications:<author name>`, or `content` when the content check found no full article. */
|
|
59
59
|
field: string;
|
|
60
60
|
confidence: number;
|
|
61
61
|
reason: string;
|
|
62
62
|
}
|
|
63
|
+
/** What the content check concluded about the extracted text. */
|
|
64
|
+
export type ContentVerdict =
|
|
65
|
+
/** The article body is all there. */
|
|
66
|
+
"full"
|
|
67
|
+
/** A subscribe or sign-in wall replaces or cuts the body. */
|
|
68
|
+
| "paywalled"
|
|
69
|
+
/** The body stops early without a wall: a teaser, an abstract, "Read more". */
|
|
70
|
+
| "truncated"
|
|
71
|
+
/** A bot check, consent wall, access-denied or error page instead of the article. */
|
|
72
|
+
| "blocked"
|
|
73
|
+
/** A home page, index or listing, with no single article. */
|
|
74
|
+
| "not-article"
|
|
75
|
+
/** The model gave no usable verdict. */
|
|
76
|
+
| "unknown";
|
|
77
|
+
export declare const CONTENT_VERDICTS: ContentVerdict[];
|
|
78
|
+
/** The kinds of clutter a readability tip can be about. */
|
|
79
|
+
export type PageRegion = "header" | "nav" | "sidebar" | "footer" | "ads" | "related" | "newsletter" | "share" | "comments" | "cookie-banner" | "other";
|
|
80
|
+
export declare const PAGE_REGIONS: PageRegion[];
|
|
81
|
+
/** Clutter the extraction kept, and how to cut it. */
|
|
82
|
+
export interface ReadabilityTip {
|
|
83
|
+
region: PageRegion;
|
|
84
|
+
/** CSS selector of the block to cut. Kept only when it matches an element on the page. */
|
|
85
|
+
selector?: string;
|
|
86
|
+
/** A short snippet of the clutter, kept only when it is in the text. */
|
|
87
|
+
example?: string;
|
|
88
|
+
/** How to cut it, in a sentence. */
|
|
89
|
+
tip: string;
|
|
90
|
+
}
|
|
91
|
+
/** Whether the extracted content is the full article, and how to make it cleaner. */
|
|
92
|
+
export interface ContentCheck {
|
|
93
|
+
verdict: ContentVerdict;
|
|
94
|
+
/** True only for `"full"`. */
|
|
95
|
+
isFullContent: boolean;
|
|
96
|
+
/** 0–1. */
|
|
97
|
+
confidence: number;
|
|
98
|
+
/** Phrases from the text that show the verdict ("Subscribe to continue reading"); only ones that are in the text are kept. */
|
|
99
|
+
signals: string[];
|
|
100
|
+
/** One or two sentences for the reader. */
|
|
101
|
+
note: string;
|
|
102
|
+
/** CSS selector of the element that holds the article body. Kept only when it matches. */
|
|
103
|
+
contentSelector?: string;
|
|
104
|
+
/** Clutter the content still holds (header, nav, sidebar, footer, …), biggest first. */
|
|
105
|
+
tips: ReadabilityTip[];
|
|
106
|
+
/**
|
|
107
|
+
* The checked selectors in the shape of an `extract-webpage`
|
|
108
|
+
* `extract-selectors-per-domain.json` entry, ready to paste.
|
|
109
|
+
*/
|
|
110
|
+
selectors: {
|
|
111
|
+
content: string[];
|
|
112
|
+
remove: string[];
|
|
113
|
+
};
|
|
114
|
+
/** Words of content the model was shown. */
|
|
115
|
+
wordsChecked: number;
|
|
116
|
+
}
|
|
63
117
|
export interface FormattedCitation {
|
|
64
118
|
/** Plain text. */
|
|
65
119
|
text: string;
|
|
@@ -82,6 +136,8 @@ export interface ExtractCiteLLMResult {
|
|
|
82
136
|
origin: Partial<Record<CitationField, "regex" | "llm" | "both">>;
|
|
83
137
|
/** One entry per requested style. */
|
|
84
138
|
formatted: Partial<Record<CitationStyle, FormattedCitation>>;
|
|
139
|
+
/** Full article or paywall stub, plus tips for cutting clutter. Absent with `checkContent: false`. */
|
|
140
|
+
contentCheck?: ContentCheck;
|
|
85
141
|
model: string;
|
|
86
142
|
ms: number;
|
|
87
143
|
}
|
|
@@ -104,6 +160,15 @@ export interface ExtractCiteLLMOptions {
|
|
|
104
160
|
reviewThreshold?: number;
|
|
105
161
|
/** Characters of page text sent to the model. Default 12000. */
|
|
106
162
|
maxChars?: number;
|
|
163
|
+
/**
|
|
164
|
+
* The content extracted from the page (HTML or text), e.g. extract-webpage's
|
|
165
|
+
* `html`, for the content check. Default: the page's own visible text.
|
|
166
|
+
*/
|
|
167
|
+
content?: string;
|
|
168
|
+
/** Check that the content is the full article and suggest what to cut. Default true. */
|
|
169
|
+
checkContent?: boolean;
|
|
170
|
+
/** Words of content the check is shown, from the start. Default 3000. */
|
|
171
|
+
contentWords?: number;
|
|
107
172
|
/** Abort the model call after this many milliseconds. Default 60000. */
|
|
108
173
|
timeoutMs?: number;
|
|
109
174
|
/** ISO date printed as the access date. Default: today. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "extract-cite",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"module": "./dist/extract-cite.es.js",
|
|
5
5
|
"description": "Cite any webpage: regex/metadata extraction of author, date, title and source, plus an optional LLM pass (OpenRouter by default) that completes the APA parts, scores confidence per field, flags fields for review, extracts author qualifications, and formats APA, MLA, Chicago, Harvard, IEEE and BibTeX.",
|
|
6
6
|
"author": "vtempest <grokthiscontact@gmail.com>",
|
|
@@ -58,4 +58,4 @@
|
|
|
58
58
|
"author",
|
|
59
59
|
"scraping"
|
|
60
60
|
]
|
|
61
|
-
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The content check that rides along with the citation call:
|
|
3
|
+
* the first few thousand words of the extracted content and an outline of the
|
|
4
|
+
* page's blocks go to the model, which says whether this is the full article or
|
|
5
|
+
* a paywall stub, and which header, nav, sidebar and footer blocks to cut. The
|
|
6
|
+
* reply is checked like the citation: quoted phrases must be in the text and
|
|
7
|
+
* selectors must match an element on the page, or they are dropped.
|
|
8
|
+
*/
|
|
9
|
+
import { parseHTML } from "linkedom";
|
|
10
|
+
import { appearsIn, clamp01 } from "./parse-reply";
|
|
11
|
+
import {
|
|
12
|
+
CONTENT_VERDICTS,
|
|
13
|
+
PAGE_REGIONS,
|
|
14
|
+
type ContentCheck,
|
|
15
|
+
type ContentVerdict,
|
|
16
|
+
type PageRegion,
|
|
17
|
+
type ReadabilityTip,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
type Json = Record<string, any>;
|
|
21
|
+
|
|
22
|
+
const REMOVE = "script,style,noscript,svg,template,iframe,canvas";
|
|
23
|
+
const LANDMARK = /^(header|nav|aside|footer|main|article|section|form)$/;
|
|
24
|
+
/** An id or class usable in a selector without escaping. */
|
|
25
|
+
const IDENT = /^[A-Za-z_-][\w-]*$/;
|
|
26
|
+
|
|
27
|
+
export interface ContentInput {
|
|
28
|
+
/** The first `words` words of the content, for the prompt. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** How many words that is. */
|
|
31
|
+
words: number;
|
|
32
|
+
/** One line per block of the page: selector, word count, opening words. */
|
|
33
|
+
outline: string;
|
|
34
|
+
/** The parsed page, to check the model's selectors against. */
|
|
35
|
+
document: any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const countWords = (text: string) => (text.match(/\S+/g) || []).length;
|
|
39
|
+
|
|
40
|
+
/** The first `max` words of `text`. */
|
|
41
|
+
export function firstWords(text: string, max: number): string {
|
|
42
|
+
const words = text.match(/\S+/g) || [];
|
|
43
|
+
return words.length <= max ? words.join(" ") : `${words.slice(0, max).join(" ")} […]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A node's text with a space between elements, so "Home</a><a>News" does not run together. */
|
|
47
|
+
function textOf(node: any): string {
|
|
48
|
+
if (node.nodeType === 3) return node.data || "";
|
|
49
|
+
if (node.nodeType !== 1 || node.matches?.(REMOVE)) return "";
|
|
50
|
+
return Array.from(node.childNodes || []).map(textOf).join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const squash = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
54
|
+
|
|
55
|
+
/** HTML or text → plain text. */
|
|
56
|
+
export function contentToText(content: string): string {
|
|
57
|
+
if (!/<[a-z!/][^>]*>/i.test(content)) return squash(content);
|
|
58
|
+
const { document } = parseHTML(`<html><body>${content}</body></html>`);
|
|
59
|
+
return document.body ? squash(textOf(document.body)) : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `tag#id.class.class`, from parts that need no escaping. */
|
|
63
|
+
export function selectorFor(el: any): string {
|
|
64
|
+
const tag = String(el.tagName || "").toLowerCase();
|
|
65
|
+
const id = el.getAttribute?.("id");
|
|
66
|
+
if (id && IDENT.test(id)) return `${tag}#${id}`;
|
|
67
|
+
const classes = String(el.getAttribute?.("class") || "")
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.filter((c) => IDENT.test(c))
|
|
70
|
+
.slice(0, 2);
|
|
71
|
+
const role = el.getAttribute?.("role");
|
|
72
|
+
const roleAttr = role && IDENT.test(role) ? `[role="${role}"]` : "";
|
|
73
|
+
return `${tag}${classes.map((c) => `.${c}`).join("")}${roleAttr}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The page's blocks as an indented list the model can pick selectors from:
|
|
78
|
+
* landmarks (`header`, `nav`, `aside`, `footer`, `main`, `article`, …) and
|
|
79
|
+
* elements with an id, class or role, with their word count and first words.
|
|
80
|
+
*/
|
|
81
|
+
export function pageOutline(document: any, maxLines = 60): string {
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
const walk = (parent: any, depth: number, level: number) => {
|
|
84
|
+
for (const el of Array.from(parent.children || []) as any[]) {
|
|
85
|
+
if (lines.length >= maxLines) return;
|
|
86
|
+
const tag = String(el.tagName || "").toLowerCase();
|
|
87
|
+
if (el.matches?.(REMOVE)) continue;
|
|
88
|
+
const text = squash(textOf(el));
|
|
89
|
+
const words = countWords(text);
|
|
90
|
+
if (words < 3) continue;
|
|
91
|
+
// A bare `div` says nothing and would match every div on the page.
|
|
92
|
+
const selector = selectorFor(el);
|
|
93
|
+
const named = LANDMARK.test(tag) || selector !== tag;
|
|
94
|
+
if (named) {
|
|
95
|
+
const opening = text.split(" ").slice(0, 8).join(" ");
|
|
96
|
+
lines.push(`${" ".repeat(level)}${selector} (${words} words) "${opening}"`);
|
|
97
|
+
}
|
|
98
|
+
if (depth < 8 && level < 4) walk(el, depth + 1, named ? level + 1 : level);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
if (document.body) walk(document.body, 0, 0);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What the model is shown for the content check. */
|
|
106
|
+
export function prepareContent(
|
|
107
|
+
html: string,
|
|
108
|
+
options: { content?: string; text?: string; words?: number } = {}
|
|
109
|
+
): ContentInput {
|
|
110
|
+
const { document } = parseHTML(html);
|
|
111
|
+
const source = options.content ?? options.text;
|
|
112
|
+
const full = source !== undefined ? contentToText(source) : document.body ? squash(textOf(document.body)) : "";
|
|
113
|
+
const text = firstWords(full, options.words ?? 3000);
|
|
114
|
+
return { text, words: Math.min(countWords(full), options.words ?? 3000), outline: pageOutline(document), document };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Bare tags that are safe to cut whole: every one of them is page chrome. */
|
|
118
|
+
const CHROME_TAG = /^(header|nav|aside|footer)$/i;
|
|
119
|
+
|
|
120
|
+
/** Is `selector` valid and does it match something on the page? */
|
|
121
|
+
function matches(document: any, selector: string): boolean {
|
|
122
|
+
try {
|
|
123
|
+
return Boolean(document.querySelector(selector));
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A trimmed, tag-free string, capped. */
|
|
130
|
+
function str(value: unknown, max: number): string | undefined {
|
|
131
|
+
if (typeof value !== "string") return undefined;
|
|
132
|
+
const clean = value.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
|
133
|
+
if (!clean || /^(null|none|n\/a)$/i.test(clean)) return undefined;
|
|
134
|
+
return clean.slice(0, max);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Normalizes and checks the model's `contentCheck`: an unknown verdict becomes
|
|
139
|
+
* `"unknown"`, signals and examples not in the text are dropped, and selectors
|
|
140
|
+
* that are invalid or match nothing on the page are dropped.
|
|
141
|
+
* @param haystack the content and page text the model was shown
|
|
142
|
+
*/
|
|
143
|
+
export function parseContentCheck(reply: unknown, input: ContentInput, haystack: string): ContentCheck {
|
|
144
|
+
const r = (reply && typeof reply === "object" ? reply : {}) as Json;
|
|
145
|
+
const verdict: ContentVerdict = CONTENT_VERDICTS.includes(r.verdict) ? r.verdict : "unknown";
|
|
146
|
+
const selector = (value: unknown, bareOk: RegExp) => {
|
|
147
|
+
const s = str(value, 200);
|
|
148
|
+
if (!s || !matches(input.document, s)) return undefined;
|
|
149
|
+
// A bare tag like `div` or `section` would cut (or keep) far too much.
|
|
150
|
+
return /^[a-z][a-z0-9]*$/i.test(s) && !bareOk.test(s) ? undefined : s;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const signals = (Array.isArray(r.signals) ? r.signals : [])
|
|
154
|
+
.map((s: unknown) => str(s, 160))
|
|
155
|
+
.filter((s: string | undefined): s is string => Boolean(s) && appearsIn(s, haystack))
|
|
156
|
+
.slice(0, 5);
|
|
157
|
+
|
|
158
|
+
const tips: ReadabilityTip[] = [];
|
|
159
|
+
for (const raw of Array.isArray(r.tips) ? r.tips.slice(0, 12) : []) {
|
|
160
|
+
if (!raw || typeof raw !== "object") continue;
|
|
161
|
+
const tip = str(raw.tip, 300);
|
|
162
|
+
if (!tip) continue;
|
|
163
|
+
const example = str(raw.example, 160);
|
|
164
|
+
tips.push({
|
|
165
|
+
region: PAGE_REGIONS.includes(raw.region) ? (raw.region as PageRegion) : "other",
|
|
166
|
+
selector: selector(raw.selector, CHROME_TAG),
|
|
167
|
+
example: example && appearsIn(example, haystack) ? example : undefined,
|
|
168
|
+
tip,
|
|
169
|
+
});
|
|
170
|
+
if (tips.length === 8) break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const contentSelector = selector(r.contentSelector, /^(main|article)$/i);
|
|
174
|
+
const remove = [...new Set(tips.map((t) => t.selector).filter(Boolean) as string[])];
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
verdict,
|
|
178
|
+
isFullContent: verdict === "full",
|
|
179
|
+
confidence: verdict === "unknown" ? 0 : clamp01(r.confidence),
|
|
180
|
+
signals,
|
|
181
|
+
note: str(r.note, 400) || (verdict === "unknown" ? "The model did not check the content." : ""),
|
|
182
|
+
contentSelector,
|
|
183
|
+
tips,
|
|
184
|
+
selectors: { content: contentSelector ? [contentSelector] : [], remove },
|
|
185
|
+
wordsChecked: input.words,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview `extractCiteLLM`: the cheap regex/metadata pass first, then one
|
|
3
3
|
* model call that is handed what was found and what is still missing, completes
|
|
4
|
-
* the APA parts, scores each one, and reads the authors' qualifications.
|
|
4
|
+
* the APA parts, scores each one, and reads the authors' qualifications. The
|
|
5
|
+
* same call checks that the extracted content is the full article, not a
|
|
6
|
+
* paywall stub, and names the header, nav, sidebar and footer blocks to cut.
|
|
5
7
|
*/
|
|
6
8
|
import { parseHTML } from "linkedom";
|
|
7
9
|
import { extractCite } from "../html-to-cite/extract-cite";
|
|
8
10
|
import { callLLM } from "./call-llm";
|
|
11
|
+
import { parseContentCheck, prepareContent } from "./content-check";
|
|
9
12
|
import { formatCitations } from "./format-citation";
|
|
10
13
|
import { fetchPageHTML, preparePage } from "./page-text";
|
|
11
14
|
import { parseCitationReply } from "./parse-reply";
|
|
12
|
-
import { buildUserPrompt, missingFields,
|
|
15
|
+
import { buildUserPrompt, missingFields, systemPrompt } from "./prompt";
|
|
13
16
|
import {
|
|
14
17
|
CITATION_STYLES,
|
|
15
18
|
type CitationField,
|
|
@@ -29,6 +32,8 @@ import {
|
|
|
29
32
|
* cite.formatted.apa?.text; // full APA 7 entry
|
|
30
33
|
* cite.needsReview; // fields to check by hand
|
|
31
34
|
* cite.citation.authors[0].qualifications; // bio facts, quoted from the page
|
|
35
|
+
* cite.contentCheck?.verdict; // "full", "paywalled", "truncated", …
|
|
36
|
+
* cite.contentCheck?.tips; // header, nav, sidebar, footer blocks to cut
|
|
32
37
|
* ```
|
|
33
38
|
*
|
|
34
39
|
* Throws when the page cannot be fetched or the model call fails; a missing
|
|
@@ -36,7 +41,7 @@ import {
|
|
|
36
41
|
*/
|
|
37
42
|
export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<ExtractCiteLLMResult> {
|
|
38
43
|
const started = Date.now();
|
|
39
|
-
const { url, reviewThreshold = 0.7, styles = [...CITATION_STYLES] } = options;
|
|
44
|
+
const { url, reviewThreshold = 0.7, styles = [...CITATION_STYLES], checkContent = true } = options;
|
|
40
45
|
|
|
41
46
|
let html = options.html;
|
|
42
47
|
if (!html && options.text === undefined) {
|
|
@@ -51,6 +56,9 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
|
|
|
51
56
|
|
|
52
57
|
// 2. One model call with the partial in hand.
|
|
53
58
|
const page = preparePage(html, { text: options.text, maxChars: options.maxChars });
|
|
59
|
+
const content = checkContent
|
|
60
|
+
? prepareContent(html, { content: options.content, text: options.text, words: options.contentWords })
|
|
61
|
+
: undefined;
|
|
54
62
|
const { json, model } = await callLLM({
|
|
55
63
|
apiKey: options.apiKey,
|
|
56
64
|
model: options.model,
|
|
@@ -58,8 +66,8 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
|
|
|
58
66
|
headers: options.headers,
|
|
59
67
|
timeoutMs: options.timeoutMs,
|
|
60
68
|
fetch: options.fetch,
|
|
61
|
-
system:
|
|
62
|
-
user: buildUserPrompt({ url, partial, missing, page }),
|
|
69
|
+
system: systemPrompt(checkContent),
|
|
70
|
+
user: buildUserPrompt({ url, partial, missing, page, content }),
|
|
63
71
|
});
|
|
64
72
|
|
|
65
73
|
// 3. Validate it against the page and score it.
|
|
@@ -71,6 +79,24 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
|
|
|
71
79
|
reviewThreshold
|
|
72
80
|
);
|
|
73
81
|
|
|
82
|
+
// 4. The content check, held to the same standard: quotes must be in the
|
|
83
|
+
// text, selectors must match the page.
|
|
84
|
+
const contentCheck = content
|
|
85
|
+
? parseContentCheck(
|
|
86
|
+
(json as { contentCheck?: unknown } | null)?.contentCheck,
|
|
87
|
+
content,
|
|
88
|
+
[content.text, page.text, page.pageTitle].join("\n")
|
|
89
|
+
)
|
|
90
|
+
: undefined;
|
|
91
|
+
if (contentCheck && !contentCheck.isFullContent && contentCheck.verdict !== "unknown") {
|
|
92
|
+
parsed.needsReview.push({
|
|
93
|
+
field: "content",
|
|
94
|
+
confidence: contentCheck.confidence,
|
|
95
|
+
reason: `${contentCheck.verdict}: ${contentCheck.note || contentCheck.signals[0] || "not the full article"}`,
|
|
96
|
+
});
|
|
97
|
+
parsed.needsReview.sort((a, b) => a.confidence - b.confidence);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
// `agree` means the regex pass and the model read the same value; anything
|
|
75
101
|
// else the model supplied, because the regex pass had nothing or was overruled.
|
|
76
102
|
const origin: ExtractCiteLLMResult["origin"] = {};
|
|
@@ -92,6 +118,7 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
|
|
|
92
118
|
missing,
|
|
93
119
|
origin,
|
|
94
120
|
formatted: formatCitations(parsed.citation, styles),
|
|
121
|
+
contentCheck,
|
|
95
122
|
model,
|
|
96
123
|
ms: Date.now() - started,
|
|
97
124
|
};
|
package/src/llm/index.ts
CHANGED
|
@@ -4,4 +4,5 @@ export * from "./extract-cite-llm";
|
|
|
4
4
|
export * from "./format-citation";
|
|
5
5
|
export { preparePage, fetchPageHTML, clipText } from "./page-text";
|
|
6
6
|
export { parseCitationReply, normalizeDate } from "./parse-reply";
|
|
7
|
-
export { SYSTEM_PROMPT, buildUserPrompt, missingFields } from "./prompt";
|
|
7
|
+
export { SYSTEM_PROMPT, CONTENT_CHECK_PROMPT, systemPrompt, buildUserPrompt, missingFields } from "./prompt";
|
|
8
|
+
export { prepareContent, parseContentCheck, pageOutline, contentToText, firstWords } from "./content-check";
|
package/src/llm/prompt.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview The prompt: the regex pass's partial citation goes in as
|
|
3
3
|
* candidates to verify, the still-missing parts are named, and the page text
|
|
4
|
-
* is fenced as untrusted data.
|
|
4
|
+
* is fenced as untrusted data. With the content check on, the same call also
|
|
5
|
+
* judges whether the extracted content is the full article and what to cut.
|
|
5
6
|
*/
|
|
6
7
|
import type { ExtractCiteResult } from "../html-to-cite/extract-cite";
|
|
7
|
-
import { CITATION_FIELDS, SOURCE_TYPES, type CitationField } from "./types";
|
|
8
|
+
import { CITATION_FIELDS, CONTENT_VERDICTS, PAGE_REGIONS, SOURCE_TYPES, type CitationField } from "./types";
|
|
8
9
|
import type { PageInput } from "./page-text";
|
|
10
|
+
import type { ContentInput } from "./content-check";
|
|
9
11
|
|
|
10
12
|
export const SYSTEM_PROMPT = `You build bibliographic citations from web pages for students and researchers. A wrong author or date is worse than a missing one, so never guess.
|
|
11
13
|
|
|
@@ -44,6 +46,37 @@ Reply with one JSON object and nothing else:
|
|
|
44
46
|
}
|
|
45
47
|
}`;
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Appended to `SYSTEM_PROMPT` when the content check is on: `<content>` is the
|
|
51
|
+
* start of the extracted text, `<outline>` the page's blocks as selectors.
|
|
52
|
+
*/
|
|
53
|
+
export const CONTENT_CHECK_PROMPT = `
|
|
54
|
+
Also check the content. <content> is the start of the text extracted from this page as the article (or the page's own text when nothing was extracted), and <outline> lists the page's blocks as CSS selectors with their word counts and opening words. Readers will get this text as the article, so say whether it is the whole article and what clutter to cut.
|
|
55
|
+
|
|
56
|
+
- verdict, one of: ${CONTENT_VERDICTS.filter((v) => v !== "unknown").join(", ")}.
|
|
57
|
+
"full": the article body is there, from its opening to its end, or it runs past the sample with no sign of stopping. A short article is still full.
|
|
58
|
+
"paywalled": a subscribe, sign-in or register wall replaces or cuts off the body, usually a teaser paragraph and then a prompt to pay or log in.
|
|
59
|
+
"truncated": the body stops early with no wall: a "Read more" or "Continue reading" link, a cut mid-sentence, or only an abstract or summary where the page promises more.
|
|
60
|
+
"blocked": a bot check, captcha, cookie or consent wall, access-denied or error page instead of the article.
|
|
61
|
+
"not-article": a home page, index, search results or listing, not one article.
|
|
62
|
+
Judge only by what the text shows, not by what you know about the site.
|
|
63
|
+
- signals: up to 5 short phrases copied exactly from the text that show the verdict ("Subscribe to continue reading", "Already a subscriber? Sign in"). Empty when the verdict is full.
|
|
64
|
+
- tips: the clutter in <content> that is not the article, so it can be cut for readability: the site header and top bar, navigation menus, sidebars, footers, ads, related-article lists, newsletter and subscribe boxes, share buttons, comments, cookie banners. For each: region (one of ${PAGE_REGIONS.join(", ")}), the selector from <outline> that holds it (copy it exactly; null if no outline line fits, never invent one), a short example copied exactly from the text, and a one-sentence tip on how to cut it. Biggest first, at most 8. None when the content is clean.
|
|
65
|
+
- contentSelector: the selector from <outline> of the element that holds the article body, or null.
|
|
66
|
+
- note: one or two plain sentences for the reader: is this the full article, and if not, what is missing.
|
|
67
|
+
|
|
68
|
+
Add this key to the JSON object:
|
|
69
|
+
"contentCheck": {
|
|
70
|
+
"verdict": string, "confidence": number, "signals": string[], "note": string,
|
|
71
|
+
"contentSelector": string|null,
|
|
72
|
+
"tips": [ { "region": string, "selector": string|null, "example": string|null, "tip": string } ]
|
|
73
|
+
}`;
|
|
74
|
+
|
|
75
|
+
/** The system prompt, with the content check appended when it is on. */
|
|
76
|
+
export function systemPrompt(checkContent = true): string {
|
|
77
|
+
return checkContent ? SYSTEM_PROMPT + CONTENT_CHECK_PROMPT : SYSTEM_PROMPT;
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
/** Which candidate fields the regex pass filled, for the prompt. */
|
|
48
81
|
export function describeCandidates(partial: ExtractCiteResult): Record<string, string | null> {
|
|
49
82
|
return {
|
|
@@ -72,15 +105,17 @@ export function missingFields(partial: ExtractCiteResult): CitationField[] {
|
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
/** Stops page text from closing the fence it is wrapped in. */
|
|
75
|
-
const unfence = (text: string) => text.replace(/<\/?(page|text|meta|json-ld|title)\b[^>]*>/gi, "");
|
|
108
|
+
const unfence = (text: string) => text.replace(/<\/?(page|text|meta|json-ld|title|content|outline)\b[^>]*>/gi, "");
|
|
76
109
|
|
|
77
110
|
export function buildUserPrompt(args: {
|
|
78
111
|
url?: string;
|
|
79
112
|
partial: ExtractCiteResult;
|
|
80
113
|
missing: CitationField[];
|
|
81
114
|
page: PageInput;
|
|
115
|
+
/** The content check's input; leave out to skip the check. */
|
|
116
|
+
content?: ContentInput;
|
|
82
117
|
}): string {
|
|
83
|
-
const { url, partial, missing, page } = args;
|
|
118
|
+
const { url, partial, missing, page, content } = args;
|
|
84
119
|
const parts = [
|
|
85
120
|
`URL: ${url || "(none given)"}`,
|
|
86
121
|
`Candidates from the regex pass: ${JSON.stringify(describeCandidates(partial))}`,
|
|
@@ -90,6 +125,13 @@ export function buildUserPrompt(args: {
|
|
|
90
125
|
];
|
|
91
126
|
if (page.meta) parts.push(`<meta>\n${unfence(page.meta)}\n</meta>`);
|
|
92
127
|
if (page.jsonLd) parts.push(`<json-ld>\n${unfence(page.jsonLd)}\n</json-ld>`);
|
|
93
|
-
parts.push(`<text>\n${unfence(page.text)}\n</text
|
|
128
|
+
parts.push(`<text>\n${unfence(page.text)}\n</text>`);
|
|
129
|
+
if (content) {
|
|
130
|
+
parts.push(
|
|
131
|
+
`<content words="${content.words}">\n${unfence(content.text)}\n</content>`,
|
|
132
|
+
`<outline>\n${unfence(content.outline) || "(no blocks with an id, class or role)"}\n</outline>`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
parts.push("</page>");
|
|
94
136
|
return parts.join("\n");
|
|
95
137
|
}
|