@link-assistant/web-capture 1.10.2 → 1.10.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Add live StackOverflow download coverage across JavaScript output formats and browser engines.
8
+
9
+ ## 1.10.3
10
+
11
+ ### Patch Changes
12
+
13
+ - Add compact GitHub repository capture for txt and markdown output, including repository metadata, the root file tree, and README content.
14
+
3
15
  ## 1.10.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -52,6 +52,10 @@ web-capture https://example.com --format html -o page.html
52
52
  # Capture raw paste text
53
53
  web-capture https://xpaste.pro/p/t4q0Lsp0 --format txt -o paste.txt
54
54
 
55
+ # Capture a GitHub repository as compact text or Markdown
56
+ web-capture https://github.com/link-assistant/web-capture --format txt -o repository.txt
57
+ web-capture https://github.com/link-assistant/web-capture --format markdown -o repository.md
58
+
55
59
  # Take a PNG screenshot
56
60
  web-capture https://example.com --format png -o screenshot.png
57
61
 
@@ -153,6 +157,11 @@ containing `index.md`, `xpaste-pro-<id>.md`, and `xpaste-pro-<id>.txt`.
153
157
  Canonical `/p/<id>`, `/p/<id>/raw`, `/ru/p/<id>`, and `/en/p/<id>` URLs are
154
158
  normalized before capture.
155
159
 
160
+ For plain GitHub repository URLs such as `https://github.com/owner/repo`,
161
+ `/markdown` returns a compact repository snapshot with repository metadata, the
162
+ root file tree, and README content. GitHub subpages continue through the regular
163
+ HTML-to-Markdown conversion path.
164
+
156
165
  | Parameter | Required | Description | Default |
157
166
  | ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
158
167
  | `url` | Yes | URL to fetch | - |
@@ -171,6 +180,9 @@ Returns raw text content as a `.txt` attachment. xpaste.pro paste URLs are
171
180
  normalized to their `/raw` endpoint, including localized `/ru/p/<id>` and
172
181
  `/en/p/<id>` URLs.
173
182
 
183
+ Plain GitHub repository URLs return a compact `.txt` snapshot with repository
184
+ metadata, the root file tree, and README content.
185
+
174
186
  | Parameter | Required | Description | Default |
175
187
  | --------- | -------- | ------------ | ------- |
176
188
  | `url` | Yes | URL to fetch | - |
@@ -490,6 +502,9 @@ with environment variables:
490
502
  # Download the Wikipedia page (markdown + image) in every supported engine
491
503
  WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
492
504
 
505
+ # Download a GitHub repository page as compact txt/markdown, original HTML, and screenshots
506
+ GITHUB_REPOSITORY_INTEGRATION=true npm test -- --testPathPattern="github-readme"
507
+
493
508
  # Habr articles and public Google Docs live suites
494
509
  HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
495
510
  GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
@@ -614,6 +614,12 @@ async function captureUrl(url, options) {
614
614
  captureGoogleDocWithBrowserOrFallback,
615
615
  selectGoogleDocsCaptureMethod,
616
616
  } = await import('../src/gdocs.js');
617
+ const {
618
+ fetchGithubRepositorySnapshot,
619
+ formatGithubRepositoryMarkdown,
620
+ formatGithubRepositoryText,
621
+ isGithubRepositoryUrl,
622
+ } = await import('../src/github.js');
617
623
 
618
624
  const normalizedFormat = format.toLowerCase();
