diff --git a/html/changes.html b/html/changes.html new file mode 100644 index 00000000..24ede946 --- /dev/null +++ b/html/changes.html @@ -0,0 +1,263 @@ + + + + + + + HTTrack Website Copier - Change report format specification + + + + + + + + + +
HTTrack Website Copier
+ + + + +
Open Source offline browser
+ + + + +
+ + + + +
+ + + + +
+ + +

Change report format specification

+ +
+ +Run with --changes (-%d), HTTrack writes hts-changes.json +in the project directory, next to hts-log.txt, describing what the crawl +left new, changed, unchanged and gone compared to the previous mirror. The file is +rewritten from scratch at the end of every run, and the log carries a one-line +summary of the same counts. + +

+ +

What "changed" means

+ +A resource is changed when its bytes differ, not when the server merely re-sent +it. HTTrack compares the payload it just received against the copy the previous +run left behind: for pages it parses, the previous payload comes from the cache +(the file on disk carries the mirror footer and its crawl date, so its bytes +differ on every run); for everything else, the mirrored file is the payload +verbatim and is compared directly. + +

+ +Where no digest can be taken on either side, because the cache is disabled or +the previous copy is gone, the report falls back to the transfer signal, and a +server that answers 200 rather than 304 reads as changed. Keeping the cache on +(the default) is what makes the report precise. + +

+ +

With the cache off

+ +--cache=0 costs the report more than the digest of a parsed page. The +mirror's file index (hts-cache/new.lst) is what records which files a +run produced, so without it there is no previous mirror to subtract from: nothing +is reported gone, and whether the run is a first crawl cannot be decided +at all, which first_crawl states as null rather than guess. What +is on disk is still compared byte for byte, so the other three lists stay +meaningful, except for the pages HTTrack parses: those have no cached payload to +compare against and fall back to the transfer signal. + +

+ +

Fields

+ +
    +
  • schema: format version, currently 1. It is bumped only +on an incompatible change; new fields may appear without one.
  • +
  • generator: the HTTrack build that wrote the file.
  • +
  • date: when the report was written, UTC, YYYY-MM-DDThh:mm:ssZ.
  • +
  • first_crawl: true when no index of a previous mirror +(hts-cache/old.lst) was found, so there was nothing to compare against and +everything is listed as new. Null when the run kept no index at all and the +question cannot be answered (see above).
  • +
  • partial: true when the report ran out of memory and lists only +part of the mirror.
  • +
  • purged: true when --purge-old was in effect, so the +files under gone were also deleted from disk.
  • +
  • counts: the size of each of the four lists.
  • +
  • new, changed, unchanged, gone: the +lists themselves. Every mirrored file appears in exactly one of them.
  • +
+ +Each entry is an object: + +
    +
  • url: the absolute URL the file came from. Empty under +gone: deletions are computed from the mirror's file index, which records +paths, not URLs.
  • +
  • file: the path relative to the mirror root, with forward +slashes. This is the entry's identity: a URL and a redirect that resolve to the +same local file are one entry, not two.
  • +
  • size: the mirrored file's size in bytes, absent when the file +is not on disk.
  • +
  • previous_size: under changed only, the size of the +copy the previous run left.
  • +
+ +
+ +

Encoding

+ +The file is JSON, UTF-8. URLs and local paths reach HTTrack as raw bytes and are +not guaranteed to be valid UTF-8; any byte sequence that is not becomes +U+FFFD (\ufffd), so the file always parses. Compare on file +rather than on url when a mirror is known to carry legacy-charset URLs. + +

+ +

Example

+ +
+{
+  "schema": 1,
+  "generator": "HTTrack Website Copier/3.49-14",
+  "date": "2026-07-26T15:29:03Z",
+  "first_crawl": false,
+  "partial": false,
+  "purged": true,
+  "counts": { "new": 1, "changed": 1, "unchanged": 1, "gone": 1 },
+  "new": [
+    { "url": "http://example.com/d.html", "file": "example.com/d.html", "size": 280 }
+  ],
+  "changed": [
+    { "url": "http://example.com/a.html", "file": "example.com/a.html", "size": 281, "previous_size": 273 }
+  ],
+  "unchanged": [
+    { "url": "http://example.com/b.html", "file": "example.com/b.html", "size": 277 }
+  ],
+  "gone": [
+    { "url": "", "file": "example.com/c.html" }
+  ]
+}
+
+ +

+ +

Notes

+ +
    +
  • A file listed under gone is only deleted when --purge-old is +on. Left in place it drops out of the mirror's index, so it is reported once and +not again.
  • +
  • A resource whose local file name changed since the previous mirror (a new +MIME type, say) is reported as new under its new name; the old name is +reported as gone only if the file is still on disk. The two entries are +not paired.
  • +
  • A resource this run tried and failed to transfer also drops out of the +mirror's index, but its previous copy is untouched, so it is reported +unchanged. Under --purge-old that copy is deleted anyway, and +the report says gone to match.
  • +
  • A run that transfers no data at all is rolled back: HTTrack restores the +previous cache generation and leaves the previous report in place, so a lost +connection does not overwrite a good report with an empty one.
  • +
  • Content diffs, and keeping the previous copy of a changed page, are out of +scope: both change what a mirror directory contains.
  • +
+ +

+ + +
+
+
+ + + + + +
+ + + + + + diff --git a/html/cmdguide.html b/html/cmdguide.html index 0fe2b02d..c34b9d13 100644 --- a/html/cmdguide.html +++ b/html/cmdguide.html @@ -482,6 +482,15 @@

Save a WARC archive of the crawl

archive, index and pages into one WACZ for replay tools such as replayweb.page.

+

See what a re-crawl changed

+

httrack https://example.com/ --update --changes --path mydir
+Writes hts-changes.json in the project folder, listing every +mirrored file as new, changed, unchanged or gone, plus a one-line summary in the +log. "Changed" means the bytes really differ: a server that answers 200 +with the same content it served last time lands in unchanged. Deletions +are reported whether or not --purge-old is deleting them. The format is +documented in the change report specification.

+

HTTrack as a fetch tool

httrack --get https://host/file.bin --path tmp
--get fetches one file with cache, index, depth, cookies and robots @@ -490,7 +499,8 @@

HTTrack as a fetch tool


For the complete, always-current option list, see the manual page. For the filter language, see filters; for the cache and updates, see -cache.

+cache; for the change report, see +changes.

diff --git a/html/dev.html b/html/dev.html index 877d670a..2ca5e13d 100644 --- a/html/dev.html +++ b/html/dev.html @@ -129,6 +129,9 @@

