Compare commits

...

140 Commits
v0.1.1 ... main

Author SHA1 Message Date
T. von Dein
b44c1ba1dc update x/image (#124) 2025-11-05 10:13:52 +01:00
b6350344be fix image links 2025-11-05 08:56:20 +01:00
e5ff2bbc5c fix changelog link 2025-11-05 08:45:12 +01:00
T. von Dein
230bbf3d53 migrate to codeberg (#123) 2025-11-05 08:35:00 +01:00
dependabot[bot]
b3b1aac63e Bump github.com/spf13/pflag from 1.0.9 to 1.0.10 (#170) 2025-10-02 22:42:45 +02:00
dependabot[bot]
ef68a8f36b Bump golang.org/x/image from 0.30.0 to 0.31.0 (#169) 2025-10-02 22:42:25 +02:00
dependabot[bot]
734d94291b Bump docker/login-action from 3.5.0 to 3.6.0 (#167) 2025-10-02 22:42:05 +02:00
dependabot[bot]
f90158c1e1 Bump github.com/knadh/koanf/v2 from 2.2.2 to 2.3.0 (#171) 2025-10-02 22:39:43 +02:00
dependabot[bot]
09184861e3 Bump golang.org/x/sync from 0.16.0 to 0.17.0 (#168) 2025-10-02 22:38:37 +02:00
dependabot[bot]
2a520c648d Bump actions/setup-go from 5 to 6 (#166) 2025-10-02 22:38:17 +02:00
287b9995fe update go to 1.24 2025-10-02 22:35:13 +02:00
T.v.Dein
0b53e95e5e Enhancements and various Fixes (#165)
* fix expire data calculation, fixes #162
* add shipping price, if any. fixes #164
* add gh-dash config
* add logo source
* fix #163: used invalid key for type attribute, fixed switch
2025-09-03 22:18:22 +02:00
dependabot[bot]
e95bdadb12 Bump github.com/jarcoal/httpmock from 1.4.0 to 1.4.1 (#160) 2025-09-03 22:15:07 +02:00
dependabot[bot]
9959ad2468 Bump actions/checkout from 3 to 5 (#159) 2025-09-03 22:13:32 +02:00
dependabot[bot]
8da83085c8 Bump golang.org/x/image from 0.26.0 to 0.30.0 (#158) 2025-09-03 22:12:51 +02:00
dependabot[bot]
fdbec2f393 Bump github.com/spf13/pflag from 1.0.6 to 1.0.9 (#157) 2025-09-03 22:12:20 +02:00
dependabot[bot]
25bf602bba Bump github.com/go-viper/mapstructure/v2 from 2.3.0 to 2.4.0 (#156) 2025-09-03 22:11:29 +02:00
dependabot[bot]
5bfabe8fd8 Bump github.com/knadh/koanf/v2 from 2.2.1 to 2.2.2 (#153) 2025-09-03 22:11:03 +02:00
dependabot[bot]
063aa8ba86 Bump docker/login-action from 3.4.0 to 3.5.0 (#161) 2025-09-03 22:10:04 +02:00
T.v.Dein
0654150d83 bump version (#152) 2025-07-29 12:16:50 +02:00
dependabot[bot]
519d6f350a Bump github.com/go-viper/mapstructure/v2 from 2.2.1 to 2.3.0 (#147) 2025-07-29 12:15:31 +02:00
dependabot[bot]
3c071d0447 Bump golang.org/x/sync from 0.14.0 to 0.15.0 (#150) 2025-07-29 12:15:19 +02:00
dependabot[bot]
cd9d79eda7 Bump github.com/lmittmann/tint from 1.1.1 to 1.1.2 (#148) 2025-07-29 12:08:27 +02:00
dependabot[bot]
89b7f551d8 Bump github.com/knadh/koanf/providers/file from 1.1.2 to 1.2.0 (#151) 2025-07-29 12:05:49 +02:00
dependabot[bot]
85bfb0be00 Bump github.com/knadh/koanf/v2 from 2.2.0 to 2.2.1 (#149) 2025-07-29 12:05:33 +02:00
T.v.Dein
212c338ec9 update dependencies and user agent version (#146) 2025-06-10 16:13:32 +02:00
dd45dc996b ifix go version 2025-05-04 20:40:29 +02:00
1d5aa50423 fix go versions 2025-05-04 12:36:37 +02:00
T.v.Dein
15b2e7f2a7 update go, dependencies, catch Fprint and Close errors (#138)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2025-05-04 12:05:59 +02:00
T.v.Dein
4030d04b06 Add year,month,day support to Adnametemplate (#123)
* add year, month and day to adnametemplate as well
2025-02-27 17:58:05 +01:00
eff0af0b34 pie only on linux 2025-02-19 18:08:15 +01:00
34b1ad9d1e remove symbols and crap from released binaries 2025-02-19 18:01:05 +01:00
T.v.Dein
6675c4d232 Fix/timeformat (#122)
* Fix #121: confused day with month thanks to time.Format
* Add outdir template variable example
2025-02-10 22:20:25 +01:00
T.v.Dein
46be48af38 Generic attributes (#120)
* fix #117: use a generic attribute parser, still support fixed attrs
2025-02-10 18:20:54 +01:00
T.v.Dein
09948a6b39 add color detail as well (#119)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2025-02-06 20:13:08 +01:00
T.v.Dein
bc01391872 Fix ad condition parsing (#118)
* fix #117: use details slice and pre-set to properly extract condition
* also added the type part of the detail content (original de: "Art")

---------

Co-authored-by: Thomas von Dein <tom@vondein.org>
2025-02-06 13:48:20 +01:00
cd3d00adbe add changelog builder, update release builder 2025-02-05 17:54:47 +01:00
528ecdd43d updated deps 2025-02-01 18:04:25 +01:00
dependabot[bot]
5cb928518d Bump docker/build-push-action from 6.10.0 to 6.13.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.10.0 to 6.13.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](48aba3b46d...ca877d9245)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 18:03:43 +01:00
dependabot[bot]
d8c7409c7a Bump github.com/lmittmann/tint from 1.0.6 to 1.0.7
Bumps [github.com/lmittmann/tint](https://github.com/lmittmann/tint) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/lmittmann/tint/releases)
- [Commits](https://github.com/lmittmann/tint/compare/v1.0.6...v1.0.7)

---
updated-dependencies:
- dependency-name: github.com/lmittmann/tint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 18:03:33 +01:00
dependabot[bot]
1cd6eb5134 Bump github.com/spf13/pflag from 1.0.5 to 1.0.6
Bumps [github.com/spf13/pflag](https://github.com/spf13/pflag) from 1.0.5 to 1.0.6.
- [Release notes](https://github.com/spf13/pflag/releases)
- [Commits](https://github.com/spf13/pflag/compare/v1.0.5...v1.0.6)

---
updated-dependencies:
- dependency-name: github.com/spf13/pflag
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 18:03:11 +01:00
9e2983a85c build release binaries using ci workflow 2025-01-18 10:53:41 +01:00
6eddd08e4a added warning about security update 2025-01-17 12:22:20 +01:00
34dfc25e87 bump version 2025-01-17 12:00:21 +01:00
dependabot[bot]
0bc6a0ae59 Bump github.com/lmittmann/tint from 1.0.5 to 1.0.6
Bumps [github.com/lmittmann/tint](https://github.com/lmittmann/tint) from 1.0.5 to 1.0.6.
- [Release notes](https://github.com/lmittmann/tint/releases)
- [Commits](https://github.com/lmittmann/tint/compare/v1.0.5...v1.0.6)

---
updated-dependencies:
- dependency-name: github.com/lmittmann/tint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 18:44:09 +01:00
dependabot[bot]
14c554563a Bump golang.org/x/sync from 0.9.0 to 0.10.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.9.0 to 0.10.0.
- [Commits](https://github.com/golang/sync/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 18:43:57 +01:00
475a9a2fd3 add test files in other formats 2025-01-06 18:42:19 +01:00
fbd9a5a621 make clear that this is not the complete filename 2025-01-06 18:42:19 +01:00
7014c97dee added image formats test 2025-01-06 18:42:19 +01:00
91edfeb19a support hashing with all formats 2025-01-06 18:42:19 +01:00
3b3435515c check seek err 2025-01-06 18:42:19 +01:00
2239a83f76 properly check image format for storing and distance hashing 2025-01-06 18:42:19 +01:00
31b27beee5 fix error handling when trying to open files 2024-12-13 11:23:24 +01:00
dependabot[bot]
a4be51f498 Bump docker/build-push-action from 6.9.0 to 6.10.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.9.0 to 6.10.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](4f58ea7922...48aba3b46d)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 11:11:31 +01:00
dependabot[bot]
6b5af984cc Bump golang.org/x/sync from 0.8.0 to 0.9.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.8.0 to 0.9.0.
- [Commits](https://github.com/golang/sync/compare/v0.8.0...v0.9.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 11:09:28 +01:00
dependabot[bot]
f5d3853388 Bump github.com/knadh/koanf/v2 from 2.1.1 to 2.1.2
Bumps [github.com/knadh/koanf/v2](https://github.com/knadh/koanf) from 2.1.1 to 2.1.2.
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v2.1.1...v2.1.2)

---
updated-dependencies:
- dependency-name: github.com/knadh/koanf/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 11:08:36 +01:00
07ebd7afad bump version 2024-11-01 12:21:04 +01:00
dependabot[bot]
5e82881b69 Bump github.com/knadh/koanf/providers/file from 1.1.0 to 1.1.2
Bumps [github.com/knadh/koanf/providers/file](https://github.com/knadh/koanf) from 1.1.0 to 1.1.2.
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v1.1.0...providers/file/v1.1.2)

---
updated-dependencies:
- dependency-name: github.com/knadh/koanf/providers/file
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-01 12:20:45 +01:00
c9a75e3f91 bump version 2024-10-02 10:52:24 +02:00
dependabot[bot]
2fd2028cbe Bump github.com/knadh/koanf/providers/env from 0.1.0 to 1.0.0
Bumps [github.com/knadh/koanf/providers/env](https://github.com/knadh/koanf) from 0.1.0 to 1.0.0.
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v0.1.0...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/knadh/koanf/providers/env
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 10:49:44 +02:00
dependabot[bot]
766f35d1d5 Bump docker/build-push-action from 6.7.0 to 6.9.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.7.0 to 6.9.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](5cd11c3a4c...4f58ea7922)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 10:49:17 +02:00
e4edde082b bump version 2024-09-25 15:43:15 +02:00
dependabot[bot]
6b95a01591 Bump github.com/lmittmann/tint from 1.0.4 to 1.0.5
Bumps [github.com/lmittmann/tint](https://github.com/lmittmann/tint) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/lmittmann/tint/releases)
- [Commits](https://github.com/lmittmann/tint/compare/v1.0.4...v1.0.5)

---
updated-dependencies:
- dependency-name: github.com/lmittmann/tint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:41:03 +02:00
dependabot[bot]
2f66758c9f Bump github.com/tlinden/yadu from 0.1.2 to 0.1.3
Bumps [github.com/tlinden/yadu](https://github.com/tlinden/yadu) from 0.1.2 to 0.1.3.
- [Release notes](https://github.com/tlinden/yadu/releases)
- [Commits](https://github.com/tlinden/yadu/compare/v0.1.2...v0.1.3)

---
updated-dependencies:
- dependency-name: github.com/tlinden/yadu
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:40:20 +02:00
dependabot[bot]
f21b47e14f Bump golang.org/x/sync from 0.5.0 to 0.8.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.5.0 to 0.8.0.
- [Commits](https://github.com/golang/sync/compare/v0.5.0...v0.8.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:37:23 +02:00
dependabot[bot]
14f1c89f05 Bump github.com/knadh/koanf/v2 from 2.0.1 to 2.1.1
Bumps [github.com/knadh/koanf/v2](https://github.com/knadh/koanf) from 2.0.1 to 2.1.1.
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v2.0.1...v2.1.1)

---
updated-dependencies:
- dependency-name: github.com/knadh/koanf/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:36:55 +02:00
dependabot[bot]
b7530843c5 Bump github.com/knadh/koanf/providers/file from 0.1.0 to 1.1.0
Bumps [github.com/knadh/koanf/providers/file](https://github.com/knadh/koanf) from 0.1.0 to 1.1.0.
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v0.1.0...v1.1.0)

---
updated-dependencies:
- dependency-name: github.com/knadh/koanf/providers/file
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:23:50 +02:00
dependabot[bot]
44c5e40466 Bump docker/login-action from 1.10.0 to 3.3.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 1.10.0 to 3.3.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](f054a8b539...9780b0c442)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:22:39 +02:00
dependabot[bot]
7b4dd50ebf Bump docker/build-push-action from 2.5.0 to 6.7.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2.5.0 to 6.7.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](ad44023a93...5cd11c3a4c)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:21:43 +02:00
dependabot[bot]
5a6fe0f2fe Bump golang.org/x/net from 0.0.0-20220722155237-a158d28d115b to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.0.0-20220722155237-a158d28d115b to 0.23.0.
- [Commits](https://github.com/golang/net/commits/v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 15:18:58 +02:00
d7f5fbbe58 update other actions as well 2024-09-25 15:13:41 +02:00
ee550b1ed7 use normal Error() func 2024-09-25 15:08:34 +02:00
e35815f27f Update golang-ci linter 2024-09-25 15:05:44 +02:00
9138770d6e added dependabot config 2024-09-25 14:56:47 +02:00
834fbcd9c5 check return of ReadString() 2024-04-26 13:42:28 +02:00
4c10ae89f8 bump version 2024-04-26 13:42:28 +02:00
df6fc47ca3 fix #88: respond accordingly when user double clicks kleingebaeck.exe 2024-04-26 13:42:28 +02:00
5956a68e72 minor critics corrected 2024-04-26 13:42:28 +02:00
eee0167574 added 2024-04-26 13:42:28 +02:00
a3b2748479 fix #87: document image distance hash 2024-04-26 13:42:28 +02:00
8cc5a9e3ed missed commits 2024-02-12 13:36:29 +01:00
d2bcd7b505 fix #80: using os.MkdirAll():
Recursively create ad dir including output dir. The output dir itself
is not being created separately anymore. That way, no directory will
be created if no ads could be downloaded.
2024-02-12 13:32:25 +01:00
c59c2e2931 fix #81: add arm64 build support 2024-02-12 13:32:25 +01:00
2288806105 fix #77: use processed ad dir for duplicate checking, not slug 2024-02-10 15:15:43 +01:00
5a2c277f0e fix #71 and #73: add support for outdir template and enhance docs 2024-02-10 14:44:09 +01:00
612ed2aa79 fix #74: warn if about to write to already visited ad, overwrite if -f 2024-02-10 14:44:09 +01:00
ed78731b3c check seek error 2024-01-27 17:34:44 +01:00
a84f0e1436 get rid of duplicate bytes.Buffer, use bytes.Reader instead, #39 2024-01-27 17:34:44 +01:00
d8d5be5c7d fix #58: add missing dashes to self issue template 2024-01-27 17:34:44 +01:00
bcf920c91e correct #39 add --ignoreerrors flag 2024-01-27 17:34:44 +01:00
T.v.Dein
14f8c3fd43 Fix/linter (#66)
* added lint targets
* fix linter errors
* enhance error handling
* !!BREAKING!! rename Id to ID in tpls
2024-01-25 19:04:15 +01:00
9cd1fc0596 behavior changes: UserAgent configurable, test cookies, check errors 2024-01-24 19:22:31 +01:00
8df3ebfa6d add throttling to image download 2024-01-24 19:22:31 +01:00
de82127223 first step in fixing #49:
fetch cookies from 1st response and use them in subsequent requests.
2024-01-24 19:22:31 +01:00
a79a28f4a1 add contribution guidelines and non-code-of-conduct 2024-01-23 18:01:14 +01:00
95b1172b7f fix typo 2024-01-23 17:26:06 +01:00
6f3954f1c0 added issue templates and fix make show-versions 2024-01-23 14:20:33 +01:00
a465bbfaa8 Added german README, update docker docs, add latest image tag 2024-01-23 14:20:33 +01:00
ddae6ed8be bump version (feature update) 2024-01-22 18:43:44 +01:00
8cced1f9e1 Fix #47: add mock http server for testing w/o hitting prod site 2024-01-22 18:43:44 +01:00
3245438564 update yadu to v0.1.1, fix #46 2024-01-22 18:43:44 +01:00
71c528114f fix linter error 2024-01-22 18:43:44 +01:00
e2afc1350b added -f to override d-hash, better debug and error handling 2024-01-22 18:43:44 +01:00
e971070f9f added image diff hash distance caching to not overwrite similar images 2024-01-22 18:43:44 +01:00
T.v.Dein
8771ec1108 added support to calculate and store the ad expire date (#43) 2024-01-19 18:38:35 +01:00
T.v.Dein
1896209b96 use new yadu log handler, +tests, +upd modules, +version 2024-01-19 18:38:35 +01:00
T.v.Dein
3c93c9fce0 added docker image support
* added environment variable support
* added docker instructions
* added .env hint
2024-01-19 18:38:35 +01:00
T.v.Dein
42a958fc4c refactored out http fetching code into Fetcher{}/fetch.go 2024-01-19 18:38:35 +01:00
T.v.Dein
5fa46ff106 Add HTTP retries and the possibility to ignore image download errors (#33)
added HTTP retry and --ignoreerrors which ignores image download errors, fix #30
2024-01-19 18:38:35 +01:00
T.v.Dein
cca3211023 Enhancement/http (#32)
* added HTTP debug logging using `-d` or `DEBUGHTTP=1` (headers only)
2024-01-19 18:38:35 +01:00
T.v.Dein
dce7604afb fix #30: revert default adnamedir to just use the slug as before (#31) 2024-01-19 18:38:35 +01:00
0fd9b519d1 fixed changes on kleinanzeigen.de:
- Meta did not contain condition and category together anymore, they
removed  the category. Therefore fetching (that is, validation)
failed.
- Now we extract the condition and category directly.
- On top, category now includes the whole category tree.
- unit tests had to be tweaked for this measure.
2024-01-19 18:38:35 +01:00
6b7f727449 fixed utf8 2024-01-19 18:38:35 +01:00
5abbab9527 added template for ad directory, by default include id now 2024-01-19 18:38:35 +01:00
T.v.Dein
e03c7debb6 remove duplicate license badge (#28)
* remove duplicate license badge

* fix badges
2024-01-19 18:38:35 +01:00
1d2483d18f portable error check 2024-01-19 18:38:35 +01:00
b17f4f0f3e also added coverage report+badge 2024-01-19 18:38:35 +01:00
4a91167871 put all tests into main_test.go, more failure mode tests and verify 2024-01-19 18:38:35 +01:00
0baaf6f38b better error message on 404 2024-01-19 18:38:35 +01:00
42182bb6c9 add commandline main() test units 2024-01-19 18:38:35 +01:00
8455c193eb pass a io.Writer to loggers and outputs so we can test the cmdline 2024-01-19 18:38:35 +01:00
d1faa10a52 added more invalid tests 2024-01-19 18:38:35 +01:00
e28137bf9b upd httpmock+deps 2024-01-19 18:38:35 +01:00
1ff5c240c8 put ad code into separate file, enhance error checking 2024-01-19 18:38:35 +01:00
T.v.Dein
f893f9c3d7 Test/add mock tests (#24)
* add scrape unit test using httpmock lib
2024-01-19 18:38:35 +01:00
T.v.Dein
c4e88d98f2 fix linter errors (#23) 2024-01-19 18:38:35 +01:00
T.v.Dein
0cca387982 add ci pipeline (#22)
Co-authored-by: Thomas von Dein <tom@izb.net>
2024-01-19 18:38:35 +01:00
T.v.Dein
9e619fb3c5 Doc/add prior art (#21)
* add mor prior art
2024-01-19 18:38:35 +01:00
T.v.Dein
0fdfed2929 added windows screenshots (#20)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
T.v.Dein
73c09ec38b Revert "Fix/newline windows (#18)" (#19)
This reverts commit eaf4db6cef.
2024-01-19 18:38:35 +01:00
T.v.Dein
f901af4f0c Fix/newline windows (#18)
* fix #17: use fmt.Println() after stats
* bump version
2024-01-19 18:38:35 +01:00
T.v.Dein
2a8f53ca98 added screenshots and a section about prior work (#16) 2024-01-19 18:38:35 +01:00
T.v.Dein
4a95cb1f5e add doc link (#15)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
T.v.Dein
482612f889 fix invalid mod load (#14)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
T.v.Dein
b8977df986 Bugfixes (#13)
* several fixes:

- fix #9 + #10: switched to koanf module and dropped support for HCL
- fix #11: disabling colors on windows
- fix #12: fixed race condition in go routine call inside for loop,
  images had been downloaded multiple times
- remove hcl support and use toml format (same thing, better parser)
- update documentation and example config on TOML format of config file
- use Config as arg instead of singular args
- use x/errgroup instead of sync.Waitgroup inside image download loop

---------

Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
T.v.Dein
ae5e3daea3 Dev (#8)
* fixed conf parsing: variables can now be omitted from the config
* fix newlines: use CRLF on windows
* bump version

---------

Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
T.v.Dein
1c6d832b20 added proper install instructions (#7)
Co-authored-by: Thomas von Dein <tom@vondein.org>
2024-01-19 18:38:35 +01:00
52b39d91a3 fix version finding + bump version 2024-01-19 18:38:27 +01:00
3748cd35e5 fix #5: add exe extension to built windows binaries 2024-01-19 18:38:27 +01:00
4d4577c9f8 fix #4, use filepath.Join to create portable path's 2024-01-19 18:38:27 +01:00
70 changed files with 3945 additions and 407 deletions

96
.gh-dash.yml Normal file
View File

@@ -0,0 +1,96 @@
prSections:
- title: Responsible PRs
filters: repo:tlinden/kleingebaeck is:open NOT dependabot
layout:
repoName:
hidden: true
- title: Responsible Dependabot PRs
filters: repo:tlinden/kleingebaeck is:open dependabot
layout:
repoName:
hidden: true
issuesSections:
- title: Responsible Issues
filters: is:open repo:tlinden/kleingebaeck -author:@me
layout:
repoName:
hidden: true
- title: Note-to-Self Issues
filters: is:open repo:tlinden/kleingebaeck author:@me
layout:
creator:
hidden: true
repoName:
hidden: true
defaults:
preview:
open: false
width: 100
keybindings:
universal:
- key: "shift+down"
builtin: pageDown
- key: "shift+up"
builtin: pageUp
prs:
- key: g
name: gitu
command: >
cd {{.RepoPath}} && /home/scip/bin/gitu
- key: M
name: squash-merge
command: gh pr merge --rebase --squash --admin --repo {{.RepoName}} {{.PrNumber}}
- key: i
name: show ci checks
command: gh pr checks --repo {{.RepoName}} {{.PrNumber}} | glow -p
- key: e
name: edit pr
command: ~/.config/gh-dash/edit-gh-pr {{.RepoName}} {{.PrNumber}}
- key: E
name: open repo in emacs
command: emacsclient {{.RepoPath}} &
issues:
- key: v
name: view
command: gh issue view --repo {{.RepoName}} {{.IssueNumber}} | glow -p
- key: l
name: add label
command: gh issue --repo {{.RepoName}} edit {{.IssueNumber}} --add-label $(gum choose bug enhancement question dependencies wontfix)
- key: L
name: remove label
command: gh issue --repo {{.RepoName}} edit {{.IssueNumber}} --remove-label $(gum choose bug enhancement question dependencies wontfix)
- key: E
name: open repo in emacs
command: emacsclient {{.RepoPath}} &
theme:
ui:
sectionsShowCount: true
table:
compact: false
showSeparator: true
colors:
text:
primary: "#E2E1ED"
secondary: "#6770cb"
inverted: "#242347"
faint: "#b0793b"
warning: "#E0AF68"
success: "#3DF294"
background:
selected: "#1B1B33"
border:
primary: "#383B5B"
secondary: "#39386B"
faint: "#8d3e0b"
repoPaths:
:owner/:repo: ~/dev/:repo
pager:
diff: delta

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[bug-report]"
labels: bug
assignees: TLINDEN
---
**Description**
<!-- Please provide a clear and concise description of the issue: -->
**Steps To Reproduce**
<!-- Please detail the steps to reproduce the behavior, execute kleingebaeck with the -d option: -->
**Expected behavior**
<!-- What do you expected to happen instead? -->
**Version information**
<!--
Please provide as much version information as possible:
- if you have just installed a binary, provide the output of: kleingebaeck --version
- if you installed from source, provide the output of: make show-version
- provide additional details: operating system and version and shell environment
-->
**Additional informations**

32
.github/ISSUE_TEMPLATE/bug_report_de.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bugreport Deutsch
about: Erzeuge einen Bugreport
title: "[bug-report-de]"
labels: bug
assignees: TLINDEN
---
**Beschreibung**
<!-- Bitte beschreibe den Fehler klar und möglichst präzise: -->
**Schritte um den Fehler zu reproduzieren**
<!-- Bitte gib detailiert an, welche konkreten Schritte zum Fehler
geführt haben, führe kleingebaeck mit der Option -d option aus: -->
**Erwartetes Verhalten**
<!-- Welches Verhalten hast Du ursprünglich erwartet? -->
**Versionsinformation**
<!--
Bitte gib uns so viel Versionsinfos wie möglich:
- wenn Du nur das Programm installiert hast: kleingebaeck --version
- wenn Du von Source installiert hast: make show-version
- bitte gib zusätzliche Details an: Betriebssystem + Version, Shellumgebung etc.
-->
**Zusätzliche Informationen**

View File

@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest a feature
title: "[feature-request]"
labels: feature-request
assignees: TLINDEN
---
**Description**
<!-- Please provide a clear and concise description of the feature you desire: -->
**Version information**
<!--
Just in case the feature is already present, please provide as
much version information as possible:
- if you have just installed a binary, provide the output of: tablizer --version
- if you installed from source, provide the output of: make show-version
- provide additional details: operating system and version and shell environment
-->

View File

@@ -0,0 +1,20 @@
---
name: Featurerequest Deutsch
about: Empfehle ein neues Feature
title: "[feature-request-de]"
labels: feature-request
assignees: TLINDEN
---
**Beschreibung**
<!-- Bitte beschreibe das gewünschte Feature klar und möglichst präzise: -->
**Versionsinformation**
<!--
Bitte gib uns so viel Versionsinfos wie möglich:
- wenn Du nur das Programm installiert hast: kleingebaeck --version
- wenn Du von Source installiert hast: make show-version
- bitte gib zusätzliche Details an: Betriebssystem + Version, Shellumgebung etc.
-->

View File

@@ -0,0 +1,8 @@
---
name: Note to self
about: Internal bugs and wishes
title: "[bug-report]"
labels: bug
assignees: TLINDEN
---

BIN
.github/assets/adlisting-windows.jpg vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
.github/assets/cmd-windows.jpg vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
.github/assets/english.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
.github/assets/english.xcf vendored Normal file

Binary file not shown.

BIN
.github/assets/german.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
.github/assets/german.xcf vendored Normal file

Binary file not shown.

BIN
.github/assets/kleinanzeigen-ad.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

BIN
.github/assets/kleinanzeigen-backup.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
.github/assets/kleinanzeigen-index.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
.github/assets/kleingebaecklogo.xcf vendored Normal file

Binary file not shown.

BIN
.github/assets/liste-windows.jpg vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
test
kleingebaeck
releases
t/out
.bak
t/httproot/out
t/httproot/kleinanzeigen
t/httproot/favicon.ico

69
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,69 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
gitea_urls:
api: https://codeberg.org/api/v1
download: https://codeberg.org
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- freebsd
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}_{{ .Tag }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
- goos: linux
formats: [tar.gz,binary]
files:
- src: "*.md"
strip_parent: true
- src: "docs/*"
strip_parent: true
- src: Makefile.dist
dst: Makefile
wrap_in_directory: true
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
groups:
- title: Improved
regexp: '^.*?(feat|add|new)(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Fixed
regexp: '^.*?(bug|fix)(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Changed
order: 999
release:
header: "# Release Notes"
footer: >-
---
Full Changelog: [{{ .PreviousTag }}...{{ .Tag }}](https://codeberg.org/scip/kleingebaeck/compare/{{ .PreviousTag }}...{{ .Tag }})

36
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,36 @@
matrix:
platform:
- linux/amd64
goversion:
- 1.24
labels:
platform: ${platform}
steps:
build:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go build
linter:
when:
event: [push]
image: golang:${goversion}
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0
- golangci-lint --version
- golangci-lint run ./...
depends_on: [build]
test:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go test -v -cover
depends_on: [build,linter]

32
.woodpecker/image.yaml Normal file
View File

@@ -0,0 +1,32 @@
# https://woodpecker-ci.org/plugins/docker-buildx
# enable Package unit and go to /scip/-/packages after building to link to proj
variables:
- &repo codeberg.org/${CI_REPO_OWNER}/kleingebaeck
steps:
dryrun:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
settings:
dockerfile: Dockerfile
platforms: linux/amd64
dry_run: true
repo: *repo
tags: latest
when:
event: [pull_request]
publish:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
settings:
dockerfile: Dockerfile
platforms: linux/amd64
repo: *repo
registry: codeberg.org
tags: latest,${CI_COMMIT_SHA:0:8},${CI_COMMIT_TAG}
username: ${CI_REPO_OWNER}
password:
from_secret: REGISTRY_TOKEN
when:
event: [tag]
branch: main

15
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,15 @@
# build release
labels:
platform: linux/amd64
steps:
goreleaser:
image: goreleaser/goreleaser
when:
event: [tag]
environment:
GITEA_TOKEN:
from_secret: DEPLOY_TOKEN
commands:
- goreleaser release --clean --verbose

114
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,114 @@
# No Code of Conduct
*TL;DR:* This project does **NOT** have a so called Code of Conduct,
nor will it ever have one.
## The Rant
The reasons are somewhat complicated and I'll try my best to document
them here.
Ethical codes or rules come along like laws. But how is ethical or
moral behavior defined? And who defines which behavior is ethical and
which is not? Certainly not me.
Unless you live in a dictatorship (and more than half of the
population on planet earth do as of this writing), laws come into
existence by democratic procedures. Laws cover almost every aspect of
live in a society. Laws allow and forbid behavior and laws sanction
infringements.
A software project like this one on the other hand is not a society.
There are not enough people involved to form democratic
structures. And there will always be a minority of users who have the
right to commit or reject code. How could any maintainer of a software
project dare to decree rules upon others? Actually, am I, the current
maintainer of this very project authorized to do so?
I think the anser to this question clearly is NO.
The issue is being complicated by the fact, that open source
development these days happens on a planetary scale. And this planet
houses hundreds if not thousands of different cultures, philosophies,
ideologies and worldviews. The answer to many ethical questions will
in most cases be vague and nebulous.
Ones joke will always be another ones insult.
Then there is the problem of language. I myself am not an english
native, but I publish everyting using the english language. I am able
to communicate with most people in the open source community because
of that. But I am certainly not able to understand everything and
everyone. There might be nuances to a sentence I don't sense, there
might be sarcastic connotations I don't understand or references to
historical figures, events or traditions I don't know and never have
heard of.
Judging over other peoples online behavior looks like a titanic task
to me. It is just not my job to judge others, I am not legitimized or
authorized to do so and I am not interested in this kind of business.
Another huge problem with ethical rules is that you need to outline
and enforce sanctions on those who violate the rules. But since I am
not an elected authority how would I be able to do this? I don't
know. And what happens if someone complains about myself? Shall I
remove myself from my own project? Come on!
Last but not least there's the law. So, let's say someone in india
writes something insulting to some other developer in an issue. Of
course german law does not apply to indian people. Moreover, the
insult might actually not be an insult in india. In the end, nothing
would happen. Under normal circumstances, maintainers would
eventually delete the posting, ban the user or remove push privileges
etc.
But then, is there a way for the offending user to defend himself? Of
course not, since neither indian or german law alone applies. I cannot
go to a german court and sue the guy and he cannot do the same in
india. Or - we possibly could but the judges in both countries would
just laugh and close the case.
That being said, I don't have the power nor the tools, nor the
authority to enforce serious sanctions of any meaningful kind against
others. Therefore I cannot outline any rules whatsoever.
And let's not even start talking about these undemocratic "comitees"
many projects are forming to circumvent this problem. Some projects
even include external entities like a lawyer or some bureaucrat
somewhere just to have the ability to complain against a comitee
member. What a mess!
## So, what are the ethical rules within this project then?
Well, there are none.
This project is about code, not society. It doesn't matter where you
come from, how you look, how you think, what you believe, who your
friends are, whay you said or did sometime in the past. I don't even
care if you are a human being. You are an alien so bored that you need
to submit code on github? Fine with me. You're a convicted criminal? I
don't give a shit!
**The only thing I am interested here is Code and only Code.**
So if anyhing happens here I don't like or I am obliged by (german!)
law to act on, I will decide on a case to case basis what to do. And
unfortunately, since this is the nature of a github project, you
cannot complain, object or protest. I am very sorry!
If you will, let's at least outline these:
- Please - just please - behave towards others as you'd expect others
to behave towards yourself.
- Don't judge others for any reason.
- Only judge the code.
But these are not rules, only a friendly appeal to you as a developer
and user.
Thanks a lot!

93
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,93 @@
## Project Goals
The goal of this project is to build a small tool which helps in
maintaining backups of the german ad site kleinanzeigen.de. It should
be small, fast and easy to understand.
There will be no GUI, no web interface, no public API of some sort, no
builtin interpreter.
The programming language used for this project will always be
[GOLANG](https://go.dev/) with the exception of the documentation
([Perl POD](https://perldoc.perl.org/perlpod)) and the Makefile.
# Contributing
You can contribute to this project in various ways:
## Open an issue
If you encounter a problem or don't understand how the program works
or if you think the documentation is unclear, please don't hesitate to
open an issue.
Please add as much information about the case as possible, such as:
- Your environment (operating system etc)
- kleingebaeck version (`kleingebaeck --version`)
- Commandline used. Please replace sensitive information with mock data!
- Repeat the command with debugging enabled (`-d` flag)
- Actual program output, Please replace sensitive information with mock data!
- Expected program output.
- Error message - if any.
Be aware that I am working on this (and some others) project in my
spare time which is scarce. Therefore please don't expect me to
respond to your query within hours or even days. Be patient, but I
WILL respond.
## Pull Requests
Code and documentation help is always much appreciated! Please follow
thes guidelines to successfully contribute:
- Every pull request shall be based on latest `development`
branch. `main` is only used for releases.
- Execute the unit tests before committing: `make test`. There shall
be no errors.
- Strive to be backwards compatible so that users who are already
using the program don't have to change their habits - unless it is
really neccessary.
- Try to add a unit test for your fix, addition or modification.
- Don't ever change existing unit tests!
- Add a meaningful and comprehensive rationale about your contribution:
- Why do you think it might be useful for others?
- What did you actually change or add?
- Is there an open issue which this PR fixes and if so, please link
to that issue.
- [Re-]format your code with `gofmt -s`.
- Avoid unneccesary dependencies, especially for very small functions.
- **If** a new dependency is being added, it must be compatible with
our [license agreement](LICENSE).
- You need to accept that the code or documentation you contribute
will be redistributed under the terms of said license agreement. If
your contribution is considerably large or if you contribute
regularly, then feel free to add your name (and if you want your
email address) to the *AUTHORS* section of the
[manpage](kleingebaeck.pod).
- Adhere to the above mentioned project goals.
- If you are unsure if your addition or change will be accepted,
better ask before starting coding. Open an issue about your proposal
and let's discuss it! That way we avoid doing unnessesary work on
both sides.
Each pull request will be carefully reviewed and if it is a useful
addition it will be accepted. However, please be prepared that
sometimes a PR will be rejected. The reasons may vary and will be
documented. Perhaps the above guidelines are not matched, or the
addition seems to be not so useful from my perspective, maybe there
are too much changes or there might be changes I don't even
understand.
But whatever happens: your contribution is always welcome!

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM golang:1.24-alpine as builder
RUN apk update
RUN apk upgrade
RUN apk add --no-cache git make
RUN git --version
WORKDIR /work
COPY go.mod .
COPY . .
RUN go mod download
RUN make
FROM alpine:latest
LABEL maintainer="Thomas von Dein <git@daemon.de>"
WORKDIR /app
COPY --from=builder /work/kleingebaeck /app/kleingebaeck
ENV KLEINGEBAECK_OUTDIR /backup
ENV LANG C.UTF-8
USER 1001:1001
ENTRYPOINT ["/app/kleingebaeck"]
CMD ["-h"]

View File

@@ -17,7 +17,7 @@
#
# no need to modify anything below
tool = kleingebaeck
VERSION = $(shell grep VERSION main.go | head -1 | cut -d '"' -f2)
VERSION = $(shell grep VERSION config.go | head -1 | cut -d '"' -f2)
archs = darwin freebsd linux windows
PREFIX = /usr/local
UID = root
@@ -50,11 +50,21 @@ install: buildlocal
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/
clean:
rm -rf $(tool) coverage.out testdata
rm -rf $(tool) coverage.out testdata t/out
test: clean
mkdir -p t/out
go test ./... $(ARGS)
testlint: test lint
lint:
golangci-lint run
lint-full:
golangci-lint run --enable-all --exclude-use-default --disable exhaustivestruct,exhaustruct,depguard,interfacer,deadcode,golint,structcheck,scopelint,varcheck,ifshort,maligned,nosnakecase,godot,funlen,gofumpt,cyclop,noctx,gochecknoglobals,paralleltest
gocritic check -enableAll *.go
testfuzzy: clean
go test -fuzz ./... $(ARGS)
@@ -72,12 +82,12 @@ goupdate:
buildall:
./mkrel.sh $(tool) $(VERSION)
release: buildall
gh release create v$(VERSION) --generate-notes releases/*
release:
gh release create v$(VERSION) --generate-notes
show-versions: buildlocal
@echo "### kleingebaeck version:"
@./kleingebaeck -v
@./kleingebaeck -V
@echo
@echo "### go module versions:"
@@ -86,3 +96,6 @@ show-versions: buildlocal
@echo
@echo "### go version used for building:"
@grep -m 1 go go.mod
# lint:
# golangci-lint run -p bugs -p unused

20
Makefile.dist Normal file
View File

@@ -0,0 +1,20 @@
# -*-make-*-
.PHONY: install all
tool = rpn
PREFIX = /usr/local
UID = root
GID = 0
all:
@echo "Type 'sudo make install' to install the tool."
@echo "To change prefix, type 'sudo make install PREFIX=/opt'"
install:
install -d -o $(UID) -g $(GID) $(PREFIX)/bin
install -d -o $(UID) -g $(GID) $(PREFIX)/man/man1
install -d -o $(UID) -g $(GID) $(PREFIX)/share/doc
install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/
install -o $(UID) -g $(GID) -m 444 *.md $(PREFIX)/share/doc/

354
README-de.md Normal file
View File

@@ -0,0 +1,354 @@
## Kleingebäck - kleinanzeigen.de Backup
![Kleingebaeck Logo](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleingebaecklogo-small.png)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/scip/kleingebaeck)](https://goreportcard.com/report/codeberg.org/scip/kleingebaeck)
[![status-badge](https://ci.codeberg.org/api/badges/15530/status.svg)](https://ci.codeberg.org/repos/15530)
![GitHub License](https://img.shields.io/github/license/tlinden/kleingebaeck)
[![GitHub release](https://img.shields.io/github/v/release/tlinden/kleingebaeck?color=%2300a719)](https://codeberg.org/scip/kleingebaeck/releases)
[![English](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/english.png)](https://codeberg.org/scip/kleingebaeck/raw/branch/main/README.md)
Mit diesem Tool kann man seine Anzeigen bei https://kleinanzeigen.de sichern.
Es kann alle Anzeigen eines Users (oder nur eine Ausgewählte)
inklusive der Bilder herunterladen, die in einem Verzeichnis pro
Anzeige gespeichert werden. In dem Verzeichnis wird eine Datei
`Adlisting.txt` erstellt, in der sich die Inhalte der Anzeige wie
Titel, Preis, Text etc befinden. Bilder werden natürlich auch heruntergeladen.
## ACHTUNG - SICHERHEITS-UPDATE
Fertige vorcompilierte Programme älter als Version `v0.3.12` sind von
Schwachstellen in der Behandlung von HTTP und Zertifikaten
betroffen. Falls Du eine ältere Kleingebäck-Version im Einsatz hast,
bitte update auf Version `v0.3.12` oder höher. Bitte lies auch die [Release Notes für
v0.3.12](https://codeberg.org/scip/kleingebaeck/releases/tag/v0.3.12)
für mehr Details.
## Screenshots
Das ist die Hauptseite meines kleinanzeigen.de Accounts:
![Index](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-index.png)
Sichern ich meine Anzeigen:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-download.png)
Backupverzeichnis nach dem Download:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-backup.png)
Verzeichnis einer Anzeige:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-ad.png)
**Das gleiche unter Windows:**
Anzeigen Sichern:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/cmd-windows.jpg)
Backupverzeichnis nach dem Download
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/liste-windows.jpg)
Und eine Anzeige:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/adlisting-windows.jpg)
## Installation
Das Tool hat keine weiteren Abhängigkeiten und erfordert auch keine
Anmeldung oder ähnliches. Man kädt sich einfach die ausführbare Datei
für seine Plattform herunter und kann direkt loslegen.
### Installation des vorcompilierten Programms
Auf der Seite [des letzten Releases](https://codeberg.org/scip/kleingebaeck/releases) findet man das Program für sein Betriebssystem und die Plattform (z.b. Windows + Intel)
Es gibt 2 Varianten:
1. Direkt das fertige Program für seine Plattform+OS herunterladen,
z.B. `kleingebaeck-linux-amd64-0.0.5`, nach `kleingebaeck`
umbenennen und in ein Verzeichnis kopieren, das im `PATH` ist,
(z.B. nach `$HOME/bin` oder als root nach `/usr/local/bin`).
Um sicher zu gehen, dass an dem Program nicht verändert wurde, kann
man die Signatur vergleichen. Für jeden Download gibt es eine dazu
passende Signatur, in unserem Beispiel wäre das
`kleingebaeck-linux-amd64-0.0.5.sha256`.
Zum Verifizieren ausführen:
```shell
cat kleingebaeck-linux-amd64-0.0.5.sha25 && sha256sum kleingebaeck-linux-amd64-0.0.5
```
Man sollte zweimal den gleichen SHA256 Hash sehen.
2. Man kann auch einen Tarball (tgz Dateiendung) herunterladen,
auspacken und mit GNU Make installieren:
```shell
tar xvfz kleingebaeck-linux-amd64-0.0.5.tar.gz
cd kleingebaeck-linux-amd64-0.0.5
sudo make install
```
### Installation aus dem Sourcecode
Man muss eine funktionierende Go Buildumgebung in der Version 1.21
installiert haben, um das Programm selber zu compilieren. GNU Make ist
hilfreich, aber nicht unbedingt erforderlich.
Um das Programm zu compilieren, muss man folgende Schritte ausführen:
```shell
git clone https://codeberg.org/scip/kleingebaeck.git
cd kleingebaeck
go mod tidy
make # (oder make)
sudo make install
```
### Docker image benutzen
Ein fertiges Dockerimage mit der aktuellen Programmversion ist immer
verfügbar. Man kann damit z.B. das Tool testen, bevor man es dauerhaft
benutzen möchte.
Um das Image herunterzuladen:
```
docker pull ghcr.io/tlinden/kleingebaeck:latest
```
Um kleingebäck im Image auszuführen und Daten ins lokale Filesystem zu
sichern, kann man so vorgehen:
```shell
mkdir anzeigen
docker run -u `id -u $USER` -v ./anzeigen:/backup ghcr.io/tlinden/kleingebaeck:latest -u XXX -v
ls -l anzeigen/ein-buch-mit-leeren-seiten
total 792
drwxr-xr-x 2 scip root 4096 Jan 23 12:58 ./
drwxr-xr-x 3 scip scip 4096 Jan 23 12:58 ../
-rw-r--r-- 1 scip root 131650 Jan 23 12:58 1.jpg
-rw-r--r-- 1 scip root 81832 Jan 23 12:58 2.jpg
-rw-r--r-- 1 scip root 134050 Jan 23 12:58 3.jpg
-rw-r--r-- 1 scip root 1166 Jan 23 12:58 Adlisting.txt
```
Hier wird der aktuelle User auf den User im Image gemappt und das
lokale Verzeichnis `anzeigen` nach `/backup` innerhalb des Images
gemountet.
Die Optionen `-u XXX -v` sind kleingebäck Optionen. Ersetze `XXX`
durch Deine tatsächliche kleinanzeigen.de Userid.
Eine Liste verfügbarer Images findet man [hier](https://codeberg.org/scip/kleingebaeck/pkgs/container/kleingebaeck/versions?filters%5Bversion_type%5D=tagged)
## Kommandozeilen Optionen:
```
Usage: kleingebaeck [-dvVhmoc] [<ad-listing-url>,...]
Options:
-u --user <uid> Backup ads from user with uid <uid>.
-d --debug Enable debug output.
-v --verbose Enable verbose output.
-o --outdir <dir> Set output dir (default: current directory)
-l --limit <num> Limit the ads to download to <num>, default: load all.
-c --config <file> Use config file <file> (default: ~/.kleingebaeck).
--ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
-m --manual Show manual.
-h --help Show usage.
-V --version Show program version.
If one or more <ad-listing-url>'s are specified, only backup those,
otherwise backup all ads of the given user.
```
## Konfiguration
Man kann anstelle von Kommandlineoptionen auch eine
Konfigurationsdatei verwenden. Sie befindet sich standardmäßig in
`~/.kleingebaeck` aber man kann mit dem Parameter `-c` auch eine
andere Datei angeben.
Das Format (TOML) ist einfach:
```
user = 1010101
loglevel = verbose
outdir = "test"
```
Im Source gibt es eine Beispieldatei `example.conf` mit Kommentaren.
## Umgebungsvariablen
Man kann darüber hinaus auch Umgebungsvariablen verwenden. Sie
entsprechen den Konfigurationsoptionen, aber gross geschrieben mit dem
Präfix `KLEINGEBAECK_`, z.B.
```shell
% KLEINGEBAECK_OUTDIR=/backup kleingebaeck -v
```
## Benutzung
Um das Tool einsetzen zu können, muss man zunächst seine Userid bei
kleinanzeigen.de herausfinden. Dazu ruft man am besten die Liste
seiner Anzeigen auf, während man NICHT eingeloggt ist:
https://www.kleinanzeigen.de/s-bestandsliste.html?userId=XXXXXX
Der `XXXXX` Teil der URL ist die Userid.
Trage diese Userid in der Konfigurationsdatei ein wie oben
beschrieben. Gib ausserdem das Ausgabeverzeichnis an. Dann einfach nur
`kleingebaeck` ausführen.
Innerhalb des Ausgabeverzeichnisses wird sich dann pro Anzeige ein
Unterverzeichnis befinden. Pro Anzeige gibt es eine Datei
`Adlisting.txt`, die etwa so aussieht:
```default
Title: A book I sell
Price: 99 € VB
Id: 1919191919
Category: Sachbücher
Condition: Sehr Gut
Created: 10.12.2023
This is the description text.
Pay with paypal.
```
Sowie alle Bilder.
Das Format kann man mit der Variable `template` in der Konfiguration
ändern. Die `example.conf` enthält ein Beispiel für das Standard Template.
## Verhalten des Tools
Es gibt einige Dinge über das Verhalten von kleingebäck, über die Du
Bescheid wissen solltest:
- alle HTML Seiten und Bilder werden immer heruntergeladen
- es wird ein (konfigurierbarer) Useragent verwendet
- HTTP Cookies werden beachtet
- bei Fehlern wird dreimal mit unterschiedlichem Abstand erneut
versucht
- Bilder Downloads laufen parallelisiert mit leicht unterschiedlichen
zeitlichen Abständen ab
- Gleich aussehende Bilder werden nicht überschrieben
Der letzte Punkt muss genauer erläutert werden:
Wenn man bei Kleinanzeigen.de eine Anzeige einstellt und Bilder
postet, werden diese dort in ihrer Grösse reduziert (durch Kompression
und Verkleinerung der Bilder usw.). Diese reduzierten Bilder werden
dann von kleingebäck heruntergeladen. Falls Du Deine original Bilder
behalten hast, kannst Du diese danach in das Backupverzeichnis
kopieren. Bei einem erneuten kleingebäck-Lauf werden diese Bilder dann
nicht überschrieben.
Wir verwenden dafür einen Algorythmus namens [distance
hashing](https://github.com/corona10/goimagehash). Dieser Algorithmus
prüft die Ähnlichkeit von Bildern. Diese können in ihrer Auflösung,
Kompression, Farbtiefe und vielem mehr manipuliert worden sein und
trotzdem als das "gleiche Bild" erkannt werden (wohlgemerkt nicht "das
selbe": die Dateien sind durchaus unterschiedlich!). Bis zu einer
Distance von 5 überschreiben wir keine Bilder, weil wir dann davon
ausgehen, dass das lokal Vorhandene das Original ist.
Bitte beachte aber, dass dies KEIN Cachingmechanismus ist: die Bilder
werden trotzdem immer alle heruntergeladen. Das muss so sein, da wir
uns nicht die Dateinamen anschauen können, da kleinanzeigen.de diese
nämlich zu Zahlen umbenennt. Und die Dateinamen können sich auch
ändern, wenn der User in der Anzeige die Bilder umarrangiert hat.
Du kannst dieses Verhalten mit der Option **--force** ausschalten. Du
kannst ausserdem mit der Option **--ignoreerrors** auch alle Fehler
ignorieren, die beim Bilderdownload auftreten könnten.
## Documentation
Die Dokumentation kann man
[online](https://codeberg.org/scip/kleingebaeck/raw/branch/main/kleingebaeck.pod)
oder lokal lesen mit: `kleingebaeck --manual`. Hat man das Tool mit
dem Tarball installiert, funktioniert auch `man kleingebaeck`.
## Kleingebäck?
Der Name kommt von "kleinanzeigen backup", verkürzt "klein back", das
englisch ausgesprochene "back" (deutsch bäck) führt dann zu "Kleingebäck".
## Wo bekommt man Hilfe
Obwohl ich gerne von kleingebäck Benutzern in privaten Mails höre, ist
das doch der beste Weg, die Anfrage zu übersehen und zu vergessen.
Um einen Fehler, ein unerwartetes Verhalten, eine Feature Request oder
einen Patch zu übermitteln, eröffne daher bitte einen Issue unter:
https://codeberg.org/scip/kleingebaeck/issues. Danke!
Bitte gebe den fehlgeschlagenen Befehl an, rufe es auch mit Debugging
`-d` auf.
## Ähnliche Projekte
Ich konnte kein Projekt finden, das speziell dafür geeignet ist,
Anzeigen bei kleinanzeigen.de zu sichern.
Aber es gibt ein Projekt, mit dem man ebenfalls Backups erstellen
kann: [kleinanzeigen-bot](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/).
Aber Vorsicht: kleinanzeigen.de bekämpft Bots aktiv, mit diesem hier
gibt es regelmäßige Probleme, z.B.:
[issue](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/219).
Das Hauptproblem ist, dass diese Art von Bot sich mit Deinem Account
aktiv einloggt und mit der Seite interagiert. Damit kann die Firma die
Aktivitäten recht einfach Deinem User zuordnen und diesen **sperren**!
Also sei bitte vorsichtig!
**Kleingebäck** erfordert keinen Login, es verwendet lediglich die
öffentlich verfügbare Webseite und ruft diese auf, wie ein normaler
Browser. Tatsächlich gibt es meiner Meinung nach keinen Unterschied zu
einem Browserclient: beide laufen auf Anwenderseite auf Initiative
eines Benutzers. Und mit welchen Browser ich eine Webseite aufrufe,
bleibt immer noch mir überlassen und muss mir nicht von irgendwem
vorgeschrieben werden. Das schliesst die Verwendung von Kleingebäck
mit ein.
Hinzu kommt, dass dieses Tool nicht dazu gedacht ist, rund um die Uhr
zu laufen. Man ruft es ab und zu mal auf, wenn man halt neue Anzeigen
eingestellt hat, vielleicht einmal die Woche oder so. Man weiss ja
selber, wann man was geändert hat. Man benötigt trotzdem den Zugriff
mit dem Browser oder der mobilen App um Kleinanzeigen.de verwalten zu
können.
Meiner Ansicht nach ist das Risiko also sehr minimal, es handelt sich
meiner Meinung nach auch nicht um eine Verletzung der AGBs dort. Aber
das ist nur meine persönliche Meinung, bitte beachtet das. Am Ende
müsst Ihr selbst einschätzen und beurteilen wie hoch Ihr das Risiko
seht und ob Ohr es eingehen möchtet. Für eventuell auftretende
Konsequenzen bin ich nicht verantwortlich. Siehe auch [GPL Lizenz](LICENSE).
Es gibt noch ein weiteres Tool namens
[kleinanzeigen-enhanded](https://kleinanzeigen-enhanced.de/). Das ist
eine kostenpflichtige vollständige Anzeigenverwaltung für
Profinutzer. Man muss eine monatliche Abogebühr bezahlen. Das Tool
ist als Browsererweiterung für Google Chrome implementiert, was
erklärt, warum sie Anzeigen erstellen, ändern und löschen können,
obwohl es gar keine öffentliche API gibt. Sieht nach einer netten
ausgereiften Lösung aus. Mit Backups.
## Copyright und License
Lizensiert unter der GNU GENERAL PUBLIC LICENSE Version 3.
## Autor
T.v.Dein <tom AT vondein DOT org>

238
README.md
View File

@@ -1,12 +1,14 @@
## Kleingebäck - kleinanzeigen.de Backup
![Kleingebaeck Logo](https://github.com/TLINDEN/kleingebaeck/blob/main/.github/assets/kleingebaecklogo-small.png)
![Kleingebaeck Logo](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleingebaecklogo-small.png)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://github.com/tlinden/kleingebaeck/blob/master/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/tlinden/kleingebaeck)](https://goreportcard.com/report/github.com/tlinden/kleingebaeck)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/scip/kleingebaeck)](https://goreportcard.com/report/codeberg.org/scip/kleingebaeck)
[![status-badge](https://ci.codeberg.org/api/badges/15530/status.svg)](https://ci.codeberg.org/repos/15530)
![GitHub License](https://img.shields.io/github/license/tlinden/kleingebaeck)
[![GitHub release](https://img.shields.io/github/v/release/tlinden/kleingebaeck?color=%2300a719)](https://github.com/TLINDEN/kleingebaeck/releases/latest)
[![GitHub release](https://img.shields.io/github/v/release/tlinden/kleingebaeck?color=%2300a719)](https://codeberg.org/scip/kleingebaeck/releases)
[![German](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/german.png)](https://codeberg.org/scip/kleingebaeck/raw/branch/main/README-de.md)
[Die deutsche Version des READMEs findet Ihr hier](README-de.md).
This tool can be used to backup ads on the german ad page https://kleinanzeigen.de
@@ -15,25 +17,143 @@ directory, each ad into its own subdirectory. The backup will contain
a textfile `Adlisting.txt` which contains the ad contents as the
title, body, price etc. All images will be downloaded as well.
## CAUTION - SECURITY UPDATE
Binary releases prior to version `v0.3.11` are affected by
vulnerabilities in HTTP and certificate handling. If you are using
such a binary, please update to `v0.3.12` or higher. Please also refer
to the [Release Notes of
v0.3.12](https://codeberg.org/scip/kleingebaeck/releases/tag/v0.3.12)
for more details.
## Screenshots
This is the index of my kleinanzeigen.de Account:
![Index](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-index.png)
Here I download my ads on the commandline:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-download.png)
And this is the backup directory after download:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-backup.png)
Here's a directory for one ad:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/kleinanzeigen-ad.png)
**The same thing under windows:**
Downloading ads:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/cmd-windows.jpg)
Backup directory after download:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/liste-windows.jpg)
And one ad listing directory:
![Download](https://codeberg.org/scip/kleingebaeck/raw/branch/main/.github/assets/adlisting-windows.jpg)
## Installation
The tool doesn't need authentication and doesn't have any
dependencies. Just download the binary for your platform from the
releases page and you're good to go.
The releases also include a handy tarball which you can use to install
the tool system-wide including the manual page. Just extract it and
type: `make install`.
### Installation using a pre-compiled binary
Go to the [latest release
page](https://codeberg.org/scip/kleingebaeck/releases) and
look for your OS and platform. There are two options to install the binary:
1. Directly download the binary for your platform,
e.g. `kleingebaeck-linux-amd64-0.0.5`, rename it to `kleingebaeck`
(or whatever you like more!) and put it into your bin dir
(e.g. `$HOME/bin` or as root to `/usr/local/bin`).
Be sure to verify the signature of the binary file. For this also download the matching `kleingebaeck-linux-amd64-0.0.5.sha256` file and:
```shell
cat kleingebaeck-linux-amd64-0.0.5.sha25 && sha256sum kleingebaeck-linux-amd64-0.0.5
```
You should see the same SHA256 hash.
2. You may also download a binary tarball for your platform,
e.g. `kleingebaeck-linux-amd64-0.0.5.tar.gz`, unpack and install
it. GNU Make is required for this:
```shell
tar xvfz kleingebaeck-linux-amd64-0.0.5.tar.gz
cd kleingebaeck-linux-amd64-0.0.5
sudo make install
```
### Installation from source
You will need the Golang toolchain in order to build from source. GNU
Make will also help but is not strictly neccessary.
If you want to compile the tool yourself, use `git clone` to clone the
repository. Then execute `go mod tidy` to install all
dependencies. Then just enter `go build` or - if you have GNU Make
installed - `make`.
To install after building either copy the binary or execute `sudo make install`.
### Using the docker image
A pre-built docker image is available, which you can use to test the
app without installing it. To download:
```shell
docker pull ghcr.io/tlinden/kleingebaeck:latest
```
To execute kleingebaeck inside the image and download ads to a local
directory, do something like this:
```shell
mkdir myads
docker run -u `id -u $USER` -v ./myads:/backup ghcr.io/tlinden/kleingebaeck:latest -u XXX -v
ls -l myads/ein-buch-mit-leeren-seiten
total 792
drwxr-xr-x 2 scip root 4096 Jan 23 12:58 ./
drwxr-xr-x 3 scip scip 4096 Jan 23 12:58 ../
-rw-r--r-- 1 scip root 131650 Jan 23 12:58 1.jpg
-rw-r--r-- 1 scip root 81832 Jan 23 12:58 2.jpg
-rw-r--r-- 1 scip root 134050 Jan 23 12:58 3.jpg
-rw-r--r-- 1 scip root 1166 Jan 23 12:58 Adlisting.txt
```
We map the local user to the one inside the image so the permission
will match. You'll need to create the directory first before executing
docker run. And the local directory `myads` will be mapped to
`/backup` inside the container.
The options `-u XXX -v` are kleingebaeck options, replace `XXX` with
your actual kleinanzeigen.de user id.
A list of available images is [here](https://codeberg.org/scip/kleingebaeck/pkgs/container/kleingebaeck/versions?filters%5Bversion_type%5D=tagged)
## Commandline options:
```
Usage: kleingebaeck [-dvVhmoc] [<ad-listing-url>,...]
Options:
--user,-u <uid> Backup ads from user with uid <uid>.
--debug, -d Enable debug output.
--verbose,-v Enable verbose output.
--output-dir,-o <dir> Set output dir (default: current directory)
--manual,-m Show manual.
--config,-c <file> Use config file <file> (default: ~/.kleingebaeck).
-u --user <uid> Backup ads from user with uid <uid>.
-d --debug Enable debug output.
-v --verbose Enable verbose output.
-o --outdir <dir> Set output dir (default: current directory)
-l --limit <num> Limit the ads to download to <num>, default: load all.
-c --config <file> Use config file <file> (default: ~/.kleingebaeck).
--ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
-m --manual Show manual.
-h --help Show usage.
-V --version Show program version.
If one or more <ad-listing-url>'s are specified, only backup those,
otherwise backup all ads of the given user.
@@ -42,16 +162,22 @@ otherwise backup all ads of the given user.
## Configfile
You can create a config file to save typing. By default
`~/.kleingebaeck.hcl` is being used but you can specify one with
`~/.kleingebaeck` is being used but you can specify one with
`-c` as well.
Format is simple:
```
user = 1010101
verbose = true
loglevel = verbose
outdir = "test"
template = ""
```
## Environment Variables
Kleingebaeck can also be configured using environment variables. Just prefix the config variables with `KLEINGEBAECK_` and put them to upper case. Eg:
```shell
% KLEINGEBAECK_OUTDIR=/backup kleingebaeck -v
```
## Usage
@@ -74,9 +200,11 @@ somewhat like this:
```default
Title: A book I sell
Price: 99 € VB
Shipping: 6,90 €
Id: 1919191919
Category: Sachbücher
Condition: Sehr Gut
Type: Buch
Created: 10.12.2023
This is the description text.
@@ -89,6 +217,52 @@ variable. The supplied sample config contains the default template.
All images will be stored in the same directory.
## Tool Behavior
There are a bunch of things you might want to know about the behavior
of the kleingebäck tool:
- all HTML pages and IMAGEs are always being downloaded
- we use a (customizable) user agent
- we respect HTTP cookies
- in the case of an error, the tool does 3 retries, the time it waits
between tries is longer for each retry
- image download is parallized using small time differences to look
more natural
- same images are not being overwritten on subsequent download
The latter needs to be elaborated a bit more:
If you publish an ad on kleinanzeigen.de and post images, those images
will be reduced in size by the site (by compressing and down sizing
them). This reduced images will be downloaded by kleingebäck. However,
you may still own the original images and may want to put them into
that backup directory so that you have all things for one ad together.
You can easily do that, because kleingebäck won't overwrite those
original images. It uses something called a distance hash using
[goimagehash](https://github.com/corona10/goimagehash). This
algorithmus checks the similarity of images. If an image has been
resized it is still very similar to the original one. We accept a
maximum of a distance of 5, everything above leads to overwrite.
This works with resizes, cropped and otherwise manipulated images as
long as the image still shows the original contents good enough.
Also note, that this is NOT a caching mechanism: the images will be
downloaded anyway during each run. We also can't look at the file
names because kleinanzeigen.de renames all images to numbers. And
those might even change if the user re-arranges the images.
You can override this behavior using the **--force** option. Another
option, **--ignoreerrors**, can be used to ignore all kinds of image
errors.
## Documentation
You can read the documentation [online](https://codeberg.org/scip/kleingebaeck/raw/branch/main/kleingebaeck.pod) or locally once you have installed kleingebaeck with: `kleingebaeck --manual`.
## Kleingebäck?
The name is derived from "kleinanzeigen backup": "klein" (german for
@@ -103,12 +277,40 @@ that's the best way for me to forget to do something.
In order to report a bug, unexpected behavior, feature requests or to
submit a patch, please open an issue on github:
https://github.com/TLINDEN/kleingebaeck/issues.
https://codeberg.org/scip/kleingebaeck/issues.
Please repeat the failing command with debugging enabled `-d` and
include the output in the issue.
## Copyright und License
## Related projects
I could not find any projects specifically designed to backup
kleinanzeigen.de ads, however there's a bot project which is also able
to download ads:
[kleinanzeigen-bot](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/). However,
be aware that kleinanzeigen.de is actively fighting bots! Look at this
[issue](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/219). The
problem with these kind of bots is, that they login into your account
using your credentials. If the company is able to detect bot activity
they can associate it easily with your account and **lock you
out**. So be careful.
**kleingebäck** doesn't need to login, it just accesses public
available web pages. Kleinanzeigen.de could hardly do anything against
it, once because it is legal. There's no difference between a browser
and a commandline client. Both run on the clientside and it is not
kleinanzeigen.de's decision which software one uses to access their
pages. And second: because you can use it to download any ads, not
just yours. So it is not really clear if the activity is associated in
any way with the ad owner. In addition to that comes the fact that
kleingebäck is just a backup tool. It is not intendet to be used on a
daily basis. You cannot use it to view regular ads or maintain your
own ads. You'll need to use the mobile app or the browser page with a
login. So, in my point of view, the risk is very minimal.
There is another Tool available named [kleinanzeigen-enhanced](https://kleinanzeigen-enhanced.de/). It is a complete Ad management system targeting primarily commercial users. You have to pay a monthly fee, perhaps there's also a free version available, but I haven't checked. The tool is implemented as a Chrome browser extension, which explains why it was possible to implement it without an API. It seems to be a nice solution for power users by the looks of it. And it includes backups.
## Copyright and License
Licensed under the GNU GENERAL PUBLIC LICENSE version 3.

17
SECURITY.md Normal file
View File

@@ -0,0 +1,17 @@
# Security Policy
## Supported Versions
Only the latest release is supported. If you find an issue (any
issue!), please check with the latest release first.
## Reporting a Vulnerability
I don't agree with the "responsible disclosure" process most projects
(and companies) work these days.
So, if you find a vulnerability of any kind, please just open an
[issue](https://codeberg.org/scip/kleingebaeck/issues). Please add
all details required to reproduce the vulnerability. You won't be chased.
That's just all about it.

163
ad.go Normal file
View File

@@ -0,0 +1,163 @@
/*
Copyright © 2023-2025 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"bufio"
"log/slog"
"strings"
"time"
)
type Index struct {
Links []string `goquery:".text-module-begin a,[href]"`
}
type Ad struct {
Title string `goquery:"h1"`
Slug string
ID string
Details string `goquery:".addetailslist--detail,text"`
Attributes map[string]string // processed afterwards
Condition string // post processed from details for backward compatibility
Type string // post processed from details for backward compatibility
Color string // post processed from details for backward compatibility
Material string // post processed from details for backward compatibility
Category string
CategoryTree []string `goquery:".breadcrump-link,text"`
Price string `goquery:"h2#viewad-price"`
Created string `goquery:"#viewad-extra-info,text"`
Text string `goquery:"p#viewad-description-text,html"`
Images []string `goquery:".galleryimage-element img,[src]"`
Shipping string `goquery:".boxedarticle--details--shipping,text"` // not always filled
Expire string
// runtime computed
Year, Day, Month string
}
// Used by slog to pretty print an ad
func (ad *Ad) LogValue() slog.Value {
return slog.GroupValue(
slog.String("title", ad.Title),
slog.String("price", ad.Price),
slog.String("id", ad.ID),
slog.Int("imagecount", len(ad.Images)),
slog.Int("bodysize", len(ad.Text)),
slog.String("categorytree", strings.Join(ad.CategoryTree, "+")),
slog.String("created", ad.Created),
slog.String("expire", ad.Expire),
slog.String("shipping", ad.Shipping),
slog.String("details", ad.Details),
)
}
// check for completeness. I erected these fields to be mandatory
// (though I really don't know if they really are). I consider images
// and meta optional. So, if either of the checked fields here is
// empty we return an error. All the checked fields are extracted
// using goquery. However, I think price is optional since there are
// ads for gifts as well.
//
// Note: we return true for "ad is incomplete" and false for "ad is complete"!
func (ad *Ad) Incomplete() bool {
if ad.Category == "" || ad.Created == "" || ad.Text == "" {
return true
}
return false
}
func (ad *Ad) CalculateExpire() {
if ad.Created != "" {
ts, err := time.Parse("02.01.2006", ad.Created)
if err == nil {
ad.Expire = ts.AddDate(0, 0, ExpireDays).Format("02.01.2006")
}
}
}
/*
Decode attributes like color or condition. See
https://codeberg.org/scip/kleingebaeck/issues/117
for more details. In short: the HTML delivered by
kleinanzeigen.de has no css attribute for the keys
so we cannot extract key=>value mappings of the
ad details but have to parse them manually.
The ad.Details member contains this after goq run:
Art
Weitere Kinderzimmermöbel
Farbe
Holz
Zustand
In Ordnung
We parse this into ad.Attributes and fill in some
static members for backward compatibility reasons.
*/
func (ad *Ad) DecodeAttributes() {
rd := strings.NewReader(ad.Details)
scanner := bufio.NewScanner(rd)
isattr := true
attr := ""
attrmap := map[string]string{}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if isattr {
attr = line
} else {
attrmap[attr] = line
}
isattr = !isattr
}
ad.Attributes = attrmap
if Exists(ad.Attributes, "Zustand") {
ad.Condition = ad.Attributes["Zustand"]
}
if Exists(ad.Attributes, "Farbe") {
ad.Color = ad.Attributes["Farbe"]
}
if Exists(ad.Attributes, "Art") {
ad.Type = ad.Attributes["Art"]
}
if Exists(ad.Attributes, "Material") {
ad.Material = ad.Attributes["Material"]
}
slog.Debug("parsed attributes", "attributes", ad.Attributes)
ad.Shipping = strings.Replace(ad.Shipping, "+ Versand ab ", "", 1)
}

243
config.go
View File

@@ -1,5 +1,5 @@
/*
Copyright © 2023 Thomas von Dein
Copyright © 2023-2025 Thomas von Dein
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
@@ -17,48 +17,235 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/hashicorp/hcl/v2/hclsimple"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
flag "github.com/spf13/pflag"
)
const (
VERSION string = "0.0.4"
Baseuri string = "https://www.kleinanzeigen.de"
Listuri string = "/s-bestandsliste.html"
Defaultdir string = "."
DefaultTemplate string = "Title: {{.Title}}\nPrice: {{.Price}}\nId: {{.Id}}\n" +
"Category: {{.Category}}\nCondition: {{.Condition}}\nCreated: {{.Created}}\n\n{{.Text}}\n"
Useragent string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
VERSION string = "0.3.24"
Baseuri string = "https://www.kleinanzeigen.de"
Listuri string = "/s-bestandsliste.html"
Defaultdir string = "."
/*
Also possible: loop through .Attributes:
DefaultTemplate string = "Title: {{.Title}}\nPrice: {{.Price}}\nId: {{.ID}}\n" +
"Category: {{.Category}}\n{{ range $key,$val := .Attributes }}{{ $key }}: {{ $val }}\n{{ end }}" +
"Created: {{.Created}}\nExpire: {{.Expire}}\n\n{{.Text}}\n"
*/
DefaultTemplate string = "Title: {{.Title}}\nPrice: {{.Price}}\nShipping: {{.Shipping}}\nId: {{.ID}}\n" +
"Category: {{.Category}}\nCondition: {{.Condition}}\nType: {{.Type}}\nColor: {{.Color}}\n" +
"Created: {{.Created}}\nExpire: {{.Expire}}\n\n{{.Text}}\n"
DefaultTemplateWin string = "Title: {{.Title}}\r\nPrice: {{.Price}}\r\nShipping: {{.Shipping}}\r\nId: {{.ID}}\r\n" +
"Category: {{.Category}}\r\nCondition: {{.Condition}}\r\nType: {{.Type}}\r\nColor: {{.Color}}\r\n" +
"Created: {{.Created}}\r\nExpires: {{.Expire}}\r\n\r\n{{.Text}}\r\n"
DefaultUserAgent string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
DefaultAdNameTemplate string = "{{.Slug}}"
DefaultOutdirTemplate string = "."
// for image download throttling
MinThrottle int = 2
MaxThrottle int = 20
// we extract the slug from the uri
SlugURIPartNum int = 6
// We have to calculate the ad expiry because the real value can
// only be seen by logged in users. The initial ad lifetime is 120
// days. It can be extended by the user 8 days before expire by 60
// days. But this is unknown to us, so we'll stick with our 120
// days. They may be wrong for older ads. Don't rely on it!
ExpireDays int = 120
WIN string = "windows"
)
var DirsVisited map[string]int
const Usage string = `This is kleingebaeck, the kleinanzeigen.de backup tool.
Usage: kleingebaeck [-dvVhmoclu] [<ad-listing-url>,...]
Options:
-u --user <uid> Backup ads from user with uid <uid>.
-d --debug Enable debug output.
-v --verbose Enable verbose output.
-o --outdir <dir> Set output dir (default: current directory)
-l --limit <num> Limit the ads to download to <num>, default: load all.
-c --config <file> Use config file <file> (default: ~/.kleingebaeck).
--ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
-f --force Overwrite images and ads even if the already exist.
-m --manual Show manual.
-h --help Show usage.
-V --version Show program version.
If one or more ad listing url's are specified, only backup those,
otherwise backup all ads of the given user.`
type Config struct {
Verbose bool `hcl:"verbose"`
User int `hcl:"user"`
Outdir string `hcl:"outdir"`
Template string `hcl:"template"`
Verbose bool `koanf:"verbose"` // loglevel=info
Debug bool `koanf:"debug"` // loglevel=debug
Showversion bool `koanf:"version"` // -v
Showhelp bool `koanf:"help"` // -h
Showmanual bool `koanf:"manual"` // -m
User int `koanf:"user"`
Outdir string `koanf:"outdir"`
Template string `koanf:"template"`
Adnametemplate string `koanf:"adnametemplate"`
Loglevel string `koanf:"loglevel"`
Limit int `koanf:"limit"`
IgnoreErrors bool `koanf:"ignoreerrors"`
ForceDownload bool `koanf:"force"`
UserAgent string `koanf:"useragent"` // conf only
Adlinks []string
StatsCountAds int
StatsCountImages int
}
func ParseConfigfile(file string) (*Config, error) {
c := Config{}
if path, err := os.Stat(file); !os.IsNotExist(err) {
if !path.IsDir() {
configstring, err := os.ReadFile(file)
if err != nil {
return nil, err
func (c *Config) IncrAds() {
c.StatsCountAds++
}
func (c *Config) IncrImgs(num int) {
c.StatsCountImages += num
}
// load commandline flags and config file
func InitConfig(output io.Writer) (*Config, error) {
var kloader = koanf.New(".")
// determine template based on os
template := DefaultTemplate
if runtime.GOOS == WIN {
template = DefaultTemplateWin
}
// Load default values using the confmap provider.
if err := kloader.Load(confmap.Provider(map[string]interface{}{
"template": template,
"outdir": DefaultOutdirTemplate,
"loglevel": "notice",
"userid": 0,
"adnametemplate": DefaultAdNameTemplate,
"useragent": DefaultUserAgent,
}, "."), nil); err != nil {
return nil, fmt.Errorf("failed to load default values into koanf: %w", err)
}
// setup custom usage
flagset := flag.NewFlagSet("config", flag.ContinueOnError)
flagset.Usage = func() {
_, err := fmt.Fprintln(output, Usage)
if err != nil {
panic(err)
}
os.Exit(0)
}
// parse commandline flags
flagset.StringP("config", "c", "", "config file")
flagset.StringP("outdir", "o", "", "directory where to store ads")
flagset.IntP("user", "u", 0, "user id")
flagset.IntP("limit", "l", 0, "limit ads to be downloaded (default 0, unlimited)")
flagset.BoolP("verbose", "v", false, "be verbose")
flagset.BoolP("debug", "d", false, "enable debug log")
flagset.BoolP("version", "V", false, "show program version")
flagset.BoolP("help", "h", false, "show usage")
flagset.BoolP("manual", "m", false, "show manual")
flagset.BoolP("force", "f", false, "force")
flagset.BoolP("ignoreerrors", "", false, "ignore image download HTTP errors")
if err := flagset.Parse(os.Args[1:]); err != nil {
return nil, fmt.Errorf("failed to parse program arguments: %w", err)
}
// generate a list of config files to try to load, including the
// one provided via -c, if any
var configfiles []string
configfile, _ := flagset.GetString("config")
home, _ := os.UserHomeDir()
if configfile != "" {
configfiles = []string{configfile}
} else {
configfiles = []string{
"/etc/kleingebaeck.conf", "/usr/local/etc/kleingebaeck.conf", // unix variants
filepath.Join(home, ".config", "kleingebaeck", "config"),
filepath.Join(home, ".kleingebaeck"),
"kleingebaeck.conf",
}
}
// Load the config file[s]
for _, cfgfile := range configfiles {
path, err := os.Stat(cfgfile)
if err != nil {
// ignore non-existent files, but bail out on any other errors
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
err = hclsimple.Decode(
path.Name(), configstring,
nil, &c,
)
continue
}
if err != nil {
return nil, err
if !path.IsDir() {
if err := kloader.Load(file.Provider(cfgfile), toml.Parser()); err != nil {
return nil, fmt.Errorf("error loading config file: %w", err)
}
}
}
return &c, nil
// env overrides config file
if err := kloader.Load(env.Provider("KLEINGEBAECK_", ".", func(s string) string {
return strings.ReplaceAll(strings.ToLower(
strings.TrimPrefix(s, "KLEINGEBAECK_")), "_", ".")
}), nil); err != nil {
return nil, fmt.Errorf("error loading environment: %w", err)
}
// command line overrides env
if err := kloader.Load(posflag.Provider(flagset, ".", kloader), nil); err != nil {
return nil, fmt.Errorf("error loading flags: %w", err)
}
// fetch values
conf := &Config{}
if err := kloader.Unmarshal("", &conf); err != nil {
return nil, fmt.Errorf("error unmarshalling: %w", err)
}
// adjust loglevel
switch conf.Loglevel {
case "verbose":
conf.Verbose = true
case "debug":
conf.Debug = true
}
// are there any args left on commandline? if so threat them as adlinks
conf.Adlinks = flagset.Args()
return conf, nil
}

22
docker-compose.yaml Normal file
View File

@@ -0,0 +1,22 @@
version: "3.9"
services:
init:
image: alpine:latest
user: "root"
group_add:
- '${GROUP_ID}'
volumes:
- ${OUTDIR}:/backup
command: chown -R ${USER_ID}:${USER_ID} /backup
kleingebaeck:
container_name: kleingebaeck
user: "${USER_ID}:${USER_ID}"
volumes:
- ${OUTDIR}:/backup
working_dir: /backup
build: .
image: kleingebaeck:latest
depends_on:
init:
condition: service_completed_successfully

48
example.conf Normal file
View File

@@ -0,0 +1,48 @@
#
# kleingebaeck sample configuration file.
# put this to ~/.kleingebaeck.
#
# Comments start with the '#' character.
# kleinanzeigen.de user-id. must be an unquoted number
user = 00000000
# enable verbose output (same as -v), may be true or false.
# other values: notice or debug
loglevel = "verbose"
# directory where to store downloaded ads. kleingebaeck will try to
# create it. must be a quoted string. You can also include a couple of
# template variables, e.g:
# outdir = "test-{{.Year}}-{{.Month}}-{{.Day}}"
outdir = "test"
# template for stored adlistings.
template="""
Title: {{.Title}}
Price: {{.Price}}
Shipping: {{.Shipping}}
Id: {{.Id}}
Category: {{.Category}}
Condition: {{.Condition}}
Type: {{.Type}}
Created: {{.Created}}
{{.Text}}
"""
# Ads may contain more attributes than just the Condition. To print
# all attributes, loop over all of them:
template="""
Title: {{.Title}}
Price: {{.Price}}
Id: {{.Id}}
Category: {{.Category}}
{{ range $key,$val := .Attributes }}{{ $key }}: {{ $val }}
{{ end }}
Type: {{.Type}}
Created: {{.Created}}
{{.Text}}
"""

View File

@@ -1,19 +0,0 @@
#
# kleingebaeck sample configuration file.
# put this to ~/.kleingebaeck.hcl.
#
# Comments start with the '#' character.
# kleinanzeigen.de user-id. must be an unquoted number
user = 00000000
# enable verbose output (same as -v), may be true or false.
verbose = true
# directory where to store downloaded ads. kleingebaeck will try to
# create it. must be a quoted string.
outdir = "test"
# template. leave empty to use the default one, which is:
# "Title: {{.Title}}\nPrice: {{.Price}}\nId: {{.Id}}\nCategory: {{.Category}}\nCondition: {{.Condition}}\nCreated: {{.Created}}\n\n{{.Text}}\n"
template = ""

104
fetch.go Normal file
View File

@@ -0,0 +1,104 @@
/*
Copyright © 2023-2024 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
)
// convenient wrapper to fetch some web content
type Fetcher struct {
Config *Config
Client *http.Client
Cookies []*http.Cookie
}
func NewFetcher(conf *Config) (*Fetcher, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("failed to create a cookie jar obj: %w", err)
}
return &Fetcher{
Client: &http.Client{
Transport: &loggingTransport{}, // implemented in http.go
Jar: jar,
},
Config: conf,
Cookies: []*http.Cookie{},
},
nil
}
func (f *Fetcher) Get(uri string) (io.ReadCloser, error) {
req, err := http.NewRequest(http.MethodGet, uri, http.NoBody)
if err != nil {
return nil, fmt.Errorf("failed to create a new HTTP request obj: %w", err)
}
req.Header.Set("User-Agent", f.Config.UserAgent)
if len(f.Cookies) > 0 {
uriobj, _ := url.Parse(Baseuri)
slog.Debug("have cookies, sending them",
"sample-cookie-name", f.Cookies[0].Name,
"sample-cookie-expire", f.Cookies[0].Expires,
)
f.Client.Jar.SetCookies(uriobj, f.Cookies)
}
res, err := f.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to initiate HTTP request to %s: %w", uri, err)
}
if res.StatusCode != http.StatusOK {
return nil, errors.New("could not get page via HTTP")
}
slog.Debug("got cookies?", "cookies", res.Cookies())
f.Cookies = res.Cookies()
return res.Body, nil
}
// fetch an image
func (f *Fetcher) Getimage(uri string) (io.ReadCloser, error) {
slog.Debug("fetching ad image", "uri", uri)
body, err := f.Get(uri)
if err != nil {
if f.Config.IgnoreErrors {
slog.Info("Failed to download image, error ignored", "error", err.Error())
return nil, nil
}
return nil, err
}
return body, nil
}

52
go.mod
View File

@@ -1,21 +1,41 @@
module kleingebaeck
go 1.21
go 1.24.0
toolchain go1.24.5
require (
astuart.co/goq v1.0.0 // indirect
github.com/PuerkitoBio/goquery v1.5.0 // indirect
github.com/agext/levenshtein v1.2.1 // indirect
github.com/andybalholm/cascadia v1.0.0 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/google/go-cmp v0.3.1 // indirect
github.com/hashicorp/hcl/v2 v2.19.1 // indirect
github.com/lmittmann/tint v1.0.3 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/zclconf/go-cty v1.13.0 // indirect
golang.org/x/net v0.0.0-20190606173856-1492cefac77f // indirect
golang.org/x/text v0.11.0 // indirect
astuart.co/goq v1.0.0
github.com/corona10/goimagehash v1.1.0
github.com/inconshreveable/mousetrap v1.1.0
github.com/jarcoal/httpmock v1.4.1
github.com/knadh/koanf/parsers/toml v0.1.0
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/posflag v1.0.1
github.com/knadh/koanf/v2 v2.3.0
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/spf13/pflag v1.0.10
github.com/tlinden/yadu v0.1.3
golang.org/x/image v0.32.0
golang.org/x/sync v0.17.0
)
require (
github.com/PuerkitoBio/goquery v1.5.1 // indirect
github.com/andybalholm/cascadia v1.1.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

97
go.sum
View File

@@ -1,37 +1,88 @@
astuart.co/goq v1.0.0 h1:nnYIhu/Z/j0VaX9Dp+pmh2Uh7ldEz6XfgSg+bAY5Yrw=
astuart.co/goq v1.0.0/go.mod h1:+fokcnFrO8Pw2fj8drdStJvzoMFebJH69rw8IC21rno=
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI=
github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ=
github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=
github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190606173856-1492cefac77f h1:IWHgpgFqnL5AhBUBZSgBdjl2vkQUEzcY+JNKWfcgAU0=
golang.org/x/net v0.0.0-20190606173856-1492cefac77f/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

140
http.go Normal file
View File

@@ -0,0 +1,140 @@
/*
Copyright © 2023-2024 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"fmt"
"io"
"log/slog"
"math"
"math/rand"
"net/http"
"time"
)
// I add an artificial "ID" to each HTTP request and the corresponding
// respose for debugging purposes so that the pair of them can be
// easier associated in debug output
var letters = []rune("ABCDEF0123456789")
const IDLEN int = 8
// retry after HTTP 50x errors or err!=nil
const RetryCount = 3
func getid() string {
b := make([]rune, IDLEN)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
// used to inject debug log and implement retries
type loggingTransport struct{}
// escalating timeout, $retry^2 seconds
func backoff(retries int) time.Duration {
return time.Duration(math.Pow(2, float64(retries))) * time.Second
}
// only retry in case of errors or certain non 200 HTTP codes
func shouldRetry(err error, resp *http.Response) bool {
if err != nil {
return true
}
if resp.StatusCode == http.StatusBadGateway ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode == http.StatusGatewayTimeout {
return true
}
return false
}
// Body needs to be drained, otherwise we can't reuse the http.Response
func drainBody(resp *http.Response) {
if resp != nil {
if resp.Body != nil {
_, err := io.Copy(io.Discard, resp.Body)
if err != nil {
// unable to copy data? uff!
panic(err)
}
if err := resp.Body.Close(); err != nil {
panic(err)
}
}
}
}
// the actual logging transport with retries
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// just required for debugging
requestid := getid()
// clone the request body, put into request on retry
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
slog.Debug("REQUEST", "id", requestid, "uri", req.URL, "host", req.Host)
// first try
resp, err := http.DefaultTransport.RoundTrip(req)
if err == nil {
slog.Debug("RESPONSE", "id", requestid, "status", resp.StatusCode,
"contentlength", resp.ContentLength)
}
// enter retry check and loop, if first req were successful, leave loop immediately
retries := 0
for shouldRetry(err, resp) && retries < RetryCount {
time.Sleep(backoff(retries))
// consume any response to reuse the connection.
drainBody(resp)
// clone the request body again
if req.Body != nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// actual retry
resp, err = http.DefaultTransport.RoundTrip(req)
if err == nil {
slog.Debug("RESPONSE", "id", requestid, "status", resp.StatusCode,
"contentlength", resp.ContentLength, "retry", retries)
}
retries++
}
if err != nil {
return resp, fmt.Errorf("failed to get HTTP response for %s: %w", req.URL, err)
}
return resp, nil
}

191
image.go Normal file
View File

@@ -0,0 +1,191 @@
/*
Copyright © 2023-2024 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"log/slog"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
"github.com/corona10/goimagehash"
)
const MaxDistance = 3
type Image struct {
Filename string
Hash *goimagehash.ImageHash
Data *bytes.Reader
URI string
Mime string
}
// used for logging to avoid printing Data
func (img *Image) LogValue() slog.Value {
return slog.GroupValue(
slog.String("filename", img.Filename),
slog.String("uri", img.URI),
slog.String("hash", img.Hash.ToString()),
)
}
// holds all images of an ad
type Cache []*goimagehash.ImageHash
// filename comes from the scraper, it contains directory/base w/o suffix
func NewImage(buf *bytes.Reader, filename, uri string) (*Image, error) {
_, imgconfig, err := image.DecodeConfig(buf)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
_, err = buf.Seek(0, 0)
if err != nil {
return nil, fmt.Errorf("failed to seek(0) on image buffer: %w", err)
}
if imgconfig == "jpeg" {
// we're using the format as file extension, but have used
// "jpg" in the past, so to be backwards compatible, stay with
// it.
imgconfig = "jpg"
}
if imgconfig == "" {
return nil, fmt.Errorf("failed to process image: unknown or unsupported image format (supported: jpg,png,gif,webp)")
}
filename += "." + imgconfig
img := &Image{
Filename: filename,
URI: uri,
Data: buf,
Mime: imgconfig,
}
slog.Debug("image MIME", "mime", img.Mime)
return img, nil
}
// Calculate diff hash of the image
func (img *Image) CalcHash() error {
jpgdata, format, err := image.Decode(img.Data)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
}
if format == "" {
return fmt.Errorf("failed to decode image: unknown or unsupported image format (supported: jpg,png,gif,webp)")
}
hash1, err := goimagehash.DifferenceHash(jpgdata)
if err != nil {
return fmt.Errorf("failed to calculate diff hash of image: %w", err)
}
img.Hash = hash1
return nil
}
// checks if 2 images are similar enough to be considered the same
func (img *Image) Similar(hash *goimagehash.ImageHash) bool {
distance, err := img.Hash.Distance(hash)
if err != nil {
slog.Debug("failed to compute diff hash distance", "error", err)
return false
}
if distance < MaxDistance {
slog.Debug("distance computation", "image-A", img.Hash.ToString(),
"image-B", hash.ToString(), "distance", distance)
return true
}
return false
}
// check current image against all known hashes.
func (img *Image) SimilarExists(cache Cache) bool {
for _, otherimg := range cache {
if img.Similar(otherimg) {
return true
}
}
return false
}
// read all JPG images in a ad directory, compute diff hashes and
// store the results in the slice Images
func ReadImages(addir string, dont bool) (Cache, error) {
files, err := os.ReadDir(addir)
if err != nil {
return nil, fmt.Errorf("failed to read ad directory contents: %w", err)
}
cache := Cache{}
if dont {
// forced download, -f given
return cache, nil
}
for _, file := range files {
ext := filepath.Ext(file.Name())
if !file.IsDir() && (ext == ".jpg" || ext == ".jpeg" || ext == ".JPG" || ext == ".JPEG") {
filename := filepath.Join(addir, file.Name())
data, err := ReadImage(filename)
if err != nil {
return nil, err
}
reader := bytes.NewReader(data.Bytes())
img, err := NewImage(reader, filename, "")
if err != nil {
return nil, err
}
if err := img.CalcHash(); err != nil {
return nil, err
}
if img.Hash != nil {
slog.Debug("Caching image from file system", "image", img, "hash", img.Hash.ToString())
}
cache = append(cache, img.Hash)
}
}
return cache, nil
}

View File

@@ -133,7 +133,7 @@
.\" ========================================================================
.\"
.IX Title "KLEINGEBAECK 1"
.TH KLEINGEBAECK 1 "2023-12-17" "1" "User Commands"
.TH KLEINGEBAECK 1 "2025-02-27" "1" "User Commands"
.\" For nroff, turn off justification. Always turn off hyphenation; it makes
.\" way too many mistakes in technical documents.
.if n .ad l
@@ -142,16 +142,20 @@
kleingebaeck \- kleinanzeigen.de backup tool
.SH "SYNOPSYS"
.IX Header "SYNOPSYS"
.Vb 9
\& This is kleingebaeck, the kleinanzeigen.de backup tool.
.Vb 10
\& Usage: kleingebaeck [\-dvVhmoc] [<ad\-listing\-url>,...]
\& Options:
\& \-\-user,\-u <uid> Backup ads from user with uid <uid>.
\& \-\-debug, \-d Enable debug output.
\& \-\-verbose,\-v Enable verbose output.
\& \-\-output\-dir,\-o <dir> Set output dir (default: current directory)
\& \-\-manual,\-m Show manual.
\& \-\-config,\-c <file> Use config file <file> (default: ~/.kleingebaeck).
\& \-u \-\-user <uid> Backup ads from user with uid <uid>.
\& \-d \-\-debug Enable debug output.
\& \-v \-\-verbose Enable verbose output.
\& \-o \-\-outdir <dir> Set output dir (default: current directory)
\& \-l \-\-limit <num> Limit the ads to download to <num>, default: load all.
\& \-c \-\-config <file> Use config file <file> (default: ~/.kleingebaeck).
\& \-\-ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
\& \-f \-\-force Overwrite images and ads even if the already exist.
\& \-m \-\-manual Show manual.
\& \-h \-\-help Show usage.
\& \-V \-\-version Show program version.
.Ve
.SH "DESCRIPTION"
.IX Header "DESCRIPTION"
@@ -163,27 +167,155 @@ a textfile \fBAdlisting.txt\fR which contains the ad contents such as
title, body, price etc. All images will be downloaded as well.
.SH "CONFIGURATION"
.IX Header "CONFIGURATION"
You can create a config file to save typing. By default
\&\f(CW\*(C`~/.kleingebaeck.hcl\*(C'\fR is being used but you can specify one with
\&\f(CW\*(C`\-c\*(C'\fR as well.
You can create a config file to save typing. By default
\&\f(CW\*(C`~/.kleingebaeck\*(C'\fR is being used but you can specify one with \f(CW\*(C`\-c\*(C'\fR as
well. We use \s-1TOML\s0 as our configuration language. See
<https://toml.io/en/>.
.PP
Format is simple:
Format is pretty simple:
.PP
.Vb 4
.Vb 11
\& user = 1010101
\& verbose = true
\& loglevel = verbose
\& outdir = "test"
\& template = ""
\& useragent = "Mozilla/5.0"
\& template = """
\& Title: {{.Title}}
\& Price: {{.Price}}
\& Id: {{.ID}}
\& Category: {{.Category}}
\& Condition: {{.Condition}}
\& Created: {{.Created}}
\&
\& {{.Text}}
\& """
.Ve
.PP
Be carefull if you want to change the template. The default one looks like this:
Be careful if you want to change the template. The variable is a
multiline string surrounded by three double quotes. You can left out
certain fields and use any formatting you like. Refer to
<https://pkg.go.dev/text/template> for details how to write a
template. Also read the \s-1TEMPLATES\s0 section below.
.PP
If you're on windows and want to customize the output directory, put
it into single quotes to avoid the backslashes interpreted as escape
chars like this:
.PP
.Vb 1
\& Title: {{.Title}}\enPrice: {{.Price}}\enId: {{.Id}}\enCategory: {{.Category}}\enCondition: {{.Condition}}\enCreated: {{.Created}}\en\en{{.Text}}\en
\& outdir = \*(AqC:\eData\eAds\*(Aq
.Ve
.SH "TEMPLATES"
.IX Header "TEMPLATES"
Various parts of the configuration can be modified using templates:
the output directory, the ad directory and the ad listing itself.
.SS "\s-1OUTPUT DIR TEMPLATE\s0"
.IX Subsection "OUTPUT DIR TEMPLATE"
The config varialbe \f(CW\*(C`outdir\*(C'\fR or the command line parameter \f(CW\*(C`\-o\*(C'\fR take a
template which may contain:
.ie n .IP """{{.Year}}""" 4
.el .IP "\f(CW{{.Year}}\fR" 4
.IX Item "{{.Year}}"
.PD 0
.ie n .IP """{{.Month}}""" 4
.el .IP "\f(CW{{.Month}}\fR" 4
.IX Item "{{.Month}}"
.ie n .IP """{{.Day}}""" 4
.el .IP "\f(CW{{.Day}}\fR" 4
.IX Item "{{.Day}}"
.PD
.PP
That way you can create a new output directory for every backup
run. For example:
.PP
.Vb 1
\& outdir = "/home/backups/ads\-{{.Year}}\-{{.Month}}\-{{.Day}}"
.Ve
.PP
You can left out certain fields and use any formatting you like. Refer
to <https://pkg.go.dev/text/template> for details how to write a template.
Or using the command line flag:
.PP
.Vb 1
\& \-o "/home/backups/ads\-{{.Year}}\-{{.Month}}\-{{.Day}}"
.Ve
.PP
The default value is \f(CW\*(C`.\*(C'\fR \- the current directory.
.SS "\s-1AD DIRECTORY TEMPLATE\s0"
.IX Subsection "AD DIRECTORY TEMPLATE"
The ad directory name can be modified using the following ad values:
.IP "{{.Price}}" 4
.IX Item "{{.Price}}"
.PD 0
.IP "{{.ID}}" 4
.IX Item "{{.ID}}"
.IP "{{.Category}}" 4
.IX Item "{{.Category}}"
.IP "{{.Condition}}" 4
.IX Item "{{.Condition}}"
.IP "{{.Created}}" 4
.IX Item "{{.Created}}"
.IP "{{.Slug}}" 4
.IX Item "{{.Slug}}"
.IP "{{.Text}}" 4
.IX Item "{{.Text}}"
.PD
.PP
It can only be configured in the config file. By default only
\&\f(CW\*(C`{{.Slug}}\*(C'\fR is being used, this is the title of the ad in url format.
.SS "\s-1AD NAME TEMPLATE\s0"
.IX Subsection "AD NAME TEMPLATE"
The name of the directory per ad can be tuned as well:
.ie n .IP """{{.Year}}""" 4
.el .IP "\f(CW{{.Year}}\fR" 4
.IX Item "{{.Year}}"
.PD 0
.ie n .IP """{{.Month}}""" 4
.el .IP "\f(CW{{.Month}}\fR" 4
.IX Item "{{.Month}}"
.ie n .IP """{{.Day}}""" 4
.el .IP "\f(CW{{.Day}}\fR" 4
.IX Item "{{.Day}}"
.ie n .IP """{{.Slug}}""" 4
.el .IP "\f(CW{{.Slug}}\fR" 4
.IX Item "{{.Slug}}"
.ie n .IP """{{.Category}}""" 4
.el .IP "\f(CW{{.Category}}\fR" 4
.IX Item "{{.Category}}"
.ie n .IP """{{.ID}}""" 4
.el .IP "\f(CW{{.ID}}\fR" 4
.IX Item "{{.ID}}"
.PD
.SS "\s-1AD TEMPLATE\s0"
.IX Subsection "AD TEMPLATE"
The ad listing itself can be modified as well, using the same
variables as the ad name template above.
.PP
This is the default template:
.PP
.Vb 8
\& Title: {{.Title}}
\& Price: {{.Price}}
\& Id: {{.ID}}
\& Category: {{.Category}}
\& Condition: {{.Condition}}
\& Type: {{.Type}}
\& Created: {{.Created}}
\& Expire: {{.Expire}}
\&
\& {{.Text}}
.Ve
.PP
The config parameter to modify is \f(CW\*(C`template\*(C'\fR. See example.conf in the
source repository. Please take care, since this is a multiline
string. This is how it shall look if you modify it:
.PP
.Vb 2
\& template="""
\& Title: {{.Title}}
\&
\& {{.Text}}
\& """
.Ve
.PP
That is, the content between the two \f(CW"""\fR chars is the template.
.SH "SETUP"
.IX Header "SETUP"
To setup the tool, you need to lookup your userid on
@@ -201,11 +333,27 @@ directory. Then just execute \f(CW\*(C`kleingebaeck\*(C'\fR.
.PP
You can use the \fB\-v\fR option to get verbose output or \fB\-d\fR to enable
debugging.
.SH "ENVIRONMENT VARIABLES"
.IX Header "ENVIRONMENT VARIABLES"
The following environment variables are considered:
.PP
.Vb 7
\& KLEINGEBAECK_USER
\& KLEINGEBAECK_DEBUG
\& KLEINGEBAECK_VERBOSE
\& KLEINGEBAECK_OUTDIR
\& KLEINGEBAECK_LIMIT
\& KLEINGEBAECK_CONFIG
\& KLEINGEBAECK_IGNOREERRORS
.Ve
.PP
Please note, that they take precedence over config file, but
commandline flags take precedence over env!
.SH "BUGS"
.IX Header "BUGS"
In order to report a bug, unexpected behavior, feature requests
or to submit a patch, please open an issue on github:
<https://github.com/TLINDEN/kleingebaeck/issues>.
<https://codeberg.org/scip/kleingebaeck/issues>.
.PP
Please repeat the failing command with debugging enabled \f(CW\*(C`\-d\*(C'\fR and
include the output in the issue.
@@ -219,7 +367,20 @@ Also there's currently no parallelization implemented. This will
change in the future.
.SH "LICENSE"
.IX Header "LICENSE"
Licensed under the \s-1GNU GENERAL PUBLIC LICENSE\s0 version 3.
Copyright 2023\-2025 Thomas von Dein
.PP
This program is free software: you can redistribute it and/or modify
it under the terms of the \s-1GNU\s0 General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
.PP
This program is distributed in the hope that it will be useful,
but \s-1WITHOUT ANY WARRANTY\s0; without even the implied warranty of
\&\s-1MERCHANTABILITY\s0 or \s-1FITNESS FOR A PARTICULAR PURPOSE.\s0 See the
\&\s-1GNU\s0 General Public License for more details.
.PP
You should have received a copy of the \s-1GNU\s0 General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
.SH "Author"
.IX Header "Author"
T.v.Dein <tom \s-1AT\s0 vondein \s-1DOT\s0 org>

View File

@@ -5,15 +5,19 @@ NAME
kleingebaeck - kleinanzeigen.de backup tool
SYNOPSYS
This is kleingebaeck, the kleinanzeigen.de backup tool.
Usage: kleingebaeck [-dvVhmoc] [<ad-listing-url>,...]
Options:
--user,-u <uid> Backup ads from user with uid <uid>.
--debug, -d Enable debug output.
--verbose,-v Enable verbose output.
--output-dir,-o <dir> Set output dir (default: current directory)
--manual,-m Show manual.
--config,-c <file> Use config file <file> (default: ~/.kleingebaeck).
-u --user <uid> Backup ads from user with uid <uid>.
-d --debug Enable debug output.
-v --verbose Enable verbose output.
-o --outdir <dir> Set output dir (default: current directory)
-l --limit <num> Limit the ads to download to <num>, default: load all.
-c --config <file> Use config file <file> (default: ~/.kleingebaeck).
--ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
-f --force Overwrite images and ads even if the already exist.
-m --manual Show manual.
-h --help Show usage.
-V --version Show program version.
DESCRIPTION
This tool can be used to backup ads on the german ad page
@@ -26,24 +30,114 @@ DESCRIPTION
CONFIGURATION
You can create a config file to save typing. By default
"~/.kleingebaeck.hcl" is being used but you can specify one with "-c" as
well.
"~/.kleingebaeck" is being used but you can specify one with "-c" as
well. We use TOML as our configuration language. See
<https://toml.io/en/>.
Format is simple:
Format is pretty simple:
user = 1010101
verbose = true
loglevel = verbose
outdir = "test"
template = ""
useragent = "Mozilla/5.0"
template = """
Title: {{.Title}}
Price: {{.Price}}
Id: {{.ID}}
Category: {{.Category}}
Condition: {{.Condition}}
Created: {{.Created}}
Be carefull if you want to change the template. The default one looks
{{.Text}}
"""
Be careful if you want to change the template. The variable is a
multiline string surrounded by three double quotes. You can left out
certain fields and use any formatting you like. Refer to
<https://pkg.go.dev/text/template> for details how to write a template.
Also read the TEMPLATES section below.
If you're on windows and want to customize the output directory, put it
into single quotes to avoid the backslashes interpreted as escape chars
like this:
Title: {{.Title}}\nPrice: {{.Price}}\nId: {{.Id}}\nCategory: {{.Category}}\nCondition: {{.Condition}}\nCreated: {{.Created}}\n\n{{.Text}}\n
outdir = 'C:\Data\Ads'
You can left out certain fields and use any formatting you like. Refer
to <https://pkg.go.dev/text/template> for details how to write a
template.
TEMPLATES
Various parts of the configuration can be modified using templates: the
output directory, the ad directory and the ad listing itself.
OUTPUT DIR TEMPLATE
The config varialbe "outdir" or the command line parameter "-o" take a
template which may contain:
"{{.Year}}"
"{{.Month}}"
"{{.Day}}"
That way you can create a new output directory for every backup run. For
example:
outdir = "/home/backups/ads-{{.Year}}-{{.Month}}-{{.Day}}"
Or using the command line flag:
-o "/home/backups/ads-{{.Year}}-{{.Month}}-{{.Day}}"
The default value is "." - the current directory.
AD DIRECTORY TEMPLATE
The ad directory name can be modified using the following ad values:
{{.Price}}
{{.ID}}
{{.Category}}
{{.Condition}}
{{.Created}}
{{.Slug}}
{{.Text}}
It can only be configured in the config file. By default only
"{{.Slug}}" is being used, this is the title of the ad in url format.
AD NAME TEMPLATE
The name of the directory per ad can be tuned as well:
"{{.Year}}"
"{{.Month}}"
"{{.Day}}"
"{{.Slug}}"
"{{.Category}}"
"{{.ID}}"
AD TEMPLATE
The ad listing itself can be modified as well, using the same variables
as the ad name template above.
This is the default template:
Title: {{.Title}}
Price: {{.Price}}
Id: {{.ID}}
Category: {{.Category}}
Condition: {{.Condition}}
Type: {{.Type}}
Created: {{.Created}}
Expire: {{.Expire}}
{{.Text}}
The config parameter to modify is "template". See example.conf in the
source repository. Please take care, since this is a multiline string.
This is how it shall look if you modify it:
template="""
Title: {{.Title}}
{{.Text}}
"""
That is, the content between the two """ chars is the template.
SETUP
To setup the tool, you need to lookup your userid on kleinanzeigen.de.
@@ -59,10 +153,24 @@ SETUP
You can use the -v option to get verbose output or -d to enable
debugging.
ENVIRONMENT VARIABLES
The following environment variables are considered:
KLEINGEBAECK_USER
KLEINGEBAECK_DEBUG
KLEINGEBAECK_VERBOSE
KLEINGEBAECK_OUTDIR
KLEINGEBAECK_LIMIT
KLEINGEBAECK_CONFIG
KLEINGEBAECK_IGNOREERRORS
Please note, that they take precedence over config file, but commandline
flags take precedence over env!
BUGS
In order to report a bug, unexpected behavior, feature requests or to
submit a patch, please open an issue on github:
<https://github.com/TLINDEN/kleingebaeck/issues>.
<https://codeberg.org/scip/kleingebaeck/issues>.
Please repeat the failing command with debugging enabled "-d" and
include the output in the issue.
@@ -76,7 +184,20 @@ LIMITATIONS
in the future.
LICENSE
Licensed under the GNU GENERAL PUBLIC LICENSE version 3.
Copyright 2023-2025 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
Author
T.v.Dein <tom AT vondein DOT org>

View File

@@ -4,16 +4,20 @@ kleingebaeck - kleinanzeigen.de backup tool
=head1 SYNOPSYS
This is kleingebaeck, the kleinanzeigen.de backup tool.
Usage: kleingebaeck [-dvVhmoc] [<ad-listing-url>,...]
Options:
--user,-u <uid> Backup ads from user with uid <uid>.
--debug, -d Enable debug output.
--verbose,-v Enable verbose output.
--output-dir,-o <dir> Set output dir (default: current directory)
--manual,-m Show manual.
--config,-c <file> Use config file <file> (default: ~/.kleingebaeck).
-u --user <uid> Backup ads from user with uid <uid>.
-d --debug Enable debug output.
-v --verbose Enable verbose output.
-o --outdir <dir> Set output dir (default: current directory)
-l --limit <num> Limit the ads to download to <num>, default: load all.
-c --config <file> Use config file <file> (default: ~/.kleingebaeck).
--ignoreerrors Ignore HTTP errors, may lead to incomplete ad backup.
-f --force Overwrite images and ads even if the already exist.
-m --manual Show manual.
-h --help Show usage.
-V --version Show program version.
=head1 DESCRIPTION
This tool can be used to backup ads on the german ad page L<https://kleinanzeigen.de>.
@@ -25,23 +29,146 @@ title, body, price etc. All images will be downloaded as well.
=head1 CONFIGURATION
You can create a config file to save typing. By default
C<~/.kleingebaeck.hcl> is being used but you can specify one with
C<-c> as well.
You can create a config file to save typing. By default
C<~/.kleingebaeck> is being used but you can specify one with C<-c> as
well. We use TOML as our configuration language. See
L<https://toml.io/en/>.
Format is simple:
Format is pretty simple:
user = 1010101
verbose = true
loglevel = verbose
outdir = "test"
template = ""
useragent = "Mozilla/5.0"
template = """
Title: {{.Title}}
Price: {{.Price}}
Id: {{.ID}}
Category: {{.Category}}
Condition: {{.Condition}}
Created: {{.Created}}
Be carefull if you want to change the template. The default one looks like this:
{{.Text}}
"""
Title: {{.Title}}\nPrice: {{.Price}}\nId: {{.Id}}\nCategory: {{.Category}}\nCondition: {{.Condition}}\nCreated: {{.Created}}\n\n{{.Text}}\n
Be careful if you want to change the template. The variable is a
multiline string surrounded by three double quotes. You can left out
certain fields and use any formatting you like. Refer to
L<https://pkg.go.dev/text/template> for details how to write a
template. Also read the TEMPLATES section below.
You can left out certain fields and use any formatting you like. Refer
to L<https://pkg.go.dev/text/template> for details how to write a template.
If you're on windows and want to customize the output directory, put
it into single quotes to avoid the backslashes interpreted as escape
chars like this:
outdir = 'C:\Data\Ads'
=head1 TEMPLATES
Various parts of the configuration can be modified using templates:
the output directory, the ad directory and the ad listing itself.
=head2 OUTPUT DIR TEMPLATE
The config varialbe C<outdir> or the command line parameter C<-o> take a
template which may contain:
=over
=item C<{{.Year}}>
=item C<{{.Month}}>
=item C<{{.Day}}>
=back
That way you can create a new output directory for every backup
run. For example:
outdir = "/home/backups/ads-{{.Year}}-{{.Month}}-{{.Day}}"
Or using the command line flag:
-o "/home/backups/ads-{{.Year}}-{{.Month}}-{{.Day}}"
The default value is C<.> - the current directory.
=head2 AD DIRECTORY TEMPLATE
The ad directory name can be modified using the following ad values:
=over
=item {{.Price}}
=item {{.ID}}
=item {{.Category}}
=item {{.Condition}}
=item {{.Created}}
=item {{.Slug}}
=item {{.Text}}
=back
It can only be configured in the config file. By default only
C<{{.Slug}}> is being used, this is the title of the ad in url format.
=head2 AD NAME TEMPLATE
The name of the directory per ad can be tuned as well:
=over
=item C<{{.Year}}>
=item C<{{.Month}}>
=item C<{{.Day}}>
=item C<{{.Slug}}>
=item C<{{.Category}}>
=item C<{{.ID}}>
=back
=head2 AD TEMPLATE
The ad listing itself can be modified as well, using the same
variables as the ad name template above.
This is the default template:
Title: {{.Title}}
Price: {{.Price}}
Id: {{.ID}}
Category: {{.Category}}
Condition: {{.Condition}}
Type: {{.Type}}
Created: {{.Created}}
Expire: {{.Expire}}
{{.Text}}
The config parameter to modify is C<template>. See example.conf in the
source repository. Please take care, since this is a multiline
string. This is how it shall look if you modify it:
template="""
Title: {{.Title}}
{{.Text}}
"""
That is, the content between the two C<"""> chars is the template.
=head1 SETUP
@@ -59,11 +186,28 @@ directory. Then just execute C<kleingebaeck>.
You can use the B<-v> option to get verbose output or B<-d> to enable
debugging.
=head1 ENVIRONMENT VARIABLES
The following environment variables are considered:
KLEINGEBAECK_USER
KLEINGEBAECK_DEBUG
KLEINGEBAECK_VERBOSE
KLEINGEBAECK_OUTDIR
KLEINGEBAECK_LIMIT
KLEINGEBAECK_CONFIG
KLEINGEBAECK_IGNOREERRORS
Please note, that they take precedence over config file, but
commandline flags take precedence over env!
=head1 BUGS
In order to report a bug, unexpected behavior, feature requests
or to submit a patch, please open an issue on github:
L<https://github.com/TLINDEN/kleingebaeck/issues>.
L<https://codeberg.org/scip/kleingebaeck/issues>.
Please repeat the failing command with debugging enabled C<-d> and
include the output in the issue.
@@ -79,7 +223,20 @@ change in the future.
=head1 LICENSE
Licensed under the GNU GENERAL PUBLIC LICENSE version 3.
Copyright 2023-2025 Thomas von Dein
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 L<http://www.gnu.org/licenses/>.
=head1 Author

202
main.go
View File

@@ -1,5 +1,5 @@
/*
Copyright © 2023 Thomas von Dein
Copyright © 2023-2024 Thomas von Dein
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
@@ -18,111 +18,116 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"bufio"
"errors"
"fmt"
"io"
"log/slog"
"os"
"runtime"
"runtime/debug"
"github.com/inconshreveable/mousetrap"
"github.com/lmittmann/tint"
flag "github.com/spf13/pflag"
"github.com/tlinden/yadu"
)
const Usage string = `This is kleingebaeck, the kleinanzeigen.de backup tool.
Usage: kleingebaeck [-dvVhmoc] [<ad-listing-url>,...]
Options:
--user,-u <uid> Backup ads from user with uid <uid>.
--debug, -d Enable debug output.
--verbose,-v Enable verbose output.
--output-dir,-o <dir> Set output dir (default: current directory)
--manual,-m Show manual.
--config,-c <file> Use config file <file> (default: ~/.kleingebaeck).
If one or more <ad-listing-url>'s are specified, only backup those,
otherwise backup all ads of the given user.`
const LevelNotice = slog.Level(2)
func main() {
os.Exit(Main())
os.Exit(Main(os.Stdout))
}
func Main() int {
func init() {
// if we're running on Windows AND if the user double clicked the
// exe file from explorer, we tell them and then wait until any
// key has been hit, which will make the cmd window disappear and
// thus give the user time to read it.
if runtime.GOOS == "windows" {
if mousetrap.StartedByExplorer() {
fmt.Println("Do no double click kleingebaeck.exe!")
fmt.Println("Please open a command shell and run it from there.")
fmt.Println()
fmt.Print("Press any key to quit: ")
_, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
panic(err)
}
}
}
}
func Main(output io.Writer) int {
logLevel := &slog.LevelVar{}
opts := &tint.Options{
Level: logLevel,
AddSource: false,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
// Remove time from the output
if a.Key == slog.TimeKey {
if attr.Key == slog.TimeKey {
return slog.Attr{}
}
return a
return attr
},
NoColor: IsNoTty(),
}
logLevel.Set(LevelNotice)
var handler slog.Handler = tint.NewHandler(os.Stdout, opts)
handler := tint.NewHandler(output, opts)
logger := slog.New(handler)
slog.SetDefault(logger)
showversion := false
showhelp := false
showmanual := false
enabledebug := false
enableverbose := false
uid := 0
configfile := os.Getenv("HOME") + "/.kleingebaeck.hcl"
dir := ""
flag.BoolVarP(&enabledebug, "debug", "d", false, "debug mode")
flag.BoolVarP(&enableverbose, "verbose", "v", false, "be verbose")
flag.BoolVarP(&showversion, "version", "V", false, "show version")
flag.BoolVarP(&showhelp, "help", "h", false, "show usage")
flag.BoolVarP(&showmanual, "manual", "m", false, "show manual")
flag.IntVarP(&uid, "user", "u", uid, "user id")
flag.StringVarP(&dir, "output-dir", "o", dir, "where to store ads")
flag.StringVarP(&configfile, "config", "c", configfile, "config file")
flag.Parse()
if showversion {
fmt.Printf("This is kleingebaeck version %s\n", VERSION)
return 0
}
if showhelp {
fmt.Println(Usage)
return 0
}
if showmanual {
err := man()
if err != nil {
return Die(err)
}
return 0
}
conf, err := ParseConfigfile(configfile)
conf, err := InitConfig(output)
if err != nil {
return Die(err)
}
if enableverbose || conf.Verbose {
if conf.Showversion {
_, err := fmt.Fprintf(output, "This is kleingebaeck version %s\n", VERSION)
if err != nil {
panic(err)
}
return 0
}
if conf.Showhelp {
_, err := fmt.Fprintln(output, Usage)
if err != nil {
panic(err)
}
return 0
}
if conf.Showmanual {
err := man()
if err != nil {
return Die(err)
}
return 0
}
if conf.Verbose {
logLevel.Set(slog.LevelInfo)
}
if enabledebug {
if conf.Debug {
// we're using a more verbose logger in debug mode
buildInfo, _ := debug.ReadBuildInfo()
opts := &tint.Options{
opts := &yadu.Options{
Level: logLevel,
AddSource: true,
//NoColor: IsNoTty(),
}
logLevel.Set(slog.LevelDebug)
var handler slog.Handler = tint.NewHandler(os.Stdout, opts)
handler := yadu.NewHandler(output, opts)
debuglogger := slog.New(handler).With(
slog.Group("program_info",
slog.Int("pid", os.Getpid()),
@@ -134,50 +139,58 @@ func Main() int {
slog.Debug("config", "conf", conf)
if len(dir) == 0 {
if len(conf.Outdir) > 0 {
dir = conf.Outdir
} else {
dir = Defaultdir
}
}
// prepare output dir
err = Mkdir(dir)
outdir, err := OutDirName(conf)
if err != nil {
return Die(err)
}
conf.Outdir = outdir
// used for all HTTP requests
fetch, err := NewFetcher(conf)
if err != nil {
return Die(err)
}
// which template to use
template := DefaultTemplate
if len(conf.Template) > 0 {
template = conf.Template
}
// setup ad dir registry, needed to check for duplicates
DirsVisited = make(map[string]int)
// directly backup ad listing[s]
if len(flag.Args()) >= 1 {
for _, uri := range flag.Args() {
err := Scrape(uri, dir, template)
switch {
case len(conf.Adlinks) >= 1:
// directly backup ad listing[s]
for _, uri := range conf.Adlinks {
err := ScrapeAd(fetch, uri)
if err != nil {
return Die(err)
}
}
return 0
}
// backup all ads of the given user (via config or cmdline)
if uid == 0 && conf.User > 0 {
uid = conf.User
}
if uid > 0 {
err := Start(fmt.Sprintf("%d", uid), dir, template)
case conf.User > 0:
// backup all ads of the given user (via config or cmdline)
err := ScrapeUser(fetch)
if err != nil {
return Die(err)
}
default:
return Die(errors.New("invalid or no user id or no ad link specified"))
}
if conf.StatsCountAds > 0 {
adstr := "ads"
if conf.StatsCountAds == 1 {
adstr = "ad"
}
_, err := fmt.Fprintf(output, "Successfully downloaded %d %s with %d images to %s.\n",
conf.StatsCountAds, adstr, conf.StatsCountImages, conf.Outdir)
if err != nil {
panic(err)
}
} else {
return Die(errors.New("invalid or no user id specified"))
_, err := fmt.Fprintf(output, "No ads found.")
if err != nil {
panic(err)
}
}
return 0
@@ -185,5 +198,6 @@ func Main() int {
func Die(err error) int {
slog.Error("Failure", "error", err.Error())
return 1
}

594
main_test.go Normal file
View File

@@ -0,0 +1,594 @@
/*
Copyright © 2023-2024 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
"strings"
"testing"
tpl "text/template"
"github.com/jarcoal/httpmock"
)
// the ad list, aka:
// https://www.kleinanzeigen.de/s-bestandsliste.html?userId=XXXXXX
// Note, that this HTML code is reduced to the max, so that it only
// contains the stuff required to satisfy goquery
const LISTTPL string = `<!DOCTYPE html>
<html lang="de" >
<head>
<title>Ads</title>
</head>
<body>
{{ range . }}
<h2 class="text-module-begin">
<a class="ellipsis"
href="/s-anzeige/{{ .Slug }}/{{ .ID }}">{{ .Title }}</a>
</h2>
{{ end }}
</body>
</html>
`
// an actual ad listing, aka:
// https://www.kleinanzeigen.de/s-anzeige/ad-text-slug/1010101010
// Note, that this HTML code is reduced to the max, so that it only
// contains the stuff required to satisfy goquery
const ADTPL string = `DOCTYPE html>
<html lang="de">
<head>
<title>Ad Listing</title>
</head>
<body>
<div class="l-container-row">
<div id="vap-brdcrmb" class="breadcrump">
<a class="breadcrump-link" itemprop="url" href="/" title="Kleinanzeigen ">
<span itemprop="title">Kleinanzeigen </span>
</a>
<a class="breadcrump-link" itemprop="url" href="/egal">
<span itemprop="title">{{ .Category }}</span></a>
</div>
</div>
{{ range $image := .Images }}
<div class="galleryimage-element" data-ix="3">
<img src="{{ $image }}"/>
</div>
{{ end }}
<h1 id="viewad-title" class="boxedarticle--title" itemprop="name" data-soldlabel="Verkauft">
{{ .Title }}</h1>
<div class="boxedarticle--flex--container">
<h2 class="boxedarticle--price" id="viewad-price">
{{ .Price }}</h2>
</div>
<div id="viewad-extra-info" class="boxedarticle--details--full">
<div><i class="icon icon-small icon-calendar-gray-simple"></i><span>{{ .Created }}</span></div>
</div>
<div class="splitlinebox l-container-row" id="viewad-details">
<ul class="addetailslist">
<li class="addetailslist--detail">
Zustand<span class="addetailslist--detail--value" >
{{ .Condition }}</span>
Farbe<span class="addetailslist--detail--value" >
{{ .Color }}</span>
Art<span class="addetailslist--detail--value" >
{{ .Type }}</span>
</li>
</ul>
</div>
<div class="l-container last-paragraph-no-margin-bottom">
<p id="viewad-description-text" class="text-force-linebreak " itemprop="description">
{{ .Text }}
</p>
</div>
</body>
</html>
`
const EMPTYPAGE string = `DOCTYPE html>
<html lang="de">
<head></head>
<body></body>
</html>
`
const (
EMPTYURI string = `https://www.kleinanzeigen.de/s-anzeige/empty/1`
INVALID503URI string = `https://www.kleinanzeigen.de/s-anzeige/503/1`
INVALIDPATHURI string = `https://www.kleinanzeigen.de/anzeige/name/1`
INVALID404URI string = `https://www.kleinanzeigen.de/anzeige/name/1/foo/bar`
INVALIDURI string = `https://foo.bar/weird/things`
)
var base = "kleingebaeck -c t/config-empty.conf"
type Tests struct {
name string
args string
expect string
exitcode int
}
var tests = []Tests{
{
name: "version",
args: base + " -V",
expect: "This is",
exitcode: 0,
},
{
name: "help",
args: base + " -h",
expect: "Usage:",
exitcode: 0,
},
{
name: "debug",
args: base + " -d",
expect: "error: invalid or no user id or no ad link specified",
exitcode: 1,
},
{
name: "debug-check-programinfo",
args: base + " -d",
expect: "pid:",
exitcode: 1,
},
{
name: "no-args-no-user",
args: base,
expect: "invalid or no user id",
exitcode: 1,
},
{
name: "download-single-ad",
args: base + " -o t/out https://www.kleinanzeigen.de/s-anzeige/first-ad/1",
expect: "Successfully downloaded 1 ad with 2 images to t/out",
exitcode: 0,
},
{
name: "download-single-ad-verbose",
args: base + " -o t/out https://www.kleinanzeigen.de/s-anzeige/first-ad/1 -v",
expect: "wrote ad listing",
exitcode: 0,
},
{
name: "download-single-ad-debug",
args: base + " -o t/out https://www.kleinanzeigen.de/s-anzeige/first-ad/1 -d",
expect: "DEBUG: extracted ad listing",
exitcode: 0,
},
{
name: "download-all-ads",
args: base + " -o t/out -u 1",
expect: "Successfully downloaded 7 ads with 16 images to t/out",
exitcode: 0,
},
{
name: "download-all-ads-using-config",
args: "kleingebaeck -c t/fullconfig.conf",
expect: "Successfully downloaded 7 ads with 16 images to t/out",
exitcode: 0,
},
}
var invalidtests = []Tests{
{
name: "empty-ad",
args: base + " " + EMPTYURI,
expect: "could not extract ad data from page, got empty struct",
exitcode: 1,
},
{
name: "invalid-ad",
args: base + " " + INVALIDURI,
expect: "invalid uri",
exitcode: 1,
},
{
name: "invalid-path",
args: base + " " + INVALIDPATHURI,
expect: "could not extract ad data from page, got empty struct",
exitcode: 1,
},
{
name: "404",
args: base + " " + INVALID404URI,
expect: "could not get page via HTTP",
exitcode: 1,
},
{
name: "outdir-no-exists",
args: base + " -o t/foo/bar/out https://www.kleinanzeigen.de/s-anzeige/first-ad/1 -v",
expect: "Failure",
exitcode: 1,
},
{
name: "wrong-flag",
args: base + " -X",
expect: "unknown shorthand flag: 'X' in -X",
exitcode: 1,
},
{
name: "no-config",
args: "kleingebaeck -c t/invalid.conf",
expect: "error loading config file",
exitcode: 1,
},
{
name: "503",
args: base + " " + INVALID503URI,
expect: "could not get page via HTTP",
exitcode: 1,
},
}
type AdConfig struct {
Title string
Slug string
ID string
Price string
Category string
Condition string
Type string
Color string
Created string
Text string
Images []string // files in ./t/
}
// used to generate ad listings returned by httpmock using templates
var adsrc = []AdConfig{
{
Title: "First Ad",
ID: "1", Price: "5€",
Category: "Klimbim",
Text: "Thing to sale",
Slug: "first-ad",
Condition: "Sehr Gut",
Color: "Grün",
Type: "Ball",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Secnd Ad",
ID: "2", Price: "5€",
Category: "Kram",
Text: "Thing to sale",
Slug: "second-ad",
Condition: "Gut",
Color: "Lila",
Type: "Schoki",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Third Ad",
ID: "3",
Price: "5€",
Category: "Kuddelmuddel",
Text: "Thing to sale",
Slug: "third-ad",
Condition: "In Ordnung",
Color: "Blau",
Type: "Auto",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Forth Ad",
ID: "4",
Price: "5€",
Category: "Krempel",
Text: "Thing to sale",
Slug: "fourth-ad",
Condition: "Neu",
Color: "Rot",
Type: "Spielzeut",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Fifth Ad",
ID: "5",
Price: "5€",
Category: "Kladderadatsch",
Text: "Thing to sale",
Slug: "fifth-ad",
Condition: "Sehr Gut",
Color: "Braun",
Type: "Parteibuch",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Sixth Ad",
ID: "6",
Price: "5€",
Category: "Klunker",
Text: "Thing to sale",
Slug: "sixth-ad",
Condition: "Sehr Gut",
Color: "Silber",
Type: "Ring",
Created: "Yesterday",
Images: []string{"t/1.jpg", "t/2.jpg"},
},
{
Title: "Ad with multiple img formats",
ID: "7",
Price: "5€",
Category: "Klunker",
Text: "Thing to sale",
Slug: "seventh-ad",
Condition: "Sehr Gut",
Color: "Gelpb",
Type: "Schmuck",
Created: "Yesterday",
Images: []string{"t/1.png", "t/1.gif", "t/1.webp", "t/1.jpg"},
},
}
// An Adsource is used to construct a httpmock responder for a
// particular url. So, the code (scrape.go) scrapes
// https://kleinanzeigen.de, but in reality httpmock captures the
// request and responds with our mock data
type Adsource struct {
uri string
content string
status int
}
// Render a HTML template for an adlisting or an ad
func GetTemplate(adconfigs []AdConfig, adconfig *AdConfig, htmltemplate string) string {
tmpl, err := tpl.New("template").Parse(htmltemplate)
if err != nil {
panic(err)
}
var out bytes.Buffer
if adconfig.ID == "" {
err = tmpl.Execute(&out, adconfigs)
} else {
err = tmpl.Execute(&out, adconfig)
}
if err != nil {
panic(err)
}
return out.String()
}
// Initialize the valid sources for the httpmock responder
func InitValidSources() []Adsource {
// valid ad listing page 1
list1 := []AdConfig{
adsrc[0], adsrc[1], adsrc[2],
}
// valid ad listing page 2
list2 := []AdConfig{
adsrc[3], adsrc[4], adsrc[5], adsrc[6],
}
// valid ad listing page 3, which is empty
list3 := []AdConfig{}
// used to signal GetTemplate() to render a listing
empty := AdConfig{}
// prepare urls for the listing pages
ads := []Adsource{
{
uri: fmt.Sprintf("%s%s?userId=1", Baseuri, Listuri),
content: GetTemplate(list1, &empty, LISTTPL),
},
{
uri: fmt.Sprintf("%s%s?userId=1&pageNum=2", Baseuri, Listuri),
content: GetTemplate(list2, &empty, LISTTPL),
},
{
uri: fmt.Sprintf("%s%s?userId=1&pageNum=3", Baseuri, Listuri),
content: GetTemplate(list3, &empty, LISTTPL),
},
}
// prepare urls for the ads
for _, ad := range adsrc {
ads = append(ads, Adsource{
uri: fmt.Sprintf("%s/s-anzeige/%s/%s", Baseuri, ad.Slug, ad.ID),
content: GetTemplate(nil, &ad, ADTPL),
})
}
return ads
}
func InitInvalidSources() []Adsource {
empty := AdConfig{}
ads := []Adsource{
{
// valid ad page but without content
uri: fmt.Sprintf("%s/s-anzeige/empty/1", Baseuri),
content: GetTemplate(nil, &empty, EMPTYPAGE),
},
{
// some random foreign webpage
uri: INVALIDURI,
content: GetTemplate(nil, &empty, "<html>foo</html>"),
},
{
// some invalid page path
uri: fmt.Sprintf("%s/anzeige/name/1", Baseuri),
content: GetTemplate(nil, &empty, "<html></html>"),
},
{
// some none-ad page
uri: fmt.Sprintf("%s/anzeige/name/1/foo/bar", Baseuri),
content: GetTemplate(nil, &empty, "<html>HTTP 404: /eine-anzeige/ does not exist!</html>"),
status: 404,
},
{
// valid ad page but 503
uri: fmt.Sprintf("%s/s-anzeige/503/1", Baseuri),
content: GetTemplate(nil, &empty, "<html>HTTP 503: service unavailable</html>"),
status: 503,
},
}
return ads
}
// load a test image from disk
func GetImage(path string) []byte {
dat, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return dat
}
// setup httpmock
func SetIntercept(ads []Adsource) {
headers := http.Header{}
headers.Add("Set-Cookie", "session=permanent")
for _, advertisement := range ads {
if advertisement.status == 0 {
advertisement.status = 200
}
httpmock.RegisterResponder("GET", advertisement.uri,
httpmock.NewStringResponder(advertisement.status, advertisement.content).HeaderAdd(headers))
}
// we just use 2 images, put this here
for _, image := range []string{"t/1.jpg", "t/2.jpg", "t/1.png", "t/1.gif", "t/1.webp"} {
httpmock.RegisterResponder("GET", image,
httpmock.NewBytesResponder(200, GetImage(image)).HeaderAdd(headers))
}
}
func VerifyAd(advertisement *AdConfig) error {
body := advertisement.Title + advertisement.Price + advertisement.ID + "Kleinanzeigen => " +
advertisement.Category + advertisement.Condition + advertisement.Created
// prepare ad dir name using DefaultAdNameTemplate
c := Config{Adnametemplate: "{{ .Slug }}"}
adstruct := Ad{Slug: advertisement.Slug, ID: advertisement.ID}
addir, err := AdDirName(&c, &adstruct)
if err != nil {
return err
}
file := fmt.Sprintf("t/out/%s/Adlisting.txt", addir)
content, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("unable to read adlisting file: %w", err)
}
if body != strings.TrimSpace(string(content)) {
msg := fmt.Sprintf("ad content doesn't match.\nExpect: %s\n Got: %s\n", body, content)
return errors.New(msg)
}
return nil
}
func TestMain(t *testing.T) {
oldargs := os.Args
defer func() { os.Args = oldargs }()
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// prepare httpmock responders
SetIntercept(InitValidSources())
// run commandline tests
for _, test := range tests {
var buf bytes.Buffer
os.Args = strings.Split(test.args, " ")
ret := Main(&buf)
if ret != test.exitcode {
t.Errorf("%s with cmd <%s> did not exit with %d but %d",
test.name, test.args, test.exitcode, ret)
}
if !strings.Contains(buf.String(), test.expect) {
t.Errorf("%s with cmd <%s> output did not match.\nExpect: %s\n Got: %s\n",
test.name, test.args, test.expect, buf.String())
}
}
// verify if downloaded ads match
for _, ad := range adsrc {
if err := VerifyAd(&ad); err != nil {
t.Error(err.Error())
}
}
}
func TestMainInvalids(t *testing.T) {
oldargs := os.Args
defer func() { os.Args = oldargs }()
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// prepare httpmock responders
SetIntercept(InitInvalidSources())
// run commandline tests
for _, test := range invalidtests {
var buf bytes.Buffer
os.Args = strings.Split(test.args, " ")
ret := Main(&buf)
if ret != test.exitcode {
t.Errorf("%s with cmd <%s> did not exit with %d but %d",
test.name, test.args, test.exitcode, ret)
}
if !strings.Contains(buf.String(), test.expect) {
t.Errorf("%s with cmd <%s> output did not match.\nExpect: %s\n Got: %s\n",
test.name, test.args, test.expect, buf.String())
}
}
}

View File

@@ -22,7 +22,12 @@ freebsd/amd64
linux/amd64
netbsd/amd64
openbsd/amd64
windows/amd64"
windows/amd64
freebsd/arm64
linux/arm64
netbsd/arm64
openbsd/arm64
windows/arm64"
tool="$1"
version="$2"
@@ -40,10 +45,21 @@ for D in $DIST; do
os=${D/\/*/}
arch=${D/*\//}
binfile="releases/${tool}-${os}-${arch}-${version}"
pie=""
if test "$os" = "windows"; then
binfile="${binfile}.exe"
fi
if test "$D" = "linux/amd64"; then
pie="-buildmode=pie"
fi
tardir="${tool}-${os}-${arch}-${version}"
tarfile="releases/${tool}-${os}-${arch}-${version}.tar.gz"
set -x
GOOS=${os} GOARCH=${arch} go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o ${binfile}
GOOS=${os} GOARCH=${arch} go build -tags osusergo,netgo -ldflags "-extldflags=-static -w" --trimpath $pie -o ${binfile}
strip --strip-all ${binfile}
mkdir -p ${tardir}
cp ${binfile} README.md LICENSE ${tardir}/
echo 'tool = kleingebaeck

268
scrape.go
View File

@@ -1,5 +1,5 @@
/*
Copyright © 2023 Thomas von Dein
Copyright © 2023-2025 Thomas von Dein
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
@@ -18,88 +18,47 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"errors"
"bytes"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"astuart.co/goq"
"golang.org/x/sync/errgroup"
)
type Index struct {
Links []string `goquery:".text-module-begin a,[href]"`
}
type Ad struct {
Title string `goquery:"h1"`
Slug string
Id string
Condition string
Category string
Price string `goquery:"h2#viewad-price"`
Created string `goquery:"#viewad-extra-info,text"`
Text string `goquery:"p#viewad-description-text,html"`
Images []string `goquery:".galleryimage-element img,[src]"`
Meta []string `goquery:".addetailslist--detail--value,text"`
}
func (ad *Ad) LogValue() slog.Value {
return slog.GroupValue(
slog.String("title", ad.Title),
slog.String("price", ad.Price),
slog.String("id", ad.Id),
slog.Int("imagecount", len(ad.Images)),
slog.Int("bodysize", len(ad.Text)),
)
}
// fetch some web page content
func Get(uri string, client *http.Client) (io.ReadCloser, error) {
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", Useragent)
res, err := client.Do(req)
if err != nil {
return nil, err
}
slog.Debug("response", "code", res.StatusCode, "status",
res.Status, "size", res.ContentLength)
return res.Body, nil
}
// extract links from all ad listing pages (that is: use pagination)
// and scrape every page
func Start(uid string, dir string, template string) error {
client := &http.Client{}
func ScrapeUser(fetch *Fetcher) error {
adlinks := []string{}
baseuri := Baseuri + Listuri + "?userId=" + uid
baseuri := fmt.Sprintf("%s%s?userId=%d", Baseuri, Listuri, fetch.Config.User)
page := 1
uri := baseuri
slog.Info("fetching ad pages", "user", uid)
slog.Info("fetching ad pages", "user", fetch.Config.User)
for {
var index Index
slog.Debug("fetching page", "uri", uri)
body, err := Get(uri, client)
body, err := fetch.Get(uri)
if err != nil {
return err
}
defer body.Close()
defer func() {
if err := body.Close(); err != nil {
panic(err)
}
}()
err = goq.NewDecoder(body).Decode(&index)
if err != nil {
return err
return fmt.Errorf("failed to goquery decode HTML index body: %w", err)
}
if len(index.Links) == 0 {
@@ -114,109 +73,182 @@ func Start(uid string, dir string, template string) error {
}
page++
uri = baseuri + "&pageNum=" + fmt.Sprintf("%d", page)
uri = baseuri + "&pageNum=" + strconv.Itoa(page)
}
for _, adlink := range adlinks {
err := Scrape(Baseuri+adlink, dir, template)
for index, adlink := range adlinks {
err := ScrapeAd(fetch, Baseuri+adlink)
if err != nil {
return err
}
if fetch.Config.Limit > 0 && index == fetch.Config.Limit-1 {
break
}
}
return nil
}
// scrape an ad. uri is the full uri of the ad, dir is the basedir
func Scrape(uri string, dir string, template string) error {
client := &http.Client{}
ad := &Ad{}
func ScrapeAd(fetch *Fetcher, uri string) error {
now := time.Now()
advertisement := &Ad{
Year: now.Format("2006"),
Month: now.Format("01"),
Day: now.Format("02"),
}
// extract slug and id from uri
uriparts := strings.Split(uri, "/")
if len(uriparts) < 6 {
return errors.New("invalid uri")
if len(uriparts) < SlugURIPartNum {
return fmt.Errorf("invalid uri: %s", uri)
}
ad.Slug = uriparts[4]
ad.Id = uriparts[5]
advertisement.Slug = uriparts[4]
advertisement.ID = uriparts[5]
// get the ad
slog.Debug("fetching ad page", "uri", uri)
body, err := Get(uri, client)
body, err := fetch.Get(uri)
if err != nil {
return err
}
defer body.Close()
defer func() {
if err := body.Close(); err != nil {
panic(err)
}
}()
// extract ad contents with goquery/goq
err = goq.NewDecoder(body).Decode(&ad)
err = goq.NewDecoder(body).Decode(&advertisement)
if err != nil {
return fmt.Errorf("failed to goquery decode HTML ad body: %w", err)
}
if len(advertisement.CategoryTree) > 0 {
advertisement.Category = strings.Join(advertisement.CategoryTree, " => ")
}
if advertisement.Incomplete() {
slog.Debug("got ad", "ad", advertisement)
return fmt.Errorf("could not extract ad data from page, got empty struct")
}
advertisement.DecodeAttributes()
advertisement.CalculateExpire()
// prepare ad dir name
addir, err := AdDirName(fetch.Config, advertisement)
if err != nil {
return err
}
if len(ad.Meta) == 2 {
ad.Category = ad.Meta[0]
ad.Condition = ad.Meta[1]
proceed := CheckAdVisited(fetch.Config, addir)
if !proceed {
return nil
}
slog.Debug("extracted ad listing", "ad", ad)
// write listing
err = WriteAd(dir, ad, template)
err = WriteAd(fetch.Config, advertisement, addir)
if err != nil {
return err
}
return ScrapeImages(dir, ad)
// tell the user
slog.Debug("extracted ad listing", "ad", advertisement)
// stats
fetch.Config.IncrAds()
// register for later checks
DirsVisited[addir] = 1
return ScrapeImages(fetch, advertisement, addir)
}
func ScrapeImages(dir string, ad *Ad) error {
func ScrapeImages(fetch *Fetcher, advertisement *Ad, addir string) error {
// fetch images
img := 1
var wg sync.WaitGroup
wg.Add(len(ad.Images))
failure := make(chan string)
adpath := filepath.Join(fetch.Config.Outdir, addir)
for _, imguri := range ad.Images {
file := fmt.Sprintf("%s/%d.jpg", dir, img)
go func() {
defer wg.Done()
err := Getimage(imguri, file)
// scan existing images, if any
cache, err := ReadImages(adpath, fetch.Config.ForceDownload)
if err != nil {
return err
}
egroup := new(errgroup.Group)
for _, imguri := range advertisement.Images {
imguri := imguri
// we append the suffix later in NewImage() based on image format
basefilename := filepath.Join(adpath, fmt.Sprintf("%d", img))
egroup.Go(func() error {
// wait a little
throttle := GetThrottleTime()
time.Sleep(throttle)
body, err := fetch.Getimage(imguri)
if err != nil {
failure <- err.Error()
return
return err
}
slog.Info("wrote ad image", "image", file)
}()
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(body)
if err != nil {
return fmt.Errorf("failed to read from image buffer: %w", err)
}
reader := bytes.NewReader(buf.Bytes())
image, err := NewImage(reader, basefilename, imguri)
if err != nil {
return err
}
err = image.CalcHash()
if err != nil {
return err
}
if !fetch.Config.ForceDownload {
if image.SimilarExists(cache) {
slog.Debug("similar image exists, not written", "uri", image.URI)
return nil
}
}
_, err = reader.Seek(0, 0)
if err != nil {
return fmt.Errorf("failed to seek(0) on image reader: %w", err)
}
err = WriteImage(image.Filename, reader)
if err != nil {
return err
}
slog.Debug("wrote image", "image", image, "size", buf.Len(), "throttle", throttle)
return nil
})
img++
}
close(failure)
wg.Wait()
goterr := <-failure
if goterr != "" {
return errors.New(goterr)
}
return nil
}
// fetch an image
func Getimage(uri, fileName string) error {
slog.Debug("fetching ad image", "uri", uri)
response, err := http.Get(uri)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return errors.New("received non 200 response code")
}
err = WriteImage(fileName, response.Body)
if err != nil {
return err
if err := egroup.Wait(); err != nil {
return fmt.Errorf("failed to finalize error waitgroup: %w", err)
}
fetch.Config.IncrImgs(len(advertisement.Images))
return nil
}

149
store.go
View File

@@ -1,5 +1,5 @@
/*
Copyright © 2023 Thomas von Dein
Copyright © 2023-2025 Thomas von Dein
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
@@ -18,37 +18,96 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"io"
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
tpl "text/template"
"time"
)
func WriteAd(dir string, ad *Ad, template string) error {
type OutdirData struct {
Year, Day, Month string
}
func OutDirName(conf *Config) (string, error) {
tmpl, err := tpl.New("outdir").Parse(conf.Outdir)
if err != nil {
return "", fmt.Errorf("failed to parse outdir template: %w", err)
}
buf := bytes.Buffer{}
now := time.Now()
data := OutdirData{
Year: now.Format("2006"),
Month: now.Format("01"),
Day: now.Format("02"),
}
err = tmpl.Execute(&buf, data)
if err != nil {
return "", fmt.Errorf("failed to execute outdir template: %w", err)
}
return buf.String(), nil
}
func AdDirName(conf *Config, advertisement *Ad) (string, error) {
tmpl, err := tpl.New("adname").Parse(conf.Adnametemplate)
if err != nil {
return "", fmt.Errorf("failed to parse adname template: %w", err)
}
buf := bytes.Buffer{}
err = tmpl.Execute(&buf, advertisement)
if err != nil {
return "", fmt.Errorf("failed to execute adname template: %w", err)
}
return buf.String(), nil
}
func WriteAd(conf *Config, advertisement *Ad, addir string) error {
// prepare output dir
dir = dir + "/" + ad.Slug
dir := filepath.Join(conf.Outdir, addir)
err := Mkdir(dir)
if err != nil {
return err
}
// write ad file
listingfile := strings.Join([]string{dir, "Adlisting.txt"}, "/")
f, err := os.Create(listingfile)
listingfile := filepath.Join(dir, "Adlisting.txt")
listingfd, err := os.Create(listingfile)
if err != nil {
return err
return fmt.Errorf("failed to create Adlisting.txt: %w", err)
}
defer func() {
if err := listingfd.Close(); err != nil {
panic(err)
}
}()
if runtime.GOOS == WIN {
advertisement.Text = strings.ReplaceAll(advertisement.Text, "<br/>", "\r\n")
} else {
advertisement.Text = strings.ReplaceAll(advertisement.Text, "<br/>", "\n")
}
ad.Text = strings.ReplaceAll(ad.Text, "<br/>", "\n")
tmpl, err := tpl.New("adlisting").Parse(template)
tmpl, err := tpl.New("adlisting").Parse(conf.Template)
if err != nil {
return err
return fmt.Errorf("failed to parse adlisting template: %w", err)
}
err = tmpl.Execute(f, ad)
err = tmpl.Execute(listingfd, advertisement)
if err != nil {
return err
return fmt.Errorf("failed to execute adlisting template: %w", err)
}
slog.Info("wrote ad listing", "listingfile", listingfile)
@@ -56,17 +115,71 @@ func WriteAd(dir string, ad *Ad, template string) error {
return nil
}
func WriteImage(filename string, reader io.ReadCloser) error {
func WriteImage(filename string, reader *bytes.Reader) error {
file, err := os.Create(filename)
if err != nil {
return err
return fmt.Errorf("failed to open image file: %w", err)
}
defer file.Close()
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
_, err = reader.WriteTo(file)
_, err = io.Copy(file, reader)
if err != nil {
return err
return fmt.Errorf("failed to write to image file: %w", err)
}
return nil
}
func ReadImage(filename string) (*bytes.Buffer, error) {
var buf bytes.Buffer
if !fileExists(filename) {
return nil, fmt.Errorf("image %s does not exist", filename)
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read image file: %w", err)
}
_, err = buf.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to write image into buffer: %w", err)
}
return &buf, nil
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if err != nil {
// return false on any error
return false
}
return !info.IsDir()
}
// check if an addir has already been processed by current run and
// decide what to do
func CheckAdVisited(conf *Config, adname string) bool {
if Exists(DirsVisited, adname) {
if conf.ForceDownload {
slog.Warn("an ad with the same name has already been downloaded, overwriting", "addir", adname)
return true
}
// don't overwrite
slog.Warn("an ad with the same name has already been downloaded, skipping (use -f to overwrite)", "addir", adname)
return false
}
// overwrite
return true
}

39
store_test.go Normal file
View File

@@ -0,0 +1,39 @@
/*
Copyright © 2023-2024 Thomas von Dein
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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"testing"
)
// this is a weird thing. WriteImage() is being called in scrape.go
// which is being tested by TestMain() in main_test.go. However, it
// doesn't show up in the coverage report for unknown reasons, so
// here's a single test for it
func TestWriteImage(t *testing.T) {
t.Parallel()
reader := bytes.NewReader([]byte{1, 2, 3, 4, 5, 6, 7, 8})
file := "t/out/t.jpg"
err := WriteImage(file, reader)
if err != nil {
t.Errorf("Could not write mock image to %s: %s", file, err.Error())
}
}

BIN
t/1.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 B

BIN
t/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

BIN
t/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
t/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
t/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

6
t/config-empty.conf Normal file
View File

@@ -0,0 +1,6 @@
# empty config for Main() unit tests to force unit tests NOT to use an
# eventually existing ~/.kleingebaeck!
template="""
{{.Title}}{{.Price}}{{.ID}}{{.Category}}{{.Condition}}{{.Created}}
"""

6
t/fullconfig.conf Normal file
View File

@@ -0,0 +1,6 @@
user = 1
loglevel = "verbose"
outdir = "t/out"
template="""
{{.Title}}{{.Price}}{{.ID}}{{.Category}}{{.Condition}}{{.Created}}
"""

13
t/httproot/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Mock http server
Install ehfs from https://github.com/mjpclab/extra-http-file-server/.
Install p2cli from https://github.com/wrouesnel/p2cli.
Run `templates/render.sh` to build the file structure.
Run `server.sh` to start the http server.
To scrape an ad from it, use such a URL:
http://localhost:8080/s-anzeige/first-ad/111-11-111

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

4
t/httproot/serve.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
ehfs -a :/s-anzeige:./kleinanzeigen \
-a :/api/v1/prod-ads/images/fc:./img \
-l localhost:8080 -I index.html

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<title>Ad Listing</title>
</head>
<body>
<div class="l-container-row">
<div id="vap-brdcrmb" class="breadcrump">
<a class="breadcrump-link" itemprop="url" href="/" title="Kleinanzeigen ">
<span itemprop="title">Kleinanzeigen </span>
</a>
<a class="breadcrump-link" itemprop="url" href="/egal">
<span itemprop="title">{{ category }}</span></a>
</div>
</div>
{% for image in images %}
<div class="galleryimage-element" data-ix="3">
<img src="http://localhost:8080/api/v1/prod-ads/images/fc/{{ image.id }}?rule=$_59.JPG"/>
</div>
{% endfor %}
<h1 id="viewad-title" class="boxedarticle--title" itemprop="name" data-soldlabel="Verkauft">
{{ title }}</h1>
<div class="boxedarticle--flex--container">
<h2 class="boxedarticle--price" id="viewad-price">
{{ price }}</h2>
</div>
<div id="viewad-extra-info" class="boxedarticle--details--full">
<div><i class="icon icon-small icon-calendar-gray-simple"></i><span>{{ created }}</span></div>
</div>
<div class="splitlinebox l-container-row" id="viewad-details">
<ul class="addetailslist">
<li class="addetailslist--detail">
Zustand<span class="addetailslist--detail--value" >
{{ condition }}</span>
</li>
</ul>
</div>
<div class="l-container last-paragraph-no-margin-bottom">
<p id="viewad-description-text" class="text-force-linebreak " itemprop="description">
{{ text }}
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de" >
<head>
<title>Ads</title>
</head>
<body>
{% for ad in ads %}
<h2 class="text-module-begin">
<a class="ellipsis"
href="/s-anzeige/{{ ad.slug }}/{{ ad.id }}">{{ ad.title }}</a>
</h2>
{% endfor %}
</body>
</html>

15
t/httproot/templates/render.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh -x
base="../kleinanzeigen"
rm -rf $base
mkdir -p $base
echo "Generating /s-bestandsliste.html"
p2cli -t index.tpl -i vars.yaml > $base/s-bestandsliste.html
for idx in 0 1; do
slug=$(cat vars.yaml | yq ".ads[$idx].slug")
id=$(cat vars.yaml | yq ".ads[$idx].id")
mkdir -p $base/$slug/$id
cat vars.yaml | yq ".ads[$idx]" | p2cli -t ad.tpl -f yaml > $base/$slug/$id/index.html
done

View File

@@ -0,0 +1,27 @@
ads:
- slug: first-ad
id: 111-11-111
title: First Ad
price: "19 €"
condition: "Sehr gut"
category: "Weitere Elektronik"
created: 21.12.2023
images:
- id: fcf6d664-5258-42c2-bf58-d1b8e9221574
- id: fcf6d664-5258-42c2-bf58-as43as5d43as
text: |
Zu Verkaufen.
Zahlung nur Paypal.
- slug: second-ad
id: 222-22-222
title: Second Ad
price: "200 €"
condition: "Sehr gut"
category: "Elektronik"
created: 21.12.2023
images:
- id: cdas4sd5-5258-42c2-bf58-d1b8e9221574
- id: cdas4sd5-5258-42c2-bf58-as43as5d43as
text: |
Zu Verkaufen.
Zahlung nur Überweisung.

1
t/invalid.conf Normal file
View File

@@ -0,0 +1 @@
user = "

40
util.go
View File

@@ -1,5 +1,5 @@
/*
Copyright © 2023 Thomas von Dein
Copyright © 2023-2024 Thomas von Dein
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
@@ -20,15 +20,21 @@ package main
import (
"bytes"
"errors"
"fmt"
"math/rand"
"os"
"os/exec"
"runtime"
"time"
"github.com/mattn/go-isatty"
)
func Mkdir(dir string) error {
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
err := os.Mkdir(dir, os.ModePerm)
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return err
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
@@ -39,7 +45,8 @@ func man() error {
man := exec.Command("less", "-")
var b bytes.Buffer
b.Write([]byte(manpage))
b.WriteString(manpage)
man.Stdout = os.Stdout
man.Stdin = &b
@@ -48,8 +55,31 @@ func man() error {
err := man.Run()
if err != nil {
return err
return fmt.Errorf("failed to execute 'less': %w", err)
}
return nil
}
// returns TRUE if stdout is NOT a tty or windows
func IsNoTty() bool {
if runtime.GOOS == WIN || !isatty.IsTerminal(os.Stdout.Fd()) {
return true
}
// it is a tty
return false
}
func GetThrottleTime() time.Duration {
return time.Duration(rand.Intn(MaxThrottle-MinThrottle+1)+MinThrottle) * time.Millisecond
}
// look if a key in a map exists, generic variant
func Exists[K comparable, V any](m map[K]V, v K) bool {
if _, ok := m[v]; ok {
return true
}
return false
}