@expo/code-review-cli 0.11.0 → 0.12.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 +92 -3
- package/build/cli.js +5 -0
- package/build/commands/ci.js +5 -1
- package/build/commands/post-review.js +147 -0
- package/build/commands/review.js +46 -14
- package/build/config/load.js +11 -0
- package/build/config/schema.js +34 -1
- package/build/core/deferred-review.js +119 -0
- package/build/core/prompts.js +30 -2
- package/build/core/render.js +4 -1
- package/build/core/research.js +498 -0
- package/build/core/review.js +22 -2
- package/build/research-mcp/apple-docc.js +122 -0
- package/build/research-mcp/cli.js +83 -0
- package/build/research-mcp/crawler.js +194 -0
- package/build/research-mcp/expo-algolia.js +95 -0
- package/build/research-mcp/html.js +132 -0
- package/build/research-mcp/markdown.js +32 -0
- package/build/research-mcp/paths.js +4 -0
- package/build/research-mcp/providers.js +322 -0
- package/build/research-mcp/response.js +24 -0
- package/build/research-mcp/search-index.js +131 -0
- package/build/research-mcp/server.js +118 -0
- package/build/research-mcp/types.js +28 -0
- package/build/research-mcp/youtrack.js +57 -0
- package/package.json +14 -3
- package/research/sources.json +283 -0
- package/templates/config.jsonc +16 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ref LLP 0013#one-package-two-binaries [implements] — the package's second binary owns serve/update dispatch
|
|
3
|
+
// @ref LLP 0013#index-lifecycle-and-network-boundary [implements] — review-facing serve and operator-only update stay separate
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { defaultConfigPath, defaultIndexPath } from "./paths.js";
|
|
6
|
+
import { runStdioServer } from "./server.js";
|
|
7
|
+
import { PLATFORMS } from "./types.js";
|
|
8
|
+
function printHelp() {
|
|
9
|
+
process.stdout.write(`review-research-mcp
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
review-research-mcp [serve]
|
|
13
|
+
review-research-mcp serve [--index PATH]
|
|
14
|
+
review-research-mcp update [--config PATH] [--output PATH]
|
|
15
|
+
[--platform apple|android|react-native] [--max-pages NUMBER]
|
|
16
|
+
|
|
17
|
+
The serve command uses the local index except that Expo-provider searches query
|
|
18
|
+
Expo's public documentation index and fall back locally. The update command crawls
|
|
19
|
+
the allowlisted documentation websites.
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
async function main() {
|
|
23
|
+
const [command = "serve", ...rest] = process.argv.slice(2);
|
|
24
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
25
|
+
printHelp();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (command === "serve") {
|
|
29
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
30
|
+
printHelp();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const { values } = parseArgs({
|
|
34
|
+
args: rest,
|
|
35
|
+
options: {
|
|
36
|
+
index: { type: "string" },
|
|
37
|
+
},
|
|
38
|
+
strict: true,
|
|
39
|
+
});
|
|
40
|
+
const indexPath = values.index ?? process.env.REVIEW_RESEARCH_INDEX_PATH ?? defaultIndexPath;
|
|
41
|
+
await runStdioServer(indexPath);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (command === "update") {
|
|
45
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
46
|
+
printHelp();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const { values } = parseArgs({
|
|
50
|
+
args: rest,
|
|
51
|
+
options: {
|
|
52
|
+
config: { type: "string" },
|
|
53
|
+
output: { type: "string" },
|
|
54
|
+
platform: { type: "string", multiple: true },
|
|
55
|
+
"max-pages": { type: "string" },
|
|
56
|
+
},
|
|
57
|
+
strict: true,
|
|
58
|
+
});
|
|
59
|
+
const invalidPlatform = values.platform?.find((platform) => !PLATFORMS.includes(platform));
|
|
60
|
+
if (invalidPlatform) {
|
|
61
|
+
throw new Error(`Unknown platform: ${invalidPlatform}`);
|
|
62
|
+
}
|
|
63
|
+
const maxPages = values["max-pages"] ? Number(values["max-pages"]) : undefined;
|
|
64
|
+
if (maxPages !== undefined && (!Number.isInteger(maxPages) || maxPages < 1)) {
|
|
65
|
+
throw new Error("--max-pages must be a positive integer");
|
|
66
|
+
}
|
|
67
|
+
const { updateDocumentationIndex } = await import("./crawler.js");
|
|
68
|
+
const result = await updateDocumentationIndex({
|
|
69
|
+
configPath: values.config ?? defaultConfigPath,
|
|
70
|
+
...(values.output ? { outputPath: values.output } : {}),
|
|
71
|
+
...(values.platform ? { platforms: values.platform } : {}),
|
|
72
|
+
...(maxPages ? { maxPagesPerProvider: maxPages } : {}),
|
|
73
|
+
});
|
|
74
|
+
process.stderr.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Unknown command: ${command}`);
|
|
78
|
+
}
|
|
79
|
+
main().catch((error) => {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
process.stderr.write(`review-research-mcp: ${message}\n`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { extractAppleDocCPage } from "./apple-docc.js";
|
|
5
|
+
import { chunkDocument, extractDocumentationPage } from "./html.js";
|
|
6
|
+
import { extractMarkdownDocumentationPage } from "./markdown.js";
|
|
7
|
+
import { getProvider, resolveAllowedRequestUrl, resolveAllowedUrl, } from "./providers.js";
|
|
8
|
+
import { readBodyWithLimit } from "./response.js";
|
|
9
|
+
import { buildSearchIndex, writeSearchIndex } from "./search-index.js";
|
|
10
|
+
import { extractYouTrackIssue } from "./youtrack.js";
|
|
11
|
+
import { PLATFORMS, PROVIDERS, SOURCE_KINDS, } from "./types.js";
|
|
12
|
+
const sourcesConfigSchema = z.object({
|
|
13
|
+
output: z.string().min(1),
|
|
14
|
+
crawl: z.object({
|
|
15
|
+
maxPagesPerProvider: z.number().int().min(1).max(100_000),
|
|
16
|
+
maxDepth: z.number().int().min(0).max(20),
|
|
17
|
+
delayMs: z.number().int().min(0).max(60_000),
|
|
18
|
+
timeoutMs: z.number().int().min(100).max(120_000),
|
|
19
|
+
maxResponseBytes: z.number().int().min(1_024).max(20_000_000),
|
|
20
|
+
}),
|
|
21
|
+
sources: z
|
|
22
|
+
.array(z.object({
|
|
23
|
+
provider: z.enum(PROVIDERS),
|
|
24
|
+
sourceKind: z.enum(SOURCE_KINDS),
|
|
25
|
+
seedUrls: z.array(z.string().url()).min(1),
|
|
26
|
+
maxPages: z.number().int().min(1).max(100_000).optional(),
|
|
27
|
+
maxDepth: z.number().int().min(0).max(20).optional(),
|
|
28
|
+
}))
|
|
29
|
+
.min(1),
|
|
30
|
+
});
|
|
31
|
+
function sleep(milliseconds) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
33
|
+
}
|
|
34
|
+
async function fetchAllowedContent(provider, documentUrl, limits) {
|
|
35
|
+
let currentUrl = resolveAllowedRequestUrl(provider, provider.requestUrl(documentUrl).href);
|
|
36
|
+
for (let redirectCount = 0; redirectCount <= 5; redirectCount++) {
|
|
37
|
+
const response = await fetch(currentUrl, {
|
|
38
|
+
redirect: "manual",
|
|
39
|
+
signal: AbortSignal.timeout(limits.timeoutMs),
|
|
40
|
+
headers: {
|
|
41
|
+
"accept-language": "en-US,en;q=0.9",
|
|
42
|
+
accept: (() => {
|
|
43
|
+
const format = provider.responseFormat(documentUrl);
|
|
44
|
+
if (format === "docc-json" || format === "youtrack-json") {
|
|
45
|
+
return "application/json";
|
|
46
|
+
}
|
|
47
|
+
if (format === "markdown") {
|
|
48
|
+
return "text/markdown,text/plain;q=0.9";
|
|
49
|
+
}
|
|
50
|
+
return "text/html,application/xhtml+xml;q=0.9";
|
|
51
|
+
})(),
|
|
52
|
+
"user-agent": "review-research-mcp/0.1 (+local documentation indexer)",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (response.status >= 300 && response.status < 400) {
|
|
56
|
+
const location = response.headers.get("location");
|
|
57
|
+
if (!location) {
|
|
58
|
+
throw new Error(`redirect ${response.status} did not include Location`);
|
|
59
|
+
}
|
|
60
|
+
currentUrl = resolveAllowedRequestUrl(provider, location, currentUrl.href);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`HTTP ${response.status}`);
|
|
65
|
+
}
|
|
66
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
67
|
+
const expectedFormat = provider.responseFormat(documentUrl);
|
|
68
|
+
const isExpectedType = expectedFormat === "docc-json" || expectedFormat === "youtrack-json"
|
|
69
|
+
? contentType.includes("json")
|
|
70
|
+
: expectedFormat === "markdown"
|
|
71
|
+
? contentType.includes("text/plain") || contentType.includes("text/markdown")
|
|
72
|
+
: contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
|
|
73
|
+
if (!isExpectedType) {
|
|
74
|
+
throw new Error(`unsupported content type: ${contentType || "missing"}`);
|
|
75
|
+
}
|
|
76
|
+
return readBodyWithLimit(response, limits.maxResponseBytes);
|
|
77
|
+
}
|
|
78
|
+
throw new Error("too many redirects");
|
|
79
|
+
}
|
|
80
|
+
async function crawlProvider(provider, source, limits) {
|
|
81
|
+
const queue = [];
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (const seedUrl of source.seedUrls) {
|
|
84
|
+
try {
|
|
85
|
+
queue.push({ url: resolveAllowedUrl(provider, seedUrl), depth: 0 });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
errors.push(`${seedUrl}: ${message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const queued = new Set(queue.map((item) => item.url.href));
|
|
93
|
+
const visited = new Set();
|
|
94
|
+
const documents = [];
|
|
95
|
+
while (queue.length > 0 && visited.size < limits.maxPagesPerProvider) {
|
|
96
|
+
const item = queue.shift();
|
|
97
|
+
if (!item || visited.has(item.url.href))
|
|
98
|
+
continue;
|
|
99
|
+
visited.add(item.url.href);
|
|
100
|
+
try {
|
|
101
|
+
const content = await fetchAllowedContent(provider, item.url, limits);
|
|
102
|
+
const sourceMetadata = {
|
|
103
|
+
provider: provider.id,
|
|
104
|
+
sourceKind: source.sourceKind,
|
|
105
|
+
};
|
|
106
|
+
const format = provider.responseFormat(item.url);
|
|
107
|
+
const page = format === "docc-json"
|
|
108
|
+
? extractAppleDocCPage(content, item.url.href, sourceMetadata)
|
|
109
|
+
: format === "markdown"
|
|
110
|
+
? extractMarkdownDocumentationPage(content, item.url.href, provider.platform, sourceMetadata)
|
|
111
|
+
: format === "youtrack-json"
|
|
112
|
+
? extractYouTrackIssue(content, item.url.href, sourceMetadata)
|
|
113
|
+
: extractDocumentationPage(content, item.url.href, provider.platform, sourceMetadata);
|
|
114
|
+
if (page) {
|
|
115
|
+
documents.push(page.document);
|
|
116
|
+
if (item.depth < limits.maxDepth) {
|
|
117
|
+
for (const href of page.links) {
|
|
118
|
+
try {
|
|
119
|
+
const nextUrl = resolveAllowedUrl(provider, href, item.url.href);
|
|
120
|
+
if (!queued.has(nextUrl.href) && !visited.has(nextUrl.href)) {
|
|
121
|
+
queued.add(nextUrl.href);
|
|
122
|
+
queue.push({ url: nextUrl, depth: item.depth + 1 });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Off-allowlist and malformed links are intentionally ignored.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
errors.push(`${item.url.href}: ${message}`);
|
|
135
|
+
}
|
|
136
|
+
if (limits.delayMs > 0 && queue.length > 0) {
|
|
137
|
+
await sleep(limits.delayMs);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { provider: provider.id, platform: provider.platform, documents, errors };
|
|
141
|
+
}
|
|
142
|
+
export async function readSourcesConfig(configPath) {
|
|
143
|
+
return sourcesConfigSchema.parse(JSON.parse(await readFile(configPath, "utf8")));
|
|
144
|
+
}
|
|
145
|
+
export function resolveIndexOutputPath(configPath, configuredOutput, outputPath) {
|
|
146
|
+
if (outputPath)
|
|
147
|
+
return path.resolve(outputPath);
|
|
148
|
+
return path.resolve(path.dirname(path.resolve(configPath)), configuredOutput);
|
|
149
|
+
}
|
|
150
|
+
export async function updateDocumentationIndex(options) {
|
|
151
|
+
const absoluteConfigPath = path.resolve(options.configPath);
|
|
152
|
+
const config = await readSourcesConfig(absoluteConfigPath);
|
|
153
|
+
const selectedPlatforms = new Set(options.platforms ?? PLATFORMS);
|
|
154
|
+
const limits = {
|
|
155
|
+
...config.crawl,
|
|
156
|
+
...(options.maxPagesPerProvider ? { maxPagesPerProvider: options.maxPagesPerProvider } : {}),
|
|
157
|
+
};
|
|
158
|
+
const selectedSources = config.sources.filter((source) => selectedPlatforms.has(getProvider(source.provider).platform));
|
|
159
|
+
if (selectedSources.length === 0) {
|
|
160
|
+
throw new Error("No configured sources matched the selected platforms");
|
|
161
|
+
}
|
|
162
|
+
const crawlResults = await Promise.all(selectedSources.map((source) => {
|
|
163
|
+
const sourceLimits = {
|
|
164
|
+
...limits,
|
|
165
|
+
maxPagesPerProvider: Math.min(limits.maxPagesPerProvider, source.maxPages ?? limits.maxPagesPerProvider),
|
|
166
|
+
maxDepth: Math.min(limits.maxDepth, source.maxDepth ?? limits.maxDepth),
|
|
167
|
+
};
|
|
168
|
+
return crawlProvider(getProvider(source.provider), source, sourceLimits);
|
|
169
|
+
}));
|
|
170
|
+
const indexedAt = new Date().toISOString();
|
|
171
|
+
const documents = crawlResults.flatMap((result) => result.documents);
|
|
172
|
+
const chunks = documents.flatMap((document) => chunkDocument(document, indexedAt));
|
|
173
|
+
if (chunks.length === 0) {
|
|
174
|
+
const details = crawlResults
|
|
175
|
+
.flatMap((result) => result.errors)
|
|
176
|
+
.slice(0, 10)
|
|
177
|
+
.join("\n");
|
|
178
|
+
throw new Error(`Index update produced no searchable content${details ? `:\n${details}` : ""}`);
|
|
179
|
+
}
|
|
180
|
+
const outputPath = resolveIndexOutputPath(absoluteConfigPath, config.output, options.outputPath);
|
|
181
|
+
const index = buildSearchIndex(chunks, documents.length, indexedAt);
|
|
182
|
+
await writeSearchIndex(outputPath, index.serialized);
|
|
183
|
+
return {
|
|
184
|
+
outputPath,
|
|
185
|
+
documentCount: documents.length,
|
|
186
|
+
chunkCount: chunks.length,
|
|
187
|
+
providers: crawlResults.map((result) => ({
|
|
188
|
+
provider: result.provider,
|
|
189
|
+
platform: result.platform,
|
|
190
|
+
documentCount: result.documents.length,
|
|
191
|
+
errors: result.errors,
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Expo embeds this search-only key in docs.expo.dev's browser client. It cannot
|
|
2
|
+
// browse or mutate the index and is not a secret. Expo-provider searches send
|
|
3
|
+
// only the already-sanitized API/concept query.
|
|
4
|
+
import * as cheerio from "cheerio";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { expoProvider, resolveAllowedUrl } from "./providers.js";
|
|
7
|
+
import { readBodyWithLimit } from "./response.js";
|
|
8
|
+
const EXPO_ALGOLIA_ENDPOINT = "https://qex7pb7d46-dsn.algolia.net/1/indexes/expo/query";
|
|
9
|
+
const EXPO_ALGOLIA_APPLICATION_ID = "QEX7PB7D46";
|
|
10
|
+
const EXPO_ALGOLIA_PUBLIC_SEARCH_KEY = "6652d26570e8628af4601e1d78ad456b";
|
|
11
|
+
const MAX_HITS = 10;
|
|
12
|
+
const hitSchema = z.object({
|
|
13
|
+
objectID: z.string().max(500),
|
|
14
|
+
url: z.string().url().max(2048),
|
|
15
|
+
content: z.string().max(50_000).nullable().optional(),
|
|
16
|
+
language: z.string().max(50).optional(),
|
|
17
|
+
hierarchy: z.record(z.string().max(50), z.string().max(10_000).nullable()).optional(),
|
|
18
|
+
});
|
|
19
|
+
const responseSchema = z.object({
|
|
20
|
+
hits: z.array(hitSchema).max(MAX_HITS),
|
|
21
|
+
});
|
|
22
|
+
function text(value) {
|
|
23
|
+
const $ = cheerio.load(`<body>${value}</body>`);
|
|
24
|
+
$("script,style,noscript").remove();
|
|
25
|
+
return $("body").text().replace(/\s+/g, " ").trim();
|
|
26
|
+
}
|
|
27
|
+
export function extractExpoAlgoliaDocuments(json) {
|
|
28
|
+
const response = responseSchema.parse(JSON.parse(json));
|
|
29
|
+
const documents = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const hit of response.hits) {
|
|
32
|
+
const originalUrl = new URL(hit.url);
|
|
33
|
+
const canonicalUrl = resolveAllowedUrl(expoProvider, hit.url);
|
|
34
|
+
canonicalUrl.hash = originalUrl.hash.slice(0, 300);
|
|
35
|
+
const url = canonicalUrl.href;
|
|
36
|
+
const hierarchy = Object.entries(hit.hierarchy ?? {})
|
|
37
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
38
|
+
.map(([, value]) => (value ? text(value) : ""))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
const title = hierarchy.at(-1) ?? hierarchy[0];
|
|
41
|
+
const body = [...hierarchy, hit.content ? text(hit.content) : ""].filter(Boolean).join("\n\n");
|
|
42
|
+
if (!title || body.length < 20 || seen.has(hit.objectID))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(hit.objectID);
|
|
45
|
+
documents.push({
|
|
46
|
+
platform: "react-native",
|
|
47
|
+
provider: "expo",
|
|
48
|
+
sourceKind: "official-api",
|
|
49
|
+
title,
|
|
50
|
+
url,
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return documents;
|
|
55
|
+
}
|
|
56
|
+
async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
|
|
57
|
+
const response = await fetch(EXPO_ALGOLIA_ENDPOINT, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
redirect: "error",
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
headers: {
|
|
62
|
+
"content-type": "application/json",
|
|
63
|
+
"x-algolia-api-key": EXPO_ALGOLIA_PUBLIC_SEARCH_KEY,
|
|
64
|
+
"x-algolia-application-id": EXPO_ALGOLIA_APPLICATION_ID,
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
params: new URLSearchParams({
|
|
68
|
+
query,
|
|
69
|
+
hitsPerPage: String(limit),
|
|
70
|
+
page: "0",
|
|
71
|
+
}).toString(),
|
|
72
|
+
facetFilters: [["version:none", "version:latest"]],
|
|
73
|
+
attributesToRetrieve: ["objectID", "url", "content", "hierarchy", "language"],
|
|
74
|
+
attributesToHighlight: [],
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`Expo Algolia returned HTTP ${response.status}`);
|
|
79
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
80
|
+
if (!contentType.includes("json")) {
|
|
81
|
+
throw new Error(`Expo Algolia returned unsupported content type: ${contentType || "missing"}`);
|
|
82
|
+
}
|
|
83
|
+
const body = await readBodyWithLimit(response, maxResponseBytes);
|
|
84
|
+
return extractExpoAlgoliaDocuments(body);
|
|
85
|
+
}
|
|
86
|
+
export async function searchExpoAlgolia(query, limit) {
|
|
87
|
+
const normalized = query.replace(/\s+/g, " ").trim();
|
|
88
|
+
if (!normalized || normalized.length > 300) {
|
|
89
|
+
throw new Error("Expo Algolia query must contain between 1 and 300 characters");
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
|
|
92
|
+
throw new Error("Expo Algolia result limit must be between 1 and 10");
|
|
93
|
+
}
|
|
94
|
+
return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000);
|
|
95
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
const discardedSelectors = [
|
|
4
|
+
"script",
|
|
5
|
+
"style",
|
|
6
|
+
"noscript",
|
|
7
|
+
"svg",
|
|
8
|
+
"nav",
|
|
9
|
+
"footer",
|
|
10
|
+
"form",
|
|
11
|
+
"button",
|
|
12
|
+
"[hidden]",
|
|
13
|
+
"[aria-hidden='true']",
|
|
14
|
+
].join(",");
|
|
15
|
+
const blockSelectors = [
|
|
16
|
+
"address",
|
|
17
|
+
"article",
|
|
18
|
+
"aside",
|
|
19
|
+
"blockquote",
|
|
20
|
+
"br",
|
|
21
|
+
"dd",
|
|
22
|
+
"div",
|
|
23
|
+
"dl",
|
|
24
|
+
"dt",
|
|
25
|
+
"figcaption",
|
|
26
|
+
"figure",
|
|
27
|
+
"h1",
|
|
28
|
+
"h2",
|
|
29
|
+
"h3",
|
|
30
|
+
"h4",
|
|
31
|
+
"h5",
|
|
32
|
+
"h6",
|
|
33
|
+
"li",
|
|
34
|
+
"main",
|
|
35
|
+
"p",
|
|
36
|
+
"pre",
|
|
37
|
+
"section",
|
|
38
|
+
"table",
|
|
39
|
+
"td",
|
|
40
|
+
"th",
|
|
41
|
+
"tr",
|
|
42
|
+
].join(",");
|
|
43
|
+
function cleanText(value) {
|
|
44
|
+
return value
|
|
45
|
+
.replace(/\r/g, "")
|
|
46
|
+
.replace(/[ \t]+/g, " ")
|
|
47
|
+
.replace(/ *\n */g, "\n")
|
|
48
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
function cleanTitle(value) {
|
|
52
|
+
return cleanText(value)
|
|
53
|
+
.replace(/\s*[|–—-]\s*Apple Developer Documentation$/i, "")
|
|
54
|
+
.replace(/\s*[|–—-]\s*Android Developers$/i, "")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
export function extractDocumentationPage(html, url, platform, source = {}) {
|
|
58
|
+
const $ = cheerio.load(html);
|
|
59
|
+
$(discardedSelectors).remove();
|
|
60
|
+
const main = $("main").first().length
|
|
61
|
+
? $("main").first()
|
|
62
|
+
: $("article").first().length
|
|
63
|
+
? $("article").first()
|
|
64
|
+
: $("[role='main']").first().length
|
|
65
|
+
? $("[role='main']").first()
|
|
66
|
+
: $("body").first();
|
|
67
|
+
main.find(blockSelectors).each((_, element) => {
|
|
68
|
+
$(element).append("\n");
|
|
69
|
+
});
|
|
70
|
+
const body = cleanText(main.text());
|
|
71
|
+
const title = cleanTitle($("h1").first().text() || $("title").first().text());
|
|
72
|
+
if (!title || body.length < 80) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const links = new Set();
|
|
76
|
+
$("a[href]").each((_, element) => {
|
|
77
|
+
const href = $(element).attr("href");
|
|
78
|
+
if (href) {
|
|
79
|
+
links.add(href);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
document: { platform, title, url, body, ...source },
|
|
84
|
+
links: [...links],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function chunkDocument(document, indexedAt, targetCharacters = 1400, overlapCharacters = 180) {
|
|
88
|
+
const paragraphs = document.body
|
|
89
|
+
.split(/\n{2,}/)
|
|
90
|
+
.map((paragraph) => paragraph.trim())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
const passages = [];
|
|
93
|
+
let current = "";
|
|
94
|
+
const flush = () => {
|
|
95
|
+
const passage = current.trim();
|
|
96
|
+
if (passage) {
|
|
97
|
+
passages.push(passage);
|
|
98
|
+
}
|
|
99
|
+
current = passage.slice(Math.max(0, passage.length - overlapCharacters));
|
|
100
|
+
};
|
|
101
|
+
for (const paragraph of paragraphs) {
|
|
102
|
+
if (current && current.length + paragraph.length + 2 > targetCharacters) {
|
|
103
|
+
flush();
|
|
104
|
+
}
|
|
105
|
+
if (paragraph.length > targetCharacters * 2) {
|
|
106
|
+
let remaining = paragraph;
|
|
107
|
+
while (remaining.length > targetCharacters) {
|
|
108
|
+
const splitAt = remaining.lastIndexOf(" ", targetCharacters);
|
|
109
|
+
const boundary = splitAt > targetCharacters / 2 ? splitAt : targetCharacters;
|
|
110
|
+
current = `${current}\n\n${remaining.slice(0, boundary)}`.trim();
|
|
111
|
+
flush();
|
|
112
|
+
remaining = remaining.slice(boundary).trim();
|
|
113
|
+
}
|
|
114
|
+
current = `${current}\n\n${remaining}`.trim();
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
current = `${current}\n\n${paragraph}`.trim();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
flush();
|
|
121
|
+
const documentId = createHash("sha256")
|
|
122
|
+
.update(`${document.provider ?? document.platform}\0${document.url}\0${document.title}`)
|
|
123
|
+
.digest("hex")
|
|
124
|
+
.slice(0, 16);
|
|
125
|
+
return passages.map((passage, chunkIndex) => ({
|
|
126
|
+
...document,
|
|
127
|
+
body: undefined,
|
|
128
|
+
id: `${document.provider ?? document.platform}:${documentId}#${chunkIndex}`,
|
|
129
|
+
passage,
|
|
130
|
+
indexedAt,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function extractMarkdownDocumentationPage(markdown, url, platform, source = {}) {
|
|
2
|
+
const sanitized = markdown
|
|
3
|
+
.replace(/<!--[^]*?-->/g, "")
|
|
4
|
+
.replace(/<script\b[^>]*>[^]*?<\/script>/gi, "")
|
|
5
|
+
.replace(/<style\b[^>]*>[^]*?<\/style>/gi, "")
|
|
6
|
+
.replace(/\r/g, "");
|
|
7
|
+
const withoutFrontMatter = sanitized.replace(/^---\n[^]*?\n---\n/, "");
|
|
8
|
+
const title = withoutFrontMatter.match(/^#\s+(.+)$/m)?.[1]?.trim() ??
|
|
9
|
+
withoutFrontMatter.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)?.[1]?.trim() ??
|
|
10
|
+
"";
|
|
11
|
+
const body = withoutFrontMatter
|
|
12
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
13
|
+
.replace(/^(?:=+|-+)\s*$/gm, "")
|
|
14
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
15
|
+
.replace(/<[^>]+>/g, " ")
|
|
16
|
+
.replace(/[ \t]+/g, " ")
|
|
17
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
18
|
+
.trim();
|
|
19
|
+
if (!title || body.length < 80)
|
|
20
|
+
return null;
|
|
21
|
+
const links = [...sanitized.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)].flatMap((match) => (match[1] ? [match[1]] : []));
|
|
22
|
+
return {
|
|
23
|
+
document: {
|
|
24
|
+
platform,
|
|
25
|
+
title,
|
|
26
|
+
url,
|
|
27
|
+
body,
|
|
28
|
+
...source,
|
|
29
|
+
},
|
|
30
|
+
links,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
export const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
3
|
+
export const defaultConfigPath = fileURLToPath(new URL("../../research/sources.json", import.meta.url));
|
|
4
|
+
export const defaultIndexPath = fileURLToPath(new URL("../../research/data/docs-index.json", import.meta.url));
|