@link-assistant/web-capture 1.1.2 → 1.7.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/CHANGELOG.md +57 -13
- package/Dockerfile +26 -21
- package/README.md +290 -160
- package/bin/web-capture.js +622 -17
- package/package.json +15 -4
- package/src/animation.js +283 -0
- package/src/archive.js +225 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/extract-images.js +120 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +335 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +281 -1
- package/src/localize-images.js +202 -0
- package/src/markdown.js +7 -2
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +263 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +278 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +110 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown image localization module (R5).
|
|
3
|
+
*
|
|
4
|
+
* Post-processing tool that:
|
|
5
|
+
* 1. Reads markdown files
|
|
6
|
+
* 2. Extracts all external image URLs
|
|
7
|
+
* 3. Downloads images to local directory
|
|
8
|
+
* 4. Updates markdown to reference local paths
|
|
9
|
+
*
|
|
10
|
+
* Based on reference implementation from:
|
|
11
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/download-markdown-images.mjs
|
|
12
|
+
*
|
|
13
|
+
* @module localize-images
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fetch from 'node-fetch';
|
|
17
|
+
import { URL } from 'url';
|
|
18
|
+
import { retry } from './retry.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract image references from markdown text.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} markdownText - Markdown content
|
|
24
|
+
* @returns {Object[]} Array of {fullMatch, altText, url}
|
|
25
|
+
*/
|
|
26
|
+
export function extractImageReferences(markdownText) {
|
|
27
|
+
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
|
28
|
+
const images = [];
|
|
29
|
+
let match;
|
|
30
|
+
|
|
31
|
+
while ((match = imageRegex.exec(markdownText)) !== null) {
|
|
32
|
+
images.push({
|
|
33
|
+
fullMatch: match[0],
|
|
34
|
+
altText: match[1],
|
|
35
|
+
url: match[2],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return images;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get file extension from URL.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} url - Image URL
|
|
46
|
+
* @returns {string} File extension with dot (e.g., '.png')
|
|
47
|
+
*/
|
|
48
|
+
export function getExtensionFromUrl(url) {
|
|
49
|
+
try {
|
|
50
|
+
const pathname = new URL(url).pathname.split('?')[0];
|
|
51
|
+
const ext = pathname.match(/\.(\w+)$/);
|
|
52
|
+
if (ext) {
|
|
53
|
+
const lower = ext[1].toLowerCase();
|
|
54
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(lower)) {
|
|
55
|
+
return `.${lower}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
return '.png';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate local filename for a downloaded image.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} url - Image URL
|
|
68
|
+
* @param {number} index - Zero-based index
|
|
69
|
+
* @returns {string} Local filename (e.g., 'image-01.png')
|
|
70
|
+
*/
|
|
71
|
+
export function generateLocalFilename(url, index) {
|
|
72
|
+
const ext = getExtensionFromUrl(url);
|
|
73
|
+
return `image-${String(index + 1).padStart(2, '0')}${ext}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Localize images in markdown text by downloading external images
|
|
78
|
+
* and replacing URLs with local paths.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} markdownText - Input markdown
|
|
81
|
+
* @param {Object} [options] - Options
|
|
82
|
+
* @param {string} [options.imagesDir='images'] - Local images directory name
|
|
83
|
+
* @param {boolean} [options.dryRun=false] - Only report what would be done
|
|
84
|
+
* @param {Function} [options.onProgress] - Callback(index, total, status, url)
|
|
85
|
+
* @param {string[]} [options.excludeDomains] - Domains to skip (already local)
|
|
86
|
+
* @returns {Promise<Object>} Result with updated markdown and metadata
|
|
87
|
+
*/
|
|
88
|
+
export async function localizeImages(markdownText, options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
imagesDir = 'images',
|
|
91
|
+
dryRun = false,
|
|
92
|
+
onProgress,
|
|
93
|
+
excludeDomains = [],
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
const allImages = extractImageReferences(markdownText);
|
|
97
|
+
|
|
98
|
+
// Filter to only external images not already localized
|
|
99
|
+
const externalImages = allImages.filter((img) => {
|
|
100
|
+
if (!img.url.startsWith('http')) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (img.url.includes(`${imagesDir}/`)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
for (const domain of excludeDomains) {
|
|
107
|
+
if (img.url.includes(domain)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (externalImages.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
markdown: markdownText,
|
|
117
|
+
downloaded: 0,
|
|
118
|
+
total: 0,
|
|
119
|
+
replacements: [],
|
|
120
|
+
metadata: [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const replacements = [];
|
|
125
|
+
const metadata = [];
|
|
126
|
+
let downloadedCount = 0;
|
|
127
|
+
let updatedMarkdown = markdownText;
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < externalImages.length; i++) {
|
|
130
|
+
const image = externalImages[i];
|
|
131
|
+
const localFilename = generateLocalFilename(image.url, i);
|
|
132
|
+
const relativePath = `${imagesDir}/${localFilename}`;
|
|
133
|
+
|
|
134
|
+
if (onProgress) {
|
|
135
|
+
onProgress(i + 1, externalImages.length, 'downloading', image.url);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (dryRun) {
|
|
139
|
+
replacements.push({
|
|
140
|
+
from: image.fullMatch,
|
|
141
|
+
to: ``,
|
|
142
|
+
});
|
|
143
|
+
metadata.push({
|
|
144
|
+
index: i + 1,
|
|
145
|
+
originalUrl: image.url,
|
|
146
|
+
altText: image.altText,
|
|
147
|
+
localPath: relativePath,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const resp = await retry(() => fetch(image.url), {
|
|
154
|
+
retries: 3,
|
|
155
|
+
baseDelay: 1000,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const buffer = await resp.buffer();
|
|
163
|
+
downloadedCount++;
|
|
164
|
+
|
|
165
|
+
replacements.push({
|
|
166
|
+
from: image.fullMatch,
|
|
167
|
+
to: ``,
|
|
168
|
+
buffer,
|
|
169
|
+
filename: localFilename,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
metadata.push({
|
|
173
|
+
index: i + 1,
|
|
174
|
+
originalUrl: image.url,
|
|
175
|
+
altText: image.altText,
|
|
176
|
+
localPath: relativePath,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (onProgress) {
|
|
180
|
+
onProgress(i + 1, externalImages.length, 'downloaded', image.url);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
if (onProgress) {
|
|
184
|
+
onProgress(i + 1, externalImages.length, 'failed', image.url);
|
|
185
|
+
}
|
|
186
|
+
// Keep original URL if download fails
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Apply replacements to markdown
|
|
191
|
+
for (const replacement of replacements) {
|
|
192
|
+
updatedMarkdown = updatedMarkdown.replace(replacement.from, replacement.to);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
markdown: updatedMarkdown,
|
|
197
|
+
downloaded: downloadedCount,
|
|
198
|
+
total: externalImages.length,
|
|
199
|
+
replacements,
|
|
200
|
+
metadata,
|
|
201
|
+
};
|
|
202
|
+
}
|
package/src/markdown.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { fetchHtml, convertHtmlToMarkdown } from './lib.js';
|
|
2
|
+
import { stripBase64Images } from './extract-images.js';
|
|
2
3
|
|
|
3
4
|
export async function markdownHandler(req, res) {
|
|
4
5
|
const url = req.query.url;
|
|
5
6
|
if (!url) {
|
|
6
7
|
return res.status(400).send('Missing `url` parameter');
|
|
7
8
|
}
|
|
9
|
+
const embedImages = req.query.embedImages === 'true';
|
|
8
10
|
try {
|
|
9
11
|
const html = await fetchHtml(url);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
let markdown = convertHtmlToMarkdown(html, url);
|
|
13
|
+
if (!embedImages) {
|
|
14
|
+
const strip = stripBase64Images(markdown);
|
|
15
|
+
markdown = strip.markdown;
|
|
16
|
+
}
|
|
12
17
|
res.type('text/markdown').send(markdown);
|
|
13
18
|
} catch (err) {
|
|
14
19
|
console.error(err);
|
package/src/metadata.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Article metadata extraction module.
|
|
3
|
+
*
|
|
4
|
+
* Extracts metadata from web pages including:
|
|
5
|
+
* - Author information (name, URL, karma)
|
|
6
|
+
* - Publication date and modification date
|
|
7
|
+
* - Reading time and difficulty
|
|
8
|
+
* - Views, votes, bookmarks, comments
|
|
9
|
+
* - Hubs and tags (with URLs)
|
|
10
|
+
* - Translation information
|
|
11
|
+
* - LD+JSON structured data
|
|
12
|
+
*
|
|
13
|
+
* Based on reference implementation from:
|
|
14
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/download-article.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract article metadata from HTML using Cheerio.
|
|
19
|
+
* Works without a browser by parsing the HTML directly.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} $ - Cheerio instance loaded with the page HTML
|
|
22
|
+
* @returns {Object} Extracted metadata
|
|
23
|
+
*/
|
|
24
|
+
export function extractMetadata($) {
|
|
25
|
+
const meta = {};
|
|
26
|
+
|
|
27
|
+
// Author
|
|
28
|
+
const authorEl = $('.tm-user-info__username');
|
|
29
|
+
if (authorEl.length > 0) {
|
|
30
|
+
meta.author = authorEl.text().trim();
|
|
31
|
+
meta.authorUrl = authorEl.attr('href') || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Publication date
|
|
35
|
+
const timeEl = $('time[datetime]').first();
|
|
36
|
+
if (timeEl.length > 0) {
|
|
37
|
+
meta.publishDate = timeEl.attr('datetime');
|
|
38
|
+
meta.publishDateText = timeEl.text().trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Reading time
|
|
42
|
+
const readTimeEl = $('.tm-article-reading-time__label');
|
|
43
|
+
if (readTimeEl.length > 0) {
|
|
44
|
+
meta.readingTime = readTimeEl.text().trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Difficulty level
|
|
48
|
+
const diffEl = $('.tm-article-complexity__label');
|
|
49
|
+
if (diffEl.length > 0) {
|
|
50
|
+
meta.difficulty = diffEl.text().trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Views
|
|
54
|
+
const viewsEl = $('.tm-icon-counter__value');
|
|
55
|
+
if (viewsEl.length > 0) {
|
|
56
|
+
meta.views = viewsEl.attr('title') || viewsEl.text().trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Hubs (use specific hub link selector to avoid duplicates)
|
|
60
|
+
const hubEls = $('.tm-publication-hub__link');
|
|
61
|
+
if (hubEls.length > 0) {
|
|
62
|
+
meta.hubs = [];
|
|
63
|
+
meta.hubUrls = [];
|
|
64
|
+
hubEls.each(function () {
|
|
65
|
+
const nameSpan = $(this).find('span:first-child');
|
|
66
|
+
const name = nameSpan.length
|
|
67
|
+
? nameSpan.text().trim()
|
|
68
|
+
: $(this)
|
|
69
|
+
.text()
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/\s*\*\s*$/, '');
|
|
72
|
+
meta.hubs.push(name);
|
|
73
|
+
meta.hubUrls.push({
|
|
74
|
+
name,
|
|
75
|
+
url: $(this).attr('href') || null,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tags from meta keywords
|
|
81
|
+
const keywordsMeta = $('meta[name="keywords"]');
|
|
82
|
+
if (keywordsMeta.length > 0) {
|
|
83
|
+
const content = keywordsMeta.attr('content');
|
|
84
|
+
if (content) {
|
|
85
|
+
meta.tags = content
|
|
86
|
+
.split(',')
|
|
87
|
+
.map((t) => t.trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Tags with URLs (from article footer)
|
|
93
|
+
const tagEls = $('.tm-article-body__tags-item a, .tm-tags-list__link');
|
|
94
|
+
if (tagEls.length > 0) {
|
|
95
|
+
meta.tagLinks = [];
|
|
96
|
+
tagEls.each(function () {
|
|
97
|
+
meta.tagLinks.push({
|
|
98
|
+
name: $(this).text().trim(),
|
|
99
|
+
url: $(this).attr('href') || null,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Translation badge
|
|
105
|
+
const translationLabelEl = $('.tm-publication-label_variant-translation');
|
|
106
|
+
if (translationLabelEl.length > 0) {
|
|
107
|
+
meta.isTranslation = true;
|
|
108
|
+
meta.translationLabel = translationLabelEl.text().trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Original article link
|
|
112
|
+
const originLinkEl = $('.tm-article-presenter__origin-link');
|
|
113
|
+
if (originLinkEl.length > 0) {
|
|
114
|
+
meta.originalArticleUrl = originLinkEl.attr('href') || null;
|
|
115
|
+
const authorSpan = originLinkEl.find('span');
|
|
116
|
+
if (authorSpan.length > 0) {
|
|
117
|
+
meta.originalAuthors = authorSpan.text().trim();
|
|
118
|
+
}
|
|
119
|
+
meta.originalAuthorText = originLinkEl.text().trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// LD+JSON structured data
|
|
123
|
+
const ldJsonScript = $('script[type="application/ld+json"]').first();
|
|
124
|
+
if (ldJsonScript.length > 0) {
|
|
125
|
+
try {
|
|
126
|
+
const ldData = JSON.parse(ldJsonScript.html());
|
|
127
|
+
if (ldData.dateModified) {
|
|
128
|
+
meta.dateModified = ldData.dateModified;
|
|
129
|
+
}
|
|
130
|
+
if (ldData.author?.name) {
|
|
131
|
+
meta.authorFullName = ldData.author.name;
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore parse errors
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Votes
|
|
139
|
+
const votesEl = $('.tm-votes-meter__value');
|
|
140
|
+
if (votesEl.length > 0) {
|
|
141
|
+
meta.votes = votesEl.text().trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Comments count
|
|
145
|
+
const commentsEl = $('.tm-article-comments-counter-link__value');
|
|
146
|
+
if (commentsEl.length > 0) {
|
|
147
|
+
meta.comments = commentsEl.text().trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Bookmarks count
|
|
151
|
+
const bookmarksEl = $('.bookmarks-button__counter');
|
|
152
|
+
if (bookmarksEl.length > 0) {
|
|
153
|
+
meta.bookmarks = bookmarksEl.text().trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Author karma
|
|
157
|
+
const karmaEl = $('.tm-karma__votes');
|
|
158
|
+
if (karmaEl.length > 0) {
|
|
159
|
+
meta.authorKarma = karmaEl.text().trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return meta;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Format metadata as a markdown header block.
|
|
167
|
+
* Placed after the title in the output markdown.
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} metadata - Extracted metadata object
|
|
170
|
+
* @returns {string[]} Array of markdown lines
|
|
171
|
+
*/
|
|
172
|
+
export function formatMetadataBlock(metadata) {
|
|
173
|
+
if (!metadata) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const lines = [];
|
|
177
|
+
|
|
178
|
+
// Author line
|
|
179
|
+
if (metadata.author) {
|
|
180
|
+
const authorName = metadata.authorFullName
|
|
181
|
+
? `${metadata.authorFullName} (${metadata.author})`
|
|
182
|
+
: metadata.author;
|
|
183
|
+
const authorLink = metadata.authorUrl
|
|
184
|
+
? `[${authorName}](${metadata.authorUrl})`
|
|
185
|
+
: authorName;
|
|
186
|
+
lines.push(`**Author:** ${authorLink}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Article type (translation)
|
|
190
|
+
if (metadata.isTranslation) {
|
|
191
|
+
lines.push(`**Type:** ${metadata.translationLabel || 'Translation'}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Original article link
|
|
195
|
+
if (metadata.originalAuthors) {
|
|
196
|
+
const authorsText = metadata.originalAuthors;
|
|
197
|
+
if (metadata.originalArticleUrl) {
|
|
198
|
+
lines.push(
|
|
199
|
+
`**Original article:** [${authorsText}](${metadata.originalArticleUrl})`
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
lines.push(`**Original authors:** ${authorsText}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Publication date
|
|
207
|
+
if (metadata.publishDate) {
|
|
208
|
+
const date = new Date(metadata.publishDate);
|
|
209
|
+
const formatted = date.toLocaleDateString('en-US', {
|
|
210
|
+
year: 'numeric',
|
|
211
|
+
month: 'long',
|
|
212
|
+
day: 'numeric',
|
|
213
|
+
});
|
|
214
|
+
let dateLine = `**Published:** ${formatted}`;
|
|
215
|
+
if (metadata.dateModified) {
|
|
216
|
+
const modDate = new Date(metadata.dateModified);
|
|
217
|
+
const modFormatted = modDate.toLocaleDateString('en-US', {
|
|
218
|
+
year: 'numeric',
|
|
219
|
+
month: 'long',
|
|
220
|
+
day: 'numeric',
|
|
221
|
+
});
|
|
222
|
+
if (modFormatted !== formatted) {
|
|
223
|
+
dateLine += ` (updated ${modFormatted})`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
lines.push(dateLine);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Reading time and difficulty
|
|
230
|
+
const infoItems = [];
|
|
231
|
+
if (metadata.readingTime) {
|
|
232
|
+
infoItems.push(`Reading time: ${metadata.readingTime}`);
|
|
233
|
+
}
|
|
234
|
+
if (metadata.difficulty) {
|
|
235
|
+
infoItems.push(`Difficulty: ${metadata.difficulty}`);
|
|
236
|
+
}
|
|
237
|
+
if (metadata.views) {
|
|
238
|
+
infoItems.push(`Views: ${metadata.views}`);
|
|
239
|
+
}
|
|
240
|
+
if (infoItems.length > 0) {
|
|
241
|
+
lines.push(`**${infoItems.join(' | ')}**`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Hubs
|
|
245
|
+
if (metadata.hubs && metadata.hubs.length > 0) {
|
|
246
|
+
lines.push(`**Hubs:** ${metadata.hubs.join(', ')}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Tags
|
|
250
|
+
if (metadata.tags && metadata.tags.length > 0) {
|
|
251
|
+
lines.push(`**Tags:** ${metadata.tags.join(', ')}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Format footer metadata block.
|
|
259
|
+
* Placed at the end of the article, matching Habr article footer.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} metadata - Extracted metadata object
|
|
262
|
+
* @returns {string[]} Array of markdown lines
|
|
263
|
+
*/
|
|
264
|
+
export function formatFooterBlock(metadata) {
|
|
265
|
+
if (!metadata) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const lines = [];
|
|
269
|
+
|
|
270
|
+
lines.push('---');
|
|
271
|
+
lines.push('');
|
|
272
|
+
|
|
273
|
+
// Tags with links
|
|
274
|
+
if (metadata.tagLinks && metadata.tagLinks.length > 0) {
|
|
275
|
+
const tagStrings = metadata.tagLinks.map((t) =>
|
|
276
|
+
t.url ? `[${t.name}](${t.url})` : t.name
|
|
277
|
+
);
|
|
278
|
+
lines.push(`**Tags:** ${tagStrings.join(', ')}`);
|
|
279
|
+
lines.push('');
|
|
280
|
+
} else if (metadata.tags && metadata.tags.length > 0) {
|
|
281
|
+
lines.push(`**Tags:** ${metadata.tags.join(', ')}`);
|
|
282
|
+
lines.push('');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Hubs with links
|
|
286
|
+
if (metadata.hubUrls && metadata.hubUrls.length > 0) {
|
|
287
|
+
const hubStrings = metadata.hubUrls.map((h) =>
|
|
288
|
+
h.url ? `[${h.name}](${h.url})` : h.name
|
|
289
|
+
);
|
|
290
|
+
lines.push(`**Hubs:** ${hubStrings.join(', ')}`);
|
|
291
|
+
lines.push('');
|
|
292
|
+
} else if (metadata.hubs && metadata.hubs.length > 0) {
|
|
293
|
+
lines.push(`**Hubs:** ${metadata.hubs.join(', ')}`);
|
|
294
|
+
lines.push('');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Article stats
|
|
298
|
+
const stats = [];
|
|
299
|
+
if (metadata.votes) {
|
|
300
|
+
stats.push(`Votes: ${metadata.votes}`);
|
|
301
|
+
}
|
|
302
|
+
if (metadata.views) {
|
|
303
|
+
stats.push(`Views: ${metadata.views}`);
|
|
304
|
+
}
|
|
305
|
+
if (metadata.bookmarks) {
|
|
306
|
+
stats.push(`Bookmarks: ${metadata.bookmarks}`);
|
|
307
|
+
}
|
|
308
|
+
if (metadata.comments) {
|
|
309
|
+
stats.push(`Comments: ${metadata.comments}`);
|
|
310
|
+
}
|
|
311
|
+
if (stats.length > 0) {
|
|
312
|
+
lines.push(`**${stats.join(' | ')}**`);
|
|
313
|
+
lines.push('');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Author info
|
|
317
|
+
if (metadata.author) {
|
|
318
|
+
const authorName = metadata.authorFullName
|
|
319
|
+
? `${metadata.authorFullName} (${metadata.author})`
|
|
320
|
+
: metadata.author;
|
|
321
|
+
const authorLink = metadata.authorUrl
|
|
322
|
+
? `[${authorName}](${metadata.authorUrl})`
|
|
323
|
+
: authorName;
|
|
324
|
+
let authorLine = `**Author:** ${authorLink}`;
|
|
325
|
+
if (metadata.authorKarma) {
|
|
326
|
+
authorLine += ` | Karma: ${metadata.authorKarma}`;
|
|
327
|
+
}
|
|
328
|
+
lines.push(authorLine);
|
|
329
|
+
lines.push('');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
package/src/pdf.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF export handler.
|
|
3
|
+
*
|
|
4
|
+
* Renders a web page to PDF using the browser's native print-to-PDF.
|
|
5
|
+
* All images are embedded automatically by the browser engine.
|
|
6
|
+
*
|
|
7
|
+
* Query parameters:
|
|
8
|
+
* url (required) - URL to capture
|
|
9
|
+
* engine - 'puppeteer' or 'playwright'
|
|
10
|
+
* width - viewport width in px (default 1280)
|
|
11
|
+
* height - viewport height in px (default 800)
|
|
12
|
+
* theme - 'light' or 'dark'
|
|
13
|
+
* dismissPopups - 'true' (default) to auto-close popups before export
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
17
|
+
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
18
|
+
|
|
19
|
+
export async function pdfHandler(req, res) {
|
|
20
|
+
const url = req.query.url;
|
|
21
|
+
if (!url) {
|
|
22
|
+
return res.status(400).send('Missing `url` parameter');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const width = parseInt(req.query.width, 10) || 1280;
|
|
26
|
+
const height = parseInt(req.query.height, 10) || 800;
|
|
27
|
+
const theme = req.query.theme;
|
|
28
|
+
const shouldDismissPopups = req.query.dismissPopups !== 'false';
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
32
|
+
const engine = getBrowserEngine(req);
|
|
33
|
+
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
34
|
+
const browser = await createBrowser(engine, browserOpts);
|
|
35
|
+
try {
|
|
36
|
+
const page = await browser.newPage();
|
|
37
|
+
await page.setExtraHTTPHeaders({
|
|
38
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
39
|
+
'Accept-Charset': 'utf-8',
|
|
40
|
+
});
|
|
41
|
+
await page.setUserAgent(
|
|
42
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
43
|
+
);
|
|
44
|
+
await page.setViewport({ width, height });
|
|
45
|
+
await page.goto(absoluteUrl, {
|
|
46
|
+
waitUntil: 'networkidle0',
|
|
47
|
+
timeout: 30000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
51
|
+
await scrollToLoadContent(page);
|
|
52
|
+
|
|
53
|
+
if (shouldDismissPopups) {
|
|
54
|
+
await dismissPopups(page);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Generate PDF using browser-commander's unified pdf() API (v0.8.0+)
|
|
58
|
+
const pdfBuffer = await page.pdf({
|
|
59
|
+
pdfOptions: {
|
|
60
|
+
format: 'A4',
|
|
61
|
+
printBackground: true,
|
|
62
|
+
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
res.set('Content-Type', 'application/pdf');
|
|
67
|
+
res.set('Content-Disposition', 'inline; filename="page.pdf"');
|
|
68
|
+
res.end(pdfBuffer);
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(err);
|
|
74
|
+
res.status(500).send('Error generating PDF');
|
|
75
|
+
}
|
|
76
|
+
}
|