619
625
  log.debug(() => ({
@@ -921,15 +927,22 @@ async function captureUrl(url, options) {
921
927
 
922
928
  try {
923
929
  if (normalizedFormat === 'txt' || normalizedFormat === 'text') {
924
- const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
925
- if (!response.ok) {
926
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
927
- }
928
- const contentType = response.headers.get('content-type') || 'text/plain';
929
- if (!contentType.includes('text/')) {
930
- throw new Error(`Expected text content, got ${contentType}`);
930
+ let text;
931
+ if (isGithubRepositoryUrl(absoluteUrl)) {
932
+ const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
933
+ text = formatGithubRepositoryText(snapshot);
934
+ } else {
935
+ const response = await fetch(normalizeUrlForTextContent(absoluteUrl));
936
+ if (!response.ok) {
937
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
938
+ }
939
+ const contentType =
940
+ response.headers.get('content-type') || 'text/plain';
941
+ if (!contentType.includes('text/')) {
942
+ throw new Error(`Expected text content, got ${contentType}`);
943
+ }
944
+ text = await response.text();
931
945
  }
932
- const text = await response.text();
933
946
  const output =
934
947
  explicitOutput === '-'
935
948
  ? null
@@ -1151,17 +1164,27 @@ async function captureUrl(url, options) {
1151
1164
  console.error(`Archive saved to: ${outPath}`);
1152
1165
  } else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
1153
1166
  // Markdown format — enhanced conversion is now the default
1154
- const html = await fetchHtml(absoluteUrl);
1155
- const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
1156
- const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
1157
- extractLatex: options.extractLatex,
1158
- extractMetadata: options.extractMetadata,
1159
- postProcess: options.postProcess,
1160
- detectCodeLanguage: options.detectCodeLanguage,
1161
- contentSelector: options.contentSelector,
1162
- bodySelector: options.bodySelector,
1163
- });
1164
- const markdown = result.markdown;
1167
+ let markdown;
1168
+ if (
1169
+ isGithubRepositoryUrl(absoluteUrl) &&
1170
+ !options.contentSelector &&
1171
+ !options.bodySelector
1172
+ ) {
1173
+ const snapshot = await fetchGithubRepositorySnapshot(absoluteUrl);
1174
+ markdown = formatGithubRepositoryMarkdown(snapshot);
1175
+ } else {
1176
+ const html = await fetchHtml(absoluteUrl);
1177
+ const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
1178
+ const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
1179
+ extractLatex: options.extractLatex,
1180
+ extractMetadata: options.extractMetadata,
1181
+ postProcess: options.postProcess,
1182
+ detectCodeLanguage: options.detectCodeLanguage,
1183
+ contentSelector: options.contentSelector,
1184
+ bodySelector: options.bodySelector,
1185
+ });
1186
+ markdown = result.markdown;
1187
+ }
1165
1188
 
1166
1189
  const output =
1167
1190
  explicitOutput === '-'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.2",
3
+ "version": "1.10.4",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/github.js ADDED
@@ -0,0 +1,285 @@
1
+ import fetch from 'node-fetch';
2
+ import { URL } from 'node:url';
3
+
4
+ const GITHUB_API_BASE = 'https://api.github.com';
5
+ const GITHUB_USER_AGENT = 'web-capture';
6
+
7
+ export function parseGithubRepositoryUrl(url) {
8
+ try {
9
+ const parsed = new URL(url);
10
+ const host = parsed.hostname.toLowerCase();
11
+ if (host !== 'github.com' && host !== 'www.github.com') {
12
+ return null;
13
+ }
14
+
15
+ const parts = parsed.pathname.split('/').filter(Boolean);
16
+ if (parts.length !== 2) {
17
+ return null;
18
+ }
19
+
20
+ const [owner, repo] = parts;
21
+ if (!owner || !repo) {
22
+ return null;
23
+ }
24
+
25
+ return {
26
+ owner,
27
+ repo,
28
+ fullName: `${owner}/${repo}`,
29
+ htmlUrl: `https://github.com/${owner}/${repo}`,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function isGithubRepositoryUrl(url) {
37
+ return Boolean(parseGithubRepositoryUrl(url));
38
+ }
39
+
40
+ export function getGithubRepositoryTextFilename(url) {
41
+ const parsed = parseGithubRepositoryUrl(url);
42
+ return parsed ? `${parsed.owner}-${parsed.repo}.txt` : null;
43
+ }
44
+
45
+ export async function fetchGithubRepositorySnapshot(url) {
46
+ const parsed = parseGithubRepositoryUrl(url);
47
+ if (!parsed) {
48
+ throw new Error(`Not a GitHub repository URL: ${url}`);
49
+ }
50
+
51
+ const repository = await fetchGithubJson(
52
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}`
53
+ );
54
+ const defaultBranch = repository.default_branch || 'main';
55
+
56
+ const [readme, tree] = await Promise.all([
57
+ fetchGithubReadme(parsed, defaultBranch),
58
+ fetchGithubRootTree(parsed, defaultBranch),
59
+ ]);
60
+
61
+ return {
62
+ sourceUrl: parsed.htmlUrl,
63
+ repository,
64
+ defaultBranch,
65
+ readme,
66
+ tree,
67
+ };
68
+ }
69
+
70
+ export function formatGithubRepositoryMarkdown(snapshot) {
71
+ const { repository, defaultBranch, readme, tree, sourceUrl } = snapshot;
72
+ const lines = [
73
+ `# ${repository.full_name}`,
74
+ '',
75
+ repository.description ? `> ${repository.description}` : null,
76
+ '',
77
+ '## Repository',
78
+ '',
79
+ `- URL: ${repository.html_url || sourceUrl}`,
80
+ `- Default branch: \`${defaultBranch}\``,
81
+ repository.language ? `- Primary language: ${repository.language}` : null,
82
+ numberLine('Stars', repository.stargazers_count),
83
+ numberLine('Forks', repository.forks_count),
84
+ numberLine('Open issues', repository.open_issues_count),
85
+ repository.license?.spdx_id
86
+ ? `- License: ${repository.license.spdx_id}`
87
+ : null,
88
+ topicsLine(repository.topics),
89
+ '',
90
+ '## Files',
91
+ '',
92
+ ].filter((line) => line !== null);
93
+
94
+ if (tree.length > 0) {
95
+ for (const item of tree) {
96
+ const label = item.type === 'dir' ? `${item.name}/` : item.name;
97
+ const suffix =
98
+ item.type === 'file' && typeof item.size === 'number'
99
+ ? ` (${formatBytes(item.size)})`
100
+ : '';
101
+ lines.push(`- [${label}](${item.html_url})${suffix}`);
102
+ }
103
+ } else {
104
+ lines.push('- No root files returned by the GitHub API.');
105
+ }
106
+
107
+ lines.push('', `## ${readme?.path || 'README'}`, '');
108
+ if (readme?.content) {
109
+ lines.push(readme.content.trimEnd(), '');
110
+ } else {
111
+ lines.push('README content was not returned by the GitHub API.', '');
112
+ }
113
+
114
+ return lines.join('\n');
115
+ }
116
+
117
+ export function formatGithubRepositoryText(snapshot) {
118
+ const { repository, defaultBranch, readme, tree, sourceUrl } = snapshot;
119
+ const lines = [
120
+ `Repository: ${repository.full_name}`,
121
+ repository.description ? `Description: ${repository.description}` : null,
122
+ `URL: ${repository.html_url || sourceUrl}`,
123
+ `Default branch: ${defaultBranch}`,
124
+ repository.language ? `Primary language: ${repository.language}` : null,
125
+ plainNumberLine('Stars', repository.stargazers_count),
126
+ plainNumberLine('Forks', repository.forks_count),
127
+ plainNumberLine('Open issues', repository.open_issues_count),
128
+ repository.license?.spdx_id
129
+ ? `License: ${repository.license.spdx_id}`
130
+ : null,
131
+ repository.topics?.length
132
+ ? `Topics: ${repository.topics.join(', ')}`
133
+ : null,
134
+ '',
135
+ 'Files:',
136
+ ].filter((line) => line !== null);
137
+
138
+ if (tree.length > 0) {
139
+ for (const item of tree) {
140
+ const label = item.type === 'dir' ? `${item.name}/` : item.name;
141
+ const suffix =
142
+ item.type === 'file' && typeof item.size === 'number'
143
+ ? ` (${formatBytes(item.size)})`
144
+ : '';
145
+ lines.push(`- ${label}${suffix}`);
146
+ }
147
+ } else {
148
+ lines.push('- No root files returned by the GitHub API.');
149
+ }
150
+
151
+ lines.push('', `${readme?.path || 'README'}:`, '');
152
+ if (readme?.content) {
153
+ lines.push(readme.content.trimEnd(), '');
154
+ } else {
155
+ lines.push('README content was not returned by the GitHub API.', '');
156
+ }
157
+
158
+ return lines.join('\n');
159
+ }
160
+
161
+ async function fetchGithubReadme(parsed, defaultBranch) {
162
+ const readme = await fetchGithubJson(
163
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}/readme?ref=${encodeURIComponent(defaultBranch)}`,
164
+ { optional: true }
165
+ );
166
+ if (!readme) {
167
+ return null;
168
+ }
169
+
170
+ let content = null;
171
+ if (readme.content && readme.encoding === 'base64') {
172
+ content = Buffer.from(
173
+ readme.content.replace(/\s+/g, ''),
174
+ 'base64'
175
+ ).toString('utf8');
176
+ } else if (readme.download_url) {
177
+ content = await fetchGithubText(readme.download_url, { optional: true });
178
+ }
179
+
180
+ return {
181
+ name: readme.name || 'README',
182
+ path: readme.path || readme.name || 'README',
183
+ htmlUrl: readme.html_url,
184
+ content,
185
+ };
186
+ }
187
+
188
+ async function fetchGithubRootTree(parsed, defaultBranch) {
189
+ const tree = await fetchGithubJson(
190
+ `${GITHUB_API_BASE}/repos/${encodePath(parsed.owner)}/${encodePath(parsed.repo)}/contents?ref=${encodeURIComponent(defaultBranch)}`,
191
+ { optional: true }
192
+ );
193
+ if (!Array.isArray(tree)) {
194
+ return [];
195
+ }
196
+
197
+ return tree
198
+ .map((item) => {
199
+ const pathKind = item.type === 'dir' ? 'tree' : 'blob';
200
+ return {
201
+ name: item.name,
202
+ path: item.path,
203
+ type: item.type,
204
+ size: item.size,
205
+ html_url:
206
+ item.html_url ||
207
+ `https://github.com/${parsed.owner}/${parsed.repo}/${pathKind}/${defaultBranch}/${item.path}`,
208
+ };
209
+ })
210
+ .sort((a, b) => {
211
+ if (a.type !== b.type) {
212
+ return a.type === 'dir' ? -1 : 1;
213
+ }
214
+ return a.name.localeCompare(b.name);
215
+ });
216
+ }
217
+
218
+ async function fetchGithubJson(url, options = {}) {
219
+ const response = await fetch(url, {
220
+ headers: githubHeaders('application/vnd.github+json'),
221
+ });
222
+ if (options.optional && response.status === 404) {
223
+ return null;
224
+ }
225
+ if (!response.ok) {
226
+ throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
227
+ }
228
+ return response.json();
229
+ }
230
+
231
+ async function fetchGithubText(url, options = {}) {
232
+ const response = await fetch(url, {
233
+ headers: githubHeaders('text/plain'),
234
+ });
235
+ if (options.optional && response.status === 404) {
236
+ return null;
237
+ }
238
+ if (!response.ok) {
239
+ throw new Error(`GitHub raw ${response.status}: ${response.statusText}`);
240
+ }
241
+ return response.text();
242
+ }
243
+
244
+ function githubHeaders(accept) {
245
+ const headers = {
246
+ Accept: accept,
247
+ 'User-Agent': GITHUB_USER_AGENT,
248
+ 'X-GitHub-Api-Version': '2022-11-28',
249
+ };
250
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
251
+ if (token) {
252
+ headers.Authorization = `Bearer ${token}`;
253
+ }
254
+ return headers;
255
+ }
256
+
257
+ function encodePath(value) {
258
+ return encodeURIComponent(value);
259
+ }
260
+
261
+ function numberLine(label, value) {
262
+ return typeof value === 'number'
263
+ ? `- ${label}: ${value.toLocaleString()}`
264
+ : null;
265
+ }
266
+
267
+ function plainNumberLine(label, value) {
268
+ return typeof value === 'number'
269
+ ? `${label}: ${value.toLocaleString()}`
270
+ : null;
271
+ }
272
+
273
+ function topicsLine(topics) {
274
+ return topics?.length ? `- Topics: ${topics.join(', ')}` : null;
275
+ }
276
+
277
+ function formatBytes(size) {
278
+ if (size < 1024) {
279
+ return `${size} B`;
280
+ }
281
+ if (size < 1024 * 1024) {
282
+ return `${(size / 1024).toFixed(1)} KB`;
283
+ }
284
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
285
+ }
package/src/html.js CHANGED
@@ -1,4 +1,9 @@
1
- import { fetchHtml, convertToUtf8, convertRelativeUrls } from './lib.js';
1
+ import {
2
+ fetchHtml,
3
+ convertToUtf8,
4
+ convertRelativeUrls,
5
+ isStackOverflowQuestionUrl,
6
+ } from './lib.js';
2
7
  import { createBrowser, getBrowserEngine } from './browser.js';
3
8
 
4
9
  export async function htmlHandler(req, res) {
@@ -12,6 +17,11 @@ export async function htmlHandler(req, res) {
12
17
 
13
18
  // First try to fetch HTML directly
14
19
  const html = await fetchHtml(absoluteUrl);
20
+ if (isStackOverflowQuestionUrl(absoluteUrl)) {
21
+ const utf8Html = convertToUtf8(html);
22
+ const absoluteHtml = convertRelativeUrls(utf8Html, absoluteUrl);
23
+ return res.type('text/html; charset=utf-8').send(absoluteHtml);
24
+ }
15
25
 
16
26
  // Check if it's valid HTML and contains JavaScript
17
27
  const hasJavaScript =
package/src/image.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createBrowser, getBrowserEngine } from './browser.js';
2
+ import { isStackOverflowQuestionUrl } from './lib.js';
2
3
  import { dismissPopups, scrollToLoadContent } from './popups.js';
3
4
 
4
5
  /**
@@ -39,6 +40,10 @@ export async function imageHandler(req, res) {
39
40
  try {
40
41
  const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
41
42
  const engine = getBrowserEngine(req);
43
+ const waitUntil =
44
+ engine === 'playwright' && isStackOverflowQuestionUrl(absoluteUrl)
45
+ ? 'load'
46
+ : 'networkidle0';
42
47
  const browserOpts = theme ? { colorScheme: theme } : {};
43
48
  const browser = await createBrowser(engine, browserOpts);
44
49
  try {
@@ -52,7 +57,7 @@ export async function imageHandler(req, res) {
52
57
  );
53
58
  await page.setViewport({ width, height });
54
59
  await page.goto(absoluteUrl, {
55
- waitUntil: 'networkidle0',
60
+ waitUntil,
56
61
  timeout: 30000,
57
62
  });
58
63
 
package/src/lib.js CHANGED
@@ -4,27 +4,87 @@ import fetch from 'node-fetch';
4
4
  import * as cheerio from 'cheerio';
5
5
  import TurndownService from 'turndown';
6
6
  import iconv from 'iconv-lite';
7
- import { URL } from 'url';
7
+ import { URL, URLSearchParams } from 'url';
8
8
  import turndownPluginGfm from 'turndown-plugin-gfm';
9
9
  import he from 'he';
10
10
  import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
11
11
  import { extractMetadata } from './metadata.js';
12
12
  import { postProcessMarkdown } from './postprocess.js';
13
+ import { retry } from './retry.js';
14
+
15
+ const STACKPRINTER_RETRIES = 3;
16
+ const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
13
17
 
14
18
  export async function fetchHtml(url) {
15
19
  if (!url) {
16
20
  throw new Error('Missing URL parameter');
17
21
  }
22
+ const stackPrinterUrl = stackPrinterUrlForQuestion(url);
23
+ if (stackPrinterUrl) {
24
+ return await fetchStackPrinterHtml(stackPrinterUrl);
25
+ }
26
+
18
27
  const response = await fetch(url);
19
28
  return response.text();
20
29
  }
21
30
 
31
+ async function fetchStackPrinterHtml(url) {
32
+ return await retry(
33
+ async () => {
34
+ const response = await fetch(url);
35
+ if (!response.ok) {
36
+ throw new Error(`StackPrinter returned HTTP ${response.status}`);
37
+ }
38
+
39
+ const html = await response.text();
40
+ if (isStackPrinterTransientError(html)) {
41
+ throw new Error('StackPrinter returned a transient error page');
42
+ }
43
+ return html;
44
+ },
45
+ {
46
+ retries: STACKPRINTER_RETRIES,
47
+ baseDelay: STACKPRINTER_RETRY_BASE_DELAY_MS,
48
+ }
49
+ );
50
+ }
51
+
52
+ function isStackPrinterTransientError(html) {
53
+ return /Ooooops|Please try again later/i.test(html);
54
+ }
55
+
56
+ export function isStackOverflowQuestionUrl(url) {
57
+ return Boolean(getStackOverflowQuestionId(url));
58
+ }
59
+
60
+ export function stackPrinterUrlForQuestion(url) {
61
+ const questionId = getStackOverflowQuestionId(url);
62
+ if (!questionId) {
63
+ return null;
64
+ }
65
+
66
+ const params = new URLSearchParams({
67
+ question: questionId,
68
+ service: 'stackoverflow',
69
+ language: 'en',
70
+ hideAnswers: 'false',
71
+ showAll: 'true',
72
+ width: '640',
73
+ });
74
+ return `https://stackprinter.appspot.com/export?${params.toString()}`;
75
+ }
76
+
22
77
  export function isTextPasteUrl(url) {
23
78
  const parsed = parseTextPasteUrl(url);
24
79
  return Boolean(parsed);
25
80
  }
26
81
 
27
82
  export function normalizeUrlForTextContent(url) {
83
+ const stackPrinterUrl = stackPrinterUrlForQuestion(url);
84
+ if (stackPrinterUrl) {
85
+ return stackPrinterUrl;
86
+ }
87
+
28
88
  const parsed = parseTextPasteUrl(url);
29
89
  if (!parsed) {
30
90
  return url;
@@ -49,6 +109,25 @@ export function getTextPasteFilename(url) {
49
109
  return pasteId ? `xpaste-pro-${pasteId}.txt` : null;
50
110
  }
51
111
 
112
+ function getStackOverflowQuestionId(url) {
113
+ try {
114
+ const parsed = new URL(url);
115
+ const hostname = parsed.hostname.replace(/^www\./, '');
116
+ if (hostname !== 'stackoverflow.com') {
117
+ return null;
118
+ }
119
+
120
+ const [, prefix, questionId] = parsed.pathname.split('/');
121
+ if (prefix !== 'questions' || !/^\d+$/.test(questionId || '')) {
122
+ return null;
123
+ }
124
+
125
+ return questionId;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
52
131
  export function appendTextPasteMarkdownAttachment(markdown, url, rawText) {
53
132
  const textFilename = getTextPasteFilename(url) || 'download.txt';
54
133
  const normalizedRawText = normalizeMarkdownAttachmentText(rawText);
package/src/markdown.js CHANGED
@@ -8,6 +8,11 @@ import {
8
8
  normalizeUrlForTextPage,
9
9
  scopeHtmlForMarkdown,
10
10
  } from './lib.js';
11
+ import {
12
+ fetchGithubRepositorySnapshot,
13
+ formatGithubRepositoryMarkdown,
14
+ isGithubRepositoryUrl,
15
+ } from './github.js';
11
16
  import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
12
17
  import { applyImageMode } from './extract-images.js';
13
18
  import archiver from 'archiver';
@@ -39,6 +44,21 @@ export async function markdownHandler(req, res) {
39
44
 
40
45
  try {
41
46
  const pageUrl = normalizeUrlForTextPage(url);
47
+ if (
48
+ format === 'text' &&
49
+ !req.query.contentSelector &&
50
+ !req.query.bodySelector &&
51
+ isGithubRepositoryUrl(pageUrl)
52
+ ) {
53
+ const snapshot = await fetchGithubRepositorySnapshot(pageUrl);
54
+ let markdown = formatGithubRepositoryMarkdown(snapshot);
55
+ const result = await applyImageMode(markdown, {
56
+ mode: embedImages ? 'embed' : 'default',
57
+ });
58
+ markdown = result.markdown;
59
+ return await sendMarkdownResponse(res, url, markdown);
60
+ }
61
+
42
62
  const html = await fetchHtml(pageUrl);
43
63
 
44
64
  if (converter === 'kreuzberg') {
package/src/txt.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import fetch from 'node-fetch';
2
2
  import { URL } from 'node:url';
3
- import { getTextPasteFilename, normalizeUrlForTextContent } from './lib.js';
3
+ import {
4
+ fetchHtml,
5
+ getTextPasteFilename,
6
+ isStackOverflowQuestionUrl,
7
+ normalizeUrlForTextContent,
8
+ } from './lib.js';
9
+ import {
10
+ fetchGithubRepositorySnapshot,
11
+ formatGithubRepositoryText,
12
+ getGithubRepositoryTextFilename,
13
+ isGithubRepositoryUrl,
14
+ } from './github.js';
4
15
 
5
16
  export async function txtHandler(req, res) {
6
17
  const url = req.query.url;
@@ -9,6 +20,17 @@ export async function txtHandler(req, res) {
9
20
  }
10
21
 
11
22
  try {
23
+ if (isGithubRepositoryUrl(url)) {
24
+ const snapshot = await fetchGithubRepositorySnapshot(url);
25
+ const text = formatGithubRepositoryText(snapshot);
26
+ return sendTextResponse(res, url, text);
27
+ }
28
+
29
+ if (isStackOverflowQuestionUrl(url)) {
30
+ const text = await fetchHtml(url);
31
+ return sendTextResponse(res, url, text);
32
+ }
33
+
12
34
  // Normalize URL to get text content (e.g., xpaste.pro -> xpaste.pro/raw)
13
35
  const textUrl = normalizeUrlForTextContent(url);
14
36
 
@@ -27,20 +49,28 @@ export async function txtHandler(req, res) {
27
49
 
28
50
  const text = await response.text();
29
51
 
30
- // Set appropriate headers for text file download
31
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
32
- res.setHeader(
33
- 'Content-Disposition',
34
- `attachment; filename="${getFilenameFromUrl(url)}"`
35
- );
36
- res.send(text);
52
+ sendTextResponse(res, url, text);
37
53
  } catch (err) {
38
54
  console.error('Text fetch error:', err);
39
55
  res.status(500).send('Error fetching text content');
40
56
  }
41
57
  }
42
58
 
59
+ function sendTextResponse(res, url, text) {
60
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
61
+ res.setHeader(
62
+ 'Content-Disposition',
63
+ `attachment; filename="${getFilenameFromUrl(url)}"`
64
+ );
65
+ res.send(text);
66
+ }
67
+
43
68
  function getFilenameFromUrl(url) {
69
+ const githubRepositoryFilename = getGithubRepositoryTextFilename(url);
70
+ if (githubRepositoryFilename) {
71
+ return githubRepositoryFilename;
72
+ }
73
+
44
74
  const textPasteFilename = getTextPasteFilename(url);
45
75
  if (textPasteFilename) {
46
76
  return textPasteFilename;
@@ -25,6 +25,50 @@ const MOCK_HTML = `<!DOCTYPE html>
25
25
  <img src="https://example.com/image.png" alt="test">
26
26
  </body></html>`;
27
27
 
28
+ function mockGithubRepository(owner = 'octocat', repo = 'Hello-World') {
29
+ const readme = '# Hello World\n\nThis README came from GitHub.';
30
+ nock('https://api.github.com')
31
+ .get(`/repos/${owner}/${repo}`)
32
+ .reply(200, {
33
+ full_name: `${owner}/${repo}`,
34
+ description: 'A friendly test repository',
35
+ html_url: `https://github.com/${owner}/${repo}`,
36
+ default_branch: 'master',
37
+ language: 'JavaScript',
38
+ stargazers_count: 42,
39
+ forks_count: 7,
40
+ open_issues_count: 3,
41
+ license: { spdx_id: 'MIT' },
42
+ topics: ['demo', 'capture'],
43
+ })
44
+ .get(`/repos/${owner}/${repo}/readme`)
45
+ .query({ ref: 'master' })
46
+ .reply(200, {
47
+ name: 'README.md',
48
+ path: 'README.md',
49
+ encoding: 'base64',
50
+ content: Buffer.from(readme, 'utf8').toString('base64'),
51
+ html_url: `https://github.com/${owner}/${repo}/blob/master/README.md`,
52
+ })
53
+ .get(`/repos/${owner}/${repo}/contents`)
54
+ .query({ ref: 'master' })
55
+ .reply(200, [
56
+ {
57
+ name: 'src',
58
+ path: 'src',
59
+ type: 'dir',
60
+ html_url: `https://github.com/${owner}/${repo}/tree/master/src`,
61
+ },
62
+ {
63
+ name: 'README.md',
64
+ path: 'README.md',
65
+ type: 'file',
66
+ size: readme.length,
67
+ html_url: `https://github.com/${owner}/${repo}/blob/master/README.md`,
68
+ },
69
+ ]);
70
+ }
71
+
28
72
  afterEach(() => {
29
73
  nock.cleanAll();
30
74
  });
@@ -70,6 +114,44 @@ describe('API Endpoint Tests', () => {
70
114
  expect(res.text).toContain('Hello world');
71
115
  });
72
116
 
117
+ it('returns a compact GitHub repository markdown snapshot', async () => {
118
+ mockGithubRepository();
119
+
120
+ const res = await request(app)
121
+ .get('/markdown')
122
+ .query({ url: 'https://github.com/octocat/Hello-World' })
123
+ .expect(200);
124
+
125
+ expect(res.headers['content-type']).toMatch(/text\/markdown/);
126
+ expect(res.text).toContain('# octocat/Hello-World');
127
+ expect(res.text).toContain('## Repository');
128
+ expect(res.text).toContain('## Files');
129
+ expect(res.text).toContain('- [src/](');
130
+ expect(res.text).toContain('## README.md');
131
+ expect(res.text).toContain('This README came from GitHub.');
132
+ expect(res.text).not.toContain('Skip to content');
133
+ expect(nock.isDone()).toBe(true);
134
+ });
135
+
136
+ it('returns a compact GitHub repository markdown snapshot for kreuzberg text format', async () => {
137
+ mockGithubRepository();
138
+
139
+ const res = await request(app)
140
+ .get('/markdown')
141
+ .query({
142
+ url: 'https://github.com/octocat/Hello-World',
143
+ converter: 'kreuzberg',
144
+ format: 'text',
145
+ })
146
+ .expect(200);
147
+
148
+ expect(res.headers['content-type']).toMatch(/text\/markdown/);
149
+ expect(res.text).toContain('# octocat/Hello-World');
150
+ expect(res.text).toContain('## Files');
151
+ expect(res.text).toContain('This README came from GitHub.');
152
+ expect(nock.isDone()).toBe(true);
153
+ });
154
+
73
155
  it('rejects unsupported converter names', async () => {
74
156
  await request(app)
75
157
  .get('/markdown')
@@ -133,6 +215,28 @@ describe('API Endpoint Tests', () => {
133
215
  });
134
216
  });
135
217
 
218
+ describe('GET /txt', () => {
219
+ it('returns a compact GitHub repository plain-text snapshot', async () => {
220
+ mockGithubRepository();
221
+
222
+ const res = await request(app)
223
+ .get('/txt')
224
+ .query({ url: 'https://github.com/octocat/Hello-World' })
225
+ .expect(200);
226
+
227
+ expect(res.headers['content-type']).toMatch(/text\/plain/);
228
+ expect(res.headers['content-disposition']).toContain(
229
+ 'filename="octocat-Hello-World.txt"'
230
+ );
231
+ expect(res.text).toContain('Repository: octocat/Hello-World');
232
+ expect(res.text).toContain('Files:');
233
+ expect(res.text).toContain('- src/');
234
+ expect(res.text).toContain('README.md:');
235
+ expect(res.text).toContain('This README came from GitHub.');
236
+ expect(nock.isDone()).toBe(true);
237
+ });
238
+ });
239
+
136
240
  describe('GET /archive', () => {
137
241
  it('returns a ZIP archive with remote images (markdown format)', async () => {
138
242
  nock('https://example.com').get('/archive-test').reply(200, MOCK_HTML, {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Integration tests for capturing GitHub repository pages (issue #5).
3
+ *
4
+ * Live tests are gated behind GITHUB_REPOSITORY_INTEGRATION so default/offline
5
+ * runs stay deterministic. The mocked API endpoint tests cover the default
6
+ * behavior without network access; this suite proves the real GitHub repository
7
+ * page can still be captured as compact text/markdown, original HTML, and PNG
8
+ * screenshots in every supported JavaScript browser engine.
9
+ */
10
+
11
+ import { jest } from '@jest/globals';
12
+ import { createBrowser } from '../../src/browser.js';
13
+ import { fetchHtml } from '../../src/lib.js';
14
+ import { retry } from '../../src/retry.js';
15
+ import {
16
+ fetchGithubRepositorySnapshot,
17
+ formatGithubRepositoryMarkdown,
18
+ formatGithubRepositoryText,
19
+ parseGithubRepositoryUrl,
20
+ } from '../../src/github.js';
21
+
22
+ const GITHUB_REPOSITORY_URL =
23
+ process.env.GITHUB_REPOSITORY_URL ||
24
+ 'https://github.com/link-assistant/web-capture';
25
+ const PNG_SIGNATURE = Buffer.from([
26
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
27
+ ]);
28
+
29
+ const SKIP_LIVE =
30
+ !process.env.GITHUB_REPOSITORY_INTEGRATION ||
31
+ process.env.GITHUB_REPOSITORY_INTEGRATION === 'false';
32
+ const describeIfLive = SKIP_LIVE ? describe.skip : describe;
33
+
34
+ jest.setTimeout(120000);
35
+
36
+ async function navigateWithRetry(page, url) {
37
+ await retry(
38
+ async () => {
39
+ await page.goto(url, {
40
+ waitUntil: 'domcontentloaded',
41
+ timeout: 30000,
42
+ });
43
+ },
44
+ {
45
+ retries: 3,
46
+ baseDelay: 2000,
47
+ onRetry: (err, attempt, delay) => {
48
+ console.log(
49
+ `Navigation retry ${attempt} for ${url} after ${delay}ms: ${err.message}`
50
+ );
51
+ },
52
+ }
53
+ );
54
+ await page.waitForTimeout(5000);
55
+ }
56
+
57
+ describeIfLive('GitHub repository capture', () => {
58
+ it('downloads compact repository text and markdown with README plus file tree', async () => {
59
+ const parsed = parseGithubRepositoryUrl(GITHUB_REPOSITORY_URL);
60
+ expect(parsed).not.toBeNull();
61
+
62
+ const snapshot = await retry(
63
+ () => fetchGithubRepositorySnapshot(GITHUB_REPOSITORY_URL),
64
+ {
65
+ retries: 3,
66
+ baseDelay: 2000,
67
+ }
68
+ );
69
+
70
+ const text = formatGithubRepositoryText(snapshot);
71
+ const markdown = formatGithubRepositoryMarkdown(snapshot);
72
+
73
+ expect(text).toContain(`Repository: ${parsed.fullName}`);
74
+ expect(text).toContain('Files:');
75
+ expect(text).toMatch(/README/i);
76
+ expect(text.length).toBeGreaterThan(500);
77
+
78
+ expect(markdown).toContain(`# ${parsed.fullName}`);
79
+ expect(markdown).toContain('## Files');
80
+ expect(markdown).toMatch(/## .*README/i);
81
+ expect(markdown.length).toBeGreaterThan(500);
82
+ expect(markdown).not.toMatch(/<html/i);
83
+ });
84
+
85
+ it('downloads original GitHub repository HTML', async () => {
86
+ const parsed = parseGithubRepositoryUrl(GITHUB_REPOSITORY_URL);
87
+ expect(parsed).not.toBeNull();
88
+
89
+ const html = await retry(() => fetchHtml(GITHUB_REPOSITORY_URL), {
90
+ retries: 3,
91
+ baseDelay: 2000,
92
+ });
93
+
94
+ expect(html).toMatch(/<html/i);
95
+ expect(html).toContain(parsed.owner);
96
+ expect(html).toContain(parsed.repo);
97
+ expect(html.length).toBeGreaterThan(1000);
98
+ });
99
+ });
100
+
101
+ describe.each(['puppeteer', 'playwright'])(
102
+ 'GitHub repository screenshot (%s engine)',
103
+ (engine) => {
104
+ describeIfLive(`${engine}`, () => {
105
+ let browser;
106
+
107
+ beforeAll(async () => {
108
+ browser = await createBrowser(engine);
109
+ });
110
+
111
+ afterAll(async () => {
112
+ if (browser) {
113
+ await browser.close();
114
+ }
115
+ });
116
+
117
+ it('captures the GitHub repository page as a PNG screenshot', async () => {
118
+ const page = await browser.newPage();
119
+ await page.setExtraHTTPHeaders({
120
+ 'Accept-Language': 'en-US,en;q=0.9',
121
+ 'Accept-Charset': 'utf-8',
122
+ });
123
+ await page.setUserAgent(
124
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
125
+ );
126
+ await page.setViewport({ width: 1280, height: 800 });
127
+ await navigateWithRetry(page, GITHUB_REPOSITORY_URL);
128
+
129
+ const screenshot = await page.screenshot({ type: 'png' });
130
+
131
+ expect(screenshot).toBeInstanceOf(Buffer);
132
+ expect(screenshot.length).toBeGreaterThan(1000);
133
+ expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
134
+ });
135
+ });
136
+ }
137
+ );
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Integration tests for downloading the StackOverflow page from issue #11.
3
+ *
4
+ * These tests hit the live StackOverflow page:
5
+ * https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git
6
+ *
7
+ * Set STACKOVERFLOW_INTEGRATION=true to run them. The suite is skipped by
8
+ * default so offline/local test runs stay deterministic.
9
+ */
10
+
11
+ import { jest } from '@jest/globals';
12
+ import request from 'supertest';
13
+ import unzipper from 'unzipper';
14
+ import { app } from '../../src/index.js';
15
+ import { retry } from '../../src/retry.js';
16
+
17
+ const STACKOVERFLOW_URL =
18
+ 'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git';
19
+ const STACKOVERFLOW_TITLE =
20
+ 'How do I undo the most recent local commits in Git';
21
+
22
+ const PNG_SIGNATURE = Buffer.from([
23
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
24
+ ]);
25
+ const ZIP_SIGNATURE = Buffer.from([0x50, 0x4b]);
26
+ const PDF_SIGNATURE = Buffer.from([0x25, 0x50, 0x44, 0x46]);
27
+
28
+ const SKIP_LIVE =
29
+ !process.env.STACKOVERFLOW_INTEGRATION ||
30
+ process.env.STACKOVERFLOW_INTEGRATION === 'false';
31
+ const describeIfLive = SKIP_LIVE ? describe.skip : describe;
32
+
33
+ jest.setTimeout(180000);
34
+
35
+ function parseBinary(res, callback) {
36
+ const chunks = [];
37
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
38
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
39
+ }
40
+
41
+ async function getText(path, query = {}) {
42
+ return await retry(
43
+ () =>
44
+ request(app)
45
+ .get(path)
46
+ .query({ url: STACKOVERFLOW_URL, ...query })
47
+ .expect(200),
48
+ {
49
+ retries: 2,
50
+ baseDelay: 2000,
51
+ onRetry: (err, attempt, delay) => {
52
+ console.log(
53
+ `Retry ${attempt} for ${path} after ${delay}ms: ${err.message}`
54
+ );
55
+ },
56
+ }
57
+ );
58
+ }
59
+
60
+ async function getBinary(path, query = {}) {
61
+ return await retry(
62
+ () =>
63
+ request(app)
64
+ .get(path)
65
+ .query({ url: STACKOVERFLOW_URL, ...query })
66
+ .buffer(true)
67
+ .parse(parseBinary)
68
+ .expect(200),
69
+ {
70
+ retries: 2,
71
+ baseDelay: 2000,
72
+ onRetry: (err, attempt, delay) => {
73
+ console.log(
74
+ `Retry ${attempt} for ${path} after ${delay}ms: ${err.message}`
75
+ );
76
+ },
77
+ }
78
+ );
79
+ }
80
+
81
+ function expectStackOverflowContent(text) {
82
+ expect(text).toContain(STACKOVERFLOW_TITLE);
83
+ expect(text).toMatch(/Stack Overflow/i);
84
+ expect(text).toMatch(/\bgit\b/i);
85
+ }
86
+
87
+ function expectBufferWithSignature(buffer, signature) {
88
+ expect(Buffer.isBuffer(buffer)).toBe(true);
89
+ expect(buffer.length).toBeGreaterThan(1000);
90
+ expect(buffer.subarray(0, signature.length)).toEqual(signature);
91
+ }
92
+
93
+ describeIfLive('StackOverflow page downloads (JavaScript)', () => {
94
+ it('downloads the page as Markdown and plain text', async () => {
95
+ const markdown = await getText('/markdown');
96
+ expect(markdown.headers['content-type']).toMatch(/text\/markdown/);
97
+ expectStackOverflowContent(markdown.text);
98
+ expect(markdown.text).not.toMatch(/<html/i);
99
+
100
+ const text = await getText('/txt');
101
+ expect(text.headers['content-type']).toMatch(/text\/plain/);
102
+ expect(text.headers['content-disposition']).toContain('stackoverflow-com');
103
+ expectStackOverflowContent(text.text);
104
+ });
105
+
106
+ it('downloads Markdown and HTML archives with the expected files', async () => {
107
+ const markdownArchive = await getBinary('/archive', {
108
+ documentFormat: 'markdown',
109
+ localImages: 'false',
110
+ });
111
+ expect(markdownArchive.headers['content-type']).toMatch(/application\/zip/);
112
+ expectBufferWithSignature(markdownArchive.body, ZIP_SIGNATURE);
113
+
114
+ const markdownZip = await unzipper.Open.buffer(markdownArchive.body);
115
+ const markdownEntries = new Map(
116
+ markdownZip.files.map((file) => [file.path, file])
117
+ );
118
+ expect(markdownEntries.has('document.md')).toBe(true);
119
+ expect(markdownEntries.has('document.html')).toBe(true);
120
+ expectStackOverflowContent(
121
+ (await markdownEntries.get('document.md').buffer()).toString('utf8')
122
+ );
123
+
124
+ const htmlArchive = await getBinary('/archive', {
125
+ documentFormat: 'html',
126
+ localImages: 'false',
127
+ });
128
+ expect(htmlArchive.headers['content-type']).toMatch(/application\/zip/);
129
+ expectBufferWithSignature(htmlArchive.body, ZIP_SIGNATURE);
130
+
131
+ const htmlZip = await unzipper.Open.buffer(htmlArchive.body);
132
+ const htmlEntries = new Map(htmlZip.files.map((file) => [file.path, file]));
133
+ expect(htmlEntries.has('document.html')).toBe(true);
134
+ expectStackOverflowContent(
135
+ (await htmlEntries.get('document.html').buffer()).toString('utf8')
136
+ );
137
+ });
138
+
139
+ it('downloads the page as DOCX and PDF', async () => {
140
+ const docx = await getBinary('/docx');
141
+ expect(docx.headers['content-type']).toMatch(
142
+ /application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document/
143
+ );
144
+ expectBufferWithSignature(docx.body, ZIP_SIGNATURE);
145
+
146
+ const pdf = await getBinary('/pdf');
147
+ expect(pdf.headers['content-type']).toMatch(/application\/pdf/);
148
+ expectBufferWithSignature(pdf.body, PDF_SIGNATURE);
149
+ }, 180000);
150
+ });
151
+
152
+ describe.each(['puppeteer', 'playwright'])(
153
+ 'StackOverflow browser downloads (%s engine)',
154
+ (engine) => {
155
+ describeIfLive(`${engine}`, () => {
156
+ it('downloads rendered HTML for the page', async () => {
157
+ const html = await getText('/html', { engine });
158
+ expect(html.headers['content-type']).toMatch(/text\/html/);
159
+ expectStackOverflowContent(html.text);
160
+ expect(html.text).toMatch(/<html/i);
161
+ }, 120000);
162
+
163
+ it('downloads a PNG screenshot for the page', async () => {
164
+ const image = await getBinary('/image', {
165
+ engine,
166
+ dismissPopups: 'true',
167
+ });
168
+ expect(image.headers['content-type']).toMatch(/image\/png/);
169
+ expectBufferWithSignature(image.body, PNG_SIGNATURE);
170
+ }, 120000);
171
+ });
172
+ }
173
+ );
@@ -0,0 +1,97 @@
1
+ import {
2
+ formatGithubRepositoryMarkdown,
3
+ formatGithubRepositoryText,
4
+ getGithubRepositoryTextFilename,
5
+ isGithubRepositoryUrl,
6
+ parseGithubRepositoryUrl,
7
+ } from '../../src/github.js';
8
+
9
+ const SNAPSHOT = {
10
+ sourceUrl: 'https://github.com/octocat/Hello-World',
11
+ defaultBranch: 'master',
12
+ repository: {
13
+ full_name: 'octocat/Hello-World',
14
+ description: 'A friendly test repository',
15
+ html_url: 'https://github.com/octocat/Hello-World',
16
+ default_branch: 'master',
17
+ language: 'JavaScript',
18
+ stargazers_count: 42,
19
+ forks_count: 7,
20
+ open_issues_count: 3,
21
+ license: { spdx_id: 'MIT' },
22
+ topics: ['demo', 'capture'],
23
+ },
24
+ tree: [
25
+ {
26
+ name: 'src',
27
+ path: 'src',
28
+ type: 'dir',
29
+ html_url: 'https://github.com/octocat/Hello-World/tree/master/src',
30
+ },
31
+ {
32
+ name: 'README.md',
33
+ path: 'README.md',
34
+ type: 'file',
35
+ size: 37,
36
+ html_url: 'https://github.com/octocat/Hello-World/blob/master/README.md',
37
+ },
38
+ ],
39
+ readme: {
40
+ path: 'README.md',
41
+ content: '# Hello World\n\nThis is the README.',
42
+ },
43
+ };
44
+
45
+ describe('GitHub repository URLs', () => {
46
+ it('detects plain GitHub repository pages', () => {
47
+ expect(
48
+ parseGithubRepositoryUrl('https://github.com/octocat/Hello-World')
49
+ ).toEqual({
50
+ owner: 'octocat',
51
+ repo: 'Hello-World',
52
+ fullName: 'octocat/Hello-World',
53
+ htmlUrl: 'https://github.com/octocat/Hello-World',
54
+ });
55
+ expect(
56
+ isGithubRepositoryUrl('https://github.com/octocat/Hello-World')
57
+ ).toBe(true);
58
+ expect(
59
+ isGithubRepositoryUrl('https://github.com/octocat/Hello-World/issues')
60
+ ).toBe(false);
61
+ expect(
62
+ isGithubRepositoryUrl('https://example.com/octocat/Hello-World')
63
+ ).toBe(false);
64
+ });
65
+
66
+ it('derives repository text filenames', () => {
67
+ expect(
68
+ getGithubRepositoryTextFilename('https://github.com/octocat/Hello-World')
69
+ ).toBe('octocat-Hello-World.txt');
70
+ });
71
+ });
72
+
73
+ describe('GitHub repository snapshot formatting', () => {
74
+ it('formats a compact markdown snapshot with metadata, file tree, and README', () => {
75
+ const markdown = formatGithubRepositoryMarkdown(SNAPSHOT);
76
+
77
+ expect(markdown).toContain('# octocat/Hello-World');
78
+ expect(markdown).toContain('> A friendly test repository');
79
+ expect(markdown).toContain('- Default branch: `master`');
80
+ expect(markdown).toContain('- [src/](');
81
+ expect(markdown).toContain('- [README.md](');
82
+ expect(markdown).toContain('## README.md');
83
+ expect(markdown).toContain('# Hello World');
84
+ });
85
+
86
+ it('formats a compact plain-text snapshot with metadata, file tree, and README', () => {
87
+ const text = formatGithubRepositoryText(SNAPSHOT);
88
+
89
+ expect(text).toContain('Repository: octocat/Hello-World');
90
+ expect(text).toContain('Description: A friendly test repository');
91
+ expect(text).toContain('Files:');
92
+ expect(text).toContain('- src/');
93
+ expect(text).toContain('- README.md');
94
+ expect(text).toContain('README.md:');
95
+ expect(text).toContain('This is the README.');
96
+ });
97
+ });
@@ -0,0 +1,32 @@
1
+ import {
2
+ isStackOverflowQuestionUrl,
3
+ stackPrinterUrlForQuestion,
4
+ } from '../../src/lib.js';
5
+
6
+ describe('Stack Overflow URL helpers', () => {
7
+ it('recognizes Stack Overflow question URLs', () => {
8
+ expect(
9
+ isStackOverflowQuestionUrl(
10
+ 'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
11
+ )
12
+ ).toBe(true);
13
+ expect(
14
+ isStackOverflowQuestionUrl(
15
+ 'https://stackoverflow.com/questions/tagged/git'
16
+ )
17
+ ).toBe(false);
18
+ expect(
19
+ isStackOverflowQuestionUrl('https://serverfault.com/questions/927358')
20
+ ).toBe(false);
21
+ });
22
+
23
+ it('builds a StackPrinter URL for direct captures', () => {
24
+ const stackPrinterUrl = stackPrinterUrlForQuestion(
25
+ 'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
26
+ );
27
+
28
+ expect(stackPrinterUrl).toBe(
29
+ 'https://stackprinter.appspot.com/export?question=927358&service=stackoverflow&language=en&hideAnswers=false&showAll=true&width=640'
30
+ );
31
+ });
32
+ });