@songmu/mdhq 0.0.2 → 0.0.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/README.md +20 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +69 -15
- package/dist/frontmatter/frontmatter.d.ts +2 -8
- package/dist/frontmatter/frontmatter.js +11 -21
- package/dist/get-page.js +78 -45
- package/dist/http/fetch.d.ts +2 -0
- package/dist/http/fetch.js +3 -2
- package/dist/http/scheduler.d.ts +12 -0
- package/dist/http/scheduler.js +69 -0
- package/dist/path/storage-path.js +21 -2
- package/dist/storage/save.d.ts +1 -1
- package/dist/storage/save.js +6 -4
- package/dist/types.d.ts +2 -0
- package/dist/url/identity.d.ts +3 -0
- package/dist/url/identity.js +30 -4
- package/dist/url/normalize.d.ts +3 -0
- package/dist/url/normalize.js +83 -0
- package/docs/configuration.md +13 -2
- package/docs/library-api.md +15 -8
- package/docs/specification.md +129 -50
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# mdhq - Markdown headquarters
|
|
1
|
+
# mdhq - Markdown headquarters, probably
|
|
2
2
|
|
|
3
3
|
`mdhq` saves web pages as Markdown in a [ghq](https://github.com/x-motemen/ghq)-inspired filesystem layout. It
|
|
4
4
|
uses [Defuddle](https://defuddle.md/) for content extraction and keeps all state in Markdown and asset
|
|
@@ -18,6 +18,8 @@ npm install --global @songmu/mdhq
|
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
mdhq get https://example.com/article
|
|
21
|
+
mdhq get https://example.com/article https://example.com/another-article
|
|
22
|
+
cat urls.txt | mdhq get
|
|
21
23
|
mdhq get --update https://example.com/article
|
|
22
24
|
mdhq get --no-assets https://example.com/article
|
|
23
25
|
mdhq get --json --header 'Cookie: session=value' https://example.com/article
|
|
@@ -26,9 +28,15 @@ mdhq list --full-path
|
|
|
26
28
|
mdhq root
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
`mdhq get`
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
`mdhq get` accepts multiple URLs as arguments or one URL per line on standard
|
|
32
|
+
input; both sources are merged. It prints one absolute Markdown path per URL
|
|
33
|
+
to stdout by default. Warnings are written to stderr. `--json` writes one
|
|
34
|
+
compact JSON result object per URL as JSON Lines. Results are written as soon
|
|
35
|
+
as each URL finishes, so parallel requests can produce output in completion
|
|
36
|
+
order rather than input order. If a later request fails, results already
|
|
37
|
+
written to stdout remain available.
|
|
38
|
+
Requests are limited to eight in parallel, with requests to the same host
|
|
39
|
+
serialized and spaced one second apart; image downloads use the same limits.
|
|
32
40
|
|
|
33
41
|
`mdhq list` recursively lists `.md` files below the storage root, one per
|
|
34
42
|
line, in sorted root-relative form. Use `-p` or `--full-path` to print absolute
|
|
@@ -103,6 +111,14 @@ Saved frontmatter uses Obsidian Web Clipper-compatible names such as `title`,
|
|
|
103
111
|
conditional updates. It does not add `type` or `tags` by default; use
|
|
104
112
|
`frontmatter.values` to opt into values such as `"type": "clip"`.
|
|
105
113
|
|
|
114
|
+
`source` is normalized after redirects. mdhq accepts an HTML canonical URL
|
|
115
|
+
only when its normalized origin and pathname match the fetched page; otherwise
|
|
116
|
+
it removes known tracking parameters with `urlpurify`, while retaining
|
|
117
|
+
functional WordPress preview parameters. Fragments are removed.
|
|
118
|
+
Queryless sources use the normal ghq-inspired path. Sources with a query use a
|
|
119
|
+
configured `entryQueryKey` value when available, or a deterministic MD5
|
|
120
|
+
filename derived from the final path segment and ordered query string.
|
|
121
|
+
|
|
106
122
|
An update returns `updated` when the normalized Markdown body or user-facing
|
|
107
123
|
frontmatter changes and `unchanged` when HTTP returns 304 or the fetched note
|
|
108
124
|
content is unchanged.
|
package/dist/cli.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
export interface CliIo {
|
|
4
4
|
stdout: Pick<NodeJS.WriteStream, "write">;
|
|
5
5
|
stderr: Pick<NodeJS.WriteStream, "write">;
|
|
6
|
+
stdin?: NodeJS.ReadStream;
|
|
6
7
|
}
|
|
7
8
|
export declare function createProgram(io?: CliIo): Command;
|
|
8
9
|
export declare function runCli(argv?: string[], io?: CliIo): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { MdhqError } from "./errors.js";
|
|
|
8
8
|
import { getPage } from "./get-page.js";
|
|
9
9
|
import { listMarkdownFiles } from "./list-files.js";
|
|
10
10
|
import { VERSION } from "./version.js";
|
|
11
|
+
import { RequestScheduler } from "./http/scheduler.js";
|
|
11
12
|
function collect(value, previous) {
|
|
12
13
|
return [...previous, value];
|
|
13
14
|
}
|
|
@@ -25,6 +26,27 @@ function parseHeaders(values) {
|
|
|
25
26
|
};
|
|
26
27
|
});
|
|
27
28
|
}
|
|
29
|
+
async function readStdinUrls(stdin) {
|
|
30
|
+
if (!stdin || stdin.isTTY) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const urls = [];
|
|
34
|
+
let remainder = "";
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
for await (const chunk of stdin) {
|
|
37
|
+
const text = typeof chunk === "string"
|
|
38
|
+
? chunk
|
|
39
|
+
: decoder.decode(chunk, { stream: true });
|
|
40
|
+
const lines = `${remainder}${text}`.split(/\r?\n/u);
|
|
41
|
+
remainder = lines.pop() ?? "";
|
|
42
|
+
urls.push(...lines.map((line) => line.trim()).filter(Boolean));
|
|
43
|
+
}
|
|
44
|
+
remainder += decoder.decode();
|
|
45
|
+
if (remainder.trim()) {
|
|
46
|
+
urls.push(remainder.trim());
|
|
47
|
+
}
|
|
48
|
+
return urls;
|
|
49
|
+
}
|
|
28
50
|
export function createProgram(io = process) {
|
|
29
51
|
const program = new Command()
|
|
30
52
|
.name("mdhq")
|
|
@@ -40,25 +62,57 @@ export function createProgram(io = process) {
|
|
|
40
62
|
});
|
|
41
63
|
program
|
|
42
64
|
.command("get")
|
|
43
|
-
.description("Fetch and save
|
|
44
|
-
.argument("
|
|
65
|
+
.description("Fetch and save web pages.")
|
|
66
|
+
.argument("[urls...]")
|
|
45
67
|
.option("--root <path>", "storage root")
|
|
46
68
|
.option("--no-assets", "do not download images")
|
|
47
69
|
.option("--update", "update an existing page")
|
|
48
70
|
.option("--user-agent <value>", "HTTP User-Agent")
|
|
49
71
|
.option("--header <header>", "additional HTTP header", collect, [])
|
|
50
|
-
.addOption(new Option("--json", "print
|
|
51
|
-
.action(async (
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
.addOption(new Option("--json", "print results as JSON Lines"))
|
|
73
|
+
.action(async (urls, options) => {
|
|
74
|
+
const inputUrls = await readStdinUrls(io.stdin);
|
|
75
|
+
const requestedUrls = [...(urls ?? []), ...inputUrls]
|
|
76
|
+
.map((url) => url.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (requestedUrls.length === 0) {
|
|
79
|
+
throw new MdhqError("INVALID_URL", "At least one URL is required");
|
|
80
|
+
}
|
|
81
|
+
const scheduler = new RequestScheduler();
|
|
82
|
+
let nextIndex = 0;
|
|
83
|
+
const worker = async () => {
|
|
84
|
+
while (nextIndex < requestedUrls.length) {
|
|
85
|
+
const index = nextIndex;
|
|
86
|
+
nextIndex += 1;
|
|
87
|
+
const url = requestedUrls[index];
|
|
88
|
+
if (url === undefined) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const result = await getPage({
|
|
92
|
+
url,
|
|
93
|
+
...(options.root ? { root: options.root } : {}),
|
|
94
|
+
...(options.assets === false ? { assets: false } : {}),
|
|
95
|
+
update: options.update ?? false,
|
|
96
|
+
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
|
97
|
+
headers: parseHeaders(options.header),
|
|
98
|
+
scheduler,
|
|
99
|
+
onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
|
|
100
|
+
});
|
|
101
|
+
io.stdout.write(`${options.json ? JSON.stringify(result) : result.path}\n`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const failures = [];
|
|
105
|
+
await Promise.all(Array.from({ length: Math.min(8, requestedUrls.length) }, async () => {
|
|
106
|
+
try {
|
|
107
|
+
await worker();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
failures.push(error);
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
if (failures.length > 0) {
|
|
114
|
+
throw failures[0];
|
|
115
|
+
}
|
|
62
116
|
});
|
|
63
117
|
program
|
|
64
118
|
.command("list")
|
|
@@ -115,5 +169,5 @@ if (process.argv[1] !== undefined) {
|
|
|
115
169
|
}
|
|
116
170
|
}
|
|
117
171
|
if (isMain) {
|
|
118
|
-
process.exitCode = await runCli();
|
|
172
|
+
process.exitCode = await runCli(process.argv, process);
|
|
119
173
|
}
|
|
@@ -5,13 +5,9 @@ export interface FrontmatterOptions {
|
|
|
5
5
|
sourceUrl: string;
|
|
6
6
|
requestedUrl: string;
|
|
7
7
|
created: Date | string;
|
|
8
|
-
modified: Date;
|
|
9
|
-
contentDigest: string;
|
|
8
|
+
modified: Date | string;
|
|
10
9
|
etag?: string;
|
|
11
10
|
lastModified?: string;
|
|
12
|
-
vary?: string[];
|
|
13
|
-
image?: string;
|
|
14
|
-
imageSource?: string;
|
|
15
11
|
config?: MdhqConfig["frontmatter"];
|
|
16
12
|
}
|
|
17
13
|
interface ControlledFrontmatterOptions {
|
|
@@ -19,11 +15,9 @@ interface ControlledFrontmatterOptions {
|
|
|
19
15
|
sourceUrl: string;
|
|
20
16
|
requestedUrl: string;
|
|
21
17
|
created: Date | string;
|
|
22
|
-
modified: Date;
|
|
23
|
-
contentDigest: string;
|
|
18
|
+
modified: Date | string;
|
|
24
19
|
etag?: string;
|
|
25
20
|
lastModified?: string;
|
|
26
|
-
vary?: string[];
|
|
27
21
|
config?: MdhqConfig["frontmatter"];
|
|
28
22
|
}
|
|
29
23
|
export declare function buildFrontmatter(options: FrontmatterOptions): Record<string, unknown>;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { parse, stringify } from "yaml";
|
|
3
3
|
import { formatLocalRfc3339 } from "../date.js";
|
|
4
|
+
const REMOVED_DEFAULT_FIELDS = ["site", "domain", "image", "image_source", "word_count"];
|
|
4
5
|
function applyControlledFields(options) {
|
|
5
6
|
const fields = { ...options.fields };
|
|
6
7
|
delete fields.type;
|
|
8
|
+
for (const key of REMOVED_DEFAULT_FIELDS) {
|
|
9
|
+
delete fields[key];
|
|
10
|
+
}
|
|
7
11
|
for (const key of options.config?.exclude ?? []) {
|
|
8
12
|
delete fields[key];
|
|
9
13
|
}
|
|
@@ -21,8 +25,11 @@ function applyControlledFields(options) {
|
|
|
21
25
|
typeof options.created === "string"
|
|
22
26
|
? options.created
|
|
23
27
|
: formatLocalRfc3339(options.created);
|
|
24
|
-
fields.modified =
|
|
25
|
-
|
|
28
|
+
fields.modified =
|
|
29
|
+
typeof options.modified === "string"
|
|
30
|
+
? options.modified
|
|
31
|
+
: formatLocalRfc3339(options.modified);
|
|
32
|
+
delete fields.content_digest;
|
|
26
33
|
if (options.etag) {
|
|
27
34
|
fields.etag = options.etag;
|
|
28
35
|
}
|
|
@@ -35,12 +42,7 @@ function applyControlledFields(options) {
|
|
|
35
42
|
else {
|
|
36
43
|
delete fields.last_modified;
|
|
37
44
|
}
|
|
38
|
-
|
|
39
|
-
fields.vary = options.vary;
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
delete fields.vary;
|
|
43
|
-
}
|
|
45
|
+
delete fields.vary;
|
|
44
46
|
return fields;
|
|
45
47
|
}
|
|
46
48
|
export function buildFrontmatter(options) {
|
|
@@ -51,25 +53,13 @@ export function buildFrontmatter(options) {
|
|
|
51
53
|
["author", options.metadata.author],
|
|
52
54
|
["published", options.metadata.published],
|
|
53
55
|
["updated", options.metadata.updated],
|
|
54
|
-
["
|
|
55
|
-
["domain", options.metadata.domain],
|
|
56
|
-
["language", options.metadata.language],
|
|
57
|
-
["word_count", options.metadata.wordCount]
|
|
56
|
+
["language", options.metadata.language]
|
|
58
57
|
];
|
|
59
58
|
for (const [key, value] of metadataFields) {
|
|
60
59
|
if (value !== undefined && value !== "") {
|
|
61
60
|
fields[key] = value;
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
|
-
if (options.image) {
|
|
65
|
-
fields.image = options.image;
|
|
66
|
-
}
|
|
67
|
-
else if (options.metadata.image) {
|
|
68
|
-
fields.image = options.metadata.image;
|
|
69
|
-
}
|
|
70
|
-
if (options.imageSource) {
|
|
71
|
-
fields.image_source = options.imageSource;
|
|
72
|
-
}
|
|
73
63
|
return applyControlledFields({ ...options, fields });
|
|
74
64
|
}
|
|
75
65
|
export function refreshFrontmatter(existing, options) {
|
package/dist/get-page.js
CHANGED
|
@@ -9,7 +9,8 @@ import { fetchHtml, fetchWithEnvProxy } from "./http/fetch.js";
|
|
|
9
9
|
import { transformMarkdown } from "./markdown/transform.js";
|
|
10
10
|
import { storagePathForUrl } from "./path/storage-path.js";
|
|
11
11
|
import { inspectDestination, saveDocument } from "./storage/save.js";
|
|
12
|
-
import { normalizeHost,
|
|
12
|
+
import { normalizeHost, sameHttpTarget } from "./url/identity.js";
|
|
13
|
+
import { normalizeRequestedUrl, normalizeSourceUrl, normalizeSourceUrlWithoutCanonical } from "./url/normalize.js";
|
|
13
14
|
function varyNames(value) {
|
|
14
15
|
return [
|
|
15
16
|
...new Set((value ?? "")
|
|
@@ -24,8 +25,37 @@ function hasCredentialHeaders(headers) {
|
|
|
24
25
|
return name === "authorization" || name === "cookie";
|
|
25
26
|
});
|
|
26
27
|
}
|
|
28
|
+
const NOTE_BOOKKEEPING_FIELDS = new Set([
|
|
29
|
+
"created",
|
|
30
|
+
"modified",
|
|
31
|
+
"etag",
|
|
32
|
+
"last_modified",
|
|
33
|
+
"content_digest",
|
|
34
|
+
"vary"
|
|
35
|
+
]);
|
|
36
|
+
function normalizeComparable(value) {
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return value.map(normalizeComparable);
|
|
39
|
+
}
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
return Object.fromEntries(Object.entries(value)
|
|
42
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
43
|
+
.map(([key, entry]) => [key, normalizeComparable(entry)]));
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function userFacingFrontmatter(frontmatter) {
|
|
48
|
+
return Object.fromEntries(Object.entries(frontmatter)
|
|
49
|
+
.filter(([key]) => !NOTE_BOOKKEEPING_FIELDS.has(key))
|
|
50
|
+
.map(([key, value]) => [key, normalizeComparable(value)])
|
|
51
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
52
|
+
}
|
|
53
|
+
function sameUserFacingFrontmatter(left, right) {
|
|
54
|
+
return (JSON.stringify(userFacingFrontmatter(left)) ===
|
|
55
|
+
JSON.stringify(userFacingFrontmatter(right)));
|
|
56
|
+
}
|
|
27
57
|
export async function getPage(options) {
|
|
28
|
-
const requestedUrl =
|
|
58
|
+
const requestedUrl = normalizeRequestedUrl(options.url);
|
|
29
59
|
const loaded = await loadConfig(options.configPath);
|
|
30
60
|
const warnings = [];
|
|
31
61
|
const warn = (warning) => {
|
|
@@ -40,7 +70,8 @@ export async function getPage(options) {
|
|
|
40
70
|
}
|
|
41
71
|
async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeadersAllowed) {
|
|
42
72
|
const { options, requestedUrl, loaded, warnings, warn, root } = context;
|
|
43
|
-
const
|
|
73
|
+
const requestedCandidateUrl = normalizeSourceUrlWithoutCanonical(fetchUrl);
|
|
74
|
+
const requested = new URL(requestedCandidateUrl);
|
|
44
75
|
const requestedConfig = resolveHostConfig(normalizeHost(requested), requested.pathname, loaded.config.hosts ?? {});
|
|
45
76
|
const requestedEntryKey = requestedConfig?.entryQueryKey ?? undefined;
|
|
46
77
|
const requestedPath = storagePathForUrl({
|
|
@@ -48,7 +79,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
48
79
|
url: requested,
|
|
49
80
|
...(requestedEntryKey ? { entryQueryKey: requestedEntryKey } : {})
|
|
50
81
|
});
|
|
51
|
-
const requestedExisting = await inspectDestination(requestedPath,
|
|
82
|
+
const requestedExisting = await inspectDestination(requestedPath, requestedCandidateUrl, requestedEntryKey, root);
|
|
52
83
|
if (requestedExisting && !options.update) {
|
|
53
84
|
return {
|
|
54
85
|
requestedUrl,
|
|
@@ -59,6 +90,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
59
90
|
warnings
|
|
60
91
|
};
|
|
61
92
|
}
|
|
93
|
+
if (options.update &&
|
|
94
|
+
requestedExisting &&
|
|
95
|
+
!sameHttpTarget(requestedCandidateUrl, fetchUrl)) {
|
|
96
|
+
return getPageAttempt(context, requestedCandidateUrl, retriesRemaining, callerHeadersAllowed);
|
|
97
|
+
}
|
|
62
98
|
const headers = callerHeadersAllowed ? options.headers : [];
|
|
63
99
|
const userAgent = options.userAgent ?? loaded.config.userAgent;
|
|
64
100
|
const timeoutMs = options.timeoutMs ?? loaded.config.timeoutMs;
|
|
@@ -69,12 +105,12 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
69
105
|
...(userAgent ? { userAgent } : {}),
|
|
70
106
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
71
107
|
...(maxResponseBytes !== undefined ? { maxResponseBytes } : {}),
|
|
72
|
-
...(maxRedirects !== undefined ? { maxRedirects } : {})
|
|
108
|
+
...(maxRedirects !== undefined ? { maxRedirects } : {}),
|
|
109
|
+
...(options.scheduler ? { scheduler: options.scheduler } : {})
|
|
73
110
|
};
|
|
74
111
|
let conditional;
|
|
75
112
|
if (options.update &&
|
|
76
113
|
requestedExisting &&
|
|
77
|
-
requestedExisting.vary?.length === 0 &&
|
|
78
114
|
!hasCredentialHeaders(headers) &&
|
|
79
115
|
sameHttpTarget(requestedExisting.sourceUrl, fetchUrl)) {
|
|
80
116
|
if (requestedExisting.etag) {
|
|
@@ -126,17 +162,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
126
162
|
created: isRfc3339DateTime(requestedExisting.created)
|
|
127
163
|
? requestedExisting.created
|
|
128
164
|
: now,
|
|
129
|
-
modified: now,
|
|
130
|
-
contentDigest: requestedExisting.contentDigest,
|
|
165
|
+
modified: requestedExisting.modified ?? now,
|
|
131
166
|
...(etag ? { etag } : {}),
|
|
132
167
|
...(reusableLastModified
|
|
133
168
|
? { lastModified: reusableLastModified }
|
|
134
169
|
: {}),
|
|
135
|
-
...(vary.length > 0
|
|
136
|
-
? { vary }
|
|
137
|
-
: etag || reusableLastModified
|
|
138
|
-
? { vary: [] }
|
|
139
|
-
: {}),
|
|
140
170
|
...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
|
|
141
171
|
});
|
|
142
172
|
const content = serializeDocument(frontmatter, requestedExisting.markdown);
|
|
@@ -159,32 +189,34 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
159
189
|
requestedUrl,
|
|
160
190
|
sourceUrl: requestedExisting.sourceUrl,
|
|
161
191
|
path: requestedPath,
|
|
162
|
-
status: storageStatus === "
|
|
192
|
+
status: storageStatus === "saved" ? "saved" : "unchanged",
|
|
163
193
|
assets: [],
|
|
164
194
|
warnings
|
|
165
195
|
};
|
|
166
196
|
}
|
|
167
|
-
const
|
|
168
|
-
const
|
|
197
|
+
const finalResponseUrl = new URL(fetched.finalUrl);
|
|
198
|
+
const sourceUrl = normalizeSourceUrl(fetched.html, finalResponseUrl);
|
|
199
|
+
const normalizedSource = new URL(sourceUrl);
|
|
200
|
+
const matchedConfig = resolveHostConfig(normalizeHost(normalizedSource), normalizedSource.pathname, loaded.config.hosts ?? {});
|
|
169
201
|
const entryQueryKey = matchedConfig?.entryQueryKey ?? undefined;
|
|
170
202
|
const markdownPath = storagePathForUrl({
|
|
171
203
|
root,
|
|
172
|
-
url:
|
|
204
|
+
url: normalizedSource,
|
|
173
205
|
...(entryQueryKey ? { entryQueryKey } : {})
|
|
174
206
|
});
|
|
175
|
-
const existing = await inspectDestination(markdownPath,
|
|
207
|
+
const existing = await inspectDestination(markdownPath, sourceUrl, entryQueryKey, root);
|
|
176
208
|
if (options.update &&
|
|
177
209
|
existing &&
|
|
178
210
|
markdownPath !== requestedPath) {
|
|
179
211
|
if (retriesRemaining === 0) {
|
|
180
212
|
throw new MdhqError("STORAGE_ERROR", `Redirect destination changed repeatedly while updating ${markdownPath}`);
|
|
181
213
|
}
|
|
182
|
-
return getPageAttempt(context,
|
|
214
|
+
return getPageAttempt(context, sourceUrl, retriesRemaining - 1, responseHeadersAllowed);
|
|
183
215
|
}
|
|
184
216
|
if (existing && !options.update) {
|
|
185
217
|
return {
|
|
186
218
|
requestedUrl,
|
|
187
|
-
sourceUrl
|
|
219
|
+
sourceUrl,
|
|
188
220
|
path: markdownPath,
|
|
189
221
|
status: "skipped",
|
|
190
222
|
assets: [],
|
|
@@ -195,7 +227,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
195
227
|
const responseCredentialed = responseHeadersAllowed && hasCredentialHeaders(http.headers);
|
|
196
228
|
const converted = await convertHtml({
|
|
197
229
|
html: fetched.html,
|
|
198
|
-
url:
|
|
230
|
+
url: finalResponseUrl,
|
|
199
231
|
defuddle: {
|
|
200
232
|
...loaded.config.defuddle,
|
|
201
233
|
fetch: fetchWithEnvProxy,
|
|
@@ -208,7 +240,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
208
240
|
let metadata = converted.metadata;
|
|
209
241
|
if (converted.metadata.image) {
|
|
210
242
|
try {
|
|
211
|
-
const image = new URL(converted.metadata.image,
|
|
243
|
+
const image = new URL(converted.metadata.image, finalResponseUrl);
|
|
212
244
|
if (image.protocol !== "http:" && image.protocol !== "https:") {
|
|
213
245
|
throw new TypeError(`Unsupported image URL scheme: ${image.protocol}`);
|
|
214
246
|
}
|
|
@@ -223,11 +255,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
223
255
|
warn({
|
|
224
256
|
code: "INVALID_IMAGE_URL",
|
|
225
257
|
message: `Invalid representative image URL: ${converted.metadata.image}`,
|
|
226
|
-
url:
|
|
258
|
+
url: finalResponseUrl.href
|
|
227
259
|
});
|
|
228
260
|
}
|
|
229
261
|
}
|
|
230
|
-
const transformed = transformMarkdown(converted.markdown,
|
|
262
|
+
const transformed = transformMarkdown(converted.markdown, finalResponseUrl.href);
|
|
231
263
|
const assetsEnabled = options.assets ?? loaded.config.assets ?? true;
|
|
232
264
|
const localized = assetsEnabled
|
|
233
265
|
? await localizeAssets({
|
|
@@ -236,7 +268,7 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
236
268
|
...(metadata.image ? { representativeImage: metadata.image } : {}),
|
|
237
269
|
markdownPath,
|
|
238
270
|
root,
|
|
239
|
-
baseUrl:
|
|
271
|
+
baseUrl: finalResponseUrl.href,
|
|
240
272
|
http: responseHeadersAllowed ? http : { ...http, headers: [] },
|
|
241
273
|
warn
|
|
242
274
|
})
|
|
@@ -251,36 +283,36 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
251
283
|
const contentDigest = markdownContentDigest(localized.markdown);
|
|
252
284
|
const lastModified = normalizeLastModified(fetched.lastModified);
|
|
253
285
|
const vary = varyNames(fetched.vary);
|
|
254
|
-
const validatorsReusable = vary.length === 0 &&
|
|
286
|
+
const validatorsReusable = vary.length === 0 &&
|
|
287
|
+
!responseCredentialed &&
|
|
288
|
+
sameHttpTarget(sourceUrl, finalResponseUrl);
|
|
255
289
|
const etag = validatorsReusable ? fetched.etag : undefined;
|
|
256
290
|
const reusableLastModified = validatorsReusable ? lastModified : undefined;
|
|
257
|
-
const
|
|
291
|
+
const nextFrontmatterOptions = {
|
|
258
292
|
metadata,
|
|
259
|
-
sourceUrl
|
|
293
|
+
sourceUrl,
|
|
260
294
|
requestedUrl,
|
|
261
295
|
created,
|
|
262
296
|
modified: now,
|
|
263
|
-
contentDigest,
|
|
264
297
|
...(etag ? { etag } : {}),
|
|
265
298
|
...(reusableLastModified ? { lastModified: reusableLastModified } : {}),
|
|
266
|
-
...(vary.length > 0
|
|
267
|
-
? { vary }
|
|
268
|
-
: etag || reusableLastModified
|
|
269
|
-
? { vary: [] }
|
|
270
|
-
: {}),
|
|
271
|
-
...(localized.representativeImage
|
|
272
|
-
? { image: localized.representativeImage }
|
|
273
|
-
: {}),
|
|
274
|
-
...(localized.representativeImageSource
|
|
275
|
-
? { imageSource: localized.representativeImageSource }
|
|
276
|
-
: {}),
|
|
277
299
|
...(loaded.config.frontmatter ? { config: loaded.config.frontmatter } : {})
|
|
278
|
-
}
|
|
300
|
+
};
|
|
301
|
+
const nextFrontmatter = buildFrontmatter(nextFrontmatterOptions);
|
|
302
|
+
const noteChanged = !expectedExisting ||
|
|
303
|
+
expectedExisting.contentDigest !== contentDigest ||
|
|
304
|
+
!sameUserFacingFrontmatter(expectedExisting.frontmatter, nextFrontmatter);
|
|
305
|
+
const frontmatter = noteChanged
|
|
306
|
+
? nextFrontmatter
|
|
307
|
+
: buildFrontmatter({
|
|
308
|
+
...nextFrontmatterOptions,
|
|
309
|
+
modified: expectedExisting.modified ?? now
|
|
310
|
+
});
|
|
279
311
|
const content = serializeDocument(frontmatter, localized.markdown);
|
|
280
312
|
const storageStatus = await saveDocument({
|
|
281
313
|
path: markdownPath,
|
|
282
314
|
content,
|
|
283
|
-
sourceUrl
|
|
315
|
+
sourceUrl,
|
|
284
316
|
update: options.update ?? false,
|
|
285
317
|
...(options.update
|
|
286
318
|
? { expectedContent: expectedExisting?.content ?? null }
|
|
@@ -296,10 +328,11 @@ async function getPageAttempt(context, fetchUrl, retriesRemaining, callerHeaders
|
|
|
296
328
|
}
|
|
297
329
|
return {
|
|
298
330
|
requestedUrl,
|
|
299
|
-
sourceUrl
|
|
331
|
+
sourceUrl,
|
|
300
332
|
path: markdownPath,
|
|
301
|
-
status:
|
|
302
|
-
|
|
333
|
+
status: expectedExisting &&
|
|
334
|
+
(storageStatus === "updated" || storageStatus === "skipped") &&
|
|
335
|
+
!noteChanged
|
|
303
336
|
? "unchanged"
|
|
304
337
|
: storageStatus,
|
|
305
338
|
assets: localized.assets,
|
package/dist/http/fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HeaderValue } from "../types.js";
|
|
2
|
+
import type { RequestScheduler } from "./scheduler.js";
|
|
2
3
|
export interface FetchResourceOptions {
|
|
3
4
|
headers?: HeaderValue[];
|
|
4
5
|
userAgent?: string;
|
|
@@ -11,6 +12,7 @@ export interface FetchResourceOptions {
|
|
|
11
12
|
etag?: string;
|
|
12
13
|
lastModified?: string;
|
|
13
14
|
};
|
|
15
|
+
scheduler?: RequestScheduler;
|
|
14
16
|
}
|
|
15
17
|
export interface FetchedResource {
|
|
16
18
|
body: Uint8Array;
|
package/dist/http/fetch.js
CHANGED
|
@@ -73,12 +73,13 @@ export async function fetchResource(input, options = {}) {
|
|
|
73
73
|
for (let redirects = 0;; redirects += 1) {
|
|
74
74
|
let response;
|
|
75
75
|
try {
|
|
76
|
-
|
|
76
|
+
const fetch = () => undiciFetch(url, {
|
|
77
77
|
dispatcher: proxyAgent,
|
|
78
78
|
headers: requestHeaders(options, customHeadersAllowed, redirects === 0),
|
|
79
79
|
redirect: "manual",
|
|
80
80
|
signal: AbortSignal.timeout(timeoutMs)
|
|
81
|
-
})
|
|
81
|
+
});
|
|
82
|
+
response = options.scheduler ? await options.scheduler.run(url, fetch) : await fetch();
|
|
82
83
|
}
|
|
83
84
|
catch (error) {
|
|
84
85
|
throw new MdhqError("FETCH_FAILED", `Failed to fetch ${url.href}`, { cause: error });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class RequestScheduler {
|
|
2
|
+
private readonly maxConcurrent;
|
|
3
|
+
private readonly hostIntervalMs;
|
|
4
|
+
private active;
|
|
5
|
+
private readonly pending;
|
|
6
|
+
private readonly activeHosts;
|
|
7
|
+
private readonly lastStarted;
|
|
8
|
+
private timer;
|
|
9
|
+
constructor(maxConcurrent?: number, hostIntervalMs?: number);
|
|
10
|
+
run<T>(url: string | URL, task: () => Promise<T>): Promise<T>;
|
|
11
|
+
private pump;
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class RequestScheduler {
|
|
2
|
+
maxConcurrent;
|
|
3
|
+
hostIntervalMs;
|
|
4
|
+
active = 0;
|
|
5
|
+
pending = [];
|
|
6
|
+
activeHosts = new Set();
|
|
7
|
+
lastStarted = new Map();
|
|
8
|
+
timer;
|
|
9
|
+
constructor(maxConcurrent = 8, hostIntervalMs = 1_000) {
|
|
10
|
+
this.maxConcurrent = maxConcurrent;
|
|
11
|
+
this.hostIntervalMs = hostIntervalMs;
|
|
12
|
+
}
|
|
13
|
+
async run(url, task) {
|
|
14
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
15
|
+
await new Promise((resolve) => {
|
|
16
|
+
this.pending.push({ host, run: resolve });
|
|
17
|
+
this.pump();
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
return await task();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
this.active -= 1;
|
|
24
|
+
this.activeHosts.delete(host);
|
|
25
|
+
this.pump();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
pump() {
|
|
29
|
+
if (this.timer) {
|
|
30
|
+
clearTimeout(this.timer);
|
|
31
|
+
this.timer = undefined;
|
|
32
|
+
}
|
|
33
|
+
if (this.active >= this.maxConcurrent) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
let waitMs;
|
|
38
|
+
const index = this.pending.findIndex(({ host }) => {
|
|
39
|
+
if (this.activeHosts.has(host)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const remaining = (this.lastStarted.get(host) ?? 0) + this.hostIntervalMs - now;
|
|
43
|
+
if (remaining > 0) {
|
|
44
|
+
waitMs = waitMs === undefined ? remaining : Math.min(waitMs, remaining);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
if (index < 0) {
|
|
50
|
+
if (waitMs !== undefined) {
|
|
51
|
+
this.timer = setTimeout(() => {
|
|
52
|
+
this.timer = undefined;
|
|
53
|
+
this.pump();
|
|
54
|
+
}, waitMs);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const item = this.pending.splice(index, 1)[0];
|
|
59
|
+
if (!item) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { host, run } = item;
|
|
63
|
+
this.active += 1;
|
|
64
|
+
this.activeHosts.add(host);
|
|
65
|
+
this.lastStarted.set(host, Date.now());
|
|
66
|
+
run();
|
|
67
|
+
this.pump();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { MdhqError } from "../errors.js";
|
|
4
|
-
import { createUrlIdentity, parseHttpUrl } from "../url/identity.js";
|
|
4
|
+
import { createUrlIdentity, parseHttpUrl, queryTailHash } from "../url/identity.js";
|
|
5
5
|
import { decodeUrlPathSegment, storageBasename } from "../url/pathname.js";
|
|
6
6
|
const INVALID_CHARACTERS = /[\/\\:*?"<>|\u0000-\u001f\u007f]/u;
|
|
7
7
|
const WINDOWS_DEVICES = /^(con|prn|aux|nul|com(?:[1-9]|[¹²³])|lpt(?:[1-9]|[¹²³]))(?:\.|$)/iu;
|
|
@@ -66,13 +66,32 @@ export function storagePathForUrl(options) {
|
|
|
66
66
|
const url = parseHttpUrl(options.url);
|
|
67
67
|
const identity = createUrlIdentity(url, options.entryQueryKey);
|
|
68
68
|
const rawSegments = url.pathname.split("/").filter(Boolean);
|
|
69
|
-
const
|
|
69
|
+
const queryHash = identity.entryValue === undefined ? queryTailHash(url) : undefined;
|
|
70
|
+
const directorySegments = (queryHash && url.pathname.endsWith("/")
|
|
71
|
+
? rawSegments
|
|
72
|
+
: rawSegments.slice(0, -1));
|
|
73
|
+
if (queryHash && url.pathname.endsWith("/")) {
|
|
74
|
+
if (directorySegments.length === 0) {
|
|
75
|
+
directorySegments.push("index");
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const finalIndex = directorySegments.length - 1;
|
|
79
|
+
const finalSegment = directorySegments[finalIndex];
|
|
80
|
+
if (finalSegment !== undefined) {
|
|
81
|
+
directorySegments[finalIndex] = encodeURIComponent(storageBasename(finalSegment));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const directories = directorySegments.map((segment) => fitSegment(segment));
|
|
70
86
|
let filename;
|
|
71
87
|
if (identity.entryValue !== undefined) {
|
|
72
88
|
const pageSegment = rawSegments.at(-1) ?? "index";
|
|
73
89
|
directories.push(fitSegment(pageSegment));
|
|
74
90
|
filename = `${fitSegment(encodeURIComponent(identity.entryValue), ".md")}.md`;
|
|
75
91
|
}
|
|
92
|
+
else if (queryHash) {
|
|
93
|
+
filename = `${queryHash}.md`;
|
|
94
|
+
}
|
|
76
95
|
else if (rawSegments.length === 0) {
|
|
77
96
|
filename = "index.md";
|
|
78
97
|
}
|
package/dist/storage/save.d.ts
CHANGED
|
@@ -14,9 +14,9 @@ export interface ExistingDocument {
|
|
|
14
14
|
markdown: string;
|
|
15
15
|
contentDigest: string;
|
|
16
16
|
created?: string;
|
|
17
|
+
modified?: string;
|
|
17
18
|
etag?: string;
|
|
18
19
|
lastModified?: string;
|
|
19
|
-
vary?: string[];
|
|
20
20
|
}
|
|
21
21
|
export declare function readExistingDocument(filePath: string): Promise<ExistingDocument | undefined>;
|
|
22
22
|
export declare function inspectDestination(filePath: string, sourceUrl: string, entryQueryKey?: string, root?: string): Promise<ExistingDocument | undefined>;
|
package/dist/storage/save.js
CHANGED
|
@@ -29,13 +29,12 @@ export async function readExistingDocument(filePath) {
|
|
|
29
29
|
markdown: parsed.markdown,
|
|
30
30
|
contentDigest: markdownContentDigest(parsed.markdown),
|
|
31
31
|
...(typeof frontmatter.created === "string" ? { created: frontmatter.created } : {}),
|
|
32
|
+
...(typeof frontmatter.modified === "string"
|
|
33
|
+
? { modified: frontmatter.modified }
|
|
34
|
+
: {}),
|
|
32
35
|
...(typeof frontmatter.etag === "string" ? { etag: frontmatter.etag } : {}),
|
|
33
36
|
...(typeof frontmatter.last_modified === "string"
|
|
34
37
|
? { lastModified: frontmatter.last_modified }
|
|
35
|
-
: {}),
|
|
36
|
-
...(Array.isArray(frontmatter.vary) &&
|
|
37
|
-
frontmatter.vary.every((value) => typeof value === "string")
|
|
38
|
-
? { vary: frontmatter.vary }
|
|
39
38
|
: {})
|
|
40
39
|
};
|
|
41
40
|
}
|
|
@@ -81,6 +80,9 @@ export async function saveDocument(options) {
|
|
|
81
80
|
if (!options.update) {
|
|
82
81
|
return "skipped";
|
|
83
82
|
}
|
|
83
|
+
if (existing.content === options.content) {
|
|
84
|
+
return "skipped";
|
|
85
|
+
}
|
|
84
86
|
}
|
|
85
87
|
if (existing) {
|
|
86
88
|
await replaceFileAtomic(options.path, options.content, {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DefuddleOptions } from "defuddle/node";
|
|
2
|
+
import type { RequestScheduler } from "./http/scheduler.js";
|
|
2
3
|
export interface MdhqWarning {
|
|
3
4
|
code: string;
|
|
4
5
|
message: string;
|
|
@@ -44,6 +45,7 @@ export interface GetPageOptions {
|
|
|
44
45
|
useAsync?: boolean;
|
|
45
46
|
now?: () => Date;
|
|
46
47
|
onWarning?: (warning: MdhqWarning) => void;
|
|
48
|
+
scheduler?: RequestScheduler;
|
|
47
49
|
}
|
|
48
50
|
export interface AssetResult {
|
|
49
51
|
sourceUrl: string;
|
package/dist/url/identity.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ export interface UrlIdentity {
|
|
|
3
3
|
pathname: string;
|
|
4
4
|
entryKey?: string;
|
|
5
5
|
entryValue?: string;
|
|
6
|
+
queryHash?: string;
|
|
6
7
|
}
|
|
8
|
+
export declare function queryTail(url: URL): string;
|
|
9
|
+
export declare function queryTailHash(url: URL): string | undefined;
|
|
7
10
|
export declare function parseHttpUrl(input: string | URL): URL;
|
|
8
11
|
export declare function normalizeHost(url: URL): string;
|
|
9
12
|
export declare function createUrlIdentity(input: string | URL, entryQueryKey?: string): UrlIdentity;
|
package/dist/url/identity.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { domainToASCII } from "node:url";
|
|
2
3
|
import { MdhqError } from "../errors.js";
|
|
3
4
|
import { canonicalPathname } from "./pathname.js";
|
|
5
|
+
export function queryTail(url) {
|
|
6
|
+
if (!url.search) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
if (url.pathname.endsWith("/")) {
|
|
10
|
+
return url.search;
|
|
11
|
+
}
|
|
12
|
+
return `${url.pathname.split("/").at(-1) ?? ""}${url.search}`;
|
|
13
|
+
}
|
|
14
|
+
export function queryTailHash(url) {
|
|
15
|
+
const tail = queryTail(url);
|
|
16
|
+
return tail
|
|
17
|
+
? createHash("md5").update(tail, "utf8").digest("hex")
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
4
20
|
export function parseHttpUrl(input) {
|
|
5
21
|
let url;
|
|
6
22
|
try {
|
|
@@ -23,8 +39,10 @@ export function normalizeHost(url) {
|
|
|
23
39
|
}
|
|
24
40
|
export function createUrlIdentity(input, entryQueryKey) {
|
|
25
41
|
const url = parseHttpUrl(input);
|
|
26
|
-
const entryValue = entryQueryKey
|
|
27
|
-
|
|
42
|
+
const entryValue = entryQueryKey
|
|
43
|
+
? url.searchParams.getAll(entryQueryKey).find((value) => value !== "")
|
|
44
|
+
: undefined;
|
|
45
|
+
const hasEntryValue = entryValue !== undefined;
|
|
28
46
|
const identity = {
|
|
29
47
|
host: normalizeHost(url),
|
|
30
48
|
pathname: canonicalPathname(url.pathname || "/", hasEntryValue)
|
|
@@ -33,12 +51,20 @@ export function createUrlIdentity(input, entryQueryKey) {
|
|
|
33
51
|
identity.entryKey = entryQueryKey;
|
|
34
52
|
identity.entryValue = entryValue.normalize("NFC");
|
|
35
53
|
}
|
|
54
|
+
else {
|
|
55
|
+
const hash = queryTailHash(url);
|
|
56
|
+
if (hash) {
|
|
57
|
+
identity.queryHash = hash;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
36
60
|
return identity;
|
|
37
61
|
}
|
|
38
62
|
export function serializeUrlIdentity(identity) {
|
|
39
63
|
const entry = identity.entryKey && identity.entryValue
|
|
40
|
-
?
|
|
41
|
-
:
|
|
64
|
+
? `?entry-key=${encodeURIComponent(identity.entryKey)}&entry-value=${encodeURIComponent(identity.entryValue)}`
|
|
65
|
+
: identity.queryHash
|
|
66
|
+
? `?query-md5=${identity.queryHash}`
|
|
67
|
+
: "";
|
|
42
68
|
return `//${identity.host}${identity.pathname}${entry}`;
|
|
43
69
|
}
|
|
44
70
|
export function sameUrlIdentity(left, right, entryQueryKey) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseHTML } from "linkedom";
|
|
2
|
+
import { defaultTrackingParams, stripTrackingParams } from "urlpurify";
|
|
3
|
+
import { canonicalPathname } from "./pathname.js";
|
|
4
|
+
import { normalizeHost, parseHttpUrl } from "./identity.js";
|
|
5
|
+
const FUNCTIONAL_PAGE_PARAMS = new Set([
|
|
6
|
+
"preview",
|
|
7
|
+
"preview_id",
|
|
8
|
+
"preview_nonce"
|
|
9
|
+
]);
|
|
10
|
+
const PAGE_TRACKING_PARAMS = defaultTrackingParams.filter((parameter) => typeof parameter !== "string" ||
|
|
11
|
+
!FUNCTIONAL_PAGE_PARAMS.has(parameter.toLowerCase()));
|
|
12
|
+
function withoutFragment(url) {
|
|
13
|
+
const normalized = new URL(url.href);
|
|
14
|
+
normalized.hash = "";
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
function normalizedOrigin(url) {
|
|
18
|
+
return `${url.protocol}//${normalizeHost(url)}`;
|
|
19
|
+
}
|
|
20
|
+
function canonicalUrl(html, finalUrl) {
|
|
21
|
+
let document;
|
|
22
|
+
try {
|
|
23
|
+
({ document } = parseHTML(html));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const canonicalLinks = [...document.querySelectorAll("link[href]")].filter((link) => (link.getAttribute("rel") ?? "")
|
|
29
|
+
.split(/\s+/u)
|
|
30
|
+
.some((token) => token.toLowerCase() === "canonical"));
|
|
31
|
+
if (canonicalLinks.length !== 1) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
let baseUrl = finalUrl;
|
|
35
|
+
const baseHref = document.querySelector("base[href]")?.getAttribute("href");
|
|
36
|
+
if (baseHref) {
|
|
37
|
+
try {
|
|
38
|
+
baseUrl = parseHttpUrl(new URL(baseHref, finalUrl));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
baseUrl = finalUrl;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const href = canonicalLinks[0]?.getAttribute("href");
|
|
45
|
+
if (!href) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return parseHttpUrl(new URL(href, baseUrl));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function isCanonicalEquivalent(finalUrl, canonical) {
|
|
56
|
+
return (canonical.username === "" &&
|
|
57
|
+
canonical.password === "" &&
|
|
58
|
+
normalizedOrigin(finalUrl) === normalizedOrigin(canonical) &&
|
|
59
|
+
canonicalPathname(finalUrl.pathname || "/") ===
|
|
60
|
+
canonicalPathname(canonical.pathname || "/"));
|
|
61
|
+
}
|
|
62
|
+
export function normalizeRequestedUrl(input) {
|
|
63
|
+
return withoutFragment(parseHttpUrl(input)).href;
|
|
64
|
+
}
|
|
65
|
+
export function normalizeSourceUrlWithoutCanonical(input) {
|
|
66
|
+
const url = withoutFragment(parseHttpUrl(input));
|
|
67
|
+
return withoutFragment(parseHttpUrl(stripTrackingParams(url.href, PAGE_TRACKING_PARAMS))).href;
|
|
68
|
+
}
|
|
69
|
+
export function normalizeSourceUrl(html, finalUrlInput) {
|
|
70
|
+
const finalUrl = withoutFragment(parseHttpUrl(finalUrlInput));
|
|
71
|
+
const canonical = canonicalUrl(html, finalUrl);
|
|
72
|
+
if (canonical) {
|
|
73
|
+
try {
|
|
74
|
+
if (isCanonicalEquivalent(finalUrl, canonical)) {
|
|
75
|
+
return withoutFragment(canonical).href;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Ignore malformed optional canonical metadata.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return normalizeSourceUrlWithoutCanonical(finalUrl);
|
|
83
|
+
}
|
package/docs/configuration.md
CHANGED
|
@@ -120,6 +120,10 @@ Use the dedicated User-Agent option for replacement.
|
|
|
120
120
|
credentials such as cookies or authorization tokens from being stored in the
|
|
121
121
|
configuration file.
|
|
122
122
|
|
|
123
|
+
After fetching and source normalization, host and path configuration is
|
|
124
|
+
resolved again against the normalized source URL. This final match determines
|
|
125
|
+
the effective `entryQueryKey` and storage destination.
|
|
126
|
+
|
|
123
127
|
## Defuddle options
|
|
124
128
|
|
|
125
129
|
Supported `defuddle` fields:
|
|
@@ -219,6 +223,11 @@ Selection order:
|
|
|
219
223
|
2. The matching glob with the greatest number of fixed literal characters.
|
|
220
224
|
3. No host configuration.
|
|
221
225
|
|
|
226
|
+
When the effective `entryQueryKey` has multiple values, mdhq uses the first
|
|
227
|
+
non-empty value and ignores the remaining query parameters for storage
|
|
228
|
+
identity. When no non-empty value exists, the URL uses the generic ordered
|
|
229
|
+
query-string MD5 destination rather than the queryless destination.
|
|
230
|
+
|
|
222
231
|
If multiple matching patterns have equal specificity, processing fails with
|
|
223
232
|
`CONFIG_ERROR`. Multiple patterns that normalize to the same exact host are
|
|
224
233
|
also an error.
|
|
@@ -238,5 +247,7 @@ Rules:
|
|
|
238
247
|
- A matching path-level string overrides the host value.
|
|
239
248
|
- A matching path-level `null` disables the host value.
|
|
240
249
|
- A matching path object that omits `entryQueryKey` inherits the host value.
|
|
241
|
-
-
|
|
242
|
-
|
|
250
|
+
- When at least one non-empty selected value exists, all non-selected query
|
|
251
|
+
parameters are ignored.
|
|
252
|
+
- When no non-empty selected value exists, a remaining query falls back to the
|
|
253
|
+
generic ordered query-string MD5 destination.
|
package/docs/library-api.md
CHANGED
|
@@ -159,14 +159,16 @@ interface GetPageResult {
|
|
|
159
159
|
}
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
- `requestedUrl` is the input after WHATWG URL serialization
|
|
163
|
-
and IDNA-normalizes the host
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
162
|
+
- `requestedUrl` is the input after WHATWG URL serialization and fragment
|
|
163
|
+
removal. This lowercases and IDNA-normalizes the host and removes an explicit
|
|
164
|
+
default port.
|
|
165
|
+
- `sourceUrl` is the normalized source URL written to frontmatter `source`.
|
|
166
|
+
After redirects, mdhq accepts one HTML canonical URL when its normalized
|
|
167
|
+
origin and pathname match the final response URL. Invalid, ambiguous,
|
|
168
|
+
cross-origin, or path-divergent canonical URLs are ignored. Without an
|
|
169
|
+
accepted canonical, mdhq uses `urlpurify` to remove known tracking
|
|
170
|
+
parameters from the final response URL. Fragments are always removed.
|
|
171
|
+
For a pre-fetch skip, `sourceUrl` is the existing document's stored source.
|
|
170
172
|
- `path` is the absolute Markdown path.
|
|
171
173
|
- `assets` is empty when page processing is skipped.
|
|
172
174
|
- `warnings` contains configuration and asset warnings.
|
|
@@ -187,6 +189,11 @@ for the same HTTP target and does not include credentials. `etag` and
|
|
|
187
189
|
`last_modified` are stored in Markdown frontmatter only when the response is
|
|
188
190
|
safe to revalidate; `Vary` and body digests are not serialized.
|
|
189
191
|
|
|
192
|
+
The fast pre-fetch skip is retained. If the requested URL candidate already
|
|
193
|
+
maps to an existing document and `update` is false, mdhq does not make a
|
|
194
|
+
network request and therefore cannot observe remote redirect or canonical
|
|
195
|
+
changes.
|
|
196
|
+
|
|
190
197
|
### Asset results
|
|
191
198
|
|
|
192
199
|
```ts
|
package/docs/specification.md
CHANGED
|
@@ -21,13 +21,13 @@ Runtime requirements:
|
|
|
21
21
|
The executable provides three subcommands:
|
|
22
22
|
|
|
23
23
|
```text
|
|
24
|
-
mdhq get [options]
|
|
24
|
+
mdhq get [options] [urls...]
|
|
25
25
|
mdhq list [options]
|
|
26
26
|
mdhq root [options]
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`mdhq get` accepts
|
|
30
|
-
|
|
29
|
+
`mdhq get` accepts multiple URL arguments and one URL per line from standard
|
|
30
|
+
input. Both sources are merged, and up to eight URLs are processed in parallel.
|
|
31
31
|
|
|
32
32
|
### `get` options
|
|
33
33
|
|
|
@@ -37,22 +37,19 @@ processing is delegated to external tools such as `xargs`.
|
|
|
37
37
|
| `--update` | Fetch and replace an existing document with the same URL identity. |
|
|
38
38
|
| `--user-agent <value>` | Override the default HTTP User-Agent. |
|
|
39
39
|
| `--header <header>` | Add an HTTP header. The option is repeatable and uses `Name: value` syntax. |
|
|
40
|
-
| `--json` | Write
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
path followed by a newline.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"assets": [],
|
|
54
|
-
"warnings": []
|
|
55
|
-
}
|
|
40
|
+
| `--json` | Write one result per line as JSON Lines instead of only the Markdown path. |
|
|
41
|
+
|
|
42
|
+
Without `--json`, stdout receives one absolute Markdown path per requested URL,
|
|
43
|
+
with each path followed by a newline. Each result is written as soon as that
|
|
44
|
+
URL finishes, so parallel requests can produce output in completion order
|
|
45
|
+
rather than input order.
|
|
46
|
+
Warnings are written to stderr.
|
|
47
|
+
|
|
48
|
+
With `--json`, stdout contains one compact JSON object per requested URL,
|
|
49
|
+
also written as soon as the URL finishes. Each line has this shape:
|
|
50
|
+
|
|
51
|
+
```jsonl
|
|
52
|
+
{"requestedUrl":"https://example.com/start","sourceUrl":"https://example.com/article","path":"/data/mdhq/example.com/article.md","status":"saved","assets":[],"warnings":[]}
|
|
56
53
|
```
|
|
57
54
|
|
|
58
55
|
`status` is one of:
|
|
@@ -63,7 +60,8 @@ With `--json`, stdout contains an object with this shape:
|
|
|
63
60
|
body, including an HTTP 304 response.
|
|
64
61
|
- `skipped`: an existing same-identity file was kept.
|
|
65
62
|
|
|
66
|
-
An error is written to stderr and causes exit status `1`.
|
|
63
|
+
An error is written to stderr and causes exit status `1`. Results written to
|
|
64
|
+
stdout before the error remain available to downstream consumers.
|
|
67
65
|
|
|
68
66
|
### `list`
|
|
69
67
|
|
|
@@ -93,22 +91,31 @@ create it.
|
|
|
93
91
|
|
|
94
92
|
1. Validate that the requested URL uses HTTP or HTTPS.
|
|
95
93
|
2. Load the JSON configuration and collect unknown-key warnings.
|
|
96
|
-
3. Resolve the storage root and host/path-specific entry query key.
|
|
97
|
-
4.
|
|
94
|
+
3. Resolve the storage root and a pre-fetch host/path-specific entry query key.
|
|
95
|
+
4. Remove known tracking parameters from the requested URL candidate and check
|
|
96
|
+
whether it already has a same-identity destination.
|
|
98
97
|
5. Fetch the page when it cannot be skipped before network access.
|
|
99
|
-
6. Resolve the final URL after redirects
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
8.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
98
|
+
6. Resolve the final response URL after redirects.
|
|
99
|
+
7. Derive the normalized source URL from an accepted HTML canonical URL or
|
|
100
|
+
`urlpurify` tracking-parameter removal.
|
|
101
|
+
8. Recalculate configuration and destination from the normalized source and
|
|
102
|
+
check the final destination for another same-identity skip.
|
|
103
|
+
9. Convert the fetched HTML to Markdown with Defuddle using the final response
|
|
104
|
+
URL as its base URL.
|
|
105
|
+
10. Normalize ordinary links and discover image links using the final response
|
|
106
|
+
URL.
|
|
107
|
+
11. Download supported images and rewrite successful image destinations.
|
|
108
|
+
12. Normalize the Markdown body and calculate its SHA-256 content digest.
|
|
109
|
+
13. Build YAML frontmatter.
|
|
110
|
+
14. Save the Markdown with collision-safe create or update behavior.
|
|
111
|
+
|
|
112
|
+
The normalized source determines the destination and the `source` frontmatter
|
|
113
|
+
field. The fragment-free WHATWG-serialized original URL is retained as
|
|
114
|
+
`requested_url` only when it differs from the normalized source. Intermediate
|
|
115
|
+
redirect URLs are not stored.
|
|
116
|
+
|
|
117
|
+
The pre-fetch skip is intentionally retained. When it succeeds, mdhq performs
|
|
118
|
+
no request and cannot discover later redirect or canonical changes.
|
|
112
119
|
|
|
113
120
|
## HTTP behavior
|
|
114
121
|
|
|
@@ -136,8 +143,8 @@ With `update`, mdhq uses validators from a recognized existing destination:
|
|
|
136
143
|
5. Otherwise perform an ordinary GET.
|
|
137
144
|
|
|
138
145
|
Storage identity is intentionally broader than HTTP validator scope. A
|
|
139
|
-
same-identity URL with a different scheme,
|
|
140
|
-
without stored validators.
|
|
146
|
+
same-identity URL with a different scheme, ignored secondary query parameter
|
|
147
|
+
under an entry key, or path alias is fetched without stored validators.
|
|
141
148
|
|
|
142
149
|
Automatic validators are sent only on the first request and are not forwarded
|
|
143
150
|
after redirects. When a stored validator is available, it replaces any
|
|
@@ -271,12 +278,14 @@ Identity includes:
|
|
|
271
278
|
- normalized host
|
|
272
279
|
- canonical pathname
|
|
273
280
|
- one configured entry query key and value, when present and non-empty
|
|
281
|
+
- otherwise, the MD5 digest of the ordered query tail when a query remains
|
|
274
282
|
|
|
275
283
|
Identity ignores:
|
|
276
284
|
|
|
277
285
|
- the difference between HTTP and HTTPS
|
|
278
286
|
- fragments
|
|
279
|
-
-
|
|
287
|
+
- query parameters other than a selected non-empty entry key when such a key
|
|
288
|
+
is used
|
|
280
289
|
|
|
281
290
|
Host normalization:
|
|
282
291
|
|
|
@@ -298,8 +307,42 @@ Path normalization:
|
|
|
298
307
|
- removes a final recognized HTML extension for identity comparison
|
|
299
308
|
- applies canonical percent encoding for identity comparison
|
|
300
309
|
|
|
301
|
-
The first value returned for the configured entry query key is used.
|
|
302
|
-
|
|
310
|
+
The first non-empty value returned for the configured entry query key is used.
|
|
311
|
+
When no non-empty value exists, a remaining query uses the generic query-tail
|
|
312
|
+
MD5 identity.
|
|
313
|
+
|
|
314
|
+
## Source URL normalization
|
|
315
|
+
|
|
316
|
+
After redirects, mdhq examines `link` elements whose whitespace-separated
|
|
317
|
+
`rel` tokens contain `canonical`, case-insensitively.
|
|
318
|
+
|
|
319
|
+
- Exactly one canonical link must be present.
|
|
320
|
+
- A relative canonical is resolved against the document base URL. A valid
|
|
321
|
+
`<base href>` is honored; an invalid base falls back to the final response
|
|
322
|
+
URL.
|
|
323
|
+
- The resolved canonical must use HTTP or HTTPS.
|
|
324
|
+
- Canonical URLs containing a username or password are rejected.
|
|
325
|
+
- Its normalized origin and canonical pathname must equal those of the final
|
|
326
|
+
response URL. Scheme, normalized host, port, and pathname aliases therefore
|
|
327
|
+
cannot point to another content location.
|
|
328
|
+
- An accepted canonical is WHATWG-serialized and has its fragment removed. It
|
|
329
|
+
is not passed through `urlpurify`.
|
|
330
|
+
|
|
331
|
+
When canonical is absent or rejected, mdhq applies
|
|
332
|
+
`urlpurify.stripTrackingParams()` to the final response URL. URL wrapper
|
|
333
|
+
unwrapping is not used. The result is validated as HTTP(S), WHATWG-serialized,
|
|
334
|
+
and stripped of its fragment. Functional WordPress preview parameters
|
|
335
|
+
(`preview`, `preview_id`, and `preview_nonce`) are retained because mdhq
|
|
336
|
+
accepts arbitrary page URLs rather than feed URLs only.
|
|
337
|
+
|
|
338
|
+
The normalized source is used for host/path configuration, storage identity,
|
|
339
|
+
destination, and frontmatter. The final response URL is used as the base for
|
|
340
|
+
Defuddle conversion, ordinary links, images, and assets, as well as for HTTP
|
|
341
|
+
redirect and credential handling. This allows storage-equivalent canonical
|
|
342
|
+
aliases such as `/article` and `/article/` without changing relative URL
|
|
343
|
+
targets.
|
|
344
|
+
|
|
345
|
+
Re-fetching a path-divergent canonical target is not currently supported.
|
|
303
346
|
|
|
304
347
|
## Storage paths
|
|
305
348
|
|
|
@@ -347,13 +390,48 @@ https://example.com/blog/blog.php?entry_id=123
|
|
|
347
390
|
-> example.com/blog/blog.php/123.md
|
|
348
391
|
```
|
|
349
392
|
|
|
350
|
-
Other query parameters and the fragment do not affect the destination
|
|
393
|
+
Other query parameters and the fragment do not affect the destination when a
|
|
394
|
+
non-empty entry value is selected.
|
|
395
|
+
|
|
396
|
+
Entry values are inspected in URL order and the first non-empty value is used,
|
|
397
|
+
so percent escapes are decoded and `+` is interpreted as a space. The
|
|
398
|
+
resulting value then uses the same NFC normalization, unsafe-character
|
|
399
|
+
encoding, reserved-name handling, 240-byte limit, and MD5 fallback as a URL
|
|
400
|
+
path segment. A slash in the query value becomes `%2F` inside the filename and
|
|
401
|
+
never creates another directory.
|
|
402
|
+
|
|
403
|
+
### Generic query destinations
|
|
404
|
+
|
|
405
|
+
When a normalized source retains a query and has no usable configured entry
|
|
406
|
+
value, mdhq creates a lowercase 32-character MD5 digest from UTF-8 bytes. Query
|
|
407
|
+
parameter order is preserved.
|
|
408
|
+
|
|
409
|
+
For a pathname not ending in `/`, the digest input is the final raw pathname
|
|
410
|
+
segment followed by the serialized query including `?`:
|
|
411
|
+
|
|
412
|
+
```text
|
|
413
|
+
https://example.com/path/to.php?id=123
|
|
414
|
+
MD5 input: to.php?id=123
|
|
415
|
+
-> example.com/path/<md5>.md
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
For a pathname ending in `/`, the digest input is only the serialized query:
|
|
419
|
+
|
|
420
|
+
```text
|
|
421
|
+
https://example.com/path/to/?id=123
|
|
422
|
+
MD5 input: ?id=123
|
|
423
|
+
-> example.com/path/to/<md5>.md
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The final directory segment uses the same recognized HTML-extension
|
|
427
|
+
normalization as queryless storage, so `/to/?id=123` and
|
|
428
|
+
`/to.html/?id=123` resolve to the same destination.
|
|
429
|
+
|
|
430
|
+
Root queries use `index` as their directory, so `/?id=123` and
|
|
431
|
+
`/index.html/?id=123` both resolve to `index/<md5>.md`.
|
|
351
432
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
same NFC normalization, unsafe-character encoding, reserved-name handling,
|
|
355
|
-
240-byte limit, and MD5 fallback as a URL path segment. A slash in the query
|
|
356
|
-
value becomes `%2F` inside the filename and never creates another directory.
|
|
433
|
+
If canonical selection or tracking cleanup removes the complete query, the
|
|
434
|
+
normal queryless destination rules apply.
|
|
357
435
|
|
|
358
436
|
### Safe path segments
|
|
359
437
|
|
|
@@ -410,14 +488,14 @@ Defuddle's exact whitespace.
|
|
|
410
488
|
|
|
411
489
|
For ordinary links:
|
|
412
490
|
|
|
413
|
-
- relative URLs are resolved against the final
|
|
491
|
+
- relative URLs are resolved against the final response URL
|
|
414
492
|
- absolute URLs are retained
|
|
415
493
|
- fragment-only links are retained
|
|
416
494
|
- reference-style link definitions are resolved and normalized
|
|
417
495
|
|
|
418
496
|
For images:
|
|
419
497
|
|
|
420
|
-
- relative destinations are resolved against the final
|
|
498
|
+
- relative destinations are resolved against the final response URL
|
|
421
499
|
- reference-style image definitions are resolved and localized
|
|
422
500
|
- absolute image URLs are collected for asset localization
|
|
423
501
|
- duplicate source URLs are fetched once
|
|
@@ -552,8 +630,9 @@ Metadata fields are emitted when non-empty:
|
|
|
552
630
|
|
|
553
631
|
mdhq-controlled fields:
|
|
554
632
|
|
|
555
|
-
- `source`:
|
|
556
|
-
- `requested_url`: original URL, only when different
|
|
633
|
+
- `source`: normalized source URL
|
|
634
|
+
- `requested_url`: fragment-free serialized original URL, only when different
|
|
635
|
+
from `source`
|
|
557
636
|
- `created`: initial local acquisition time
|
|
558
637
|
- `modified`: time of the latest meaningful Markdown note change
|
|
559
638
|
- `etag`: HTTP ETag stored verbatim, when supplied
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@songmu/mdhq",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Save web pages as Markdown in a ghq-inspired filesystem layout.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"temml": "^0.13.5",
|
|
62
62
|
"turndown": "^7.2.0",
|
|
63
63
|
"undici": "^8.10.0",
|
|
64
|
+
"urlpurify": "1.4.0",
|
|
64
65
|
"yaml": "^2.8.1",
|
|
65
66
|
"zod": "^4.5.2"
|
|
66
67
|
},
|
|
@@ -68,6 +69,6 @@
|
|
|
68
69
|
"@types/node": "^26.4.0",
|
|
69
70
|
"@types/proper-lockfile": "4.1.4",
|
|
70
71
|
"typescript": "^7.0.2",
|
|
71
|
-
"vitest": "^
|
|
72
|
+
"vitest": "^5.0.0"
|
|
72
73
|
}
|
|
73
74
|
}
|