Compare commits

...

458 Commits

Author SHA1 Message Date
5fdba040b5 release: bump to 1.0.413
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / e2e (push) Blocked by required conditions
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
2026-08-20 21:00:58 +00:00
d07e53a378 release: bump to 1.0.412 2026-08-20 20:01:03 +00:00
1b26be2667 release: bump to 1.0.411 2026-08-20 19:01:01 +00:00
7250b3d05e release: bump to 1.0.410 2026-08-20 18:01:00 +00:00
2f56ed38f2 release: bump to 1.0.409 2026-08-20 17:01:00 +00:00
d82eecfc30 release: bump to 1.0.408 2026-08-20 16:00:58 +00:00
5ddedf5a2e release: bump to 1.0.407 2026-08-20 15:01:00 +00:00
ba745011ff release: bump to 1.0.406 2026-08-20 14:00:59 +00:00
1075fd341e release: bump to 1.0.405 2026-08-20 13:00:57 +00:00
35a05f7493 release: bump to 1.0.404 2026-08-20 12:00:56 +00:00
062243e99b release: bump to 1.0.403 2026-08-20 11:00:57 +00:00
928a9b28e8 release: bump to 1.0.402 2026-08-20 10:00:58 +00:00
0e5d012855 release: bump to 1.0.401 2026-08-20 09:01:00 +00:00
7c662ae87c release: bump to 1.0.400 2026-08-20 08:00:58 +00:00
41b0a77d9d release: bump to 1.0.399 2026-08-20 07:00:55 +00:00
19ba32e5b1 release: bump to 1.0.398 2026-08-20 06:00:56 +00:00
c6dd01f423 release: bump to 1.0.397 2026-08-20 05:00:56 +00:00
f16e202469 release: bump to 1.0.396 2026-08-20 04:00:54 +00:00
b4ff71d9f7 release: bump to 1.0.395 2026-08-20 03:00:56 +00:00
d86416c080 release: bump to 1.0.394 2026-08-20 02:00:53 +00:00
aaef8dd576 release: bump to 1.0.393 2026-08-20 01:00:57 +00:00
369cb33b40 release: bump to 1.0.392 2026-08-20 00:00:55 +00:00
d41f2956f1 release: bump to 1.0.391 2026-08-19 23:00:56 +00:00
b0c8c948e1 release: bump to 1.0.390 2026-08-19 22:00:55 +00:00
ee51218daa release: bump to 1.0.389 2026-08-19 21:00:55 +00:00
a1024d52dc release: bump to 1.0.388 2026-08-19 20:00:54 +00:00
6ca806aad9 release: bump to 1.0.387 2026-08-19 19:00:57 +00:00
e4acf2542b release: bump to 1.0.386 2026-08-19 18:00:56 +00:00
67630624ca release: bump to 1.0.385 2026-08-19 17:00:57 +00:00
3b2c3fef61 release: bump to 1.0.384 2026-08-19 16:00:58 +00:00
6892f0c332 release: bump to 1.0.383 2026-08-19 15:00:57 +00:00
8fdc462309 release: bump to 1.0.382 2026-08-19 14:00:57 +00:00
e50636ace3 release: bump to 1.0.381 2026-08-19 13:00:55 +00:00
3c3d815354 release: bump to 1.0.380 2026-08-19 12:00:58 +00:00
7e870448da release: bump to 1.0.379 2026-08-19 11:00:57 +00:00
378447abd1 release: bump to 1.0.378 2026-08-19 10:00:59 +00:00
aec0268cd2 release: bump to 1.0.377 2026-08-19 09:00:54 +00:00
7129180d76 release: bump to 1.0.376 2026-08-19 08:00:57 +00:00
2103653f09 release: bump to 1.0.375 2026-08-19 07:00:54 +00:00
01ebb2da77 release: bump to 1.0.374 2026-08-19 06:00:57 +00:00
cbb778cd7a release: bump to 1.0.373 2026-08-19 05:00:56 +00:00
3bca60fabc release: bump to 1.0.372 2026-08-19 04:00:54 +00:00
756a89aa22 release: bump to 1.0.371 2026-08-19 03:00:59 +00:00
e48b255046 release: bump to 1.0.370 2026-08-19 02:00:58 +00:00
3cf21bb0ab release: bump to 1.0.369 2026-08-19 01:00:57 +00:00
3da11e464a release: bump to 1.0.368 2026-08-19 00:00:54 +00:00
b1a0b74cab release: bump to 1.0.367 2026-08-18 23:00:58 +00:00
66423155cd release: bump to 1.0.366 2026-08-18 22:00:55 +00:00
8880d1da1d release: bump to 1.0.365 2026-08-18 21:00:55 +00:00
f1720ad2a5 release: bump to 1.0.364 2026-08-18 20:00:55 +00:00
6ca92f2f9f release: bump to 1.0.363 2026-08-18 19:00:58 +00:00
dcc72ac815 release: bump to 1.0.362 2026-08-18 18:00:56 +00:00
5642bce508 release: bump to 1.0.361 2026-08-18 17:00:58 +00:00
29345f26fa release: bump to 1.0.360 2026-08-18 16:00:55 +00:00
db1d6928e2 release: bump to 1.0.359 2026-08-18 15:00:55 +00:00
95e331b011 release: bump to 1.0.358 2026-08-18 14:00:57 +00:00
23027b020e release: bump to 1.0.357 2026-08-18 13:00:55 +00:00
033260c188 release: bump to 1.0.356 2026-08-18 12:00:54 +00:00
878c67b9ab release: bump to 1.0.355 2026-08-18 11:00:55 +00:00
9e78327282 release: bump to 1.0.354 2026-08-18 10:00:58 +00:00
16a33212c5 release: bump to 1.0.353 2026-08-18 09:00:57 +00:00
9b13275d64 release: bump to 1.0.352 2026-08-18 08:00:55 +00:00
ef9c2cea69 release: bump to 1.0.351 2026-08-18 07:00:55 +00:00
a553010f0e release: bump to 1.0.350 2026-08-18 06:00:56 +00:00
de6efee453 release: bump to 1.0.349 2026-08-18 05:00:58 +00:00
cf62866d54 release: bump to 1.0.348 2026-08-18 04:00:56 +00:00
73038a0b4b release: bump to 1.0.347 2026-08-18 03:00:59 +00:00
9ceb2677a9 release: bump to 1.0.346 2026-08-18 02:00:57 +00:00
6a23f3340f release: bump to 1.0.345 2026-08-18 01:00:57 +00:00
b67a443b98 release: bump to 1.0.344 2026-08-18 00:53:29 +00:00
84d61e4134 release: bump to 1.0.343 2026-08-17 22:00:52 +00:00
ce95fbf2c5 release: bump to 1.0.342 2026-08-17 21:00:54 +00:00
07c9e30494 release: bump to 1.0.341 2026-08-17 20:00:55 +00:00
b5f02a2722 release: bump to 1.0.340 2026-08-17 19:00:57 +00:00
0f413397c1 release: bump to 1.0.339 2026-08-17 18:00:57 +00:00
78d4b574c7 release: bump to 1.0.338 2026-08-17 17:00:52 +00:00
2ba0c06a50 release: bump to 1.0.337 2026-08-17 16:00:53 +00:00
067acb03ca release: bump to 1.0.336 2026-08-17 15:00:56 +00:00
632b6be2c4 release: bump to 1.0.335 2026-08-17 14:00:57 +00:00
65f352facf release: bump to 1.0.334 2026-08-17 13:00:55 +00:00
5475691107 release: bump to 1.0.333 2026-08-17 12:00:55 +00:00
90df519831 release: bump to 1.0.332 2026-08-17 11:00:52 +00:00
c164610f69 release: bump to 1.0.331 2026-08-17 10:00:57 +00:00
a1e9a77dc7 release: bump to 1.0.330 2026-08-17 09:00:56 +00:00
53597d85d7 release: bump to 1.0.329 2026-08-17 08:00:56 +00:00
3915c10e97 release: bump to 1.0.328 2026-08-17 07:00:56 +00:00
7953339e63 release: bump to 1.0.327 2026-08-17 06:00:57 +00:00
cdd26ca3bf release: bump to 1.0.326 2026-08-17 05:00:52 +00:00
45128d882f release: bump to 1.0.325 2026-08-17 04:00:57 +00:00
478190211c release: bump to 1.0.324 2026-08-17 03:00:52 +00:00
9ae921b2eb release: bump to 1.0.323 2026-08-17 02:00:56 +00:00
c400129bc3 release: bump to 1.0.322 2026-08-17 01:00:53 +00:00
a7148c48b5 release: bump to 1.0.321 2026-08-17 00:00:56 +00:00
6a6820fd99 release: bump to 1.0.320 2026-08-16 23:00:55 +00:00
542ade7174 release: bump to 1.0.319 2026-08-16 22:00:53 +00:00
297fd2354e release: bump to 1.0.318 2026-08-16 21:00:54 +00:00
a39c97a631 release: bump to 1.0.317 2026-08-16 20:00:55 +00:00
235de24d6f release: bump to 1.0.316 2026-08-16 19:00:57 +00:00
a2f8a3f51e release: bump to 1.0.315 2026-08-16 18:00:57 +00:00
c6f0694b44 release: bump to 1.0.314 2026-08-16 17:00:56 +00:00
0eca22bfff release: bump to 1.0.313 2026-08-16 16:00:56 +00:00
6e0c211c2b release: bump to 1.0.312 2026-08-16 15:00:55 +00:00
9d916889f7 release: bump to 1.0.311 2026-08-16 14:00:53 +00:00
4efb2807b9 release: bump to 1.0.310 2026-08-16 13:00:54 +00:00
94f9d0de19 release: bump to 1.0.309 2026-08-16 12:00:53 +00:00
8404e74332 release: bump to 1.0.308 2026-08-16 11:00:56 +00:00
a03fc885f9 release: bump to 1.0.307 2026-08-16 10:00:53 +00:00
c5ff0c70ac release: bump to 1.0.306 2026-08-16 09:00:56 +00:00
e646d9b80a release: bump to 1.0.305 2026-08-16 08:00:54 +00:00
7dd0b92521 release: bump to 1.0.304 2026-08-16 07:00:53 +00:00
77c97b0466 release: bump to 1.0.303 2026-08-16 06:00:53 +00:00
97e5487eb8 release: bump to 1.0.302 2026-08-16 05:00:56 +00:00
c7f0ab16d5 release: bump to 1.0.301 2026-08-16 04:00:57 +00:00
f56de5c365 release: bump to 1.0.300 2026-08-16 03:00:53 +00:00
baf5d45631 release: bump to 1.0.299 2026-08-16 02:00:57 +00:00
eaec15fd97 release: bump to 1.0.298 2026-08-16 01:00:52 +00:00
4eb11456b8 release: bump to 1.0.297 2026-08-16 00:00:57 +00:00
4b04237177 release: bump to 1.0.296 2026-08-15 23:00:56 +00:00
0c92b45774 release: bump to 1.0.295 2026-08-15 22:00:53 +00:00
d573c5df39 release: bump to 1.0.294 2026-08-15 21:00:55 +00:00
5db0dbca1d release: bump to 1.0.293 2026-08-15 20:00:57 +00:00
1f6dcccaac release: bump to 1.0.292 2026-08-15 19:00:53 +00:00
291c8193d8 release: bump to 1.0.291 2026-08-15 18:00:58 +00:00
121cb46253 release: bump to 1.0.290 2026-08-15 17:00:53 +00:00
e48975e353 release: bump to 1.0.289 2026-08-15 16:00:54 +00:00
e5032fe654 release: bump to 1.0.288 2026-08-15 15:00:55 +00:00
d19548d23d release: bump to 1.0.287 2026-08-15 14:00:55 +00:00
3c4ed7124f release: bump to 1.0.286 2026-08-15 13:00:57 +00:00
6c67dfa828 release: bump to 1.0.285 2026-08-15 12:00:54 +00:00
650dadf6aa release: bump to 1.0.284 2026-08-15 11:00:54 +00:00
98c395b72e release: bump to 1.0.283 2026-08-15 10:00:56 +00:00
c97256233a release: bump to 1.0.282 2026-08-15 09:00:56 +00:00
68c7a5b352 release: bump to 1.0.281 2026-08-15 08:00:55 +00:00
b1f9784617 release: bump to 1.0.280 2026-08-15 07:00:56 +00:00
5be4d1c382 release: bump to 1.0.279 2026-08-15 06:00:54 +00:00
671a71875f release: bump to 1.0.278 2026-08-15 05:00:56 +00:00
862446fdae release: bump to 1.0.277 2026-08-15 04:00:57 +00:00
a700d7d254 release: bump to 1.0.276 2026-08-15 03:00:53 +00:00
e64ef32a91 release: bump to 1.0.275 2026-08-15 02:00:53 +00:00
cd02cda979 release: bump to 1.0.274 2026-08-15 01:00:55 +00:00
9c3692488e release: bump to 1.0.273 2026-08-15 00:00:57 +00:00
c7af045f7d release: bump to 1.0.272 2026-08-14 23:00:53 +00:00
446bf0cf2c release: bump to 1.0.271 2026-08-14 22:00:55 +00:00
0c99e7c413 release: bump to 1.0.270 2026-08-14 21:00:57 +00:00
cf81d94b7c release: bump to 1.0.269 2026-08-14 20:00:54 +00:00
f2b0d39ede release: bump to 1.0.268 2026-08-14 19:00:54 +00:00
90aaebbbc3 release: bump to 1.0.267 2026-08-14 18:00:55 +00:00
29b8756a63 release: bump to 1.0.266 2026-08-14 17:00:56 +00:00
46d7c9487f release: bump to 1.0.265 2026-08-14 16:00:56 +00:00
6448786b43 release: bump to 1.0.264 2026-08-14 15:00:56 +00:00
7cb8b6ec24 release: bump to 1.0.263 2026-08-14 14:00:56 +00:00
350b6e1f71 release: bump to 1.0.262 2026-08-14 13:00:57 +00:00
e9f4c62b7b release: bump to 1.0.261 2026-08-14 12:00:54 +00:00
519b82f678 release: bump to 1.0.260 2026-08-14 11:00:57 +00:00
bb1ddd8a24 release: bump to 1.0.259 2026-08-14 10:00:53 +00:00
9b793520a0 release: bump to 1.0.258 2026-08-14 09:00:56 +00:00
f9d08e972b release: bump to 1.0.257 2026-08-14 08:00:55 +00:00
13157388d8 release: bump to 1.0.256 2026-08-14 07:00:58 +00:00
8c04280029 release: bump to 1.0.255 2026-08-14 06:00:54 +00:00
07266196a8 release: bump to 1.0.254 2026-08-14 05:00:55 +00:00
321a40f30f release: bump to 1.0.253 2026-08-14 04:00:53 +00:00
4d4f054af6 release: bump to 1.0.252 2026-08-14 03:00:55 +00:00
f40a228778 release: bump to 1.0.251 2026-08-14 02:00:54 +00:00
e2aa6417d3 release: bump to 1.0.250 2026-08-14 01:00:54 +00:00
8b497352c7 release: bump to 1.0.249 2026-08-14 00:00:52 +00:00
fe3c6aae6e release: bump to 1.0.248 2026-08-13 23:00:53 +00:00
afb9098253 release: bump to 1.0.247 2026-08-13 22:00:53 +00:00
55009bd900 release: bump to 1.0.246 2026-08-13 21:00:55 +00:00
eabee494d4 release: bump to 1.0.245 2026-08-13 20:00:54 +00:00
3ac5fadc0b release: bump to 1.0.244 2026-08-13 19:00:56 +00:00
23712f4e9e release: bump to 1.0.243 2026-08-13 18:00:57 +00:00
74b108a95f release: bump to 1.0.242 2026-08-13 17:00:56 +00:00
8039728ca9 release: bump to 1.0.241 2026-08-13 16:00:54 +00:00
3685245f3c release: bump to 1.0.240 2026-08-13 15:00:54 +00:00
808ca4b753 release: bump to 1.0.239 2026-08-13 14:00:56 +00:00
be7408520e release: bump to 1.0.238 2026-08-13 13:00:55 +00:00
519ce2941c release: bump to 1.0.237 2026-08-13 12:00:56 +00:00
3b41defcca release: bump to 1.0.236 2026-08-13 11:00:56 +00:00
5f68c016a0 release: bump to 1.0.235 2026-08-13 10:00:55 +00:00
1b0d1bead7 release: bump to 1.0.234 2026-08-13 09:00:55 +00:00
ca43956db7 release: bump to 1.0.233 2026-08-13 08:00:53 +00:00
a40aa3e03e release: bump to 1.0.232 2026-08-13 07:00:57 +00:00
be1bfb1b0e release: bump to 1.0.231 2026-08-13 06:00:56 +00:00
530258e916 release: bump to 1.0.230 2026-08-13 05:00:54 +00:00
38006d6812 release: bump to 1.0.229 2026-08-13 04:00:55 +00:00
4d42179858 release: bump to 1.0.228 2026-08-13 03:00:55 +00:00
d45f067201 release: bump to 1.0.227 2026-08-13 02:00:57 +00:00
4d438f5249 release: bump to 1.0.226 2026-08-13 01:00:55 +00:00
9f82010e6e release: bump to 1.0.225 2026-08-13 00:00:55 +00:00
4cf63ffa22 release: bump to 1.0.224 2026-08-12 23:00:57 +00:00
ed62576ff0 release: bump to 1.0.223 2026-08-12 22:00:56 +00:00
254ae852d6 release: bump to 1.0.222 2026-08-12 21:00:57 +00:00
794204fc2b release: bump to 1.0.221 2026-08-12 20:00:54 +00:00
87da8bc970 release: bump to 1.0.220 2026-08-12 19:00:57 +00:00
b8579d1699 release: bump to 1.0.219 2026-08-12 18:00:54 +00:00
371713215e release: bump to 1.0.218 2026-08-12 17:00:54 +00:00
3869b7b284 release: bump to 1.0.217 2026-08-12 16:00:55 +00:00
2d5be25af5 release: bump to 1.0.216 2026-08-12 15:00:58 +00:00
92cef13a7d release: bump to 1.0.215 2026-08-12 14:00:57 +00:00
62f693543f release: bump to 1.0.214 2026-08-12 13:00:53 +00:00
468d18e275 release: bump to 1.0.213 2026-08-12 12:00:55 +00:00
45a71be07d release: bump to 1.0.212 2026-08-12 11:00:55 +00:00
188f44b67a release: bump to 1.0.211 2026-08-12 10:00:55 +00:00
263b5cb46b release: bump to 1.0.210 2026-08-12 09:00:56 +00:00
4d507fd49b release: bump to 1.0.209 2026-08-12 08:00:55 +00:00
f5f116415a release: bump to 1.0.208 2026-08-12 07:00:56 +00:00
b4e1f8c77c release: bump to 1.0.207 2026-08-12 06:00:54 +00:00
2da61948ea release: bump to 1.0.206 2026-08-12 05:00:56 +00:00
2cb8eb2653 release: bump to 1.0.205 2026-08-12 04:00:56 +00:00
f918b7a161 release: bump to 1.0.204 2026-08-12 03:00:55 +00:00
8552591d16 release: bump to 1.0.203 2026-08-12 02:00:54 +00:00
22e49505f4 release: bump to 1.0.202 2026-08-12 01:00:59 +00:00
95dc025694 release: bump to 1.0.201 2026-08-12 00:00:54 +00:00
46f9c5d490 release: bump to 1.0.200 2026-08-11 23:00:58 +00:00
db34f27147 release: bump to 1.0.199 2026-08-11 22:00:53 +00:00
7462fad703 release: bump to 1.0.198 2026-08-11 21:00:57 +00:00
95938fea4e release: bump to 1.0.197 2026-08-11 20:00:54 +00:00
59fe21aaa7 release: bump to 1.0.196 2026-08-11 19:00:55 +00:00
d3770a8188 release: bump to 1.0.195 2026-08-11 18:00:57 +00:00
c1b836db54 release: bump to 1.0.194 2026-08-11 17:00:57 +00:00
8cb8f5229d release: bump to 1.0.193 2026-08-11 16:00:56 +00:00
40ebeb3788 release: bump to 1.0.192 2026-08-11 15:00:55 +00:00
4db86a2a91 release: bump to 1.0.191 2026-08-11 14:00:57 +00:00
1b50c65ecb release: bump to 1.0.190 2026-08-11 13:00:57 +00:00
837fb283ff release: bump to 1.0.189 2026-08-11 12:00:53 +00:00
b7d0660aa7 release: bump to 1.0.188 2026-08-11 11:00:57 +00:00
ccc6aca82f release: bump to 1.0.187 2026-08-11 10:00:57 +00:00
94c3c3a504 release: bump to 1.0.186 2026-08-11 09:00:53 +00:00
31e7236aa9 release: bump to 1.0.185 2026-08-11 08:00:57 +00:00
683bf5b20b release: bump to 1.0.184 2026-08-11 07:00:58 +00:00
0888cee547 release: bump to 1.0.183 2026-08-11 06:00:57 +00:00
aa606830f9 release: bump to 1.0.182 2026-08-11 05:00:55 +00:00
f23b261307 release: bump to 1.0.181 2026-08-11 04:00:59 +00:00
5069862fd1 release: bump to 1.0.180 2026-08-11 03:24:52 +00:00
2c07f69f36 release: bump to 1.0.179 2026-08-10 23:00:18 +00:00
8352efa219 release: bump to 1.0.178 2026-08-10 22:00:17 +00:00
4f999547eb release: bump to 1.0.177 2026-08-10 21:00:18 +00:00
6217ceea24 release: bump to 1.0.176 2026-08-10 20:00:19 +00:00
67fccb6b30 release: bump to 1.0.175 2026-08-10 19:00:18 +00:00
fd30c2ba9a release: bump to 1.0.174 2026-08-10 18:00:19 +00:00
2b54013a77 release: bump to 1.0.173 2026-08-10 17:00:15 +00:00
65a84fca6e release: bump to 1.0.172 2026-08-10 16:00:19 +00:00
9694dc686a release: bump to 1.0.171 2026-08-10 15:00:17 +00:00
28f3f24c46 release: bump to 1.0.170 2026-08-10 14:00:18 +00:00
1fd782880b release: bump to 1.0.169 2026-08-10 13:00:16 +00:00
cdfcff82d8 release: bump to 1.0.168 2026-08-10 12:00:19 +00:00
ba4c7d941b release: bump to 1.0.167 2026-08-10 11:00:16 +00:00
83bd5a6137 release: bump to 1.0.166 2026-08-10 10:00:18 +00:00
a64fec24e6 release: bump to 1.0.165 2026-08-10 09:00:17 +00:00
ee8579deed release: bump to 1.0.164 2026-08-10 08:00:19 +00:00
7e5655e0dd release: bump to 1.0.163 2026-08-10 07:00:16 +00:00
2a03f56204 release: bump to 1.0.162 2026-08-10 06:00:19 +00:00
4c6eba2042 release: bump to 1.0.161 2026-08-10 05:00:18 +00:00
2de789871c release: bump to 1.0.160 2026-08-10 04:00:16 +00:00
bbd4c57c90 release: bump to 1.0.159 2026-08-10 03:00:15 +00:00
8da6376dd0 release: bump to 1.0.158 2026-08-10 02:00:15 +00:00
f926f30819 release: bump to 1.0.157 2026-08-10 01:00:17 +00:00
66c3e9750d release: bump to 1.0.156 2026-08-10 00:00:18 +00:00
de6382cb49 release: bump to 1.0.155 2026-08-09 23:00:18 +00:00
f8a1691cac release: bump to 1.0.154 2026-08-09 22:00:19 +00:00
908399922b release: bump to 1.0.153 2026-08-09 21:00:17 +00:00
d01e28dcda release: bump to 1.0.152 2026-08-09 20:00:18 +00:00
3c7f18ff25 release: bump to 1.0.151 2026-08-09 19:00:19 +00:00
90a74e85c9 release: bump to 1.0.150 2026-08-09 18:00:16 +00:00
98947d885a release: bump to 1.0.149 2026-08-09 17:00:18 +00:00
83f8e3620d release: bump to 1.0.148 2026-08-09 16:00:15 +00:00
daf9c19880 release: bump to 1.0.147 2026-08-09 15:00:16 +00:00
e73b53af9d release: bump to 1.0.146 2026-08-09 14:00:16 +00:00
564b5e325e release: bump to 1.0.145 2026-08-09 13:00:17 +00:00
620e1f822f release: bump to 1.0.144 2026-08-09 12:00:16 +00:00
2f144f260f release: bump to 1.0.143 2026-08-09 11:00:17 +00:00
7b459eeb85 release: bump to 1.0.142 2026-08-09 10:00:18 +00:00
1d6cdc7561 release: bump to 1.0.141 2026-08-09 09:00:16 +00:00
5afa60af84 release: bump to 1.0.140 2026-08-09 08:00:16 +00:00
6508a4e07f release: bump to 1.0.139 2026-08-09 07:00:19 +00:00
b9bfeb1383 release: bump to 1.0.138 2026-08-09 06:00:16 +00:00
f897dbccca release: bump to 1.0.137 2026-08-09 05:00:20 +00:00
3fe7e8a922 release: bump to 1.0.136 2026-08-09 04:00:16 +00:00
707416c1b3 release: bump to 1.0.135 2026-08-09 03:00:18 +00:00
21dd83ad7d release: bump to 1.0.134 2026-08-09 02:00:19 +00:00
5236d8a5be release: bump to 1.0.133 2026-08-09 01:00:15 +00:00
17560f089f release: bump to 1.0.132 2026-08-09 00:00:19 +00:00
80d0610a28 release: bump to 1.0.131 2026-08-08 23:00:16 +00:00
9929e53b12 release: bump to 1.0.130 2026-08-08 22:00:18 +00:00
8053febf30 release: bump to 1.0.129 2026-08-08 21:00:20 +00:00
7491627c7b release: bump to 1.0.128 2026-08-08 20:00:16 +00:00
5881960fe2 release: bump to 1.0.127 2026-08-08 19:00:15 +00:00
4816403d7c release: bump to 1.0.126 2026-08-08 18:00:15 +00:00
33489e5a7c release: bump to 1.0.125 2026-08-08 17:00:16 +00:00
2c5817109b release: bump to 1.0.124 2026-08-08 16:00:19 +00:00
8fe3669e56 release: bump to 1.0.123 2026-08-08 15:00:20 +00:00
d89757b3f0 release: bump to 1.0.122 2026-08-08 14:00:17 +00:00
fe78b9e7a2 release: bump to 1.0.121 2026-08-08 13:00:15 +00:00
1aed4b36a8 release: bump to 1.0.120 2026-08-08 12:00:18 +00:00
d16153df7b release: bump to 1.0.119 2026-08-08 11:00:18 +00:00
6b4145e497 release: bump to 1.0.118 2026-08-08 10:00:17 +00:00
1b6a094142 release: bump to 1.0.117 2026-08-08 09:00:18 +00:00
2ef869118d release: bump to 1.0.116 2026-08-08 08:00:16 +00:00
46ad79d3ab release: bump to 1.0.115 2026-08-08 07:00:17 +00:00
b72d355b3b release: bump to 1.0.114 2026-08-08 06:00:17 +00:00
d9e61d8125 release: bump to 1.0.113 2026-08-08 05:00:17 +00:00
6484d7bbc3 release: bump to 1.0.112 2026-08-08 04:00:17 +00:00
3e029f07be release: bump to 1.0.111 2026-08-08 03:00:20 +00:00
44908b2170 release: bump to 1.0.110 2026-08-08 02:00:18 +00:00
ec3605c0cd release: bump to 1.0.109 2026-08-08 01:00:15 +00:00
56620e5ee9 release: bump to 1.0.108 2026-08-08 00:00:21 +00:00
bfba777f50 release: bump to 1.0.107 2026-08-07 23:00:18 +00:00
4affbd5687 release: bump to 1.0.106 2026-08-07 22:00:16 +00:00
c1346e14e6 release: bump to 1.0.105 2026-08-07 21:00:17 +00:00
e5d1db6df5 release: bump to 1.0.104 2026-08-07 20:00:19 +00:00
2e04a00c8e release: bump to 1.0.103 2026-08-07 19:00:17 +00:00
cded9507da release: bump to 1.0.102 2026-08-07 18:00:20 +00:00
62ba4c0fee release: bump to 1.0.101 2026-08-07 17:00:17 +00:00
5f14c21177 release: bump to 1.0.100 2026-08-07 16:00:19 +00:00
e4c40813c7 release: bump to 1.0.099 2026-08-07 15:00:19 +00:00
d82985d014 release: bump to 1.0.098 2026-08-07 14:00:16 +00:00
78936c08a6 release: bump to 1.0.097 2026-08-07 13:00:19 +00:00
ff0c4bea86 release: bump to 1.0.096 2026-08-07 12:00:20 +00:00
cef335603a release: bump to 1.0.095 2026-08-07 11:00:19 +00:00
543e6e8aab release: bump to 1.0.094 2026-08-07 10:00:20 +00:00
b5930c6ac5 release: bump to 1.0.093 2026-08-07 09:00:19 +00:00
ef84619d35 release: bump to 1.0.092 2026-08-07 08:00:16 +00:00
addd0afb04 release: bump to 1.0.091 2026-08-07 07:00:16 +00:00
0cba38b37d release: bump to 1.0.090 2026-08-07 06:00:17 +00:00
d1d18a7908 release: bump to 1.0.089 2026-08-07 05:00:16 +00:00
c6b4720e6d release: bump to 1.0.088 2026-08-07 04:00:18 +00:00
7ccd43b4c6 release: bump to 1.0.087 2026-08-07 03:00:18 +00:00
3c2d7dd967 release: bump to 1.0.086 2026-08-07 02:00:18 +00:00
a8fa138442 release: bump to 1.0.085 2026-08-07 01:00:17 +00:00
d497295d58 release: bump to 1.0.084 2026-08-07 00:00:15 +00:00
3065b4856a release: bump to 1.0.083 2026-08-06 23:00:16 +00:00
58280f4f9a release: bump to 1.0.082 2026-08-06 22:00:18 +00:00
6559206842 release: bump to 1.0.081 2026-08-06 21:00:21 +00:00
de1fa5f1e9 release: bump to 1.0.080 2026-08-06 20:00:18 +00:00
5875a7fa08 release: bump to 1.0.079 2026-08-06 19:10:33 +00:00
f30a2efb49 release: bump to 1.0.078 2026-08-06 18:00:36 +00:00
4306014866 release: bump to 1.0.077 2026-08-06 17:00:37 +00:00
2e466588a1 release: bump to 1.0.076 2026-08-06 16:00:38 +00:00
bbbd664265 release: bump to 1.0.075 2026-08-06 15:00:37 +00:00
347b47cc95 release: bump to 1.0.074 2026-08-06 14:00:35 +00:00
83c5594ee6 release: bump to 1.0.073 2026-08-06 13:00:39 +00:00
a72190aa97 release: bump to 1.0.072 2026-08-06 12:00:38 +00:00
0de6a52396 release: bump to 1.0.071 2026-08-06 11:00:35 +00:00
8ebc19c06b release: bump to 1.0.070 2026-08-06 10:00:34 +00:00
0e6d9b794d release: bump to 1.0.069 2026-08-06 09:00:39 +00:00
4b368e4555 release: bump to 1.0.068 2026-08-06 08:00:39 +00:00
53bd1076b1 release: bump to 1.0.067 2026-08-06 07:00:39 +00:00
df4cf863de release: bump to 1.0.066 2026-08-06 06:00:37 +00:00
436e4ee72d release: bump to 1.0.065 2026-08-06 05:00:38 +00:00
d4f4e3b93f release: bump to 1.0.064 2026-08-06 04:00:36 +00:00
bea6456d68 release: bump to 1.0.063 2026-08-06 03:00:37 +00:00
bed6aa7495 release: bump to 1.0.062 2026-08-06 02:00:35 +00:00
9ffd06fa58 release: bump to 1.0.061 2026-08-06 01:00:35 +00:00
125607e8fc release: bump to 1.0.060 2026-08-06 00:00:38 +00:00
bb4f2340fe release: bump to 1.0.059 2026-08-05 23:00:35 +00:00
7fcbb2b17e release: bump to 1.0.058 2026-08-05 22:00:38 +00:00
3efe527a06 release: bump to 1.0.057 2026-08-05 21:00:36 +00:00
74cf5ce806 release: bump to 1.0.056 2026-08-05 20:00:34 +00:00
fe047b0b34 release: bump to 1.0.055 2026-08-05 19:00:36 +00:00
05d10a5474 release: bump to 1.0.054 2026-08-05 18:00:34 +00:00
7a283ef1c0 release: bump to 1.0.053 2026-08-05 17:00:39 +00:00
f23eca3a87 release: bump to 1.0.052 2026-08-05 16:00:37 +00:00
b44e48073a release: bump to 1.0.051 2026-08-05 15:00:39 +00:00
f764188b23 release: bump to 1.0.050 2026-08-05 14:00:39 +00:00
00a9a253dc release: bump to 1.0.049 2026-08-05 13:00:40 +00:00
2687e164fa release: bump to 1.0.048 2026-08-05 12:00:35 +00:00
3f9903607c release: bump to 1.0.047 2026-08-05 11:00:36 +00:00
3ce5397bde release: bump to 1.0.046 2026-08-05 10:00:36 +00:00
9d350b51da release: bump to 1.0.045 2026-08-05 09:00:37 +00:00
88cc6313b8 release: bump to 1.0.044 2026-08-05 08:00:38 +00:00
0f514d9641 release: bump to 1.0.043 2026-08-05 07:00:36 +00:00
aa5fcdadbe release: bump to 1.0.042 2026-08-05 06:00:38 +00:00
4d03fd8479 release: bump to 1.0.041 2026-08-05 05:00:38 +00:00
ff27c1c642 release: bump to 1.0.040 2026-08-05 04:00:36 +00:00
d655ede8f8 release: bump to 1.0.039 2026-08-05 03:00:35 +00:00
f859b1eb5e release: bump to 1.0.038 2026-08-05 02:00:36 +00:00
9bd5524451 release: bump to 1.0.037 2026-08-05 01:00:34 +00:00
c5f23af12e release: bump to 1.0.036 2026-08-05 00:00:35 +00:00
77a9307d90 release: bump to 1.0.035 2026-08-04 23:00:35 +00:00
d44d690392 release: bump to 1.0.034 2026-08-04 22:00:37 +00:00
c0f55a91ac release: bump to 1.0.033 2026-08-04 21:00:35 +00:00
77051d3b1d release: bump to 1.0.032 2026-08-04 20:00:37 +00:00
e2017a5793 release: bump to 1.0.031 2026-08-04 19:00:37 +00:00
3b79a41115 release: bump to 1.0.030 2026-08-04 18:00:36 +00:00
c4960126f1 release: bump to 1.0.029 2026-08-04 17:00:35 +00:00
7a536bceea release: bump to 1.0.028 2026-08-04 16:00:39 +00:00
a1861d19f9 release: bump to 1.0.027 2026-08-04 15:00:39 +00:00
25cf7e35b5 release: bump to 1.0.026 2026-08-04 14:00:36 +00:00
b4f43e1d95 release: bump to 1.0.025 2026-08-04 13:00:36 +00:00
49a047d8b6 release: bump to 1.0.024 2026-08-04 12:00:38 +00:00
1476bce574 release: bump to 1.0.023 2026-08-04 11:00:37 +00:00
043c8e39b4 release: bump to 1.0.022 2026-08-04 10:00:38 +00:00
a2acb594d4 release: bump to 1.0.021 2026-08-04 09:00:38 +00:00
2480f6a758 release: bump to 1.0.020 2026-08-04 08:00:37 +00:00
8529819847 release: bump to 1.0.019 2026-08-04 07:00:37 +00:00
b8645220d2 release: bump to 1.0.018 2026-08-04 06:00:35 +00:00
8bf68b152d release: bump to 1.0.017 2026-08-04 05:00:35 +00:00
8046730278 release: bump to 1.0.016 2026-08-04 04:00:36 +00:00
dd8733b60d release: bump to 1.0.015 2026-08-04 03:00:34 +00:00
542da65db5 release: bump to 1.0.014 2026-08-04 02:00:37 +00:00
3e2ad1034b release: bump to 1.0.013 2026-08-04 01:53:06 +00:00
411a73d2ee release: bump to 1.0.012 2026-07-08 12:59:59 +00:00
01557bc0e0 release: bump to 1.0.011 2026-07-08 12:00:02 +00:00
64e6aca3c8 release: bump to 1.0.010 2026-07-08 11:00:01 +00:00
2a70972001 release: bump to 1.0.009 2026-07-08 10:00:00 +00:00
1aad0d838f release: bump to 1.0.008 2026-07-08 09:00:03 +00:00
c02be42339 release: bump to 1.0.007 2026-07-08 08:00:04 +00:00
f526f6c056 release: bump to 1.0.006 2026-07-08 07:00:00 +00:00
db9b3d7403 release: bump to 1.0.005 2026-07-08 06:00:03 +00:00
c6189cba06 release: bump to 1.0.004 2026-07-08 05:00:03 +00:00
bd36ed75d1 release: bump to 1.0.003 2026-07-08 04:00:04 +00:00
f132b926f7 release: bump to 1.0.002 2026-07-08 03:00:04 +00:00
2352e02fc9 fix: start archive scheduler in background thread when serving 2026-07-08 02:45:50 +00:00
60e99e521d ci: trigger fresh run 2026-07-08 02:22:09 +00:00
13dbb6b058 release: bump to 1.0.001 2026-07-08 02:00:03 +00:00
1882d63b31 fix(ci): mount entire tests/playwright dir to fix config resolution 2026-07-08 01:57:58 +00:00
59d0545808 fix: convert playwright config to JS, add git credentials to release push 2026-07-08 01:50:34 +00:00
72903dc6dd fix(ci): fix indentation in Playwright test step (YAML parse error) 2026-07-08 00:44:30 +00:00
7135d8b808 ci: trigger workflow run 2026-07-08 00:43:07 +00:00
35f67723d4 fix(ci): mount test files individually, install playwright@1.51.0 in container to avoid node_modules version conflict 2026-07-08 00:28:18 +00:00
771facb878 chore(ci): remove redundant pr-check.yml, ci.yml covers all workflows 2026-07-08 00:22:26 +00:00
cae5c4f4a0 fix(ci): pin playwright@1.51.0 and set PLAYWRIGHT_BROWSERS_PATH for baked-in browsers 2026-07-08 00:20:40 +00:00
fec577ef23 fix(ci): replace grep -oP with portable grep -oE for BusyBox 2026-07-08 00:13:48 +00:00
cf3101e18f ci: trigger pipeline 2026-07-07 23:45:56 +00:00
d7e7c02838 feat: reserved CI port range 10000-10099, document convention 2026-07-07 23:27:12 +00:00
c27bd399ec feat: CI auto-detects and shifts port to avoid production conflicts 2026-07-07 23:15:11 +00:00
e47dd2315e fix: skip e2e on push to main, improve timeline CSS 2026-07-07 23:09:31 +00:00
31035119fe feat: timeline layout with dates on left grouped by day 2026-07-07 23:01:16 +00:00
1298f88df8 fix(ci): isolate e2e container name to prevent killing production 2026-07-07 22:54:17 +00:00
9c2ffabfbf fix: remove stale container before e2e app start 2026-07-07 22:47:20 +00:00
80ff136c31 feat: add Playwright E2E tests to CI
- 15 tests: home, listing, detail, nav, status, RSS/Atom, theme toggle
- CI e2e job seeds test DB (80 articles, 3 sources) in Docker container
- Tests run in mcr.microsoft.com/playwright:v1.51.0-jammy image
- Port 5000 published for health check from runner
- node_modules + test-results excluded from git
2026-07-07 22:45:52 +00:00
4c5e936ca8 fix: stable named volume for data persistence across deploys 2026-07-07 22:05:56 +00:00
5efa6e4284 fix: use pure shell instead of python3 for version parsing in runner 2026-07-07 21:18:07 +00:00
3fc5b2d58f fix: use GITHUB_OUTPUT file in bump step too 2026-07-07 20:05:33 +00:00
7fbb84dce2 fix: use python3 instead of jq for JSON parsing in runner 2026-07-07 20:05:13 +00:00
3a32a89e34 fix: add missing import os for environ access 2026-07-07 19:47:25 +00:00
f7fdcb374a ci: use Gitea container registry instead of release branches 2026-07-07 19:36:39 +00:00
54f57a0668 Merge pull request 'ci: add PR check and release/deploy workflows' (#39) from ci-fix into main
Reviewed-on: https://git.example.com/jarianc/NewsArchiverV2/pulls/39
2026-07-07 14:25:55 -05:00
b3dfe17ef9 ci: add CODEOWNERS for official reviews 2026-07-07 19:20:54 +00:00
0ae66c921b Merge branch 'main' into ci-fix 2026-07-07 19:19:34 +00:00
1c4f6b809e fix: lowercase docker image name for uppercase repo 2026-07-07 19:18:10 +00:00
0ad3fbb68d ci: add PR check and release/deploy workflows with version.json 2026-07-07 18:35:30 +00:00
5aa4a98e51 Merge pull request 'fix: fix docker build for CI' (#37) from ci-fix into main
Reviewed-on: https://git.example.com/jarianc/NewsArchiverV2/pulls/37
2026-07-05 19:14:03 -05:00
976e29087f fix: fix docker build for CI 2026-07-05 23:59:38 +00:00
c68b3012dc Merge pull request 'Fix meta tags (#30), favicon (#31), aria-labels (#29)' (#36) from fix/ope-hardening-2 into main 2026-07-04 23:27:05 -05:00
11a91ee297 fix: add meta tags (#30), favicon (#31), aria-labels (#29) 2026-07-05 04:26:50 +00:00
1ce7dc94a1 Merge pull request 'OPE hardening: logging, Docker, deps, scheduler, cleanup' (#35) from fix/ope-hardening into main 2026-07-04 23:14:23 -05:00
a7936b8b11 fix: OPE hardening - logging, Docker, deps, scheduler, cleanup
- #12: Remove duplicate logging.basicConfig() from 10 modules
- #15: Remove redundant import re in rebuild_database.py
- #17: rglob('*') → rglob('*.html/json/txt/xml/md') for speed
- #18: Dockerfile individual COPY → glob COPY *.py/*.json + .dockerignore
- #19: Remove deprecated docker-compose version field
- #20: Pin requirements.txt versions (flask, requests, etc.)
- #22: SIGALRM → threading.Timer for multi-threaded safety
- #23: AP regex parsing → BeautifulSoup selectors
2026-07-05 04:14:05 +00:00
db9fa92c40 Merge pull request 'Security hardening: auth, CSRF, path traversal, XSS, tests' (#34) from fix/security-and-tests into main 2026-07-04 23:08:51 -05:00
7565366138 fix: tests + URL-encoded path traversal fix + fix test files
- Add URL-decode in validate_archive_path to block ..%2F encoded traversal
- Rewrite test_storage_manager.py (was corrupted, #14)
- Rewrite test_path_handling.py (remove hardcoded macOS path, #13)
- Add test_web_interface.py (auth, CSRF, headers, XSS tests)
- All 11 tests pass
- Fixes: #1 path traversal, #2 XSS, #13 hardcoded path, #14 corrupted tests
2026-07-05 04:06:06 +00:00
0a411d13cf Merge pull request 'CI: remove --no-cache for docker layer caching' (#33) from ci-fix-nocache into main
Reviewed-on: https://git.example.com/jarianc/NewsArchiverV2/pulls/33
2026-07-04 22:23:44 -05:00
5afeb290a7 CI: remove --no-cache for docker layer caching 2026-07-05 03:13:52 +00:00
aa7fc1f1e6 CI: add generalized workflow 2026-07-05 02:46:35 +00:00
e93b5cee3e fix: security hardening - auth, CSRF, path traversal, XSS, secrets, headers
- #3: Path traversal fix in /archive and /archive-file routes via resolve() check
- #4: SSRF mitigation - env-based SERVER_URL, no hardcoded internal IPs
- #5: Stored XSS fix - remove |safe filter from article.html template
- #6: Missing import os in scheduler.py (crash on import)
- #7: Flask auth (password via NEWSARCHIVER_PASSWORD) + CSRF tokens
- #8: Same as #5 (template XSS via |safe)
- #9: Motley Fool API key removed - use env var interpolation
- #10: Hardcoded paths in setup_cron.sh, stop_services.sh - use BASH_SOURCE
- #11: Hardcoded user paths in singlefile_archive.py - use Path.home()
- #16: HTTP RSS feeds updated to HTTPS (Barchart, Guardian, BBC, MarketWatch)
- #24: SSRF - replace hardcoded 192.168.8.150:5000 with NEWSARCHIVER_SERVER_URL
- #25: Command execution details sanitized in error messages
- #26: Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, CSP)
- #27: Auth guard on all routes except RSS/Atom feeds
- archive_engine.py: Add missing import os
2026-07-04 05:23:05 +00:00
eb6228d3af most deletes 2026-03-31 10:16:38 -05:00
ff24fd0db5 added docker support 2026-03-31 10:15:50 -05:00
09d81e8cda fix singlefile archived links on the UI 2026-03-31 09:55:44 -05:00
0f1b2741db Current state of NA 2026-03-31 09:37:36 -05:00
51 changed files with 14343 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
__pycache__
*.pyc
.git
.gitignore
*.md
nohup.out
*.log
tests/
archival_data/
cache.db

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# NewsArchiver Environment Variables
# Copy this file to .env and edit with your values
# Directory where archived files will be stored
# This is perfect for NAS mounting
ARCHIVE_DIR=/data/archives
# Optional: Web server configuration
# WEB_HOST=0.0.0.0
# WEB_PORT=5000
# Optional: Logging level (DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO

247
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,247 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
GITEA_URL: https://git.example.com
jobs:
lint:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run ruff (Python lint)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install ruff
ruff check .
else
echo "No Python project detected, skipping ruff"
fi
- name: Run npm lint (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run lint --if-present || true
else
echo "No Node.js project detected, skipping npm lint"
fi
test:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run pytest (Python)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
python3 -m pip install --upgrade pip
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
pip3 install pytest
pytest tests/ -v --tb=short 2>/dev/null || true
else
echo "No Python project detected, skipping pytest"
fi
- name: Run npm test (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run test --if-present || true
else
echo "No Node.js project detected, skipping npm test"
fi
- name: Run Go tests
if: always()
run: |
if [[ -f go.mod ]]; then
go test ./...
else
echo "No Go project detected, skipping go test"
fi
e2e:
runs-on: ubuntu-latest
needs: [docker-build]
if: github.event_name == 'pull_request'
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Create test network
run: docker network create newsarchiver-network 2>/dev/null || true
- name: Start app container
run: |
docker rm -f newsarchiver-e2e 2>/dev/null || true
docker run -d --name newsarchiver-e2e \
--network newsarchiver-network \
-e CI=true \
-e CI_PORT_OFFSET=1 \
-e ADMIN_PASSWORD="" \
-e ARCHIVE_DIR=/data/archives \
-e DISABLE_RSS_FETCH=1 \
jarianc/newsarchiverv2:test \
python run_archiver.py --serve --host 0.0.0.0 --port 5000
- name: Discover app port
id: port
run: |
sleep 2
# CI port range 10000-10099: port = 10000 + CI_PORT_OFFSET
# NewsArchiverV2 uses offset 1, so port = 10001
CI_LOG=$(docker logs newsarchiver-e2e 2>&1 | grep "\[ci-port-shift\]" || echo "")
if [ -n "$CI_LOG" ]; then
APP_PORT=$(echo "$CI_LOG" | grep -oE 'to [0-9]+' | grep -oE '[0-9]+$')
echo "app_port=${APP_PORT}" >> $GITHUB_OUTPUT
echo "$CI_LOG"
else
# Fallback: compute from offset
OFFSET=${CI_PORT_OFFSET:-1}
echo "app_port=$((10000 + OFFSET))" >> $GITHUB_OUTPUT
echo "No CI port shift in logs, computed port $((10000 + OFFSET))"
fi
- name: Seed test data
run: |
sleep 3
docker exec newsarchiver-e2e python3 -c "
import sqlite3, os
db = os.environ.get('ARCHIVE_DIR', '/app/archival_data') + '/cache.db'
conn = sqlite3.connect(db)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT, source_name TEXT NOT NULL,
article_url TEXT NOT NULL UNIQUE, article_guid TEXT, title TEXT,
author TEXT, publish_date TEXT, content_text TEXT, content_html TEXT,
archive_file_path TEXT, metadata_file_path TEXT,
status TEXT DEFAULT 'pending', error_message TEXT,
extraction_method TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source_name)')
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(article_url)')
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status)')
now = '2026-07-07 12:00:00'
sources = ['Test News', 'Daily Wire', 'Tech Today']
for i in range(1, 81):
src = sources[(i-1) % len(sources)]
c.execute('INSERT OR IGNORE INTO articles (source_name, article_url, title, publish_date, content_text, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
(src, f'https://test.com/a/{i}', f'Test Article {i}', f'2026-07-{(i % 28) + 1:02d} 10:00:00', f'Content for article {i}.', 'archived', now))
conn.commit()
conn.close()
print('Seeded 80 test articles across 3 sources')
"
- name: Wait for app
run: |
APP_PORT="${{ steps.port.outputs.app_port }}"
sleep 3
# Health check from inside container (no host port publish needed)
for i in $(seq 1 30); do
docker exec newsarchiver-e2e curl -sf "http://localhost:${APP_PORT}/" && echo "App ready on port ${APP_PORT}" && exit 0
sleep 2
done
echo "App failed to start" && exit 1
- name: Run Playwright tests
run: |
APP_PORT="${{ steps.port.outputs.app_port }}"
docker run --rm \
--network newsarchiver-network \
-v $GITHUB_WORKSPACE/tests/playwright:/tests \
-w /tests \
-e APP_URL=http://newsarchiver-e2e:${APP_PORT} \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
mcr.microsoft.com/playwright:v1.51.0-jammy \
sh -c "npm install @playwright/test@1.51.0 && npx playwright test"
- name: Cleanup
if: always()
run: |
docker rm -f newsarchiver-e2e || true
docker network rm newsarchiver-network 2>/dev/null || true
docker-build:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Build Docker image
if: always()
run: |
if [[ -f Dockerfile ]]; then
docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
else
echo "No Dockerfile found, skipping docker build"
fi
security:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run bandit (Python SAST)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install bandit
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
else
echo "No Python project detected, skipping bandit"
fi
- name: Run npm audit (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
else
echo "No Node.js project detected, skipping npm audit"
fi
build-result:
needs: [lint, test, docker-build, security, e2e]
runs-on: ubuntu-latest
container:
image: gitea-job-image
if: always()
steps:
- name: Summary
run: echo "All CI checks completed"

View File

@ -0,0 +1,101 @@
name: Release & Deploy
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
env:
GITEA_URL: https://git.example.com
REGISTRY: git.example.com
DEPLOY_SCRIPT: /home/user/deploy/deploy.sh
IMAGE_NAME: newsarchiver
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 https://user:pass@git.example.com/${{ github.repository }} $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout main
- name: Read version
id: version
run: |
MAJOR=$(grep '"major"' version.json | sed 's/.*: *//; s/[^0-9]//g')
MINOR=$(grep '"minor"' version.json | sed 's/.*: *//; s/[^0-9]//g')
PATCH=$(grep '"patch"' version.json | sed 's/.*: *//; s/[^0-9]//g')
PATCH_PADDED=$(printf "%03d" "$PATCH")
FULL="${MAJOR}.${MINOR}.${PATCH_PADDED}"
echo "version=${FULL}" >> $GITHUB_OUTPUT
echo "Current version: ${FULL}"
- name: Check if deploy needed
id: check
run: |
VERSION_FILE="/home/user/deploy/deployed/newsarchiver.version"
CURRENT_VERSION="${{ steps.version.outputs.version }}"
if [ -f "$VERSION_FILE" ]; then
DEPLOYED_VERSION=$(cat "$VERSION_FILE" | cut -d: -f2)
echo "Deployed: $DEPLOYED_VERSION"
if [ "$CURRENT_VERSION" = "$DEPLOYED_VERSION" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "No new version to deploy"
exit 0
fi
fi
echo "skip=false" >> $GITHUB_OUTPUT
- name: Bump patch version
if: steps.check.outputs.skip != 'true'
id: bump
run: |
PATCH=$(grep '"patch"' version.json | sed 's/.*: *//; s/[^0-9]//g')
NEW_PATCH=$((PATCH + 1))
PATCH_PADDED=$(printf "%03d" "$NEW_PATCH")
MAJOR=$(grep '"major"' version.json | sed 's/.*: *//; s/[^0-9]//g')
MINOR=$(grep '"minor"' version.json | sed 's/.*: *//; s/[^0-9]//g')
FULL="${MAJOR}.${MINOR}.${PATCH_PADDED}"
sed -i "s/\"patch\": ${PATCH}/\"patch\": ${NEW_PATCH}/" version.json
echo "release_version=${FULL}" >> $GITHUB_OUTPUT
echo "Bumped to ${FULL}"
git config user.email "bot@example.com"
git config user.name "CI Release Bot"
git add version.json
git commit -m "release: bump to ${FULL}"
git remote set-url origin https://user:pass@git.example.com/${{ github.repository }}
git push origin main
git tag -a "v${FULL}" -m "release: ${FULL}"
git push origin "v${FULL}"
- name: Login to Gitea Registry
if: steps.check.outputs.skip != 'true'
run: |
echo "REDACTED" | docker login --username jarianc --password-stdin ${{ env.REGISTRY }}
- name: Build & Push to Registry
if: steps.check.outputs.skip != 'true'
id: build
run: |
TAG="${{ steps.bump.outputs.release_version }}"
REGISTRY_IMAGE="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${TAG}"
LATEST_IMAGE="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest"
docker build -t "$REGISTRY_IMAGE" .
docker tag "$REGISTRY_IMAGE" "$LATEST_IMAGE"
docker push "$REGISTRY_IMAGE"
docker push "$LATEST_IMAGE"
echo "registry_image=$REGISTRY_IMAGE" >> $GITHUB_OUTPUT
- name: Deploy
if: steps.check.outputs.skip != 'true'
run: |
REGISTRY_IMAGE="${{ steps.build.outputs.registry_image }}"
bash "$DEPLOY_SCRIPT" newsarchiver "$REGISTRY_IMAGE" "http://127.0.0.1:5000/"

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# Python
__pycache__/
*.pyc
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environments
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Project specific
archival_data/
*.log
*.db
*.sqlite
*.sqlite3
# SingleFile
singlefile-*.html
# OS
.DS_Store
Thumbs.db
*.pid
# Node
node_modules/
package-lock.json
test-results/

45
AGENTS.md Normal file
View File

@ -0,0 +1,45 @@
# CI Port Convention
## Reserved Range: 10000-10099
CI jobs never use production ports. Each service gets a fixed offset (1-99) within the 10000-10099 range.
**Formula:** `CI_PORT = 10000 + CI_PORT_OFFSET`
## Port Assignments
| Offset | CI Port | Service |
|--------|---------|---------|
| 1 | 10001 | NewsArchiverV2 |
| 2 | 10002 | paste-bin |
| 3-99 | 10003-10099 | Reserved for future services |
## Usage
**CI workflow:**
```yaml
docker run -d --name myapp-e2e \
--network test-network \
-e CI=true \
-e CI_PORT_OFFSET=1 \
myapp:test \
python main.py --serve --port 5000
# App auto-shifts to port 10001
```
**App code (any service):**
```python
if os.environ.get("CI") == "true" and os.environ.get("SKIP_PORT_SHIFT") != "1":
offset = int(os.environ.get("CI_PORT_OFFSET", "1"))
args.port = 10000 + offset
print(f"[ci-port-shift] Port shifted from {original} to {args.port}")
```
**Production:** Never sets `CI=true` or `CI_PORT_OFFSET`. Ports stay unchanged.
## Rules
1. CI jobs always set `CI=true` and the service's `CI_PORT_OFFSET`
2. Port discovery via log parsing: `docker logs | grep "\[ci-port-shift\]"`
3. Container-to-container traffic uses Docker networks, never host port publish
4. Production containers never have `CI=true`

1
CODEOWNERS Normal file
View File

@ -0,0 +1 @@
* @jarianc

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies for Playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Create app directory structure
RUN mkdir -p /app/archival_data
# Set environment variable for archive directory (can be overridden)
ENV ARCHIVE_DIR=/app/archival_data
# Copy application code
COPY *.py ./
COPY *.json ./
# Copy templates and static directories if they exist
COPY templates/ ./templates/
COPY static/ ./static/
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose Flask port
EXPOSE 5000
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Default command
CMD ["python", "run_archiver.py", "--serve", "--host", "0.0.0.0", "--port", "5000"]

View File

@ -124,6 +124,52 @@ The SQLite database (`archival_data/cache.db`) stores:
- Flask, Trafilatura, feedparser, APScheduler, requests, beautifulsoup4
- SingleFile CLI (optional, for web page archiving)
## Docker Deployment
The NewsArchiver can be deployed using Docker for easier management and isolation.
### Quick Start with Docker
```bash
# Build the Docker image
docker build -t newsarchiver .
# Run with default settings (archives stored in container)
docker run -p 5000:5000 newsarchiver
# Run with NAS storage mount
docker run -p 5000:5000 \
-v /path/to/nas/backup:/data/archives \
-e ARCHIVE_DIR=/data/archives \
newsarchiver
```
### Using Docker Compose
```bash
# Edit docker-compose.yml to configure your NAS mount path
vim docker-compose.yml
# Start the service
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the service
docker-compose down
```
### Configuration
The `ARCHIVE_DIR` environment variable controls where archived files are stored. To use NAS storage:
1. Edit `docker-compose.yml` and update the volume mount path
2. Set `ARCHIVE_DIR` to match the container path (e.g., `/data/archives`)
3. Restart the container
The archived data will persist even if the container is removed, as it's stored in a Docker volume or mounted NAS directory.
## Stopping Services
To stop all NewsArchiver services:

222
ap_processor.py Normal file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""AP News Processor for NewsArchiver - Phase 2.6
Processes AP News front page to extract article URLs and archive them.
Uses direct HTML parsing since AP doesn't provide RSS feeds.
"""
import argparse
import json
import logging
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import List
try:
import requests
except ImportError:
print("WARNING: requests not installed. URL fetching may not work.")
print("Install with: pip install requests")
requests = None
try:
from bs4 import BeautifulSoup
except ImportError:
print("WARNING: beautifulsoup4 not installed. AP parsing may not work.")
print("Install with: pip install beautifulsoup4")
BeautifulSoup = None
try:
import sqlite3
except ImportError:
print("ERROR: sqlite3 is required (should be built-in)")
sys.exit(1)
try:
import storage_manager
except ImportError:
print("ERROR: storage_manager module not found")
sys.exit(1)
try:
from rss_processor import is_duplicate
except ImportError:
print("ERROR: rss_processor module not found")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
ARCHIVE_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
def fetch_ap_frontpage(timeout: int = 30) -> str:
"""Fetch AP News front page HTML.
Args:
timeout: Request timeout in seconds
Returns:
HTML string or empty string on failure
"""
if requests is None:
logger.error("requests library not available")
return ""
url = "https://apnews.com"
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, timeout=timeout, headers=headers)
response.raise_for_status()
logger.info("Fetched AP front page: %s", url)
return response.text
except Exception as e:
logger.error("Failed to fetch AP front page: %s", str(e))
return ""
def extract_article_links(html: str) -> List[str]:
"""Extract AP article URLs from HTML using BeautifulSoup.
Args:
html: Raw HTML string
Returns:
List of unique article URLs
"""
if BeautifulSoup is None:
logger.error("BeautifulSoup not available for link extraction")
return []
soup = BeautifulSoup(html, 'html.parser')
urls = []
for link in soup.find_all('a', href=True):
href = link['href']
if href.startswith('https://apnews.com/article/'):
if href not in urls:
urls.append(href)
logger.info("Extracted %d unique article links", len(urls))
return urls
def process_ap_articles(
output_dir: Path,
db_path: Path = ARCHIVE_DIR / 'cache.db',
dry_run: bool = False
) -> dict:
"""Process AP News articles from front page.
Args:
output_dir: Output directory for archived content
db_path: SQLite cache database path
dry_run: If True, preview without archiving
Returns:
Dictionary with results summary
"""
results = {
'source': 'Associated Press',
'processed': 0,
'archived': 0,
'skipped': 0,
'failed': 0,
'urls': []
}
html = fetch_ap_frontpage()
if not html:
logger.error("Failed to fetch AP front page")
return results
article_urls = extract_article_links(html)
for article_url in article_urls:
results['processed'] += 1
results['urls'].append(article_url)
if is_duplicate(article_url, 'Associated Press', db_path):
logger.debug("Skipping duplicate: %s", article_url[:60])
results['skipped'] += 1
continue
if dry_run:
logger.info("[DRY-RUN] Would archive: %s", article_url[:60])
results['archived'] += 1
continue
try:
from archive_engine import archive_and_extract
extraction_result = archive_and_extract(
article_url,
'Associated Press',
output_dir
)
if extraction_result['success']:
results['archived'] += 1
storage_manager.save_article('Associated Press', extraction_result['article_data'])
else:
results['failed'] += 1
logger.error("Failed to archive %s: %s", article_url[:60],
extraction_result.get('error', 'Unknown error'))
except Exception as e:
results['failed'] += 1
logger.error("Error processing %s: %s", article_url[:60], str(e))
time.sleep(0.5)
logger.info("AP processing complete: %d processed, %d archived, %d skipped, %d failed",
results['processed'], results['archived'], results['skipped'], results['failed'])
return results
def main():
parser = argparse.ArgumentParser(description='AP News Front Page Processor')
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
help='Output directory for archived content')
parser.add_argument('--dry-run', action='store_true', help='Preview without archiving')
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
logger.info("=" * 60)
logger.info("AP News Front Page Processor - Phase 2.6")
logger.info("=" * 60)
storage_manager.initialize_storage()
results = process_ap_articles(args.output, ARCHIVE_DIR / 'cache.db', args.dry_run)
print("\n" + "=" * 60)
print("AP NEWS PROCESSING COMPLETE")
print("=" * 60)
print(f"Processed: {results['processed']}")
print(f"Archived: {results['archived']}")
print(f"Skipped: {results['skipped']}")
print(f"Failed: {results['failed']}")
if results['urls']:
print("\nArticle URLs:")
for url in results['urls'][:10]:
print(f" - {url[:70]}")
if len(results['urls']) > 10:
print(f" ... and {len(results['urls']) - 10} more")
print("=" * 60)
if __name__ == '__main__':
main()

538
archive_engine.py Normal file
View File

@ -0,0 +1,538 @@
#!/usr/bin/env python3
"""Archive Engine for NewsArchiver - Phase 2.5
Orchestrates the archiving process:
- RSS polling
- Page archiving with SingleFile
- Content extraction
- Storage management
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
try:
import storage_manager
from rss_processor import fetch_rss_feed, is_duplicate, save_article as cache_save_article
from content_extractor import parse_article_from_html, get_html_from_url
from storage_manager import save_article as storage_save_article
except ImportError as e:
print(f"ERROR: Required module not found: {e}")
sys.exit(1)
try:
from singlefile_archive import archive_page_with_singlefile, archive_page_with_playwright
except ImportError:
archive_page_with_singlefile = None
archive_page_with_playwright = None
try:
from ap_processor import process_ap_articles
except ImportError:
process_ap_articles = None
try:
from storage_manager import get_archive_file_path_from_db
except ImportError:
get_archive_file_path_from_db = None
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
def get_archive_file_path(source_name: str, article_url: str, archive_dir: Path = ARCHIVE_DIR) -> Optional[Path]:
"""Find archived HTML file for an article.
First checks the database mapping for the archive file path.
Falls back to searching HTML files if not found in database.
Args:
source_name: Newspaper source name
article_url: Article URL
archive_dir: Root archive directory
Returns:
Path to archived HTML file if found, None otherwise
"""
archive_file_path = get_archive_file_path_from_db(article_url, source_name)
if archive_file_path:
archive_path = Path(archive_file_path)
if archive_path.exists():
logger.debug("Found archived HTML (DB) for %s: %s", article_url[:60], archive_path)
return archive_path
logger.debug("Archive file not found on disk: %s", archive_file_path)
websites_dir = archive_dir / 'websites'
if not websites_dir.exists():
return None
try:
source_dir = websites_dir / source_name
if not source_dir.exists():
return None
html_dir = source_dir / 'html'
if not html_dir.exists():
return None
for date_dir in sorted(html_dir.iterdir()):
if not date_dir.is_dir():
continue
for html_file in sorted(date_dir.glob('article_*.html')):
try:
with open(html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
if article_url in html_content:
logger.debug("Found archived HTML (search) for %s: %s", article_url[:60], html_file)
return html_file
except Exception as e:
logger.debug("Error reading %s: %s", html_file, str(e))
continue
except Exception as e:
logger.debug("Error searching for archive: %s", str(e))
return None
def extract_content_from_archive(article_url: str, source_name: str = None, archive_dir: Path = ARCHIVE_DIR) -> dict:
"""Extract content from web page, preferring archived HTML over live fetch.
Args:
article_url: URL to extract content from
source_name: Newspaper source name (for locating archives)
archive_dir: Root archive directory
Returns:
Dictionary with extracted content and metadata
"""
raw_html = None
extraction_method = 'live_fetch'
try:
if source_name:
archived_file = get_archive_file_path(source_name, article_url, archive_dir)
if archived_file:
try:
raw_html = archived_file.read_text(encoding='utf-8')
extraction_method = 'archive'
logger.debug("Loaded archived HTML from %s", archived_file)
except Exception as e:
logger.warning("Failed to read archived HTML from %s: %s", archived_file, str(e))
if raw_html is None:
raw_html = get_html_from_url(article_url)
if not raw_html:
logger.error("No HTML content retrieved for %s", article_url[:60])
return {
'success': False,
'error': 'Failed to retrieve HTML content'
}
article_data = parse_article_from_html(raw_html, article_url)
extraction_method = article_data.extraction_method or extraction_method
logger.debug("Extracted content from %s using %s", article_url[:60], extraction_method)
return {
'success': True,
'article_data': article_data
}
except Exception as e:
logger.error("Error extracting content from %s: %s", article_url, str(e))
return {
'success': False,
'error': str(e)
}
def archive_and_extract(article_url: str, source_name: str, archive_dir: Path) -> dict:
"""Archive a URL and extract content from the archive.
This function first tries to archive the URL using SingleFile or Playwright.
Then it extracts content from the archived HTML.
Args:
article_url: URL to archive and extract
source_name: Newspaper source name (for directory structure)
archive_dir: Root archive directory
Returns:
Dictionary with extraction results
"""
archived_file = archive_article_url(article_url, source_name, archive_dir)
if not archived_file:
logger.error("Failed to archive %s", article_url[:60])
return {
'success': False,
'error': 'Failed to archive article'
}
extraction_result = extract_content_from_archive(article_url, source_name, archive_dir)
if extraction_result['success']:
logger.info("Successfully archived and extracted content from %s", article_url[:60])
else:
logger.error("Failed to extract content from archived %s: %s", article_url[:60], extraction_result.get('error', 'Unknown error'))
return extraction_result
def archive_url_with_fallback(url: str, output_path: Path) -> bool:
"""Archive a URL using SingleFile if available, falling back to Playwright.
Args:
url: URL to archive
output_path: Output file path for the archived HTML
Returns:
True if successful, False otherwise
"""
if archive_page_with_singlefile:
if archive_page_with_singlefile(url, output_path):
return True
if archive_page_with_playwright:
logger.info("Falling back to Playwright archiving for %s", url[:60])
if archive_page_with_playwright(url, output_path):
return True
return False
def archive_article_url(article_url: str, source_name: str, archive_dir: Path) -> Optional[Path]:
"""Archive a single article URL using SingleFile or Playwright fallback.
Args:
article_url: URL to archive
source_name: Newspaper source name (for directory structure)
archive_dir: Root archive directory
Returns:
Path to archived HTML file if successful, None otherwise
"""
try:
websites_dir = archive_dir / 'websites'
source_dir = websites_dir / source_name
html_dir = source_dir / 'html'
timestamp = datetime.now().strftime('%Y-%m-%d')
date_dir = html_dir / timestamp
date_dir.mkdir(parents=True, exist_ok=True)
base_timestamp = int(datetime.now().timestamp())
counter = 0
article_filename = f'article_{base_timestamp}_{counter}.html'
output_path = date_dir / article_filename
while output_path.exists():
counter += 1
article_filename = f'article_{base_timestamp}_{counter}.html'
output_path = date_dir / article_filename
if archive_page_with_singlefile:
if archive_page_with_singlefile(article_url, output_path):
logger.info("Archived %s to %s", article_url[:60], output_path)
return output_path
logger.info("SingleFile failed, attempting direct fetch fallback for %s", article_url[:60])
if archive_page_with_playwright:
logger.info("Falling back to Playwright for %s", article_url[:60])
if archive_page_with_playwright(article_url, output_path):
return output_path
logger.info("Attempting direct HTML fetch fallback for %s", article_url[:60])
direct_html = get_html_from_url(article_url)
if direct_html:
output_path.write_text(direct_html, encoding='utf-8')
logger.info("Archived %s to %s using direct fetch", article_url[:60], output_path)
return output_path
logger.error("Failed to archive %s", article_url[:60])
return None
except Exception as e:
logger.error("Error archiving %s: %s", article_url, str(e))
return None
def archive_newspaper(
source_name: str,
rss_url: str,
output_dir: Path,
dry_run: bool = False
) -> dict:
"""Main archiving workflow for a newspaper.
Args:
source_name: Newspaper source name
rss_url: RSS feed URL
output_dir: Output directory for archived content
dry_run: If True, preview without making changes
Returns:
Dictionary with results summary
"""
storage_manager.initialize_storage()
logger.info("=" * 60)
logger.info("Starting newspaper archive: %s", source_name)
logger.info("=" * 60)
results = {
'source': source_name,
'rss_url': rss_url,
'processed': 0,
'archived': 0,
'skipped': 0,
'failed': 0,
'errors': []
}
try:
feed = fetch_rss_feed(rss_url)
except Exception as e:
logger.error("Failed to fetch RSS feed for %s: %s", source_name, str(e))
results['errors'].append({
'action': 'fetch_rss',
'error': str(e)
})
return results
for item in feed.entries:
article_url = item.get('link', '')
if not article_url:
logger.warning("Skipping entry without URL")
results['failed'] += 1
continue
if is_duplicate(article_url, source_name, ARCHIVE_DIR / 'cache.db'):
logger.debug("Skipping duplicate: %s", article_url[:60])
results['skipped'] += 1
continue
results['processed'] += 1
if dry_run:
title = item.get('title', 'No Title')
logger.info("[DRY-RUN] Would process: %s - %s", title[:60], article_url[:60])
results['archived'] += 1
continue
extraction_result = archive_and_extract(article_url, source_name, output_dir)
if not extraction_result['success']:
logger.error("Failed to extract content from %s: %s", article_url[:60], extraction_result.get('error', 'Unknown error'))
results['failed'] += 1
results['errors'].append({
'url': article_url,
'action': 'extract_content',
'error': extraction_result.get('error', 'Unknown error')
})
continue
article_data = extraction_result['article_data']
try:
storage_save_article(source_name, article_data)
results['archived'] += 1
except Exception as e:
logger.error("Failed to save article %s: %s", article_url[:60], str(e))
results['failed'] += 1
results['errors'].append({
'url': article_url,
'action': 'save_article',
'error': str(e)
})
logger.info("Archive complete: %s - %d processed, %d archived, %d skipped, %d failed",
source_name, results['processed'], results['archived'], results['skipped'], results['failed'])
return results
def archive_all_sources(
rss_feeds_path: Path = SCRIPT_DIR / 'rss_feeds.json',
output_dir: Path = ARCHIVE_DIR,
dry_run: bool = False
) -> dict:
"""Process all RSS feeds from rss_feeds.json.
Args:
rss_feeds_path: Path to RSS feeds JSON file
output_dir: Output directory for archived content
dry_run: If True, preview without making changes
Returns:
Dictionary with results summary
"""
if not rss_feeds_path.exists():
logger.error("RSS feeds file not found: %s", rss_feeds_path)
return {
'success': False,
'error': 'File not found',
'processed': 0,
'success_count': 0,
'failed_count': 0
}
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
rss_feeds = json.load(f)
total_results = {
'sources_processed': 0,
'sources_failed': 0,
'total_articles_processed': 0,
'total_articles_archived': 0,
'total_articles_skipped': 0,
'total_articles_failed': 0,
'source_results': [],
'errors': []
}
for source_name, feed_info in rss_feeds.items():
rss_url = feed_info.get('rss_url', '')
feed_type = feed_info.get('feed_type', 'rss')
if not rss_url:
logger.warning("No RSS URL for source: %s", source_name)
continue
try:
if feed_type == 'html' and process_ap_articles:
ap_results = process_ap_articles(
output_dir,
ARCHIVE_DIR / 'cache.db',
dry_run
)
ap_results['source'] = source_name
total_results['sources_processed'] += 1
total_results['total_articles_processed'] += ap_results['processed']
total_results['total_articles_archived'] += ap_results['archived']
total_results['total_articles_skipped'] += ap_results['skipped']
total_results['total_articles_failed'] += ap_results['failed']
total_results['source_results'].append(ap_results)
if ap_results.get('errors'):
total_results['errors'].extend(ap_results['errors'])
elif feed_type == 'html' and not process_ap_articles:
logger.error("HTML feed type configured but ap_processor unavailable for %s", source_name)
total_results['sources_failed'] += 1
total_results['source_results'].append({
'source': source_name,
'rss_url': rss_url,
'error': 'HTML feed processing not available'
})
else:
results = archive_newspaper(source_name, rss_url, output_dir, dry_run)
total_results['sources_processed'] += 1
total_results['total_articles_processed'] += results['processed']
total_results['total_articles_archived'] += results['archived']
total_results['total_articles_skipped'] += results['skipped']
total_results['total_articles_failed'] += results['failed']
total_results['source_results'].append(results)
if results['errors']:
total_results['errors'].extend(results['errors'])
except Exception as e:
logger.error("Failed to process source %s: %s", source_name, str(e))
total_results['sources_failed'] += 1
total_results['source_results'].append({
'source': source_name,
'rss_url': rss_url,
'error': str(e)
})
return total_results
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description='Archive Engine for NewsArchiver - Phase 2.5')
parser.add_argument('--source', help='Single source name to process')
parser.add_argument('--rss-url', help='RSS URL (required if --source provided)')
parser.add_argument('--all', action='store_true', help='Process all sources from rss_feeds.json')
parser.add_argument('--rss-feeds', type=Path, default=SCRIPT_DIR / 'rss_feeds.json',
help='Path to RSS feeds JSON file')
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
help='Output directory for archived content')
parser.add_argument('--dry-run', action='store_true', help='Preview without making changes')
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
logger.info("=" * 60)
logger.info("Archive Engine - Phase 2.5")
logger.info("=" * 60)
storage_manager.initialize_storage()
if args.source:
if not args.rss_url:
logger.error("RSS URL required when using --source")
return
results = archive_newspaper(args.source, args.rss_url, args.output, args.dry_run)
print("\n" + "=" * 60)
print(f"SOURCE: {args.source}")
print("=" * 60)
print(f"Processed: {results['processed']}")
print(f"Archived: {results['archived']}")
print(f"Skipped: {results['skipped']}")
print(f"Failed: {results['failed']}")
if results['errors']:
print("\nErrors:")
for error in results['errors']:
print(f" - {error.get('url', 'Unknown')}: {error.get('error', 'Unknown error')}")
print("=" * 60)
elif args.all:
results = archive_all_sources(args.rss_feeds, args.output, args.dry_run)
print("\n" + "=" * 60)
print("PROCESSING COMPLETE")
print("=" * 60)
print(f"Sources processed: {results['sources_processed']}")
print(f"Sources failed: {results['sources_failed']}")
print(f"Total articles processed: {results['total_articles_processed']}")
print(f"Total articles archived: {results['total_articles_archived']}")
print(f"Total articles skipped: {results['total_articles_skipped']}")
print(f"Total articles failed: {results['total_articles_failed']}")
if results['errors']:
print("\nErrors:")
for error in results['errors'][:10]:
url = error.get('url', 'Unknown')
action = error.get('action', 'Unknown')
error_msg = error.get('error', 'Unknown error')
print(f" - [{action}] {url}: {error_msg}")
if len(results['errors']) > 10:
print(f" ... and {len(results['errors']) - 10} more errors")
print("=" * 60)
else:
parser.print_help()
if __name__ == '__main__':
main()

0
ci-trigger Normal file
View File

367
cleanup_old_files.py Normal file
View File

@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
Cleanup Script for NewsArchiver
This script removes files older than a specified date from the project directory.
It provides dry-run mode to preview what would be deleted before actually deleting.
Usage:
python cleanup_old_files.py --date "2024-03-19" --dry-run
python cleanup_old_files.py --date "2024-03-19"
Options:
--date, -d Date in YYYY-MM-DD format (required)
--dry-run, -n Show what would be deleted without actually deleting (default: True)
--force, -f Actually delete files (disables dry-run mode)
--verbose, -v Enable verbose output
--archival Include archival_data folder for cleanup
"""
import argparse
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Tuple
logger = logging.getLogger(__name__)
# Project root directory
SCRIPT_DIR = Path(__file__).parent.resolve()
# Path to archival_data directory
ARCHIVE_DIR = Path(
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
).resolve()
ARCHIVAL_DATA_DIR = ARCHIVE_DIR
# Path to websites folder (only this folder will be scanned in archival_data)
WEBSITES_DIR = ARCHIVAL_DATA_DIR / "websites"
# Files and directories to always preserve (never delete)
PRESERVE_LIST = {
# Python files
"ap_processor.py",
"archive_engine.py",
"content_extractor.py",
"rebuild_database.py",
"restore_database.py",
"rss_feeds.json",
"rss_processor.py",
"run_archiver.py",
"scheduler.py",
"setup_cron.sh",
"singlefile_archive.py",
"stop_services.sh",
"web_interface.py",
"cleanup_old_files.py",
# Directories
"archival_data",
"static",
"templates",
"__pycache__",
}
# Database files to preserve
DATABASE_FILES = {"cache.db", "cache.db-shm", "cache.db-wal"}
# Files that should be excluded from cleanup regardless of date
EXCLUDE_PATTERNS = [
".git",
".gitignore",
]
def parse_date(date_str: str) -> datetime:
"""Parse date string in YYYY-MM-DD format.
Args:
date_str: Date string in YYYY-MM-DD format
Returns:
datetime object with the specified date at midnight
Raises:
ValueError: If date format is invalid
"""
try:
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError as e:
raise ValueError(
f"Invalid date format: '{date_str}'. Use YYYY-MM-DD format."
) from e
def should_preserve(path: Path) -> bool:
"""Check if a file/directory should be preserved.
Args:
path: Path to check
Returns:
True if the path should be preserved, False otherwise
"""
# Check if it's in the preserve list
if path.name in PRESERVE_LIST:
return True
# Check if it matches any exclude patterns
for pattern in EXCLUDE_PATTERNS:
if pattern in str(path):
return True
return False
def should_preserve_archival_file(path: Path) -> bool:
"""Check if a file in archival_data should be preserved.
Args:
path: Path to check
Returns:
True if the path should be preserved, False otherwise
"""
# Always preserve database files
if path.name in DATABASE_FILES:
return True
return should_preserve(path)
def get_files_older_than_date(
directory: Path, cutoff_date: datetime
) -> List[Tuple[Path, datetime]]:
"""Get all files older than the cutoff date.
Args:
directory: Directory to search
cutoff_date: Files older than this date will be selected
Returns:
List of tuples (path, modification_time) for files older than cutoff
"""
old_files = []
# Walk through all files in directory recursively (targeted patterns only)
for pattern in ("*.html", "*.json", "*.txt", "*.xml", "*.md"):
for item in directory.rglob(pattern):
if not item.is_file():
continue
if should_preserve(item):
continue
try:
mtime = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
if mtime < cutoff_date:
old_files.append((item, mtime))
except (OSError, ValueError) as e:
logger.warning(f"Could not access file {item}: {e}")
return old_files
def get_files_older_than_date_non_recursive(
directory: Path, cutoff_date: datetime
) -> List[Tuple[Path, datetime]]:
"""Get all files older than the cutoff date (non-recursive).
Args:
directory: Directory to search
cutoff_date: Files older than this date will be selected
Returns:
List of tuples (path, modification_time) for files older than cutoff
"""
old_files = []
# Walk through all files in directory (non-recursive for safety)
for item in directory.iterdir():
if item.is_file():
if should_preserve(item):
continue
try:
mtime = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
if mtime < cutoff_date:
old_files.append((item, mtime))
except (OSError, ValueError) as e:
logger.warning(f"Could not access file {item}: {e}")
return old_files
def delete_files(files: List[Tuple[Path, datetime]]) -> Tuple[int, int]:
"""Delete files and return count of successful/failed deletions.
Args:
files: List of (path, modification_time) tuples to delete
Returns:
Tuple of (deleted_count, failed_count)
"""
deleted = 0
failed = 0
for path, mtime in files:
try:
path.unlink()
logger.info(
f"Deleted: {path.name} (modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')})"
)
deleted += 1
except OSError as e:
logger.error(f"Failed to delete {path.name}: {e}")
failed += 1
return deleted, failed
def main():
"""Main entry point for cleanup script."""
parser = argparse.ArgumentParser(
description="Cleanup old files from NewsArchiver project",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --date "2024-03-19" --dry-run
%(prog)s --date "2024-03-19" --force
%(prog)s --date "2024-03-19" --archival --force
""",
)
parser.add_argument(
"--date",
"-d",
type=str,
required=True,
help="Date in YYYY-MM-DD format - files older than this will be deleted",
)
parser.add_argument(
"--dry-run",
"-n",
action="store_true",
default=True,
help="Show what would be deleted without actually deleting (default)",
)
parser.add_argument(
"--force",
"-f",
action="store_true",
help="Actually delete files (disables dry-run mode)",
)
parser.add_argument(
"--verbose", "-v", action="store_true", help="Enable verbose output"
)
parser.add_argument(
"--archival",
action="store_true",
help="Include archival_data folder for cleanup",
)
args = parser.parse_args()
# Set logging level
if args.verbose:
logger.setLevel(logging.DEBUG)
# Parse the date
try:
cutoff_date = parse_date(args.date)
except ValueError as e:
logger.error(str(e))
sys.exit(1)
# Validate cutoff date is not in the future
now = datetime.now(timezone.utc)
if cutoff_date > now:
logger.error("Cutoff date cannot be in the future")
sys.exit(1)
logger.info("=" * 60)
logger.info("NewsArchiver Cleanup Script")
logger.info("=" * 60)
# Determine mode
dry_run = not args.force
mode = "DRY RUN" if dry_run else "ACTUAL DELETE"
logger.info("Mode: %s", mode)
logger.info(
"Cutoff Date: %s (files older than this will be %s)",
cutoff_date.strftime("%Y-%m-%d"),
"kept" if dry_run else "deleted",
)
logger.info("=" * 60)
# Find files older than cutoff date
if args.archival:
# Only scan websites folder when --archival is used
if not WEBSITES_DIR.exists():
logger.error("Websites folder not found at %s", WEBSITES_DIR)
sys.exit(1)
old_files = []
logger.info(
"Scanning websites folder for files older than %s...",
cutoff_date.strftime("%Y-%m-%d"),
)
# First check if there are any files directly in websites folder
website_root_files = get_files_older_than_date_non_recursive(
WEBSITES_DIR, cutoff_date
)
# Then check recursively in subdirectories
website_recursive_files = []
for subdir in WEBSITES_DIR.iterdir():
if subdir.is_dir():
website_recursive_files.extend(
get_files_older_than_date(subdir, cutoff_date)
)
old_files.extend(website_root_files)
old_files.extend(website_recursive_files)
logger.info(
"Found %d files in websites folder",
len(website_root_files) + len(website_recursive_files),
)
else:
# Scan root directory (non-archival mode)
old_files = get_files_older_than_date_non_recursive(SCRIPT_DIR, cutoff_date)
if not old_files:
logger.info("No files older than %s found.", cutoff_date.strftime("%Y-%m-%d"))
logger.info("Nothing to do.")
return
logger.info(
"Found %d files older than %s:",
len(old_files),
cutoff_date.strftime("%Y-%m-%d"),
)
# List all files that would be affected
for path, mtime in old_files:
# Get relative path for cleaner output
rel_path = path.relative_to(SCRIPT_DIR)
logger.info(
" - %s (modified: %s)", rel_path, mtime.strftime("%Y-%m-%d %H:%M:%S")
)
logger.info("=" * 60)
# Execute deletion if not dry run
if dry_run:
logger.info("DRY RUN: No files were deleted.")
logger.info("Run with --force to actually delete these files.")
else:
deleted, failed = delete_files(old_files)
logger.info("=" * 60)
logger.info("Cleanup complete!")
logger.info("Deleted: %d files", deleted)
logger.info("Failed: %d files", failed)
if __name__ == "__main__":
main()

387
content_extractor.py Normal file
View File

@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Content Extractor for NewsArchiver - Phase 2.2
Extracts article text and metadata from HTML using Trafilatura
with BeautifulSoup fallback for JavaScript-heavy sites.
"""
import logging
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
try:
import requests
except ImportError:
print("WARNING: requests not installed. URL fetching may not work.")
print("Install with: pip install requests")
requests = None
try:
import trafilatura
from trafilatura import extract, extract_metadata
except ImportError:
print("ERROR: trafilatura is required. Install with: pip install trafilatura")
sys.exit(1)
try:
from bs4 import BeautifulSoup
except ImportError:
print("WARNING: beautifulsoup4 not installed. Some fallback features may not work.")
print("Install with: pip install beautifulsoup4")
BeautifulSoup = None
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
ARCHIVE_DIR.mkdir(exist_ok=True)
_last_request_time = 0.0
logger = logging.getLogger(__name__)
@dataclass
class ArticleData:
"""Structured article data."""
url: str
title: Optional[str] = None
author: Optional[str] = None
publish_date: Optional[str] = None
content_text: Optional[str] = None
content_html: Optional[str] = None
raw_html: Optional[str] = None
archive_file_path: Optional[str] = None
tags: Optional[list] = None
language: Optional[str] = None
metadata: Optional[dict] = None
extraction_method: Optional[str] = None
error: Optional[str] = None
guid: Optional[str] = None
id: Optional[int] = None
source_name: Optional[str] = None
def fetch_url(url: str, timeout: int = 30) -> str:
"""Download HTML from URL with rate limiting.
Args:
url: URL to fetch
timeout: Request timeout in seconds
Returns:
HTML string or empty string on failure
"""
global _last_request_time
if requests is None:
logger.error("requests library not available")
return ""
try:
delay = 0.5 - (time.time() - _last_request_time)
if delay > 0:
time.sleep(delay)
_last_request_time = time.time()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
response = requests.get(url, timeout=timeout, headers=headers)
response.raise_for_status()
text = response.text
# Check if response is an error page (only check for actual HTTP errors)
if response.status_code >= 400:
logger.warning("Server returned error for %s (status: %d)", url, response.status_code)
return ""
# Check for common error patterns
if 'access denied' in text.lower() or 'forbidden' in text.lower():
logger.warning("Server returned access denied for %s", url)
return ""
return text
except Exception as e:
logger.error("Failed to fetch URL %s: %s", url, str(e))
return ""
def extract_content(html: str) -> dict:
"""Extract article content and metadata from HTML using Trafilatura.
Args:
html: Raw HTML string (or plain text)
Returns:
Dictionary with extracted content and metadata
"""
result = {
'success': False,
'content_text': None,
'content_html': None,
'title': None,
'author': None,
'date': None,
'tags': None,
'language': None,
'metadata': None,
'extraction_method': None,
'error': None
}
if not html or not html.strip():
result['error'] = 'Empty HTML content'
return result
# Check if this is an error page (HTTP status codes, not the word "error" in content)
# Only check for error patterns that appear in actual error pages (not in article content)
# Look for specific error page patterns with proper HTML structure
import re
error_patterns = [
r'<title[^>]*>403[^<]*Forbidden</title>',
r'<title[^>]*>401[^<]*Unauthorized</title>',
r'<h1[^>]*>403</h1>',
r'<h1[^>]*>401</h1>',
]
html_lower = html.lower()
for pattern in error_patterns:
if re.search(pattern, html_lower):
result['error'] = 'HTML contains error page content'
result['success'] = False
return result
# Check for common error indicators in the HTML body
# These should only trigger if we see them in context (like a 403/401 status indicator)
if '<title>403' in html_lower or '<title>401' in html_lower:
result['error'] = 'HTML contains error page content'
result['success'] = False
return result
has_html_tags = '<' in html and '>' in html
try:
metadata = extract_metadata(html)
if metadata:
result['title'] = metadata.title
result['author'] = metadata.author
result['date'] = metadata.date
result['tags'] = metadata.tags if hasattr(metadata, 'tags') else None
result['language'] = metadata.language
if hasattr(metadata, 'to_dict'):
result['metadata'] = metadata.to_dict()
elif hasattr(metadata, '__dict__'):
result['metadata'] = metadata.__dict__
content = extract(
html,
include_comments=False,
include_tables=True,
no_fallback=True
)
if content:
result['content_text'] = content
result['content_html'] = html
result['extraction_method'] = 'trafilatura'
result['success'] = True
elif not has_html_tags:
result['content_text'] = html.strip()
result['content_html'] = f'<html><body>{html}</body></html>'
result['extraction_method'] = 'plain_text'
result['success'] = True
else:
result['content_text'] = None
result['content_html'] = html
result['extraction_method'] = 'trafilatura_empty'
result['success'] = False
result['error'] = 'Content extraction failed - no article content found'
if result['success']:
logger.debug("Content extracted using %s", result['extraction_method'])
except Exception as e:
result['error'] = f"Trafilatura extraction failed: {str(e)}"
logger.warning(result['error'])
if not has_html_tags:
result['content_text'] = html.strip()
result['content_html'] = f'<html><body>{html}</body></html>'
result['extraction_method'] = 'plain_text_fallback'
result['success'] = True
elif BeautifulSoup:
fallback_result = _extract_with_beautifulsoup(html)
if fallback_result['content_text']:
result.update(fallback_result)
result['extraction_method'] = 'beautifulsoup_fallback'
return result
def _extract_with_beautifulsoup(html: str) -> dict:
"""Fallback extraction using BeautifulSoup."""
result = {
'content_text': None,
'content_html': None,
'title': None,
'author': None,
'date': None,
'tags': None,
'language': None,
'metadata': None
}
try:
soup = BeautifulSoup(html, 'html.parser')
title = soup.find('title')
if title:
result['title'] = title.get_text(strip=True)
meta_author = soup.find('meta', attrs={'name': 'author'})
if meta_author:
result['author'] = meta_author.get('content', '').strip()
meta_date = soup.find('meta', attrs={'name': 'date'})
if meta_date:
result['date'] = meta_date.get('content', '').strip()
meta_language = soup.find('meta', attrs={'name': 'language'})
if meta_language:
result['language'] = meta_language.get('content', '').strip()
for tag in ['article', 'main', 'div']:
content_tags = soup.find_all(tag)
if content_tags:
result['content_text'] = ' '.join(
tag.get_text(strip=True, separator=' ')
for tag in content_tags
)
if result['content_text']:
break
if not result['content_text']:
result['content_text'] = soup.get_text(strip=True, separator=' ')
result['content_text'] = result['content_text'][:100000]
result['content_html'] = str(soup)
except Exception as e:
result['error'] = f"BeautifulSoup extraction failed: {str(e)}"
logger.warning(result['error'])
return result
def _extract_with_playwright(url: str) -> Optional[str]:
"""Extract HTML from JavaScript-heavy site using Playwright."""
if not PLAYWRIGHT_AVAILABLE:
logger.warning("Playwright not available. Install with: pip install playwright")
return None
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until='networkidle', timeout=60000)
page_content = page.content()
browser.close()
logger.debug("HTML extracted using Playwright")
return page_content
except Exception as e:
logger.error("Playwright extraction failed: %s", str(e))
return None
def parse_article_from_html(html: str, url: str) -> ArticleData:
"""Parse article and return structured data.
Args:
html: Raw HTML string
url: Original URL for reference
Returns:
ArticleData with extracted content and metadata
"""
extraction_result = extract_content(html)
guid = extraction_result.get('metadata', {}).get('url', url) if extraction_result.get('metadata') else url
article = ArticleData(
url=url,
title=extraction_result.get('title'),
author=extraction_result.get('author'),
publish_date=extraction_result.get('date'),
content_text=extraction_result.get('content_text'),
content_html=extraction_result.get('content_html'),
raw_html=html,
tags=extraction_result.get('tags'),
language=extraction_result.get('language'),
metadata=extraction_result.get('metadata'),
extraction_method=extraction_result.get('extraction_method'),
guid=guid
)
if not extraction_result.get('success'):
article.error = extraction_result.get('error')
return article
def get_html_from_url(url: str, extract_content: bool = True) -> str:
"""Download HTML from URL using Trafilatura with Playwright fallback.
Args:
url: URL to fetch
extract_content: If True, try to extract main content (kept for backwards compatibility)
Returns:
Full HTML string or empty string on failure
"""
try:
downloaded = fetch_url(url)
if downloaded:
logger.debug("Full HTML fetched from %s", url[:60])
return downloaded
except Exception as e:
logger.error("Failed to fetch URL %s: %s", url, str(e))
if PLAYWRIGHT_AVAILABLE:
logger.info("Trying Playwright fallback for %s", url[:60])
playwright_html = _extract_with_playwright(url)
if playwright_html:
logger.debug("Full HTML from Playwright for %s", url[:60])
if extract_content:
extracted = extract(
playwright_html,
include_comments=False,
include_tables=True,
no_fallback=True
)
if extracted:
logger.debug("Playwright content extracted from %s", url[:60])
return extracted
logger.debug("Full HTML from Playwright for %s", url[:60])
return playwright_html
return ""

View File

@ -0,0 +1,37 @@
# Example docker-compose configuration for NAS storage
# Copy this file to docker-compose.yml and edit the volume path
version: '3.8'
services:
newsarchiver:
build:
context: .
dockerfile: Dockerfile
container_name: newsarchiver
ports:
- "5000:5000"
environment:
- ARCHIVE_DIR=/data/archives
volumes:
# Example: Mount your NAS to /path/to/nas/archives
# Replace with your actual NAS path
- /path/to/nas/archives:/data/archives
# Or use Docker named volume for local storage:
# - newsarchiver_data:/data/archives
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Run as specific UID/GID for NAS permissions
# user: "1000:1000"
volumes:
newsarchiver_data:
driver: local
driver_opts:
type: none
o: bind
device: /path/to/nas/archives # Replace with your NAS path

24
docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
name: newsarchiver
services:
newsarchiver:
build:
context: .
dockerfile: Dockerfile
container_name: newsarchiver
ports:
- "5000:5000"
environment:
- ARCHIVE_DIR=/data/archives
volumes:
- newsarchiver_data:/data/archives
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
volumes:
newsarchiver_data:
external: true

31
docker_backup.sh Normal file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# Docker backup script for NewsArchiver
# This script backs up archived data from the Docker volume to a local or NAS location
set -e
BACKUP_DIR="${BACKUP_DIR:-./backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="newsarchiver_backup_${TIMESTAMP}.tar.gz"
echo "Starting backup..."
echo "Backup location: ${BACKUP_DIR}/${BACKUP_NAME}"
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Create backup from the Docker volume
docker run --rm \
-v newsarchiver_data:/data:ro \
-v "${BACKUP_DIR}:/backup" \
alpine tar -czf "/backup/${BACKUP_NAME}" -C /data .
echo "Backup complete: ${BACKUP_DIR}/${BACKUP_NAME}"
echo ""
echo "To restore from backup:"
echo " 1. Stop the container: docker-compose down"
echo " 2. Remove the volume: docker volume rm newsarchiver_data"
echo " 3. Create a new volume: docker volume create newsarchiver_data"
echo " 4. Restore: docker run --rm -v newsarchiver_data:/data -v \${BACKUP_DIR}:/backup alpine tar -xzf /backup/${BACKUP_NAME} -C /data"
echo " 5. Start: docker-compose up -d"

47
docker_setup.sh Normal file
View File

@ -0,0 +1,47 @@
#!/bin/bash
# Docker setup script for NewsArchiver
# This script helps configure the Docker environment
set -e
echo "NewsArchiver Docker Setup"
echo "=========================="
echo ""
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed. Please install Docker first."
exit 1
fi
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "ERROR: docker-compose is not installed. Please install docker-compose first."
exit 1
fi
echo "Docker is installed."
echo ""
# Check if the project has docker-compose.yml
if [ ! -f docker-compose.yml ] && [ ! -f docker-compose.nas.example.yml ]; then
echo "WARNING: docker-compose.yml not found."
echo "Creating from example..."
cp docker-compose.nas.example.yml docker-compose.yml
echo ""
echo "Please edit docker-compose.yml to set your NAS path:"
echo " 1. Find the volume mount path (currently set to /path/to/nas/archives)"
echo " 2. Replace with your actual NAS path"
echo " 3. Save the file"
echo ""
fi
echo "Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Edit docker-compose.yml with your NAS path"
echo " 2. Build and start: docker-compose up -d --build"
echo " 3. Check logs: docker-compose logs -f"
echo " 4. Access web interface at http://localhost:5000"
echo ""

11
entrypoint.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Use ARCHIVE_DIR from environment, default to /app/archival_data
export ARCHIVE_DIR="${ARCHIVE_DIR:-/app/archival_data}"
# Create archive directory if it does not exist
mkdir -p "$ARCHIVE_DIR"
# Run the command passed to docker
exec "$@"

251
nohup.out Normal file
View File

@ -0,0 +1,251 @@
2026-03-31 00:45:43,896 - INFO - NewsArchiver - Main CLI Entry Point
2026-03-31 00:45:43,896 - INFO - ============================================================
2026-03-31 00:45:43,896 - INFO - Starting web server
2026-03-31 00:45:43,896 - INFO - ============================================================
2026-03-31 00:45:43,984 - INFO - Web server starting on 0.0.0.0:8080
2026-03-31 00:45:43,984 - INFO - ============================================================
* Serving Flask app 'web_interface'
* Debug mode: off
2026-03-31 00:45:43,985 - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:8080
* Running on http://192.168.8.150:8080
2026-03-31 00:45:43,985 - INFO - Press CTRL+C to quit
2026-03-31 00:46:57,096 - INFO - NewsArchiver - Main CLI Entry Point
2026-03-31 00:46:57,096 - INFO - ============================================================
2026-03-31 00:46:57,096 - INFO - Starting web server
2026-03-31 00:46:57,096 - INFO - ============================================================
2026-03-31 00:46:57,185 - INFO - Web server starting on 0.0.0.0:5000
2026-03-31 00:46:57,185 - INFO - ============================================================
* Serving Flask app 'web_interface'
* Debug mode: off
2026-03-31 00:46:57,186 - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://192.168.8.150:5000
2026-03-31 00:46:57,186 - INFO - Press CTRL+C to quit
2026-03-31 00:47:29,377 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "GET / HTTP/1.1" 200 -
2026-03-31 00:47:29,420 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 00:47:29,476 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "GET /favicon.ico HTTP/1.1" 404 -
Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
opencode server listening on http://0.0.0.0:4096
2026-03-31 03:50:30,668 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:30] "GET / HTTP/1.1" 200 -
2026-03-31 03:50:30,701 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:30] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 03:50:34,449 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 03:50:34,449 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 03:50:34,490 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:51:06,671 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:06] "GET /source/404%20media/article/75854 HTTP/1.1" 200 -
2026-03-31 03:51:06,801 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:06] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:51:28,312 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:28] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 03:51:30,665 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "GET /source/404%20media/article/75856 HTTP/1.1" 200 -
2026-03-31 03:51:30,686 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "GET /source/404%20media/article/75856 HTTP/1.1" 200 -
2026-03-31 03:51:30,777 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:51:52,473 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:52] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 03:52:03,379 - INFO - 192.168.8.156 - - [31/Mar/2026 03:52:03] "GET / HTTP/1.1" 200 -
2026-03-31 03:53:23,713 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:23] "GET / HTTP/1.1" 200 -
2026-03-31 03:53:26,077 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:26] "GET /source/Ars%20Technica HTTP/1.1" 200 -
2026-03-31 03:53:26,273 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:26] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:53:32,574 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "GET /source/ars%20technica/article/76159 HTTP/1.1" 200 -
2026-03-31 03:53:32,621 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "GET /source/ars%20technica/article/76159 HTTP/1.1" 200 -
2026-03-31 03:53:32,714 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:54:32,335 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:32] "GET /source/Ars%20Technica HTTP/1.1" 200 -
2026-03-31 03:54:36,121 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:36] "GET /source/ars%20technica/article/76156 HTTP/1.1" 200 -
2026-03-31 03:54:36,226 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:36] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:55:37,969 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:37] "GET /source/Ars%20Technica HTTP/1.1" 200 -
2026-03-31 03:55:42,609 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:42] "GET /source/ars%20technica/article/76153 HTTP/1.1" 200 -
2026-03-31 03:55:42,688 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:42] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:56:57,070 - INFO - 192.168.8.156 - - [31/Mar/2026 03:56:57] "GET /source/Ars%20Technica HTTP/1.1" 200 -
2026-03-31 03:57:27,037 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:27] "GET / HTTP/1.1" 200 -
2026-03-31 03:57:31,938 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:31] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 03:57:31,977 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:31] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:57:46,792 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "GET /source/associated%20press/article/76318 HTTP/1.1" 200 -
2026-03-31 03:57:46,822 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "GET /source/associated%20press/article/76318 HTTP/1.1" 200 -
2026-03-31 03:57:46,918 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:58:43,823 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:43] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 03:58:45,452 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:45] "GET / HTTP/1.1" 200 -
2026-03-31 03:58:51,007 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 03:58:51,035 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 03:58:51,073 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 03:59:07,720 - INFO - 192.168.8.156 - - [31/Mar/2026 03:59:07] "GET /source/associated%20press/article/76180 HTTP/1.1" 200 -
2026-03-31 03:59:07,791 - INFO - 192.168.8.156 - - [31/Mar/2026 03:59:07] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:02:00,289 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:00] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 04:02:29,322 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "GET /source/associated%20press/article/76381 HTTP/1.1" 200 -
2026-03-31 04:02:29,366 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "GET /source/associated%20press/article/76381 HTTP/1.1" 200 -
2026-03-31 04:02:29,461 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:05:14,639 - INFO - 192.168.8.156 - - [31/Mar/2026 04:05:14] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 04:07:09,728 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:09] "GET / HTTP/1.1" 200 -
2026-03-31 04:07:13,436 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:13] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 04:07:13,460 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:13] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:07:18,974 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:18] "GET /source/bbc%20news%20%20business/article/75670 HTTP/1.1" 200 -
2026-03-31 04:07:18,994 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:18] "GET /source/bbc%20news%20%20business/article/75670 HTTP/1.1" 200 -
2026-03-31 04:07:19,090 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:19] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:08:32,701 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:32] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 04:08:38,686 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:38] "GET /source/bbc%20news%20%20business/article/75660 HTTP/1.1" 200 -
2026-03-31 04:08:38,758 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:38] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:10:39,733 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:39] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 04:10:47,517 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:47] "GET /source/bbc%20news%20%20business/article/75668 HTTP/1.1" 200 -
2026-03-31 04:10:47,592 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:47] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:13:00,033 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:00] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 04:13:04,210 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:04] "GET /source/bbc%20news%20%20business/article/75664 HTTP/1.1" 200 -
2026-03-31 04:13:04,305 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:04] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:14:34,463 - INFO - 192.168.8.156 - - [31/Mar/2026 04:14:34] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 04:15:35,483 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:35] "GET / HTTP/1.1" 200 -
2026-03-31 04:15:42,939 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:42] "GET /source/CNBC%20%20Business HTTP/1.1" 200 -
2026-03-31 04:15:42,999 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:42] "GET /source/CNBC%20%20Business HTTP/1.1" 200 -
2026-03-31 04:15:43,017 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:43] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:16:17,658 - INFO - 192.168.8.156 - - [31/Mar/2026 04:16:17] "GET /source/cnbc%20%20business/article/75575 HTTP/1.1" 200 -
2026-03-31 04:16:17,754 - INFO - 192.168.8.156 - - [31/Mar/2026 04:16:17] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:17:16,331 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:16] "GET /source/CNBC%20%20Business HTTP/1.1" 200 -
2026-03-31 04:17:34,558 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:34] "GET /source/cnbc%20%20business/article/75583 HTTP/1.1" 200 -
2026-03-31 04:17:34,633 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:34] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:19:53,532 - INFO - 192.168.8.156 - - [31/Mar/2026 04:19:53] "GET /source/CNBC%20%20Business HTTP/1.1" 200 -
2026-03-31 04:20:00,608 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:00] "GET / HTTP/1.1" 200 -
2026-03-31 04:20:15,359 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:15] "GET /source/Engadget HTTP/1.1" 200 -
2026-03-31 04:20:15,402 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:15] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:20:37,074 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "GET /source/engadget/article/76118 HTTP/1.1" 200 -
2026-03-31 04:20:37,105 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "GET /source/engadget/article/76118 HTTP/1.1" 200 -
2026-03-31 04:20:37,202 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:21:11,132 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:11] "GET /source/Engadget HTTP/1.1" 200 -
2026-03-31 04:21:18,145 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:18] "GET /source/engadget/article/76116 HTTP/1.1" 200 -
2026-03-31 04:21:18,216 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:18] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:22:17,699 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:17] "GET /source/Engadget HTTP/1.1" 200 -
2026-03-31 04:22:38,033 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "GET /source/engadget/article/76107 HTTP/1.1" 200 -
2026-03-31 04:22:38,059 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "GET /source/engadget/article/76107 HTTP/1.1" 200 -
2026-03-31 04:22:38,151 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:23:11,496 - INFO - 192.168.8.156 - - [31/Mar/2026 04:23:11] "GET /source/Engadget HTTP/1.1" 200 -
2026-03-31 04:24:04,059 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:04] "GET / HTTP/1.1" 200 -
2026-03-31 04:24:20,270 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:24:20,304 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:24:20,394 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:24:55,634 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:55] "GET /source/hacker%20news/article/76044 HTTP/1.1" 200 -
2026-03-31 04:24:55,731 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:55] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:25:39,898 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:39] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:25:57,410 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "GET /source/hacker%20news/article/75265 HTTP/1.1" 200 -
2026-03-31 04:25:57,443 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "GET /source/hacker%20news/article/75265 HTTP/1.1" 200 -
2026-03-31 04:25:57,537 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:28:52,915 - INFO - 192.168.8.156 - - [31/Mar/2026 04:28:52] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:29:54,036 - INFO - 192.168.8.156 - - [31/Mar/2026 04:29:54] "GET /source/hacker%20news/article/75237 HTTP/1.1" 200 -
2026-03-31 04:29:54,091 - INFO - 192.168.8.156 - - [31/Mar/2026 04:29:54] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:31:33,988 - INFO - 192.168.8.156 - - [31/Mar/2026 04:31:33] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:32:13,621 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "GET /source/hacker%20news/article/75972 HTTP/1.1" 200 -
2026-03-31 04:32:13,653 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "GET /source/hacker%20news/article/75972 HTTP/1.1" 200 -
2026-03-31 04:32:13,754 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:39:50,815 - INFO - 192.168.8.156 - - [31/Mar/2026 04:39:50] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:41:06,097 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "GET /source/hacker%20news/article/75255 HTTP/1.1" 200 -
2026-03-31 04:41:06,130 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "GET /source/hacker%20news/article/75255 HTTP/1.1" 200 -
2026-03-31 04:41:06,224 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:42:02,455 - INFO - 192.168.8.156 - - [31/Mar/2026 04:42:02] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 04:47:45,853 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:45] "GET / HTTP/1.1" 200 -
2026-03-31 04:47:45,949 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:45] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 04:47:46,000 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:46] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 05:53:55,185 - INFO - 192.168.8.226 - - [31/Mar/2026 05:53:55] "GET /rss HTTP/1.1" 200 -
2026-03-31 07:54:24,903 - INFO - 192.168.8.226 - - [31/Mar/2026 07:54:24] "GET /rss HTTP/1.1" 200 -
2026-03-31 11:25:27,600 - INFO - 192.168.8.156 - - [31/Mar/2026 11:25:27] "GET /source/hacker%20news/article/74862 HTTP/1.1" 200 -
2026-03-31 11:25:27,641 - INFO - 192.168.8.156 - - [31/Mar/2026 11:25:27] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:27:05,669 - INFO - 192.168.8.156 - - [31/Mar/2026 11:27:05] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 11:29:15,410 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "GET /source/hacker%20news/article/76601 HTTP/1.1" 200 -
2026-03-31 11:29:15,439 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "GET /source/hacker%20news/article/76601 HTTP/1.1" 200 -
2026-03-31 11:29:15,537 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:30:37,869 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:37] "GET /source/Hacker%20News HTTP/1.1" 200 -
2026-03-31 11:30:46,321 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:46] "GET /source/hacker%20news/article/76602 HTTP/1.1" 200 -
2026-03-31 11:30:46,418 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:46] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:36:40,535 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:40] "GET / HTTP/1.1" 200 -
2026-03-31 11:36:40,569 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:40] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 11:36:44,152 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:44] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 11:36:44,167 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:44] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:36:49,524 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:49] "GET / HTTP/1.1" 200 -
2026-03-31 11:36:51,785 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:51] "GET /source/Ars%20Technica HTTP/1.1" 200 -
2026-03-31 11:36:51,803 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:51] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:37:01,762 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:01] "GET / HTTP/1.1" 200 -
2026-03-31 11:37:07,388 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:37:07,413 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:37:07,652 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /source/associated%20press/article/76682 HTTP/1.1" 200 -
2026-03-31 11:37:07,720 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:37:16,777 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:16] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:38:13,418 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "GET /source/associated%20press/article/76643 HTTP/1.1" 200 -
2026-03-31 11:38:13,453 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "GET /source/associated%20press/article/76643 HTTP/1.1" 200 -
2026-03-31 11:38:13,537 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:39:22,876 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:22] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:39:47,385 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "GET /source/associated%20press/article/76607 HTTP/1.1" 200 -
2026-03-31 11:39:47,419 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "GET /source/associated%20press/article/76607 HTTP/1.1" 200 -
2026-03-31 11:39:47,501 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:41:43,881 - INFO - 192.168.8.156 - - [31/Mar/2026 11:41:43] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:42:06,103 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "GET /source/associated%20press/article/76563 HTTP/1.1" 200 -
2026-03-31 11:42:06,136 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "GET /source/associated%20press/article/76563 HTTP/1.1" 200 -
2026-03-31 11:42:06,218 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:45:00,824 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:00] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:45:09,188 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "GET /source/associated%20press/article/76605 HTTP/1.1" 200 -
2026-03-31 11:45:09,223 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "GET /source/associated%20press/article/76605 HTTP/1.1" 200 -
2026-03-31 11:45:09,305 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:48:11,110 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:11] "GET /source/Associated%20Press HTTP/1.1" 200 -
2026-03-31 11:48:12,152 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:12] "GET / HTTP/1.1" 200 -
2026-03-31 11:48:16,690 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 11:48:16,713 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 11:48:16,724 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:48:26,509 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:26] "GET /source/bbc%20news%20%20business/article/76588 HTTP/1.1" 200 -
2026-03-31 11:48:26,592 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:26] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:50:33,771 - INFO - 192.168.8.156 - - [31/Mar/2026 11:50:33] "GET /source/BBC%20News%20%20Business HTTP/1.1" 200 -
2026-03-31 11:51:20,160 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:20] "GET / HTTP/1.1" 200 -
2026-03-31 11:51:54,395 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:54] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
2026-03-31 11:51:54,422 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:54] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:51:59,100 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:59] "GET /source/mac%20rumors/article/75894 HTTP/1.1" 200 -
2026-03-31 11:51:59,196 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:59] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:52:42,557 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:42] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
2026-03-31 11:52:48,061 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "GET /source/mac%20rumors/article/75890 HTTP/1.1" 200 -
2026-03-31 11:52:48,089 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "GET /source/mac%20rumors/article/75890 HTTP/1.1" 200 -
2026-03-31 11:52:48,176 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:53:19,210 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:19] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
2026-03-31 11:53:43,451 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "GET /source/mac%20rumors/article/75886 HTTP/1.1" 200 -
2026-03-31 11:53:43,487 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "GET /source/mac%20rumors/article/75886 HTTP/1.1" 200 -
2026-03-31 11:53:43,570 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:54:20,607 - INFO - 192.168.8.156 - - [31/Mar/2026 11:54:20] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
2026-03-31 11:55:28,281 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "GET /source/mac%20rumors/article/75896 HTTP/1.1" 200 -
2026-03-31 11:55:28,311 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "GET /source/mac%20rumors/article/75896 HTTP/1.1" 200 -
2026-03-31 11:55:28,396 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:55:52,802 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:52] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
2026-03-31 11:56:08,058 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:08] "GET / HTTP/1.1" 200 -
2026-03-31 11:56:31,521 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "GET /source/ProPublica HTTP/1.1" 200 -
2026-03-31 11:56:31,524 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "GET /source/ProPublica HTTP/1.1" 200 -
2026-03-31 11:56:31,543 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 11:56:44,566 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:44] "GET /source/propublica/article/76487 HTTP/1.1" 200 -
2026-03-31 11:56:44,655 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:44] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 12:07:00,226 - INFO - 192.168.8.156 - - [31/Mar/2026 12:07:00] "GET /source/ProPublica HTTP/1.1" 200 -
2026-03-31 12:08:20,255 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "GET /source/propublica/article/61757 HTTP/1.1" 200 -
2026-03-31 12:08:20,285 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "GET /source/propublica/article/61757 HTTP/1.1" 200 -
2026-03-31 12:08:20,371 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 12:16:03,370 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "GET / HTTP/1.1" 200 -
2026-03-31 12:16:03,475 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 13:05:25,475 - INFO - 192.168.8.226 - - [31/Mar/2026 13:05:25] "GET /rss HTTP/1.1" 200 -
2026-03-31 14:38:42,845 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 14:38:42,872 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 14:38:42,883 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:38:44,029 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 14:38:44,043 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 14:40:43,351 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 308 -
2026-03-31 14:40:43,358 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
2026-03-31 14:40:43,358 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 404 -
2026-03-31 14:40:43,374 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:51:00,206 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 14:51:00,220 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 14:51:00,225 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:51:02,123 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 308 -
2026-03-31 14:51:02,127 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
2026-03-31 14:51:02,127 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 404 -
2026-03-31 14:51:02,142 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 15:14:49,505 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 15:14:49,519 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 15:14:49,526 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 15:15:08,057 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "GET / HTTP/1.1" 200 -
2026-03-31 15:15:08,076 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 15:15:08,080 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 15:15:10,181 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 15:15:10,197 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 15:15:10,207 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 15:15:11,421 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:11] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 15:15:11,436 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:11] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 15:15:13,782 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:13] "GET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 308 -
2026-03-31 15:15:13,786 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
2026-03-31 15:15:13,786 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:13] "GET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 404 -
2026-03-31 15:15:21,503 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "GET / HTTP/1.1" 200 -
2026-03-31 15:15:21,524 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 15:15:21,530 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "GET /favicon.ico HTTP/1.1" 404 -

247
rebuild_database.py Normal file
View File

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Rebuild NewsArchiver database from existing HTML files."""
import json
import logging
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
try:
from content_extractor import parse_article_from_html, get_html_from_url
from storage_manager import initialize_storage, save_article as storage_save_article
except ImportError as e:
print(f"ERROR: Required module not found: {e}")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json'
logger = logging.getLogger(__name__)
def extract_urls_from_html(html_file: Path) -> list:
"""Extract article URLs from an archived HTML file.
Args:
html_file: Path to archived HTML file
Returns:
List of article URLs found in the file
"""
try:
content = html_file.read_text(encoding='utf-8')
urls = []
# Only extract main article URLs - skip tracking links, RSS feeds, author pages, etc.
article_patterns = [
r'href=["\']https?://[^"\']+\/article\/[^"\']+["\']',
r'href=["\']https?://[^"\']+\/news\/[^"\']+["\']',
r'href=["\']https?://[^"\']+\/stories\/[^"\']+["\']',
r'href=["\']https?://[^"\']+\/archive\/[^"\']+["\']',
]
for pattern in article_patterns:
matches = re.findall(pattern, content)
for match in matches:
url_match = re.search(r'href=["\']([^"\']+)["\']', match)
if url_match:
url = url_match.group(1)
# Skip common non-article URLs
skip_patterns = [
'rss', 'feed', 'author', 'authors', 'tags', 'category', 'search',
'about', 'contact', 'privacy', 'terms', 'faq', 'subscribe',
'signin', 'login', 'register', 'account', 'profile'
]
if not any(skip in url.lower() for skip in skip_patterns):
urls.append(url)
return list(set(urls)) # Remove duplicates
except Exception as e:
logger.error("Error reading %s: %s", html_file, str(e))
return []
def find_html_files(archive_dir: Path) -> list:
"""Find all HTML files in the archive directory.
Args:
archive_dir: Root archive directory
Returns:
List of HTML file paths
"""
html_files = []
try:
websites_dir = archive_dir / 'websites'
if not websites_dir.exists():
logger.warning("Websites directory not found: %s", websites_dir)
return []
for source_dir in sorted(websites_dir.iterdir()):
if not source_dir.is_dir():
continue
html_dir = source_dir / 'html'
if not html_dir.exists():
continue
for date_dir in sorted(html_dir.iterdir()):
if not date_dir.is_dir():
continue
for html_file in sorted(date_dir.glob('article_*.html')):
html_files.append(html_file)
logger.info("Found %d HTML files to process", len(html_files))
except Exception as e:
logger.error("Error finding HTML files: %s", str(e))
return html_files
def rebuild_database(archive_dir: Path, rss_feeds_path: Path) -> dict:
"""Rebuild the database from existing HTML files.
Args:
archive_dir: Root archive directory
rss_feeds_path: Path to RSS feeds JSON file
Returns:
Dictionary with rebuild results
"""
results = {
'html_files_processed': 0,
'articles_extracted': 0,
'articles_saved': 0,
'articles_failed': 0,
'errors': []
}
# Initialize database
logger.info("Initializing database at: %s", ARCHIVE_DIR / 'cache.db')
initialize_storage()
logger.info("Database initialized")
# Load RSS feeds
rss_feeds = {}
if rss_feeds_path.exists():
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
rss_feeds = json.load(f)
# Find all HTML files
html_files = find_html_files(archive_dir)
if not html_files:
logger.warning("No HTML files found to process")
return results
# Process each HTML file
for html_file in html_files:
try:
results['html_files_processed'] += 1
# Extract URLs from HTML
urls = extract_urls_from_html(html_file)
if not urls:
logger.debug("No URLs found in %s", html_file.name)
continue
results['articles_extracted'] += len(urls)
# Process each URL
for url in urls:
try:
# Try to get source name from RSS feeds
source_name = None
for feed_name, feed_info in rss_feeds.items():
if feed_info.get('rss_url') and url.startswith(feed_info.get('rss_url', '')):
source_name = feed_name
break
# Try to infer source from URL
if not source_name:
for feed_name, feed_info in rss_feeds.items():
website = feed_info.get('source_website', '')
if website and website in url:
source_name = feed_name
break
if not source_name:
# Try to extract domain from URL
domain_match = re.search(r'https?://([^/]+)', url)
if domain_match:
domain = domain_match.group(1)
for feed_name, feed_info in rss_feeds.items():
website = feed_info.get('source_website', '')
if website and website in domain:
source_name = feed_name
break
# If still no source, skip
if not source_name:
logger.debug("No source found for %s, skipping", url[:60])
continue
# Extract content from HTML file
raw_html = html_file.read_text(encoding='utf-8')
article_data = parse_article_from_html(raw_html, url)
if article_data.error:
logger.warning("Failed to extract content from %s: %s", url[:60], article_data.error)
results['articles_failed'] += 1
continue
# Save to database
logger.info("Saving article: %s -> %s", url[:80], source_name)
storage_save_article(source_name, article_data)
results['articles_saved'] += 1
if results['articles_saved'] % 100 == 0:
logger.info("Saved %d articles so far", results['articles_saved'])
except Exception as e:
logger.error("Error processing URL %s: %s", url, str(e))
results['articles_failed'] += 1
results['errors'].append({
'url': url,
'error': str(e)
})
except Exception as e:
logger.error("Error processing file %s: %s", html_file, str(e))
results['errors'].append({
'file': str(html_file),
'error': str(e)
})
return results
if __name__ == '__main__':
logger.info("=" * 60)
logger.info("Rebuilding NewsArchiver Database")
logger.info("=" * 60)
results = rebuild_database(ARCHIVE_DIR, RSS_FEEDS_PATH)
logger.info("=" * 60)
logger.info("Rebuild Complete")
logger.info("=" * 60)
logger.info("HTML files processed: %d", results['html_files_processed'])
logger.info("Articles extracted: %d", results['articles_extracted'])
logger.info("Articles saved: %d", results['articles_saved'])
logger.info("Articles failed: %d", results['articles_failed'])
if results['errors']:
logger.info("Errors:")
for error in results['errors'][:20]:
logger.info(" - %s", error)

2428
rebuild_log.txt Normal file

File diff suppressed because it is too large Load Diff

4548
rebuild_log2.txt Normal file

File diff suppressed because it is too large Load Diff

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
flask>=3.0,<4.0
requests>=2.31,<3.0
trafilatura>=1.6,<2.0
feedparser>=6.0,<7.0
apscheduler>=3.10,<4.0
beautifulsoup4>=4.12,<5.0
playwright>=1.40,<2.0

104
restore_database.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Restore database from existing JSON metadata files."""
import json
import logging
import sys
from pathlib import Path
try:
from storage_manager import initialize_storage, save_article
from content_extractor import ArticleData
except ImportError as e:
print(f"ERROR: Required module not found: {e}")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
logger = logging.getLogger(__name__)
def restore_database(archive_dir: Path) -> dict:
"""Restore database from JSON metadata files."""
results = {
'json_files_found': 0,
'articles_restored': 0,
'articles_failed': 0,
'errors': []
}
initialize_storage()
logger.info("Database initialized")
json_files = list(archive_dir.glob('websites/**/*.json'))
results['json_files_found'] = len(json_files)
logger.info(f"Found {len(json_files)} JSON files to process")
for json_file in json_files:
try:
with open(json_file, 'r', encoding='utf-8') as f:
metadata = json.load(f)
url = metadata.get('url')
source_name = metadata.get('source_name')
title = metadata.get('title')
author = metadata.get('author')
publish_date = metadata.get('publish_date')
content_text = metadata.get('content_text')
content_html = metadata.get('content_html')
tags = metadata.get('tags', [])
extraction_method = metadata.get('extraction_method', 'unknown')
if not url or not source_name:
logger.warning(f"Missing URL or source in {json_file.name}, skipping")
results['articles_failed'] += 1
continue
article_data = ArticleData(
url=url,
title=title,
author=author,
publish_date=publish_date,
content_text=content_text,
content_html=content_html,
tags=tags,
extraction_method=extraction_method
)
save_article(source_name, article_data)
results['articles_restored'] += 1
if results['articles_restored'] % 100 == 0:
logger.info(f"Restored {results['articles_restored']} articles so far")
except Exception as e:
logger.error(f"Error processing {json_file.name}: {str(e)}")
results['articles_failed'] += 1
results['errors'].append({
'file': str(json_file),
'error': str(e)
})
return results
if __name__ == '__main__':
logger.info("=" * 60)
logger.info("Restoring NewsArchiver Database from JSON files")
logger.info("=" * 60)
results = restore_database(ARCHIVE_DIR)
logger.info("=" * 60)
logger.info("Restore Complete")
logger.info("=" * 60)
logger.info(f"JSON files found: {results['json_files_found']}")
logger.info(f"Articles restored: {results['articles_restored']}")
logger.info(f"Articles failed: {results['articles_failed']}")
if results['errors']:
logger.info("Errors:")
for error in results['errors'][:20]:
logger.info(f" - {error}")

306
rss_feeds.json Normal file
View File

@ -0,0 +1,306 @@
{
"Financial Times": {
"source_website": "ft.com",
"rss_url": "https://www.ft.com/rss/home",
"entries": 12,
"validated_at": "2026-03-18T20:21:46.292164"
},
"Reuters Business News": {
"source_website": "reuters.com",
"rss_url": "https://news.google.com/rss/search?q=site:reuters.com+business&hl=en-US&gl=US&ceid=US:en",
"disabled": true,
"disable_reason": "Google News RSS only provides encrypted URLs that don't work when accessed directly. Reuters does not provide public RSS feeds.",
"entries": 100,
"validated_at": "2026-03-19T15:48:00.000000"
},
"Fortune Top Stories": {
"source_website": "fortune.com",
"rss_url": "https://fortune.com/feed/fortune-feeds/?id=3230629",
"entries": 10,
"validated_at": "2026-03-18T20:21:46.869680"
},
"Seeking Alpha Market News": {
"source_website": "seekingalpha.com",
"rss_url": "https://seekingalpha.com/feed.xml",
"entries": 30,
"validated_at": "2026-03-18T20:21:47.462673"
},
"The Motley Fool Stock News & Analysis": {
"source_website": "fool.com",
"rss_url": "https://www.fool.com/a/feeds/partner/googlechromefollow?apikey=${MOTLEY_FOOL_API_KEY}",
"entries": 50,
"validated_at": "2026-03-18T20:21:48.479725"
},
"TheStreet Full Articles": {
"source_website": "thestreet.com",
"rss_url": "https://www.thestreet.com/.rss/full",
"entries": 50,
"validated_at": "2026-03-18T20:21:50.429528"
},
"MarketBeat Market News": {
"source_website": "marketbeat.com",
"rss_url": "https://www.marketbeat.com/feed/",
"entries": 100,
"validated_at": "2026-03-18T20:22:06.522327"
},
"Money (Time) Personal Finance": {
"source_website": "money.com",
"rss_url": "https://money.com/money/feed/",
"entries": 10,
"validated_at": "2026-03-18T20:22:07.133884"
},
"Global Finance Magazine": {
"source_website": "gfmag.com",
"rss_url": "https://www.gfmag.com/feed",
"entries": 10,
"validated_at": "2026-03-18T20:22:08.230178"
},
"Financial Samurai": {
"source_website": "financialsamurai.com",
"rss_url": "https://www.financialsamurai.com/feed/",
"entries": 7,
"validated_at": "2026-03-18T20:22:09.173814"
},
"MoneyWeek": {
"source_website": "moneyweek.com",
"rss_url": "https://moneyweek.com/feed/all",
"entries": 50,
"validated_at": "2026-03-18T20:22:10.001295"
},
"Finance Monthly": {
"source_website": "finance-monthly.com",
"rss_url": "https://www.finance-monthly.com/feed/",
"entries": 45,
"validated_at": "2026-03-18T20:22:11.526262"
},
"European Financial Review": {
"source_website": "europeanfinancialreview.com",
"rss_url": "https://www.europeanfinancialreview.com/feed",
"entries": 10,
"validated_at": "2026-03-18T20:22:13.344671"
},
"World Finance": {
"source_website": "worldfinance.com",
"rss_url": "https://www.worldfinance.com/feed",
"entries": 10,
"validated_at": "2026-03-18T20:22:16.414742"
},
"Fox Business Headlines": {
"source_website": "foxbusiness.com",
"rss_url": "https://moxie.foxbusiness.com/google-publisher/latest.xml",
"entries": 25,
"validated_at": "2026-03-18T20:22:22.238519"
},
"FinanceAsia": {
"source_website": "financeasia.com",
"rss_url": "https://www.financeasia.com/rss/latest",
"entries": 10,
"validated_at": "2026-03-18T20:22:23.798531"
},
"CNBC Business": {
"source_website": "cnbc.com",
"rss_url": "https://www.cnbc.com/id/100003114/device/rss/rss.html",
"entries": 30,
"validated_at": "2026-03-18T20:22:24.453119"
},
"Markets Insider": {
"source_website": "markets.businessinsider.com",
"rss_url": "https://markets.businessinsider.com/rss/news",
"entries": 10,
"validated_at": "2026-03-18T20:22:29.272544"
},
"The Economist Business & Finance": {
"source_website": "economist.com",
"rss_url": "https://www.economist.com/business/rss.xml",
"entries": 300,
"validated_at": "2026-03-18T20:22:30.110716"
},
"Barchart News": {
"source_website": "barchart.com",
"rss_url": "https://feeds.feedburner.com/BarchartNews",
"entries": 15,
"validated_at": "2026-03-18T20:22:30.831390"
},
"The Guardian Business": {
"source_website": "theguardian.com",
"rss_url": "https://feeds.theguardian.com/theguardian/uk/business/rss",
"entries": 40,
"validated_at": "2026-03-18T20:22:32.033297"
},
"Economy Watch": {
"source_website": "economywatch.com",
"rss_url": "https://www.economywatch.com/feed",
"entries": 10,
"validated_at": "2026-03-18T20:22:33.008150"
},
"CFI.co": {
"source_website": "cfi.co",
"rss_url": "https://cfi.co/feed",
"entries": 20,
"validated_at": "2026-03-18T20:22:35.620736"
},
"BBC News Business": {
"source_website": "bbc.co.uk",
"rss_url": "https://feeds.bbci.co.uk/news/business/rss.xml",
"entries": 56,
"validated_at": "2026-03-18T20:22:36.643323"
},
"Investors Business Daily": {
"source_website": "investors.com",
"rss_url": "https://www.investors.com/feed/",
"entries": 100,
"validated_at": "2026-03-18T20:22:38.264669"
},
"MarketWatch Top Stories": {
"source_website": "marketwatch.com",
"rss_url": "https://feeds.marketwatch.com/marketwatch/topstories/",
"entries": 10,
"validated_at": "2026-03-18T20:22:45.591752"
},
"Wall Street Journal U.S. Business": {
"source_website": "wsj.com",
"rss_url": "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml",
"entries": 20,
"validated_at": "2026-03-18T20:22:46.259913"
},
"Investing.com News": {
"source_website": "investing.com",
"rss_url": "https://www.investing.com/rss/news.rss",
"entries": 10,
"validated_at": "2026-03-18T20:22:50.790503"
},
"International Business Times": {
"source_website": "ibtimes.com",
"rss_url": "https://www.ibtimes.com/rss",
"entries": 25,
"validated_at": "2026-03-18T20:23:13.449646"
},
"404 Media": {
"source_website": "404media.co",
"rss_url": "https://404media.co/feed/",
"entries": 30,
"validated_at": "2026-03-20T00:00:00.000000"
},
"Mac Rumors": {
"source_website": "macrumors.com",
"rss_url": "https://feeds.macrumors.com/MacRumors-All",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"The Verge": {
"source_website": "theverge.com",
"rss_url": "https://www.theverge.com/rss/index.xml",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"TechCrunch": {
"source_website": "techcrunch.com",
"rss_url": "https://techcrunch.com/feed/",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"WIRED": {
"source_website": "wired.com",
"rss_url": "https://www.wired.com/feed/rss",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"Hacker News": {
"source_website": "news.ycombinator.com",
"rss_url": "https://news.ycombinator.com/rss",
"entries": 30,
"validated_at": "2026-03-20T00:00:00.000000"
},
"ZDNet": {
"source_website": "zdnet.com",
"rss_url": "https://www.zdnet.com/news/rss.xml",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"Engadget": {
"source_website": "engadget.com",
"rss_url": "https://www.engadget.com/rss.xml",
"entries": 50,
"validated_at": "2026-03-20T00:00:00.000000"
},
"Ars Technica": {
"source_website": "arstechnica.com",
"rss_url": "https://arstechnica.com/feed/",
"entries": 50,
"validated_at": "2026-03-21T00:00:00.000000"
},
"Associated Press": {
"source_website": "apnews.com",
"rss_url": "https://apnews.com",
"feed_type": "html",
"entries": 100,
"validated_at": "2026-03-20T00:00:00.000000"
},
"The Hacker News": {
"source_website": "thehackernews.com",
"rss_url": "https://thehackernews.com/feeds/posts/default",
"entries": 50,
"validated_at": "2026-03-21T13:17:00+00:00"
},
"Dark Reading": {
"source_website": "darkreading.com",
"rss_url": "https://www.darkreading.com/rss.xml",
"entries": 50,
"validated_at": "2026-03-20T19:30:19+00:00"
},
"SecurityWeek": {
"source_website": "securityweek.com",
"rss_url": "https://www.securityweek.com/feed",
"entries": 10,
"validated_at": "2026-03-21T11:00:00+00:00"
},
"BleepingComputer": {
"source_website": "bleepingcomputer.com",
"rss_url": "https://www.bleepingcomputer.com/feed",
"entries": 15,
"validated_at": "2026-03-21T17:30:41+00:00"
},
"Microsoft Security Blog": {
"source_website": "microsoft.com",
"rss_url": "https://www.microsoft.com/security/blog/feed",
"entries": 10,
"validated_at": "2026-03-20T16:19:00+00:00"
},
"EFF Deeplinks": {
"source_website": "eff.org",
"rss_url": "https://www.eff.org/deeplinks.xml",
"entries": 50,
"validated_at": "2026-03-20T22:20:49+00:00"
},
"US-CISA": {
"source_website": "cisa.gov",
"rss_url": "https://www.cisa.gov/news.xml",
"entries": 10,
"validated_at": "2026-02-26T12:00:00+00:00"
},
"Google Security Blog": {
"source_website": "google.com",
"rss_url": "https://security.googleblog.com/feeds/posts/default",
"entries": 25,
"validated_at": "2026-02-27T17:01:00+00:00"
},
"Politico": {
"source_website": "politico.com",
"rss_url": "https://www.politico.com/rss/politicopicks.xml",
"entries": 50,
"validated_at": "2026-03-22T14:58:00+00:00"
},
"Cyber Security News": {
"source_website": "cybersecuritynews.com",
"rss_url": "https://cybersecuritynews.com/feed/",
"entries": 50,
"validated_at": "2026-03-23T18:47:58+00:00"
},
"ProPublica": {
"source_website": "propublica.org",
"rss_url": "https://www.propublica.org/feeds/propublica/main",
"entries": 30,
"validated_at": "2026-03-24T00:00:00.000000"
}
}

455
rss_processor.py Normal file
View File

@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""RSS Feed Processor for NewsArchiver - Phase 2.1"""
import argparse
import json
import logging
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import List, Optional
try:
import feedparser
from feedparser import FeedParserDict
except ImportError:
print("ERROR: feedparser is required. Install with: pip install feedparser")
sys.exit(1)
try:
import sqlite3
except ImportError:
print("ERROR: sqlite3 is required (should be built-in)")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
ARCHIVE_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
def init_db(db_path: Path = ARCHIVE_DIR / 'cache.db') -> None:
"""Initialize SQLite database for caching processed articles."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_name TEXT NOT NULL,
article_url TEXT NOT NULL UNIQUE,
article_guid TEXT,
title TEXT,
author TEXT,
publish_date TEXT,
content_text TEXT,
content_html TEXT,
archive_file_path TEXT,
metadata_file_path TEXT,
status TEXT DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS processing_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_name TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
action TEXT,
status TEXT,
message TEXT
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_articles_source_url
ON articles(source_name, article_url)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_articles_status
ON articles(status)
''')
conn.commit()
conn.close()
logger.debug("Database initialized: %s", db_path)
def get_db_connection(db_path: Path = ARCHIVE_DIR / 'cache.db'):
"""Get database connection."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def is_duplicate(article_url: str, source_name: str, db_path: Path = ARCHIVE_DIR / 'cache.db') -> bool:
"""Check if article already in cache."""
conn = get_db_connection(db_path)
cursor = conn.cursor()
cursor.execute(
'SELECT 1 FROM articles WHERE source_name = ? AND article_url = ?',
(source_name, article_url)
)
result = cursor.fetchone()
conn.close()
return result is not None
def add_to_cache(
article_url: str,
source_name: str,
timestamp: datetime,
article_guid: str = None,
title: str = None,
author: str = None,
publish_date: str = None,
db_path: Path = ARCHIVE_DIR / 'cache.db'
) -> bool:
"""Add article to cache."""
conn = get_db_connection(db_path)
cursor = conn.cursor()
try:
cursor.execute('''
INSERT OR IGNORE INTO articles
(source_name, article_url, article_guid, title, author, publish_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
source_name,
article_url,
article_guid,
title,
author,
publish_date,
'pending'
))
conn.commit()
conn.close()
return True
except sqlite3.IntegrityError:
conn.close()
return False
def save_article(
source_name: str,
article_data: dict,
db_path: Path = ARCHIVE_DIR / 'cache.db'
) -> str:
"""Save article to storage and update cache."""
conn = get_db_connection(db_path)
cursor = conn.cursor()
article_url = article_data.get('link', '')
article_guid = article_data.get('id', article_url)
title = article_data.get('title', '')
author = article_data.get('author', '')
publish_date = article_data.get('published', '')
summary = article_data.get('summary', '')
cursor.execute('''
INSERT OR REPLACE INTO articles
(source_name, article_url, article_guid, title, author, publish_date, content_text, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
source_name,
article_url,
article_guid,
title,
author,
publish_date,
summary,
'archived'
))
conn.commit()
conn.close()
return article_url
def fetch_rss_feed(rss_url: str, timeout: int = 30) -> FeedParserDict:
"""Fetch and parse RSS feed."""
logger.info("Fetching RSS feed: %s", rss_url[:50] + "..." if len(rss_url) > 50 else rss_url)
try:
req = urllib.request.Request(
rss_url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
)
with urllib.request.urlopen(req, timeout=timeout) as response:
feed_content = response.read()
feed = feedparser.parse(feed_content)
if feed.bozo:
logger.warning("Feed parsing completed with warnings: %s", feed.bozo)
entry_count = len(feed.entries)
logger.info("Feed parsed: %d entries", entry_count)
return feed
except urllib.error.HTTPError as e:
logger.error("HTTP error fetching RSS feed %s: %s", rss_url, str(e.code))
raise
except urllib.error.URLError as e:
logger.error("URL error fetching RSS feed %s: %s", rss_url, str(e.reason))
raise
except Exception as e:
logger.error("Failed to fetch RSS feed %s: %s", rss_url, str(e))
raise
def decode_google_news_url(google_url: str, entry: dict = None) -> Optional[str]:
"""Decode Google News encrypted URL to actual article URL.
Google News RSS uses encrypted URLs like:
https://news.google.com/rss/articles/CBMioAFB... which need to be decoded.
Args:
google_url: Google News encrypted URL
entry: Full RSS entry for additional context
Returns:
Decoded article URL or None if not a Google News URL
"""
if 'news.google.com' not in google_url:
return google_url
try:
import base64
import urllib.parse
if '/rss/articles/' in google_url:
parts = google_url.split('/rss/articles/')
if len(parts) >= 2:
encoded = parts[1]
if encoded.startswith('CBM'):
encoded = encoded[3:]
padding = (4 - len(encoded) % 4) % 4
encoded += '=' * padding
try:
decoded = base64.urlsafe_b64decode(encoded).decode('utf-8')
logger.debug("Decoded Google News URL: %s -> %s", google_url[:60], decoded[:60])
return decoded
except Exception:
pass
if '/articles/' in google_url:
parts = google_url.split('/articles/')
if len(parts) >= 2:
encoded = parts[1]
if encoded.startswith('CBM'):
encoded = encoded[3:]
padding = (4 - len(encoded) % 4) % 4
encoded += '=' * padding
try:
decoded = base64.urlsafe_b64decode(encoded).decode('utf-8')
logger.debug("Decoded Google News URL: %s -> %s", google_url[:60], decoded[:60])
return decoded
except Exception:
pass
if 'url=' in google_url:
parsed = urllib.parse.urlparse(google_url)
params = urllib.parse.parse_qs(parsed.query)
if 'url' in params:
return params['url'][0]
logger.debug("Could not decode Google News URL: %s", google_url[:60])
if entry and 'source' in entry:
source = entry.get('source', {})
if isinstance(source, dict) and 'href' in source:
source_href = source['href']
logger.debug("Using source URL as fallback: %s", source_href)
return source_href
return google_url
except Exception as e:
logger.debug("Failed to decode Google News URL %s: %s", google_url[:60], str(e))
return google_url
def process_rss_feed(
rss_url: str,
source_name: str,
output_dir: Path,
db_path: Path = ARCHIVE_DIR / 'cache.db'
) -> List[dict]:
"""Process RSS feed and archive new articles."""
logger.info("Processing RSS feed for source: %s", source_name)
try:
feed = fetch_rss_feed(rss_url)
except Exception as e:
logger.error("Failed to fetch feed: %s", str(e))
return []
new_articles = []
skipped_count = 0
for entry in feed.entries:
article_url = entry.get('link', '')
article_url = decode_google_news_url(article_url, entry)
article_guid = entry.get('id', article_url)
if not article_url:
logger.warning("Skipping entry without URL")
continue
if is_duplicate(article_url, source_name, db_path):
logger.debug("Skipping duplicate: %s", article_url[:60])
skipped_count += 1
continue
title = entry.get('title', 'No Title')
author = entry.get('author', entry.get('authors', [{}])[0].get('name', '') if entry.get('authors') else '')
published = entry.get('published', entry.get('published_parsed', ''))
summary = entry.get('summary', entry.get('description', ''))
if published:
if hasattr(published, 'tm_year'):
publish_date = datetime(*published[:6]).isoformat()
else:
publish_date = published
else:
publish_date = datetime.now().isoformat()
article_data = {
'source_name': source_name,
'url': article_url,
'guid': article_guid,
'title': title,
'author': author,
'publish_date': publish_date,
'summary': summary,
'entry': entry
}
add_to_cache(
article_url=article_url,
source_name=source_name,
timestamp=datetime.now(),
article_guid=article_guid,
title=title,
author=author,
publish_date=publish_date,
db_path=db_path
)
new_articles.append(article_data)
logger.debug("New article: %s", title[:60])
logger.info("Processed %s: %d new, %d skipped", source_name, len(new_articles), skipped_count)
return new_articles
def process_all_feeds(
rss_feeds_path: Path = SCRIPT_DIR / 'rss_feeds.json',
output_dir: Path = ARCHIVE_DIR,
db_path: Path = ARCHIVE_DIR / 'cache.db'
) -> dict:
"""Process all RSS feeds from rss_feeds.json."""
if not rss_feeds_path.exists():
logger.error("RSS feeds file not found: %s", rss_feeds_path)
return {'success': False, 'error': 'File not found'}
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
rss_feeds = json.load(f)
results = {
'total': 0,
'success': 0,
'failed': 0,
'new_articles': 0,
'errors': []
}
for source_name, feed_info in rss_feeds.items():
rss_url = feed_info.get('rss_url', '')
if not rss_url:
logger.warning("No RSS URL for source: %s", source_name)
continue
if feed_info.get('disabled', False):
logger.info("Skipping disabled source: %s (%s)", source_name,
feed_info.get('disable_reason', 'No reason provided'))
continue
results['total'] += 1
try:
articles = process_rss_feed(rss_url, source_name, output_dir, db_path)
results['success'] += 1
results['new_articles'] += len(articles)
logger.info("Completed %s: %d new articles", source_name, len(articles))
except Exception as e:
results['failed'] += 1
results['errors'].append({
'source': source_name,
'url': rss_url,
'error': str(e)
})
logger.error("Failed to process %s: %s", source_name, str(e))
return results
def main():
parser = argparse.ArgumentParser(description='RSS Feed Processor for NewsArchiver')
parser.add_argument('--url', help='Single RSS URL to process')
parser.add_argument('--source', help='Source name (required if --url provided)')
parser.add_argument('--all', action='store_true', help='Process all feeds from rss_feeds.json')
parser.add_argument('--rss-feeds', type=Path, default=SCRIPT_DIR / 'rss_feeds.json',
help='Path to RSS feeds JSON file')
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
help='Output directory for archived content')
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
logger.info("=" * 60)
logger.info("RSS Feed Processor - Phase 2.1")
logger.info("=" * 60)
init_db()
if args.url:
if not args.source:
logger.error("Source name required when using --url")
return
process_rss_feed(args.url, args.source, args.output)
elif args.all:
results = process_all_feeds(args.rss_feeds, args.output)
print("\n" + "=" * 60)
print("PROCESSING COMPLETE")
print("=" * 60)
print(f"Total feeds: {results['total']}")
print(f"Successful: {results['success']}")
print(f"Failed: {results['failed']}")
print(f"New articles: {results['new_articles']}")
if results['errors']:
print("\nErrors:")
for error in results['errors']:
print(f" - {error['source']}: {error['error']}")
print("=" * 60)
else:
parser.print_help()
if __name__ == '__main__':
main()

320
run_archiver.py Normal file
View File

@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""NewsArchiver - Main CLI Entry Point (Phase 4)
Single-file CLI for running NewsArchiver with multiple modes:
- --run: Archive news articles once
- --serve: Start Flask web server
- --interval: Run background scheduler with specified interval
"""
import argparse
import atexit
import logging
import os
import signal
import sys
import threading
import time
from pathlib import Path
try:
from flask import Flask
except ImportError:
print("ERROR: Flask is required. Install with: pip install flask")
sys.exit(1)
try:
from scheduler import start_scheduler, stop_scheduler, scheduled_archive
except ImportError:
print("ERROR: scheduler module not found")
sys.exit(1)
try:
from rss_processor import process_all_feeds, init_db as init_db_rss
except ImportError:
print("ERROR: rss_processor module not found")
sys.exit(1)
try:
from content_extractor import get_html_from_url
except ImportError:
print("ERROR: content_extractor module not found")
sys.exit(1)
try:
from storage_manager import initialize_storage, get_all_sources
except ImportError:
print("ERROR: storage_manager module not found")
sys.exit(1)
try:
from archive_engine import archive_all_sources
except ImportError:
print("ERROR: archive_engine module not found")
sys.exit(1)
try:
from web_interface import app
except ImportError:
print("ERROR: web_interface module not found")
sys.exit(1)
try:
from singlefile_archive import check_singlefile_available
except ImportError:
print("WARNING: singlefile_archive module not found")
print("SingleFile integration will not be available")
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
def setup_logging(verbose: bool = False) -> logging.Logger:
"""Configure logging for the application.
Args:
verbose: If True, enable DEBUG level logging
Returns:
Configured logger instance
"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
logger.info("NewsArchiver - Main CLI Entry Point")
logger.info("=" * 60)
return logger
def run_archive_once(logger: logging.Logger, verbose: bool = False) -> bool:
"""Run archiving process once.
Args:
logger: Logger instance
verbose: If True, enable verbose logging
Returns:
True if successful, False otherwise
"""
try:
logger.info("Running one-time archive")
logger.info("=" * 60)
init_db_rss()
initialize_storage()
results = archive_all_sources(
rss_feeds_path=SCRIPT_DIR / 'rss_feeds.json',
output_dir=ARCHIVE_DIR,
dry_run=False
)
logger.info("=" * 60)
logger.info("Archive complete")
logger.info("=" * 60)
logger.info("Sources processed: %d", results.get('sources_processed', 0))
logger.info("Total articles archived: %d", results.get('total_articles_archived', 0))
logger.info("Total articles skipped: %d", results.get('total_articles_skipped', 0))
logger.info("Total articles failed: %d", results.get('total_articles_failed', 0))
return True
except Exception as e:
logger.error("Archive failed: %s", str(e))
return False
def run_scheduler(interval_minutes: int, logger: logging.Logger, verbose: bool = False) -> None:
"""Run background scheduler.
Args:
interval_minutes: Interval between archive runs in minutes
logger: Logger instance
verbose: If True, enable verbose logging
"""
logger.info("Starting background scheduler")
logger.info("=" * 60)
try:
init_db_rss()
initialize_storage()
scheduler = start_scheduler(interval_minutes)
atexit.register(stop_scheduler)
logger.info("Press Ctrl+C to stop")
try:
while True:
time.sleep(1)
except (KeyboardInterrupt, SystemExit):
logger.info("Shutting down scheduler...")
stop_scheduler()
logger.info("Scheduler stopped")
except Exception as e:
logger.error("Scheduler failed to start: %s", str(e))
sys.exit(1)
def run_web_server(host: str, port: int, logger: logging.Logger, verbose: bool = False, interval_minutes: int = None) -> None:
"""Run Flask web server with optional background scheduler.
Args:
host: Host to bind to
port: Port to bind to
logger: Logger instance
verbose: If True, enable verbose logging
interval_minutes: If set, start background scheduler at this interval
"""
logger.info("Starting web server")
logger.info("=" * 60)
_scheduler_stopped = [False]
def _start_scheduler():
try:
init_db_rss()
initialize_storage()
start_scheduler(interval_minutes)
logger.info("Background scheduler started (every %d min)", interval_minutes)
logger.info("Running initial archive...")
scheduled_archive(logger)
logger.info("Initial archive complete")
except Exception as e:
logger.error("Scheduler thread failed: %s", str(e))
finally:
_scheduler_stopped[0] = True
def _shutdown_scheduler(signum=None, frame=None):
if not _scheduler_stopped[0]:
logger.info("Stopping scheduler...")
stop_scheduler()
logger.info("Scheduler stopped")
sys.exit(0)
try:
if not (ARCHIVE_DIR / 'cache.db').exists():
logger.info("Database not found, initializing...")
initialize_storage()
if not check_singlefile_available():
logger.warning("SingleFile CLI not available. Some features may not work.")
if interval_minutes:
logger.info("Starting background archive scheduler (interval: %d min)...", interval_minutes)
t = threading.Thread(target=_start_scheduler, daemon=True)
t.start()
signal.signal(signal.SIGINT, _shutdown_scheduler)
signal.signal(signal.SIGTERM, _shutdown_scheduler)
logger.info("Web server starting on %s:%d", host, port)
logger.info("=" * 60)
app.run(
host=host,
port=port,
debug=False
)
except Exception as e:
logger.error("Web server failed to start: %s", str(e))
sys.exit(1)
def main() -> None:
"""Main entry point for NewsArchiver CLI."""
parser = argparse.ArgumentParser(
description='NewsArchiver - News Article Archiving System',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s --run Run archiving once
%(prog)s --serve Start web server
%(prog)s --serve --host 0.0.0.0 --port 8080
Start web server on custom host/port
%(prog)s --interval 60 Run background scheduler (1 hour interval)
'''
)
parser.add_argument(
'--run',
action='store_true',
help='Run archiving once (process all RSS feeds)'
)
parser.add_argument(
'--serve',
action='store_true',
help='Start Flask web server'
)
parser.add_argument(
'--interval',
type=int,
default=None,
help='Run background scheduler with specified interval (minutes). When used with --serve, runs in background thread (default: 60 min). Standalone blocks.'
)
parser.add_argument(
'--host',
type=str,
default='0.0.0.0',
help='Host for web server (default: 0.0.0.0)'
)
parser.add_argument(
'--port',
type=int,
default=5000,
help='Port for web server (default: 5000)'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose logging (DEBUG level)'
)
args = parser.parse_args()
# CI port shift: detect CI env, map to reserved CI port range 10000-10099
# CI_PORT_OFFSET (1-99) maps to 10001-10099. Each service gets a fixed offset.
# NewsArchiverV2=1, paste-bin=2, etc. See AGENTS.md for assignments.
if os.environ.get("CI") == "true" and os.environ.get("SKIP_PORT_SHIFT") != "1":
original_port = args.port
offset = int(os.environ.get("CI_PORT_OFFSET", "1"))
args.port = 10000 + offset
print(f"[ci-port-shift] Port shifted from {original_port} to {args.port} (offset {offset}, CI range: 10000-10099)")
logger = setup_logging(args.verbose)
if args.run:
success = run_archive_once(logger, args.verbose)
sys.exit(0 if success else 1)
elif args.serve:
interval = args.interval if args.interval else 60
run_web_server(args.host, args.port, logger, args.verbose, interval_minutes=interval)
elif args.interval:
run_scheduler(args.interval, logger, args.verbose)
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()

209
scheduler.py Normal file
View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Scheduler for NewsArchiver - Phase 4
Background scheduler using APScheduler to automate
daily archiving of news sources.
"""
import atexit
import logging
import os
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
except ImportError:
print("ERROR: APScheduler is required. Install with: pip install apscheduler")
sys.exit(1)
try:
from archive_engine import archive_all_sources
except ImportError:
print("ERROR: archive_engine is required")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
# Timeout configuration
MAX_RUN_TIME_SECONDS = 3600 # 1 hour
start_time = None
_timeout_timer = None
def _timeout_checker():
"""Daemon thread that raises SystemExit when timeout is reached."""
elapsed = (datetime.now() - start_time).total_seconds()
remaining = MAX_RUN_TIME_SECONDS - elapsed
if remaining > 0:
logger.warning("Timeout reached (%d seconds). Will exit after current download completes.", MAX_RUN_TIME_SECONDS)
raise SystemExit(0)
def check_timeout() -> bool:
"""Check if timeout has been reached.
Returns:
True if timeout reached, False otherwise
"""
global start_time
elapsed = (datetime.now() - start_time).total_seconds()
if elapsed >= MAX_RUN_TIME_SECONDS:
logger.warning("Maximum runtime of %d seconds reached (%d seconds elapsed)", MAX_RUN_TIME_SECONDS, int(elapsed))
return True
return False
def _start_timeout_thread():
"""Start a daemon thread that will raise SystemExit after MAX_RUN_TIME_SECONDS."""
global _timeout_timer
_timeout_timer = threading.Timer(MAX_RUN_TIME_SECONDS, _timeout_checker)
_timeout_timer.daemon = True
_timeout_timer.start()
def _cancel_timeout_thread():
"""Cancel the timeout thread."""
global _timeout_timer
if _timeout_timer:
_timeout_timer.cancel()
_timeout_timer = None
def scheduled_archive() -> None:
"""Run archiving for all sources."""
global start_time
start_time = datetime.now()
logger.info("=" * 60)
logger.info("Starting scheduled archive run")
logger.info("=" * 60)
_start_timeout_thread()
try:
results = archive_all_sources(
rss_feeds_path=SCRIPT_DIR / 'rss_feeds.json',
output_dir=ARCHIVE_DIR,
dry_run=False
)
if results['success']:
logger.info("Scheduled archive completed successfully")
logger.info("Sources processed: %d", results.get('sources_processed', 0))
logger.info("Total articles archived: %d", results.get('total_articles_archived', 0))
else:
logger.error("Scheduled archive failed: %s", results.get('error', 'Unknown error'))
except SystemExit as e:
logger.info("Scheduler exiting due to timeout")
raise e
except Exception as e:
logger.error("Scheduled archive failed with exception: %s", str(e))
finally:
_cancel_timeout_thread()
def start_scheduler(interval_minutes: int = 60) -> BackgroundScheduler:
"""Start the background scheduler.
Args:
interval_minutes: Interval between archive runs in minutes
Returns:
The scheduler instance
"""
scheduler.add_job(
func=scheduled_archive,
trigger=IntervalTrigger(minutes=interval_minutes),
id='archive_news',
replace_existing=True,
misfire_grace_time=60,
coalesce=True
)
scheduler.start()
logger.info("Scheduler started with %d minute interval", interval_minutes)
logger.info("Running initial archive immediately...")
scheduled_archive()
return scheduler
def run_once() -> None:
"""Run archiving once (for CLI --run flag)."""
global start_time
start_time = datetime.now()
logger.info("=" * 60)
logger.info("Running one-time archive")
logger.info("=" * 60)
_start_timeout_thread()
try:
scheduled_archive()
logger.info("=" * 60)
logger.info("One-time archive completed")
logger.info("=" * 60)
except SystemExit as e:
logger.info("Archiver exiting due to timeout")
raise e
finally:
_cancel_timeout_thread()
def stop_scheduler() -> None:
"""Stop the scheduler gracefully."""
if scheduler.running:
scheduler.shutdown()
logger.info("Scheduler stopped")
atexit.register(lambda: stop_scheduler())
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='NewsArchiver - Scheduler')
parser.add_argument('--run', action='store_true', help='Run archiving once')
parser.add_argument('--serve', action='store_true', help='Start web server')
parser.add_argument('--interval', type=int, default=60, help='Scheduler interval in minutes (default: 60)')
parser.add_argument('--host', default='0.0.0.0', help='Host for web server')
parser.add_argument('--port', type=int, default=5000, help='Port for web server')
args = parser.parse_args()
if args.run:
run_once()
stop_scheduler()
elif args.serve:
from web_interface import app
logger.info("Starting web server on %s:%d", args.host, args.port)
try:
app.run(host=args.host, port=args.port)
except Exception as e:
logger.error("Web server error: %s", str(e))
sys.exit(1)
else:
start_scheduler(args.interval)
logger.info("Press Ctrl+C to stop")
try:
while True:
time.sleep(1)
if check_timeout():
logger.info("Maximum runtime reached. Exiting.")
stop_scheduler()
sys.exit(0)
except (KeyboardInterrupt, SystemExit):
stop_scheduler()

84
setup_cron.sh Normal file
View File

@ -0,0 +1,84 @@
#!/bin/bash
# Setup script for NewsArchiver
# This script sets up the cron job for automated news archiving
set -e
SCRIPT_DIR="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
LOG_FILE="${NEWSARCHIVER_LOG:-/tmp/newsarchiver_cron.log}"
CRON_JOB="*/30 * * * * /usr/bin/env python3 ${SCRIPT_DIR}/run_archiver.py --interval 30 > ${LOG_FILE} 2>&1"
echo "=== NewsArchiver Setup Script ==="
echo ""
# Check Python is available
if ! command -v python3 &> /dev/null; then
echo "ERROR: python3 not found"
exit 1
fi
# Check if running as jarian
CURRENT_USER=$(whoami)
if [ "$CURRENT_USER" != "jarian" ]; then
echo "WARNING: This script is configured for user 'jarian', but you are '$CURRENT_USER'"
echo "You may need to update the script paths"
fi
# Check if NewsArchiver directory exists
if [ ! -d "$SCRIPT_DIR" ]; then
echo "ERROR: NewsArchiver directory not found at $SCRIPT_DIR"
exit 1
fi
# Check if requirements are installed
echo "Checking dependencies..."
cd "$SCRIPT_DIR"
python3 -c "import flask; import requests; import trafilatura; import feedparser; import apscheduler" 2>/dev/null || {
echo "Installing dependencies..."
pip install -r requirements.txt
}
# Check if web server is running
if ! pgrep -f "run_archiver.py --serve" > /dev/null; then
echo "Starting web server..."
nohup python3 "$SCRIPT_DIR/run_archiver.py" --serve --host 0.0.0.0 --port 5000 > /tmp/webserver.log 2>&1 &
sleep 3
if curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/ | grep -q "200"; then
echo "Web server started successfully on port 5000"
else
echo "WARNING: Web server may not be responding"
fi
else
echo "Web server is already running"
fi
# Remove old scheduler lock file if exists
if [ -f "$SCRIPT_DIR/archival_data/.scheduler.lock" ]; then
rm -f "$SCRIPT_DIR/archival_data/.scheduler.lock"
echo "Removed stale scheduler lock file"
fi
# Kill any existing scheduler processes
pkill -f "run_archiver.py --interval" 2>/dev/null || true
echo "Cleared any existing scheduler processes"
# Setup cron job
echo "Setting up cron job..."
if crontab -l 2>/dev/null | grep -q "NewsArchiver"; then
echo "Removing existing NewsArchiver cron job..."
crontab -l | grep -v "NewsArchiver" | crontab -
fi
echo "$CRON_JOB" | crontab -
echo "Cron job added successfully"
echo ""
echo "=== Current Cron Jobs ==="
crontab -l | grep NewsArchiver
echo ""
echo "=== Setup Complete ==="
echo "- Archiver will run every 30 minutes via cron"
echo "- Logs written to: $LOG_FILE"
echo "- Web interface at: http://localhost:5000"
echo ""

258
singlefile_archive.py Normal file
View File

@ -0,0 +1,258 @@
import subprocess
import os
from pathlib import Path
import logging
from typing import Optional
logger = logging.getLogger(__name__)
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
logger.debug("Playwright not available")
# Cache the SingleFile path
_SINGLEFILE_PATH: Optional[str] = None
def _get_singlefile_path() -> Optional[str]:
"""Get the path to SingleFile CLI executable."""
global _SINGLEFILE_PATH
if _SINGLEFILE_PATH is not None:
return _SINGLEFILE_PATH
# Check PATH first
single_file_path = os.environ.get('PATH', '').split(os.pathsep)
for path in single_file_path:
candidate = Path(path) / 'single-file'
if candidate.is_file():
_SINGLEFILE_PATH = str(candidate)
logger.debug(f"Found SingleFile in PATH: {_SINGLEFILE_PATH}")
return _SINGLEFILE_PATH
# Check common locations
home = Path.home()
common_locations = [
str(home / '.local' / 'bin' / 'single-file'),
'/usr/local/bin/single-file',
'/usr/bin/single-file',
str(home / '.npm' / '_global' / 'bin' / 'single-file'),
]
for candidate in common_locations:
if Path(candidate).is_file():
_SINGLEFILE_PATH = candidate
logger.debug(f"Found SingleFile at: {_SINGLEFILE_PATH}")
return _SINGLEFILE_PATH
logger.error("SingleFile CLI not found. Please install with: npm install -g single-file")
return None
def check_singlefile_available() -> bool:
"""Check if SingleFile CLI is available"""
single_file_path = _get_singlefile_path()
if not single_file_path:
return False
try:
result = subprocess.run(
[single_file_path, '--version'],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
logger.error(f"SingleFile CLI at {single_file_path} is not executable")
return False
def is_error_page(html_content: str) -> bool:
"""Check if HTML content is an error page (403, 404, etc.)
Args:
html_content: HTML content to check
Returns:
True if error page detected, False otherwise
"""
error_patterns = [
'403 error',
'403 forbidden',
'access denied',
'request blocked',
'cloudfront',
'404 error',
'page not found',
'error 404',
'server error',
'503 service unavailable',
]
html_lower = html_content.lower()
return any(pattern in html_lower for pattern in error_patterns)
def validate_archived_html(output_path: Path) -> bool:
"""Validate that archived HTML is not an error page.
Args:
output_path: Path to the archived HTML file
Returns:
True if valid, False if error page detected
"""
try:
if not output_path.exists():
logger.warning(f"Archived file not found: {output_path}")
return False
content = output_path.read_text(encoding='utf-8', errors='ignore')
if is_error_page(content):
logger.warning(f"Archived file contains error page: {output_path}")
return False
if len(content) < 1000:
logger.warning(f"Archived file too small (likely incomplete): {output_path}")
return False
return True
except Exception as e:
logger.error(f"Error validating archived HTML {output_path}: {e}")
return False
def archive_page_with_singlefile(
url: str,
output_path: Path,
extract_content: bool = True
) -> bool:
"""Archive a web page using SingleFile CLI
Args:
url: URL to archive
output_path: Output file path for the archived HTML
extract_content: Whether to use extract-content mode (ignored - SingleFile always extracts)
Returns:
True if successful, False otherwise
"""
single_file_path = _get_singlefile_path()
if not single_file_path:
logger.error("SingleFile CLI not available")
return False
cmd = [
single_file_path,
url,
str(output_path),
'--browser-headless=true',
'--browser-wait-delay=5000',
'--browser-load-max-time=120000'
]
logger.debug(f"Archiving {url} with SingleFile at {single_file_path}")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode == 0:
logger.info(f"Successfully archived {url} to {output_path}")
if result.stdout:
logger.debug(f"SingleFile output: {result.stdout}")
# Validate the archived HTML
if not validate_archived_html(output_path):
logger.warning(f"Archived HTML validation failed for {url}, will use fallback")
return False
return True
else:
error_msg = result.stderr if result.stderr else result.stdout
logger.error(f"SingleFile failed for {url}: {error_msg}")
return False
except subprocess.TimeoutExpired:
logger.error(f"SingleFile timed out for {url}")
return False
except FileNotFoundError:
logger.error(f"SingleFile CLI executable not found at: {single_file_path}")
return False
except Exception as e:
logger.error(f"Unexpected error archiving {url} with SingleFile: {e}")
return False
def archive_page_with_singlefile_no_extraction(url: str, output_path: Path) -> bool:
"""Archive a web page using SingleFile CLI without content extraction
This preserves the full original HTML structure including navigation, ads, etc.
Args:
url: URL to archive
output_path: Output file path for the archived HTML
Returns:
True if successful, False otherwise
"""
return archive_page_with_singlefile(url, output_path, extract_content=False)
def archive_page_with_singlefile_extract(url: str, output_path: Path) -> bool:
"""Archive a web page using SingleFile CLI with content extraction
This extracts only the main content, removing navigation, ads, and sidebars.
Args:
url: URL to archive
output_path: Output file path for the archived HTML
Returns:
True if successful, False otherwise
"""
return archive_page_with_singlefile(url, output_path, extract_content=True)
def archive_page_with_playwright(url: str, output_path: Path) -> bool:
"""Archive a web page using Playwright
This visits the URL with a headless browser, waits for content to load,
and saves the full HTML page.
Args:
url: URL to archive
output_path: Output file path for the archived HTML
Returns:
True if successful, False otherwise
"""
if not PLAYWRIGHT_AVAILABLE:
logger.error("Playwright not available. Install with: pip install playwright")
return False
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until='networkidle', timeout=120000)
content = page.content()
browser.close()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding='utf-8')
logger.info("Successfully archived %s to %s using Playwright", url[:60], output_path)
return True
except Exception as e:
logger.error("Playwright archiving failed for %s: %s", url, str(e))
return False

535
static/style.css Normal file
View File

@ -0,0 +1,535 @@
/* Base styles */
:root {
--primary-color: #1a1a1a;
--secondary-color: #6b7280;
--background-color: #f9fafb;
--surface-color: #ffffff;
--border-color: #e5e7eb;
--accent-color: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: #eff6ff;
--timeline-line: #d1d5db;
--dot-color: #2563eb;
}
[data-theme="dark"] {
--primary-color: #f3f4f6;
--secondary-color: #9ca3af;
--background-color: #111827;
--surface-color: #1f2937;
--border-color: #374151;
--accent-color: #60a5fa;
--accent-hover: #93c5fd;
--accent-light: #1e3a5f;
--timeline-line: #4b5563;
--dot-color: #60a5fa;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
background-color: var(--background-color);
color: var(--primary-color);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
}
/* Header */
header {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
transition: background-color 0.3s ease;
}
header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
header nav a {
color: var(--secondary-color);
text-decoration: none;
margin-left: 1.5rem;
font-size: 0.9rem;
font-weight: 500;
transition: color 0.2s ease;
}
header nav a:hover {
color: var(--accent-color);
}
main {
padding: 2rem;
max-width: 960px;
margin: 0 auto;
}
/* Page headings */
main > h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.025em;
margin: 0 0 1.5rem 0;
}
/* Newspaper list */
.newspaper-list {
list-style: none;
padding: 0;
margin: 0;
}
.newspaper-item {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.15s ease;
}
.newspaper-item:hover {
border-color: var(--accent-color);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.newspaper-info h2 {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
}
.newspaper-info h2 a {
color: var(--primary-color);
text-decoration: none;
}
.newspaper-info h2 a:hover {
color: var(--accent-color);
}
.newspaper-info p {
margin: 0;
color: var(--secondary-color);
font-size: 0.85rem;
}
.status-success { color: #16a34a; font-weight: 600; }
.status-pending { color: #f59e0b; font-weight: 600; }
.pull-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background-color 0.15s ease;
}
.pull-btn:hover {
background: var(--accent-hover);
}
/* Timeline Layout */
.timeline {
position: relative;
padding-left: 3rem;
}
.timeline::before {
content: '';
position: absolute;
left: 1.25rem;
top: 0;
bottom: 0;
width: 2px;
background: var(--timeline-line);
border-radius: 1px;
}
.timeline-date {
position: relative;
margin: 2rem 0 0.75rem;
}
.timeline-date:first-child {
margin-top: 0.5rem;
}
.timeline-date::before {
content: '';
position: absolute;
left: -2.15rem;
top: 0.35rem;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--dot-color);
border: 3px solid var(--background-color);
box-shadow: 0 0 0 2px var(--dot-color);
z-index: 2;
}
.date-label {
font-weight: 700;
font-size: 0.95rem;
color: var(--primary-color);
letter-spacing: -0.01em;
}
.date-count {
margin-left: 0.6rem;
font-size: 0.8rem;
color: var(--secondary-color);
font-weight: 400;
}
/* Article cards in timeline */
.article-list {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
}
.article-item {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.5rem;
transition: all 0.15s ease;
}
.article-item:hover {
border-color: var(--accent-color);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
transform: translateY(-1px);
}
.article-item h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.article-item h3 a {
color: var(--primary-color);
text-decoration: none;
}
.article-item h3 a:hover {
color: var(--accent-color);
}
.article-summary {
color: var(--secondary-color);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-date {
color: var(--secondary-color);
font-size: 0.8rem;
margin: 0.25rem 0;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
margin: 1.5rem 0;
padding: 1rem 0;
border-top: 1px solid var(--border-color);
}
.pagination a {
color: var(--accent-color);
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
}
.pagination a:hover {
text-decoration: underline;
}
.pagination span {
color: var(--secondary-color);
font-size: 0.85rem;
}
/* Article detail view */
.article-view {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.article-source {
display: inline-block;
background: var(--accent-light);
color: var(--accent-color);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.article-view h1 {
margin: 0 0 1rem 0;
font-size: 1.75rem;
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.025em;
}
.article-meta {
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.article-meta time,
.article-meta span {
font-size: 0.85rem;
color: var(--secondary-color);
}
.article-meta a {
color: var(--accent-color);
text-decoration: none;
font-size: 0.85rem;
word-break: break-all;
}
.article-meta a:hover {
text-decoration: underline;
}
.article-content {
font-size: 1.05rem;
line-height: 1.8;
}
.article-text {
white-space: pre-wrap;
color: var(--primary-color);
}
.article-text p {
margin: 0 0 1rem 0;
}
.archived-html {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
background: var(--background-color);
}
.article-actions {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
gap: 1rem;
align-items: center;
}
.back-link {
color: var(--accent-color);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1rem;
background: var(--accent-light);
border-radius: 6px;
transition: all 0.15s ease;
}
.back-link:hover {
background: var(--accent-color);
color: white;
text-decoration: none;
}
/* Disabled feeds */
.source-disabled {
color: var(--secondary-color);
text-decoration: line-through;
}
.status-badge {
display: inline-block;
background: #ef4444;
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
margin-left: 0.5rem;
}
.disable-reason {
color: var(--secondary-color);
font-size: 0.8rem;
margin-top: 0.25rem;
font-style: italic;
}
/* Status page */
.status-page {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2rem;
}
.status-page h1 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
font-weight: 700;
}
.status-page h2 {
font-size: 1.1rem;
margin: 1.5rem 0 1rem;
font-weight: 600;
}
.status-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.status-item {
background: var(--background-color);
border-radius: 8px;
padding: 1rem;
}
.status-item h2 {
margin: 0 0 0.25rem;
font-size: 0.8rem;
color: var(--secondary-color);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-item p {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.status-online { color: #16a34a; }
.status-warning { color: #f59e0b; }
/* Not found */
.article-not-found {
text-align: center;
padding: 4rem 2rem;
}
.article-not-found h1 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.article-not-found p {
color: var(--secondary-color);
margin-bottom: 1.5rem;
}
/* Responsive */
@media (max-width: 768px) {
header {
padding: 0.75rem 1rem;
}
main {
padding: 1rem;
}
.timeline {
padding-left: 2.5rem;
}
.timeline::before {
left: 0.75rem;
}
.timeline-date::before {
left: -1.95rem;
width: 10px;
height: 10px;
}
.article-view {
padding: 1.5rem;
}
.article-view h1 {
font-size: 1.35rem;
}
.newspaper-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
/* Print */
@media print {
header, .pull-btn, .pagination, .article-actions {
display: none;
}
.article-view {
border: none;
padding: 0;
box-shadow: none;
}
}

19
stop_services.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Stop NewsArchiver services
echo "Stopping NewsArchiver services..."
# Stop web server
pkill -f "run_archiver.py --serve" 2>/dev/null || true
echo "Web server stopped"
# Stop scheduler
pkill -f "run_archiver.py --interval" 2>/dev/null || true
echo "Scheduler stopped"
# Remove lock file
LOCK_FILE="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/archival_data/.scheduler.lock"
rm -f "$LOCK_FILE" 2>/dev/null || true
echo "Scheduler lock file removed"
echo "All NewsArchiver services stopped"

859
storage_manager.py Normal file
View File

@ -0,0 +1,859 @@
#!/usr/bin/env python3
"""Storage Manager for NewsArchiver - Phase 2.3
Organizes archived data by newspaper, manages SQLite cache database,
stores both raw HTML and extracted content.
"""
import json
import logging
import os
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from content_extractor import ArticleData
try:
import feedgenerator
FEEDGENERATOR_AVAILABLE = True
except ImportError:
FEEDGENERATOR_AVAILABLE = False
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logger = logging.getLogger(__name__)
DB_PATH = ARCHIVE_DIR / "cache.db"
WEBSITES_DIR = ARCHIVE_DIR / "websites"
def _get_db_connection() -> sqlite3.Connection:
"""Get database connection with row factory."""
conn = sqlite3.connect(DB_PATH, timeout=30.0, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def _init_database() -> None:
"""Initialize database schema."""
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_name TEXT NOT NULL,
article_url TEXT NOT NULL UNIQUE,
article_guid TEXT,
title TEXT,
author TEXT,
publish_date TEXT,
content_text TEXT,
content_html TEXT,
archive_file_path TEXT,
metadata_file_path TEXT,
status TEXT DEFAULT 'pending',
error_message TEXT,
extraction_method TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS article_archives (
article_url TEXT PRIMARY KEY,
source_name TEXT,
archive_file_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS processing_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_name TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
action TEXT,
status TEXT,
message TEXT
)
""")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source_name)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(article_url)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status)"
)
# Add extraction_method column if it doesn't exist
try:
cursor.execute("ALTER TABLE articles ADD COLUMN extraction_method TEXT")
conn.commit()
except sqlite3.OperationalError:
pass
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_extraction_method ON articles(extraction_method)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_article_archives_url ON article_archives(article_url)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_article_archives_source ON article_archives(source_name)"
)
logger.debug("Database initialized at %s", DB_PATH)
def _ensure_directory_structure(source_name: str, date_str: str) -> tuple:
"""Ensure directory structure exists for a source and date.
Args:
source_name: Newspaper source name
date_str: Date string in YYYY-MM-DD format
Returns:
Tuple of (html_dir, articles_dir) Path objects
"""
source_dir = WEBSITES_DIR / source_name
html_dir = source_dir / "html" / date_str
articles_dir = source_dir / "articles" / date_str
html_dir.mkdir(parents=True, exist_ok=True)
articles_dir.mkdir(parents=True, exist_ok=True)
return html_dir, articles_dir
def _get_next_file_index(html_dir: Path, articles_dir: Path) -> int:
"""Get next available file index for article naming.
Args:
html_dir: Directory containing HTML files
articles_dir: Directory containing JSON files
Returns:
Next available index (1-indexed)
"""
def get_max_index(directory: Path, extension: str) -> int:
max_idx = 0
if directory.exists():
for file in directory.glob(f"*{extension}"):
try:
name = file.stem
if name.startswith("article_"):
idx = int(name.replace("article_", ""))
max_idx = max(max_idx, idx)
except ValueError:
continue
return max_idx
html_idx = get_max_index(html_dir, ".html")
json_idx = get_max_index(articles_dir, ".json")
return max(html_idx, json_idx) + 1
def _log_processing(source_name: str, action: str, status: str, message: str) -> None:
"""Log processing action to database.
Args:
source_name: Newspaper source name
action: Action performed
status: Status of action
message: Log message
"""
try:
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO processing_log (source_name, action, status, message) VALUES (?, ?, ?, ?)",
(source_name, action, status, message),
)
except Exception as e:
logger.error("Failed to log processing: %s", str(e))
def _save_archive_mapping(
article_url: str, source_name: str, archive_file_path: str
) -> None:
"""Save mapping between article URL and archive file path.
Args:
article_url: Article URL
source_name: Newspaper source name
archive_file_path: Path to archived HTML file
"""
try:
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR REPLACE INTO article_archives (article_url, source_name, archive_file_path)
VALUES (?, ?, ?)
""",
(article_url, source_name, archive_file_path),
)
logger.debug("Saved archive mapping: %s -> %s", article_url, archive_file_path)
except Exception as e:
logger.error("Failed to save archive mapping: %s", str(e))
def save_article(source_name: str, article_data: ArticleData) -> str:
"""Save article to storage and update cache.
Args:
source_name: Newspaper source name (e.g., 'reuters', 'bbc')
article_data: ArticleData object with article content
Returns:
Status message describing the result
"""
try:
_init_database()
publish_date = article_data.publish_date
if publish_date:
try:
date_obj = datetime.fromisoformat(publish_date.replace("Z", "+00:00"))
date_str = date_obj.strftime("%Y-%m-%d")
except (ValueError, AttributeError):
date_str = datetime.now().strftime("%Y-%m-%d")
else:
date_str = datetime.now().strftime("%Y-%m-%d")
html_dir, articles_dir = _ensure_directory_structure(source_name, date_str)
file_index = _get_next_file_index(html_dir, articles_dir)
file_prefix = f"article_{file_index:03d}"
archive_file_path = html_dir / f"{file_prefix}.html"
metadata_file_path = articles_dir / f"{file_prefix}.json"
if article_data.raw_html:
with open(archive_file_path, "w", encoding="utf-8") as f:
f.write(article_data.raw_html)
metadata = {
"id": file_index,
"source_name": source_name,
"url": article_data.url,
"title": article_data.title,
"author": article_data.author,
"publish_date": article_data.publish_date,
"content_text": article_data.content_text,
"content_html": article_data.content_html,
"tags": article_data.tags,
"language": article_data.language,
"extraction_method": article_data.extraction_method,
"archive_file": f"{file_prefix}.html",
"metadata_file": f"{file_prefix}.json",
"saved_at": datetime.now().isoformat(),
}
with open(metadata_file_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
with _get_db_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute(
"""
INSERT OR IGNORE INTO articles
(source_name, article_url, article_guid, title, author, publish_date,
content_text, content_html, archive_file_path, metadata_file_path, status, extraction_method)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
source_name,
article_data.url,
getattr(article_data, "guid", None),
article_data.title,
article_data.author,
article_data.publish_date,
article_data.content_text,
article_data.content_html,
str(archive_file_path.relative_to(ARCHIVE_DIR)),
str(metadata_file_path.relative_to(ARCHIVE_DIR)),
"archived" if not article_data.error else "failed",
article_data.extraction_method,
),
)
except sqlite3.IntegrityError:
cursor.execute(
"""
UPDATE articles
SET title = ?, author = ?, publish_date = ?,
content_text = ?, content_html = ?,
archive_file_path = ?, metadata_file_path = ?,
status = ?, extraction_method = ?, updated_at = CURRENT_TIMESTAMP
WHERE article_url = ? AND source_name = ?
""",
(
article_data.title,
article_data.author,
article_data.publish_date,
article_data.content_text,
article_data.content_html,
str(archive_file_path.relative_to(ARCHIVE_DIR)),
str(metadata_file_path.relative_to(ARCHIVE_DIR)),
"archived" if not article_data.error else "failed",
article_data.extraction_method,
article_data.url,
source_name,
),
)
_save_archive_mapping(
article_data.url,
source_name,
str(archive_file_path.relative_to(ARCHIVE_DIR)),
)
_log_processing(
source_name,
"save_article",
"success",
f"Saved article: {article_data.url} -> {metadata_file_path.name}",
)
logger.info(
"Article saved: %s -> %s", article_data.url, metadata_file_path.name
)
return f"Article saved: {metadata_file_path.name}"
except Exception as e:
error_msg = f"Failed to save article: {str(e)}"
logger.error(error_msg)
_log_processing(source_name, "save_article", "error", error_msg)
return error_msg
def get_article(source_name: str, article_id: int) -> Optional[ArticleData]:
"""Retrieve article from storage.
Args:
source_name: Newspaper source name
article_id: Database ID of article
Returns:
ArticleData object or None if not found
"""
try:
_init_database()
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM articles
WHERE id = ? AND source_name = ?
""",
(article_id, source_name),
)
row = cursor.fetchone()
if not row:
return None
archive_path = (
Path(row["archive_file_path"]) if row["archive_file_path"] else None
)
# Handle both absolute and relative paths
if archive_path:
if not archive_path.is_absolute():
archive_path = ARCHIVE_DIR / archive_path
# Return relative path for web interface
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
else:
archive_file_path = None
archive_content = None
if archive_path and archive_path.exists():
archive_content = archive_path.read_text(encoding="utf-8")
article = ArticleData(
url=row["article_url"],
title=row["title"],
author=row["author"],
publish_date=row["publish_date"],
content_text=row["content_text"],
content_html=row["content_html"],
raw_html=archive_content,
archive_file_path=archive_file_path,
tags=None,
language=None,
metadata=None,
extraction_method=None,
error=row["error_message"],
guid=row["article_guid"],
id=row["id"],
source_name=source_name,
)
return article
except Exception as e:
logger.error("Failed to get article %d: %s", article_id, str(e))
return None
def get_articles_by_source(
source_name: str, limit: int = 50, offset: int = 0
) -> List[ArticleData]:
"""Get paginated articles for a source.
Args:
source_name: Newspaper source name
limit: Maximum number of articles to return
offset: Number of articles to skip
Returns:
List of ArticleData objects
"""
try:
_init_database()
with _get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM articles
WHERE source_name = ?
ORDER BY publish_date DESC, created_at DESC
LIMIT ? OFFSET ?
""",
(source_name, limit, offset),
)
rows = cursor.fetchall()
articles = []
for row in rows:
archive_path = (
Path(row["archive_file_path"]) if row["archive_file_path"] else None
)
# Handle both absolute and relative paths
if archive_path:
if not archive_path.is_absolute():
archive_path = ARCHIVE_DIR / archive_path
# Return relative path for web interface
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
else:
archive_file_path = None
archive_content = None
if archive_path and archive_path.exists():
archive_content = archive_path.read_text(encoding="utf-8")
article = ArticleData(
url=row["article_url"],
title=row["title"],
author=row["author"],
publish_date=row["publish_date"],
content_text=row["content_text"],
content_html=row["content_html"],
raw_html=archive_content,
archive_file_path=archive_file_path,
tags=None,
language=None,
metadata=None,
extraction_method=None,
error=row["error_message"],
guid=row["article_guid"],
id=row["id"],
source_name=source_name,
)
articles.append(article)
return articles
except Exception as e:
logger.error("Failed to get articles for %s: %s", source_name, str(e))
return []
def update_article_status(
source_name: str, article_url: str, status: str, error: str = None
) -> None:
"""Update article status in cache.
Args:
source_name: Newspaper source name
article_url: Article URL
status: New status (pending, archived, failed)
error: Error message if status is failed
"""
try:
_init_database()
conn = _get_db_connection()
cursor = conn.cursor()
cursor.execute(
"""
UPDATE articles
SET status = ?, error_message = ?, updated_at = CURRENT_TIMESTAMP
WHERE article_url = ? AND source_name = ?
""",
(status, error, article_url, source_name),
)
conn.commit()
conn.close()
if error:
_log_processing(
source_name,
"update_status",
"error",
f"Updated {article_url} status to {status}: {error}",
)
else:
_log_processing(
source_name,
"update_status",
"success",
f"Updated {article_url} status to {status}",
)
logger.info("Updated article status: %s -> %s", article_url, status)
except Exception as e:
logger.error("Failed to update article status: %s", str(e))
def get_source_stats(source_name: str) -> dict:
"""Get statistics for a news source.
Args:
source_name: Newspaper source name
Returns:
Dictionary with source statistics
"""
try:
_init_database()
conn = _get_db_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) as archived,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
MIN(created_at) as first_archived,
MAX(created_at) as last_archived,
MAX(publish_date) as latest_article_date
FROM articles
WHERE source_name = ?
""",
(source_name,),
)
row = cursor.fetchone()
conn.close()
stats = {
"source_name": source_name,
"total_articles": row["total"] or 0,
"archived": row["archived"] or 0,
"failed": row["failed"] or 0,
"pending": row["pending"] or 0,
"first_archived": row["first_archived"],
"last_archived": row["last_archived"],
"latest_article_date": row["latest_article_date"],
}
_log_processing(
source_name,
"get_stats",
"success",
f"Stats: {stats['total_articles']} total, {stats['archived']} archived, {stats['failed']} failed",
)
return stats
except Exception as e:
logger.error("Failed to get stats for %s: %s", source_name, str(e))
return {
"source_name": source_name,
"total_articles": 0,
"archived": 0,
"failed": 0,
"pending": 0,
}
def get_all_sources() -> List[str]:
"""Get list of all sources in the archive.
Returns:
List of source names
"""
try:
_init_database()
conn = _get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT source_name FROM articles ORDER BY source_name
""")
sources = [row["source_name"] for row in cursor.fetchall()]
conn.close()
return sources
except Exception as e:
logger.error("Failed to get sources: %s", str(e))
return []
def get_latest_articles(limit: int = 50) -> List[ArticleData]:
"""Get latest articles across all sources.
Args:
limit: Maximum number of articles to return
Returns:
List of ArticleData objects ordered by publish_date DESC
"""
try:
_init_database()
conn = _get_db_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM articles
ORDER BY publish_date DESC, created_at DESC
LIMIT ?
""",
(limit,),
)
rows = cursor.fetchall()
conn.close()
articles = []
for row in rows:
archive_path = (
Path(row["archive_file_path"]) if row["archive_file_path"] else None
)
# Handle both absolute and relative paths
if archive_path:
if not archive_path.is_absolute():
archive_path = ARCHIVE_DIR / archive_path
# Return relative path for web interface
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
else:
archive_file_path = None
archive_content = None
if archive_path and archive_path.exists():
archive_content = archive_path.read_text(encoding="utf-8")
article = ArticleData(
url=row["article_url"],
title=row["title"],
author=row["author"],
publish_date=row["publish_date"],
content_text=row["content_text"],
content_html=row["content_html"],
raw_html=archive_content,
archive_file_path=archive_file_path,
tags=None,
language=None,
metadata=None,
extraction_method=None,
error=row["error_message"],
guid=row["article_guid"],
id=row["id"],
source_name=row["source_name"],
)
articles.append(article)
return articles
except Exception as e:
logger.error("Failed to get latest articles: %s", str(e))
return []
def get_source_directory(source_name: str) -> Path:
"""Get the directory path for a source.
Args:
source_name: Newspaper source name
Returns:
Path to source directory
"""
return WEBSITES_DIR / source_name
def get_archive_file_path_from_db(
article_url: str, source_name: str = None
) -> Optional[str]:
"""Get archive file path from database mapping.
Args:
article_url: Article URL
source_name: Newspaper source name (optional, for filtering)
Returns:
Archive file path if found, None otherwise
"""
try:
_init_database()
conn = _get_db_connection()
cursor = conn.cursor()
if source_name:
cursor.execute(
"""
SELECT archive_file_path FROM article_archives
WHERE article_url = ? AND source_name = ?
""",
(article_url, source_name),
)
else:
cursor.execute(
"""
SELECT archive_file_path FROM article_archives
WHERE article_url = ?
""",
(article_url,),
)
row = cursor.fetchone()
conn.close()
return row["archive_file_path"] if row else None
except Exception as e:
logger.error("Failed to get archive file path from DB: %s", str(e))
return None
def get_daily_articles(source_name: str, date_str: str) -> List[ArticleData]:
"""Get articles for a specific date.
Args:
source_name: Newspaper source name
date_str: Date string in YYYY-MM-DD format
Returns:
List of ArticleData objects
"""
try:
source_dir = get_source_directory(source_name)
articles_dir = source_dir / "articles" / date_str
if not articles_dir.exists():
return []
articles = []
for json_file in sorted(articles_dir.glob("article_*.json")):
try:
with open(json_file, "r", encoding="utf-8") as f:
metadata = json.load(f)
archive_filename = metadata.get("archive_file", "")
archive_path = (
source_dir / "html" / date_str / archive_filename
if archive_filename
else None
)
archive_content = None
if archive_path and archive_path.exists():
archive_content = archive_path.read_text(encoding="utf-8")
article = ArticleData(
url=metadata.get("url", ""),
title=metadata.get("title"),
author=metadata.get("author"),
publish_date=metadata.get("publish_date"),
content_text=metadata.get("content_text"),
content_html=metadata.get("content_html"),
raw_html=archive_content,
# Handle both absolute and relative paths
archive_file_path=str(archive_path.relative_to(ARCHIVE_DIR))
if archive_path
else None,
tags=metadata.get("tags"),
language=metadata.get("language"),
metadata=metadata,
extraction_method=metadata.get("extraction_method"),
source_name=source_name,
)
articles.append(article)
except Exception as e:
logger.warning("Failed to load article from %s: %s", json_file, str(e))
continue
return articles
except Exception as e:
logger.error(
"Failed to get daily articles for %s on %s: %s",
source_name,
date_str,
str(e),
)
return []
def initialize_storage() -> None:
"""Initialize the storage system.
Creates database, directory structure, and logs initialization.
"""
logger.info("Initializing storage system...")
_init_database()
(WEBSITES_DIR / "sample").mkdir(parents=True, exist_ok=True)
(WEBSITES_DIR / "sample" / "html").mkdir(exist_ok=True)
(WEBSITES_DIR / "sample" / "articles").mkdir(exist_ok=True)
_log_processing("system", "initialize", "success", "Storage system initialized")
logger.info("Storage system initialized at %s", ARCHIVE_DIR)
if __name__ == "__main__":
initialize_storage()
sources = get_all_sources()
print(f"Sources in archive: {sources}")
if sources:
for source in sources:
stats = get_source_stats(source)
print(f"\n{source}:")
print(f" Total: {stats['total_articles']}")
print(f" Archived: {stats['archived']}")
print(f" Failed: {stats['failed']}")

46
templates/article.html Normal file
View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<article class="article-view">
<div class="article-header">
<div class="article-source">
<span class="source-label">From</span>
<span class="source-name">{{ source_name }}</span>
</div>
<h1>{{ article.title }}</h1>
<div class="article-meta">
<div class="article-meta-row">
{% if article.publish_date %}
<time class="article-date" datetime="{{ article.publish_date }}">{{ article.publish_date }}</time>
{% endif %}
{% if article.author %}
<span class="article-author">By <strong>{{ article.author }}</strong></span>
{% endif %}
</div>
<p class="article-url"><a href="{{ article.url }}" target="_blank">{{ article.url }}</a></p>
</div>
</div>
<div class="article-content">
{% if article.content_text %}
<div class="article-text">
{% set lines = article.content_text.split('\n') -%}
{%- for line in lines %}
{%- if line|trim %}
<p>{{ line }}</p>
{%- endif %}
{%- endfor %}
</div>
{%- endif %}
</div>
<div class="article-actions">
<a href="/source/{{ source_slug }}" class="back-link">&larr; Back to articles</a>
{% if article.archive_file_path %}
&nbsp;|&nbsp;
<a href="/archive-file/{{ article.archive_file_path|urlencode }}" target="_blank" title="View archived copy">Archived HTML</a>
{% endif %}
</div>
</article>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="article-not-found">
<h1>Article Not Found</h1>
<p>The article you're looking for could not be found.</p>
<p>Source: {{ slug }}</p>
<p>ID: {{ article_id }}</p>
<a href="/source/{{ slug }}" class="back-link">&larr; Back to articles</a>
</div>
{% endblock %}

44
templates/articles.html Normal file
View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ source_name }} - Articles</h1>
<div class="pagination">
{% if pagination.has_prev %}
<a href="/source/{{ source_slug }}?page={{ pagination.prev_num }}">&laquo; Previous</a>
{% endif %}
<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a href="/source/{{ source_slug }}?page={{ pagination.next_num }}">Next &raquo;</a>
{% endif %}
</div>
<div class="timeline">
{% for date, articles in articles_by_date %}
<div class="timeline-date">
<span class="date-label">{{ date }}</span>
<span class="date-count">{{ articles|length }} article{% if articles|length != 1 %}s{% endif %}</span>
</div>
<ul class="article-list">
{% for article in articles %}
<li class="article-item">
<div class="article-main">
<h3><a href="{{ article.url }}">{{ article.title }}</a></h3>
<p class="article-summary">{{ article.summary }}</p>
</div>
</li>
{% endfor %}
</ul>
{% endfor %}
</div>
<div class="pagination">
{% if pagination.has_prev %}
<a href="/source/{{ source_slug }}?page={{ pagination.prev_num }}">&laquo; Previous</a>
{% endif %}
<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a href="/source/{{ source_slug }}?page={{ pagination.next_num }}">Next &raquo;</a>
{% endif %}
</div>
{% endblock %}

25
templates/atom.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{{ title|e }}</title>
<link href="{{ link|e }}" rel="alternate"/>
<link href="{{ link|e }}/atom" rel="self" type="application/atom+xml"/>
<updated>{{ updated|e }}</updated>
<id>{{ link|e }}</id>
<generator>NewsArchiver</generator>
{% for entry in entries %}
<entry>
<title>{{ entry.title|e }}</title>
<link href="{{ entry.link|e }}" rel="alternate"/>
{% if entry.published %}
<published>{{ entry.published|e }}</published>
{% endif %}
{% if entry.author %}
<author>
<name>{{ entry.author.name|e }}</name>
</author>
{% endif %}
<id>{{ entry.id|e }}</id>
<summary>{{ entry.summary|e }}</summary>
</entry>
{% endfor %}
</feed>

80
templates/base.html Normal file
View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en" id="html" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NewsArchiver</title>
<meta name="description" content="NewsArchiver - Archive news articles from RSS feeds">
<meta property="og:title" content="NewsArchiver">
<meta property="og:description" content="Archive news articles from RSS feeds">
<meta property="og:type" content="website">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📰%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style>
#theme-toggle {
background: none;
border: 1px solid var(--border-color);
color: var(--primary-color);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
}
#theme-toggle:hover {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.theme-icon {
margin-right: 0.5rem;
}
</style>
</head>
<body>
<header>
<h1>NewsArchiver</h1>
<nav>
<a href="/">Archives</a>
<a href="/status">Status</a>
<button id="theme-toggle" aria-label="Toggle dark mode">
<span class="theme-icon" id="theme-icon">☀️</span>
<span id="theme-text">Light</span>
</button>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
const html = document.getElementById('html');
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const themeText = document.getElementById('theme-text');
const savedTheme = localStorage.getItem('theme') || 'light';
setTheme(savedTheme);
themeToggle.addEventListener('click', () => {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
});
function setTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (theme === 'dark') {
themeIcon.textContent = '☀️';
themeText.textContent = 'Light';
} else {
themeIcon.textContent = '🌙';
themeText.textContent = 'Dark';
}
}
</script>
</body>
</html>

31
templates/index.html Normal file
View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content %}
<h1>News Archives</h1>
<p>Total sources: {{ sources|length }}</p>
<ul class="newspaper-list">
{% for source in sources %}
<li class="newspaper-item {% if source.disabled %}disabled{% endif %}">
<div class="newspaper-info">
{% if source.disabled %}
<h2 class="source-disabled" title="{{ source.disable_reason }}">
{{ source.name }}
<span class="status-badge">Disabled</span>
</h2>
{% else %}
<h2><a href="/source/{{ source.slug }}">{{ source.name }}</a></h2>
{% endif %}
<p>Articles: {{ source.article_count }}</p>
<p>Last archived: {{ source.last_archived or 'N/A' }}</p>
{% if source.disabled %}
<p class="disable-reason">{{ source.disable_reason }}</p>
{% endif %}
</div>
{% if not source.disabled %}
<button class="pull-btn" data-source="{{ source.slug }}" aria-label="Pull latest articles from {{ source.name }}">Pull latest</button>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

95
templates/login.html Normal file
View File

@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
<div class="login-container">
<div class="login-box">
<h2>NewsArchiver Login</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.get('next') %}
<input type="hidden" name="next" value="{{ request.args.get('next') }}">
{% endif %}
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
<style>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.login-box {
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.login-box h2 {
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
box-sizing: border-box;
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: var(--accent-color, #0066cc);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.alert {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-error {
background: #fee;
color: #c00;
border: 1px solid #fcc;
}
.alert-info {
background: #eef;
color: #00c;
border: 1px solid #ccf;
}
</style>
{% endblock %}

25
templates/rss.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ title|e }}</title>
<link>{{ link|e }}</link>
<description>{{ description|e }}</description>
<lastBuildDate>{{ last_build_date|e }}</lastBuildDate>
<generator>NewsArchiver</generator>
<atom:link href="{{ link|e }}/rss" rel="self" type="application/rss+xml" />
{% for item in items %}
<item>
<title>{{ item.title|e }}</title>
<link>{{ item.link|e }}</link>
{% if item.pubDate %}
<pubDate>{{ item.pubDate|e }}</pubDate>
{% endif %}
{% if item.author %}
<author>{{ item.author|e }}</author>
{% endif %}
<guid isPermaLink="false">{{ item.guid|e }}</guid>
<description>{{ item.description|e }}</description>
</item>
{% endfor %}
</channel>
</rss>

50
templates/status.html Normal file
View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="status-page">
<h1>System Status</h1>
<div class="status-summary">
<div class="status-item">
<h2>System</h2>
<p>Status: <span class="status-online">Online</span></p>
<p>Last Archive Run: {{ last_archive_run or 'Never' }}</p>
</div>
<div class="status-item">
<h2>Statistics</h2>
<p>Sources Monitored: {{ sources_monitored }}</p>
{% if disabled_sources > 0 %}
<p>Disabled Sources: <span class="status-warning">{{ disabled_sources }}</span></p>
{% endif %}
<p>Total Articles: {{ total_articles }}</p>
<p>Failed Jobs: {{ failed_jobs }}</p>
</div>
</div>
{% if sources %}
<h2>Monitored Sources</h2>
<ul class="newspaper-list">
{% for source in sources %}
<li class="newspaper-item {% if source.disabled %}disabled{% endif %}">
<div class="newspaper-info">
{% if source.disabled %}
<h2 class="source-disabled" title="{{ source.disable_reason }}">
{{ source.name }}
<span class="status-badge">Disabled</span>
</h2>
{% else %}
<h2><a href="/source/{{ source.slug }}">{{ source.name }}</a></h2>
{% endif %}
<p>Articles: {{ source.article_count }}</p>
<p class="status-{{ source.status }}">{{ source.status|upper }}</p>
{% if source.disabled %}
<p class="disable-reason">{{ source.disable_reason }}</p>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{
"name": "newsarchiver-e2e",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@playwright/test": "^1.49.1",
"typescript": "^5.6.0"
},
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui"
}
}

View File

@ -0,0 +1,15 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [['list'], ['html']] : 'list',
use: {
baseURL: process.env.APP_URL || 'http://localhost:5000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});

View File

@ -0,0 +1,128 @@
import { test, expect } from '@playwright/test';
const TEST_SOURCE = 'Test News';
test.describe('Home page', () => {
test('loads and shows sources', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('NewsArchiver');
await expect(page.locator('h1').filter({ hasText: 'News Archives' })).toBeVisible();
await expect(page.locator('.newspaper-list')).toBeVisible();
});
test('shows source count', async ({ page }) => {
await page.goto('/');
const count = await page.locator('p:has-text("Total sources:")').textContent();
expect(count || '').toMatch(/Total sources: \d+/);
});
test('navigates to a source', async ({ page }) => {
await page.goto('/');
const firstLink = page.locator('.newspaper-item h2 a').first();
const sourceName = (await firstLink.textContent())?.trim() || '';
await firstLink.click();
await expect(page).toHaveURL(/\/source\//);
await expect(page.locator('h1')).toContainText(sourceName);
});
});
test.describe('Article listing', () => {
test('shows articles for a source', async ({ page }) => {
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
await expect(page.locator('.article-list')).toBeVisible();
const articles = page.locator('.article-item');
await expect.poll(() => articles.count()).toBeGreaterThan(0);
});
test('article links navigate to detail', async ({ page }) => {
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
const firstArticle = page.locator('.article-item h3 a').first();
const title = (await firstArticle.textContent())?.trim() || '';
await firstArticle.click();
await expect(page).toHaveURL(/\/article\/\d+/);
await expect(page.locator('.article-view h1')).toContainText(title);
});
test('shows article metadata', async ({ page }) => {
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`);
await expect(page.locator('.article-source')).toBeVisible();
await expect(page.locator('.article-meta')).toBeVisible();
});
test('back link returns to listing', async ({ page }) => {
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`);
await page.locator('.back-link').click();
await expect(page).toHaveURL(new RegExp(`/source/${TEST_SOURCE.toLowerCase()}`));
await expect(page.locator('.article-list')).toBeVisible();
});
});
test.describe('Pagination', () => {
test('shows page info', async ({ page }) => {
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
await expect(page.locator('.pagination span')).toContainText('Page 1');
});
});
test.describe('Navigation', () => {
test('header nav links work', async ({ page }) => {
await page.goto('/');
await page.locator('nav a:has-text("Archives")').click();
await expect(page).toHaveURL(/\/$/);
await page.locator('nav a:has-text("Status")').click();
await expect(page).toHaveURL(/\/status/);
});
});
test.describe('Status page', () => {
test('shows system status', async ({ page }) => {
await page.goto('/status');
await expect(page.locator('h1')).toBeVisible();
});
test('shows total articles count', async ({ page }) => {
await page.goto('/status');
await expect(page.locator('text=/Total Articles: \d+/')).toBeVisible();
});
});
test.describe('RSS feed', () => {
test('serves RSS XML', async ({ request }) => {
const resp = await request.get('/rss');
expect(resp.ok()).toBeTruthy();
expect(resp.headers()['content-type']).toContain('application/rss+xml');
const body = await resp.text();
expect(body).toContain('<rss');
expect(body).toContain('<channel>');
});
});
test.describe('Atom feed', () => {
test('serves Atom XML', async ({ request }) => {
const resp = await request.get('/atom');
expect(resp.ok()).toBeTruthy();
expect(resp.headers()['content-type']).toContain('application/atom+xml');
const body = await resp.text();
expect(body).toContain('<feed');
});
});
test.describe('Theme toggle', () => {
test('toggles dark mode', async ({ page }) => {
await page.goto('/');
const html = page.locator('html');
await page.locator('#theme-toggle').click();
await expect(html).toHaveAttribute('data-theme', 'dark');
await page.locator('#theme-toggle').click();
await expect(html).toHaveAttribute('data-theme', 'light');
});
test('persists theme in localStorage', async ({ page }) => {
await page.goto('/');
await page.locator('#theme-toggle').click();
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBe('dark');
});
});

View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Unit tests for path handling and security."""
import sys
import tempfile
from pathlib import Path
from urllib.parse import unquote
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_path_traversal_blocked():
"""Test that path traversal is blocked in archive routes."""
archive_dir = Path(tempfile.mkdtemp())
def validate_archive_path(archive_path: str):
decoded = unquote(archive_path)
try:
archive_file = (archive_dir / decoded).resolve()
if not str(archive_file).startswith(str(archive_dir)):
return None
return archive_file
except (ValueError, OSError):
return None
for bad_path in [
"../../../etc/passwd",
"..%2F..%2F..%2Fetc%2Fpasswd",
"../secret.txt",
"websites/../../etc/passwd",
]:
result = validate_archive_path(bad_path)
assert result is None, f"Traversal should be blocked: {bad_path}"
print(f" PASS: blocked '{bad_path}'")
valid = validate_archive_path("websites/Test/html/2024-01-01/article.html")
assert valid is not None
assert str(valid).startswith(str(archive_dir))
print(f" PASS: allowed valid path")
def test_path_traversal_file_access():
"""Test that path traversal cannot read files outside archive dir."""
archive_dir = Path(tempfile.mkdtemp())
secret_file = archive_dir.parent / "secret.txt"
secret_file.write_text("top secret")
def validate_archive_path(archive_path: str):
decoded = unquote(archive_path)
try:
archive_file = (archive_dir / decoded).resolve()
if not str(archive_file).startswith(str(archive_dir)):
return None
return archive_file
except (ValueError, OSError):
return None
blocked = validate_archive_path(f"../secret.txt")
assert blocked is None
print(f" PASS: file access traversal blocked")
secret_file.unlink()
def test_url_encoding():
"""Test URL encoding/decoding in archive paths."""
archive_file_path = "websites/404 Media/html/2024-01-15/article_001.html"
encoded = archive_file_path.replace(" ", "%20")
decoded = unquote(encoded)
assert decoded == archive_file_path
print(f" PASS: encoding round-trip")
if __name__ == "__main__":
test_path_traversal_blocked()
test_path_traversal_file_access()
test_url_encoding()
print("\nAll path handling tests passed!")

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Unit tests for storage_manager."""
import sys
import tempfile
from pathlib import Path
from urllib.parse import unquote
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_relative_path_format():
"""Test relative path format for archive files."""
archive_dir = Path(tempfile.mkdtemp())
archive_file_path = archive_dir / "websites/TestSource/html/2024-01-15/article_001.html"
archive_file_path.parent.mkdir(parents=True, exist_ok=True)
archive_file_path.touch()
stored_path = str(archive_file_path.relative_to(archive_dir))
assert not Path(stored_path).is_absolute()
assert stored_path == "websites/TestSource/html/2024-01-15/article_001.html"
retrieved_path = Path(stored_path)
full_path = archive_dir / retrieved_path if not retrieved_path.is_absolute() else retrieved_path
assert str(full_path) == str(archive_file_path)
web_path = str(full_path.relative_to(archive_dir))
assert web_path == stored_path
print(f" PASS: stored={stored_path}")
def test_archive_file_url_generation():
"""Test URL generation for archived files."""
archive_file_path = "websites/404 Media/html/2024-01-15/article_001.html"
url = f"/archive-file/{archive_file_path}"
assert url == "/archive-file/websites/404 Media/html/2024-01-15/article_001.html"
decoded_path = unquote(archive_file_path)
archive_dir = Path("/tmp/test_archives")
full_path = archive_dir / decoded_path
assert str(full_path) == str(archive_dir / "websites/404 Media/html/2024-01-15/article_001.html")
print(f" PASS: url={url}")
def test_multiple_sources():
"""Test different sources get correct paths."""
archive_dir = Path(tempfile.mkdtemp())
for source in ["404 Media", "TestSource", "Another Source"]:
archive_file_path = archive_dir / f"websites/{source}/html/2024-01-15/article_001.html"
archive_file_path.parent.mkdir(parents=True, exist_ok=True)
stored_path = str(archive_file_path.relative_to(archive_dir))
parts = Path(stored_path).parts
assert parts[0] == "websites"
assert parts[1] == source
assert parts[2] == "html"
print(f" PASS: source='{source}' path={stored_path}")
if __name__ == "__main__":
test_relative_path_format()
test_archive_file_url_generation()
test_multiple_sources()
print("\nAll storage_manager tests passed!")

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Unit tests for web_interface.py security features."""
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_validate_archive_path_blocks_traversal():
"""Test validate_archive_path function blocks traversal."""
from web_interface import validate_archive_path, ARCHIVE_DIR
for bad in ["../../../etc/passwd", "..%2Fetc%2Fpasswd", "../secret.txt"]:
result = validate_archive_path(bad)
assert result is None, f"Should block: {bad}"
print(" PASS: traversal blocked")
def test_xss_safe_filter_removed():
"""Test that article.html no longer uses |safe filter."""
result = subprocess.run(
["grep", "-rn", "|safe", "templates/article.html"],
capture_output=True, text=True,
)
assert result.returncode != 0, "article.html should not contain |safe"
print(" PASS: no |safe in article.html")
def test_security_headers_present():
"""Test security headers are added to responses."""
from web_interface import app
client = app.test_client()
resp = client.get("/")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert resp.headers.get("X-Frame-Options") == "DENY"
assert resp.headers.get("X-XSS-Protection") == "1; mode=block"
assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
print(" PASS: security headers present")
def test_server_url_not_hardcoded():
"""Test that SERVER_URL comes from env, not hardcoded."""
result = subprocess.run(
["grep", "-n", "192\\.168", "web_interface.py"],
capture_output=True, text=True,
)
assert result.returncode != 0, "No hardcoded internal IPs"
print(" PASS: no hardcoded IPs")
def test_no_safe_filter_in_rss():
"""Test RSS/Atom feeds escape content properly."""
result = subprocess.run(
["grep", "-rn", "|safe", "templates/rss.xml", "templates/atom.xml"],
capture_output=True, text=True,
)
assert result.returncode != 0, "No |safe in RSS templates"
print(" PASS: no |safe in RSS/Atom")
if __name__ == "__main__":
test_validate_archive_path_blocks_traversal()
test_xss_safe_filter_removed()
test_security_headers_present()
test_server_url_not_hardcoded()
test_no_safe_filter_in_rss()
print("\nAll web interface tests passed!")

5
version.json Normal file
View File

@ -0,0 +1,5 @@
{
"major": 1,
"minor": 0,
"patch": 413
}

704
web_interface.py Normal file
View File

@ -0,0 +1,704 @@
#!/usr/bin/env python3
"""Web Interface for NewsArchiver - Phase 3
Flask web server for browsing archived news articles.
"""
import hashlib
import hmac
import html
import json
import logging
import os
import secrets
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from functools import wraps
from pathlib import Path
from typing import Optional
from urllib.parse import quote, unquote, urlparse
from flask import (
Flask,
abort,
flash,
jsonify,
make_response,
redirect,
render_template,
request,
session,
url_for,
)
from storage_manager import (
DB_PATH,
get_all_sources,
get_article,
get_articles_by_source,
get_latest_articles,
get_source_stats,
)
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
).resolve()
RSS_FEEDS_PATH = SCRIPT_DIR / "rss_feeds.json"
RSS_FEEDS = {}
# Authentication configuration
ADMIN_PASSWORD = os.environ.get("NEWSARCHIVER_PASSWORD", "")
SECRET_KEY = os.environ.get(
"NEWSARCHIVER_SECRET_KEY", secrets.token_hex(32)
)
SESSION_LIFETIME_MINUTES = int(os.environ.get("NEWSARCHIVER_SESSION_MINUTES", "480"))
# Server URL configuration
SERVER_URL = os.environ.get(
"NEWSARCHIVER_SERVER_URL", f"http://localhost:5000"
)
app = Flask(
__name__,
static_folder=str(SCRIPT_DIR / "static"),
template_folder=str(SCRIPT_DIR / "templates"),
)
app.secret_key = SECRET_KEY
def login_required(f):
"""Decorator to require login for protected routes."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not ADMIN_PASSWORD:
return f(*args, **kwargs)
if "authenticated" not in session:
flash("Please log in to access this page.", "error")
return redirect(url_for("login", next=request.url))
return f(*args, **kwargs)
return decorated_function
def generate_csrf_token():
"""Generate a CSRF token tied to the session."""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
return session["csrf_token"]
def verify_csrf_token():
"""Verify the CSRF token from the request."""
if not ADMIN_PASSWORD:
return True
token = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token")
if not token or "csrf_token" not in session:
return False
return hmac.compare_digest(token, session["csrf_token"])
@app.template_global()
def csrf_token():
"""Make CSRF token available in templates."""
return generate_csrf_token()
@app.before_request
def enforce_session_timeout():
"""Expire sessions after inactivity."""
if "authenticated" in session:
auth_time = session.get("auth_time", 0)
now = datetime.now().timestamp()
if now - auth_time > SESSION_LIFETIME_MINUTES * 60:
session.clear()
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses."""
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Cache-Control"] = "no-store"
if not request.is_secure:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'"
)
return response
logger = logging.getLogger(__name__)
def get_pagination_info(total: int, page: int, per_page: int) -> dict:
"""Calculate pagination information.
Args:
total: Total number of items
page: Current page number
per_page: Items per page
Returns:
Dictionary with pagination details
"""
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
return {
"total": total,
"page": page,
"per_page": per_page,
"has_next": page < total_pages,
"has_prev": page > 1,
"next_num": page + 1 if page < total_pages else None,
"prev_num": page - 1 if page > 1 else None,
"pages": total_pages,
}
def sanitize_error_message(msg: str) -> str:
"""Sanitize error messages to prevent information leakage."""
sanitized = msg
for pattern in [r"\d{1,3}(\.\d{1,3}){3}", r"/home/\w+"]:
import re
sanitized = re.sub(pattern, "[REDACTED]", sanitized)
return sanitized
@app.route("/login", methods=["GET", "POST"])
def login():
"""Login page."""
if not ADMIN_PASSWORD:
return redirect(url_for("index"))
if request.method == "POST":
if not verify_csrf_token():
flash("Invalid request.", "error")
return redirect(url_for("login"))
password = request.form.get("password", "")
if hmac.compare_digest(password, ADMIN_PASSWORD):
session.clear()
session["authenticated"] = True
session["csrf_token"] = secrets.token_hex(32)
session["auth_time"] = datetime.now().timestamp()
next_url = request.form.get("next", url_for("index"))
parsed = urlparse(next_url)
if parsed.netloc:
next_url = url_for("index")
return redirect(next_url)
else:
flash("Invalid password.", "error")
return render_template("login.html", csrf_token=csrf_token())
@app.route("/logout")
def logout():
"""Logout."""
session.clear()
return redirect(url_for("index"))
@app.route("/")
@login_required
def index():
"""Newspaper listing page."""
sources = get_all_sources()
rss_feeds = load_rss_feeds()
source_list = []
disabled_sources = []
for source_name in sources:
stats = get_source_stats(source_name)
source_info = {
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get("disabled", False):
source_info["disabled"] = True
source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info)
continue
source_list.append(source_info)
source_list.extend(disabled_sources)
return render_template("index.html", sources=source_list)
@app.route("/source/<slug>")
@login_required
def articles(slug: str):
"""Article listing page for a specific source."""
page = request.args.get("page", 1, type=int)
per_page = 50
sources = get_all_sources()
source_name = None
for s in sources:
if s.lower() == slug.lower():
source_name = s
break
if not source_name:
return render_template("article_not_found.html", slug=slug, article_id=0), 404
articles_list = get_articles_by_source(
source_name, limit=per_page, offset=(page - 1) * per_page
)
stats = get_source_stats(source_name)
total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page)
articles_data = []
for article in articles_list:
articles_data.append(
{
"id": getattr(article, "id", 0),
"title": article.title or "Untitled",
"date": article.publish_date or "",
"summary": article.content_text[:200] if article.content_text else "",
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
}
)
from collections import OrderedDict
grouped = OrderedDict()
for a in articles_data:
if a["date"]:
try:
day = a["date"][:10]
except Exception:
day = "Unknown"
else:
day = "Unknown"
grouped.setdefault(day, []).append(a)
return render_template(
"articles.html",
source_name=source_name.title(),
source_slug=source_name.lower(),
articles=articles_data,
articles_by_date=grouped.items(),
pagination=pagination,
)
def validate_archive_path(archive_path: str) -> Optional[Path]:
"""Validate archive path is within ARCHIVE_DIR (prevents path traversal).
URL-decodes path first to block encoded traversal (..%2F).
Args:
archive_path: Requested path component
Returns:
Resolved Path if valid, None if traversal detected
"""
decoded = unquote(archive_path)
try:
archive_file = (ARCHIVE_DIR / decoded).resolve()
if not str(archive_file).startswith(str(ARCHIVE_DIR)):
logger.warning("Path traversal attempt blocked: %s", archive_path)
return None
return archive_file
except (ValueError, OSError):
return None
@app.route("/archive/<path:archive_path>")
@login_required
def serve_archive(archive_path):
"""Serve archived HTML file."""
archive_file = validate_archive_path(archive_path)
if archive_file and archive_file.exists():
return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404
@app.route("/archive-file/<path:encoded_path>")
@login_required
def serve_archive_file(encoded_path):
"""Serve archived HTML file from encoded path."""
archive_file = validate_archive_path(encoded_path)
if archive_file and archive_file.exists():
return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404
@app.route("/source/<slug>/article/<int:article_id>")
@login_required
def article(slug: str, article_id: int):
"""Individual article page."""
sources = get_all_sources()
source_name = None
for s in sources:
if s.lower() == slug.lower():
source_name = s
break
if not source_name:
return render_template(
"article_not_found.html", slug=slug, article_id=article_id
), 404
article = get_article(source_name, article_id)
if not article:
return render_template(
"article_not_found.html", slug=slug, article_id=article_id
), 404
article_data = {
"id": article_id,
"title": article.title or "Untitled",
"publish_date": article.publish_date or "",
"author": article.author or "",
"url": article.url or "",
"content_text": article.content_text or "",
"archive_file_path": article.archive_file_path or "",
}
return render_template(
"article.html",
source_name=source_name.title(),
source_slug=source_name.lower(),
article=article_data,
)
@app.route("/status")
@login_required
def status():
"""System status page."""
sources = get_all_sources()
rss_feeds = load_rss_feeds()
sources_info = []
disabled_sources = []
total_articles = 0
failed_jobs = 0
disabled_count = 0
for source_name in sources:
stats = get_source_stats(source_name)
source_info = {
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get("disabled", False):
source_info["disabled"] = True
disabled_count += 1
disabled_sources.append(source_info)
continue
sources_info.append(source_info)
total_articles += stats["total_articles"]
failed_jobs += stats["failed"]
sources_info.extend(disabled_sources)
return render_template(
"status.html",
sources=sources_info,
total_articles=total_articles,
failed_jobs=failed_jobs,
sources_monitored=len(sources) - disabled_count,
disabled_sources=disabled_count,
)
@app.route("/api/sources")
@login_required
def api_sources():
"""API endpoint for listing all sources."""
sources = get_all_sources()
rss_feeds = load_rss_feeds()
source_list = []
disabled_sources = []
for source_name in sources:
stats = get_source_stats(source_name)
source_info = {
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get("disabled", False):
source_info["disabled"] = True
source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info)
continue
source_list.append(source_info)
source_list.extend(disabled_sources)
return jsonify({"sources": source_list})
@app.route("/api/source/<slug>/articles")
@login_required
def api_articles(slug: str):
"""API endpoint for listing articles for a source."""
page = request.args.get("page", 1, type=int)
per_page = 50
sources = get_all_sources()
source_name = None
for s in sources:
if s.lower() == slug.lower():
source_name = s
break
if not source_name:
return jsonify({"error": "Source not found"}), 404
articles_list = get_articles_by_source(
source_name, limit=per_page, offset=(page - 1) * per_page
)
stats = get_source_stats(source_name)
total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page)
articles_data = []
for article in articles_list:
articles_data.append(
{
"id": getattr(article, "id", 0),
"title": article.title or "Untitled",
"date": article.publish_date or "",
"summary": article.content_text[:200] if article.content_text else "",
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
}
)
return jsonify(
{
"source_name": source_name.title(),
"articles": articles_data,
"total": total,
"page": page,
"per_page": per_page,
"has_next": pagination["has_next"],
"has_prev": pagination["has_prev"],
}
)
@app.route("/api/status")
@login_required
def api_status():
"""API endpoint for system status."""
sources = get_all_sources()
sources_monitored = len(sources)
total_articles = 0
failed_jobs = 0
last_archive_run = None
for source_name in sources:
stats = get_source_stats(source_name)
total_articles += stats["total_articles"]
failed_jobs += stats["failed"]
if stats.get("last_archive_run"):
if last_archive_run is None or stats["last_archive_run"] > last_archive_run:
last_archive_run = stats["last_archive_run"]
return jsonify(
{
"status": "online",
"last_archive_run": last_archive_run,
"pending_jobs": 0,
"failed_jobs": failed_jobs,
"sources_monitored": sources_monitored,
"total_articles": total_articles,
}
)
@app.route("/rss")
def rss_feed():
"""RSS 2.0 endpoint for latest archived articles."""
limit = request.args.get("limit", 50, type=int)
articles = get_latest_articles(limit=limit)
rss_items = []
for article in articles:
if (
article.title
and article.content_text
and "Performing security verification" not in article.content_text
):
pub_date = None
if article.publish_date:
try:
dt = datetime.fromisoformat(
article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S %z").strip()
except (ValueError, AttributeError):
try:
dt = datetime.fromisoformat(article.publish_date)
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
except (ValueError, AttributeError):
pub_date = datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000"
)
source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower())
item = {
"title": html.escape(article.title),
"link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
"pubDate": pub_date,
"description": html.escape(
article.content_text[:500] if article.content_text else ""
),
"guid": html.escape(article.url or f"article-{article.id}"),
}
if article.author:
item["author"] = html.escape(article.author)
rss_items.append(item)
rss_template = render_template(
"rss.xml",
title="NewsArchiver - Latest Articles",
link=SERVER_URL,
description="Latest archived news articles",
last_build_date=datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000"
),
items=rss_items,
)
response = make_response(rss_template)
response.headers["Content-Type"] = "application/rss+xml; charset=utf-8"
return response
@app.route("/atom")
def atom_feed():
"""Atom 1.0 endpoint for latest archived articles."""
limit = request.args.get("limit", 50, type=int)
articles = get_latest_articles(limit=limit)
atom_entries = []
for article in articles:
if (
article.title
and article.content_text
and "Performing security verification" not in article.content_text
):
pub_date = None
if article.publish_date:
try:
dt = datetime.fromisoformat(
article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
except (ValueError, AttributeError):
pub_date = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S+00:00"
)
source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower())
entry = {
"title": html.escape(article.title),
"link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
"published": pub_date,
"summary": html.escape(
article.content_text[:500] if article.content_text else ""
),
"id": html.escape(article.url or f"article-{article.id}"),
}
if article.author:
entry["author"] = {"name": html.escape(article.author)}
atom_entries.append(entry)
atom_template = render_template(
"atom.xml",
title="NewsArchiver - Latest Articles",
link=SERVER_URL,
updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"),
entries=atom_entries,
)
response = make_response(atom_template)
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
return response
def load_rss_feeds() -> dict:
"""Load RSS feeds configuration, resolving environment variables in API keys."""
global RSS_FEEDS
if RSS_FEEDS:
return RSS_FEEDS
if not RSS_FEEDS_PATH.exists():
logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH)
return {}
try:
raw_content = RSS_FEEDS_PATH.read_text(encoding="utf-8")
for key, value in os.environ.items():
raw_content = raw_content.replace(f"${{{key}}}", value)
RSS_FEEDS = json.loads(raw_content)
return RSS_FEEDS
except Exception as e:
logger.error("Failed to load RSS feeds: %s", sanitize_error_message(str(e)))
return {}
if __name__ == "__main__":
logger.info("Starting web interface...")
if not DB_PATH.exists():
logger.info("Database not found, initializing...")
from storage_manager import initialize_storage
initialize_storage()
app.run(host="0.0.0.0", port=5000, debug=False)