@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 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` prints the absolute Markdown path to stdout by default. Warnings
30
- are written to stderr. `--json` returns the requested URL, final source URL,
31
- Markdown path, status, downloaded assets, and warnings.
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 one web page.")
44
- .argument("<url>")
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 a structured result"))
51
- .action(async (url, options) => {
52
- const result = await getPage({
53
- url,
54
- ...(options.root ? { root: options.root } : {}),
55
- ...(options.assets === false ? { assets: false } : {}),
56
- update: options.update ?? false,
57
- ...(options.userAgent ? { userAgent: options.userAgent } : {}),
58
- headers: parseHeaders(options.header),
59
- onWarning: (warning) => io.stderr.write(`warning: ${warning.message}\n`)
60
- });
61
- io.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
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 = formatLocalRfc3339(options.modified);
25
- fields.content_digest = options.contentDigest;
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
- if (options.vary) {
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
- ["site", options.metadata.site],
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, parseHttpUrl, sameHttpTarget } from "./url/identity.js";
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 = parseHttpUrl(options.url).href;
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 requested = new URL(fetchUrl);
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, fetchUrl, requestedEntryKey, root);
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 === "updated" ? "unchanged" : storageStatus,
192
+ status: storageStatus === "saved" ? "saved" : "unchanged",
163
193
  assets: [],
164
194
  warnings
165
195
  };
166
196
  }
167
- const finalUrl = new URL(fetched.finalUrl);
168
- const matchedConfig = resolveHostConfig(normalizeHost(finalUrl), finalUrl.pathname, loaded.config.hosts ?? {});
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: finalUrl,
204
+ url: normalizedSource,
173
205
  ...(entryQueryKey ? { entryQueryKey } : {})
174
206
  });
175
- const existing = await inspectDestination(markdownPath, finalUrl.href, entryQueryKey, root);
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, finalUrl.href, retriesRemaining - 1, responseHeadersAllowed);
214
+ return getPageAttempt(context, sourceUrl, retriesRemaining - 1, responseHeadersAllowed);
183
215
  }
184
216
  if (existing && !options.update) {
185
217
  return {
186
218
  requestedUrl,
187
- sourceUrl: finalUrl.href,
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: finalUrl,
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, finalUrl);
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: finalUrl.href
258
+ url: finalResponseUrl.href
227
259
  });
228
260
  }
229
261
  }
230
- const transformed = transformMarkdown(converted.markdown, finalUrl.href);
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: finalUrl.href,
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 && !responseCredentialed;
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 frontmatter = buildFrontmatter({
291
+ const nextFrontmatterOptions = {
258
292
  metadata,
259
- sourceUrl: finalUrl.href,
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: finalUrl.href,
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: finalUrl.href,
331
+ sourceUrl,
300
332
  path: markdownPath,
301
- status: storageStatus === "updated" &&
302
- expectedExisting?.contentDigest === contentDigest
333
+ status: expectedExisting &&
334
+ (storageStatus === "updated" || storageStatus === "skipped") &&
335
+ !noteChanged
303
336
  ? "unchanged"
304
337
  : storageStatus,
305
338
  assets: localized.assets,
@@ -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;
@@ -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
- response = (await undiciFetch(url, {
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 directories = rawSegments.slice(0, -1).map((segment) => fitSegment(segment));
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
  }
@@ -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>;
@@ -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;
@@ -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;
@@ -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 ? url.searchParams.get(entryQueryKey) : null;
27
- const hasEntryValue = entryValue !== null && entryValue !== "";
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
- ? `?${encodeURIComponent(identity.entryKey)}=${encodeURIComponent(identity.entryValue)}`
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,3 @@
1
+ export declare function normalizeRequestedUrl(input: string | URL): string;
2
+ export declare function normalizeSourceUrlWithoutCanonical(input: string | URL): string;
3
+ export declare function normalizeSourceUrl(html: string, finalUrlInput: string | URL): string;
@@ -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
+ }
@@ -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
- - A missing or empty parameter value falls back to normal path-based storage.
242
- - All non-selected query parameters are ignored.
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.
@@ -159,14 +159,16 @@ interface GetPageResult {
159
159
  }
160
160
  ```
161
161
 
162
- - `requestedUrl` is the input after WHATWG URL serialization. This lowercases
163
- and IDNA-normalizes the host, removes an explicit default port, and retains
164
- the query and fragment. This serialization is separate from URL identity,
165
- which ignores the scheme, fragment, and unselected query parameters.
166
- - `sourceUrl` is the WHATWG-serialized final URL maintained by the redirect
167
- loop. Its query and fragment are whatever remain on that final URL after
168
- URL resolution. It is exactly the value written to frontmatter `source`.
169
- For a pre-fetch skip, it is the existing document's stored `source` value.
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
@@ -21,13 +21,13 @@ Runtime requirements:
21
21
  The executable provides three subcommands:
22
22
 
23
23
  ```text
24
- mdhq get [options] <url>
24
+ mdhq get [options] [urls...]
25
25
  mdhq list [options]
26
26
  mdhq root [options]
27
27
  ```
28
28
 
29
- `mdhq get` accepts exactly one URL per invocation. Parallel or multi-URL
30
- processing is delegated to external tools such as `xargs`.
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 a structured result instead of only the Markdown path. |
41
-
42
- On success without `--json`, stdout contains exactly one absolute Markdown
43
- path followed by a newline. Warnings are written to stderr.
44
-
45
- With `--json`, stdout contains an object with this shape:
46
-
47
- ```json
48
- {
49
- "requestedUrl": "https://example.com/start",
50
- "sourceUrl": "https://example.com/article",
51
- "path": "/data/mdhq/example.com/article.md",
52
- "status": "saved",
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. Check whether the requested URL already has a same-identity destination.
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 and recalculate configuration and
100
- destination from that URL.
101
- 7. Check the final destination for another same-identity skip.
102
- 8. Convert the fetched HTML to Markdown with Defuddle.
103
- 9. Normalize ordinary links and discover image links.
104
- 10. Download supported images and rewrite successful image destinations.
105
- 11. Normalize the Markdown body and calculate its SHA-256 content digest.
106
- 12. Build YAML frontmatter.
107
- 13. Save the Markdown with collision-safe create or update behavior.
108
-
109
- The final URL determines the destination and the `source` frontmatter field.
110
- The original URL is retained as `requested_url` only when it differs from the
111
- final URL.
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, query, or path alias is fetched
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
- - all query parameters except the selected entry query key
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. A missing
302
- or empty value causes the URL to use its normal path-based identity.
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
- The query value comes from `URLSearchParams.get`, so percent escapes are
353
- decoded and `+` is interpreted as a space. The resulting value then uses the
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 page URL
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 page URL
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`: final page URL
556
- - `requested_url`: original URL, only when different from `source`
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.2",
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": "^4.1.11"
72
+ "vitest": "^5.0.0"
72
73
  }
73
74
  }