Technical references

  • Cache format

  • HTTrack stores original HTML data and references to downloaded files in a cache, located in the hts-cache directory. This page describes the HTTrack cache format. +
  • Change report format

  • +With --changes, HTTrack writes hts-changes.json describing what the crawl left new, changed, unchanged and gone +compared to the previous mirror. This page describes that file. diff --git a/html/httrack.man.html b/html/httrack.man.html index 4e82953d..2de27131 100644 --- a/html/httrack.man.html +++ b/html/httrack.man.html @@ -108,16 +108,17 @@

    SYNOPSIS --footer ] [ -%l, --language ] [ -%a, --accept ] [ -%X, --headers ] [ -C, --cache[=N] ] [ -k, --store-all-in-cache ] [ --%r, --warc ] [ -%n, --do-not-recatch ] [ --%v, --display ] [ -Q, --do-not-log ] [ -q, ---quiet ] [ -z, --extra-log ] [ -Z, ---debug-log ] [ -v, --verbose ] [ -f, ---file-log ] [ -f2, --single-log ] [ -I, ---index ] [ -%i, --build-top-index ] [ -%I, ---search-index ] [ -pN, --priority[=N] ] [ -S, ---stay-on-same-dir ] [ -D, --can-go-down ] [ --U, --can-go-up ] [ -B, --can-go-up-and-down ] -[ -a, --stay-on-same-address ] [ -d, +-%r, --warc ] [ -%d, --changes ] [ -%n, +--do-not-recatch ] [ -%v, --display ] [ -Q, +--do-not-log ] [ -q, --quiet ] [ -z, +--extra-log ] [ -Z, --debug-log ] [ -v, +--verbose ] [ -f, --file-log ] [ -f2, +--single-log ] [ -I, --index ] [ -%i, +--build-top-index ] [ -%I, --search-index ] [ +-pN, --priority[=N] ] [ -S, --stay-on-same-dir +] [ -D, --can-go-down ] [ -U, --can-go-up ] [ +-B, --can-go-up-and-down ] [ -a, +--stay-on-same-address ] [ -d, --stay-on-same-domain ] [ -l, --stay-on-same-tld ] [ -e, --go-everywhere ] [ -%H, --debug-headers ] [ -%!, @@ -1143,6 +1144,19 @@

    Log, index, cache +

    -%d

    + + + + +

    write hts-changes.json listing what this crawl left new, +changed, unchanged and gone compared to the previous mirror +(--changes)

    + + + + +

    -%n

    diff --git a/html/server/option9.html b/html/server/option9.html index 6c3a3ef7..93f90232 100644 --- a/html/server/option9.html +++ b/html/server/option9.html @@ -100,6 +100,7 @@

    ${LANG_O2} - ${LANG_IOPT9}

    + @@ -121,6 +122,11 @@

    ${LANG_O2} - ${LANG_IOPT9}

    >

    + ${LANG_CHANGES} +

    + ${LANG_I34b} diff --git a/html/server/step2.html b/html/server/step2.html index a01a1876..ed01c616 100644 --- a/html/server/step2.html +++ b/html/server/step2.html @@ -143,6 +143,7 @@

    ${fexist:index.html:LANG_G42}

    ${do:copy:StoreAllInCache:cache2} ${do:copy:Warc:warc} ${do:copy:WarcFile:warcfile} +${do:copy:Changes:changes} ${do:copy:LogType:logtype} ${do:copy:UseHTTPProxyForFTP:ftpprox} ${do:copy:ProxyType:proxytype} diff --git a/html/server/step4.html b/html/server/step4.html index 6f021d5c..053fe59e 100644 --- a/html/server/step4.html +++ b/html/server/step4.html @@ -190,6 +190,7 @@

    ${LANG_J9}

    ${test:cache2:--store-all-in-cache} ${test:warc:--warc} ${test:warcfile:--warc-file "}${arg:warcfile}${test:warcfile:"} + ${test:changes:--changes} ${test:norecatch:--do-not-recatch} ${test:logf:--single-log} ${test:logtype:::--extra-log:--debug-log} @@ -242,6 +243,7 @@

    ${LANG_J9}

    StoreAllInCache=${ztest:cache2:0:1} Warc=${ztest:warc:0:1} WarcFile=${warcfile} +Changes=${ztest:changes:0:1} LogType=${logtype} UseHTTPProxyForFTP=${ztest:ftpprox:0:1} ProxyType=${proxytype} diff --git a/lang.def b/lang.def index f11c1e27..169594a7 100755 --- a/lang.def +++ b/lang.def @@ -1042,3 +1042,7 @@ LANG_WARCFILE WARC archive name: LANG_WARCFILETIP Optional base name for the WARC archive; leave blank to auto-name it under the output directory. +LANG_CHANGES +Report what changed since the previous mirror +LANG_CHANGESTIP +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. diff --git a/lang/Bulgarian.txt b/lang/Bulgarian.txt index e5cde4af..9080c79f 100644 --- a/lang/Bulgarian.txt +++ b/lang/Bulgarian.txt @@ -964,3 +964,7 @@ WARC archive name: WARC : Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC ; . +Report what changed since the previous mirror + +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. + hts-changes.json , , . diff --git a/lang/Castellano.txt b/lang/Castellano.txt index d394362f..9b6cb9eb 100644 --- a/lang/Castellano.txt +++ b/lang/Castellano.txt @@ -964,3 +964,7 @@ WARC archive name: Nombre del archivo WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nombre base opcional para el archivo WARC; djelo en blanco para nombrarlo automticamente en el directorio de salida. +Report what changed since the previous mirror +Informar de los cambios desde la copia anterior +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Escribir tambin hts-changes.json con la lista de lo que esta captura deja como nuevo, modificado, sin cambios o desaparecido respecto a la copia anterior. diff --git a/lang/Cesky.txt b/lang/Cesky.txt index 4f177a44..b785d53c 100644 --- a/lang/Cesky.txt +++ b/lang/Cesky.txt @@ -964,3 +964,7 @@ WARC archive name: Nzev archivu WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Voliteln zkladn nzev archivu WARC; ponechte przdn pro automatick pojmenovn ve vstupnm adresi. +Report what changed since the previous mirror +Hlsit, co se od pedchozho zrcadlen zmnilo +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zapsat tak hts-changes.json se seznamem toho, co je oproti pedchozmu zrcadlen nov, zmnn, nezmnn nebo chybjc. diff --git a/lang/Chinese-BIG5.txt b/lang/Chinese-BIG5.txt index 9d4e9333..17016e62 100644 --- a/lang/Chinese-BIG5.txt +++ b/lang/Chinese-BIG5.txt @@ -964,3 +964,7 @@ WARC archive name: WARC ʦsɦW١G Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC ʦsɪΰ򥻦W١FdūhXؿ۰ʩRWC +Report what changed since the previous mirror +^ۤWgHӪܧ +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +PɼgJ hts-changes.jsonACXo^۹WgsWBܧBܧPءC diff --git a/lang/Chinese-Simplified.txt b/lang/Chinese-Simplified.txt index f7a8f8f7..011c66b0 100644 --- a/lang/Chinese-Simplified.txt +++ b/lang/Chinese-Simplified.txt @@ -964,3 +964,7 @@ WARC archive name: WARC 鵵ƣ Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC 鵵ĿѡƣĿ¼Զ +Report what changed since the previous mirror +ϴξı +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +ͬʱд hts-changes.jsonгץȡϴξġδĺʧĿ diff --git a/lang/Croatian.txt b/lang/Croatian.txt index 9e5e321c..2902aa6e 100644 --- a/lang/Croatian.txt +++ b/lang/Croatian.txt @@ -966,3 +966,7 @@ WARC archive name: Naziv WARC arhive: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Neobavezni osnovni naziv WARC arhive; ostavite prazno za automatsko imenovanje u izlaznom direktoriju. +Report what changed since the previous mirror +Prijavi to se promijenilo od prethodnog zrcaljenja +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zapii i hts-changes.json s popisom onoga to je u odnosu na prethodno zrcaljenje novo, promijenjeno, nepromijenjeno ili nestalo. diff --git a/lang/Dansk.txt b/lang/Dansk.txt index f45f59c4..15d86096 100644 --- a/lang/Dansk.txt +++ b/lang/Dansk.txt @@ -1012,3 +1012,7 @@ WARC archive name: Navn p WARC-arkiv: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Valgfrit basisnavn til WARC-arkivet; lad feltet st tomt for automatisk navngivning i outputmappen. +Report what changed since the previous mirror +Rapportr hvad der er ndret siden den forrige spejling +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Skriv ogs hts-changes.json med en liste over, hvad denne gennemgang efterlader som nyt, ndret, undret eller forsvundet i forhold til den forrige spejling. diff --git a/lang/Deutsch.txt b/lang/Deutsch.txt index b191894a..de8533e4 100644 --- a/lang/Deutsch.txt +++ b/lang/Deutsch.txt @@ -964,3 +964,7 @@ WARC archive name: Name des WARC-Archivs: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Optionaler Basisname fr das WARC-Archiv; leer lassen, um es automatisch im Ausgabeverzeichnis zu benennen. +Report what changed since the previous mirror +Melden, was sich seit der vorherigen Spiegelung gendert hat +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zustzlich hts-changes.json schreiben, das auflistet, was dieser Durchlauf gegenber der vorherigen Spiegelung als neu, gendert, unverndert oder verschwunden hinterlsst. diff --git a/lang/Eesti.txt b/lang/Eesti.txt index 38c0b144..2baa553e 100644 --- a/lang/Eesti.txt +++ b/lang/Eesti.txt @@ -964,3 +964,7 @@ WARC archive name: WARC-arhiivi nimi: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC-arhiivi valikuline phinimi; jta thjaks, et see vljundkataloogis automaatselt nimetada. +Report what changed since the previous mirror +Teata, mis on eelmisest peegeldusest saadik muutunud +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Kirjuta ka hts-changes.json, mis loetleb, mis on vrreldes eelmise peegeldusega uus, muutunud, muutumatu vi kadunud. diff --git a/lang/English.txt b/lang/English.txt index 86c402be..797cbcb4 100644 --- a/lang/English.txt +++ b/lang/English.txt @@ -1012,3 +1012,7 @@ WARC archive name: WARC archive name: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Optional base name for the WARC archive; leave blank to auto-name it under the output directory. +Report what changed since the previous mirror +Report what changed since the previous mirror +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. diff --git a/lang/Finnish.txt b/lang/Finnish.txt index 6c22996a..d86d1bd1 100644 --- a/lang/Finnish.txt +++ b/lang/Finnish.txt @@ -966,3 +966,7 @@ WARC archive name: WARC-arkiston nimi: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Valinnainen WARC-arkiston perusnimi; jt tyhjksi, jotta se nimetn automaattisesti tulostehakemistoon. +Report what changed since the previous mirror +Raportoi, mik on muuttunut edellisen peilauksen jlkeen +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Kirjoita mys hts-changes.json, joka luettelee, mik on edelliseen peilaukseen verrattuna uutta, muuttunutta, muuttumatonta tai kadonnutta. diff --git a/lang/Francais.txt b/lang/Francais.txt index 74131f84..1a1e4a0e 100644 --- a/lang/Francais.txt +++ b/lang/Francais.txt @@ -1012,3 +1012,7 @@ WARC archive name: Nom de l'archive WARC : Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nom de base optionnel pour l'archive WARC ; laissez vide pour le gnrer automatiquement dans le rpertoire de sortie. +Report what changed since the previous mirror +Signaler ce qui a chang depuis le miroir prcdent +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +crire aussi hts-changes.json, qui liste ce que ce crawl laisse nouveau, modifi, inchang ou disparu par rapport au miroir prcdent. diff --git a/lang/Greek.txt b/lang/Greek.txt index a38e4fc1..da30d6c0 100644 --- a/lang/Greek.txt +++ b/lang/Greek.txt @@ -966,3 +966,7 @@ WARC archive name: WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC. . +Report what changed since the previous mirror + +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. + hts-changes.json, , , . diff --git a/lang/Italiano.txt b/lang/Italiano.txt index 23b7438f..e495bcf1 100644 --- a/lang/Italiano.txt +++ b/lang/Italiano.txt @@ -964,3 +964,7 @@ WARC archive name: Nome dell'archivio WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nome di base facoltativo per l'archivio WARC; lascia vuoto per assegnarlo automaticamente nella directory di output. +Report what changed since the previous mirror +Segnala che cosa cambiato dalla copia precedente +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Scrive anche hts-changes.json, che elenca ci che questa scansione lascia come nuovo, modificato, invariato o scomparso rispetto alla copia precedente. diff --git a/lang/Japanese.txt b/lang/Japanese.txt index 329eb09c..621c354a 100644 --- a/lang/Japanese.txt +++ b/lang/Japanese.txt @@ -964,3 +964,7 @@ WARC archive name: WARC A[JCu: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC A[JCu̔Cӂ̃x[XB󗓂ɂƏo̓fBNgŎIɖOt܂B +Report what changed since the previous mirror +Õ~[̕ύX_񍐂 +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +hts-changes.json oAÕ~[ƔׂĐVKAύXAύXȂAłƂȂ̂ꗗɂ܂B diff --git a/lang/Macedonian.txt b/lang/Macedonian.txt index 45245f46..a1c72df8 100644 --- a/lang/Macedonian.txt +++ b/lang/Macedonian.txt @@ -964,3 +964,7 @@ WARC archive name: WARC : Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC ; . +Report what changed since the previous mirror + +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. + hts-changes.json , , . diff --git a/lang/Magyar.txt b/lang/Magyar.txt index 8087cc7a..31537eb5 100644 --- a/lang/Magyar.txt +++ b/lang/Magyar.txt @@ -964,3 +964,7 @@ WARC archive name: WARC archvum neve: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. A WARC archvum opcionlis alapneve; hagyja resen az automatikus elnevezshez a kimeneti knyvtrban. +Report what changed since the previous mirror +Jelents arrl, mi vltozott az elz tkrzs ta +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +A hts-changes.json fjl rsa is, amely felsorolja, mi j, mi vltozott, mi maradt vltozatlan s mi tnt el az elz tkrzshez kpest. diff --git a/lang/Nederlands.txt b/lang/Nederlands.txt index 749baf8b..ccc1bc18 100644 --- a/lang/Nederlands.txt +++ b/lang/Nederlands.txt @@ -964,3 +964,7 @@ WARC archive name: Naam van WARC-archief: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Optionele basisnaam voor het WARC-archief; laat leeg om het automatisch een naam te geven in de uitvoermap. +Report what changed since the previous mirror +Rapporteren wat er sinds de vorige spiegeling is gewijzigd +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Ook hts-changes.json schrijven met een overzicht van wat deze doorloop nieuw, gewijzigd, ongewijzigd of verdwenen laat ten opzichte van de vorige spiegeling. diff --git a/lang/Norsk.txt b/lang/Norsk.txt index 0338aeef..6762f51b 100644 --- a/lang/Norsk.txt +++ b/lang/Norsk.txt @@ -964,3 +964,7 @@ WARC archive name: Navn p WARC-arkiv: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Valgfritt basisnavn for WARC-arkivet; la feltet st tomt for automatisk navngivning i utdatamappen. +Report what changed since the previous mirror +Rapporter hva som er endret siden forrige speiling +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Skriv ogs hts-changes.json som lister opp hva denne gjennomgangen etterlater som nytt, endret, uendret eller forsvunnet sammenlignet med forrige speiling. diff --git a/lang/Polski.txt b/lang/Polski.txt index 72b751e8..bf774ff4 100644 --- a/lang/Polski.txt +++ b/lang/Polski.txt @@ -964,3 +964,7 @@ WARC archive name: Nazwa archiwum WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Opcjonalna nazwa bazowa archiwum WARC; pozostaw puste, aby nazwa je automatycznie w katalogu wyjciowym. +Report what changed since the previous mirror +Zgo, co zmienio si od poprzedniej kopii +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zapisz rwnie plik hts-changes.json z list tego, co w porwnaniu z poprzedni kopi jest nowe, zmienione, niezmienione lub usunite. diff --git a/lang/Portugues-Brasil.txt b/lang/Portugues-Brasil.txt index a95c30c4..76aac379 100644 --- a/lang/Portugues-Brasil.txt +++ b/lang/Portugues-Brasil.txt @@ -1012,3 +1012,7 @@ WARC archive name: Nome do arquivo WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nome base opcional para o arquivo WARC; deixe em branco para nome-lo automaticamente no diretrio de sada. +Report what changed since the previous mirror +Relatar o que mudou desde o espelhamento anterior +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Gravar tambm hts-changes.json listando o que esta captura deixa como novo, alterado, inalterado ou removido em relao ao espelhamento anterior. diff --git a/lang/Portugues.txt b/lang/Portugues.txt index f85c53b3..de865153 100644 --- a/lang/Portugues.txt +++ b/lang/Portugues.txt @@ -964,3 +964,7 @@ WARC archive name: Nome do arquivo WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nome base opcional para o arquivo WARC; deixe em branco para o nomear automaticamente no diretrio de sada. +Report what changed since the previous mirror +Comunicar o que mudou desde o espelho anterior +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Escrever tambm hts-changes.json, que lista o que esta recolha deixa como novo, alterado, inalterado ou desaparecido em relao ao espelho anterior. diff --git a/lang/Romanian.txt b/lang/Romanian.txt index 5d7a189c..e3fabac2 100644 --- a/lang/Romanian.txt +++ b/lang/Romanian.txt @@ -964,3 +964,7 @@ WARC archive name: Numele arhivei WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Nume de baza optional pentru arhiva WARC; lasati gol pentru a-l denumi automat in directorul de iesire. +Report what changed since the previous mirror +Raporteaza ce s-a schimbat fata de copia anterioara +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Scrie si hts-changes.json, care listeaza ce este nou, modificat, nemodificat sau disparut fata de copia anterioara. diff --git a/lang/Russian.txt b/lang/Russian.txt index 248fb8d2..9755a0f0 100644 --- a/lang/Russian.txt +++ b/lang/Russian.txt @@ -964,3 +964,7 @@ WARC archive name: WARC-: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC-; . +Report what changed since the previous mirror +, +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. + hts-changes.json , , , . diff --git a/lang/Slovak.txt b/lang/Slovak.txt index 7dc4111c..8eb4c4d4 100644 --- a/lang/Slovak.txt +++ b/lang/Slovak.txt @@ -964,3 +964,7 @@ WARC archive name: Nzov archvu WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Voliten zkladn nzov archvu WARC; ponechajte przdne pre automatick pomenovanie vo vstupnom adresri. +Report what changed since the previous mirror +Oznmi, o sa od predchdzajceho zrkadlenia zmenilo +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zapsa aj hts-changes.json so zoznamom toho, o je oproti predchdzajcemu zrkadleniu nov, zmenen, nezmenen alebo chbajce. diff --git a/lang/Slovenian.txt b/lang/Slovenian.txt index 51ee6862..bcf93310 100644 --- a/lang/Slovenian.txt +++ b/lang/Slovenian.txt @@ -964,3 +964,7 @@ WARC archive name: Ime arhiva WARC: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Neobvezno osnovno ime arhiva WARC; pustite prazno za samodejno poimenovanje v izhodni mapi. +Report what changed since the previous mirror +Porocaj, kaj se je spremenilo od prejsnjega zrcaljenja +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Zapisi tudi hts-changes.json s seznamom tega, kar je v primerjavi s prejsnjim zrcaljenjem novo, spremenjeno, nespremenjeno ali izginilo. diff --git a/lang/Svenska.txt b/lang/Svenska.txt index 3d1cfe57..8fb95060 100644 --- a/lang/Svenska.txt +++ b/lang/Svenska.txt @@ -964,3 +964,7 @@ WARC archive name: WARC-arkivets namn: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. Valfritt basnamn fr WARC-arkivet; lmna tomt fr att namnge det automatiskt i utdatakatalogen. +Report what changed since the previous mirror +Rapportera vad som ndrats sedan fregende spegling +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Skriv ven hts-changes.json som listar vad den hr genomgngen lmnar som nytt, ndrat, ofrndrat eller frsvunnet jmfrt med fregende spegling. diff --git a/lang/Turkish.txt b/lang/Turkish.txt index 9eb7f814..563db196 100644 --- a/lang/Turkish.txt +++ b/lang/Turkish.txt @@ -964,3 +964,7 @@ WARC archive name: WARC arivi ad: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC arivi iin istee bal temel ad; kt dizininde otomatik adlandrma iin bo brakn. +Report what changed since the previous mirror +nceki yansmadan bu yana deienleri bildir +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +Ayrca hts-changes.json yazarak bu taramann nceki yansmaya gre neyi yeni, deimi, deimemi veya kaybolmu braktn listele. diff --git a/lang/Ukrainian.txt b/lang/Ukrainian.txt index abb18176..31510d4c 100644 --- a/lang/Ukrainian.txt +++ b/lang/Ukrainian.txt @@ -964,3 +964,7 @@ WARC archive name: ' WARC-: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. ' WARC-; . +Report what changed since the previous mirror +, +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. + hts-changes.json , , , . diff --git a/lang/Uzbek.txt b/lang/Uzbek.txt index 6c35f804..56f987f4 100644 --- a/lang/Uzbek.txt +++ b/lang/Uzbek.txt @@ -964,3 +964,7 @@ WARC archive name: WARC arxivi nomi: Optional base name for the WARC archive; leave blank to auto-name it under the output directory. WARC arxivi uchun ixtiyoriy asosiy nom; chiqish katalogida avtomatik nomlash uchun bo'sh qoldiring. +Report what changed since the previous mirror +Oldingi nusxadan beri nima ozgarganini xabar qilish +Also write hts-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror. +hts-changes.json ham yoziladi: unda ushbu yigish oldingi nusxaga nisbatan nimani yangi, ozgargan, ozgarmagan yoki yoqolgan holda qoldirgani royxati boladi. diff --git a/man/httrack.1 b/man/httrack.1 index 86cfc84a..ac3e6bd7 100644 --- a/man/httrack.1 +++ b/man/httrack.1 @@ -3,7 +3,7 @@ .\" .\" This file is generated by man/makeman.sh; do not edit by hand. .\" SPDX-License-Identifier: GPL-3.0-or-later -.TH httrack 1 "23 July 2026" "httrack website copier" +.TH httrack 1 "26 July 2026" "httrack website copier" .SH NAME httrack \- offline browser : copy websites to a local directory .SH SYNOPSIS @@ -75,6 +75,7 @@ httrack \- offline browser : copy websites to a local directory [ \fB\-C, \-\-cache[=N]\fR ] [ \fB\-k, \-\-store\-all\-in\-cache\fR ] [ \fB\-%r, \-\-warc\fR ] +[ \fB\-%d, \-\-changes\fR ] [ \fB\-%n, \-\-do\-not\-recatch\fR ] [ \fB\-%v, \-\-display\fR ] [ \fB\-Q, \-\-do\-not\-log\fR ] @@ -279,6 +280,8 @@ create/use a cache for updates and retries (C0 no cache,C1 cache is prioritary,* store all files in cache (not useful if files on disk) (\-\-store\-all\-in\-cache) .IP \-%r write an ISO\-28500 WARC/1.1 archive; \-\-warc\-file NAME sets the output name, \-\-warc\-max\-size N rotates segments past N bytes, \-\-warc\-cdx also writes a sorted CDXJ index, \-\-wacz packages it all as a WACZ file (\-\-warc) +.IP \-%d +write hts\-changes.json listing what this crawl left new, changed, unchanged and gone compared to the previous mirror (\-\-changes) .IP \-%n do not re\-download locally erased files (\-\-do\-not\-recatch) .IP \-%v diff --git a/src/Makefile.am b/src/Makefile.am index 185d7c1e..12715b45 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -66,7 +66,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \ htscmdline.c htshelp.c htslib.c htsurlport.c htscoremain.c \ htsname.c htsrobots.c htstools.c htswizard.c \ htsalias.c htsthread.c htsindex.c htsbauth.c \ - htsmd5.c htscodec.c htswarc.c htsproxy.c htszlib.c htswrap.c htsconcat.c \ + htsmd5.c htscodec.c htswarc.c htschanges.c htsproxy.c htszlib.c htswrap.c htsconcat.c \ htsmodules.c htscharset.c punycode.c htsencoding.c htssniff.c \ md5.c \ minizip/ioapi.c minizip/mztools.c minizip/unzip.c minizip/zip.c \ @@ -77,7 +77,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \ htshelp.h htsindex.h htslib.h htsurlport.h htsmd5.h \ htsmodules.h htsname.h htsnet.h htssniff.h \ htsopt.h htsrobots.h htsthread.h \ - htstools.h htswizard.h htswrap.h htscodec.h htswarc.h htsproxy.h htszlib.h \ + htstools.h htswizard.h htswrap.h htscodec.h htswarc.h htschanges.h htsproxy.h htszlib.h \ htsstrings.h htsarrays.h httrack-library.h \ htscharset.h punycode.h htsencoding.h \ htsentities.h htsentities.sh htsbasiccharsets.sh htscodepages.h \ diff --git a/src/htsalias.c b/src/htsalias.c index 8d127a74..f780c14c 100644 --- a/src/htsalias.c +++ b/src/htsalias.c @@ -114,6 +114,8 @@ const char *hts_optalias[][4] = { "strip [host/pattern=]key1,key2,... from URLs"}, {"cookies-file", "-%K", "param1", "load extra cookies from a Netscape cookies.txt"}, + {"changes", "-%d", "single", + "write hts-changes.json: what this crawl changed vs. the previous mirror"}, {"warc", "-%r", "single", "write an ISO-28500 WARC/1.1 archive of the crawl"}, {"warc-file", "-%rf", "param1", "write a WARC archive to the given base name"}, {"warc-max-size", "-%rs", "param1", diff --git a/src/htsback.c b/src/htsback.c index 2e8d0617..63b4eb80 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -38,6 +38,7 @@ Please visit our Website: http://www.httrack.com #include "htsnet.h" #include "htscore.h" #include "htswarc.h" +#include "htschanges.h" #include "htsthread.h" #include /* END specific definitions */ @@ -728,6 +729,13 @@ int back_finalize(httrackp * opt, cache_back * cache, struct_back * sback, if ((size = hts_codec_unpack(codec, back[p].tmpfile, unpacked)) >= 0) { back[p].r.size = back[p].r.totalsize = size; + if (back[p].r.is_write) { + /* Sample the previous copy now: the rename below replaces + it, and file_notify() only fires once it is gone. */ + hts_changes_notify( + opt, back[p].url_adr, back[p].url_fil, back[p].url_sav, + HTS_TRUE, back[p].r.notmodified ? HTS_TRUE : HTS_FALSE); + } if (!back[p].r.is_write) { // fichier -> mémoire ; le fichier est écrit plus tard deleteaddr(&back[p].r); diff --git a/src/htschanges.c b/src/htschanges.c new file mode 100644 index 00000000..59991816 --- /dev/null +++ b/src/htschanges.c @@ -0,0 +1,679 @@ +/* ------------------------------------------------------------ */ +/* +HTTrack Website Copier, Offline Browser for Windows and Unix +Copyright (C) 2026 Xavier Roche and other contributors + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Ethical use: we kindly ask that you NOT use this software to harvest email +addresses or to collect any other private information about people. Doing so +would dishonor our work and waste the many hours we have spent on it. + +Please visit our Website: http://www.httrack.com +*/ + +/* ------------------------------------------------------------ */ +/* File: htschanges.c subroutines: */ +/* --changes: what this crawl changed vs. the previous */ +/* mirror (hts-changes.json) */ +/* Author: Xavier Roche */ +/* ------------------------------------------------------------ */ + +#define HTS_INTERNAL_BYTECODE + +#include "htschanges.h" + +#include "htscache.h" +#include "htscharset.h" +#include "htscore.h" +#include "htslib.h" +#include "htsmd5.h" +#include "htssafe.h" +#include "htsthread.h" +#include "htstools.h" +#include "coucal/coucal.h" +#include "md5.h" + +#include +#include +#include + +#define DIGEST_SIZE 16 + +typedef struct hts_changes hts_changes; + +/* One mirrored file. The mirror-relative path is the key, not the URL: a + redirect and its target can share a save name, and counting that file twice + would put it in two buckets. */ +typedef struct { + char *url; /* absolute URL, "" for an engine-generated file */ + char *file; /* mirror-relative path, as listed in new.lst */ + hts_boolean rewritten; /* the crawl wrote over the local copy */ + hts_boolean not_updated; /* transfer signal: the server reported no change */ + hts_boolean existed; /* the file was part of the previous mirror */ + hts_boolean on_disk; /* a copy was on disk when the crawl first saw it */ + hts_boolean listed_prev; /* the previous mirror's index listed this file */ + hts_boolean has_prev; /* prev_digest holds the previous payload's digest */ + hts_boolean has_new; /* new_digest was taken from the payload, not disk */ + unsigned char prev_digest[DIGEST_SIZE]; + unsigned char new_digest[DIGEST_SIZE]; + LLint prev_size; /* -1 when unknown */ + LLint size; /* size after the crawl, -1 when unknown */ + hts_change_bucket bucket; +} changes_entry; + +struct hts_changes { + coucal index; /* mirror-relative path -> entry slot + 1 */ + changes_entry *entries; + size_t count; + size_t capacity; + hts_boolean has_index; /* the run keeps a mirror index (the cache is on) */ + hts_boolean old_index; /* the previous mirror's index was read */ + hts_boolean overflow; /* an allocation failed; the report is partial */ + hts_boolean closed; /* reported: late notifies are dropped, not recorded */ +}; + +/* ------------------------------------------------------------ */ +/* JSON */ +/* ------------------------------------------------------------ */ + +/* Length of the UTF-8 sequence led by c, or 0 if c cannot lead one. */ +static size_t utf8_lead_length(unsigned char c) { + if (c >= 0xc2 && c <= 0xdf) + return 2; + if (c >= 0xe0 && c <= 0xef) + return 3; + if (c >= 0xf0 && c <= 0xf4) + return 4; + return 0; +} + +void hts_changes_json_string(String *out, const char *s) { + const unsigned char *p = (const unsigned char *) s; + + StringAddchar(*out, '"'); + while (*p != '\0') { + const unsigned char c = *p; + + if (c == '"' || c == '\\') { + StringAddchar(*out, '\\'); + StringAddchar(*out, (char) c); + p++; + } else if (c < 0x20 || c == 0x7f) { + char esc[8]; + + snprintf(esc, sizeof(esc), "\\u%04x", (unsigned) c); + StringCat(*out, esc); + p++; + } else if (c < 0x80) { + StringAddchar(*out, (char) c); + p++; + } else { + /* Non-ASCII: emit it verbatim only if it is a well-formed sequence, so + a legacy-charset URL cannot produce unparseable JSON. */ + const size_t len = utf8_lead_length(c); + + if (len != 0 && strnlen((const char *) p, len) == len && + hts_isStringUTF8((const char *) p, len)) { + StringMemcat(*out, (const char *) p, len); + p += len; + } else { + StringCat(*out, "\\ufffd"); + p++; + } + } + } + StringAddchar(*out, '"'); +} + +/* ------------------------------------------------------------ */ +/* Accumulator */ +/* ------------------------------------------------------------ */ + +static hts_changes *changes_new(void) { + hts_changes *changes = calloct(1, sizeof(*changes)); + + if (changes == NULL) + return NULL; + changes->index = coucal_new(0); + if (changes->index == NULL) { + freet(changes); + return NULL; + } + return changes; +} + +static void changes_free(hts_changes **pchanges) { + hts_changes *changes = *pchanges; + + if (changes == NULL) + return; + if (changes->entries != NULL) { + size_t i; + + for (i = 0; i < changes->count; i++) { + freet(changes->entries[i].url); + freet(changes->entries[i].file); + } + freet(changes->entries); + } + if (changes->index != NULL) + coucal_delete(&changes->index); + freet(changes); + *pchanges = NULL; +} + +/* Slot of `file`, or -1 when it has not been recorded. */ +static intptr_t changes_find(const hts_changes *changes, const char *file) { + intptr_t slot = 0; + + return coucal_read(changes->index, file, &slot) ? slot - 1 : -1; +} + +/* Slot of `file`, appending a fresh entry when it is new; -1 if allocation + failed, which flags the report partial. */ +static intptr_t changes_slot(hts_changes *changes, const char *file, + hts_boolean *is_new) { + intptr_t slot = 0; + + *is_new = HTS_FALSE; + if (coucal_read(changes->index, file, &slot)) + return slot - 1; + + if (changes->count == changes->capacity) { + const size_t capacity = changes->capacity != 0 ? changes->capacity * 2 : 64; + changes_entry *const entries = + realloct(changes->entries, capacity * sizeof(*entries)); + + if (entries == NULL) { + changes->overflow = HTS_TRUE; + return -1; + } + changes->entries = entries; + changes->capacity = capacity; + } + slot = (intptr_t) changes->count; + memset(&changes->entries[slot], 0, sizeof(changes->entries[slot])); + changes->entries[slot].file = strdupt(file); + changes->entries[slot].prev_size = -1; + changes->entries[slot].size = -1; + if (changes->entries[slot].file == NULL) { + changes->overflow = HTS_TRUE; + return -1; + } + changes->count++; + coucal_write(changes->index, file, slot + 1); + *is_new = HTS_TRUE; + return slot; +} + +/* MD5 of the file at `path`; not a security boundary. Never call it holding + changes_mutex: hashing a large file would stall every other connection. + HTS_FALSE if it cannot be read. */ +static hts_boolean digest_file(const char *path, + unsigned char digest[DIGEST_SIZE]) { + const int endian = 1; + struct MD5Context ctx; + char BIGSTK buffer[32768]; + FILE *fp = FOPEN(path, "rb"); + size_t nread; + + if (fp == NULL) + return HTS_FALSE; + MD5Init(&ctx, *((const char *) &endian)); + while ((nread = fread(buffer, 1, sizeof(buffer), fp)) > 0) + MD5Update(&ctx, (const unsigned char *) buffer, (unsigned int) nread); + if (ferror(fp) != 0) { + fclose(fp); + return HTS_FALSE; + } + fclose(fp); + MD5Final(digest, &ctx); + return HTS_TRUE; +} + +static void digest_mem(const char *buffer, size_t len, + unsigned char digest[DIGEST_SIZE]) { + domd5mem(buffer, len, (char *) digest, 0); +} + +hts_change_bucket hts_changes_classify(hts_boolean rewritten, + hts_boolean existed, + hts_boolean not_updated, + hts_boolean have_digests, + hts_boolean digests_equal) { + if (!rewritten) + return HTS_CHANGE_UNCHANGED; /* the crawl left the copy alone */ + if (!existed) + return HTS_CHANGE_NEW; + if (have_digests) + return digests_equal ? HTS_CHANGE_UNCHANGED : HTS_CHANGE_CHANGED; + /* No digest to compare: a server with no validators answers 200 with the + same bytes, so this signal alone over-reports. */ + return not_updated ? HTS_CHANGE_UNCHANGED : HTS_CHANGE_CHANGED; +} + +/* ------------------------------------------------------------ */ +/* Engine hooks */ +/* ------------------------------------------------------------ */ + +/* FTP transfers reach file_notify() from a thread the crawl never joins, so + every entry point below, report and teardown included, takes this lock. */ +static htsmutex changes_mutex = HTSMUTEX_INIT; + +/* The live accumulator, created on first use; NULL when --changes is off or + the report is already written. Call under the lock. */ +static hts_changes *changes_get(httrackp *opt) { + hts_changes *changes = (hts_changes *) opt->changes_state; + + if (!opt->changes) + return NULL; + if (changes == NULL) { + changes = changes_new(); + opt->changes_state = changes; + } + return changes != NULL && !changes->closed ? changes : NULL; +} + +void hts_changes_notify(httrackp *opt, const char *adr, const char *fil, + const char *save, hts_boolean rewritten, + hts_boolean not_updated) { + hts_changes *changes; + char BIGSTK file[HTS_URLMAXSIZE * 2]; + char BIGSTK url[HTS_URLMAXSIZE * 4 + 8]; /* holds adr + fil + a scheme */ + unsigned char prev_digest[DIGEST_SIZE]; + hts_boolean has_prev = HTS_FALSE; + hts_boolean is_new = HTS_FALSE; + intptr_t slot = -1; + LLint prev_size; + + if (!opt->changes) + return; + /* Engine-generated scaffolding (the top index) carries no URL and is not a + mirrored resource. */ + if (save == NULL || !strnotempty(save) || adr == NULL || fil == NULL || + (!strnotempty(adr) && !strnotempty(fil))) + return; + + hts_savename_listed(StringBuff(opt->path_html), save, file, sizeof(file)); + + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + if (changes != NULL) { + slot = changes_slot(changes, file, &is_new); + if (slot >= 0 && !is_new) { + /* Retry, or a second call site for the same file: the pre-run state was + already sampled and the copy on disk may no longer be it. */ + changes->entries[slot].rewritten = + changes->entries[slot].rewritten || rewritten; + } + } + hts_mutexrelease(&changes_mutex); + if (slot < 0 || !is_new) + return; + + url[0] = '\0'; + if (!link_has_authority(adr)) + strlcatbuff(url, "http://", sizeof(url)); + strlcatbuff(url, adr, sizeof(url)); + strlcatbuff(url, fil, sizeof(url)); + + /* Unlocked: hashing a large file would stall every other connection. Hash + it only when it is about to be overwritten, the last moment it exists. */ + prev_size = fsize_utf8(save); + if (prev_size >= 0 && rewritten) + has_prev = digest_file(save, prev_digest); + + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + /* The slot index is stable, entries being append-only; the array is not. */ + if (changes != NULL && (size_t) slot < changes->count) { + changes_entry *const entry = &changes->entries[slot]; + + entry->url = strdupt(url); + if (entry->url == NULL) + changes->overflow = HTS_TRUE; + entry->rewritten = entry->rewritten || rewritten; + entry->not_updated = not_updated; + entry->prev_size = prev_size; + entry->on_disk = prev_size >= 0; + /* hts_changes_html() compares payloads, and its digests win. */ + if (has_prev && !entry->has_new) { + entry->has_prev = HTS_TRUE; + memcpy(entry->prev_digest, prev_digest, DIGEST_SIZE); + } + } + hts_mutexrelease(&changes_mutex); +} + +void hts_changes_html(httrackp *opt, cache_back *cache, const htsblk *r, + const char *adr, const char *fil, const char *save) { + hts_changes *changes; + char BIGSTK file[HTS_URLMAXSIZE * 2]; + char BIGSTK location[HTS_URLMAXSIZE * 2]; + unsigned char new_digest[DIGEST_SIZE]; + unsigned char prev_digest[DIGEST_SIZE]; + hts_boolean has_prev = HTS_FALSE; + intptr_t slot; + htsblk prev; + + /* Ahead of the digest and the cache read: off must cost nothing. */ + if (!opt->changes) + return; + if (r->adr == NULL || r->size < 0 || save == NULL || !strnotempty(save)) + return; + + /* On disk this is the payload plus rewritten links and a footer dated by the + crawl, so it differs every run; compare payloads, the previous one being + the body the cache kept. */ + digest_mem(r->adr, (size_t) r->size, new_digest); + prev = cache_read_ro(opt, cache, adr, fil, "", location); + if (HTTP_IS_OK(prev.statuscode) && prev.adr != NULL && prev.size >= 0) { + digest_mem(prev.adr, (size_t) prev.size, prev_digest); + has_prev = HTS_TRUE; + } + freet(prev.adr); + + /* Only now take the lock and look the entry up: cache_read_ro() can itself + reach file_notify(), which would move the entries array. */ + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + /* file_notify() records the entry; this call only refines it. */ + if (changes != NULL) { + hts_savename_listed(StringBuff(opt->path_html), save, file, sizeof(file)); + slot = changes_find(changes, file); + if (slot >= 0) { + changes_entry *const entry = &changes->entries[slot]; + + entry->has_new = HTS_TRUE; + memcpy(entry->new_digest, new_digest, DIGEST_SIZE); + entry->has_prev = has_prev; + if (has_prev) + memcpy(entry->prev_digest, prev_digest, DIGEST_SIZE); + } + } + hts_mutexrelease(&changes_mutex); +} + +void hts_changes_dropped(httrackp *opt, const char *file, hts_boolean kept) { + hts_changes *changes; + hts_boolean is_new; + intptr_t slot; + + if (!opt->changes || file == NULL || !strnotempty(file)) + return; + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + if (changes != NULL) { + slot = changes_slot(changes, file, &is_new); + if (slot >= 0 && is_new) { + changes->entries[slot].url = strdupt(""); + changes->entries[slot].listed_prev = HTS_TRUE; + /* A kept file leaves rewritten clear, which resolves to unchanged. */ + if (!kept) + changes->entries[slot].bucket = HTS_CHANGE_GONE; + } + } + hts_mutexrelease(&changes_mutex); +} + +void hts_changes_previous(httrackp *opt, const char *file) { + hts_changes *changes; + intptr_t slot; + + if (!opt->changes || file == NULL) + return; + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + if (changes != NULL) { + changes->has_index = HTS_TRUE; + changes->old_index = HTS_TRUE; + slot = changes_find(changes, file); + if (slot >= 0) + changes->entries[slot].listed_prev = HTS_TRUE; + } + hts_mutexrelease(&changes_mutex); +} + +void hts_changes_indexed(httrackp *opt) { + hts_changes *changes; + + if (!opt->changes) + return; + hts_mutexlock(&changes_mutex); + changes = changes_get(opt); + if (changes != NULL) + changes->has_index = HTS_TRUE; + hts_mutexrelease(&changes_mutex); +} + +/* ------------------------------------------------------------ */ +/* Report */ +/* ------------------------------------------------------------ */ + +/* Assign each entry its final bucket from the bytes now on disk. */ +static void changes_resolve(hts_changes *changes, httrackp *opt) { + char catbuff[CATBUFF_SIZE]; + size_t i; + + for (i = 0; i < changes->count; i++) { + changes_entry *const entry = &changes->entries[i]; + const char *path; + unsigned char digest[DIGEST_SIZE]; + hts_boolean have_digests = HTS_FALSE; + hts_boolean digests_equal = HTS_FALSE; + + if (entry->bucket == HTS_CHANGE_GONE) + continue; + + /* The previous mirror's index is the authority on what was there before: + a partial left by this crawl's own failed attempt is on disk but was + never part of the previous mirror. Without an index, fall back to what + the first notify saw on disk. */ + entry->existed = changes->old_index ? entry->listed_prev : entry->on_disk; + path = fconcat(catbuff, sizeof(catbuff), StringBuff(opt->path_html), + entry->file); + entry->size = fsize_utf8(path); + if (entry->rewritten && entry->existed) { + /* The size shortcut only holds when both digests describe the file on + disk; has_new means they are payload digests, and a parsed page's + rendered size moves with its links and footer. */ + if (!entry->has_new && entry->size >= 0 && entry->prev_size >= 0 && + entry->size != entry->prev_size) { + have_digests = HTS_TRUE; /* different lengths: no need to hash */ + digests_equal = HTS_FALSE; + } else if (entry->has_prev && + (entry->has_new + ? (memcpy(digest, entry->new_digest, DIGEST_SIZE), 1) + : digest_file(path, digest))) { + have_digests = HTS_TRUE; + digests_equal = memcmp(digest, entry->prev_digest, DIGEST_SIZE) == 0 + ? HTS_TRUE + : HTS_FALSE; + } + } + entry->bucket = + hts_changes_classify(entry->rewritten, entry->existed, + entry->not_updated, have_digests, digests_equal); + } +} + +static const char *const bucket_names[HTS_CHANGE_BUCKETS] = { + "new", "changed", "unchanged", "gone"}; + +/* Serialize the report. Call under changes_mutex. */ +static void changes_serialize(httrackp *opt, String *out) { + hts_changes *const changes = (hts_changes *) opt->changes_state; + size_t counts[HTS_CHANGE_BUCKETS]; + char date[32]; + char scratch[64]; + int bucket; + size_t i; + + StringClear(*out); + if (changes == NULL) + return; + changes_resolve(changes, opt); + + memset(counts, 0, sizeof(counts)); + for (i = 0; i < changes->count; i++) + counts[changes->entries[i].bucket]++; + + hts_now_iso8601(date); + StringCat(*out, "{\n \"schema\": "); + snprintf(scratch, sizeof(scratch), "%d", HTS_CHANGES_SCHEMA); + StringCat(*out, scratch); + StringCat(*out, ",\n \"generator\": "); + hts_changes_json_string(out, "HTTrack Website Copier/" HTTRACK_VERSION); + StringCat(*out, ",\n \"date\": "); + hts_changes_json_string(out, date); + StringCat(*out, ",\n \"first_crawl\": "); + StringCat(*out, !changes->has_index + ? "null" + : (changes->old_index ? "false" : "true")); + StringCat(*out, ",\n \"partial\": "); + StringCat(*out, changes->overflow ? "true" : "false"); + StringCat(*out, ",\n \"purged\": "); + StringCat(*out, opt->delete_old ? "true" : "false"); + + StringCat(*out, ",\n \"counts\": {"); + for (bucket = 0; bucket < HTS_CHANGE_BUCKETS; bucket++) { + StringCat(*out, bucket != 0 ? ", " : " "); + hts_changes_json_string(out, bucket_names[bucket]); + snprintf(scratch, sizeof(scratch), ": %d", (int) counts[bucket]); + StringCat(*out, scratch); + } + StringCat(*out, " }"); + + for (bucket = 0; bucket < HTS_CHANGE_BUCKETS; bucket++) { + hts_boolean first = HTS_TRUE; + + StringCat(*out, ",\n "); + hts_changes_json_string(out, bucket_names[bucket]); + StringCat(*out, ": ["); + for (i = 0; i < changes->count; i++) { + const changes_entry *const entry = &changes->entries[i]; + + if (entry->bucket != bucket) + continue; + StringCat(*out, first ? "\n { \"url\": " : ",\n { \"url\": "); + first = HTS_FALSE; + hts_changes_json_string(out, entry->url != NULL ? entry->url : ""); + StringCat(*out, ", \"file\": "); + hts_changes_json_string(out, entry->file); + if (entry->size >= 0) { + snprintf(scratch, sizeof(scratch), ", \"size\": " LLintP, + (LLint) entry->size); + StringCat(*out, scratch); + } + if (bucket == HTS_CHANGE_CHANGED && entry->prev_size >= 0) { + snprintf(scratch, sizeof(scratch), ", \"previous_size\": " LLintP, + (LLint) entry->prev_size); + StringCat(*out, scratch); + } + StringCat(*out, " }"); + } + StringCat(*out, first ? "]" : "\n ]"); + } + StringCat(*out, "\n}\n"); +} + +void hts_changes_report(httrackp *opt, String *out) { + hts_mutexlock(&changes_mutex); + changes_serialize(opt, out); + hts_mutexrelease(&changes_mutex); +} + +void hts_changes_close_opt(httrackp *opt) { + char catbuff[CATBUFF_SIZE]; + const char *path; + String report = STRING_EMPTY; + size_t counts[HTS_CHANGE_BUCKETS]; + size_t total; + hts_boolean has_index, old_index; + FILE *fp; + + hts_mutexlock(&changes_mutex); + { + /* changes_get(), not the raw field: a crawl that mirrored nothing still + owes the user a report, and a stale one from the previous run must not + survive on disk as if it described this one. */ + hts_changes *const changes = changes_get(opt); + size_t i; + + if (changes == NULL) { + hts_mutexrelease(&changes_mutex); + return; + } + changes_serialize(opt, &report); + memset(counts, 0, sizeof(counts)); + for (i = 0; i < changes->count; i++) + counts[changes->entries[i].bucket]++; + total = changes->count; + has_index = changes->has_index; + old_index = changes->old_index; + /* Sticky: whatever the crawl's stragglers do next is not in this report. */ + changes->closed = HTS_TRUE; + } + hts_mutexrelease(&changes_mutex); + + path = fconcat(catbuff, sizeof(catbuff), StringBuff(opt->path_log), + HTS_CHANGES_FILE); + fp = FOPEN(path, "wb"); + if (fp != NULL) { + const size_t len = StringLength(report); + + if (len != 0 && fwrite(StringBuff(report), 1, len, fp) != len) + hts_log_print(opt, LOG_ERROR | LOG_ERRNO, + "Unable to write the change report %s", path); + fclose(fp); + } else { + hts_log_print(opt, LOG_ERROR | LOG_ERRNO, + "Unable to create the change report %s", path); + } + + if (!has_index) { + hts_log_print(opt, LOG_NOTICE, + "Change report: no mirror index (the cache is off), %d files " + "mirrored, deletions not detected (%s)", + (int) total, HTS_CHANGES_FILE); + } else if (!old_index) { + hts_log_print(opt, LOG_NOTICE, + "Change report: first crawl, %d files mirrored, nothing to " + "compare against (%s)", + (int) total, HTS_CHANGES_FILE); + } else { + hts_log_print(opt, LOG_NOTICE, + "Change report: %d new, %d changed, %d unchanged, %d gone " + "(%s)", + (int) counts[HTS_CHANGE_NEW], + (int) counts[HTS_CHANGE_CHANGED], + (int) counts[HTS_CHANGE_UNCHANGED], + (int) counts[HTS_CHANGE_GONE], HTS_CHANGES_FILE); + } + + StringFree(report); +} + +void hts_changes_free_opt(httrackp *opt) { + hts_changes *changes; + + hts_mutexlock(&changes_mutex); + changes = (hts_changes *) opt->changes_state; + changes_free(&changes); + opt->changes_state = NULL; + hts_mutexrelease(&changes_mutex); +} diff --git a/src/htschanges.h b/src/htschanges.h new file mode 100644 index 00000000..613618c7 --- /dev/null +++ b/src/htschanges.h @@ -0,0 +1,125 @@ +/* ------------------------------------------------------------ */ +/* +HTTrack Website Copier, Offline Browser for Windows and Unix +Copyright (C) 2026 Xavier Roche and other contributors + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Ethical use: we kindly ask that you NOT use this software to harvest email +addresses or to collect any other private information about people. Doing so +would dishonor our work and waste the many hours we have spent on it. + +Please visit our Website: http://www.httrack.com +*/ + +/* ------------------------------------------------------------ */ +/* HTTrack change report (--changes). Internal, not installed. + Accumulates what the crawl did to each mirrored file, compares the bytes + against the copy the previous run left behind, and writes + hts-changes.json next to the log. */ +/* ------------------------------------------------------------ */ + +#ifndef HTS_CHANGES_DEFH +#define HTS_CHANGES_DEFH + +#include "htsopt.h" +#include "htsstrings.h" + +#ifndef HTS_DEF_FWSTRUCT_cache_back +#define HTS_DEF_FWSTRUCT_cache_back +typedef struct cache_back cache_back; +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Report file name, written under the project's log directory. */ +#define HTS_CHANGES_FILE "hts-changes.json" + +/* Schema version carried by the report; bump on an incompatible change. */ +#define HTS_CHANGES_SCHEMA 1 + +/* Which side of the comparison a mirrored file ended up on. */ +typedef enum { + HTS_CHANGE_NEW = 0, /* no local copy before this run */ + HTS_CHANGE_CHANGED, /* rewritten, and the bytes differ */ + HTS_CHANGE_UNCHANGED, /* the bytes are those of the previous mirror */ + HTS_CHANGE_GONE, /* in the previous mirror, absent from this one */ + HTS_CHANGE_BUCKETS +} hts_change_bucket; + +/* Record what this crawl is doing to the local file `save` (absolute path; + adr/fil form its URL, either may be empty for an engine-generated file). + `rewritten` means the copy on disk is being written over, `not_updated` is + the 200-versus-304 signal, used only when no digest can be taken. Only the + first call for a file samples the previous copy, so a retried or + twice-notified resource is counted once. No-op unless --changes is on. */ +void hts_changes_notify(httrackp *opt, const char *adr, const char *fil, + const char *save, hts_boolean rewritten, + hts_boolean not_updated); + +/* Refine the entry for a parsed HTML file: its local copy carries rewritten + links and a footer dated by the crawl, so it differs every run. Compares the + payload `r` holds against the body the previous run left in the cache. */ +void hts_changes_html(httrackp *opt, cache_back *cache, const htsblk *r, + const char *adr, const char *fil, const char *save); + +/* Record `file` (mirror-relative, as listed in new.lst) as listed by the + previous mirror's index and absent from this run's. `kept` means its local + copy survives the run because the crawl tried and failed to replace it: a + failed transfer is not a deletion, so it is reported unchanged, not gone. */ +void hts_changes_dropped(httrackp *opt, const char *file, hts_boolean kept); + +/* Record that the previous mirror's index listed `file`. That index, not the + file's presence on disk, decides what counts as already mirrored. */ +void hts_changes_previous(httrackp *opt, const char *file); + +/* Record that this run keeps a mirror index. Without one (the cache is off) + deletions and first_crawl are undecidable, and the report says so. */ +void hts_changes_indexed(httrackp *opt); + +/* Resolve every entry against the bytes now on disk and serialize the report + into `out` (replaced). Exposed for the self-tests. */ +void hts_changes_report(httrackp *opt, String *out); + +/* Write the report and log a one-line summary. Idempotent, and seals the + accumulator: a late notify is dropped rather than starting a second one. */ +void hts_changes_close_opt(httrackp *opt); + +/* Drop the accumulator, for a run that never reached its end and to start the + next one clean. Null-safe and idempotent. */ +void hts_changes_free_opt(httrackp *opt); + +/* Bucket for one entry, from what was observed. No filesystem access: + `have_digests` says both sides could be hashed, `digests_equal` compares + them, and `not_updated` is the fallback signal when they could not. */ +hts_change_bucket hts_changes_classify(hts_boolean rewritten, + hts_boolean existed, + hts_boolean not_updated, + hts_boolean have_digests, + hts_boolean digests_equal); + +/* Append `s` to `out` as a quoted JSON string. Byte sequences that are not + valid UTF-8 become U+FFFD, so a mirror carrying legacy-charset URLs still + produces parseable JSON. */ +void hts_changes_json_string(String *out, const char *s); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/htscore.c b/src/htscore.c index dab4e4bf..e87d9816 100644 --- a/src/htscore.c +++ b/src/htscore.c @@ -40,6 +40,7 @@ Please visit our Website: http://www.httrack.com /* File defs */ #include "htscore.h" #include "htswarc.h" +#include "htschanges.h" /* specific definitions */ #include "htsbase.h" @@ -488,6 +489,7 @@ void hts_finish_html_file(httrackp *opt, cache_back *cache, htsblk *r, const char *adr, const char *fil, const char *save) { { file_notify(opt, adr, fil, save, 1, 1, r->notmodified); + hts_changes_html(opt, cache, r, adr, fil, save); *fp = filecreate(&opt->state.strc, save); if (*fp) { if (ht_len > 0 && fwrite(ht_buff, 1, ht_len, *fp) != ht_len) { @@ -691,6 +693,9 @@ int httpmirror(char *url1, httrackp * opt) { // hash table opt->hash = &hash; + // a change report left by a previous crawl on this opt is not this one's + hts_changes_free_opt(opt); + // initialize link heap hts_record_init(opt); @@ -2089,9 +2094,12 @@ int httpmirror(char *url1, httrackp * opt) { if (cache.lst) { fclose(cache.lst); cache.lst = opt->state.strc.lst = NULL; - if (opt->delete_old) { + /* old.lst minus new.lst is the set of files the previous mirror had and + this one does not. --changes reports it; only --purge-old acts on it. */ + if (opt->delete_old || opt->changes) { FILE *old_lst, *new_lst; + hts_changes_indexed(opt); // opt->state._hts_in_html_parsing = 3; // @@ -2117,21 +2125,37 @@ int httpmirror(char *url1, httrackp * opt) { int purge = 0; while(!feof(old_lst)) { + char BIGSTK file[HTS_URLMAXSIZE * 2]; + linput(old_lst, line, 1000); + if (!strnotempty(line)) + continue; + strcpybuff(file, StringBuff(opt->path_html)); + strcatbuff(file, line + 1); + file[strlen(file) - 1] = '\0'; + hts_changes_previous(opt, file + StringLength(opt->path_html)); if (!strstr(adr, line)) { // not found in the new list? - char BIGSTK file[HTS_URLMAXSIZE * 2]; - - strcpybuff(file, StringBuff(opt->path_html)); - strcatbuff(file, line + 1); - file[strlen(file) - 1] = '\0'; - if (fexist_utf8(file)) { // toujours sur disque: virer - hts_log_print(opt, LOG_INFO, "Purging %s", file); - UNLINK(file); - purge = 1; + if (fexist_utf8(file)) { // still on disk + /* A link this crawl did try but never wrote (a transfer + killed mid-flight) also drops out of new.lst. Unless it + is about to be purged, its previous copy stands and the + file is not gone. */ + const hts_boolean kept = + !opt->delete_old && + hash_read(opt->hash, file, NULL, + HASH_STRUCT_FILENAME) >= 0; + + hts_changes_dropped( + opt, file + StringLength(opt->path_html), kept); + if (opt->delete_old) { + hts_log_print(opt, LOG_INFO, "Purging %s", file); + UNLINK(file); + purge = 1; + } } } } - { + if (opt->delete_old) { // emptied directories go with the files fseek(old_lst, 0, SEEK_SET); while(!feof(old_lst)) { linput(old_lst, line, 1000); @@ -2166,7 +2190,7 @@ int httpmirror(char *url1, httrackp * opt) { } } // - if (!purge) { + if (opt->delete_old && !purge) { hts_log_print(opt, LOG_INFO, "No files purged"); } } @@ -2256,6 +2280,7 @@ int httpmirror(char *url1, httrackp * opt) { // ending usercommand(opt, 0, NULL, NULL, NULL, NULL); warc_close_opt(opt); + hts_changes_close_opt(opt); // désallocation mémoire & buffers XH_uninit; @@ -2881,6 +2906,17 @@ int filecreateempty(filenote_strc * strc, const char *filename) { return 0; } +void hts_savename_listed(const char *root, const char *s, char *dest, + size_t destsize) { + char catbuff[CATBUFF_SIZE]; + + strlcpybuff(dest, fslash(catbuff, sizeof(catbuff), s), destsize); + if (strnotempty(root) && strncmp(fslash(catbuff, sizeof(catbuff), root), dest, + strlen(root)) == 0) { + strlcpybuff(dest, s + strlen(root), destsize); + } +} + // noter fichier int filenote(filenote_strc * strc, const char *s, filecreate_params * params) { // gestion du fichier liste liste @@ -2890,15 +2926,8 @@ int filenote(filenote_strc * strc, const char *s, filecreate_params * params) { return 0; } else if (strc->lst) { char BIGSTK savelst[HTS_URLMAXSIZE * 2]; - char catbuff[CATBUFF_SIZE]; - strcpybuff(savelst, fslash(catbuff, sizeof(catbuff), s)); - // couper chemin? - if (strnotempty(strc->path)) { - if (strncmp(fslash(catbuff, sizeof(catbuff), strc->path), savelst, strlen(strc->path)) == 0) { // couper - strcpybuff(savelst, s + strlen(strc->path)); - } - } + hts_savename_listed(strc->path, s, savelst, sizeof(savelst)); fprintf(strc->lst, "[%s]" LF, savelst); fflush(strc->lst); } @@ -2908,6 +2937,9 @@ int filenote(filenote_strc * strc, const char *s, filecreate_params * params) { /* Note: utf-8 */ void file_notify(httrackp * opt, const char *adr, const char *fil, const char *save, int create, int modify, int not_updated) { + hts_changes_notify(opt, adr, fil, save, + (create || modify) ? HTS_TRUE : HTS_FALSE, + not_updated ? HTS_TRUE : HTS_FALSE); RUN_CALLBACK6(opt, filesave2, adr, fil, save, create, modify, not_updated); } @@ -3642,6 +3674,7 @@ HTSEXT_API int copy_htsopt(const httrackp * from, httrackp * to) { to->warc_max_size = from->warc_max_size; to->warc_cdx = from->warc_cdx; to->warc_wacz = from->warc_wacz; + to->changes = from->changes; if (from->pause_max_ms > 0) { to->pause_min_ms = from->pause_min_ms; diff --git a/src/htscore.h b/src/htscore.h index a2c4e40d..f959678a 100644 --- a/src/htscore.h +++ b/src/htscore.h @@ -354,6 +354,12 @@ int filecreateempty(filenote_strc * strct, const char *filename); int filenote(filenote_strc * strct, const char *s, filecreate_params * params); +/* Copy into dest (destsize bytes) the form under which the local path s is + listed in new.lst: forward slashes, with the mirror `root` stripped when s + sits under it. Also the change report's key, so the two must not drift. */ +void hts_savename_listed(const char *root, const char *s, char *dest, + size_t destsize); + void file_notify(httrackp * opt, const char *adr, const char *fil, const char *save, int create, int modify, int wasupdated); diff --git a/src/htscoremain.c b/src/htscoremain.c index 400cda40..a6bd2e1d 100644 --- a/src/htscoremain.c +++ b/src/htscoremain.c @@ -41,6 +41,7 @@ Please visit our Website: http://www.httrack.com #include "htsdefines.h" #include "htsalias.h" #include "htswarc.h" +#include "htschanges.h" #include "htsbauth.h" #include "htswrap.h" #include "htsmodules.h" @@ -1747,6 +1748,13 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) { StringCopy(opt->cookies_file, argv[na]); } break; + case 'd': // --changes: report what this crawl changed + opt->changes = HTS_TRUE; + if (*(com + 1) == '0') { + opt->changes = HTS_FALSE; + com++; + } + break; case 'r': // warc / warc-file: write an ISO-28500 WARC archive if (*(com + 1) == 'f') { // --warc-file NAME: explicit basename com++; diff --git a/src/htshelp.c b/src/htshelp.c index 5a7d3da3..8944307a 100644 --- a/src/htshelp.c +++ b/src/htshelp.c @@ -608,6 +608,8 @@ void help(const char *app, int more) { "output name, --warc-max-size N rotates segments past N bytes, " "--warc-cdx also writes a sorted CDXJ index, --wacz packages it all " "as a WACZ file"); + infomsg(" %d write hts-changes.json listing what this crawl left new, " + "changed, unchanged and gone compared to the previous mirror"); infomsg(" %n do not re-download locally erased files"); infomsg (" %v display on screen filenames downloaded (in realtime) - * %v1 short version - %v2 full animation"); diff --git a/src/htslib.c b/src/htslib.c index 31819c24..d25708ac 100644 --- a/src/htslib.c +++ b/src/htslib.c @@ -37,6 +37,7 @@ Please visit our Website: http://www.httrack.com #include "htscore.h" #include "htswarc.h" +#include "htschanges.h" /* specific definitions */ #include "htsbase.h" @@ -2698,6 +2699,24 @@ void time_gmt_rfc822(char *s) { time_rfc822(s, A); } +void hts_now_iso8601(char out[32]) { + time_t t = time(NULL); + struct tm tmv; + +#if defined(_WIN32) + struct tm *g = gmtime(&t); + + if (g != NULL) + tmv = *g; + else + memset(&tmv, 0, sizeof(tmv)); +#else + if (gmtime_r(&t, &tmv) == NULL) + memset(&tmv, 0, sizeof(tmv)); +#endif + strftime(out, 32, "%Y-%m-%dT%H:%M:%SZ", &tmv); +} + // heure actuelle, format rfc (taille buffer 256o) void time_local_rfc822(char *s) { time_t tt; @@ -6011,6 +6030,8 @@ HTSEXT_API httrackp *hts_create_opt(void) { StringCopy(opt->cookies_file, ""); StringCopy(opt->warc_file, ""); opt->warc_max_size = 0; /* no rotation unless --warc-max-size sets it */ + opt->changes = HTS_FALSE; + opt->changes_state = NULL; StringCopy(opt->why_url, ""); opt->pause_min_ms = 0; opt->pause_max_ms = 0; @@ -6164,6 +6185,8 @@ HTSEXT_API void hts_free_opt(httrackp * opt) { StringFree(opt->why_url); StringFree(opt->warc_file); + hts_changes_free_opt(opt); + StringFree(opt->path_html); StringFree(opt->path_html_utf8); StringFree(opt->path_log); diff --git a/src/htslib.h b/src/htslib.h index d06d0eda..5087c01b 100644 --- a/src/htslib.h +++ b/src/htslib.h @@ -258,6 +258,10 @@ void sec2str(char *s, TStamp t); void time_gmt_rfc822(char *s); void time_local_rfc822(char *s); + +/* Current UTC time as "YYYY-MM-DDThh:mm:ssZ". */ +void hts_now_iso8601(char out[32]); + struct tm *convert_time_rfc822(struct tm *buffer, const char *s); int set_filetime(const char *file, struct tm *tm_time); int set_filetime_rfc822(const char *file, const char *date); diff --git a/src/htsopt.h b/src/htsopt.h index 6ead3537..4a6b27fe 100644 --- a/src/htsopt.h +++ b/src/htsopt.h @@ -547,6 +547,10 @@ struct httrackp { archive. Tail: ABI */ hts_boolean warc_wacz; /**< --wacz: package archive+index+pages as a WACZ zip (implies --warc + --warc-cdx). Tail: ABI */ + hts_boolean changes; /**< --changes: report what this crawl changed against + the previous mirror. Tail: ABI */ + void *changes_state; /**< live change-report accumulator (hts_changes*), + engine-owned. Tail: ABI */ }; /* Running statistics for a mirror. */ diff --git a/src/htsselftest.c b/src/htsselftest.c index a597e8c9..b1d5e7dc 100644 --- a/src/htsselftest.c +++ b/src/htsselftest.c @@ -60,6 +60,7 @@ Please visit our Website: http://www.httrack.com #include "htscodec.h" #include "htsproxy.h" #include "htswarc.h" +#include "htschanges.h" #if HTS_USEZLIB #include "htszlib.h" #endif @@ -1740,6 +1741,12 @@ static int st_copyopt(httrackp *opt, int argc, char **argv) { if (strcmp(StringBuff(to->warc_file), "run.warc.gz") != 0) err = 1; + from->changes = HTS_TRUE; + to->changes = HTS_FALSE; + copy_htsopt(from, to); + if (to->changes != HTS_TRUE) + err = 1; + /* #185 pause pair: copied when enabled (max>0), the 0 sentinel skips */ from->pause_min_ms = 5000; from->pause_max_ms = 10000; @@ -5226,6 +5233,192 @@ static int st_cookieimport(httrackp *opt, int argc, char **argv) { return 0; } +/* --changes bucket accounting and JSON escaping (#714). */ +static int st_changes(httrackp *opt, int argc, char **argv) { + String out = STRING_EMPTY; + int err = 0; + + (void) opt; + (void) argc; + (void) argv; + + /* A file the crawl did not rewrite is unchanged whatever the wire said. */ + assertf(hts_changes_classify(HTS_FALSE, HTS_TRUE, HTS_FALSE, HTS_FALSE, + HTS_FALSE) == HTS_CHANGE_UNCHANGED); + /* Rewritten with no previous copy: new, digests or not. */ + assertf(hts_changes_classify(HTS_TRUE, HTS_FALSE, HTS_FALSE, HTS_TRUE, + HTS_FALSE) == HTS_CHANGE_NEW); + /* Digests decide, and outrank the transfer signal both ways: a server with + no validators answers 200 with the same bytes, and a 304 can still sit in + front of a locally damaged copy. */ + assertf(hts_changes_classify(HTS_TRUE, HTS_TRUE, HTS_FALSE, HTS_TRUE, + HTS_TRUE) == HTS_CHANGE_UNCHANGED); + assertf(hts_changes_classify(HTS_TRUE, HTS_TRUE, HTS_TRUE, HTS_TRUE, + HTS_FALSE) == HTS_CHANGE_CHANGED); + /* Only with no digest at all does the transfer signal get a say. */ + assertf(hts_changes_classify(HTS_TRUE, HTS_TRUE, HTS_TRUE, HTS_FALSE, + HTS_FALSE) == HTS_CHANGE_UNCHANGED); + assertf(hts_changes_classify(HTS_TRUE, HTS_TRUE, HTS_FALSE, HTS_FALSE, + HTS_FALSE) == HTS_CHANGE_CHANGED); + +#define JSON_IS(SRC, WANT) \ + do { \ + StringClear(out); \ + hts_changes_json_string(&out, SRC); \ + if (strcmp(StringBuff(out), WANT) != 0) { \ + fprintf(stderr, "changes: %s -> %s, expected %s\n", #SRC, \ + StringBuff(out), WANT); \ + err = 1; \ + } \ + } while (0) + + JSON_IS("/a/b.html", "\"/a/b.html\""); + JSON_IS("a\"b\\c", "\"a\\\"b\\\\c\""); + JSON_IS("tab\there", "\"tab\\u0009here\""); + /* Valid UTF-8 rides through; a lone Latin-1 byte, a truncated sequence and + an overlong encoding of '/' each become U+FFFD rather than invalid JSON. */ + JSON_IS("caf\xc3\xa9", "\"caf\xc3\xa9\""); + JSON_IS("caf\xe9", "\"caf\\ufffd\""); + JSON_IS("\xc3", "\"\\ufffd\""); + JSON_IS("\xc0\xaf", "\"\\ufffd\\ufffd\""); + /* A UTF-16 surrogate half is well-formed UTF-8 by shape only. */ + JSON_IS("\xed\xa0\x80", "\"\\ufffd\\ufffd\\ufffd\""); + +#undef JSON_IS + StringFree(out); + printf("changes self-test: %s\n", err ? "FAIL" : "OK"); + return err; +} + +#define CHANGES_RACE_FILES 8 +#define CHANGES_RACE_ROUNDS 400 + +static void changes_race_notify(httrackp *opt, int n) { + char fil[64]; + char BIGSTK save[HTS_URLMAXSIZE * 2]; + + snprintf(fil, sizeof(fil), "/f%d.bin", n); + strlcpybuff(save, StringBuff(opt->path_html), sizeof(save)); + strlcatbuff(save, "race.example", sizeof(save)); + strlcatbuff(save, fil, sizeof(save)); + hts_changes_notify(opt, "race.example", fil, save, HTS_TRUE, HTS_FALSE); +} + +/* htsthread_wait() counts a thread only once it is running, so it can return + before any of them started; join on our own counter instead. */ +static htsmutex changes_race_lock = HTSMUTEX_INIT; +static int changes_race_started = 0; +static int changes_race_live = 0; + +static int changes_race_count(int *which) { + int n; + + hts_mutexlock(&changes_race_lock); + n = *which; + hts_mutexrelease(&changes_race_lock); + return n; +} + +static void changes_race_thread(void *arg) { + httrackp *const opt = (httrackp *) arg; + int i; + + hts_mutexlock(&changes_race_lock); + changes_race_started++; + hts_mutexrelease(&changes_race_lock); + for (i = 0; i < CHANGES_RACE_ROUNDS; i++) + changes_race_notify(opt, i % CHANGES_RACE_FILES); + hts_mutexlock(&changes_race_lock); + changes_race_live--; + hts_mutexrelease(&changes_race_lock); +} + +/* A transfer thread the crawl never joins (FTP) reaches hts_changes_notify() + while the report is being resolved and written. Run it under TSan. */ +static int st_changes_race(httrackp *opt, int argc, char **argv) { + String out = STRING_EMPTY; + char base[HTS_URLMAXSIZE]; + int err = 0; + int i; + + if (argc < 1) { + fprintf(stderr, "usage: -#test=changes-race \n"); + return 1; + } + strcpybuff(base, argv[0]); + if (base[0] != '\0' && base[strlen(base) - 1] != '/') + strcatbuff(base, "/"); + StringCopy(opt->path_html, base); + StringCopy(opt->path_html_utf8, base); + StringCopy(opt->path_log, base); + opt->changes = HTS_TRUE; + hts_changes_free_opt(opt); + + /* Real files, so the reader hashes and stats them for as long as it takes. */ + { + char BIGSTK dir[HTS_URLMAXSIZE * 2]; + + strlcpybuff(dir, base, sizeof(dir)); + strlcatbuff(dir, "race.example", sizeof(dir)); + for (i = 0; i < CHANGES_RACE_FILES; i++) { + char BIGSTK path[HTS_URLMAXSIZE * 2]; + char name[64]; + FILE *fp; + int n; + + snprintf(name, sizeof(name), "/f%d.bin", i); + strlcpybuff(path, dir, sizeof(path)); + strlcatbuff(path, name, sizeof(path)); + structcheck(path); + fp = FOPEN(path, "wb"); + if (fp == NULL) { + fprintf(stderr, "changes-race: cannot write %s\n", path); + return 1; + } + for (n = 0; n < 16384; n++) + fwrite("0123456789abcdef", 1, 16, fp); + fclose(fp); + } + } + + /* Take both locks once here: hts_mutexlock() initializes lazily, and two + threads reaching a fresh one together race on the init itself. */ + hts_mutexlock(&changes_race_lock); + changes_race_started = 0; + changes_race_live = 4; + hts_mutexrelease(&changes_race_lock); + for (i = 0; i < 4; i++) { + if (hts_newthread(changes_race_thread, opt) != 0) { + fprintf(stderr, "changes-race: cannot spawn a notifier thread\n"); + return 1; + } + } + /* Report only once they are all notifying, or there is nothing to race. */ + while (changes_race_count(&changes_race_started) < 4) + Sleep(10); + for (i = 0; i < 64; i++) + hts_changes_report(opt, &out); + hts_changes_close_opt(opt); + while (changes_race_count(&changes_race_live) > 0) + Sleep(10); + + /* Sealed: a straggler must be dropped, not start a report nobody writes. */ + changes_race_notify(opt, CHANGES_RACE_FILES + 1); + hts_changes_report(opt, &out); + if (StringLength(out) == 0) { + fprintf(stderr, "changes-race: the report was lost after close\n"); + err = 1; + } else if (strstr(StringBuff(out), "f9.bin") != NULL) { + fprintf(stderr, "changes-race: a post-close notify reached the report\n"); + err = 1; + } + + StringFree(out); + hts_changes_free_opt(opt); + printf("changes-race self-test: %s\n", err ? "FAIL" : "OK"); + return err; +} + /* ------------------------------------------------------------ */ /* Registry: name -> handler, with a usage hint and a one-line description. */ /* ------------------------------------------------------------ */ @@ -5281,6 +5474,10 @@ static const struct selftest_entry { {"strsafe", "[overflow|overflow-buff|overflow-src [str]]", "bounded string-op self-test", st_strsafe}, {"copyopt", "", "copy_htsopt option-copy self-test", st_copyopt}, + {"changes", "", "--changes bucket accounting and JSON escaping (#714)", + st_changes}, + {"changes-race", "", "--changes under a late transfer thread (#714)", + st_changes_race}, {"pause", "", "randomized inter-file pause target self-test", st_pause}, {"relative", " ", "relative link between two paths", st_relative}, diff --git a/src/htswarc.c b/src/htswarc.c index 78debda4..9f23b816 100644 --- a/src/htswarc.c +++ b/src/htswarc.c @@ -462,22 +462,6 @@ static void warc_make_id(warc_writer *w, char out[64]) { b[11], b[12], b[13], b[14], b[15]); } -static void warc_now_iso8601(char out[32]) { - time_t t = time(NULL); - struct tm tmv; -#if defined(_WIN32) - struct tm *g = gmtime(&t); - if (g != NULL) - tmv = *g; - else - memset(&tmv, 0, sizeof(tmv)); -#else - if (gmtime_r(&t, &tmv) == NULL) - memset(&tmv, 0, sizeof(tmv)); -#endif - strftime(out, 32, "%Y-%m-%dT%H:%M:%SZ", &tmv); -} - /* Case-insensitive "is this the header named name?" test, tolerating optional whitespace before the ':' (non-compliant "Name : value" is still matched). */ static int header_is(const char *line, size_t line_len, const char *name) { @@ -1063,7 +1047,7 @@ static void warc_wacz_package(warc_writer *w) { wbuf_free(&pages); /* datapackage.json listing every stored file with its sha256 + size. */ - warc_now_iso8601(created); + hts_now_iso8601(created); memset(&dp, 0, sizeof(dp)); if (!err && (wbuf_printf(&dp, @@ -1166,7 +1150,7 @@ static int warc_emit(warc_writer *w, const char *type, const char *content_type, memset(&hdr, 0, sizeof(hdr)); warc_make_id(w, id); - warc_now_iso8601(date); + hts_now_iso8601(date); #if HTS_USEOPENSSL /* Block digest over the whole block, in one streaming pass. */ @@ -1670,7 +1654,7 @@ int warc_write_transaction(warc_writer *w, const char *target_uri, char date[32]; char *slot; size_t need; - warc_now_iso8601(date); + hts_now_iso8601(date); need = strlen(target_uri) + 1 + strlen(date) + 1; slot = malloct(need); if (slot != NULL) { diff --git a/src/libhttrack.vcxproj b/src/libhttrack.vcxproj index 86256d81..a5c2a867 100644 --- a/src/libhttrack.vcxproj +++ b/src/libhttrack.vcxproj @@ -138,6 +138,7 @@ + diff --git a/tests/01_engine-changes.test b/tests/01_engine-changes.test new file mode 100644 index 00000000..28953d7e --- /dev/null +++ b/tests/01_engine-changes.test @@ -0,0 +1,30 @@ +#!/bin/bash +# + +set -euo pipefail + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_changes_st.XXXXXX") || exit 1 +trap 'rm -rf "$tmpdir"' EXIT HUP INT QUIT PIPE TERM + +# No pipe into grep: SIGPIPE would mask a failing exit status. +expect_ok() { + local label="$1" out + shift + out=$("$@" 2>&1) || { + echo "FAIL: ${label} exited non-zero: ${out}" + exit 1 + } + case "$out" in + *"${label}: OK"*) ;; + *) + echo "FAIL: ${out}" + exit 1 + ;; + esac +} + +# --changes bucket accounting and JSON escaping (#714). +expect_ok "changes self-test" httrack -O "${tmpdir}/o1" -#test=changes run +# The report must hold against a transfer thread the crawl never joins. +expect_ok "changes-race self-test" httrack -O "${tmpdir}/o2" \ + -#test=changes-race "${tmpdir}/race" diff --git a/tests/90_webhttrack-checkbox-clear.test b/tests/90_webhttrack-checkbox-clear.test index 281cb9e9..c07bc2b2 100755 --- a/tests/90_webhttrack-checkbox-clear.test +++ b/tests/90_webhttrack-checkbox-clear.test @@ -150,6 +150,7 @@ BOXES = [ ("toler", "TolerantRequests", "--tolerant", None), ("http10", "HTTP10", "--http-10", None), ("warc", "Warc", "--warc", None), + ("changes", "Changes", "--changes", None), ("norecatch", "NoRecatch", "--do-not-recatch", None), ("logf", "Log", "--single-log", None), ("index", "Index", None, None), diff --git a/tests/93_local-changes.test b/tests/93_local-changes.test new file mode 100644 index 00000000..ba465bb3 --- /dev/null +++ b/tests/93_local-changes.test @@ -0,0 +1,246 @@ +#!/bin/bash +# --changes (#714): the report must match the mirror's real deltas. Every route +# answers 200 with no validators on both passes, so the transfer signal alone +# would call the whole site changed. +set -euo pipefail + +: "${top_srcdir:=..}" +testdir=$(cd "$(dirname "$0")" && pwd) +# shellcheck source=tests/testlib.sh +. "${testdir}/testlib.sh" +server=$(nativepath "${testdir}/local-server.py") +root=$(nativepath "${testdir}/server-root") + +python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77 + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_changes.XXXXXX") || exit 1 +serverpid= +cleanup() { + stop_server "$serverpid" + rm -rf "$tmpdir" +} +trap cleanup EXIT HUP INT QUIT PIPE TERM + +serverlog="${tmpdir}/server.log" +: >"$serverlog" +"$python" "$server" --root "$root" >"$serverlog" 2>&1 & +serverpid=$! +port= +for _ in $(seq 1 50); do + line=$(head -n1 "$serverlog" 2>/dev/null) + if test "${line%% *}" == "PORT"; then + port="${line#PORT }" + break + fi + kill -0 "$serverpid" 2>/dev/null || { + echo "server exited early: $(cat "$serverlog")" + exit 1 + } + sleep 0.1 +done +test -n "$port" || { + echo "could not discover server port" + exit 1 +} +base="http://127.0.0.1:${port}" + +which httrack >/dev/null || { + echo "could not find httrack" + exit 1 +} +out="${tmpdir}/crawl" +mkdir "$out" +report="${out}/hts-changes.json" +# Purging off for the first two passes: the deleted set must be computed with +# nothing acting on it. +common=(--quiet --disable-security-limits --robots=0 --timeout=30 --changes --retries=2) + +# Report field extractor: a scalar, and the files listed under a bucket. +field() { sed -n 's/.*"'"$1"'": \([a-z0-9]*\).*/\1/p' "${2:-$report}" | head -1; } +# tr: python prints CRLF on Windows, and the expectations below are LF. +listed() { + "$python" - "${2:-$report}" "$1" <<'EOF' | tr -d '\r' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as fp: + report = json.load(fp) +for entry in report[sys.argv[2]]: + print(entry["file"]) +EOF +} +expect() { + local label="$1" want="$2" got="$3" rep="${4:-$report}" + printf '[%s] ..\t' "$label" + test "$want" == "$got" || { + echo "FAIL: expected '${want}', got '${got}'" + cat "$rep" + exit 1 + } + echo "OK" +} +# Every count must equal the length of the list it counts. +expect_counts_match() { + local label="$1" rep="${2:-$report}" bucket n + printf '[%s] ..\t' "$label" + for bucket in new changed unchanged gone; do + n=$(listed "$bucket" "$rep" | grep -c . || true) + test "$(field "$bucket" "$rep")" == "$n" || { + echo "FAIL: ${bucket} count $(field "$bucket" "$rep") but $n listed" + cat "$rep" + exit 1 + } + done + echo "OK" +} +lines() { printf '%s\n' "$@" | sort; } +# A connection killed before the status line surfaces differently per platform +# (macOS truncates the file, #748; Linux leaves it), so reset.bin is asserted on +# its own and kept out of the exact lists. +listed_but_reset() { listed "$1" | grep -v '/reset\.bin$' | sort; } + +# --- pass 1: nothing to compare against -------------------------------------- +httrack "${common[@]}" -O "$out" --purge-old=0 "${base}/changes/index.html" \ + >"${tmpdir}/log1" 2>&1 +test -s "$report" || { + echo "FAIL: pass 1 wrote no ${report}" + exit 1 +} +expect "pass 1 declares a first crawl" "true" "$(field first_crawl)" +# Every mirrored file: index, stable.html, moved.html, stable.bin, moved.bin, +# doomed.html, redirtarget.html, flaky.bin, coded.bin, codedstable.bin, +# reset.bin, sized.html and the redirect target it points at. +expect "pass 1 counts 13 new" "13" "$(field new)" +expect "pass 1 counts 0 changed" "0" "$(field changed)" +expect "pass 1 counts 0 gone" "0" "$(field gone)" +expect_counts_match "pass 1 counts match its lists" +grep -aq "first crawl" "${out}/hts-log.txt" || { + echo "FAIL: pass 1 log carries no first-crawl summary" + exit 1 +} + +host="127.0.0.1_${port}" +# A leftover from some earlier failed attempt, at a name pass 2 fetches for the +# first time: on disk, but never part of the previous mirror, so it is new. +printf 'leftover junk' >"${out}/${host}/changes/fresh.html" + +# --- pass 2: two bodies move, pages appear and disappear --------------------- +httrack "${common[@]}" -O "$out" --purge-old=0 --update \ + "${base}/changes/index.html" >"${tmpdir}/log2" 2>&1 +expect "pass 2 is not a first crawl" "false" "$(field first_crawl)" + +# The redirect behind sized.html now points at a much longer target name. +long="${host}/changes/$(printf 's%.0s' $(seq 40)).html" +expect "new is the two fresh pages and the renamed redirect target" \ + "$(lines "${host}/changes/fresh.html" "${host}/changes/transient.html" "$long")" \ + "$(listed new | sort)" +expect "gone is doomed.html and the old redirect target" \ + "$(lines "${host}/changes/doomed.html" "${host}/changes/s.html")" \ + "$(listed gone | sort)" +# index.html changes because its link list does; moved.* change their bodies. +# coded.bin arrives gzipped and direct-to-disk, so its previous copy has to be +# sampled before the decoded temp is renamed over it; it is the same length on +# both passes, so only a digest separates it from codedstable.bin. +expect "changed is exactly the four moved resources" \ + "$(lines "${host}/changes/coded.bin" "${host}/changes/index.html" \ + "${host}/changes/moved.bin" "${host}/changes/moved.html")" \ + "$(listed_but_reset changed)" +# Re-served byte for byte, 200, no Last-Modified, no ETag. flaky.bin gets there +# through a failed transfer and a retry, so its file is notified twice; +# redirtarget.html arrives behind a 302. sized.html has a byte-identical +# payload, but the rewritten link to the renamed redirect target makes the file +# on disk change length. +expect "unchanged is exactly the six stable resources" \ + "$(lines "${host}/changes/codedstable.bin" "${host}/changes/flaky.bin" \ + "${host}/changes/redirtarget.html" "${host}/changes/sized.html" \ + "${host}/changes/stable.bin" "${host}/changes/stable.html")" \ + "$(listed_but_reset unchanged)" +# reset.bin never completes a transfer, so it drops out of new.lst with its +# mirrored copy still there. Whatever else it is, it is not a deletion. +expect "a failed re-fetch is never reported gone" "" \ + "$(listed gone | grep '/reset\.bin$' || true)" +expect_counts_match "pass 2 counts match its lists" + +# Every mirrored file appears once and only once, across all four buckets. +printf '[each file in exactly one bucket] ..\t' +dupes=$( ( + listed new + listed changed + listed unchanged + listed gone +) | sort | uniq -d) +test -z "$dupes" || { + echo "FAIL: listed in more than one bucket: ${dupes}" + exit 1 +} +echo "OK" + +# The log summary is the report, rendered: it must not drift from the counts. +summary="Change report: $(field new) new, $(field changed) changed, $(field unchanged) unchanged, $(field gone) gone (hts-changes.json)" +printf '[the log summary matches the counts] ..\t' +grep -aqF "$summary" "${out}/hts-log.txt" || { + echo "FAIL: no '${summary}' in the log" + grep -a "Change report" "${out}/hts-log.txt" || true + exit 1 +} +echo "OK" + +expect "the report says nothing was purged" "false" "$(field purged)" +printf '[reporting gone did not delete it] ..\t' +test -f "${out}/${host}/changes/doomed.html" || { + echo "FAIL: doomed.html was deleted with --purge-old=0" + exit 1 +} +echo "OK" +printf '[a failed transfer keeps its file] ..\t' +test -f "${out}/${host}/changes/reset.bin" || { + echo "FAIL: reset.bin lost its previous copy" + exit 1 +} +echo "OK" + +# --- pass 3: the same run with purging on ------------------------------------ +httrack "${common[@]}" -O "$out" --update "${base}/changes/index.html" \ + >"${tmpdir}/log3" 2>&1 +expect "the report says files were purged" "true" "$(field purged)" +expect "gone is the page that only pass 2 linked" \ + "${host}/changes/transient.html" "$(listed_but_reset gone)" +printf '[a purged file is off disk] ..\t' +test ! -f "${out}/${host}/changes/transient.html" || { + echo "FAIL: transient.html survived --purge-old" + exit 1 +} +echo "OK" + +# --- cache off: the report degrades, it does not lie ------------------------- +nocache="${tmpdir}/nocache" +mkdir "$nocache" +ncreport="${nocache}/hts-changes.json" +for pass in 1 2; do + httrack "${common[@]}" -O "$nocache" -C0 "${base}/changes2/index.html" \ + >"${tmpdir}/lognc${pass}" 2>&1 +done +expect "with no cache first_crawl is undetermined, not guessed" "null" \ + "$(field first_crawl "$ncreport")" "$ncreport" +# The bodies on disk are still compared, so a constant one is not called +# changed. index.html is: a parsed page is compared against the cached payload, +# and there is no cache, which is the degradation the report declares. +expect "the constant body is still recognised as unchanged" \ + "${host}/changes2/stable2.bin" "$(listed unchanged "$ncreport")" "$ncreport" +expect "the moving body is still recognised as changed" \ + "$(lines "${host}/changes2/index.html" "${host}/changes2/ticker2.bin")" \ + "$(listed changed "$ncreport" | sort)" "$ncreport" +expect_counts_match "cache-off counts match its lists" "$ncreport" +printf '[every entry carries its size] ..\t' +"$python" - "$ncreport" <<'EOF' || exit 1 +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as fp: + report = json.load(fp) +for bucket in ("new", "changed", "unchanged"): + for entry in report[bucket]: + if "size" not in entry: + sys.exit("FAIL: %s has no size in %s" % (entry["file"], bucket)) +EOF +echo "OK" diff --git a/tests/Makefile.am b/tests/Makefile.am index 8b67a253..3d5d3600 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -30,6 +30,7 @@ TEST_EXTENSIONS = .test TEST_LOG_COMPILER = $(BASH) TESTS = \ 00_runnable.test \ + 01_engine-changes.test \ 01_engine-charset.test \ 01_engine-cmdline.test \ 01_engine-cmdline-split.test \ @@ -189,6 +190,7 @@ TESTS = \ 89_webhttrack-error-overflow.test \ 90_webhttrack-checkbox-clear.test \ 91_webhttrack-directory.test \ - 92_local-proxytrack-ndx-fields.test + 92_local-proxytrack-ndx-fields.test \ + 93_local-changes.test CLEANFILES = check-network_sh.cache diff --git a/tests/local-server.py b/tests/local-server.py index b0c15517..c3de826d 100755 --- a/tests/local-server.py +++ b/tests/local-server.py @@ -1571,6 +1571,167 @@ def route_maxrecv_404(self): if self.command != "HEAD": self.wfile.write(body) + # --changes (#714). Every route answers 200 with no validators, so the + # transfer signal alone would call the whole site changed on pass 2; only a + # payload comparison can tell stable.html and stable.bin from moved.*. + def route_changes_index(self): + links = "".join( + '\t%s\n' % (name, name) + for name in ( + "stable.html", + "moved.html", + "stable.bin", + "moved.bin", + "redir.html", + "flaky.bin", + "coded.bin", + "codedstable.bin", + "sized.html", + "reset.bin", + ) + ) + seen = self.refetch_pass() + if seen == 1: + links += '\tdoomed\n' + else: + links += '\tfresh\n' + # transient.html appears on pass 2 only, so pass 3 has a deletion of + # its own to purge. + if seen == 2: + links += '\ttransient\n' + self.send_html(links) + + def route_changes_stable(self): + self.refetch_pass() + self.send_raw(b"

    CHANGES-STABLE

    ", "text/html") + + def route_changes_moved(self): + pass1 = self.refetch_pass() == 1 + self.send_raw( + b"

    CHANGES-MOVED-V%d

    " % (1 if pass1 else 2), + "text/html", + ) + + def route_changes_stable_bin(self): + self.refetch_pass() + self.send_raw( + b"CHANGES-STABLE-BIN\n" + b"\x00\x01\x02\xff" * 512, + "application/octet-stream", + ) + + def route_changes_moved_bin(self): + pass1 = self.refetch_pass() == 1 + self.send_raw( + b"CHANGES-MOVED-BIN-V%d\n" % (1 if pass1 else 2) + + b"\x03\x02\x01\xfe" * 512, + "application/octet-stream", + ) + + def route_changes_doomed(self): + self.refetch_pass() + self.send_raw(b"

    CHANGES-DOOMED

    ", "text/html") + + def route_changes_fresh(self): + self.refetch_pass() + self.send_raw(b"

    CHANGES-FRESH

    ", "text/html") + + def route_changes_redir(self): + self.send_response(302) + self.send_header("Location", "/changes/redirtarget.html") + self.send_header("Content-Length", "0") + self.end_headers() + + def route_changes_redirtarget(self): + self.refetch_pass() + self.send_raw(b"

    CHANGES-REDIR

    ", "text/html") + + # Direct-to-disk, and the first body fetch of each pass dies mid-transfer: + # the retry notifies the same file a second time, so the accumulator has to + # keep the pre-run sample from the first notify. + FLAKY = b"CHANGES-FLAKY\n" + b"\x07\x06\x05\x04" * 512 + + # A page whose payload never changes, linking through a redirect whose + # target is renamed between passes: the rewritten link makes the file on + # disk change length while the payload is byte-identical. Only a payload + # comparison can call it unchanged. + SIZED_SHORT = "s.html" + SIZED_LONG = "s" * 40 + ".html" + + def route_changes_sized(self): + self.refetch_pass() + self.send_raw( + b't', + "text/html", + ) + + def route_changes_sizedredir(self): + target = self.SIZED_SHORT if self.refetch_pass() == 1 else self.SIZED_LONG + self.send_response(302) + self.send_header("Location", "/changes/" + target) + self.send_header("Content-Length", "0") + self.end_headers() + + def route_changes_sizedtarget(self): + self.refetch_pass() + self.send_raw(b"

    SIZED-TARGET

    ", "text/html") + + # Content-Encoding on a direct-to-disk body: the decoded temp is renamed + # over the mirror file, so the previous copy has to be sampled before the + # rename. Same length on both passes, so only a digest can tell them apart. + def route_changes_coded(self): + pass1 = self.refetch_pass() == 1 + body = b"CHANGES-CODED-V%d\n" % (1 if pass1 else 2) + b"\x11\x22" * 1024 + self.send_coded(self.gzipped(body), "application/octet-stream") + + # Control: same coding, same bytes on both passes. + def route_changes_coded_stable(self): + self.refetch_pass() + body = b"CHANGES-CODED-STABLE\n" + b"\x33\x44" * 1024 + self.send_coded(self.gzipped(body), "application/octet-stream") + + def route_changes_flaky(self): + if self.refetch_pass() % 2 == 1: + self.send_truncated(self.FLAKY, "application/octet-stream") + else: + self.send_raw(self.FLAKY, "application/octet-stream") + + def route_changes_transient(self): + self.refetch_pass() + self.send_raw( + b"

    CHANGES-TRANSIENT

    ", "text/html" + ) + + # Answers once, then hangs up before sending anything on every later + # attempt, retries included: nothing is written, so the file never reaches + # new.lst while its mirrored copy stays intact. + RESET = b"CHANGES-RESET\n" + b"\x21\x22\x23\x24" * 512 + + def route_changes_reset(self): + if self.refetch_pass() == 1: + self.send_raw(self.RESET, "application/octet-stream") + else: + self.close_connection = True + self.connection.close() + + # A second, independent mirror crawled with the cache off: nothing but the + # bytes on disk can tell stable2 from ticker2, whose body differs every + # fetch at a constant length. + def route_changes2_index(self): + self.refetch_pass() + self.send_html('\ts\n\tt\n') + + def route_changes2_stable(self): + self.refetch_pass() + self.send_raw( + b"CHANGES2-STABLE-00\n" + b"\x55\xaa" * 512, "application/octet-stream" + ) + + def route_changes2_ticker(self): + self.send_raw( + b"CHANGES2-TICKER-%02d\n" % (self.refetch_pass() % 100) + b"\x55\xaa" * 512, + "application/octet-stream", + ) + ROUTES = { "/cookies/entrance.php": route_entrance, "/cookies/second.php": route_second, @@ -1588,6 +1749,27 @@ def route_maxrecv_404(self): "/codec/bad.html": route_codec_bad, "/codec/bin.dat": route_codec_bin, "/codec/ae.html": route_codec_ae, + "/changes/index.html": route_changes_index, + "/changes/stable.html": route_changes_stable, + "/changes/moved.html": route_changes_moved, + "/changes/stable.bin": route_changes_stable_bin, + "/changes/moved.bin": route_changes_moved_bin, + "/changes/doomed.html": route_changes_doomed, + "/changes/fresh.html": route_changes_fresh, + "/changes/redir.html": route_changes_redir, + "/changes/redirtarget.html": route_changes_redirtarget, + "/changes/flaky.bin": route_changes_flaky, + "/changes/coded.bin": route_changes_coded, + "/changes/sized.html": route_changes_sized, + "/changes/sizedredir.html": route_changes_sizedredir, + "/changes/" + SIZED_SHORT: route_changes_sizedtarget, + "/changes/" + SIZED_LONG: route_changes_sizedtarget, + "/changes/codedstable.bin": route_changes_coded_stable, + "/changes/transient.html": route_changes_transient, + "/changes/reset.bin": route_changes_reset, + "/changes2/index.html": route_changes2_index, + "/changes2/stable2.bin": route_changes2_stable, + "/changes2/ticker2.bin": route_changes2_ticker, "/upcodec/index.html": route_upcodec_index, "/upcodec/mem.html": route_upcodec_mem, "/upcodec/disk.bin": route_upcodec_disk, diff --git a/tests/webhttrack-smoke.sh b/tests/webhttrack-smoke.sh index 379ecf89..dd09101d 100644 --- a/tests/webhttrack-smoke.sh +++ b/tests/webhttrack-smoke.sh @@ -46,12 +46,14 @@ cat >"$stubdir/x-www-browser" <&2 # Also fetch an option page and require a rendered title='' tooltip: proves the # option template expands and the \${html:} filter escapes into the attribute. -# option9 additionally proves the WARC control renders with its expanded label. +# option9 additionally proves the WARC and change-report controls render with +# their expanded labels. opturl="\${1%/}/server/option2.html" warcurl="\${1%/}/server/option9.html" if body="\$(curl -fsSL --max-time 20 "\$1")" && printf '%s' "\$body" | grep -qai httrack && printf '%s' "\$body" | grep -qaF step2.html && opt="\$(curl -fsSL --max-time 20 "\$opturl")" && printf '%s' "\$opt" | grep -qaF "title='" && - warc="\$(curl -fsSL --max-time 20 "\$warcurl")" && printf '%s' "\$warc" | grep -qaF 'name="warcfile"' && printf '%s' "\$warc" | grep -qaF WARC; then + warc="\$(curl -fsSL --max-time 20 "\$warcurl")" && printf '%s' "\$warc" | grep -qaF 'name="warcfile"' && printf '%s' "\$warc" | grep -qaF WARC && + printf '%s' "\$warc" | grep -qaF 'name="changes"' && printf '%s' "\$warc" | grep -qaF hts-changes.json; then echo PASS >"$marker" else echo "FAIL: unexpected response from \$1" >"$marker"