mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 06:37:41 +00:00 
			
		
		
		
	Compare commits
	
		
			1404 Commits
		
	
	
		
			0.38.1
			...
			browserste
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 960c0510b3 | ||
|   | 440847820f | ||
|   | c9f0921b02 | ||
|   | 0d1366dfb9 | ||
|   | ffde79ecac | ||
|   | 66ad43b2df | ||
|   | 6b0e56ca80 | ||
|   | 5a2d84d8b4 | ||
|   | a941156f26 | ||
|   | a1fdeeaa29 | ||
|   | 40ea2604a7 | ||
|   | ceda526093 | ||
|   | 4197254c53 | ||
|   | a0b7efb436 | ||
|   | 5f5e8ede6c | ||
|   | 52ca855a29 | ||
|   | 079efd0a85 | ||
|   | 3a583a4e5d | ||
|   | cfb4decf67 | ||
|   | 8067d5170b | ||
|   | 5551acf67d | ||
|   | 45a030bac6 | ||
|   | 96dc49e229 | ||
|   | 5f43d988a3 | ||
|   | 4269079c54 | ||
|   | cdfb3f206c | ||
|   | 9f326783e5 | ||
|   | 4e6e680d79 | ||
|   | 1378b5b2ff | ||
|   | 456c6e3f58 | ||
|   | 61be7f68db | ||
|   | 0e38a3c881 | ||
|   | 2c630e9853 | ||
|   | 786e0d1fab | ||
|   | 78b7aee512 | ||
|   | 9d9d01863a | ||
|   | 108cdf84a5 | ||
|   | 8c6f6f1578 | ||
|   | df4ffaaff8 | ||
|   | d522c65e50 | ||
|   | c3b2a8b019 | ||
|   | 28d3151090 | ||
|   | 2a1c832f8d | ||
|   | 0170adb171 | ||
|   | cb62404b8c | ||
|   | 8f9c46bd3f | ||
|   | 97291ce6d0 | ||
|   | f689e5418e | ||
|   | f751f0b0ef | ||
|   | ea9ba3bb2e | ||
|   | c7ffebce2a | ||
|   | 54b7c070f7 | ||
|   | 6c1b687cd1 | ||
|   | e850540a91 | ||
|   | d4bc9dfc50 | ||
|   | f26ea55e9c | ||
|   | b53e1985ac | ||
|   | 302ef80d95 | ||
|   | 5b97c29714 | ||
|   | 64075c87ee | ||
|   | d58a71cffc | ||
|   | 036b006226 | ||
|   | f29f89d078 | ||
|   | 289f118581 | ||
|   | 10b2bbea83 | ||
|   | 32d110b92f | ||
|   | 860a5f5c1a | ||
|   | 70a18ee4b5 | ||
|   | 73189672c3 | ||
|   | 7e7d5dc383 | ||
|   | 1c2cfc37aa | ||
|   | 0634fe021d | ||
|   | 04934b6b3b | ||
|   | ff00417bc5 | ||
|   | 849c5b2293 | ||
|   | 4bf560256b | ||
|   | 7903b03a0c | ||
|   | 5e7c0880c1 | ||
|   | 957aef4ff3 | ||
|   | 8e9a83d8f4 | ||
|   | 5961838143 | ||
|   | 8cf4a8128b | ||
|   | 24c3bfe5ad | ||
|   | bdd9760f3c | ||
|   | e37467f649 | ||
|   | d42fdf0257 | ||
|   | 939fa86582 | ||
|   | b87c92b9e0 | ||
|   | 4d5535d72c | ||
|   | ad08219d03 | ||
|   | 82211eef82 | ||
|   | 5d9380609c | ||
|   | a8b3918fca | ||
|   | e83fb37fb6 | ||
|   | 6b99afe0f7 | ||
|   | 09ebc6ec63 | ||
|   | 6b1065502e | ||
|   | d4c470984a | ||
|   | 55da48f719 | ||
|   | dbd4adf23a | ||
|   | b1e700b3ff | ||
|   | 1c61b5a623 | ||
|   | e799a1cdcb | ||
|   | 938065db6f | ||
|   | 4f2d38ff49 | ||
|   | 8960f401b7 | ||
|   | 1c1f1c6f6b | ||
|   | a2a98811a5 | ||
|   | 5a0ef8fc01 | ||
|   | d90de0851d | ||
|   | 360b4f0d8b | ||
|   | 6fc04d7f1c | ||
|   | 66fb05527b | ||
|   | 202e47d728 | ||
|   | d67d396b88 | ||
|   | 05f54f0ce6 | ||
|   | 6adf10597e | ||
|   | 4419bc0e61 | ||
|   | f7e9846c9b | ||
|   | 5dea5e1def | ||
|   | 0fade0a473 | ||
|   | 121e9c20e0 | ||
|   | 12cec2d541 | ||
|   | d52e6e8e11 | ||
|   | bae1a89b75 | ||
|   | e49711f449 | ||
|   | a3a3ab0622 | ||
|   | c5fe188b28 | ||
|   | 1fb0adde54 | ||
|   | 2614b275f0 | ||
|   | 1631a55830 | ||
|   | f00b8e4efb | ||
|   | 179ca171d4 | ||
|   | 84f2870d4f | ||
|   | 7421e0f95e | ||
|   | c6162e48f1 | ||
|   | feccb18cdc | ||
|   | 1462ad89ac | ||
|   | cfb9fadec8 | ||
|   | d9f9fa735d | ||
|   | 6084b0f23d | ||
|   | 4e18aea5ff | ||
|   | fdba6b5566 | ||
|   | 4e6c783c45 | ||
|   | 0f0f5af7b5 | ||
|   | 7fcba26bea | ||
|   | 4bda1a234f | ||
|   | d297850539 | ||
|   | 751239250f | ||
|   | 6aceeb01ab | ||
|   | 49bc982c69 | ||
|   | e0abf0b505 | ||
|   | f08a1185aa | ||
|   | ad5d7efbbf | ||
|   | 7029d10f8b | ||
|   | 26d3a23e05 | ||
|   | 942625e1fb | ||
|   | 33c83230a6 | ||
|   | 87510becb5 | ||
|   | 5e95dc62a5 | ||
|   | 7d94535dbf | ||
|   | 563c196396 | ||
|   | e8b82c47ca | ||
|   | e84de7e8f4 | ||
|   | 1543edca24 | ||
|   | 82e0b99b07 | ||
|   | b0ff9d161e | ||
|   | c1dd681643 | ||
|   | ecafa27833 | ||
|   | f7d4e58613 | ||
|   | 5bb47e47db | ||
|   | 03151da68e | ||
|   | a16a70229d | ||
|   | 9476c1076b | ||
|   | a4959b5971 | ||
|   | a278fa22f2 | ||
|   | d39530b261 | ||
|   | d4b4355ff5 | ||
|   | c1c8de3104 | ||
|   | 5a768d7db3 | ||
|   | f38429ec93 | ||
|   | 783926962d | ||
|   | 6cd1d50a4f | ||
|   | 54a4970a4c | ||
|   | fd00453e6d | ||
|   | 2842ffb205 | ||
|   | ec4e2f5649 | ||
|   | fe8e3d1cb1 | ||
|   | 69fbafbdb7 | ||
|   | f255165571 | ||
|   | 7ff34baa90 | ||
|   | 043378d09c | ||
|   | af4bafcff8 | ||
|   | b656338c63 | ||
|   | 97af190910 | ||
|   | e9e063e18e | ||
|   | 45c444d0db | ||
|   | 00458b95c4 | ||
|   | dad9760832 | ||
|   | e2c2a76cb2 | ||
|   | 5b34aece96 | ||
|   | 1b625dc18a | ||
|   | 367afc81e9 | ||
|   | ddfbef6db3 | ||
|   | e173954cdd | ||
|   | e830fb2320 | ||
|   | c6589ee1b4 | ||
|   | dc936a2e8a | ||
|   | 8c1527c1ad | ||
|   | a5ff1cd1d7 | ||
|   | 543cb205d2 | ||
|   | 273adfa0a4 | ||
|   | 8ecfd17973 | ||
|   | 19f3851c9d | ||
|   | 7f2fa20318 | ||
|   | e16814e40b | ||
|   | 337fcab3f1 | ||
|   | eaccd6026c | ||
|   | 5b70625eaa | ||
|   | 60d292107d | ||
|   | 1cb38347da | ||
|   | 55fe2abf42 | ||
|   | 4225900ec3 | ||
|   | 1fb4342488 | ||
|   | 7071df061a | ||
|   | 6dd1fa2b88 | ||
|   | 371f85d544 | ||
|   | 932cf15e1e | ||
|   | bf0d410d32 | ||
|   | 730f37c7ba | ||
|   | 8a35d62e02 | ||
|   | f527744024 | ||
|   | 71c9b1273c | ||
|   | ec68450df1 | ||
|   | 2fd762a783 | ||
|   | d7e85ffe8f | ||
|   | d23a301826 | ||
|   | 3ce6096fdb | ||
|   | 8acdcdd861 | ||
|   | 755cba33de | ||
|   | 8aae7dfae0 | ||
|   | ed00f67a80 | ||
|   | 44e7e142f8 | ||
|   | fe704e05a3 | ||
|   | e756e0af5e | ||
|   | c0b6c8581e | ||
|   | de558f208f | ||
|   | 321426dea2 | ||
|   | bde27c8a8f | ||
|   | 1405e962f0 | ||
|   | a9f10946f4 | ||
|   | 6f2186b442 | ||
|   | cf0ff26275 | ||
|   | cffb6d748c | ||
|   | 99b0935b42 | ||
|   | f1853b0ce7 | ||
|   | c331612a22 | ||
|   | 445bb0dde3 | ||
|   | 8f3a6a42bc | ||
|   | 732ae1d935 | ||
|   | 5437144dff | ||
|   | ed38012c6e | ||
|   | f07ff9b55e | ||
|   | 1c46914992 | ||
|   | e9c4037178 | ||
|   | 1af342ef64 | ||
|   | e09ee7da97 | ||
|   | 09bc24ff34 | ||
|   | a1d04bb37f | ||
|   | 01f910f840 | ||
|   | bed16009bb | ||
|   | faeed78ffb | ||
|   | 5d9081ccb2 | ||
|   | 2cf1829073 | ||
|   | 526551a205 | ||
|   | ba139e7f3f | ||
|   | 13e343f9da | ||
|   | 13be4623db | ||
|   | 3b19e3d2bf | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ce42f8ea26 | ||
|   | 343e359b39 | ||
|   | ffd160ce0e | ||
|   | d31fc860cc | ||
|   | 90b357f457 | ||
|   | cc147be76e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8ae5ed76ce | ||
|   | a9ed113369 | ||
|   | eacf920b9a | ||
|   | c9af9b6374 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5e65fb606b | ||
|   | 434a1b242e | ||
|   | bce02f9c82 | ||
|   | 76ffc3e891 | ||
|   | c6ee6687b5 | ||
|   | de48892243 | ||
|   | 6aded50aca | ||
|   | b8e279a025 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8041d00e75 | ||
|   | 6a0e14cfce | ||
|   | be91c5425c | ||
|   | 778680d517 | ||
|   | 7e8aa7e3ff | ||
|   | d77f913aa0 | ||
|   | 59cefe58e7 | ||
|   | cfc689e046 | ||
|   | 7b04b52e45 | ||
|   | f49eb4567f | ||
|   | a8959be348 | ||
|   | 05bf3c9a5c | ||
|   | 4293639f51 | ||
|   | f0ed4f64e8 | ||
|   | add2c658b4 | ||
|   | e27f66eb73 | ||
|   | e4504fee49 | ||
|   | 5798581f18 | ||
|   | ef910b86ef | ||
|   | 8d1fb96d18 | ||
|   | 5df5d0fbe7 | ||
|   | 815cba11ca | ||
|   | 3aed4e5af9 | ||
|   | 3618c389c6 | ||
|   | d127214d8f | ||
|   | c0f000b1d1 | ||
|   | ee5294740a | ||
|   | bd6eda696c | ||
|   | 1ba29655f5 | ||
|   | 830a0a3a82 | ||
|   | e110b3ee93 | ||
|   | 3ae9bfa6f9 | ||
|   | 6f3c3b7dfb | ||
|   | 74707909f1 | ||
|   | d4dac23ba1 | ||
|   | f9954f93f3 | ||
|   | 1a43b112dc | ||
|   | db59bf73e1 | ||
|   | 8aac7bccbe | ||
|   | 9449c59fbb | ||
|   | 21f4ba2208 | ||
|   | daef1cd036 | ||
|   | 56b365df40 | ||
|   | 8e5bf91965 | ||
|   | 1ae59551be | ||
|   | a176468fb8 | ||
|   | 8fac593201 | ||
|   | e3b8c0f5af | ||
|   | 514fd7f91e | ||
|   | 38c4768b92 | ||
|   | 6555d99044 | ||
|   | e719dbd19b | ||
|   | b28a8316cc | ||
|   | e609a2d048 | ||
|   | 994d34c776 | ||
|   | de776800e9 | ||
|   | 8b8ed58f20 | ||
|   | 79c6d765de | ||
|   | c6db7fc90e | ||
|   | bc587efae2 | ||
|   | 6ee6be1a5f | ||
|   | c83485094b | ||
|   | 387ce32e6f | ||
|   | 6b9a788d75 | ||
|   | 14e632bc19 | ||
|   | 52c895b2e8 | ||
|   | a62043e086 | ||
|   | 3d390b6ea4 | ||
|   | 301a40ca34 | ||
|   | 1c099cdba6 | ||
|   | af747e6e3f | ||
|   | aefad0bdf6 | ||
|   | 904ef84f82 | ||
|   | d2569ba715 | ||
|   | ccb42bcb12 | ||
|   | 4163030805 | ||
|   | 140d375ad0 | ||
|   | 1a608d0ae6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e6ed91cfe3 | ||
|   | 008272cd77 | ||
|   | 823a0c99f4 | ||
|   | 1f57d9d0b6 | ||
|   | 3287283065 | ||
|   | c5a4e0aaa3 | ||
|   | 5119efe4fb | ||
|   | 78a2dceb81 | ||
|   | 72c7645f60 | ||
|   | e09eb47fb7 | ||
|   | 616c0b3f65 | ||
|   | c90b27823a | ||
|   | 3b16b19a94 | ||
|   | 4ee9fa79e1 | ||
|   | 4b49759113 | ||
|   | e9a9790cb0 | ||
|   | 593660e2f6 | ||
|   | 7d96b4ba83 | ||
|   | fca40e4d5b | ||
|   | 66e2dfcead | ||
|   | bce7eb68fb | ||
|   | 93c0385119 | ||
|   | e17f3be739 | ||
|   | 3a9f79b756 | ||
|   | 1f5670253e | ||
|   | fe3cf5ffd2 | ||
|   | d31a45d49a | ||
|   | 19ee65361d | ||
|   | 677082723c | ||
|   | 96793890f8 | ||
|   | 0439155127 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 29ca2521eb | ||
|   | 7d67ad057c | ||
|   | 2e88872b7e | ||
|   | b30b718373 | ||
|   | 402f1e47e7 | ||
|   | 9510345e01 | ||
|   | 36085d8cf4 | ||
|   | 399cdf0fbf | ||
|   | 4be0fafa93 | ||
|   | 51ce7ac66e | ||
|   | c3d825f38c | ||
|   | fc3c4b804d | ||
|   | 1749c07750 | ||
|   | 65428655b8 | ||
|   | 8be0029260 | ||
|   | 3c727ca54b | ||
|   | 4596532090 | ||
|   | d0a88d54a1 | ||
|   | 21ab4b16a0 | ||
|   | 77133de1cf | ||
|   | 0d92be348a | ||
|   | 3ac0c9346c | ||
|   | b6d8db4c67 | ||
|   | 436a66d465 | ||
|   | 764514e5eb | ||
|   | ad3ffb6ccb | ||
|   | e051b29bf2 | ||
|   | 126852b778 | ||
|   | d115b2c858 | ||
|   | 2db04e4211 | ||
|   | 946a556fb6 | ||
|   | eda23678aa | ||
|   | 273bd45ad7 | ||
|   | 3d1e1025d2 | ||
|   | 5528b7c4b3 | ||
|   | 0dce3f4fec | ||
|   | af4311a68c | ||
|   | 792fedb8bc | ||
|   | 824748df9e | ||
|   | c086ec0d68 | ||
|   | 8e207ba438 | ||
|   | f0823126c8 | ||
|   | 98f56736c1 | ||
|   | 872bd2de85 | ||
|   | e6de1dd135 | ||
|   | 599291645d | ||
|   | 156d403552 | ||
|   | 7fe0ef7099 | ||
|   | fe70beeaed | ||
|   | abf7ed9085 | ||
|   | 19e752e9ba | ||
|   | 684e96f5f1 | ||
|   | 8f321139fd | ||
|   | 7fdae82e46 | ||
|   | bbc18d8e80 | ||
|   | d8ee5472f1 | ||
|   | 8fd57280b7 | ||
|   | 0285d00f13 | ||
|   | f7f98945a2 | ||
|   | 5e2049c538 | ||
|   | 26931e0167 | ||
|   | 5229094e44 | ||
|   | 5a306aa78c | ||
|   | c8dcc072c8 | ||
|   | 7c97a5a403 | ||
|   | 7dd967be8e | ||
|   | 3607d15185 | ||
|   | 3382b4cb3f | ||
|   | 5f030d3668 | ||
|   | 06975d6d8f | ||
|   | f58e5b7f19 | ||
|   | e50eff8e35 | ||
|   | 07a853ce59 | ||
|   | 80f8d23309 | ||
|   | 9f41d15908 | ||
|   | 89797dfe02 | ||
|   | c905652780 | ||
|   | 99246d3e6d | ||
|   | f9f69bf0dd | ||
|   | 68efb25e9b | ||
|   | 70606ab05d | ||
|   | d3c8386874 | ||
|   | 47103d7f3d | ||
|   | 03c671bfff | ||
|   | e209d9fba0 | ||
|   | 3b43da35ec | ||
|   | a0665e1f18 | ||
|   | 9ffe7e0eaf | ||
|   | 3e5671a3a2 | ||
|   | cd1aca9ee3 | ||
|   | 6a589e14f3 | ||
|   | dbb76f3618 | ||
|   | 4ae27af511 | ||
|   | e1860549dc | ||
|   | 9765d56a23 | ||
|   | 349111eb35 | ||
|   | 71e50569a0 | ||
|   | c372942295 | ||
|   | 0aef5483d9 | ||
|   | c266c64b94 | ||
|   | 32e5498a9d | ||
|   | 0ba7928d58 | ||
|   | 1709e8f936 | ||
|   | b16d65741c | ||
|   | 1cadcc6d15 | ||
|   | b58d521d19 | ||
|   | 52225f2ad8 | ||
|   | 7220afab0a | ||
|   | 1c0fe4c23e | ||
|   | 4f6b0eb8a5 | ||
|   | f707c914b6 | ||
|   | 9cb636e638 | ||
|   | 1d5fe51157 | ||
|   | c0b49d3be9 | ||
|   | c4dc85525f | ||
|   | 26159840c8 | ||
|   | 522e9786c6 | ||
|   | 9ce86a2835 | ||
|   | f9f6300a70 | ||
|   | 7734b22a19 | ||
|   | da421fe110 | ||
|   | 3e2b55a46f | ||
|   | 7ace259d70 | ||
|   | aa6ad7bf47 | ||
|   | 40dd29dbc6 | ||
|   | 7debccca73 | ||
|   | 59578803bf | ||
|   | a5db3a0b99 | ||
|   | 49a5337ac4 | ||
|   | ceac8c21e4 | ||
|   | a7132b1cfc | ||
|   | 2b948c15c1 | ||
|   | 34f2d30968 | ||
|   | 700729a332 | ||
|   | b6060ac90c | ||
|   | 5cccccb0b6 | ||
|   | c52eb512e8 | ||
|   | 7282df9c08 | ||
|   | e30b17b8bc | ||
|   | 1e88136325 | ||
|   | 57de4ffe4f | ||
|   | 51e2e8a226 | ||
|   | 8887459462 | ||
|   | 460c724e51 | ||
|   | dcf4bf37ed | ||
|   | e3cf22fc27 | ||
|   | d497db639e | ||
|   | 7355ac8d21 | ||
|   | 2f2d0ea0f2 | ||
|   | a958e1fe20 | ||
|   | 5dc3b00ec6 | ||
|   | 8ac4757cd9 | ||
|   | 2180bb256d | ||
|   | 212f15ad5f | ||
|   | 22b2068208 | ||
|   | 4916043055 | ||
|   | 7bf13bad30 | ||
|   | 0aa2276afb | ||
|   | 3b875e5a6a | ||
|   | 8ec50294d2 | ||
|   | e3c9255d9e | ||
|   | 3b03bdcb82 | ||
|   | e25792bcec | ||
|   | bf4168a2aa | ||
|   | 9d37eaa57b | ||
|   | 40d01acde9 | ||
|   | d34832de73 | ||
|   | ed4bafae63 | ||
|   | 3a5bceadfa | ||
|   | 6abdf2d332 | ||
|   | dee23709a9 | ||
|   | 52df3b10e7 | ||
|   | 087d21c61e | ||
|   | 171faf465c | ||
|   | a3d8bd0b1a | ||
|   | 6ef8a1c18f | ||
|   | 126f0fbf87 | ||
|   | cfa712c88c | ||
|   | 6a6ba40b6a | ||
|   | e7f726c057 | ||
|   | df0cc7b585 | ||
|   | 76cd98b521 | ||
|   | f84ba0fb31 | ||
|   | c35cbd33d6 | ||
|   | 661f7fe32c | ||
|   | 7cb7eebbc5 | ||
|   | aaceb4ebad | ||
|   | 56cf6e5ea5 | ||
|   | 1987e109e8 | ||
|   | 20d65cdd26 | ||
|   | 37ff5f6d37 | ||
|   | 2f777ea3bb | ||
|   | e709201955 | ||
|   | 572f71299f | ||
|   | 5f150c4f03 | ||
|   | 8cbf8e8f57 | ||
|   | 0e65dda5b6 | ||
|   | 72a415144b | ||
|   | 52f2c00308 | ||
|   | 72311fb845 | ||
|   | f1b10a22f8 | ||
|   | a4c620c308 | ||
|   | 9434eac72d | ||
|   | edb5e20de6 | ||
|   | e62eeb1c4a | ||
|   | a4e6fd1ec3 | ||
|   | d8b9f0fd78 | ||
|   | f9387522ee | ||
|   | ba8d2e0c2d | ||
|   | 247db22a33 | ||
|   | aeabd5b3fc | ||
|   | e9e1ce893f | ||
|   | b5a415c7b6 | ||
|   | 9e954532d6 | ||
|   | 955835df72 | ||
|   | 1aeafef910 | ||
|   | 1367197df7 | ||
|   | 143971123d | ||
|   | 04d2d3fb00 | ||
|   | 236f0c098d | ||
|   | 582c6b465b | ||
|   | a021ba87fa | ||
|   | e9057cb851 | ||
|   | 72ec438caa | ||
|   | 367dec48e1 | ||
|   | dd87912c88 | ||
|   | 0126cb0aac | ||
|   | 463b2d0449 | ||
|   | e4f6d54ae2 | ||
|   | 5f338d7824 | ||
|   | 0b563a93ec | ||
|   | d939882dde | ||
|   | 690cf4acc9 | ||
|   | 3cb3c7ba2e | ||
|   | 5325918f29 | ||
|   | 8eee913438 | ||
|   | 06921d973e | ||
|   | 316f28a0f2 | ||
|   | 3801d339f5 | ||
|   | d814535dc6 | ||
|   | cf3f3e4497 | ||
|   | ba76c2a280 | ||
|   | 94f38f052e | ||
|   | 1710885fc4 | ||
|   | 2018e73240 | ||
|   | fae8c89a4e | ||
|   | 40988c55c6 | ||
|   | 5aa713b7ea | ||
|   | e1f5dfb703 | ||
|   | 966600d28e | ||
|   | e7ac356d99 | ||
|   | e874df4ffc | ||
|   | d1f44d0345 | ||
|   | 8536af0845 | ||
|   | 9076ba6bd3 | ||
|   | 43af18e2bc | ||
|   | ad75e8cdd0 | ||
|   | f604643356 | ||
|   | d5fd22f693 | ||
|   | 1d9d11b3f5 | ||
|   | f49464f451 | ||
|   | bc6bde4062 | ||
|   | 2863167f45 | ||
|   | ce3966c104 | ||
|   | d5f574ca17 | ||
|   | c96ece170a | ||
|   | 1fb90bbddc | ||
|   | 55b6ae86e8 | ||
|   | 66b892f770 | ||
|   | 3b80bb2f0e | ||
|   | e6d2d87b31 | ||
|   | 6e71088cde | ||
|   | 2bc988dffc | ||
|   | a578de36c5 | ||
|   | 4c74d39df0 | ||
|   | c454cbb808 | ||
|   | 6f1eec0d5a | ||
|   | 0d05ee1586 | ||
|   | 23476f0e70 | ||
|   | cf363971c1 | ||
|   | 35409f79bf | ||
|   | fc88306805 | ||
|   | 8253074d56 | ||
|   | 5f9c8db3e1 | ||
|   | abf234298c | ||
|   | 0e1032a36a | ||
|   | 3b96e40464 | ||
|   | c747cf7ba8 | ||
|   | 3e98c8ae4b | ||
|   | aaad71fc19 | ||
|   | 78f93113d8 | ||
|   | e9e586205a | ||
|   | 89f1ba58b6 | ||
|   | 6f4fd011e3 | ||
|   | 900dc5ee78 | ||
|   | 7b8b50138b | ||
|   | 01af21f856 | ||
|   | f7f4ab314b | ||
|   | ce0355c0ad | ||
|   | 0f43213d9d | ||
|   | 93c57d9fad | ||
|   | 3cdd075baf | ||
|   | 5c617e8530 | ||
|   | 1a48965ba1 | ||
|   | 41856c4ed8 | ||
|   | 0ed897c50f | ||
|   | f8e587c415 | ||
|   | d47a25eb6d | ||
|   | 9a0792d185 | ||
|   | 948ef7ade4 | ||
|   | 0ba139f8f9 | ||
|   | a9431191fc | ||
|   | 774451f256 | ||
|   | 04577cbf32 | ||
|   | f2864af8f1 | ||
|   | 9a36d081c4 | ||
|   | 7048a0acbd | ||
|   | fba719ab8d | ||
|   | 7c5e2d00af | ||
|   | 02b8fc0c18 | ||
|   | de15dfd80d | ||
|   | 024c8d8fd5 | ||
|   | fab7d325f7 | ||
|   | 58c7cbeac7 | ||
|   | ab9efdfd14 | ||
|   | 65d5a5d34c | ||
|   | 93c157ee7f | ||
|   | de85db887c | ||
|   | 50805ca38a | ||
|   | fc6424c39e | ||
|   | f0966eb23a | ||
|   | e4fb5ab4da | ||
|   | e99f07a51d | ||
|   | 08ee223b5f | ||
|   | 572f9b8a31 | ||
|   | fcfd1b5e10 | ||
|   | 0790dd555e | ||
|   | 0b20dc7712 | ||
|   | 13c4121f52 | ||
|   | e8e176f3bd | ||
|   | 7a1d2d924e | ||
|   | c3731cf055 | ||
|   | a287e5a86c | ||
|   | 235535c327 | ||
|   | 44dc62da2d | ||
|   | 0c380c170f | ||
|   | b7a2501d64 | ||
|   | e970fef991 | ||
|   | b76148a0f4 | ||
|   | 93cc30437f | ||
|   | 6562d6e0d4 | ||
|   | 6c217cc3b6 | ||
|   | f30cdf0674 | ||
|   | 14da0646a7 | ||
|   | b413cdecc7 | ||
|   | 7bf52d9275 | ||
|   | 09e6624afd | ||
|   | b58fd995b5 | ||
|   | f7bb8a0afa | ||
|   | 3e333496c1 | ||
|   | ee776a9627 | ||
|   | 65db4d68e3 | ||
|   | 74d93d10c3 | ||
|   | 37aef0530a | ||
|   | f86763dc7a | ||
|   | 13c25f9b92 | ||
|   | 265f622e75 | ||
|   | c12db2b725 | ||
|   | a048e4a02d | ||
|   | 69662ff91c | ||
|   | fc94c57d7f | ||
|   | 7b94ba6f23 | ||
|   | 2345b6b558 | ||
|   | b8d5a12ad0 | ||
|   | 9e67a572c5 | ||
|   | 378d7b7362 | ||
|   | d1d4045c49 | ||
|   | 77409eeb3a | ||
|   | 87726e0bb2 | ||
|   | 72222158e9 | ||
|   | 1814924c19 | ||
|   | 8aae4197d7 | ||
|   | 3a8a41a3ff | ||
|   | 64caeea491 | ||
|   | 3838bff397 | ||
|   | 55ea983bda | ||
|   | b4d79839bf | ||
|   | 0b8c3add34 | ||
|   | 51d57f0963 | ||
|   | 6d932149e3 | ||
|   | 2c764e8f84 | ||
|   | 07765b0d38 | ||
|   | 7c3faa8e38 | ||
|   | 4624974b91 | ||
|   | 991841f1f9 | ||
|   | e3db324698 | ||
|   | 0988bef2cd | ||
|   | 5b281f2c34 | ||
|   | a224f64cd6 | ||
|   | 7ee97ae37f | ||
|   | 69756f20f2 | ||
|   | 326b7aacbb | ||
|   | fde7b3fd97 | ||
|   | 9d04cb014a | ||
|   | 5b530ff61c | ||
|   | c98536ace4 | ||
|   | 463747d3b7 | ||
|   | 791bdb42aa | ||
|   | ce6c2737a8 | ||
|   | ade9e1138b | ||
|   | 68d5178367 | ||
|   | 41dc57aee3 | ||
|   | 943704cd04 | ||
|   | 883561f979 | ||
|   | 35d44c8277 | ||
|   | d07d7a1b18 | ||
|   | f066a1c38f | ||
|   | d0d191a7d1 | ||
|   | d7482c8d6a | ||
|   | bcf7417f63 | ||
|   | df6e835035 | ||
|   | ab28f20eba | ||
|   | 1174b95ab4 | ||
|   | a564475325 | ||
|   | 85d8d57997 | ||
|   | 359dcb63e3 | ||
|   | b043d477dc | ||
|   | 06bcfb28e5 | ||
|   | ca3b351bae | ||
|   | b7e0f0a5e4 | ||
|   | 61f0ac2937 | ||
|   | fca66eb558 | ||
|   | 359fc48fb4 | ||
|   | d0efeb9770 | ||
|   | 3416532cd6 | ||
|   | defc7a340e | ||
|   | c197c062e1 | ||
|   | 77b59809ca | ||
|   | f90b170e68 | ||
|   | c93ca1841c | ||
|   | 57f604dff1 | ||
|   | 8499468749 | ||
|   | 7f6a13ea6c | ||
|   | 9874f0cbc7 | ||
|   | 72834a42fd | ||
|   | 724cb17224 | ||
|   | 4eb4b401a1 | ||
|   | 5d40e16c73 | ||
|   | 492bbce6b6 | ||
|   | 0394a56be5 | ||
|   | 7839551d6b | ||
|   | 9c5588c791 | ||
|   | 5a43a350de | ||
|   | 3c31f023ce | ||
|   | 4cbcc59461 | ||
|   | 4be0260381 | ||
|   | 957a3c1c16 | ||
|   | 85897e0bf9 | ||
|   | 63095f70ea | ||
|   | 8d5b0b5576 | ||
|   | 1b077abd93 | ||
|   | 32ea1a8721 | ||
|   | fff32cef0d | ||
|   | 8fb146f3e4 | ||
|   | 770b0faa45 | ||
|   | f6faa90340 | ||
|   | 669fd3ae0b | ||
|   | 17d37fb626 | ||
|   | dfa7fc3a81 | ||
|   | cd467df97a | ||
|   | 71bc2fed82 | ||
|   | 738fcfe01c | ||
|   | 3ebb2ab9ba | ||
|   | ac98bc9144 | ||
|   | 3705ce6681 | ||
|   | f7ea99412f | ||
|   | d4715e2bc8 | ||
|   | 8567a83c47 | ||
|   | 77fdf59ae3 | ||
|   | 0e194aa4b4 | ||
|   | 2ba55bb477 | ||
|   | 4c759490da | ||
|   | 58a52c1f60 | ||
|   | 22638399c1 | ||
|   | e3381776f2 | ||
|   | 26e2f21a80 | ||
|   | b6009ae9ff | ||
|   | b046d6ef32 | ||
|   | e154a3cb7a | ||
|   | 1262700263 | ||
|   | 434c5813b9 | ||
|   | 0a3dc7d77b | ||
|   | a7e296de65 | ||
|   | bd0fbaaf27 | ||
|   | 0c111bd9ae | ||
|   | ed9ac0b7fb | ||
|   | 743a3069bb | ||
|   | fefc39427b | ||
|   | 2c6faa7c4e | ||
|   | 6168cd2899 | ||
|   | f3c7c969d8 | ||
|   | 1355c2a245 | ||
|   | 96cf1a06df | ||
|   | 019a4a0375 | ||
|   | db2f7b80ea | ||
|   | bfabd7b094 | ||
|   | d92dbfe765 | ||
|   | 67d2441334 | ||
|   | 3c30bc02d5 | ||
|   | dcb54117d5 | ||
|   | b1e32275dc | ||
|   | e2a6865932 | ||
|   | f04adb7202 | ||
|   | 1193a7f22c | ||
|   | 0b976827bb | ||
|   | 280e916033 | ||
|   | 5494e61a05 | ||
|   | e461c0b819 | ||
|   | d67c654f37 | ||
|   | 06ab34b6af | ||
|   | ba8676c4ba | ||
|   | 4899c1a4f9 | ||
|   | 9bff1582f7 | ||
|   | 269e3bb7c5 | ||
|   | 9976f3f969 | ||
|   | 1f250aa868 | ||
|   | 1c08d9f150 | ||
|   | 9942107016 | ||
|   | 1eb5726cbf | ||
|   | b3271ff7bb | ||
|   | f82d3b648a | ||
|   | 034b1330d4 | ||
|   | a7d005109f | ||
|   | 048c355e04 | ||
|   | 4026575b0b | ||
|   | 8c466b4826 | ||
|   | 6f072b42e8 | ||
|   | e318253f31 | ||
|   | f0f2fe94ce | ||
|   | 26f5c56ba4 | ||
|   | a1c3107cd6 | ||
|   | 8fef3ff4ab | ||
|   | baa25c9f9e | ||
|   | 488699b7d4 | ||
|   | cf3a1ee3e3 | ||
|   | daae43e9f9 | ||
|   | cdeedaa65c | ||
|   | 3c9d2ded38 | ||
|   | 9f4364a130 | ||
|   | 5bd9eaf99d | ||
|   | b1c51c0a65 | ||
|   | 232bd92389 | ||
|   | e6173357a9 | ||
|   | f2b8888aff | ||
|   | 9c46f175f9 | ||
|   | 1f27865fdf | ||
|   | faa42d75e0 | ||
|   | 3b6e6d85bb | ||
|   | 30d6a272ce | ||
|   | 291700554e | ||
|   | a82fad7059 | ||
|   | c2fe5ae0d1 | ||
|   | 5beefdb7cc | ||
|   | 872bbba71c | ||
|   | d578de1a35 | ||
|   | cdc104be10 | ||
|   | dd0eeca056 | ||
|   | a95468be08 | ||
|   | ace44d0e00 | ||
|   | ebb8b88621 | ||
|   | 12fc2200de | ||
|   | 52d3d375ba | ||
|   | 08117089e6 | ||
|   | 2ba3a6d53f | ||
|   | 2f636553a9 | ||
|   | 0bde48b282 | ||
|   | fae1164c0b | ||
|   | 169c293143 | ||
|   | 46cb5cff66 | ||
|   | 05584ea886 | ||
|   | 176a591357 | ||
|   | 15569f9592 | ||
|   | 5f9e475fe0 | ||
|   | 34b8784f50 | ||
|   | 2b054ced8c | ||
|   | 6553980cd5 | ||
|   | 7c12c47204 | ||
|   | dbd9b470d7 | ||
|   | 83555a9991 | ||
|   | 5bfdb28bd2 | ||
|   | 31a6a6717b | ||
|   | 7da32f9ac3 | ||
|   | bb732d3d2e | ||
|   | 485e55f9ed | ||
|   | 601a20ea49 | ||
|   | 76996b9eb8 | ||
|   | fba2b1a39d | ||
|   | 4a91505af5 | ||
|   | 4841c79b4c | ||
|   | 2ba00d2e1d | ||
|   | 19c96f4bdd | ||
|   | 82b900fbf4 | ||
|   | 358a365303 | ||
|   | a07ca4b136 | ||
|   | ba8cf2c8cf | ||
|   | 3106b6688e | ||
|   | 2c83845dac | ||
|   | 111266d6fa | ||
|   | ead610151f | ||
|   | 7e1e763989 | ||
|   | 327cc4af34 | ||
|   | 6008ff516e | ||
|   | cdcf4b353f | ||
|   | 1ab70f8e86 | ||
|   | 8227c012a7 | ||
|   | c113d5fb24 | ||
|   | 8c8d4066d7 | ||
|   | 277dc9e1c1 | ||
|   | fc0fd1ce9d | ||
|   | bd6127728a | ||
|   | 4101ae00c6 | ||
|   | 62f14df3cb | ||
|   | 560d465c59 | ||
|   | 7929aeddfc | ||
|   | 8294519f43 | ||
|   | 8ba8a220b6 | ||
|   | aa3c8a9370 | ||
|   | dbb5468cdc | ||
|   | 329c7620fb | ||
|   | 1f974bfbb0 | ||
|   | 437c8525af | ||
|   | a2a1d5ae90 | ||
|   | 2566de2aae | ||
|   | dfec8dbb39 | ||
|   | 5cefb16e52 | ||
|   | 341ae24b73 | ||
|   | f47c2fb7f6 | ||
|   | 9d742446ab | ||
|   | e3e022b0f4 | ||
|   | 6de4027c27 | ||
|   | cda3837355 | ||
|   | 7983675325 | ||
|   | eef56e52c6 | ||
|   | 8e3195f394 | ||
|   | e17c2121f7 | ||
|   | 07e279b38d | ||
|   | 2c834cfe37 | ||
|   | dbb5c666f0 | ||
|   | 70b3493866 | ||
|   | 3b11c474d1 | ||
|   | 890e1e6dcd | ||
|   | 6734fb91a2 | ||
|   | 16809b48f8 | ||
|   | 67c833d2bc | ||
|   | 31fea55ee4 | ||
|   | b6c50d3b1a | ||
|   | 034507f14f | ||
|   | 0e385b1c22 | ||
|   | f28c260576 | ||
|   | 18f0b63b7d | ||
|   | 97045e7a7b | ||
|   | 9807cf0cda | ||
|   | d4b5237103 | ||
|   | dc6f76ba64 | ||
|   | 1f2f93184e | ||
|   | 0f08c8dda3 | ||
|   | 68db20168e | ||
|   | 1d4474f5a3 | ||
|   | 613308881c | ||
|   | f69585b276 | ||
|   | 0179940df1 | ||
|   | c0d0424e7e | ||
|   | 014dc61222 | ||
|   | 06517bfd22 | ||
|   | b3a115dd4a | ||
|   | ffc4215411 | ||
|   | 9e708810d1 | ||
|   | 1e8aa6158b | ||
|   | 015353eccc | ||
|   | 501183e66b | ||
|   | def74f27e6 | ||
|   | 37775a46c6 | ||
|   | e4eaa0c817 | ||
|   | 206ded4201 | ||
|   | 9e71f2aa35 | ||
|   | f9594aeffb | ||
|   | b4e1353376 | ||
|   | 5b670c38d3 | ||
|   | 2a9fb12451 | ||
|   | 6c3c5dc28a | ||
|   | 8f062bfec9 | ||
|   | 380c512cc2 | ||
|   | d7ed7c44ed | ||
|   | 34a87c0f41 | ||
|   | 4074fe53f1 | ||
|   | 44d599d0d1 | ||
|   | 615fe9290a | ||
|   | 2cc6955bc3 | ||
|   | 9809af142d | ||
|   | 1890881977 | ||
|   | 9fc2fe85d5 | ||
|   | bb3c546838 | ||
|   | 165f794595 | ||
|   | a440eece9e | ||
|   | 34c83f0e7c | ||
|   | f6e518497a | ||
|   | 63e91a3d66 | ||
|   | 3034d047c2 | ||
|   | 2620818ba7 | ||
|   | 9fe4f95990 | ||
|   | ffd2a89d60 | ||
|   | 8f40f19328 | ||
|   | 082634f851 | ||
|   | 334010025f | ||
|   | 81aa8fa16b | ||
|   | c79d6824e3 | ||
|   | 946377d2be | ||
|   | 5db9a30ad4 | ||
|   | 1d060225e1 | ||
|   | 7e0f0d0fd8 | ||
|   | 8b2afa2220 | ||
|   | f55ffa0f62 | ||
|   | 942c3f021f | ||
|   | 5483f5d694 | ||
|   | f2fa638480 | ||
|   | 82d1a7f73e | ||
|   | 9fc291fb63 | ||
|   | 3e8a15456a | ||
|   | 2a03f3f57e | ||
|   | ffad5cca97 | ||
|   | 60a9a786e0 | ||
|   | 165e950e55 | ||
|   | c25294ca57 | ||
|   | d4359c2e67 | ||
|   | 44fc804991 | ||
|   | b72c9eaf62 | ||
|   | 7ce9e4dfc2 | ||
|   | 3cc6586695 | ||
|   | 09204cb43f | ||
|   | a709122874 | ||
|   | efbeaf9535 | ||
|   | 1a19fba07d | ||
|   | eb9020c175 | ||
|   | 13bb44e4f8 | ||
|   | 47f294c23b | ||
|   | a4cce16188 | ||
|   | 69aec23d1d | ||
|   | f85ccffe0a | ||
|   | 0005131472 | ||
|   | 3be1f4ea44 | ||
|   | 46c72a7fb3 | ||
|   | 96664ffb10 | ||
|   | 615fa2c5b2 | ||
|   | fd45fcce2f | ||
|   | 75ca7ec504 | ||
|   | 8b1e9f6591 | ||
|   | 883aa968fd | ||
|   | 3240ed2339 | ||
|   | a89ffffc76 | ||
|   | fda93c3798 | ||
|   | a51c555964 | ||
|   | b401998030 | ||
|   | 014fda9058 | ||
|   | dd384619e0 | ||
|   | 85715120e2 | ||
|   | a0e4f9b88a | ||
|   | 04bef6091e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 536948c8c6 | ||
|   | d4f4ab306a | ||
|   | 8d2e240a2a | ||
|   | d7ed479ca2 | ||
|   | f25cdf0a67 | ||
|   | 5214a7e0f3 | ||
|   | eb3dca3805 | ||
|   | a580c238b6 | ||
|   | 7ca89f5ec3 | ||
|   | 8ab8aaa6ae | ||
|   | 22ef9afb93 | ||
|   | abaec224f6 | ||
|   | 5a645fb74d | ||
|   | 14db60e518 | ||
|   | e250c552d0 | ||
|   | 8e54a17e14 | ||
|   | 8607eccaad | ||
|   | 17511d0d7d | ||
|   | 41b806228c | ||
|   | 453cf81e1d | ||
|   | 0095b28ea3 | ||
|   | 73101a47e7 | ||
|   | 03f776ca45 | ||
|   | 39b7be9e7a | ||
|   | 6611823962 | ||
|   | c1c453e4fe | ||
|   | 4887180671 | ||
|   | ac7378b7fb | ||
|   | eeba8c864d | ||
|   | abe88192f4 | ||
|   | af8efbb6d2 | ||
|   | bbc2875ef3 | ||
|   | b7ca10ebac | ||
|   | a896493797 | ||
|   | e5fe095f16 | ||
|   | 271181968f | ||
|   | 8206383ee5 | ||
|   | ecfc02ba23 | ||
|   | 3331ccd061 | ||
|   | bd8f389a65 | ||
|   | bc74227635 | ||
|   | 07c60a6acc | ||
|   | 7916faf58b | ||
|   | febb2bbf0d | ||
|   | 59d31bf76f | ||
|   | f87f7077a6 | ||
|   | f166ab1e30 | ||
|   | 55e679e973 | ||
|   | e211ba806f | ||
|   | b33105d576 | ||
|   | b73f5a5c88 | ||
|   | 023951a10e | ||
|   | fbd9ecab62 | ||
|   | b5c1fce136 | ||
|   | 489671dcca | ||
|   | d4dc3466dc | ||
|   | 0439acacbe | ||
|   | 735fc2ac8e | ||
|   | 8a825f0055 | ||
|   | d0ae8b7923 | ||
|   | a504773941 | ||
|   | feb8e6c76c | ||
|   | a37a5038d8 | ||
|   | f1933b786c | ||
|   | d6a6ef2c1d | ||
|   | cf9554b169 | ||
|   | d602cf4646 | ||
|   | dfcae4ee64 | ||
|   | e3bcd8c9bf | ||
|   | c4990fa3f9 | ||
|   | 98461d813e | ||
|   | 8ec17a4c83 | ||
|   | ee708cc395 | ||
|   | 8a670c029a | ||
|   | 9fa5aec01e | ||
|   | 43c9cb8b0c | ||
|   | b6a359d55b | ||
|   | ae5a88beea | ||
|   | a899d338e9 | ||
|   | 7975e8ec2e | ||
|   | ce383bcd04 | ||
|   | 0b0cdb101b | ||
|   | 396509bae8 | ||
|   | 2973f40035 | ||
|   | 067fac862c | ||
|   | 20647ea319 | ||
|   | fafc7fda62 | ||
|   | b1aaf9f277 | ||
|   | 18987aeb23 | ||
|   | 856789a9ba | ||
|   | 2857c7bb77 | ||
|   | df951637c4 | ||
|   | ba6fe076bb | ||
|   | 9815fc2526 | ||
|   | e71dbbe771 | ||
|   | bd222c99c6 | ||
|   | 4b002ad9e0 | ||
|   | fe2ffd6356 | ||
|   | 266bebb5bc | ||
|   | 115ff5bc2e | ||
|   | dd6a24d337 | ||
|   | f0d418d58c | ||
|   | 10d3b09051 | ||
|   | 35d0c74454 | ||
|   | dd450b81ad | ||
|   | 512d76c52b | ||
|   | 5a10acfd09 | ||
|   | a7c09c8990 | ||
|   | 9235eae608 | ||
|   | 5bbd82be79 | ||
|   | 7f8c0fb2fa | ||
|   | 489eedf34e | ||
|   | 3956b3fd68 | ||
|   | 61c1d213d0 | ||
|   | e07f573f64 | ||
|   | ecba130fdb | ||
|   | ff6dc842c0 | ||
|   | 4659993ecf | ||
|   | 0a29b3a582 | ||
|   | c55bf418c5 | ||
|   | 4bbb7d99b6 | ||
|   | a8e92e2226 | ||
|   | c17327633f | ||
|   | 56d1dde7c3 | ||
|   | 6e4ddacaf8 | ||
|   | 3195ffa1c6 | ||
|   | c749d2ee44 | ||
|   | ec94359f3c | ||
|   | 4d0bd58eb1 | ||
|   | 3525f43469 | ||
|   | d70252c1eb | ||
|   | b57b94c63a | ||
|   | 9e914c140e | ||
|   | 5d5ceb2f52 | ||
|   | bc0303c5da | ||
|   | 1240da4a6e | ||
|   | 4267bda853 | ||
|   | db1ff1843c | ||
|   | fe3c20b618 | ||
|   | 2fa93cba3a | ||
|   | 254fbd5a47 | ||
|   | 18f2318572 | ||
|   | 84417fc2b1 | ||
|   | 7f7fc737b3 | ||
|   | 2dc43bdfd3 | ||
|   | 95e39aa727 | ||
|   | 2c71f577e0 | ||
|   | f987d32c72 | ||
|   | cd7df86f54 | ||
|   | cb8fa2583a | ||
|   | 3d3e5db81c | ||
|   | c9860dc55e | ||
|   | dbd5cf117a | ||
|   | e805d6ebe3 | ||
|   | 01f469d91d | ||
|   | e91cab0c6d | ||
|   | 106c3269a6 | ||
|   | 1628602860 | ||
|   | ca0ab50c5e | ||
|   | df0b7bb0fe | ||
|   | fe59ac4986 | ||
|   | 25476bfcb2 | ||
|   | 6901fc493d | ||
|   | c40417ff96 | ||
|   | fd2d938528 | ||
|   | cd20dea590 | ||
|   | f921e98265 | ||
|   | c0e905265c | ||
|   | 5e6a923c35 | ||
|   | 7618081e83 | ||
|   | b903280cd0 | ||
|   | 5b60314e8b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | dfd34d2a5b | ||
|   | 98f6f0c80d | ||
|   | 8c65c60c27 | ||
|   | bd0d9048e7 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3b14be4fef | ||
|   | 05f7e123ed | ||
|   | 54d80ddea0 | ||
|   | b9e0ad052f | ||
|   | f8937e437a | ||
|   | fbe9270528 | ||
|   | 58c3bc371d | ||
|   | 4683b0d120 | ||
|   | 5fb9bbdfa3 | ||
|   | 5883e5b920 | ||
|   | b99957f54a | ||
|   | 21cb7fbca9 | ||
|   | 4ed5d4c2e7 | ||
|   | 8c3163f459 | ||
|   | a11b6daa2e | ||
|   | 642ad5660d | ||
|   | 252d6ee6fd | ||
|   | ba7b6b0f8b | ||
|   | f2094a3010 | ||
|   | b9ed7e2d20 | ||
|   | 6d3962acb6 | ||
|   | 32a0d38025 | ||
|   | df08d51d2a | ||
|   | d87c643e58 | ||
|   | 9e08f326be | ||
|   | 1f821d6e8b | ||
|   | 00fe4d4e41 | ||
|   | f88561e713 | ||
|   | dd193ffcec | ||
|   | 1e39a1b745 | ||
|   | 1084603375 | ||
|   | 3f9d949534 | ||
|   | 684deaed35 | ||
|   | 1b931fef20 | ||
|   | d1976db149 | ||
|   | a8fb17df9a | ||
|   | 8f28c80ef5 | ||
|   | 5a2c534fde | ||
|   | e2304b2ce0 | ||
|   | b87236ea20 | ||
|   | dfbc9bfc53 | ||
|   | f3ba051df4 | ||
|   | affe39ff98 | ||
|   | 0f5d5e6caf | ||
|   | 2a66ac1db0 | ||
|   | 07308eedbd | ||
|   | 750b882546 | ||
|   | 1c09407e24 | ||
|   | 7e87591ae5 | ||
|   | 9e6c2bf3e0 | ||
|   | c396cf8176 | ||
|   | b19a037fac | ||
|   | 5cd4a36896 | ||
|   | aec3531127 | ||
|   | 78434114be | 
| @@ -1,2 +1,31 @@ | ||||
| .git | ||||
| .github | ||||
| # Git | ||||
| .git/ | ||||
| .gitignore | ||||
|  | ||||
| # GitHub | ||||
| .github/ | ||||
|  | ||||
| # Byte-compiled / optimized / DLL files | ||||
| **/__pycache__ | ||||
| **/*.py[cod] | ||||
|  | ||||
| # Caches | ||||
| .mypy_cache/ | ||||
| .pytest_cache/ | ||||
| .ruff_cache/ | ||||
|  | ||||
| # Distribution / packaging | ||||
| build/ | ||||
| dist/ | ||||
| *.egg-info* | ||||
|  | ||||
| # Virtual environment | ||||
| .env | ||||
| .venv/ | ||||
| venv/ | ||||
|  | ||||
| # IntelliJ IDEA | ||||
| .idea/ | ||||
|  | ||||
| # Visual Studio | ||||
| .vscode/ | ||||
|   | ||||
							
								
								
									
										62
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| --- | ||||
| name: Bug report | ||||
| about: Create a bug report, if you don't follow this template, your report will be DELETED | ||||
| title: '' | ||||
| labels: 'triage' | ||||
| assignees: 'dgtlmoon' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED** | ||||
|  | ||||
| This form is only for direct bugs and feature requests todo directly with the software. | ||||
|  | ||||
| Please report watched websites (full URL and _any_ settings) that do not work with changedetection.io as expected [**IN THE DISCUSSION FORUMS**](https://github.com/dgtlmoon/changedetection.io/discussions) or your report will be deleted | ||||
|  | ||||
| CONSIDER TAKING OUT A SUBSCRIPTION FOR A SMALL PRICE PER MONTH, YOU GET THE BENEFIT OF USING OUR PAID PROXIES AND FURTHERING THE DEVELOPMENT OF CHANGEDETECTION.IO | ||||
|  | ||||
| THANK YOU | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| **Describe the bug** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
| **Version** | ||||
| *Exact version* in the top right area: 0.... | ||||
|  | ||||
| **How did you install?** | ||||
|  | ||||
| Docker, Pip, from source directly etc | ||||
|  | ||||
| **To Reproduce** | ||||
|  | ||||
| Steps to reproduce the behavior: | ||||
| 1. Go to '...' | ||||
| 2. Click on '....' | ||||
| 3. Scroll down to '....' | ||||
| 4. See error | ||||
|  | ||||
| ! ALWAYS INCLUDE AN EXAMPLE URL WHERE IT IS POSSIBLE TO RE-CREATE THE ISSUE - USE THE 'SHARE WATCH' FEATURE AND PASTE IN THE SHARE-LINK! | ||||
|  | ||||
| **Expected behavior** | ||||
| A clear and concise description of what you expected to happen. | ||||
|  | ||||
| **Screenshots** | ||||
| If applicable, add screenshots to help explain your problem. | ||||
|  | ||||
| **Desktop (please complete the following information):** | ||||
|  - OS: [e.g. iOS]  | ||||
|  - Browser [e.g. chrome, safari] | ||||
|  - Version [e.g. 22] | ||||
|  | ||||
| **Smartphone (please complete the following information):** | ||||
|  - Device: [e.g. iPhone6] | ||||
|  - OS: [e.g. iOS8.1] | ||||
|  - Browser [e.g. stock browser, safari] | ||||
|  - Version [e.g. 22] | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context about the problem here. | ||||
							
								
								
									
										23
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| --- | ||||
| name: Feature request | ||||
| about: Suggest an idea for this project | ||||
| title: '[feature]' | ||||
| labels: 'enhancement' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
| **Version and OS** | ||||
| For example, 0.123 on linux/docker | ||||
|  | ||||
| **Is your feature request related to a problem? Please describe.** | ||||
| A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] | ||||
|  | ||||
| **Describe the solution you'd like** | ||||
| A clear and concise description of what you want to happen. | ||||
|  | ||||
| **Describe the use-case and give concrete real-world examples** | ||||
| Attach any HTML/JSON, give links to sites, screenshots etc, we are not mind readers | ||||
|  | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context or screenshots about the feature request here. | ||||
							
								
								
									
										14
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| version: 2 | ||||
| updates: | ||||
|   - package-ecosystem: github-actions | ||||
|     directory: / | ||||
|     schedule: | ||||
|       interval: "weekly" | ||||
|     "caronc/apprise": | ||||
|       versioning-strategy: "increase" | ||||
|       schedule: | ||||
|         interval: "daily" | ||||
|     groups: | ||||
|       all: | ||||
|         patterns: | ||||
|         - "*" | ||||
							
								
								
									
										34
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| # Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile | ||||
| # Test that we can still build on Alpine (musl modified libc https://musl.libc.org/) | ||||
| # Some packages wont install via pypi because they dont have a wheel available under this architecture. | ||||
|  | ||||
| FROM ghcr.io/linuxserver/baseimage-alpine:3.21 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN \ | ||||
|  apk add --update --no-cache --virtual=build-dependencies \ | ||||
|     build-base \ | ||||
|     cargo \ | ||||
|     git \ | ||||
|     jpeg-dev \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libxslt-dev \ | ||||
|     openssl-dev \ | ||||
|     python3-dev \ | ||||
|     zip \ | ||||
|     zlib-dev && \ | ||||
|   apk add --update --no-cache \ | ||||
|     libjpeg \ | ||||
|     libxslt \ | ||||
|     nodejs \ | ||||
|     poppler-utils \ | ||||
|     python3 && \ | ||||
|   echo "**** pip3 install test of changedetection.io ****" && \ | ||||
|   python3 -m venv /lsiopy  && \ | ||||
|   pip install -U pip wheel setuptools && \ | ||||
|   pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.21/ -r /requirements.txt && \ | ||||
|   apk del --purge \ | ||||
|     build-dependencies | ||||
							
								
								
									
										8
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -30,11 +30,11 @@ jobs: | ||||
|  | ||||
|     steps: | ||||
|     - name: Checkout repository | ||||
|       uses: actions/checkout@v2 | ||||
|       uses: actions/checkout@v4 | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v1 | ||||
|       uses: github/codeql-action/init@v3 | ||||
|       with: | ||||
|         languages: ${{ matrix.language }} | ||||
|         # If you wish to specify custom queries, you can do so here or in a config file. | ||||
| @@ -45,7 +45,7 @@ jobs: | ||||
|     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). | ||||
|     # If this step fails, then you should remove it and run the build manually (see below) | ||||
|     - name: Autobuild | ||||
|       uses: github/codeql-action/autobuild@v1 | ||||
|       uses: github/codeql-action/autobuild@v3 | ||||
|  | ||||
|     # ℹ️ Command-line programs to run using the OS shell. | ||||
|     # 📚 https://git.io/JvXDl | ||||
| @@ -59,4 +59,4 @@ jobs: | ||||
|     #   make release | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v1 | ||||
|       uses: github/codeql-action/analyze@v3 | ||||
|   | ||||
							
								
								
									
										136
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| name: Build and push containers | ||||
|  | ||||
| on: | ||||
|   # Automatically triggered by a testing workflow passing, but this is only checked when it lands in the `master`/default branch | ||||
| #  workflow_run: | ||||
| #    workflows: ["ChangeDetection.io Test"] | ||||
| #    branches: [master] | ||||
| #    tags: ['0.*'] | ||||
| #    types: [completed] | ||||
|  | ||||
|   # Or a new tagged release | ||||
|   release: | ||||
|     types: [published, edited] | ||||
|  | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|  | ||||
| jobs: | ||||
|   metadata: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: Show metadata | ||||
|       run: | | ||||
|         echo SHA ${{ github.sha }} | ||||
|         echo github.ref:  ${{ github.ref }} | ||||
|         echo github_ref: $GITHUB_REF | ||||
|         echo Event name: ${{ github.event_name }} | ||||
|         echo Ref ${{ github.ref }} | ||||
|         echo c: ${{ github.event.workflow_run.conclusion }} | ||||
|         echo r: ${{ github.event.workflow_run }} | ||||
|         echo tname: "${{ github.event.release.tag_name }}" | ||||
|         echo headbranch: -${{ github.event.workflow_run.head_branch }}- | ||||
|         set | ||||
|  | ||||
|   build-push-containers: | ||||
|     runs-on: ubuntu-latest | ||||
|     # If the testing workflow has a success, then we build to :latest | ||||
|     # Or if we are in a tagged release scenario. | ||||
|     if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != '' | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Set up Python 3.11 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: 3.11 | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           python -m pip install --upgrade pip | ||||
|           pip install flake8 pytest | ||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||
|  | ||||
|       - name: Create release metadata | ||||
|         run: | | ||||
|           # COPY'ed by Dockerfile into changedetectionio/ of the image, then read by the server in store.py | ||||
|           echo ${{ github.sha }} > changedetectionio/source.txt | ||||
|           echo ${{ github.ref }} > changedetectionio/tag.txt | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
|         with: | ||||
|           image: tonistiigi/binfmt:latest | ||||
|           platforms: all | ||||
|  | ||||
|       - name: Login to GitHub Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
|       - name: Login to Docker Hub Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_HUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | ||||
|  | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|         with: | ||||
|           install: true | ||||
|           version: latest | ||||
|           driver-opts: image=moby/buildkit:master | ||||
|  | ||||
|       # master branch -> :dev container tag | ||||
|       - name: Build and push :dev | ||||
|         id: docker_build | ||||
|         if: ${{ github.ref }} == "refs/heads/master" | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: | | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:dev,ghcr.io/${{ github.repository }}:dev | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8 | ||||
|           cache-from: type=gha | ||||
|           cache-to: type=gha,mode=max | ||||
|  | ||||
| # Looks like this was disabled | ||||
| #          provenance: false | ||||
|  | ||||
|       # A new tagged release is required, which builds :tag and :latest | ||||
|       - name: Docker meta :tag | ||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||
|         uses: docker/metadata-action@v5 | ||||
|         id: meta | ||||
|         with: | ||||
|             images: | | ||||
|                 ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io | ||||
|                 ghcr.io/dgtlmoon/changedetection.io | ||||
|             tags: | | ||||
|                 type=semver,pattern={{version}} | ||||
|                 type=semver,pattern={{major}}.{{minor}} | ||||
|                 type=semver,pattern={{major}} | ||||
|  | ||||
|       - name: Build and push :tag | ||||
|         id: docker_build_tag_release | ||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8 | ||||
|           cache-from: type=gha | ||||
|           cache-to: type=gha,mode=max | ||||
| # Looks like this was disabled | ||||
| #          provenance: false | ||||
|  | ||||
|       - name: Image digest | ||||
|         run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }} | ||||
|  | ||||
							
								
								
									
										91
									
								
								.github/workflows/image-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										91
									
								
								.github/workflows/image-tag.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,91 +0,0 @@ | ||||
| name: Test, build and push tagged release to Docker Hub | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - '*.*' | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up Python 3.9 | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|  | ||||
|       - uses: olegtarasov/get-tag@v2.1 | ||||
|         id: tagName | ||||
|  | ||||
| #        with: | ||||
| #          tagRegex: "foobar-(.*)"  # Optional. Returns specified group text as tag name. Full tag string is returned if regex is not defined. | ||||
| #          tagRegexGroup: 1 # Optional. Default is 1. | ||||
|  | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           python -m pip install --upgrade pip | ||||
|           pip install flake8 pytest | ||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           # stop the build if there are Python syntax errors or undefined names | ||||
|           flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics | ||||
|           # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide | ||||
|           flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics | ||||
|  | ||||
|       - name: Create release metadata | ||||
|         run: | | ||||
|           # COPY'ed by Dockerfile into backend/ of the image, then read by the server in store.py | ||||
|           echo ${{ github.sha }} > backend/source.txt | ||||
|           echo ${{ github.ref }} > backend/tag.txt | ||||
|  | ||||
|       - name: Test with pytest | ||||
|         run: | | ||||
|           # Each test is totally isolated and performs its own cleanup/reset | ||||
|           cd backend; ./run_all_tests.sh | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1 | ||||
|         with: | ||||
|           image: tonistiigi/binfmt:latest | ||||
|           platforms: all | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_HUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | ||||
|  | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|         with: | ||||
|           install: true | ||||
|           version: latest | ||||
|           driver-opts: image=moby/buildkit:master | ||||
|  | ||||
|       - name: tag | ||||
|         run : echo ${{ github.event.release.tag_name }} | ||||
|  | ||||
|       - name: Build and push tagged version | ||||
|         id: docker_build | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: | | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ steps.tagName.outputs.tag }} | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||
|           cache-from: type=local,src=/tmp/.buildx-cache | ||||
|           cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|         env: | ||||
|             SOURCE_NAME: ${{ steps.branch_name.outputs.SOURCE_NAME }} | ||||
|             SOURCE_BRANCH: ${{ steps.branch_name.outputs.SOURCE_BRANCH }} | ||||
|             SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} | ||||
|  | ||||
|       - name: Image digest | ||||
|         run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }} | ||||
							
								
								
									
										88
									
								
								.github/workflows/image.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										88
									
								
								.github/workflows/image.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,88 +0,0 @@ | ||||
| name: Test, build and push to Docker Hub | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [ master, arm-build ] | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up Python 3.9 | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           python -m pip install --upgrade pip | ||||
|           pip install flake8 pytest | ||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           # stop the build if there are Python syntax errors or undefined names | ||||
|           flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics | ||||
|           # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide | ||||
|           flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics | ||||
|  | ||||
|       - name: Create release metadata | ||||
|         run: | | ||||
|           # COPY'ed by Dockerfile into backend/ of the image, then read by the server in store.py | ||||
|           echo ${{ github.sha }} > backend/source.txt | ||||
|           echo ${{ github.ref }} > backend/tag.txt | ||||
|  | ||||
|       - name: Test with pytest | ||||
|         run: | | ||||
|           # Each test is totally isolated and performs its own cleanup/reset | ||||
|           cd backend; ./run_all_tests.sh | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1 | ||||
|         with: | ||||
|           image: tonistiigi/binfmt:latest | ||||
|           platforms: all | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_HUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | ||||
|  | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|         with: | ||||
|           install: true | ||||
|           version: latest | ||||
|           driver-opts: image=moby/buildkit:master | ||||
|  | ||||
|       - name: Build and push | ||||
|         id: docker_build | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: | | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest | ||||
|           #                ${{ secrets.DOCKER_HUB_USERNAME }}:/changedetection.io:${{ env.RELEASE_VERSION }} | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 | ||||
| #          platforms: linux/amd64 | ||||
|           cache-from: type=local,src=/tmp/.buildx-cache | ||||
|           cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|  | ||||
|       - name: Image digest | ||||
|         run: echo step SHA ${{ steps.vars.outputs.sha_short }} tag ${{steps.vars.outputs.tag}} branch ${{steps.vars.outputs.branch}} digest ${{ steps.docker_build.outputs.digest }} | ||||
|  | ||||
| # failed: Cache service responded with 503 | ||||
| #      - name: Cache Docker layers | ||||
| #        uses: actions/cache@v2 | ||||
| #        with: | ||||
| #          path: /tmp/.buildx-cache | ||||
| #          key: ${{ runner.os }}-buildx-${{ github.sha }} | ||||
| #          restore-keys: | | ||||
| #            ${{ runner.os }}-buildx- | ||||
|  | ||||
|  | ||||
							
								
								
									
										80
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| name: Publish Python 🐍distribution 📦 to PyPI and TestPyPI | ||||
|  | ||||
| on: push | ||||
| jobs: | ||||
|   build: | ||||
|     name: Build distribution 📦 | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|     - uses: actions/checkout@v4 | ||||
|     - name: Set up Python | ||||
|       uses: actions/setup-python@v5 | ||||
|       with: | ||||
|         python-version: "3.11" | ||||
|     - name: Install pypa/build | ||||
|       run: >- | ||||
|         python3 -m | ||||
|         pip install | ||||
|         build | ||||
|         --user | ||||
|     - name: Build a binary wheel and a source tarball | ||||
|       run: python3 -m build | ||||
|     - name: Store the distribution packages | ||||
|       uses: actions/upload-artifact@v4 | ||||
|       with: | ||||
|         name: python-package-distributions | ||||
|         path: dist/ | ||||
|  | ||||
|  | ||||
|   test-pypi-package: | ||||
|     name: Test the built 📦 package works basically. | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: | ||||
|     - build | ||||
|     steps: | ||||
|     - name: Download all the dists | ||||
|       uses: actions/download-artifact@v4 | ||||
|       with: | ||||
|         name: python-package-distributions | ||||
|         path: dist/ | ||||
|     - name: Set up Python 3.11 | ||||
|       uses: actions/setup-python@v5 | ||||
|       with: | ||||
|         python-version: '3.11' | ||||
|     - name: Test that the basic pip built package runs without error | ||||
|       run: | | ||||
|         set -ex | ||||
|         ls -alR  | ||||
|          | ||||
|         # Find and install the first .whl file | ||||
|         find dist -type f -name "*.whl" -exec pip3 install {} \; -quit | ||||
|         changedetection.io -d /tmp -p 10000 & | ||||
|          | ||||
|         sleep 3 | ||||
|         curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null | ||||
|         curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null | ||||
|         killall changedetection.io | ||||
|  | ||||
|  | ||||
|   publish-to-pypi: | ||||
|     name: >- | ||||
|       Publish Python 🐍 distribution 📦 to PyPI | ||||
|     if: startsWith(github.ref, 'refs/tags/')  # only publish to PyPI on tag pushes | ||||
|     needs: | ||||
|     - test-pypi-package | ||||
|     runs-on: ubuntu-latest | ||||
|     environment: | ||||
|       name: release | ||||
|       url: https://pypi.org/p/changedetection.io | ||||
|     permissions: | ||||
|       id-token: write  # IMPORTANT: mandatory for trusted publishing | ||||
|  | ||||
|     steps: | ||||
|     - name: Download all the dists | ||||
|       uses: actions/download-artifact@v4 | ||||
|       with: | ||||
|         name: python-package-distributions | ||||
|         path: dist/ | ||||
|     - name: Publish distribution 📦 to PyPI | ||||
|       uses: pypa/gh-action-pypi-publish@release/v1 | ||||
							
								
								
									
										70
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| name: ChangeDetection.io Container Build Test | ||||
|  | ||||
| # Triggers the workflow on push or pull request events | ||||
|  | ||||
| # This line doesnt work, even tho it is the documented one | ||||
| #on: [push, pull_request] | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     paths: | ||||
|       - requirements.txt | ||||
|       - Dockerfile | ||||
|       - .github/workflows/* | ||||
|       - .github/test/Dockerfile* | ||||
|  | ||||
|   pull_request: | ||||
|     paths: | ||||
|       - requirements.txt | ||||
|       - Dockerfile | ||||
|       - .github/workflows/* | ||||
|       - .github/test/Dockerfile* | ||||
|  | ||||
|   # Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing | ||||
|   # @todo: some kind of path filter for requirements.txt and Dockerfile | ||||
| jobs: | ||||
|   test-container-build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|         - uses: actions/checkout@v4 | ||||
|         - name: Set up Python 3.11 | ||||
|           uses: actions/setup-python@v5 | ||||
|           with: | ||||
|             python-version: 3.11 | ||||
|  | ||||
|         # Just test that the build works, some libraries won't compile on ARM/rPi etc | ||||
|         - name: Set up QEMU | ||||
|           uses: docker/setup-qemu-action@v3 | ||||
|           with: | ||||
|             image: tonistiigi/binfmt:latest | ||||
|             platforms: all | ||||
|  | ||||
|         - name: Set up Docker Buildx | ||||
|           id: buildx | ||||
|           uses: docker/setup-buildx-action@v3 | ||||
|           with: | ||||
|             install: true | ||||
|             version: latest | ||||
|             driver-opts: image=moby/buildkit:master | ||||
|  | ||||
|         # https://github.com/dgtlmoon/changedetection.io/pull/1067 | ||||
|         # Check we can still build under alpine/musl | ||||
|         - name: Test that the docker containers can build (musl via alpine check) | ||||
|           id: docker_build_musl | ||||
|           uses: docker/build-push-action@v6 | ||||
|           with: | ||||
|             context: ./ | ||||
|             file: ./.github/test/Dockerfile-alpine | ||||
|             platforms: linux/amd64,linux/arm64 | ||||
|  | ||||
|         - name: Test that the docker containers can build | ||||
|           id: docker_build | ||||
|           uses: docker/build-push-action@v6 | ||||
|           # https://github.com/docker/build-push-action#customizing | ||||
|           with: | ||||
|             context: ./ | ||||
|             file: ./Dockerfile | ||||
|             platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8 | ||||
|             cache-from: type=local,src=/tmp/.buildx-cache | ||||
|             cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|  | ||||
							
								
								
									
										57
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										57
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,33 +1,44 @@ | ||||
| name: ChangeDetection.io Test | ||||
| name: ChangeDetection.io App Test | ||||
|  | ||||
| # Triggers the workflow on push or pull request events | ||||
| on: [push, pull_request] | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|   lint-code: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up Python 3.9 | ||||
|         uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|  | ||||
|       - name: Install dependencies | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Lint with Ruff | ||||
|         run: | | ||||
|           python -m pip install --upgrade pip | ||||
|           pip install flake8 pytest | ||||
|           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi | ||||
|           pip install ruff | ||||
|           # Check for syntax errors and undefined names | ||||
|           ruff check . --select E9,F63,F7,F82 | ||||
|           # Complete check with errors treated as warnings | ||||
|           ruff check . --exit-zero | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           # stop the build if there are Python syntax errors or undefined names | ||||
|           flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics | ||||
|           # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide | ||||
|           flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics | ||||
|       - name: Test with pytest | ||||
|         run: | | ||||
|           # Each test is totally isolated and performs its own cleanup/reset | ||||
|           cd backend; ./run_all_tests.sh | ||||
|   test-application-3-10: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.10' | ||||
|  | ||||
|  | ||||
|   test-application-3-11: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.11' | ||||
|  | ||||
|   test-application-3-12: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.12' | ||||
|       skip-pypuppeteer: true | ||||
|  | ||||
|   test-application-3-13: | ||||
|     needs: lint-code | ||||
|     uses: ./.github/workflows/test-stack-reusable-workflow.yml | ||||
|     with: | ||||
|       python-version: '3.13' | ||||
|       skip-pypuppeteer: true | ||||
							
								
								
									
										241
									
								
								.github/workflows/test-stack-reusable-workflow.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								.github/workflows/test-stack-reusable-workflow.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | ||||
| name: ChangeDetection.io App Test | ||||
|  | ||||
| on: | ||||
|   workflow_call: | ||||
|     inputs: | ||||
|       python-version: | ||||
|         description: 'Python version to use' | ||||
|         required: true | ||||
|         type: string | ||||
|         default: '3.11' | ||||
|       skip-pypuppeteer: | ||||
|         description: 'Skip PyPuppeteer (not supported in 3.11/3.12)' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         default: false | ||||
|  | ||||
| jobs: | ||||
|   test-application: | ||||
|     runs-on: ubuntu-latest | ||||
|     env: | ||||
|       PYTHON_VERSION: ${{ inputs.python-version }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       # Mainly just for link/flake8 | ||||
|       - name: Set up Python ${{ env.PYTHON_VERSION }} | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.PYTHON_VERSION }} | ||||
|  | ||||
|       - name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }} | ||||
|         run: | | ||||
|           echo "---- Building for Python ${{ env.PYTHON_VERSION }} -----" | ||||
|           # Build a changedetection.io container and start testing inside | ||||
|           docker build --build-arg PYTHON_VERSION=${{ env.PYTHON_VERSION }} --build-arg LOGGER_LEVEL=TRACE -t test-changedetectionio . | ||||
|           # Debug info | ||||
|           docker run test-changedetectionio  bash -c 'pip list'          | ||||
|  | ||||
|       - name: We should be Python ${{ env.PYTHON_VERSION }} ... | ||||
|         run: |          | ||||
|           docker run test-changedetectionio  bash -c 'python3 --version' | ||||
|  | ||||
|       - name: Spin up ancillary testable services | ||||
|         run: | | ||||
|            | ||||
|           docker network create changedet-network | ||||
|            | ||||
|           # Selenium | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4 | ||||
|            | ||||
|           # SocketPuppetBrowser + Extra for custom browser test | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser --hostname sockpuppetbrowser --rm -p 3000:3000 dgtlmoon/sockpuppetbrowser:latest                     | ||||
|           docker run --network changedet-network -d -e "LOG_LEVEL=TRACE" --cap-add=SYS_ADMIN --name sockpuppetbrowser-custom-url --hostname sockpuppetbrowser-custom-url  -p 3001:3000 --rm dgtlmoon/sockpuppetbrowser:latest | ||||
|  | ||||
|       - name: Spin up ancillary SMTP+Echo message test server | ||||
|         run: | | ||||
|           # Debug SMTP server/echo message back server | ||||
|           docker run --network changedet-network -d -p 11025:11025 -p 11080:11080  --hostname mailserver test-changedetectionio  bash -c 'pip3 install aiosmtpd && python changedetectionio/tests/smtp/smtp-test-server.py' | ||||
|           docker ps | ||||
|  | ||||
|       - name: Show docker container state and other debug info | ||||
|         run: | | ||||
|           set -x | ||||
|           echo "Running processes in docker..." | ||||
|           docker ps | ||||
|  | ||||
|       - name: Run Unit Tests | ||||
|         run: | | ||||
|           # Unit tests | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security' | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver' | ||||
|  | ||||
|       - name: Test built container with Pytest (generally as requests/plaintext fetching) | ||||
|         run: | | ||||
|           # All tests | ||||
|           echo "run test with pytest" | ||||
|           # The default pytest logger_level is TRACE | ||||
|           # To change logger_level for pytest(test/conftest.py), | ||||
|           # append the docker option. e.g. '-e LOGGER_LEVEL=DEBUG' | ||||
|           docker run --name test-cdio-basic-tests --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio && ./run_basic_tests.sh' | ||||
|  | ||||
| # PLAYWRIGHT/NODE-> CDP | ||||
|       - name: Playwright and SocketPuppetBrowser - Specific tests in built container | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch | ||||
|           # tests/visualselector/test_fetch_data.py will do browser steps   | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py' | ||||
|  | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Headers and requests | ||||
|         run: |        | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'find .; cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py; pwd;find .' | ||||
|  | ||||
|       - name: Playwright and SocketPuppetBrowser - Restock detection | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py' | ||||
|  | ||||
| # STRAIGHT TO CDP | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Specific tests in built container | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: | | ||||
|           # Playwright via Sockpuppetbrowser fetch  | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py' | ||||
|           docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: | | ||||
|           # Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm  -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
|       - name: Pyppeteer and SocketPuppetBrowser - Restock detection | ||||
|         if: ${{ inputs.skip-pypuppeteer == false }} | ||||
|         run: |                             | ||||
|           # restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet"  -e "FAST_PUPPETEER_CHROME_FETCHER=True"  -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py' | ||||
|  | ||||
| # SELENIUM | ||||
|       - name: Specific tests in built container for Selenium | ||||
|         run: | | ||||
|           # Selenium fetch | ||||
|           docker run --rm -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py' | ||||
|  | ||||
|       - name: Specific tests in built container for headers and requests checks with Selenium | ||||
|         run: | | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0  --live-server-port=5004 tests/test_request.py' | ||||
|  | ||||
| # OTHER STUFF | ||||
|       - name: Test SMTP notification mime types | ||||
|         run: | | ||||
|           # SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above | ||||
|           # "mailserver" hostname defined above | ||||
|           docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py' | ||||
|  | ||||
|       # @todo Add a test via playwright/puppeteer | ||||
|       # squid with auth is tested in run_proxy_tests.sh -> tests/proxy_list/test_select_custom_proxy.py | ||||
|       - name: Test proxy squid style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test proxy SOCKS5 style interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_socks_proxy_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test custom browser URL | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_custom_browser_url_tests.sh | ||||
|           cd .. | ||||
|  | ||||
|       - name: Test changedetection.io container starts+runs basically without error | ||||
|         run: | | ||||
|           docker run --name test-changedetectionio -p 5556:5000  -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           # Should return 0 (no error) when grep finds it | ||||
|           curl --retry-connrefused --retry 6  -s http://localhost:5556 |grep -q checkbox-uuid | ||||
|            | ||||
|           # and IPv6 | ||||
|           curl --retry-connrefused --retry 6  -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid | ||||
|  | ||||
|           # Check whether TRACE log is enabled. | ||||
|           # Also, check whether TRACE came from STDOUT | ||||
|           docker logs test-changedetectionio 2>/dev/null | grep 'TRACE log is enabled' || exit 1 | ||||
|           # Check whether DEBUG is came from STDOUT | ||||
|           docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1 | ||||
|  | ||||
|           docker kill test-changedetectionio | ||||
|  | ||||
|       - name: Test changedetection.io SIGTERM and SIGINT signal shutdown | ||||
|         run: | | ||||
|            | ||||
|           echo SIGINT Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGINT to sig-test container" | ||||
|           docker kill --signal=SIGINT sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists  | ||||
|           docker rm sig-test | ||||
|            | ||||
|           echo SIGTERM Shutdown request test | ||||
|           docker run --name sig-test -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           echo ">>> Sending SIGTERM to sig-test container" | ||||
|           docker kill --signal=SIGTERM sig-test | ||||
|           sleep 3 | ||||
|           # invert the check (it should be not 0/not running) | ||||
|           docker ps | ||||
|           # check signal catch(STDERR) log. Because of | ||||
|           # changedetectionio/__init__.py: logger.add(sys.stderr, level=logger_level) | ||||
|           docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1 | ||||
|           test -z "`docker ps|grep sig-test`" | ||||
|           if [ $? -ne 0 ] | ||||
|           then | ||||
|             echo "Looks like container was running when it shouldnt be" | ||||
|             docker ps | ||||
|             exit 1 | ||||
|           fi | ||||
|            | ||||
|           # @todo - scan the container log to see the right "graceful shutdown" text exists            | ||||
|           docker rm sig-test | ||||
|  | ||||
|       - name: Dump container log | ||||
|         if: always() | ||||
|         run: | | ||||
|           mkdir output-logs | ||||
|           docker logs test-cdio-basic-tests > output-logs/test-cdio-basic-tests-stdout-${{ env.PYTHON_VERSION }}.txt | ||||
|           docker logs test-cdio-basic-tests 2> output-logs/test-cdio-basic-tests-stderr-${{ env.PYTHON_VERSION }}.txt | ||||
|  | ||||
|       - name: Store everything including test-datastore | ||||
|         if: always() | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }} | ||||
|           path: . | ||||
							
								
								
									
										35
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,7 +1,30 @@ | ||||
| __pycache__ | ||||
| # Byte-compiled / optimized / DLL files | ||||
| **/__pycache__ | ||||
| **/*.py[cod] | ||||
|  | ||||
| # Caches | ||||
| .mypy_cache/ | ||||
| .pytest_cache/ | ||||
| .ruff_cache/ | ||||
|  | ||||
| # Distribution / packaging | ||||
| build/ | ||||
| dist/ | ||||
| *.egg-info* | ||||
|  | ||||
| # Virtual environment | ||||
| .env | ||||
| .venv/ | ||||
| venv/ | ||||
| .python-version | ||||
|  | ||||
| # IDEs | ||||
| .idea | ||||
| *.pyc | ||||
| datastore/url-watches.json | ||||
| datastore/* | ||||
| __pycache__ | ||||
| .pytest_cache | ||||
| .vscode/settings.json | ||||
|  | ||||
| # Datastore files | ||||
| datastore/ | ||||
| test-datastore/ | ||||
|  | ||||
| # Memory consumption log | ||||
| test-memory.log | ||||
|   | ||||
							
								
								
									
										9
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| repos: | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.11.2 | ||||
|     hooks: | ||||
|       # Lint (and apply safe fixes) | ||||
|       - id: ruff | ||||
|         args: [--fix] | ||||
|       # Fomrat | ||||
|       - id: ruff-format | ||||
							
								
								
									
										48
									
								
								.ruff.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								.ruff.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| # Minimum supported version | ||||
| target-version = "py310" | ||||
|  | ||||
| # Formatting options | ||||
| line-length = 100 | ||||
| indent-width = 4 | ||||
|  | ||||
| exclude = [ | ||||
|     "__pycache__", | ||||
|     ".eggs", | ||||
|     ".git", | ||||
|     ".tox", | ||||
|     ".venv", | ||||
|     "*.egg-info", | ||||
|     "*.pyc", | ||||
| ] | ||||
|  | ||||
| [lint] | ||||
| # https://docs.astral.sh/ruff/rules/ | ||||
| select = [ | ||||
|     "B", # flake8-bugbear | ||||
|     "B9", | ||||
|     "C",  | ||||
|     "E", # pycodestyle | ||||
|     "F", # Pyflakes | ||||
|     "I", # isort | ||||
|     "N", # pep8-naming | ||||
|     "UP", # pyupgrade | ||||
|     "W", # pycodestyle | ||||
| ] | ||||
| ignore = [ | ||||
|     "B007", # unused-loop-control-variable | ||||
|     "B909", # loop-iterator-mutation | ||||
|     "E203", # whitespace-before-punctuation | ||||
|     "E266", # multiple-leading-hashes-for-block-comment | ||||
|     "E501", # redundant-backslash | ||||
|     "F403", # undefined-local-with-import-star | ||||
|     "N802", # invalid-function-name | ||||
|     "N806", # non-lowercase-variable-in-function | ||||
|     "N815", # mixed-case-variable-in-class-scope | ||||
| ] | ||||
|  | ||||
| [lint.mccabe] | ||||
| max-complexity = 12 | ||||
|  | ||||
| [format] | ||||
| indent-style = "space" | ||||
| quote-style = "preserve" | ||||
							
								
								
									
										54
									
								
								COMMERCIAL_LICENCE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								COMMERCIAL_LICENCE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| # Generally | ||||
|  | ||||
| In any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this license must be executed and adhered to. | ||||
|  | ||||
| # Commercial License Agreement | ||||
|  | ||||
| This Commercial License Agreement ("Agreement") is entered into by and between Web Technologies s.r.o. here-in ("Licensor") and (your company or personal name) _____________ ("Licensee"). This Agreement sets forth the terms and conditions under which Licensor provides its software ("Software") and services to Licensee for the purpose of reselling the software either in part or full, as part of any commercial activity where the activity involves a third party. | ||||
|  | ||||
| ### Definition of Hosting | ||||
|  | ||||
| For the purposes of this Agreement, "hosting" means making the functionality of the Program or modified version available to third parties as a service. This includes, without limitation: | ||||
| - Enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network. | ||||
| - Offering a service the value of which entirely or primarily derives from the value of the Program or modified version. | ||||
| - Offering a service that accomplishes for users the primary purpose of the Program or modified version. | ||||
|  | ||||
| ## 1. Grant of License | ||||
| Subject to the terms and conditions of this Agreement, Licensor grants Licensee a non-exclusive, non-transferable license to install, use, and resell the Software. Licensee may: | ||||
| - Resell the Software as part of a service offering or as a standalone product. | ||||
| - Host the Software on a server and provide it as a hosted service (e.g., Software as a Service - SaaS). | ||||
| - Integrate the Software into a larger product or service that is then sold or provided for commercial purposes, where the software is used either in part or full. | ||||
|  | ||||
| ## 2. License Fees | ||||
| Licensee agrees to pay Licensor the license fees specified in the ordering document. License fees are due and payable as specified in the ordering document. The fees may include initial licensing costs and recurring fees based on the number of end users, instances of the Software resold, or revenue generated from the resale activities. | ||||
|  | ||||
| ## 3. Resale Conditions | ||||
| Licensee must comply with the following conditions when reselling the Software, whether the software is resold in part or full: | ||||
| - Provide end users with access to the source code under the same open-source license conditions as provided by Licensor. | ||||
| - Clearly state in all marketing and sales materials that the Software is provided under a commercial license from Licensor, and provide a link back to https://changedetection.io. | ||||
| - Ensure end users are aware of and agree to the terms of the commercial license prior to resale. | ||||
| - Do not sublicense or transfer the Software to third parties except as part of an authorized resale activity. | ||||
|  | ||||
| ## 4. Hosting and Provision of Services | ||||
| Licensee may host the Software (either in part or full) on its servers and provide it as a hosted service to end users. The following conditions apply: | ||||
| - Licensee must ensure that all hosted versions of the Software comply with the terms of this Agreement. | ||||
| - Licensee must provide Licensor with regular reports detailing the number of end users and instances of the hosted service. | ||||
| - Any modifications to the Software made by Licensee for hosting purposes must be made available to end users under the same open-source license conditions, unless agreed otherwise. | ||||
|  | ||||
| ## 5. Services | ||||
| Licensor will provide support and maintenance services as described in the support policy referenced in the ordering document should such an agreement be signed by all parties. Additional fees may apply for support services provided to end users resold by Licensee. | ||||
|  | ||||
| ## 6. Reporting and Audits | ||||
| Licensee agrees to provide Licensor with regular reports detailing the number of instances, end users, and revenue generated from the resale of the Software. Licensor reserves the right to audit Licensee’s records to ensure compliance with this Agreement. | ||||
|  | ||||
| ## 7. Term and Termination | ||||
| This Agreement shall commence on the effective date and continue for the period set forth in the ordering document unless terminated earlier in accordance with this Agreement. Either party may terminate this Agreement if the other party breaches any material term and fails to cure such breach within thirty (30) days after receipt of written notice. | ||||
|  | ||||
| ## 8. Limitation of Liability and Disclaimer of Warranty | ||||
| Executing this commercial license does not waive the Limitation of Liability or Disclaimer of Warranty as stated in the open-source LICENSE provided with the Software. The Software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software. | ||||
|  | ||||
| ## 9. Governing Law | ||||
| This Agreement shall be governed by and construed in accordance with the laws of the Czech Republic. | ||||
|  | ||||
| ## Contact Information | ||||
| For commercial licensing inquiries, please contact contact@changedetection.io and dgtlmoon@gmail.com. | ||||
							
								
								
									
										9
									
								
								CONTRIBUTING.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								CONTRIBUTING.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| Contributing is always welcome! | ||||
|  | ||||
| I am no professional flask developer, if you know a better way that something can be done, please let me know! | ||||
|  | ||||
| Otherwise, it's always best to PR into the `master` branch. | ||||
|  | ||||
| Please be sure that all new functionality has a matching test! | ||||
|  | ||||
| Use `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example | ||||
							
								
								
									
										72
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										72
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,56 +1,76 @@ | ||||
| # pip dependencies install stage | ||||
| FROM python:3.8-slim as builder | ||||
|  | ||||
| # rustc compiler would be needed on ARM type devices but theres an issue with some deps not building.. | ||||
| ARG PYTHON_VERSION=3.11 | ||||
|  | ||||
| FROM python:${PYTHON_VERSION}-slim-bookworm AS builder | ||||
|  | ||||
| # See `cryptography` pin comment in requirements.txt | ||||
| ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libssl-dev \ | ||||
|     libffi-dev \ | ||||
|     g++ \ | ||||
|     gcc \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libjpeg-dev \ | ||||
|     libssl-dev \ | ||||
|     libxslt-dev \ | ||||
|     zlib1g-dev \ | ||||
|     g++ | ||||
|      | ||||
|     make \ | ||||
|     zlib1g-dev | ||||
|  | ||||
| RUN mkdir /install | ||||
| WORKDIR /install | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN pip install --target=/dependencies -r /requirements.txt | ||||
| # --extra-index-url https://www.piwheels.org/simple  is for cryptography module to be prebuilt (or rustc etc needs to be installed) | ||||
| RUN pip install --extra-index-url https://www.piwheels.org/simple  --target=/dependencies -r /requirements.txt | ||||
|  | ||||
| # Playwright is an alternative to Selenium | ||||
| # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing | ||||
| # https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported) | ||||
| RUN pip install --target=/dependencies playwright~=1.48.0 \ | ||||
|     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." | ||||
|  | ||||
| # Final image stage | ||||
| FROM python:3.8-slim | ||||
| FROM python:${PYTHON_VERSION}-slim-bookworm | ||||
| LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io" | ||||
|  | ||||
| # Actual packages needed at runtime, usually due to the notification (apprise) backend | ||||
| # rustc compiler would be needed on ARM type devices but theres an issue with some deps not building.. | ||||
| ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1 | ||||
|  | ||||
| # Re #93, #73, excluding rustc (adds another 430Mb~) | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libssl-dev \ | ||||
|     libffi-dev \ | ||||
|     gcc \ | ||||
|     libc-dev \ | ||||
|     libxslt-dev \ | ||||
|     zlib1g-dev \ | ||||
|     g++ | ||||
|     libxslt1.1 \ | ||||
|     # For presenting price amounts correctly in the restock/price detection overview | ||||
|     locales \ | ||||
|     # For pdftohtml | ||||
|     poppler-utils \ | ||||
|     zlib1g \ | ||||
|     && apt-get clean && rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
|  | ||||
| # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| RUN [ ! -d "/datastore" ] && mkdir /datastore | ||||
|  | ||||
| # Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites | ||||
| RUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf | ||||
|  | ||||
| # Copy modules over to the final image and add their dir to PYTHONPATH | ||||
| COPY --from=builder /dependencies /usr/local | ||||
| ENV PYTHONPATH=/usr/local | ||||
|  | ||||
| # The actual flask app | ||||
| COPY backend /app/backend | ||||
| # The eventlet server wrapper | ||||
| EXPOSE 5000 | ||||
|  | ||||
| # The actual flask app module | ||||
| COPY changedetectionio /app/changedetectionio | ||||
| # Starting wrapper | ||||
| COPY changedetection.py /app/changedetection.py | ||||
|  | ||||
| WORKDIR /app | ||||
| # Github Action test purpose(test-only.yml). | ||||
| # On production, it is effectively LOGGER_LEVEL=''. | ||||
| ARG LOGGER_LEVEL='' | ||||
| ENV LOGGER_LEVEL="$LOGGER_LEVEL" | ||||
|  | ||||
| WORKDIR /app | ||||
| CMD ["python", "./changedetection.py", "-d", "/datastore"] | ||||
|  | ||||
|  | ||||
| CMD [ "python", "./changedetection.py" , "-d", "/datastore"] | ||||
|   | ||||
							
								
								
									
										23
									
								
								MANIFEST.in
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								MANIFEST.in
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/content_fetchers * | ||||
| recursive-include changedetectionio/conditions * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/notification * | ||||
| recursive-include changedetectionio/processors * | ||||
| recursive-include changedetectionio/static * | ||||
| recursive-include changedetectionio/templates * | ||||
| recursive-include changedetectionio/tests * | ||||
| prune changedetectionio/static/package-lock.json | ||||
| prune changedetectionio/static/styles/node_modules | ||||
| prune changedetectionio/static/styles/package-lock.json | ||||
| include changedetection.py | ||||
| include requirements.txt | ||||
| include README-pip.md | ||||
| global-exclude *.pyc | ||||
| global-exclude node_modules | ||||
| global-exclude venv | ||||
|  | ||||
| global-exclude test-datastore | ||||
| global-exclude changedetection.io*dist-info | ||||
| global-exclude changedetectionio/tests/proxy_socks5/test-datastore | ||||
							
								
								
									
										99
									
								
								README-pip.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								README-pip.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| ## Web Site Change Detection, Monitoring and Notification. | ||||
|  | ||||
| Live your data-life pro-actively, track website content changes and receive notifications via Discord, Email, Slack, Telegram and 70+ more | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring, list of websites with changes"  title="Self-hosted web page change monitoring, list of websites with changes"  />](https://changedetection.io) | ||||
|  | ||||
|  | ||||
| [**Don't have time? Let us host it for you! try our extremely affordable subscription use our proxies and support!**](https://changedetection.io)  | ||||
|  | ||||
|  | ||||
| ### Target specific parts of the webpage using the Visual Selector tool. | ||||
|  | ||||
| Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (included as part of our subscription service) | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Select parts and elements of a web page to monitor for changes"  title="Select parts and elements of a web page to monitor for changes" />](https://changedetection.io?src=pip) | ||||
|  | ||||
| ### Easily see what changed, examine by word, line, or individual character. | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " />](https://changedetection.io?src=pip) | ||||
|  | ||||
|  | ||||
| ### Perform interactive browser steps | ||||
|  | ||||
| Fill in text boxes, click buttons and more, setup your changedetection scenario.  | ||||
|  | ||||
| Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches. | ||||
|  | ||||
| [<img src="docs/browsersteps-anim.gif" style="max-width:100%;" alt="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more"  title="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" />](https://changedetection.io?src=pip) | ||||
|  | ||||
| After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in. | ||||
| Requires Playwright to be enabled. | ||||
|  | ||||
|  | ||||
| ### Example use cases | ||||
|  | ||||
| - Products and services have a change in pricing | ||||
| - _Out of stock notification_ and _Back In stock notification_ | ||||
| - Monitor and track PDF file changes, know when a PDF file has text changes. | ||||
| - Governmental department updates (changes are often only on their websites) | ||||
| - New software releases, security advisories when you're not on their mailing list. | ||||
| - Festivals with changes | ||||
| - Discogs restock alerts and monitoring | ||||
| - Realestate listing changes | ||||
| - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else | ||||
| - COVID related news from government websites | ||||
| - University/organisation news from their website | ||||
| - Detect and monitor changes in JSON API responses  | ||||
| - JSON API monitoring and alerting | ||||
| - Changes in legal and other documents | ||||
| - Trigger API calls via notifications when text appears on a website | ||||
| - Glue together APIs using the JSON filter and JSON notifications | ||||
| - Create RSS feeds based on changes in web content | ||||
| - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||
| - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||
| - Get notified when certain keywords appear in Twitter search results | ||||
| - Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords. | ||||
| - Get alerts when new job positions are open on Bamboo HR and other job platforms | ||||
| - Website defacement monitoring | ||||
| - Pokémon Card Restock Tracker / Pokémon TCG Tracker | ||||
| - RegTech - stay ahead of regulatory changes, regulatory compliance | ||||
|  | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||
|  | ||||
| #### Key Features | ||||
|  | ||||
| - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! | ||||
| - Target elements with xPath(1.0) and CSS Selectors, Easily monitor complex JSON with JSONPath or jq | ||||
| - Switch between fast non-JS and Chrome JS based "fetchers" | ||||
| - Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums) | ||||
| - Easily specify how often a site should be checked | ||||
| - Execute JS before extracting text (Good for logging in, see examples in the UI!) | ||||
| - Override Request Headers, Specify `POST` or `GET` and other methods | ||||
| - Use the "Visual Selector" to help target specific elements | ||||
| - Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) | ||||
| - Send a screenshot with the notification when a change is detected in the web page | ||||
|  | ||||
| We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link. | ||||
|  | ||||
| [Oxylabs](https://oxylabs.go2cloud.org/SH2d) is also an excellent proxy provider and well worth using, they offer Residental, ISP, Rotating and many other proxy types to suit your project.  | ||||
|  | ||||
| Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | ||||
|  | ||||
|  | ||||
|  | ||||
| ```bash | ||||
| $ pip3 install changedetection.io | ||||
| ``` | ||||
|  | ||||
| Specify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`) | ||||
|  | ||||
| ```bash | ||||
| $ changedetection.io -d /path/to/empty/data/dir -p 5000 | ||||
| ``` | ||||
|  | ||||
|  | ||||
| Then visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||
|  | ||||
| See https://changedetection.io for more information. | ||||
|  | ||||
							
								
								
									
										335
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										335
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,74 +1,194 @@ | ||||
| #  changedetection.io | ||||
| ## Web Site Change Detection, Restock monitoring and notifications. | ||||
|  | ||||
| **_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._** | ||||
|  | ||||
| _Live your data-life pro-actively._  | ||||
|  | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot.png" style="max-width:100%;" alt="Self-hosted web site page change monitoring"  title="Self-hosted web site page change monitoring"  />](https://changedetection.io?src=github) | ||||
|  | ||||
| [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) | ||||
|  | ||||
|  | ||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> | ||||
|   <img src="https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io" alt="Docker Pulls"/> | ||||
| </a> | ||||
| <a href="https://hub.docker.com/r/dgtlmoon/changedetection.io" target="_blank" title="Change detection docker hub"> | ||||
|   <img src="https://img.shields.io/github/v/release/dgtlmoon/changedetection.io" alt="Change detection latest tag version"/>  | ||||
| </a> | ||||
|  | ||||
| ## Self-hosted change monitoring of web pages. | ||||
| [**Get started with website page change monitoring straight away. Don't have time? Try our $8.99/month subscription, use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_ | ||||
|  | ||||
| _Know when web pages change! Stay ontop of new information!_  | ||||
| - Chrome browser included. | ||||
| - Nothing to install, access via browser login after signup. | ||||
| - Super fast, no registration needed setup. | ||||
| - Get started watching and receiving website change notifications straight away. | ||||
| - See our [tutorials and how-to page for more inspiration](https://changedetection.io/tutorials)  | ||||
|  | ||||
| Live your data-life *pro-actively* instead of *re-actively*, do not rely on manipulative social media for consuming important information. | ||||
| ### Target specific parts of the webpage using the Visual Selector tool. | ||||
|  | ||||
| Available when connected to a <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher">playwright content fetcher</a> (included as part of our subscription service) | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/visualselector-anim.gif" style="max-width:100%;" alt="Select parts and elements of a web page to monitor for changes"  title="Select parts and elements of a web page to monitor for changes" />](https://changedetection.io?src=github) | ||||
|  | ||||
| ### Easily see what changed, examine by word, line, or individual character. | ||||
|  | ||||
| [<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " />](https://changedetection.io?src=github) | ||||
|  | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot.png" style="max-width:100%;" alt="Self-hosted web page change monitoring"  title="Self-hosted web page change monitoring"  /> | ||||
| ### Perform interactive browser steps | ||||
|  | ||||
| #### Example use cases | ||||
| Fill in text boxes, click buttons and more, setup your changedetection scenario.  | ||||
|  | ||||
| Know when ... | ||||
| Using the **Browser Steps** configuration, add basic steps before performing change detection, such as logging into websites, adding a product to a cart, accept cookie logins, entering dates and refining searches. | ||||
|  | ||||
| - Government department updates (changes are often only on their websites) | ||||
| - Local government news (changes are often only on their websites) | ||||
| [<img src="docs/browsersteps-anim.gif" style="max-width:100%;" alt="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more"  title="Website change detection with interactive browser steps, detect changes behind login and password, search queries and more" />](https://changedetection.io?src=github) | ||||
|  | ||||
| After **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in. | ||||
| Requires Playwright to be enabled. | ||||
|  | ||||
| ### Awesome restock and price change notifications | ||||
|  | ||||
| Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing, this will extract any meta-data in the HTML page and give you many options to follow the pricing of the product. | ||||
|  | ||||
| Easily organise and monitor prices for products from the dashboard, get alerts and notifications when the price of a product changes or comes back in stock again! | ||||
|  | ||||
| [<img src="docs/restock-overview.png" style="max-width:100%;" alt="Easily keep an eye on product price changes directly from the UI"  title="Easily keep an eye on product price changes directly from the UI" />](https://changedetection.io?src=github) | ||||
|  | ||||
| Set price change notification parameters, upper and lower price, price change percentage and more. | ||||
| Always know when a product for sale drops in price. | ||||
|  | ||||
| [<img src="docs/restock-settings.png" style="max-width:100%;" alt="Set upper lower and percentage price change notification values"  title="Set upper lower and percentage price change notification values" />](https://changedetection.io?src=github) | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Example use cases | ||||
|  | ||||
| - Products and services have a change in pricing | ||||
| - _Out of stock notification_ and _Back In stock notification_ | ||||
| - Monitor and track PDF file changes, know when a PDF file has text changes. | ||||
| - Governmental department updates (changes are often only on their websites) | ||||
| - New software releases, security advisories when you're not on their mailing list. | ||||
| - Festivals with changes | ||||
| - Discogs restock alerts and monitoring | ||||
| - Realestate listing changes | ||||
| - Know when your favourite whiskey is on sale, or other special deals are announced before anyone else | ||||
| - COVID related news from government websites | ||||
| - University/organisation news from their website | ||||
| - Detect and monitor changes in JSON API responses  | ||||
| - API monitoring and alerting | ||||
| - JSON API monitoring and alerting | ||||
| - Changes in legal and other documents | ||||
| - Trigger API calls via notifications when text appears on a website | ||||
| - Glue together APIs using the JSON filter and JSON notifications | ||||
| - Create RSS feeds based on changes in web content | ||||
| - Monitor HTML source code for unexpected changes, strengthen your PCI compliance | ||||
| - You have a very sensitive list of URLs to watch and you do _not_ want to use the paid alternatives. (Remember, _you_ are the product) | ||||
| - Get notified when certain keywords appear in Twitter search results | ||||
| - Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords. | ||||
| - Get alerts when new job positions are open on Bamboo HR and other job platforms | ||||
| - Website defacement monitoring | ||||
| - Pokémon Card Restock Tracker / Pokémon TCG Tracker | ||||
| - RegTech - stay ahead of regulatory changes, regulatory compliance | ||||
|  | ||||
| _Need an actual Chrome runner with Javascript support? see the experimental <a href="https://github.com/dgtlmoon/changedetection.io/tree/javascript-browser">Javascript/Chrome support changedetection.io branch!</a>_ | ||||
| _Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_ | ||||
|  | ||||
| **Get monitoring now! super simple, one command!** | ||||
| Run the python code on your own machine by cloning this repository, or with <a href="https://docs.docker.com/get-docker/">docker</a> and/or <a href="https://www.digitalocean.com/community/tutorial_collections/how-to-install-docker-compose">docker-compose</a> | ||||
| #### Key Features | ||||
|  | ||||
| With one docker-compose command | ||||
| - Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions! | ||||
| - Target elements with xPath 1 and xPath 2, CSS Selectors, Easily monitor complex JSON with JSONPath or jq | ||||
| - Switch between fast non-JS and Chrome JS based "fetchers" | ||||
| - Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums) | ||||
| - Easily specify how often a site should be checked | ||||
| - Execute JS before extracting text (Good for logging in, see examples in the UI!) | ||||
| - Override Request Headers, Specify `POST` or `GET` and other methods | ||||
| - Use the "Visual Selector" to help target specific elements | ||||
| - Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration) | ||||
| - Send a screenshot with the notification when a change is detected in the web page | ||||
|  | ||||
| ```bash | ||||
| docker-compose up -d | ||||
| ``` | ||||
| We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) global proxy services, Bright Data will match any first deposit up to $100 using our signup link. | ||||
|  | ||||
| or | ||||
|  | ||||
|  | ||||
| ```bash | ||||
| docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ```   | ||||
|  | ||||
| Now visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||
|  | ||||
| #### Updating to latest version | ||||
|  | ||||
| Highly recommended :) | ||||
|  | ||||
| ```bash | ||||
| docker pull dgtlmoon/changedetection.io | ||||
| docker kill $(docker ps -a|grep changedetection.io|awk '{print $1}') | ||||
| docker rm $(docker ps -a|grep changedetection.io|awk '{print $1}') | ||||
| docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ``` | ||||
|    | ||||
| ### Screenshots | ||||
|  | ||||
| Examining differences in content. | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot-diff.png" style="max-width:100%;" alt="Self-hosted web page change monitoring context difference "  title="Self-hosted web page change monitoring context difference " /> | ||||
| [Oxylabs](https://oxylabs.go2cloud.org/SH2d) is also an excellent proxy provider and well worth using, they offer Residental, ISP, Rotating and many other proxy types to suit your project.  | ||||
|  | ||||
| Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | ||||
|  | ||||
| ### Notifications | ||||
| ### Conditional web page changes | ||||
|  | ||||
| Easily [configure conditional actions](https://changedetection.io/tutorial/conditional-actions-web-page-changes), for example, only trigger when a price is above or below a preset amount, or [when a web page includes (or does not include) a keyword](https://changedetection.io/tutorial/how-monitor-keywords-any-website) | ||||
|  | ||||
| <img src="./docs/web-page-change-conditions.png" style="max-width:80%;" alt="Conditional web page changes"  title="Conditional web page changes"  /> | ||||
|  | ||||
| ### Schedule web page watches in any timezone, limit by day of week and time. | ||||
|  | ||||
| Easily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours. | ||||
| Or perhaps based on a foreign timezone (for example, you want to check for the latest news-headlines in a foreign country at 0900 AM), | ||||
|  | ||||
| <img src="./docs/scheduler.png" style="max-width:80%;" alt="How to monitor web page changes according to a schedule"  title="How to monitor web page changes according to a schedule"  /> | ||||
|  | ||||
| Includes quick short-cut buttons to setup a schedule for **business hours only**, or **weekends**. | ||||
|  | ||||
| ### We have a Chrome extension! | ||||
|  | ||||
| Easily add the current web page to your changedetection.io tool, simply install the extension and click "Sync" to connect it to your existing changedetection.io install. | ||||
|  | ||||
| [<img src="./docs/chrome-extension-screenshot.png" style="max-width:80%;" alt="Chrome Extension to easily add the current web-page to detect a change."  title="Chrome Extension to easily add the current web-page to detect a change."  />](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) | ||||
|  | ||||
| [Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) ( Or check out the [GitHub repo](https://github.com/dgtlmoon/changedetection.io-browser-extension) )  | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| ### Docker | ||||
|  | ||||
| With Docker composer, just clone this repository and.. | ||||
|  | ||||
| ```bash | ||||
| $ docker compose up -d | ||||
| ``` | ||||
|  | ||||
| Docker standalone | ||||
| ```bash | ||||
| $ docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ``` | ||||
|  | ||||
| `:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch. | ||||
|  | ||||
| Alternative docker repository over at ghcr - [ghcr.io/dgtlmoon/changedetection.io](https://ghcr.io/dgtlmoon/changedetection.io) | ||||
|  | ||||
| ### Windows | ||||
|  | ||||
| See the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows | ||||
|  | ||||
| ### Python Pip | ||||
|  | ||||
| Check out our pypi page https://pypi.org/project/changedetection.io/ | ||||
|  | ||||
| ```bash | ||||
| $ pip3 install changedetection.io | ||||
| $ changedetection.io -d /path/to/empty/data/dir -p 5000 | ||||
| ``` | ||||
|  | ||||
| Then visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||
|  | ||||
| _Now with per-site configurable support for using a fast built in HTTP fetcher or use a Chrome based fetcher for monitoring of JavaScript websites!_ | ||||
|  | ||||
| ## Updating changedetection.io | ||||
|  | ||||
| ### Docker | ||||
| ``` | ||||
| docker pull dgtlmoon/changedetection.io | ||||
| docker kill $(docker ps -a -f name=changedetection.io -q) | ||||
| docker rm $(docker ps -a -f name=changedetection.io -q) | ||||
| docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ``` | ||||
|  | ||||
| ### docker compose | ||||
|  | ||||
| ```bash | ||||
| docker compose pull && docker compose up -d | ||||
| ``` | ||||
|  | ||||
| See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
|  | ||||
| ## Filters | ||||
|  | ||||
| XPath(1.0), JSONPath, jq, and CSS support comes baked in! You can be as specific as you need, use XPath exported from various XPath element query creation tools.  | ||||
| (We support LXML `re:test`, `re:match` and `re:replace`.) | ||||
|  | ||||
| ## Notifications | ||||
|  | ||||
| ChangeDetection.io supports a massive amount of notifications (including email, office365, custom APIs, etc) when a web-page has a change detected thanks to the <a href="https://github.com/caronc/apprise">apprise</a> library. | ||||
| Simply set one or more notification URL's in the _[edit]_ tab of that watch. | ||||
| @@ -86,60 +206,111 @@ Just some examples | ||||
|     json://someserver.com/custom-api | ||||
|     syslog:// | ||||
|   | ||||
| <a href="https://github.com/caronc/apprise">And everything else in this list!</a> | ||||
| <a href="https://github.com/caronc/apprise#popular-notification-services">And everything else in this list!</a> | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/screenshot-notifications.png" style="max-width:100%;" alt="Self-hosted web page change monitoring notifications"  title="Self-hosted web page change monitoring notifications"  /> | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/screenshot-notifications.png" style="max-width:100%;" alt="Self-hosted web page change monitoring notifications"  title="Self-hosted web page change monitoring notifications"  /> | ||||
|  | ||||
| Now you can also customise your notification content! | ||||
| Now you can also customise your notification content and use <a target="_new" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2 templating</a> for their title and body! | ||||
|  | ||||
| ### JSON API Monitoring | ||||
| ## JSON API Monitoring | ||||
|  | ||||
| Detect changes and monitor data in JSON API's by using the built-in JSONPath selectors as a filter / selector. | ||||
| Detect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed. | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| This will re-parse the JSON and apply formatting to the text, making it super easy to monitor and detect changes in JSON API results | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Proxy | ||||
| ### JSONPath or jq? | ||||
|  | ||||
| A proxy for ChangeDetection.io can be configured by setting environment the  | ||||
| `HTTP_PROXY`, `HTTPS_PROXY` variables, examples are also in the `docker-compose.yml` | ||||
| For more complex parsing, filtering, and modifying of JSON data, jq is recommended due to the built-in operators and functions. Refer to the [documentation](https://stedolan.github.io/jq/manual/) for more specific information on jq. | ||||
|  | ||||
| `NO_PROXY` exclude list can be specified by following `"localhost,192.168.0.0/24"` | ||||
| One big advantage of `jq` is that you can use logic in your JSON filter, such as filters to only show items that have a value greater than/less than etc. | ||||
|  | ||||
| as `docker run` with `-e` | ||||
| See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/JSON-Selector-Filter-help for more information and examples | ||||
|  | ||||
| ### Parse JSON embedded in HTML! | ||||
|  | ||||
| When you enable a `json:` or `jq:` filter, you can even automatically extract and parse embedded JSON inside a HTML page! Amazingly handy for sites that build content based on JSON, such as many e-commerce websites.  | ||||
|  | ||||
| ``` | ||||
| docker run -d --restart always -e HTTPS_PROXY="socks5h://10.10.1.10:1080" -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ``` | ||||
| <html> | ||||
| ... | ||||
| <script type="application/ld+json"> | ||||
|  | ||||
| With `docker-compose`, see the `Proxy support example` in <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a>. | ||||
| { | ||||
|    "@context":"http://schema.org/", | ||||
|    "@type":"Product", | ||||
|    "offers":{ | ||||
|       "@type":"Offer", | ||||
|       "availability":"http://schema.org/InStock", | ||||
|       "price":"3949.99", | ||||
|       "priceCurrency":"USD", | ||||
|       "url":"https://www.newegg.com/p/3D5-000D-001T1" | ||||
|    }, | ||||
|    "description":"Cobratype King Cobra Hero Desktop Gaming PC", | ||||
|    "name":"Cobratype King Cobra Hero Desktop Gaming PC", | ||||
|    "sku":"3D5-000D-001T1", | ||||
|    "itemCondition":"NewCondition" | ||||
| } | ||||
| </script> | ||||
| ```   | ||||
|  | ||||
| For more information see https://docs.python-requests.org/en/master/user/advanced/#proxies | ||||
| `json:$..price` or `jq:..price` would give `3949.99`, or you can extract the whole structure (use a JSONpath test website to validate with) | ||||
|  | ||||
| This proxy support also extends to the notifications https://github.com/caronc/apprise/issues/387#issuecomment-841718867 | ||||
|  | ||||
| ### Notes | ||||
|  | ||||
| - ~~Does not yet support Javascript~~ | ||||
| - ~~Wont work with Cloudfare type "Please turn on javascript" protected pages~~ | ||||
| - You can use the 'headers' section to monitor password protected web page changes | ||||
|  | ||||
| See the experimental <a href="https://github.com/dgtlmoon/changedetection.io/tree/javascript-browser">Javascript/Chrome browser support!</a> | ||||
|  | ||||
| ### RaspberriPi support? | ||||
|  | ||||
| RaspberriPi and linux/arm/v6 linux/arm/v7 arm64 devices are supported!  | ||||
| The application also supports notifying you that it can follow this information automatically | ||||
|  | ||||
|  | ||||
| ### Support us | ||||
| ## Proxy Configuration | ||||
|  | ||||
| See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration , we also support using [Bright Data proxy services where possible](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support) and [Oxylabs](https://oxylabs.go2cloud.org/SH2d) proxy services. | ||||
|  | ||||
| ## Raspberry Pi support? | ||||
|  | ||||
| Raspberry Pi and linux/arm/v6 linux/arm/v7 arm64 devices are supported! See the wiki for [details](https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver) | ||||
|  | ||||
| ## Import support | ||||
|  | ||||
| Easily [import your list of websites to watch for changes in Excel .xslx file format](https://changedetection.io/tutorial/how-import-your-website-change-detection-lists-excel), or paste in lists of website URLs as plaintext.  | ||||
|  | ||||
| Excel import is recommended - that way you can better organise tags/groups of websites and other features. | ||||
|  | ||||
|  | ||||
| ## API Support | ||||
|  | ||||
| Supports managing the website watch list [via our API](https://changedetection.io/docs/api_v1/index.html) | ||||
|  | ||||
| ## Support us | ||||
|  | ||||
| Do you use changedetection.io to make money? does it save you time or money? Does it make your life easier? less stressful? Remember, we write this software when we should be doing actual paid work, we have to buy food and pay rent just like you. | ||||
|  | ||||
| Please support us, even small amounts help a LOT. | ||||
|  | ||||
| BTC `1PLFN327GyUarpJd7nVe7Reqg9qHx5frNn` | ||||
| Consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!) | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/btc-support.png" style="max-width:50%;" alt="Support us!"  /> | ||||
| ## Commercial Support | ||||
|  | ||||
| I offer commercial support, this software is depended on by network security, aerospace , data-science and data-journalist professionals just to name a few, please reach out at dgtlmoon@gmail.com for any enquiries, I am more than glad to work with your organisation to further the possibilities of what can be done with changedetection.io | ||||
|  | ||||
|  | ||||
| [release-shield]: https://img.shields.io:/github/v/release/dgtlmoon/changedetection.io?style=for-the-badge | ||||
| [docker-pulls]: https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io?style=for-the-badge | ||||
| [test-shield]: https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master | ||||
|  | ||||
| [license-shield]: https://img.shields.io/github/license/dgtlmoon/changedetection.io.svg?style=for-the-badge | ||||
| [release-link]: https://github.com/dgtlmoon/changedetection.io/releases | ||||
| [docker-link]: https://hub.docker.com/r/dgtlmoon/changedetection.io | ||||
|  | ||||
| ## Commercial Licencing | ||||
|  | ||||
| If you are reselling this software either in part or full as part of any commercial arrangement, you must abide by our COMMERCIAL_LICENCE.md found in our code repository, please contact dgtlmoon@gmail.com and contact@changedetection.io . | ||||
|  | ||||
| ## Third-party licenses | ||||
|  | ||||
| changedetectionio.html_tools.elementpath_tostring: Copyright (c), 2018-2021, SISSA (Scuola Internazionale Superiore di Studi Avanzati), Licensed under [MIT license](https://github.com/sissaschool/elementpath/blob/master/LICENSE) | ||||
|  | ||||
| ## Contributors | ||||
|  | ||||
| Recognition of fantastic contributors to the project | ||||
|  | ||||
| - Constantin Hong https://github.com/Constantin1489 | ||||
|   | ||||
| @@ -1,880 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
|  | ||||
| # @todo logging | ||||
| # @todo extra options for url like , verify=False etc. | ||||
| # @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option? | ||||
| # @todo option for interval day/6 hour/etc | ||||
| # @todo on change detected, config for calling some API | ||||
| # @todo fetch title into json | ||||
| # https://distill.io/features | ||||
| # proxy per check | ||||
| #  - flask_cors, itsdangerous,MarkupSafe | ||||
|  | ||||
| import time | ||||
| import os | ||||
| import timeago | ||||
| import flask_login | ||||
| from flask_login import login_required | ||||
|  | ||||
| import threading | ||||
| from threading import Event | ||||
|  | ||||
| import queue | ||||
|  | ||||
| from flask import Flask, render_template, request, send_from_directory, abort, redirect, url_for, flash | ||||
|  | ||||
| from feedgen.feed import FeedGenerator | ||||
| from flask import make_response | ||||
| import datetime | ||||
| import pytz | ||||
|  | ||||
| datastore = None | ||||
|  | ||||
| # Local | ||||
| running_update_threads = [] | ||||
| ticker_thread = None | ||||
|  | ||||
| extra_stylesheets = [] | ||||
|  | ||||
| update_q = queue.Queue() | ||||
|  | ||||
| notification_q = queue.Queue() | ||||
|  | ||||
| app = Flask(__name__, static_url_path="/var/www/change-detection/backend/static") | ||||
|  | ||||
| # Stop browser caching of assets | ||||
| app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 | ||||
|  | ||||
| app.config.exit = Event() | ||||
|  | ||||
| app.config['NEW_VERSION_AVAILABLE'] = False | ||||
|  | ||||
| app.config['LOGIN_DISABLED'] = False | ||||
|  | ||||
| #app.config["EXPLAIN_TEMPLATE_LOADING"] = True | ||||
|  | ||||
| # Disables caching of the templates | ||||
| app.config['TEMPLATES_AUTO_RELOAD'] = True | ||||
|  | ||||
|  | ||||
| def init_app_secret(datastore_path): | ||||
|     secret = "" | ||||
|  | ||||
|     path = "{}/secret.txt".format(datastore_path) | ||||
|  | ||||
|     try: | ||||
|         with open(path, "r") as f: | ||||
|             secret = f.read() | ||||
|  | ||||
|     except FileNotFoundError: | ||||
|         import secrets | ||||
|         with open(path, "w") as f: | ||||
|             secret = secrets.token_hex(32) | ||||
|             f.write(secret) | ||||
|  | ||||
|     return secret | ||||
|  | ||||
| # Remember python is by reference | ||||
| # populate_form in wtfors didnt work for me. (try using a setattr() obj type on datastore.watch?) | ||||
| def populate_form_from_watch(form, watch): | ||||
|     for i in form.__dict__.keys(): | ||||
|         if i[0] != '_': | ||||
|             p = getattr(form, i) | ||||
|             if hasattr(p, 'data') and i in watch: | ||||
|                 if not p.data: | ||||
|                     setattr(p, "data", watch[i]) | ||||
|  | ||||
|  | ||||
| # We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread | ||||
| # running or something similar. | ||||
| @app.template_filter('format_last_checked_time') | ||||
| def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"): | ||||
|     # Worker thread tells us which UUID it is currently processing. | ||||
|     for t in running_update_threads: | ||||
|         if t.current_uuid == watch_obj['uuid']: | ||||
|             return "Checking now.." | ||||
|  | ||||
|     if watch_obj['last_checked'] == 0: | ||||
|         return 'Not yet' | ||||
|  | ||||
|     return timeago.format(int(watch_obj['last_checked']), time.time()) | ||||
|  | ||||
|  | ||||
| # @app.context_processor | ||||
| # def timeago(): | ||||
| #    def _timeago(lower_time, now): | ||||
| #        return timeago.format(lower_time, now) | ||||
| #    return dict(timeago=_timeago) | ||||
|  | ||||
| @app.template_filter('format_timestamp_timeago') | ||||
| def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"): | ||||
|     return timeago.format(timestamp, time.time()) | ||||
|     # return timeago.format(timestamp, time.time()) | ||||
|     # return datetime.datetime.utcfromtimestamp(timestamp).strftime(format) | ||||
|  | ||||
|  | ||||
| class User(flask_login.UserMixin): | ||||
|     id=None | ||||
|  | ||||
|     def set_password(self, password): | ||||
|         return True | ||||
|     def get_user(self, email="defaultuser@changedetection.io"): | ||||
|         return self | ||||
|     def is_authenticated(self): | ||||
|  | ||||
|         return True | ||||
|     def is_active(self): | ||||
|         return True | ||||
|     def is_anonymous(self): | ||||
|         return False | ||||
|     def get_id(self): | ||||
|         return str(self.id) | ||||
|  | ||||
|     def check_password(self, password): | ||||
|  | ||||
|         import hashlib | ||||
|         import base64 | ||||
|  | ||||
|         # Getting the values back out | ||||
|         raw_salt_pass = base64.b64decode(datastore.data['settings']['application']['password']) | ||||
|         salt_from_storage = raw_salt_pass[:32]  # 32 is the length of the salt | ||||
|  | ||||
|         # Use the exact same setup you used to generate the key, but this time put in the password to check | ||||
|         new_key = hashlib.pbkdf2_hmac( | ||||
|             'sha256', | ||||
|             password.encode('utf-8'),  # Convert the password to bytes | ||||
|             salt_from_storage, | ||||
|             100000 | ||||
|         ) | ||||
|         new_key =  salt_from_storage + new_key | ||||
|  | ||||
|         return new_key == raw_salt_pass | ||||
|  | ||||
|     pass | ||||
|  | ||||
| def changedetection_app(config=None, datastore_o=None): | ||||
|     global datastore | ||||
|     datastore = datastore_o | ||||
|  | ||||
|     app.config.update(dict(DEBUG=True)) | ||||
|     #app.config.update(config or {}) | ||||
|  | ||||
|     login_manager = flask_login.LoginManager(app) | ||||
|     login_manager.login_view = 'login' | ||||
|     app.secret_key = init_app_secret(config['datastore_path']) | ||||
|  | ||||
|     # Setup cors headers to allow all domains | ||||
|     # https://flask-cors.readthedocs.io/en/latest/ | ||||
|     #    CORS(app) | ||||
|  | ||||
|     @login_manager.user_loader | ||||
|     def user_loader(email): | ||||
|         user = User() | ||||
|         user.get_user(email) | ||||
|         return user | ||||
|  | ||||
|     @login_manager.unauthorized_handler | ||||
|     def unauthorized_handler(): | ||||
|         # @todo validate its a URL of this host and use that | ||||
|         return redirect(url_for('login', next=url_for('index'))) | ||||
|  | ||||
|     @app.route('/logout') | ||||
|     def logout(): | ||||
|         flask_login.logout_user() | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     # https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39 | ||||
|     # You can divide up the stuff like this | ||||
|     @app.route('/login', methods=['GET', 'POST']) | ||||
|     def login(): | ||||
|  | ||||
|         if not datastore.data['settings']['application']['password']: | ||||
|             flash("Login not required, no password enabled.", "notice") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         if request.method == 'GET': | ||||
|             output = render_template("login.html") | ||||
|             return output | ||||
|  | ||||
|         user = User() | ||||
|         user.id = "defaultuser@changedetection.io" | ||||
|  | ||||
|         password = request.form.get('password') | ||||
|  | ||||
|         if (user.check_password(password)): | ||||
|             flask_login.login_user(user, remember=True) | ||||
|             next = request.args.get('next') | ||||
|             #            if not is_safe_url(next): | ||||
|             #                return flask.abort(400) | ||||
|             return redirect(next or url_for('index')) | ||||
|  | ||||
|         else: | ||||
|             flash('Incorrect password', 'error') | ||||
|  | ||||
|         return redirect(url_for('login')) | ||||
|  | ||||
|     @app.before_request | ||||
|     def do_something_whenever_a_request_comes_in(): | ||||
|         # Disable password  loginif there is not one set | ||||
|         app.config['LOGIN_DISABLED'] = datastore.data['settings']['application']['password'] == False | ||||
|  | ||||
|     @app.route("/", methods=['GET']) | ||||
|     @login_required | ||||
|     def index(): | ||||
|         limit_tag = request.args.get('tag') | ||||
|         pause_uuid = request.args.get('pause') | ||||
|  | ||||
|         if pause_uuid: | ||||
|             try: | ||||
|                 datastore.data['watching'][pause_uuid]['paused'] ^= True | ||||
|                 datastore.needs_write = True | ||||
|  | ||||
|                 return redirect(url_for('index', tag = limit_tag)) | ||||
|             except KeyError: | ||||
|                 pass | ||||
|  | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
|         sorted_watches = [] | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|  | ||||
|             if limit_tag != None: | ||||
|                 # Support for comma separated list of tags. | ||||
|                 for tag_in_watch in watch['tag'].split(','): | ||||
|                     tag_in_watch = tag_in_watch.strip() | ||||
|                     if tag_in_watch == limit_tag: | ||||
|                         watch['uuid'] = uuid | ||||
|                         sorted_watches.append(watch) | ||||
|  | ||||
|             else: | ||||
|                 watch['uuid'] = uuid | ||||
|                 sorted_watches.append(watch) | ||||
|  | ||||
|         sorted_watches.sort(key=lambda x: x['last_changed'], reverse=True) | ||||
|  | ||||
|         existing_tags = datastore.get_all_tags() | ||||
|         rss = request.args.get('rss') | ||||
|  | ||||
|         if rss: | ||||
|             fg = FeedGenerator() | ||||
|             fg.title('changedetection.io') | ||||
|             fg.description('Feed description') | ||||
|             fg.link(href='https://changedetection.io') | ||||
|  | ||||
|             for watch in sorted_watches: | ||||
|                 if not watch['viewed']: | ||||
|                     fe = fg.add_entry() | ||||
|                     fe.title(watch['url']) | ||||
|                     fe.link(href=watch['url']) | ||||
|                     fe.description(watch['url']) | ||||
|                     fe.guid(watch['uuid'], permalink=False) | ||||
|                     dt = datetime.datetime.fromtimestamp(int(watch['newest_history_key'])) | ||||
|                     dt = dt.replace(tzinfo=pytz.UTC) | ||||
|                     fe.pubDate(dt) | ||||
|  | ||||
|             response = make_response(fg.rss_str()) | ||||
|             response.headers.set('Content-Type', 'application/rss+xml') | ||||
|             return response | ||||
|  | ||||
|         else: | ||||
|             from backend import forms | ||||
|             form = forms.quickWatchForm(request.form) | ||||
|  | ||||
|             output = render_template("watch-overview.html", | ||||
|                                      form=form, | ||||
|                                      watches=sorted_watches, | ||||
|                                      tags=existing_tags, | ||||
|                                      active_tag=limit_tag, | ||||
|                                      has_unviewed=datastore.data['has_unviewed']) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @app.route("/scrub", methods=['GET', 'POST']) | ||||
|     @login_required | ||||
|     def scrub_page(): | ||||
|  | ||||
|         import re | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             confirmtext = request.form.get('confirmtext') | ||||
|             limit_date = request.form.get('limit_date') | ||||
|  | ||||
|             try: | ||||
|                 limit_date = limit_date.replace('T', ' ') | ||||
|                 # I noticed chrome will show '/' but actually submit '-' | ||||
|                 limit_date = limit_date.replace('-', '/') | ||||
|                 # In the case that :ss seconds are supplied | ||||
|                 limit_date = re.sub('(\d\d:\d\d)(:\d\d)', '\\1', limit_date) | ||||
|  | ||||
|                 str_to_dt = datetime.datetime.strptime(limit_date, '%Y/%m/%d %H:%M') | ||||
|                 limit_timestamp = int(str_to_dt.timestamp()) | ||||
|  | ||||
|                 if limit_timestamp > time.time(): | ||||
|                     flash("Timestamp is in the future, cannot continue.", 'error') | ||||
|                     return redirect(url_for('scrub_page')) | ||||
|  | ||||
|             except ValueError: | ||||
|                 flash('Incorrect date format, cannot continue.', 'error') | ||||
|                 return redirect(url_for('scrub_page')) | ||||
|  | ||||
|             if confirmtext == 'scrub': | ||||
|                 changes_removed = 0 | ||||
|                 for uuid, watch in datastore.data['watching'].items(): | ||||
|                     if limit_timestamp: | ||||
|                         changes_removed += datastore.scrub_watch(uuid, limit_timestamp=limit_timestamp) | ||||
|                     else: | ||||
|                         changes_removed += datastore.scrub_watch(uuid) | ||||
|  | ||||
|                 flash("Cleared snapshot history ({} snapshots removed)".format(changes_removed)) | ||||
|             else: | ||||
|                 flash('Incorrect confirmation text.', 'error') | ||||
|  | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         output =  render_template("scrub.html") | ||||
|         return output | ||||
|  | ||||
|  | ||||
|     # If they edited an existing watch, we need to know to reset the current/previous md5 to include | ||||
|     # the excluded text. | ||||
|     def get_current_checksum_include_ignore_text(uuid): | ||||
|  | ||||
|         import hashlib | ||||
|         from backend import fetch_site_status | ||||
|  | ||||
|         # Get the most recent one | ||||
|         newest_history_key = datastore.get_val(uuid, 'newest_history_key') | ||||
|  | ||||
|         # 0 means that theres only one, so that there should be no 'unviewed' history availabe | ||||
|         if newest_history_key == 0: | ||||
|             newest_history_key = list(datastore.data['watching'][uuid]['history'].keys())[0] | ||||
|  | ||||
|         if newest_history_key: | ||||
|             with open(datastore.data['watching'][uuid]['history'][newest_history_key], | ||||
|                       encoding='utf-8') as file: | ||||
|                 raw_content = file.read() | ||||
|  | ||||
|                 handler = fetch_site_status.perform_site_check(datastore=datastore) | ||||
|                 stripped_content = handler.strip_ignore_text(raw_content, | ||||
|                                                              datastore.data['watching'][uuid]['ignore_text']) | ||||
|  | ||||
|                 checksum = hashlib.md5(stripped_content).hexdigest() | ||||
|                 return checksum | ||||
|  | ||||
|         return datastore.data['watching'][uuid]['previous_md5'] | ||||
|  | ||||
|  | ||||
|     @app.route("/edit/<string:uuid>", methods=['GET', 'POST']) | ||||
|     @login_required | ||||
|     def edit_page(uuid): | ||||
|         from backend import forms | ||||
|         form = forms.watchForm(request.form) | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         if request.method == 'GET': | ||||
|             if not uuid in datastore.data['watching']: | ||||
|                 flash("No watch with the UUID %s found." % (uuid), "error") | ||||
|                 return redirect(url_for('index')) | ||||
|  | ||||
|             populate_form_from_watch(form, datastore.data['watching'][uuid]) | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
|             # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default | ||||
|             if form.minutes_between_check.data == datastore.data['settings']['requests']['minutes_between_check']: | ||||
|                 form.minutes_between_check.data = None | ||||
|  | ||||
|             update_obj = {'url': form.url.data.strip(), | ||||
|                           'minutes_between_check': form.minutes_between_check.data, | ||||
|                           'tag': form.tag.data.strip(), | ||||
|                           'title': form.title.data.strip(), | ||||
|                           'headers': form.headers.data | ||||
|                           } | ||||
|  | ||||
|             # Notification URLs | ||||
|             datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data | ||||
|  | ||||
|             # Ignore text | ||||
|             form_ignore_text = form.ignore_text.data | ||||
|             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text | ||||
|  | ||||
|             # Reset the previous_md5 so we process a new snapshot including stripping ignore text. | ||||
|             if form_ignore_text: | ||||
|                 if len(datastore.data['watching'][uuid]['history']): | ||||
|                     update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid) | ||||
|  | ||||
|  | ||||
|             datastore.data['watching'][uuid]['css_filter'] = form.css_filter.data.strip() | ||||
|  | ||||
|             # Reset the previous_md5 so we process a new snapshot including stripping ignore text. | ||||
|             if form.css_filter.data.strip() != datastore.data['watching'][uuid]['css_filter']: | ||||
|                 if len(datastore.data['watching'][uuid]['history']): | ||||
|                     update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid) | ||||
|  | ||||
|  | ||||
|             datastore.data['watching'][uuid].update(update_obj) | ||||
|             datastore.needs_write = True | ||||
|             flash("Updated watch.") | ||||
|  | ||||
|             # Queue the watch for immediate recheck | ||||
|             update_q.put(uuid) | ||||
|  | ||||
|             if form.trigger_check.data: | ||||
|                 n_object = {'watch_url': form.url.data.strip(), | ||||
|                             'notification_urls': form.notification_urls.data, | ||||
|                             'uuid': uuid} | ||||
|                 notification_q.put(n_object) | ||||
|  | ||||
|                 flash('Notifications queued.') | ||||
|  | ||||
|             # Diff page [edit] link should go back to diff page | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff': | ||||
|                 return redirect(url_for('diff_history_page', uuid=uuid)) | ||||
|             else: | ||||
|                 return redirect(url_for('index')) | ||||
|  | ||||
|         else: | ||||
|             if request.method == 'POST' and not form.validate(): | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|             # Re #110 offer the default minutes | ||||
|             using_default_minutes = False | ||||
|             if form.minutes_between_check.data == None: | ||||
|                 form.minutes_between_check.data = datastore.data['settings']['requests']['minutes_between_check'] | ||||
|                 using_default_minutes = True | ||||
|  | ||||
|             output = render_template("edit.html", | ||||
|                                      uuid=uuid, | ||||
|                                      watch=datastore.data['watching'][uuid], | ||||
|                                      form=form, | ||||
|                                      using_default_minutes=using_default_minutes | ||||
|                                      ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @app.route("/settings", methods=['GET', "POST"]) | ||||
|     @login_required | ||||
|     def settings_page(): | ||||
|  | ||||
|         from backend import forms | ||||
|         form = forms.globalSettingsForm(request.form) | ||||
|  | ||||
|         if request.method == 'GET': | ||||
|             form.minutes_between_check.data = int(datastore.data['settings']['requests']['minutes_between_check']) | ||||
|             form.notification_urls.data = datastore.data['settings']['application']['notification_urls'] | ||||
|             form.extract_title_as_title.data = datastore.data['settings']['application']['extract_title_as_title'] | ||||
|             form.notification_title.data = datastore.data['settings']['application']['notification_title'] | ||||
|             form.notification_body.data = datastore.data['settings']['application']['notification_body'] | ||||
|  | ||||
|             # Password unset is a GET | ||||
|             if request.values.get('removepassword') == 'yes': | ||||
|                 from pathlib import Path | ||||
|                 datastore.data['settings']['application']['password'] = False | ||||
|                 flash("Password protection removed.", 'notice') | ||||
|                 flask_login.logout_user() | ||||
|                 return redirect(url_for('settings_page')) | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
|             datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data | ||||
|             datastore.data['settings']['requests']['minutes_between_check'] = form.minutes_between_check.data | ||||
|             datastore.data['settings']['application']['extract_title_as_title'] = form.extract_title_as_title.data | ||||
|             datastore.data['settings']['application']['notification_title'] = form.notification_title.data | ||||
|             datastore.data['settings']['application']['notification_body'] = form.notification_body.data | ||||
|  | ||||
|             datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data | ||||
|             datastore.needs_write = True | ||||
|  | ||||
|             if form.trigger_check.data and len(form.notification_urls.data): | ||||
|                 n_object = {'watch_url': "Test from changedetection.io!", | ||||
|                             'notification_urls': form.notification_urls.data} | ||||
|                 notification_q.put(n_object) | ||||
|                 flash('Notifications queued.') | ||||
|  | ||||
|             if form.password.encrypted_password: | ||||
|                 datastore.data['settings']['application']['password'] = form.password.encrypted_password | ||||
|                 flash("Password protection enabled.", 'notice') | ||||
|                 flask_login.logout_user() | ||||
|                 return redirect(url_for('index')) | ||||
|  | ||||
|             flash("Settings updated.") | ||||
|  | ||||
|         if request.method == 'POST' and not form.validate(): | ||||
|             flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|         output = render_template("settings.html", form=form) | ||||
|         return output | ||||
|  | ||||
|     @app.route("/import", methods=['GET', "POST"]) | ||||
|     @login_required | ||||
|     def import_page(): | ||||
|         import validators | ||||
|         remaining_urls = [] | ||||
|  | ||||
|         good = 0 | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             urls = request.values.get('urls').split("\n") | ||||
|             for url in urls: | ||||
|                 url = url.strip() | ||||
|                 if len(url) and validators.url(url): | ||||
|                     new_uuid = datastore.add_watch(url=url.strip(), tag="") | ||||
|                     # Straight into the queue. | ||||
|                     update_q.put(new_uuid) | ||||
|                     good += 1 | ||||
|                 else: | ||||
|                     if len(url): | ||||
|                         remaining_urls.append(url) | ||||
|  | ||||
|             flash("{} Imported, {} Skipped.".format(good, len(remaining_urls))) | ||||
|  | ||||
|             if len(remaining_urls) == 0: | ||||
|                 # Looking good, redirect to index. | ||||
|                 return redirect(url_for('index')) | ||||
|  | ||||
|         # Could be some remaining, or we could be on GET | ||||
|         output = render_template("import.html", | ||||
|                                  remaining="\n".join(remaining_urls) | ||||
|                                  ) | ||||
|         return output | ||||
|  | ||||
|     # Clear all statuses, so we do not see the 'unviewed' class | ||||
|     @app.route("/api/mark-all-viewed", methods=['GET']) | ||||
|     @login_required | ||||
|     def mark_all_viewed(): | ||||
|  | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             datastore.set_last_viewed(watch_uuid, watch['newest_history_key']) | ||||
|  | ||||
|         flash("Cleared all statuses.") | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @app.route("/diff/<string:uuid>", methods=['GET']) | ||||
|     @login_required | ||||
|     def diff_history_page(uuid): | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|         try: | ||||
|             watch = datastore.data['watching'][uuid] | ||||
|         except KeyError: | ||||
|             flash("No history found for the specified link, bad link?", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         dates = list(watch['history'].keys()) | ||||
|         # Convert to int, sort and back to str again | ||||
|         dates = [int(i) for i in dates] | ||||
|         dates.sort(reverse=True) | ||||
|         dates = [str(i) for i in dates] | ||||
|  | ||||
|         if len(dates) < 2: | ||||
|             flash("Not enough saved change detection snapshots to produce a report.", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         datastore.set_last_viewed(uuid, dates[0]) | ||||
|  | ||||
|         newest_file = watch['history'][dates[0]] | ||||
|         with open(newest_file, 'r') as f: | ||||
|             newest_version_file_contents = f.read() | ||||
|  | ||||
|         previous_version = request.args.get('previous_version') | ||||
|  | ||||
|         try: | ||||
|             previous_file = watch['history'][previous_version] | ||||
|         except KeyError: | ||||
|             # Not present, use a default value, the second one in the sorted list. | ||||
|             previous_file = watch['history'][dates[1]] | ||||
|  | ||||
|         with open(previous_file, 'r') as f: | ||||
|             previous_version_file_contents = f.read() | ||||
|  | ||||
|         output = render_template("diff.html", watch_a=watch, | ||||
|                                  newest=newest_version_file_contents, | ||||
|                                  previous=previous_version_file_contents, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  versions=dates[1:], | ||||
|                                  uuid=uuid, | ||||
|                                  newest_version_timestamp=dates[0], | ||||
|                                  current_previous_version=str(previous_version), | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  extra_title=" - Diff - {}".format(watch['title'] if watch['title'] else watch['url']), | ||||
|                                  left_sticky= True ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @app.route("/preview/<string:uuid>", methods=['GET']) | ||||
|     @login_required | ||||
|     def preview_page(uuid): | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] | ||||
|  | ||||
|         try: | ||||
|             watch = datastore.data['watching'][uuid] | ||||
|         except KeyError: | ||||
|             flash("No history found for the specified link, bad link?", "error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         newest = list(watch['history'].keys())[-1] | ||||
|         with open(watch['history'][newest], 'r') as f: | ||||
|             content = f.readlines() | ||||
|  | ||||
|         output = render_template("preview.html", | ||||
|                                  content=content, | ||||
|                                  extra_stylesheets=extra_stylesheets, | ||||
|                                  current_diff_url=watch['url'], | ||||
|                                  uuid=uuid) | ||||
|         return output | ||||
|  | ||||
|  | ||||
|     @app.route("/favicon.ico", methods=['GET']) | ||||
|     def favicon(): | ||||
|         return send_from_directory("/app/static/images", filename="favicon.ico") | ||||
|  | ||||
|     # We're good but backups are even better! | ||||
|     @app.route("/backup", methods=['GET']) | ||||
|     @login_required | ||||
|     def get_backup(): | ||||
|  | ||||
|         import zipfile | ||||
|         from pathlib import Path | ||||
|  | ||||
|         # Remove any existing backup file, for now we just keep one file | ||||
|         for previous_backup_filename in Path(app.config['datastore_path']).rglob('changedetection-backup-*.zip'): | ||||
|             os.unlink(previous_backup_filename) | ||||
|  | ||||
|         # create a ZipFile object | ||||
|         backupname = "changedetection-backup-{}.zip".format(int(time.time())) | ||||
|  | ||||
|         # We only care about UUIDS from the current index file | ||||
|         uuids = list(datastore.data['watching'].keys()) | ||||
|         backup_filepath = os.path.join(app.config['datastore_path'], backupname) | ||||
|  | ||||
|         with zipfile.ZipFile(backup_filepath, "w", | ||||
|                              compression=zipfile.ZIP_DEFLATED, | ||||
|                              compresslevel=8) as zipObj: | ||||
|  | ||||
|             # Be sure we're written fresh | ||||
|             datastore.sync_to_json() | ||||
|  | ||||
|             # Add the index | ||||
|             zipObj.write(os.path.join(app.config['datastore_path'], "url-watches.json"), arcname="url-watches.json") | ||||
|  | ||||
|             # Add the flask app secret | ||||
|             zipObj.write(os.path.join(app.config['datastore_path'], "secret.txt"), arcname="secret.txt") | ||||
|  | ||||
|             # Add any snapshot data we find, use the full path to access the file, but make the file 'relative' in the Zip. | ||||
|             for txt_file_path in Path(app.config['datastore_path']).rglob('*.txt'): | ||||
|                 parent_p = txt_file_path.parent | ||||
|                 if parent_p.name in uuids: | ||||
|                     zipObj.write(txt_file_path, | ||||
|                                  arcname=str(txt_file_path).replace(app.config['datastore_path'], ''), | ||||
|                                  compress_type=zipfile.ZIP_DEFLATED, | ||||
|                                  compresslevel=8) | ||||
|  | ||||
|             # Create a list file with just the URLs, so it's easier to port somewhere else in the future | ||||
|             list_file = os.path.join(app.config['datastore_path'], "url-list.txt") | ||||
|             with open(list_file, "w") as f: | ||||
|                 for uuid in datastore.data['watching']: | ||||
|                     url = datastore.data['watching'][uuid]['url'] | ||||
|                     f.write("{}\r\n".format(url)) | ||||
|  | ||||
|             # Add it to the Zip | ||||
|             zipObj.write(list_file, | ||||
|                          arcname="url-list.txt", | ||||
|                          compress_type=zipfile.ZIP_DEFLATED, | ||||
|                          compresslevel=8) | ||||
|  | ||||
|         return send_from_directory(app.config['datastore_path'], backupname, as_attachment=True) | ||||
|  | ||||
|     @app.route("/static/<string:group>/<string:filename>", methods=['GET']) | ||||
|     def static_content(group, filename): | ||||
|         # These files should be in our subdirectory | ||||
|         full_path = os.path.realpath(__file__) | ||||
|         p = os.path.dirname(full_path) | ||||
|  | ||||
|         try: | ||||
|             return send_from_directory("{}/static/{}".format(p, group), filename=filename) | ||||
|         except FileNotFoundError: | ||||
|             abort(404) | ||||
|  | ||||
|     @app.route("/api/add", methods=['POST']) | ||||
|     @login_required | ||||
|     def api_watch_add(): | ||||
|         from backend import forms | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|  | ||||
|         if form.validate(): | ||||
|  | ||||
|             url = request.form.get('url').strip() | ||||
|             if datastore.url_exists(url): | ||||
|                 flash('The URL {} already exists'.format(url), "error") | ||||
|                 return redirect(url_for('index')) | ||||
|  | ||||
|             # @todo add_watch should throw a custom Exception for validation etc | ||||
|             new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip()) | ||||
|             # Straight into the queue. | ||||
|             update_q.put(new_uuid) | ||||
|  | ||||
|             flash("Watch added.") | ||||
|             return redirect(url_for('index')) | ||||
|         else: | ||||
|             flash("Error") | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|     @app.route("/api/delete", methods=['GET']) | ||||
|     @login_required | ||||
|     def api_delete(): | ||||
|  | ||||
|         uuid = request.args.get('uuid') | ||||
|         datastore.delete(uuid) | ||||
|         flash('Deleted.') | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|     @app.route("/api/checknow", methods=['GET']) | ||||
|     @login_required | ||||
|     def api_watch_checknow(): | ||||
|  | ||||
|         tag = request.args.get('tag') | ||||
|         uuid = request.args.get('uuid') | ||||
|         i = 0 | ||||
|  | ||||
|         running_uuids = [] | ||||
|         for t in running_update_threads: | ||||
|             running_uuids.append(t.current_uuid) | ||||
|  | ||||
|         # @todo check thread is running and skip | ||||
|  | ||||
|         if uuid: | ||||
|             if uuid not in running_uuids: | ||||
|                 update_q.put(uuid) | ||||
|             i = 1 | ||||
|  | ||||
|         elif tag != None: | ||||
|             # Items that have this current tag | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|                 if (tag != None and tag in watch['tag']): | ||||
|                     if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                         update_q.put(watch_uuid) | ||||
|                         i += 1 | ||||
|  | ||||
|         else: | ||||
|             # No tag, no uuid, add everything. | ||||
|             for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|  | ||||
|                 if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']: | ||||
|                     update_q.put(watch_uuid) | ||||
|                     i += 1 | ||||
|         flash("{} watches are rechecking.".format(i)) | ||||
|         return redirect(url_for('index', tag=tag)) | ||||
|  | ||||
|     # @todo handle ctrl break | ||||
|     ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() | ||||
|  | ||||
|     threading.Thread(target=notification_runner).start() | ||||
|  | ||||
|     # Check for new release version | ||||
|     threading.Thread(target=check_for_new_version).start() | ||||
|     return app | ||||
|  | ||||
|  | ||||
| # Check for new version and anonymous stats | ||||
| def check_for_new_version(): | ||||
|     import requests | ||||
|  | ||||
|     import urllib3 | ||||
|     urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
|     while not app.config.exit.is_set(): | ||||
|         try: | ||||
|             r = requests.post("https://changedetection.io/check-ver.php", | ||||
|                               data={'version': datastore.data['version_tag'], | ||||
|                                     'app_guid': datastore.data['app_guid'], | ||||
|                                     'watch_count': len(datastore.data['watching']) | ||||
|                                     }, | ||||
|  | ||||
|                               verify=False) | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|         try: | ||||
|             if "new_version" in r.text: | ||||
|                 app.config['NEW_VERSION_AVAILABLE'] = True | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|         # Check daily | ||||
|         app.config.exit.wait(86400) | ||||
|  | ||||
| def notification_runner(): | ||||
|     while not app.config.exit.is_set(): | ||||
|         try: | ||||
|             # At the moment only one thread runs (single runner) | ||||
|             n_object = notification_q.get(block=False) | ||||
|         except queue.Empty: | ||||
|             time.sleep(1) | ||||
|  | ||||
|         else: | ||||
|             # Process notifications | ||||
|             try: | ||||
|                 from backend import notification | ||||
|                 notification.process_notification(n_object, datastore) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 print("Watch URL: {}  Error {}".format(n_object['watch_url'], e)) | ||||
|  | ||||
|  | ||||
|  | ||||
| # Thread runner to check every minute, look for new watches to feed into the Queue. | ||||
| def ticker_thread_check_time_launch_checks(): | ||||
|     from backend import update_worker | ||||
|  | ||||
|     # Spin up Workers. | ||||
|     for _ in range(datastore.data['settings']['requests']['workers']): | ||||
|         new_worker = update_worker.update_worker(update_q, notification_q, app, datastore) | ||||
|         running_update_threads.append(new_worker) | ||||
|         new_worker.start() | ||||
|  | ||||
|     while not app.config.exit.is_set(): | ||||
|  | ||||
|         # Get a list of watches by UUID that are currently fetching data | ||||
|         running_uuids = [] | ||||
|         for t in running_update_threads: | ||||
|             if t.current_uuid: | ||||
|                 running_uuids.append(t.current_uuid) | ||||
|  | ||||
|         # Check for watches outside of the time threshold to put in the thread queue. | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|  | ||||
|             # If they supplied an individual entry minutes to threshold. | ||||
|             if 'minutes_between_check' in watch and watch['minutes_between_check'] is not None: | ||||
|                 max_time = watch['minutes_between_check'] * 60 | ||||
|             else: | ||||
|                 # Default system wide. | ||||
|                 max_time = datastore.data['settings']['requests']['minutes_between_check'] * 60 | ||||
|  | ||||
|             threshold = time.time() - max_time | ||||
|  | ||||
|             # Yeah, put it in the queue, it's more than time. | ||||
|             if not watch['paused'] and watch['last_checked'] <= threshold: | ||||
|                 if not uuid in running_uuids and uuid not in update_q.queue: | ||||
|                     update_q.put(uuid) | ||||
|  | ||||
|         # Wait a few seconds before checking the list again | ||||
|         time.sleep(3) | ||||
|  | ||||
|         # Should be low so we can break this out in testing | ||||
|         app.config.exit.wait(1) | ||||
| @@ -1,14 +0,0 @@ | ||||
| FROM python:3.8-slim | ||||
|  | ||||
| # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| RUN [ ! -d "/datastore" ] && mkdir /datastore | ||||
|  | ||||
| COPY sleep.py / | ||||
| CMD [ "python", "/sleep.py" ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -1,7 +0,0 @@ | ||||
| import time | ||||
|  | ||||
| print ("Sleep loop, you should run your script from the console") | ||||
|  | ||||
| while True:  | ||||
|     # Wait for 5 seconds | ||||
|     time.sleep(2) | ||||
| @@ -1,176 +0,0 @@ | ||||
| import time | ||||
| import requests | ||||
| import hashlib | ||||
| from inscriptis import get_text | ||||
| import urllib3 | ||||
| from . import html_tools | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
|  | ||||
| # Some common stuff here that can be moved to a base class | ||||
| class perform_site_check(): | ||||
|  | ||||
|     def __init__(self, *args, datastore, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.datastore = datastore | ||||
|  | ||||
|     def strip_ignore_text(self, content, list_ignore_text): | ||||
|         import re | ||||
|         ignore = [] | ||||
|         ignore_regex = [] | ||||
|         for k in list_ignore_text: | ||||
|  | ||||
|             # Is it a regex? | ||||
|             if k[0] == '/': | ||||
|                 ignore_regex.append(k.strip(" /")) | ||||
|             else: | ||||
|                 ignore.append(k) | ||||
|  | ||||
|         output = [] | ||||
|         for line in content.splitlines(): | ||||
|  | ||||
|             # Always ignore blank lines in this mode. (when this function gets called) | ||||
|             if len(line.strip()): | ||||
|                 regex_matches = False | ||||
|  | ||||
|                 # if any of these match, skip | ||||
|                 for regex in ignore_regex: | ||||
|                     try: | ||||
|                         if re.search(regex, line, re.IGNORECASE): | ||||
|                             regex_matches = True | ||||
|                     except Exception as e: | ||||
|                         continue | ||||
|  | ||||
|                 if not regex_matches and not any(skip_text in line for skip_text in ignore): | ||||
|                     output.append(line.encode('utf8')) | ||||
|  | ||||
|         return "\n".encode('utf8').join(output) | ||||
|  | ||||
|  | ||||
|  | ||||
|     def run(self, uuid): | ||||
|         timestamp = int(time.time())  # used for storage etc too | ||||
|  | ||||
|         stripped_text_from_html = False | ||||
|         changed_detected = False | ||||
|  | ||||
|         update_obj = {'previous_md5': self.datastore.data['watching'][uuid]['previous_md5'], | ||||
|                       'history': {}, | ||||
|                       "last_checked": timestamp | ||||
|                       } | ||||
|  | ||||
|         extra_headers = self.datastore.get_val(uuid, 'headers') | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         request_headers = self.datastore.data['settings']['headers'].copy() | ||||
|         request_headers.update(extra_headers) | ||||
|  | ||||
|         # https://github.com/psf/requests/issues/4525 | ||||
|         # Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot | ||||
|         # do this by accident. | ||||
|         if 'Accept-Encoding' in request_headers and "br" in request_headers['Accept-Encoding']: | ||||
|             request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '') | ||||
|  | ||||
|         try: | ||||
|             timeout = self.datastore.data['settings']['requests']['timeout'] | ||||
|         except KeyError: | ||||
|             # @todo yeah this should go back to the default value in store.py, but this whole object should abstract off it | ||||
|             timeout = 15 | ||||
|  | ||||
|         try: | ||||
|             url = self.datastore.get_val(uuid, 'url') | ||||
|  | ||||
|             r = requests.get(url, | ||||
|                              headers=request_headers, | ||||
|                              timeout=timeout, | ||||
|                              verify=False) | ||||
|  | ||||
|             html = r.text | ||||
|  | ||||
|             is_html = True | ||||
|             css_filter_rule = self.datastore.data['watching'][uuid]['css_filter'] | ||||
|             if css_filter_rule and len(css_filter_rule.strip()): | ||||
|                 if 'json:' in css_filter_rule: | ||||
|                     # POC hack, @todo rename vars, see how it fits in with the javascript version | ||||
|                     import json | ||||
|                     from jsonpath_ng import jsonpath, parse | ||||
|  | ||||
|                     json_data = json.loads(html) | ||||
|                     jsonpath_expression = parse(css_filter_rule.replace('json:', '')) | ||||
|                     match = jsonpath_expression.find(json_data) | ||||
|                     s = [] | ||||
|  | ||||
|                     # More than one result, we will return it as a JSON list. | ||||
|                     if len(match) > 1: | ||||
|                         for i in match: | ||||
|                             s.append(i.value) | ||||
|  | ||||
|                     # Single value, use just the value, as it could be later used in a token in notifications. | ||||
|                     if len(match) == 1: | ||||
|                         s = match[0].value | ||||
|  | ||||
|                     stripped_text_from_html = json.dumps(s, indent=4) | ||||
|                     is_html = False | ||||
|  | ||||
|                 else: | ||||
|                     # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||
|                     html = html_tools.css_filter(css_filter=css_filter_rule, html_content=r.content) | ||||
|  | ||||
|             if is_html: | ||||
|                 stripped_text_from_html = get_text(html) | ||||
|  | ||||
|         # Usually from networkIO/requests level | ||||
|         except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as e: | ||||
|             update_obj["last_error"] = str(e) | ||||
|             print(str(e)) | ||||
|  | ||||
|         except requests.exceptions.MissingSchema: | ||||
|             print("Skipping {} due to missing schema/bad url".format(uuid)) | ||||
|  | ||||
|         # Usually from html2text level | ||||
|         except Exception as e: | ||||
|             #        except UnicodeDecodeError as e: | ||||
|             update_obj["last_error"] = str(e) | ||||
|             print(str(e)) | ||||
|             # figure out how to deal with this cleaner.. | ||||
|             # 'utf-8' codec can't decode byte 0xe9 in position 480: invalid continuation byte | ||||
|  | ||||
|  | ||||
|         else: | ||||
|             # We rely on the actual text in the html output.. many sites have random script vars etc, | ||||
|             # in the future we'll implement other mechanisms. | ||||
|  | ||||
|             update_obj["last_check_status"] = r.status_code | ||||
|             update_obj["last_error"] = False | ||||
|  | ||||
|             if not len(r.text): | ||||
|                 update_obj["last_error"] = "Empty reply" | ||||
|  | ||||
|             # If there's text to skip | ||||
|             # @todo we could abstract out the get_text() to handle this cleaner | ||||
|             if len(self.datastore.data['watching'][uuid]['ignore_text']): | ||||
|                 content = self.strip_ignore_text(stripped_text_from_html, | ||||
|                                                  self.datastore.data['watching'][uuid]['ignore_text']) | ||||
|             else: | ||||
|                 content = stripped_text_from_html.encode('utf8') | ||||
|  | ||||
|             fetched_md5 = hashlib.md5(content).hexdigest() | ||||
|  | ||||
|             # could be None or False depending on JSON type | ||||
|             if self.datastore.data['watching'][uuid]['previous_md5'] != fetched_md5: | ||||
|                 changed_detected = True | ||||
|  | ||||
|                 # Don't confuse people by updating as last-changed, when it actually just changed from None.. | ||||
|                 if self.datastore.get_val(uuid, 'previous_md5'): | ||||
|                     update_obj["last_changed"] = timestamp | ||||
|  | ||||
|                 update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|             # Extract title as title | ||||
|             if self.datastore.data['settings']['application']['extract_title_as_title']: | ||||
|                 if not self.datastore.data['watching'][uuid]['title'] or not len(self.datastore.data['watching'][uuid]['title']): | ||||
|                     update_obj['title'] = html_tools.extract_element(find='title', html_content=html) | ||||
|  | ||||
|  | ||||
|         return changed_detected, update_obj, stripped_text_from_html | ||||
							
								
								
									
										159
									
								
								backend/forms.py
									
									
									
									
									
								
							
							
						
						
									
										159
									
								
								backend/forms.py
									
									
									
									
									
								
							| @@ -1,159 +0,0 @@ | ||||
| from wtforms import Form, BooleanField, StringField, PasswordField, validators, IntegerField, fields, TextAreaField, \ | ||||
|     Field | ||||
| from wtforms import widgets | ||||
| from wtforms.validators import ValidationError | ||||
| from wtforms.fields import html5 | ||||
|  | ||||
|  | ||||
| class StringListField(StringField): | ||||
|     widget = widgets.TextArea() | ||||
|  | ||||
|     def _value(self): | ||||
|         if self.data: | ||||
|             return "\r\n".join(self.data) | ||||
|         else: | ||||
|             return u'' | ||||
|  | ||||
|     # incoming | ||||
|     def process_formdata(self, valuelist): | ||||
|         if valuelist: | ||||
|             # Remove empty strings | ||||
|             cleaned = list(filter(None, valuelist[0].split("\n"))) | ||||
|             self.data = [x.strip() for x in cleaned] | ||||
|             p = 1 | ||||
|         else: | ||||
|             self.data = [] | ||||
|  | ||||
|  | ||||
|  | ||||
| class SaltyPasswordField(StringField): | ||||
|     widget = widgets.PasswordInput() | ||||
|     encrypted_password = "" | ||||
|  | ||||
|     def build_password(self, password): | ||||
|         import hashlib | ||||
|         import base64 | ||||
|         import secrets | ||||
|  | ||||
|         # Make a new salt on every new password and store it with the password | ||||
|         salt = secrets.token_bytes(32) | ||||
|  | ||||
|         key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) | ||||
|         store = base64.b64encode(salt + key).decode('ascii') | ||||
|  | ||||
|         return store | ||||
|  | ||||
|     # incoming | ||||
|     def process_formdata(self, valuelist): | ||||
|         if valuelist: | ||||
|             # Be really sure it's non-zero in length | ||||
|             if len(valuelist[0].strip()) > 0: | ||||
|                 self.encrypted_password = self.build_password(valuelist[0]) | ||||
|                 self.data = "" | ||||
|         else: | ||||
|             self.data = False | ||||
|  | ||||
|  | ||||
| # Separated by  key:value | ||||
| class StringDictKeyValue(StringField): | ||||
|     widget = widgets.TextArea() | ||||
|  | ||||
|     def _value(self): | ||||
|         if self.data: | ||||
|             output = u'' | ||||
|             for k in self.data.keys(): | ||||
|                 output += "{}: {}\r\n".format(k, self.data[k]) | ||||
|  | ||||
|             return output | ||||
|         else: | ||||
|             return u'' | ||||
|  | ||||
|     # incoming | ||||
|     def process_formdata(self, valuelist): | ||||
|         if valuelist: | ||||
|             self.data = {} | ||||
|             # Remove empty strings | ||||
|             cleaned = list(filter(None, valuelist[0].split("\n"))) | ||||
|             for s in cleaned: | ||||
|                 parts = s.strip().split(':') | ||||
|                 if len(parts) == 2: | ||||
|                     self.data.update({parts[0].strip(): parts[1].strip()}) | ||||
|  | ||||
|         else: | ||||
|             self.data = {} | ||||
|  | ||||
| class ValidateListRegex(object): | ||||
|     """ | ||||
|     Validates that anything that looks like a regex passes as a regex | ||||
|     """ | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         import re | ||||
|  | ||||
|         for line in field.data: | ||||
|             if line[0] == '/' and line[-1] == '/': | ||||
|                 # Because internally we dont wrap in / | ||||
|                 line = line.strip('/') | ||||
|                 try: | ||||
|                     re.compile(line) | ||||
|                 except re.error: | ||||
|                     message = field.gettext('RegEx \'%s\' is not a valid regular expression.') | ||||
|                     raise ValidationError(message % (line)) | ||||
|  | ||||
| class ValidateCSSJSONInput(object): | ||||
|     """ | ||||
|     Filter validation | ||||
|     @todo CSS validator ;) | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         if 'json:' in field.data: | ||||
|             from jsonpath_ng.exceptions import JsonPathParserError | ||||
|             from jsonpath_ng import jsonpath, parse | ||||
|  | ||||
|             input = field.data.replace('json:', '') | ||||
|  | ||||
|             try: | ||||
|                 parse(input) | ||||
|             except JsonPathParserError as e: | ||||
|                 message = field.gettext('\'%s\' is not a valid JSONPath expression. (%s)') | ||||
|                 raise ValidationError(message % (input, str(e))) | ||||
|  | ||||
| class quickWatchForm(Form): | ||||
|     # https://wtforms.readthedocs.io/en/2.3.x/fields/#module-wtforms.fields.html5 | ||||
|     # `require_tld` = False is needed even for the test harness "http://localhost:5005.." to run | ||||
|  | ||||
|     url = html5.URLField('URL', [validators.URL(require_tld=False)]) | ||||
|     tag = StringField('Tag', [validators.Optional(), validators.Length(max=35)]) | ||||
|  | ||||
| class watchForm(quickWatchForm): | ||||
|  | ||||
|     minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck', | ||||
|                                                [validators.Optional(), validators.NumberRange(min=1)]) | ||||
|     css_filter = StringField('CSS/JSON Filter', [ValidateCSSJSONInput()]) | ||||
|     title = StringField('Title') | ||||
|  | ||||
|     ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) | ||||
|     notification_urls = StringListField('Notification URL List') | ||||
|     headers = StringDictKeyValue('Request Headers') | ||||
|     trigger_check = BooleanField('Send test notification on save') | ||||
|  | ||||
|  | ||||
| class globalSettingsForm(Form): | ||||
|  | ||||
|     password = SaltyPasswordField() | ||||
|  | ||||
|     minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck', | ||||
|                                                [validators.NumberRange(min=1)]) | ||||
|  | ||||
|     notification_urls = StringListField('Notification URL List') | ||||
|     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title') | ||||
|     trigger_check = BooleanField('Send test notification on save') | ||||
|  | ||||
|     notification_title = StringField('Notification Title') | ||||
|     notification_body = TextAreaField('Notification Body') | ||||
| @@ -1,26 +0,0 @@ | ||||
| from bs4 import BeautifulSoup | ||||
|  | ||||
|  | ||||
| # Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches | ||||
| def css_filter(css_filter, html_content): | ||||
|     soup = BeautifulSoup(html_content, "html.parser") | ||||
|     html_block = "" | ||||
|     for item in soup.select(css_filter, separator=""): | ||||
|         html_block += str(item) | ||||
|  | ||||
|     return html_block + "\n" | ||||
|  | ||||
|  | ||||
| # Extract/find element | ||||
| def extract_element(find='title', html_content=''): | ||||
|  | ||||
|     #Re #106, be sure to handle when its not found | ||||
|     element_text = None | ||||
|  | ||||
|     soup = BeautifulSoup(html_content, 'html.parser') | ||||
|     result = soup.find(find) | ||||
|     if result and result.string: | ||||
|         element_text = result.string.strip() | ||||
|  | ||||
|     return element_text | ||||
|  | ||||
| @@ -1,54 +0,0 @@ | ||||
| import os | ||||
| import apprise | ||||
|  | ||||
| def process_notification(n_object, datastore): | ||||
|     apobj = apprise.Apprise() | ||||
|     for url in n_object['notification_urls']: | ||||
|         apobj.add(url.strip()) | ||||
|  | ||||
|     # Get the notification body from datastore | ||||
|     n_body = datastore.data['settings']['application']['notification_body'] | ||||
|     # Get the notification title from the datastore | ||||
|     n_title = datastore.data['settings']['application']['notification_title'] | ||||
|  | ||||
|     # Insert variables into the notification content | ||||
|     notification_parameters = create_notification_parameters(n_object) | ||||
|     raw_notification_text = [n_body, n_title] | ||||
|  | ||||
|     parameterised_notification_text = dict( | ||||
|         [ | ||||
|             (i, n.replace(n, n.format(**notification_parameters))) | ||||
|             for i, n in zip(['body', 'title'], raw_notification_text) | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     apobj.notify( | ||||
|         body=parameterised_notification_text["body"], | ||||
|         title=parameterised_notification_text["title"] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| # Notification title + body content parameters get created here. | ||||
| def create_notification_parameters(n_object): | ||||
|  | ||||
|     # in the case we send a test notification from the main settings, there is no UUID. | ||||
|     uuid = n_object['uuid'] if 'uuid' in n_object else '' | ||||
|  | ||||
|     # Create URLs to customise the notification with | ||||
|     base_url = os.getenv('BASE_URL', '').strip('"') | ||||
|     watch_url = n_object['watch_url'] | ||||
|  | ||||
|     if base_url != '': | ||||
|         diff_url = "{}/diff/{}".format(base_url, uuid) | ||||
|         preview_url = "{}/preview/{}".format(base_url, uuid) | ||||
|     else: | ||||
|         diff_url = preview_url = '' | ||||
|  | ||||
|     return { | ||||
|         'base_url': base_url, | ||||
|         'watch_url': watch_url, | ||||
|         'diff_url': diff_url, | ||||
|         'preview_url': preview_url, | ||||
|         'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else '' | ||||
|     } | ||||
|  | ||||
| @@ -1,19 +0,0 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
|  | ||||
| # live_server will throw errors even with live_server_scope=function if I have the live_server setup in different functions | ||||
| # and I like to restart the server for each test (and have the test cleanup after each test) | ||||
| # merge request welcome :) | ||||
|  | ||||
|  | ||||
| # exit when any command fails | ||||
| set -e | ||||
|  | ||||
| # Re #65 - Ability to include a link back to the installation, in the notification. | ||||
| export BASE_URL="https://foobar.com" | ||||
|  | ||||
| find tests/test_*py -type f|while read test_name | ||||
| do | ||||
|   echo "TEST RUNNING $test_name" | ||||
|   pytest $test_name | ||||
| done | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 4.2 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 43 KiB | 
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,16 +0,0 @@ | ||||
| window.addEventListener("load", (event) => { | ||||
|   // just an example for now | ||||
|   function toggleVisible(elem) { | ||||
|     // theres better ways todo this | ||||
|     var x = document.getElementById(elem); | ||||
|     if (x.style.display === "block") { | ||||
|       x.style.display = "none"; | ||||
|     } else { | ||||
|       x.style.display = "block"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   document.getElementById("toggle-customise-notifications").onclick = function () { | ||||
|     toggleVisible("notification-customisation"); | ||||
|   }; | ||||
| }); | ||||
							
								
								
									
										1
									
								
								backend/static/styles/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								backend/static/styles/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1 +0,0 @@ | ||||
| node_modules | ||||
| @@ -1,56 +0,0 @@ | ||||
| #diff-ui { | ||||
|   background: #fff; | ||||
|   padding: 2em; | ||||
|   margin: 1em; | ||||
|   border-radius: 5px; | ||||
|   font-size: 9px; } | ||||
|   #diff-ui table { | ||||
|     table-layout: fixed; | ||||
|     width: 100%; } | ||||
|   #diff-ui td { | ||||
|     padding: 3px 4px; | ||||
|     border: 1px solid transparent; | ||||
|     vertical-align: top; | ||||
|     font: 1em monospace; | ||||
|     text-align: left; } | ||||
|   #diff-ui pre { | ||||
|     white-space: pre-wrap; } | ||||
|  | ||||
| h1 { | ||||
|   display: inline; | ||||
|   font-size: 100%; } | ||||
|  | ||||
| del { | ||||
|   text-decoration: none; | ||||
|   color: #b30000; | ||||
|   background: #fadad7; } | ||||
|  | ||||
| ins { | ||||
|   background: #eaf2c2; | ||||
|   color: #406619; | ||||
|   text-decoration: none; } | ||||
|  | ||||
| #result { | ||||
|   white-space: pre-wrap; } | ||||
|  | ||||
| #settings { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   color: #fff; | ||||
|   font-size: 80%; } | ||||
|   #settings label { | ||||
|     margin-left: 1em; | ||||
|     display: inline-block; | ||||
|     font-weight: normal; } | ||||
|  | ||||
| .source { | ||||
|   position: absolute; | ||||
|   right: 1%; | ||||
|   top: .2em; } | ||||
|  | ||||
| @-moz-document url-prefix() { | ||||
|   body { | ||||
|     height: 99%; | ||||
|     /* Hide scroll bar in Firefox */ } } | ||||
| @@ -1,68 +0,0 @@ | ||||
| #diff-ui { | ||||
|  | ||||
|     background: #fff; | ||||
|     padding: 2em; | ||||
|     margin: 1em; | ||||
|     border-radius: 5px; | ||||
|     font-size: 9px; | ||||
|  | ||||
|     table { | ||||
|         table-layout: fixed; | ||||
|         width: 100%; | ||||
|     } | ||||
|     td { | ||||
|         padding: 3px 4px; | ||||
|         border: 1px solid transparent; | ||||
|         vertical-align: top; | ||||
|         font: 1em monospace; | ||||
|         text-align: left; | ||||
|     } | ||||
|     pre { | ||||
|             white-space: pre-wrap; | ||||
|     } | ||||
| } | ||||
| h1 { | ||||
| 	display: inline; | ||||
| 	font-size: 100%; | ||||
| } | ||||
| del { | ||||
| 	text-decoration: none; | ||||
| 	color: #b30000; | ||||
| 	background: #fadad7; | ||||
| } | ||||
|  | ||||
| ins { | ||||
| 	background: #eaf2c2; | ||||
| 	color: #406619; | ||||
| 	text-decoration: none; | ||||
| } | ||||
|  | ||||
| #result { | ||||
| 	white-space: pre-wrap; | ||||
| } | ||||
|  | ||||
| #settings { | ||||
|     background: rgba(0,0,0,.05); | ||||
|     padding: 1em; | ||||
|     border-radius: 10px; | ||||
|     margin-bottom: 1em; | ||||
|     color: #fff; | ||||
|     font-size: 80%; | ||||
|     label { | ||||
| 	    margin-left: 1em; | ||||
| 	    display: inline-block; | ||||
| 	    font-weight: normal; | ||||
|     } | ||||
| } | ||||
|  | ||||
| .source { | ||||
| 	position: absolute; | ||||
| 	right: 1%; | ||||
| 	top: .2em; | ||||
| } | ||||
|  | ||||
| @-moz-document url-prefix() { | ||||
| 	body { | ||||
| 		height: 99%; /* Hide scroll bar in Firefox */ | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										3485
									
								
								backend/static/styles/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3485
									
								
								backend/static/styles/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,356 +0,0 @@ | ||||
| /* | ||||
|  * -- BASE STYLES -- | ||||
|  * Most of these are inherited from Base, but I want to change a few. | ||||
|  * npm run scss | ||||
|  */ | ||||
| body { | ||||
|   color: #333; | ||||
|   background: #262626; } | ||||
|  | ||||
| .pure-table-even { | ||||
|   background: #fff; } | ||||
|  | ||||
| /* Some styles from https://css-tricks.com/ */ | ||||
| a { | ||||
|   text-decoration: none; | ||||
|   color: #1b98f8; } | ||||
|  | ||||
| a.github-link { | ||||
|   color: #fff; } | ||||
|  | ||||
| .pure-menu-horizontal { | ||||
|   background: #fff; | ||||
|   padding: 5px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   border-bottom: 2px solid #ed5900; | ||||
|   align-items: center; } | ||||
|  | ||||
| section.content { | ||||
|   padding-top: 5em; | ||||
|   padding-bottom: 5em; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; } | ||||
|  | ||||
| /* table related */ | ||||
| .watch-table { | ||||
|   width: 100%; } | ||||
|   .watch-table tr.unviewed { | ||||
|     font-weight: bold; } | ||||
|   .watch-table .error { | ||||
|     color: #a00; } | ||||
|   .watch-table td { | ||||
|     font-size: 80%; | ||||
|     white-space: nowrap; } | ||||
|   .watch-table td.title-col { | ||||
|     word-break: break-all; | ||||
|     white-space: normal; } | ||||
|   .watch-table th { | ||||
|     white-space: nowrap; } | ||||
|   .watch-table .title-col a[target="_blank"]::after, .watch-table .current-diff-url::after { | ||||
|     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||
|     margin: 0 3px 0 5px; } | ||||
|  | ||||
| .watch-tag-list { | ||||
|   color: #e70069; | ||||
|   white-space: nowrap; } | ||||
|  | ||||
| .box { | ||||
|   max-width: 80%; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
|   justify-content: center; } | ||||
|  | ||||
| #post-list-buttons { | ||||
|   text-align: right; | ||||
|   padding: 0px; | ||||
|   margin: 0px; } | ||||
|   #post-list-buttons li { | ||||
|     display: inline-block; } | ||||
|   #post-list-buttons a { | ||||
|     border-top-left-radius: initial; | ||||
|     border-top-right-radius: initial; | ||||
|     border-bottom-left-radius: 5px; | ||||
|     border-bottom-right-radius: 5px; } | ||||
|  | ||||
| body:after { | ||||
|   content: ""; | ||||
|   background: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%); } | ||||
|  | ||||
| body:after, body:before { | ||||
|   display: block; | ||||
|   height: 600px; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   z-index: -1; } | ||||
|  | ||||
| body::after { | ||||
|   opacity: 0.91; } | ||||
|  | ||||
| body::before { | ||||
|   content: ""; | ||||
|   background-image: url(/static/images/gradient-border.png); } | ||||
|  | ||||
| body:before { | ||||
|   background-size: cover; } | ||||
|  | ||||
| body:after, body:before { | ||||
|   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); | ||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); } | ||||
|  | ||||
| .arrow { | ||||
|   border: solid black; | ||||
|   border-width: 0 3px 3px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; } | ||||
|   .arrow.right { | ||||
|     transform: rotate(-45deg); | ||||
|     -webkit-transform: rotate(-45deg); } | ||||
|   .arrow.left { | ||||
|     transform: rotate(135deg); | ||||
|     -webkit-transform: rotate(135deg); } | ||||
|   .arrow.up { | ||||
|     transform: rotate(-135deg); | ||||
|     -webkit-transform: rotate(-135deg); } | ||||
|   .arrow.down { | ||||
|     transform: rotate(45deg); | ||||
|     -webkit-transform: rotate(45deg); } | ||||
|  | ||||
| .button-small { | ||||
|   font-size: 85%; } | ||||
|  | ||||
| .fetch-error { | ||||
|   padding-top: 1em; | ||||
|   font-size: 60%; | ||||
|   max-width: 400px; | ||||
|   display: block; } | ||||
|  | ||||
| .edit-form { | ||||
|   background: #fff; | ||||
|   padding: 2em; | ||||
|   margin: 1em; | ||||
|   border-radius: 5px; | ||||
|   min-width: 70%; } | ||||
|  | ||||
| .button-secondary { | ||||
|   color: white; | ||||
|   border-radius: 4px; | ||||
|   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } | ||||
|  | ||||
| .button-success { | ||||
|   background: #1cb841; | ||||
|   /* this is a green */ } | ||||
|  | ||||
| .button-tag { | ||||
|   background: #636363; | ||||
|   color: #fff; | ||||
|   font-size: 65%; | ||||
|   border-bottom-left-radius: initial; | ||||
|   border-bottom-right-radius: initial; } | ||||
|   .button-tag.active { | ||||
|     background: #9c9c9c; | ||||
|     font-weight: bold; } | ||||
|  | ||||
| .button-error { | ||||
|   background: #ca3c3c; | ||||
|   /* this is a maroon */ } | ||||
|  | ||||
| .button-warning { | ||||
|   background: #df7514; | ||||
|   /* this is an orange */ } | ||||
|  | ||||
| .button-secondary { | ||||
|   background: #42b8dd; | ||||
|   /* this is a light blue */ } | ||||
|  | ||||
| .button-cancel { | ||||
|   background: #c8c8c8; | ||||
|   /* this is a green */ } | ||||
|  | ||||
| .messages li { | ||||
|   list-style: none; | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   color: #fff; | ||||
|   font-weight: bold; } | ||||
|   .messages li.message { | ||||
|     background: rgba(255, 255, 255, 0.2); } | ||||
|   .messages li.error { | ||||
|     background: rgba(255, 1, 1, 0.5); } | ||||
|   .messages li.notice { | ||||
|     background: rgba(255, 255, 255, 0.5); } | ||||
|  | ||||
| #notification-customisation { | ||||
|   display: block; | ||||
|   border: 1px solid #ccc; | ||||
|   padding: 1rem; | ||||
|   border-radius: 5px; } | ||||
|  | ||||
| #toggle-customise-notifications { | ||||
|   cursor: pointer; } | ||||
|  | ||||
| #token-table.pure-table td, #token-table.pure-table th { | ||||
|   font-size: 80%; } | ||||
|  | ||||
| #new-watch-form { | ||||
|   background: rgba(0, 0, 0, 0.05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; } | ||||
|   #new-watch-form input { | ||||
|     width: auto !important; | ||||
|     display: inline-block; } | ||||
|   #new-watch-form .label { | ||||
|     display: none; } | ||||
|   #new-watch-form legend { | ||||
|     color: #fff; } | ||||
|  | ||||
| #diff-col { | ||||
|   padding-left: 40px; } | ||||
|  | ||||
| #diff-jump { | ||||
|   position: fixed; | ||||
|   left: 0px; | ||||
|   top: 120px; | ||||
|   background: #fff; | ||||
|   padding: 10px; | ||||
|   border-top-right-radius: 5px; | ||||
|   border-bottom-right-radius: 5px; | ||||
|   box-shadow: 5px 0 5px -2px #888; } | ||||
|  | ||||
| #diff-jump a { | ||||
|   color: #1b98f8; | ||||
|   cursor: grabbing; | ||||
|   -moz-user-select: none; | ||||
|   -webkit-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
|   -o-user-select: none; } | ||||
|  | ||||
| footer { | ||||
|   padding: 10px; | ||||
|   background: #fff; | ||||
|   color: #444; | ||||
|   text-align: center; } | ||||
|  | ||||
| #feed-icon { | ||||
|   vertical-align: middle; } | ||||
|  | ||||
| #top-right-menu { | ||||
|   /* | ||||
|     position: absolute; | ||||
|     right: 0px; | ||||
|     background: linear-gradient(to right, #fff0, #fff 10%); | ||||
|     padding-left: 20px; | ||||
|     padding-right: 10px; | ||||
|     */ } | ||||
|  | ||||
| .sticky-tab { | ||||
|   position: absolute; | ||||
|   top: 80px; | ||||
|   font-size: 8px; | ||||
|   background: #fff; | ||||
|   padding: 10px; } | ||||
|   .sticky-tab#left-sticky { | ||||
|     left: 0px; } | ||||
|   .sticky-tab#right-sticky { | ||||
|     right: 0px; } | ||||
|  | ||||
| #new-version-text a { | ||||
|   color: #e07171; } | ||||
|  | ||||
| .paused-state.state-False img { | ||||
|   opacity: 0.2; } | ||||
|  | ||||
| .paused-state.state-False:hover img { | ||||
|   opacity: 0.8; } | ||||
|  | ||||
| .monospaced-textarea textarea { | ||||
|   width: 100%; | ||||
|   font-family: monospace; | ||||
|   white-space: pre; | ||||
|   overflow-wrap: normal; | ||||
|   overflow-x: scroll; } | ||||
|  | ||||
| .pure-form { | ||||
|   /* The input fields with errors */ | ||||
|   /* The list of errors */ } | ||||
|   .pure-form .pure-control-group, .pure-form .pure-group, .pure-form .pure-controls { | ||||
|     padding-bottom: 1em; } | ||||
|     .pure-form .pure-control-group div, .pure-form .pure-group div, .pure-form .pure-controls div { | ||||
|       margin: 0px; } | ||||
|   .pure-form .error input { | ||||
|     background-color: #ffebeb; } | ||||
|   .pure-form ul.errors { | ||||
|     padding: .5em .6em; | ||||
|     border: 1px solid #dd0000; | ||||
|     border-radius: 4px; | ||||
|     vertical-align: middle; | ||||
|     -webkit-box-sizing: border-box; | ||||
|     box-sizing: border-box; } | ||||
|     .pure-form ul.errors li { | ||||
|       margin-left: 1em; | ||||
|       color: #dd0000; } | ||||
|   .pure-form label { | ||||
|     font-weight: bold; } | ||||
|   .pure-form input[type=url] { | ||||
|     width: 100%; } | ||||
|   .pure-form textarea { | ||||
|     width: 100%; } | ||||
|  | ||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { | ||||
|   .box { | ||||
|     max-width: 95%; } | ||||
|   .edit-form { | ||||
|     padding: 0.5em; | ||||
|     margin: 0.5em; } | ||||
|   #nav-menu { | ||||
|     overflow-x: scroll; } } | ||||
|  | ||||
| /* | ||||
| Max width before this PARTICULAR table gets nasty | ||||
| This query will take effect for any screen smaller than 760px | ||||
| and also iPads specifically. | ||||
| */ | ||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { | ||||
|   .watch-table { | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     /* Hide table headers (but not display: none;, for accessibility) */ } | ||||
|     .watch-table thead, .watch-table tbody, .watch-table th, .watch-table td, .watch-table tr { | ||||
|       display: block; } | ||||
|     .watch-table .last-checked::before { | ||||
|       color: #555; | ||||
|       content: "Last Checked "; } | ||||
|     .watch-table .last-changed::before { | ||||
|       color: #555; | ||||
|       content: "Last Changed "; } | ||||
|     .watch-table td.inline { | ||||
|       display: inline-block; } | ||||
|     .watch-table thead tr { | ||||
|       position: absolute; | ||||
|       top: -9999px; | ||||
|       left: -9999px; } | ||||
|     .watch-table .pure-table td, .watch-table .pure-table th { | ||||
|       border: none; } | ||||
|     .watch-table td { | ||||
|       /* Behave  like a "row" */ | ||||
|       border: none; | ||||
|       border-bottom: 1px solid #eee; } | ||||
|       .watch-table td:before { | ||||
|         /* Top/left values mimic padding */ | ||||
|         top: 6px; | ||||
|         left: 6px; | ||||
|         width: 45%; | ||||
|         padding-right: 10px; | ||||
|         white-space: nowrap; } | ||||
|     .watch-table.pure-table-striped tr { | ||||
|       background-color: #fff; } | ||||
|     .watch-table.pure-table-striped tr:nth-child(2n-1) { | ||||
|       background-color: #eee; } | ||||
|     .watch-table.pure-table-striped tr:nth-child(2n-1) td { | ||||
|       background-color: inherit; } } | ||||
| @@ -1,498 +0,0 @@ | ||||
| /* | ||||
|  * -- BASE STYLES -- | ||||
|  * Most of these are inherited from Base, but I want to change a few. | ||||
|  * npm run scss | ||||
|  */ | ||||
| body { | ||||
|   color: #333; | ||||
|   background: #262626; | ||||
| } | ||||
|  | ||||
| .pure-table-even { | ||||
|   background: #fff; | ||||
| } | ||||
|  | ||||
| /* Some styles from https://css-tricks.com/ */ | ||||
| a { | ||||
|   text-decoration: none; | ||||
|   color: #1b98f8; | ||||
| } | ||||
|  | ||||
| a.github-link { | ||||
|   color: #fff; | ||||
| } | ||||
|  | ||||
| .pure-menu-horizontal { | ||||
|   background: #fff; | ||||
|   padding: 5px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   border-bottom: 2px solid #ed5900; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| section.content { | ||||
|   padding-top: 5em; | ||||
|   padding-bottom: 5em; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| /* table related */ | ||||
| .watch-table { | ||||
|   width: 100%; | ||||
|  | ||||
|   tr.unviewed { | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
|   .error { | ||||
|     color: #a00; | ||||
|   } | ||||
|  | ||||
|   td { | ||||
|     font-size: 80%; | ||||
|     white-space: nowrap; | ||||
|   } | ||||
|  | ||||
|   td.title-col { | ||||
|     word-break: break-all; | ||||
|     white-space: normal; | ||||
|   } | ||||
|  | ||||
|   th { | ||||
|     white-space: nowrap; | ||||
|   } | ||||
|  | ||||
|   .title-col a[target="_blank"]::after, .current-diff-url::after { | ||||
|     content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||
|     margin: 0 3px 0 5px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .watch-tag-list { | ||||
|   color: #e70069; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .box { | ||||
|   max-width: 80%; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
|  | ||||
| #post-list-buttons { | ||||
|   text-align: right; | ||||
|   padding: 0px; | ||||
|   margin: 0px; | ||||
|  | ||||
|   li { | ||||
|     display: inline-block; | ||||
|   } | ||||
|  | ||||
|   a { | ||||
|     border-top-left-radius: initial; | ||||
|     border-top-right-radius: initial; | ||||
|     border-bottom-left-radius: 5px; | ||||
|     border-bottom-right-radius: 5px; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| body:after { | ||||
|   content: ""; | ||||
|   background: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%) | ||||
| } | ||||
|  | ||||
| body:after, body:before { | ||||
|   display: block; | ||||
|   height: 600px; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   z-index: -1; | ||||
| } | ||||
|  | ||||
| body::after { | ||||
|   opacity: 0.91; | ||||
| } | ||||
|  | ||||
| body::before { | ||||
|   content: ""; | ||||
|   background-image: url(/static/images/gradient-border.png); | ||||
| } | ||||
|  | ||||
| body:before { | ||||
|   background-size: cover | ||||
| } | ||||
|  | ||||
| body:after, body:before { | ||||
|   -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%); | ||||
|   clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%) | ||||
| } | ||||
|  | ||||
| .arrow { | ||||
|   border: solid black; | ||||
|   border-width: 0 3px 3px 0; | ||||
|   display: inline-block; | ||||
|   padding: 3px; | ||||
|     &.right { | ||||
|       transform: rotate(-45deg); | ||||
|       -webkit-transform: rotate(-45deg); | ||||
|     } | ||||
|     &.left { | ||||
|       transform: rotate(135deg); | ||||
|       -webkit-transform: rotate(135deg); | ||||
|     } | ||||
|     &.up { | ||||
|       transform: rotate(-135deg); | ||||
|       -webkit-transform: rotate(-135deg); | ||||
|     } | ||||
|     &.down { | ||||
|       transform: rotate(45deg); | ||||
|       -webkit-transform: rotate(45deg); | ||||
|     } | ||||
| } | ||||
|  | ||||
| .button-small { | ||||
|   font-size: 85%; | ||||
| } | ||||
|  | ||||
| .fetch-error { | ||||
|   padding-top: 1em; | ||||
|   font-size: 60%; | ||||
|   max-width: 400px; | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .edit-form { | ||||
|   background: #fff; | ||||
|   padding: 2em; | ||||
|   margin: 1em; | ||||
|   border-radius: 5px; | ||||
|   min-width: 70%; | ||||
| } | ||||
|  | ||||
| .button-secondary { | ||||
|   color: white; | ||||
|   border-radius: 4px; | ||||
|   text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .button-success { | ||||
|   background: rgb(28, 184, 65); | ||||
|   /* this is a green */ | ||||
| } | ||||
|  | ||||
| .button-tag { | ||||
|   background: rgb(99, 99, 99); | ||||
|   color: #fff; | ||||
|   font-size: 65%; | ||||
|   border-bottom-left-radius: initial; | ||||
|   border-bottom-right-radius: initial; | ||||
|  | ||||
|   &.active { | ||||
|     background: #9c9c9c; | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| .button-error { | ||||
|   background: rgb(202, 60, 60); | ||||
|   /* this is a maroon */ | ||||
| } | ||||
|  | ||||
| .button-warning { | ||||
|   background: rgb(223, 117, 20); | ||||
|   /* this is an orange */ | ||||
| } | ||||
|  | ||||
| .button-secondary { | ||||
|   background: rgb(66, 184, 221); | ||||
|   /* this is a light blue */ | ||||
| } | ||||
|  | ||||
|  | ||||
| .button-cancel { | ||||
|   background: rgb(200, 200, 200); | ||||
|   /* this is a green */ | ||||
| } | ||||
|  | ||||
| .messages { | ||||
|     li { | ||||
|         list-style: none; | ||||
|         padding: 1em; | ||||
|         border-radius: 10px; | ||||
|         color: #fff; | ||||
|         font-weight: bold; | ||||
|         &.message { | ||||
|             background: rgba(255, 255, 255, .2); | ||||
|         } | ||||
|         &.error { | ||||
|             background: rgba(255, 1, 1, .5); | ||||
|         } | ||||
|         &.notice { | ||||
|             background: rgba(255, 255, 255, .5); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #notification-customisation { | ||||
|     display: block; | ||||
|     border: 1px solid #ccc; | ||||
|     padding: 1rem; | ||||
|     border-radius: 5px; | ||||
| } | ||||
|  | ||||
| #toggle-customise-notifications { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
|  | ||||
| #token-table { | ||||
|     &.pure-table td, &.pure-table th { | ||||
|         font-size: 80%; | ||||
|     } | ||||
| } | ||||
|  | ||||
| #new-watch-form { | ||||
|   background: rgba(0, 0, 0, .05); | ||||
|   padding: 1em; | ||||
|   border-radius: 10px; | ||||
|   margin-bottom: 1em; | ||||
|   input { | ||||
|     width: auto !important; | ||||
|     display: inline-block; | ||||
|   } | ||||
|   .label { | ||||
|     display: none; | ||||
|   } | ||||
|   legend { | ||||
|     color: #fff; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| #diff-col { | ||||
|   padding-left: 40px; | ||||
| } | ||||
|  | ||||
| #diff-jump { | ||||
|   position: fixed; | ||||
|   left: 0px; | ||||
|   top: 120px; | ||||
|   background: #fff; | ||||
|   padding: 10px; | ||||
|   border-top-right-radius: 5px; | ||||
|   border-bottom-right-radius: 5px; | ||||
|   box-shadow: 5px 0 5px -2px #888; | ||||
| } | ||||
|  | ||||
| #diff-jump a { | ||||
|   color: #1b98f8; | ||||
|   cursor: grabbing; | ||||
|   -moz-user-select: none; | ||||
|   -webkit-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
|   -o-user-select: none; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   padding: 10px; | ||||
|   background: #fff; | ||||
|   color: #444; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| #feed-icon { | ||||
|   vertical-align: middle; | ||||
| } | ||||
|  | ||||
| #top-right-menu { | ||||
| // Just let flex overflow the x axis for now | ||||
| /* | ||||
|     position: absolute; | ||||
|     right: 0px; | ||||
|     background: linear-gradient(to right, #fff0, #fff 10%); | ||||
|     padding-left: 20px; | ||||
|     padding-right: 10px; | ||||
|     */ | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| .sticky-tab { | ||||
|   position: absolute; | ||||
|   top: 80px; | ||||
|   font-size: 8px; | ||||
|   background: #fff; | ||||
|   padding: 10px; | ||||
|   &#left-sticky { | ||||
|     left: 0px; | ||||
|   } | ||||
|   &#right-sticky { | ||||
|     right: 0px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| #new-version-text a { | ||||
|   color: #e07171; | ||||
| } | ||||
|  | ||||
| .paused-state { | ||||
|   &.state-False img { | ||||
|     opacity: 0.2; | ||||
|   } | ||||
|  | ||||
|   &.state-False:hover img { | ||||
|     opacity: 0.8; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .monospaced-textarea { | ||||
|     textarea { | ||||
|         width: 100%; | ||||
|         font-family: monospace; | ||||
|         white-space: pre; | ||||
|         overflow-wrap: normal; | ||||
|         overflow-x: scroll; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| .pure-form { | ||||
|     .pure-control-group, .pure-group, .pure-controls { | ||||
|         padding-bottom: 1em; | ||||
|         div { | ||||
|             margin: 0px; | ||||
|         } | ||||
|     } | ||||
|   /* The input fields with errors */ | ||||
|   .error { | ||||
|     input { | ||||
|         background-color: #ffebeb; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* The list of errors */ | ||||
|   ul.errors { | ||||
|     padding: .5em .6em; | ||||
|     border: 1px solid #dd0000; | ||||
|     border-radius: 4px; | ||||
|     vertical-align: middle; | ||||
|     -webkit-box-sizing: border-box; | ||||
|     box-sizing: border-box; | ||||
|     li { | ||||
|         margin-left: 1em; | ||||
|         color: #dd0000; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   label { | ||||
|     font-weight: bold; | ||||
|   } | ||||
|  | ||||
|   input[type=url] { | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   textarea { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { | ||||
|   .box { | ||||
|     max-width: 95% | ||||
|   } | ||||
|   .edit-form { | ||||
|     padding: 0.5em; | ||||
|     margin: 0.5em; | ||||
|   } | ||||
|   #nav-menu { | ||||
|     overflow-x: scroll; | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| /* | ||||
| Max width before this PARTICULAR table gets nasty | ||||
| This query will take effect for any screen smaller than 760px | ||||
| and also iPads specifically. | ||||
| */ | ||||
| @media only screen and (max-width: 760px), | ||||
| (min-device-width: 768px) and (max-device-width: 1024px) { | ||||
|  | ||||
|   .watch-table { | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     thead, tbody, th, td, tr { | ||||
|       display: block; | ||||
|     } | ||||
|  | ||||
|     .last-checked::before { | ||||
|       color: #555; | ||||
|       content: "Last Checked "; | ||||
|     } | ||||
|  | ||||
|     .last-changed::before { | ||||
|       color: #555; | ||||
|       content: "Last Changed "; | ||||
|     } | ||||
|  | ||||
|     /* Force table to not be like tables anymore */ | ||||
|     td.inline { | ||||
|       display: inline-block; | ||||
|     } | ||||
|  | ||||
|     /* Hide table headers (but not display: none;, for accessibility) */ | ||||
|     thead tr { | ||||
|       position: absolute; | ||||
|       top: -9999px; | ||||
|       left: -9999px; | ||||
|     } | ||||
|  | ||||
|     .pure-table td, .pure-table th { | ||||
|       border: none; | ||||
|     } | ||||
|  | ||||
|     td { | ||||
|       /* Behave  like a "row" */ | ||||
|       border: none; | ||||
|       border-bottom: 1px solid #eee; | ||||
|  | ||||
|       &:before { | ||||
|         /* Top/left values mimic padding */ | ||||
|         top: 6px; | ||||
|         left: 6px; | ||||
|         width: 45%; | ||||
|         padding-right: 10px; | ||||
|         white-space: nowrap; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.pure-table-striped { | ||||
|       tr { | ||||
|         background-color: #fff; | ||||
|       } | ||||
|  | ||||
|       tr:nth-child(2n-1) { | ||||
|         background-color: #eee; | ||||
|       } | ||||
|  | ||||
|       tr:nth-child(2n-1) td { | ||||
|         background-color: inherit; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										378
									
								
								backend/store.py
									
									
									
									
									
								
							
							
						
						
									
										378
									
								
								backend/store.py
									
									
									
									
									
								
							| @@ -1,378 +0,0 @@ | ||||
| from os import unlink, path, mkdir | ||||
| import json | ||||
| import uuid as uuid_builder | ||||
| from threading import Lock | ||||
| from copy import deepcopy | ||||
|  | ||||
| import logging | ||||
| import time | ||||
| import threading | ||||
|  | ||||
|  | ||||
| # Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods? | ||||
| # Open a github issue if you know something :) | ||||
| # https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change | ||||
| class ChangeDetectionStore: | ||||
|     lock = Lock() | ||||
|  | ||||
|     def __init__(self, datastore_path="/datastore", include_default_watches=True): | ||||
|         self.needs_write = False | ||||
|         self.datastore_path = datastore_path | ||||
|         self.json_store_path = "{}/url-watches.json".format(self.datastore_path) | ||||
|         self.stop_thread = False | ||||
|  | ||||
|         self.__data = { | ||||
|             'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", | ||||
|             'watching': {}, | ||||
|             'settings': { | ||||
|                 'headers': { | ||||
|                     'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36', | ||||
|                     'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', | ||||
|                     'Accept-Encoding': 'gzip, deflate',  # No support for brolti in python requests yet. | ||||
|                     'Accept-Language': 'en-GB,en-US;q=0.9,en;' | ||||
|                 }, | ||||
|                 'requests': { | ||||
|                     'timeout': 15,  # Default 15 seconds | ||||
|                     'minutes_between_check': 3 * 60,  # Default 3 hours | ||||
|                     'workers': 10  # Number of threads, lower is better for slow connections | ||||
|                 }, | ||||
|                 'application': { | ||||
|                     'password': False, | ||||
|                     'extract_title_as_title': False, | ||||
|                     'notification_urls': [], # Apprise URL list | ||||
|                     # Custom notification content | ||||
|                     'notification_title': 'ChangeDetection.io Notification - {watch_url}', | ||||
|                     'notification_body': '{base_url}' | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         # Base definition for all watchers | ||||
|         self.generic_definition = { | ||||
|             'url': None, | ||||
|             'tag': None, | ||||
|             'last_checked': 0, | ||||
|             'last_changed': 0, | ||||
|             'paused': False, | ||||
|             'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||
|             'newest_history_key': "", | ||||
|             'title': None, | ||||
|             # Re #110, so then if this is set to None, we know to use the default value instead | ||||
|             # Requires setting to None on submit if it's the same as the default | ||||
|             'minutes_between_check': None, | ||||
|             'previous_md5': "", | ||||
|             'uuid': str(uuid_builder.uuid4()), | ||||
|             'headers': {},  # Extra headers to send | ||||
|             'history': {},  # Dict of timestamp and output stripped filename | ||||
|             'ignore_text': [], # List of text to ignore when calculating the comparison checksum | ||||
|             'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise) | ||||
|             'css_filter': "", | ||||
|         } | ||||
|  | ||||
|         if path.isfile('backend/source.txt'): | ||||
|             with open('backend/source.txt') as f: | ||||
|                 # Should be set in Dockerfile to look for /source.txt , this will give us the git commit # | ||||
|                 # So when someone gives us a backup file to examine, we know exactly what code they were running. | ||||
|                 self.__data['build_sha'] = f.read() | ||||
|  | ||||
|         try: | ||||
|             # @todo retest with ", encoding='utf-8'" | ||||
|             with open(self.json_store_path) as json_file: | ||||
|                 from_disk = json.load(json_file) | ||||
|  | ||||
|                 # @todo isnt there a way todo this dict.update recursively? | ||||
|                 # Problem here is if the one on the disk is missing a sub-struct, it wont be present anymore. | ||||
|                 if 'watching' in from_disk: | ||||
|                     self.__data['watching'].update(from_disk['watching']) | ||||
|  | ||||
|                 if 'app_guid' in from_disk: | ||||
|                     self.__data['app_guid'] = from_disk['app_guid'] | ||||
|  | ||||
|                 if 'settings' in from_disk: | ||||
|                     if 'headers' in from_disk['settings']: | ||||
|                         self.__data['settings']['headers'].update(from_disk['settings']['headers']) | ||||
|  | ||||
|                     if 'requests' in from_disk['settings']: | ||||
|                         self.__data['settings']['requests'].update(from_disk['settings']['requests']) | ||||
|  | ||||
|                     if 'application' in from_disk['settings']: | ||||
|                         self.__data['settings']['application'].update(from_disk['settings']['application']) | ||||
|  | ||||
|                 # Reinitialise each `watching` with our generic_definition in the case that we add a new var in the future. | ||||
|                 # @todo pretty sure theres a python we todo this with an abstracted(?) object! | ||||
|                 for uuid, watch in self.__data['watching'].items(): | ||||
|                     _blank = deepcopy(self.generic_definition) | ||||
|                     _blank.update(watch) | ||||
|                     self.__data['watching'].update({uuid: _blank}) | ||||
|                     self.__data['watching'][uuid]['newest_history_key'] = self.get_newest_history_key(uuid) | ||||
|                     print("Watching:", uuid, self.__data['watching'][uuid]['url']) | ||||
|  | ||||
|         # First time ran, doesnt exist. | ||||
|         except (FileNotFoundError, json.decoder.JSONDecodeError): | ||||
|             if include_default_watches: | ||||
|                 print("Creating JSON store at", self.datastore_path) | ||||
|  | ||||
|                 self.add_watch(url='http://www.quotationspage.com/random.php', tag='test') | ||||
|                 self.add_watch(url='https://news.ycombinator.com/', tag='Tech news') | ||||
|                 self.add_watch(url='https://www.gov.uk/coronavirus', tag='Covid') | ||||
|                 self.add_watch(url='https://changedetection.io', tag='Tech news') | ||||
|  | ||||
|         self.__data['version_tag'] = "0.38.1" | ||||
|  | ||||
|         # Helper to remove password protection | ||||
|         password_reset_lockfile = "{}/removepassword.lock".format(self.datastore_path) | ||||
|         if path.isfile(password_reset_lockfile): | ||||
|             self.__data['settings']['application']['password'] = False | ||||
|             unlink(password_reset_lockfile) | ||||
|  | ||||
|         if not 'app_guid' in self.__data: | ||||
|             import sys | ||||
|             import os | ||||
|             if "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ: | ||||
|                 self.__data['app_guid'] = "test-" + str(uuid_builder.uuid4()) | ||||
|             else: | ||||
|                 self.__data['app_guid'] = str(uuid_builder.uuid4()) | ||||
|  | ||||
|         self.needs_write = True | ||||
|  | ||||
|         # Finally start the thread that will manage periodic data saves to JSON | ||||
|         save_data_thread = threading.Thread(target=self.save_datastore).start() | ||||
|  | ||||
|     # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0. | ||||
|     def get_newest_history_key(self, uuid): | ||||
|         if len(self.__data['watching'][uuid]['history']) == 1: | ||||
|             return 0 | ||||
|  | ||||
|         dates = list(self.__data['watching'][uuid]['history'].keys()) | ||||
|         # Convert to int, sort and back to str again | ||||
|         dates = [int(i) for i in dates] | ||||
|         dates.sort(reverse=True) | ||||
|         if len(dates): | ||||
|             # always keyed as str | ||||
|             return str(dates[0]) | ||||
|  | ||||
|         return 0 | ||||
|  | ||||
|     def set_last_viewed(self, uuid, timestamp): | ||||
|         self.data['watching'][uuid].update({'last_viewed': int(timestamp)}) | ||||
|         self.needs_write = True | ||||
|  | ||||
|     def update_watch(self, uuid, update_obj): | ||||
|  | ||||
|         # Skip if 'paused' state | ||||
|         if self.__data['watching'][uuid]['paused']: | ||||
|             return | ||||
|  | ||||
|         with self.lock: | ||||
|  | ||||
|             # In python 3.9 we have the |= dict operator, but that still will lose data on nested structures... | ||||
|             for dict_key, d in self.generic_definition.items(): | ||||
|                 if isinstance(d, dict): | ||||
|                     if update_obj is not None and dict_key in update_obj: | ||||
|                         self.__data['watching'][uuid][dict_key].update(update_obj[dict_key]) | ||||
|                         del (update_obj[dict_key]) | ||||
|  | ||||
|             self.__data['watching'][uuid].update(update_obj) | ||||
|             self.__data['watching'][uuid]['newest_history_key'] = self.get_newest_history_key(uuid) | ||||
|  | ||||
|         self.needs_write = True | ||||
|  | ||||
|     @property | ||||
|     def data(self): | ||||
|         has_unviewed = False | ||||
|         for uuid, v in self.__data['watching'].items(): | ||||
|             self.__data['watching'][uuid]['newest_history_key'] = self.get_newest_history_key(uuid) | ||||
|             if int(v['newest_history_key']) <= int(v['last_viewed']): | ||||
|                 self.__data['watching'][uuid]['viewed'] = True | ||||
|  | ||||
|             else: | ||||
|                 self.__data['watching'][uuid]['viewed'] = False | ||||
|                 has_unviewed = True | ||||
|  | ||||
|             # #106 - Be sure this is None on empty string, False, None, etc | ||||
|             if not self.__data['watching'][uuid]['title']: | ||||
|                 self.__data['watching'][uuid]['title'] = None | ||||
|  | ||||
|         self.__data['has_unviewed'] = has_unviewed | ||||
|  | ||||
|         return self.__data | ||||
|  | ||||
|     def get_all_tags(self): | ||||
|         tags = [] | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|  | ||||
|             # Support for comma separated list of tags. | ||||
|             for tag in watch['tag'].split(','): | ||||
|                 tag = tag.strip() | ||||
|                 if tag not in tags: | ||||
|                     tags.append(tag) | ||||
|  | ||||
|         tags.sort() | ||||
|         return tags | ||||
|  | ||||
|     def unlink_history_file(self, path): | ||||
|         try: | ||||
|             unlink(path) | ||||
|         except (FileNotFoundError, IOError): | ||||
|             pass | ||||
|  | ||||
|     # Delete a single watch by UUID | ||||
|     def delete(self, uuid): | ||||
|         with self.lock: | ||||
|             if uuid == 'all': | ||||
|                 self.__data['watching'] = {} | ||||
|  | ||||
|                 # GitHub #30 also delete history records | ||||
|                 for uuid in self.data['watching']: | ||||
|                     for path in self.data['watching'][uuid]['history'].values(): | ||||
|                         self.unlink_history_file(path) | ||||
|  | ||||
|             else: | ||||
|                 for path in self.data['watching'][uuid]['history'].values(): | ||||
|                     self.unlink_history_file(path) | ||||
|  | ||||
|                 del self.data['watching'][uuid] | ||||
|  | ||||
|             self.needs_write = True | ||||
|  | ||||
|     def url_exists(self, url): | ||||
|  | ||||
|         # Probably their should be dict... | ||||
|         for watch in self.data['watching'].values(): | ||||
|             if watch['url'] == url: | ||||
|                 return True | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def get_val(self, uuid, val): | ||||
|         # Probably their should be dict... | ||||
|         return self.data['watching'][uuid].get(val) | ||||
|  | ||||
|     # Remove a watchs data but keep the entry (URL etc) | ||||
|     def scrub_watch(self, uuid, limit_timestamp = False): | ||||
|  | ||||
|         import hashlib | ||||
|         del_timestamps = [] | ||||
|  | ||||
|         changes_removed = 0 | ||||
|  | ||||
|         for timestamp, path in self.data['watching'][uuid]['history'].items(): | ||||
|             if not limit_timestamp or (limit_timestamp is not False and int(timestamp) > limit_timestamp): | ||||
|                 self.unlink_history_file(path) | ||||
|                 del_timestamps.append(timestamp) | ||||
|                 changes_removed += 1 | ||||
|  | ||||
|                 if not limit_timestamp: | ||||
|                     self.data['watching'][uuid]['last_checked'] = 0 | ||||
|                     self.data['watching'][uuid]['last_changed'] = 0 | ||||
|                     self.data['watching'][uuid]['previous_md5'] = 0 | ||||
|  | ||||
|  | ||||
|         for timestamp in del_timestamps: | ||||
|             del self.data['watching'][uuid]['history'][str(timestamp)] | ||||
|  | ||||
|             # If there was a limitstamp, we need to reset some meta data about the entry | ||||
|             # This has to happen after we remove the others from the list | ||||
|             if limit_timestamp: | ||||
|                 newest_key = self.get_newest_history_key(uuid) | ||||
|                 if newest_key: | ||||
|                     self.data['watching'][uuid]['last_checked'] = int(newest_key) | ||||
|                     # @todo should be the original value if it was less than newest key | ||||
|                     self.data['watching'][uuid]['last_changed'] = int(newest_key) | ||||
|                     try: | ||||
|                         with open(self.data['watching'][uuid]['history'][str(newest_key)], "rb") as fp: | ||||
|                             content = fp.read() | ||||
|                         self.data['watching'][uuid]['previous_md5'] = hashlib.md5(content).hexdigest() | ||||
|                     except (FileNotFoundError, IOError): | ||||
|                         self.data['watching'][uuid]['previous_md5'] = False | ||||
|                         pass | ||||
|  | ||||
|         self.needs_write = True | ||||
|         return changes_removed | ||||
|  | ||||
|     def add_watch(self, url, tag): | ||||
|         with self.lock: | ||||
|             # @todo use a common generic version of this | ||||
|             new_uuid = str(uuid_builder.uuid4()) | ||||
|             _blank = deepcopy(self.generic_definition) | ||||
|             _blank.update({ | ||||
|                 'url': url, | ||||
|                 'tag': tag, | ||||
|                 'uuid': new_uuid | ||||
|             }) | ||||
|  | ||||
|             self.data['watching'][new_uuid] = _blank | ||||
|  | ||||
|         # Get the directory ready | ||||
|         output_path = "{}/{}".format(self.datastore_path, new_uuid) | ||||
|         try: | ||||
|             mkdir(output_path) | ||||
|         except FileExistsError: | ||||
|             print(output_path, "already exists.") | ||||
|  | ||||
|         self.sync_to_json() | ||||
|         return new_uuid | ||||
|  | ||||
|     # Save some text file to the appropriate path and bump the history | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, uuid, result_obj, contents): | ||||
|  | ||||
|         output_path = "{}/{}".format(self.datastore_path, uuid) | ||||
|         fname = "{}/{}-{}.stripped.txt".format(output_path, result_obj['previous_md5'], str(time.time())) | ||||
|         with open(fname, 'w') as f: | ||||
|             f.write(contents) | ||||
|             f.close() | ||||
|  | ||||
|         # Update history with the stripped text for future reference, this will also mean we save the first | ||||
|         # Should always be keyed by string(timestamp) | ||||
|         self.update_watch(uuid, {"history": {str(result_obj["last_checked"]): fname}}) | ||||
|  | ||||
|         return fname | ||||
|  | ||||
|     def sync_to_json(self): | ||||
|         print("Saving..") | ||||
|         data ={} | ||||
|  | ||||
|         try: | ||||
|             data = deepcopy(self.__data) | ||||
|         except RuntimeError: | ||||
|             time.sleep(0.5) | ||||
|             print ("! Data changed when writing to JSON, trying again..") | ||||
|             self.sync_to_json() | ||||
|             return | ||||
|         else: | ||||
|             with open(self.json_store_path, 'w') as json_file: | ||||
|                 json.dump(data, json_file, indent=4) | ||||
|                 logging.info("Re-saved index") | ||||
|  | ||||
|             self.needs_write = False | ||||
|  | ||||
|     # Thread runner, this helps with thread/write issues when there are many operations that want to update the JSON | ||||
|     # by just running periodically in one thread, according to python, dict updates are threadsafe. | ||||
|     def save_datastore(self): | ||||
|  | ||||
|         while True: | ||||
|             if self.stop_thread: | ||||
|                 print("Shutting down datastore thread") | ||||
|                 return | ||||
|  | ||||
|             if self.needs_write: | ||||
|                 self.sync_to_json() | ||||
|             time.sleep(3) | ||||
|  | ||||
|     # Go through the datastore path and remove any snapshots that are not mentioned in the index | ||||
|     # This usually is not used, but can be handy. | ||||
|     def remove_unused_snapshots(self): | ||||
|         print ("Removing snapshots from datastore that are not in the index..") | ||||
|  | ||||
|         index=[] | ||||
|         for uuid in self.data['watching']: | ||||
|             for id in self.data['watching'][uuid]['history']: | ||||
|                 index.append(self.data['watching'][uuid]['history'][str(id)]) | ||||
|  | ||||
|         import pathlib | ||||
|         # Only in the sub-directories | ||||
|         for item in pathlib.Path(self.datastore_path).rglob("*/*txt"): | ||||
|             if not str(item) in index: | ||||
|                 print ("Removing",item) | ||||
|                 unlink(item) | ||||
| @@ -1,25 +0,0 @@ | ||||
| {% macro render_field(field) %} | ||||
|   <div {% if field.errors %} class="error" {% endif %}>{{ field.label }}</div> | ||||
|   <div {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }} | ||||
|   {% if field.errors %} | ||||
|     <ul class=errors> | ||||
|     {% for error in field.errors %} | ||||
|       <li>{{ error }}</li> | ||||
|     {% endfor %} | ||||
|     </ul> | ||||
|   {% endif %} | ||||
|   </div> | ||||
| {% endmacro %} | ||||
|  | ||||
| {% macro render_simple_field(field) %} | ||||
|   <span class="label {% if field.errors %}error{% endif %}">{{ field.label }}</span> | ||||
|   <span {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }} | ||||
|   {% if field.errors %} | ||||
|     <ul class=errors> | ||||
|     {% for error in field.errors %} | ||||
|       <li>{{ error }}</li> | ||||
|     {% endfor %} | ||||
|     </ul> | ||||
|   {% endif %} | ||||
|   </span> | ||||
| {% endmacro %} | ||||
| @@ -1,94 +0,0 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <meta name="description" content="Self hosted website change detection."> | ||||
|     <title>Change Detection{{extra_title}}</title> | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='pure-min.css')}}"> | ||||
|     <link rel="stylesheet" href="{{url_for('static_content', group='styles', filename='styles.css')}}"> | ||||
|     {% if extra_stylesheets %} | ||||
|         {% for m in extra_stylesheets %} | ||||
|         <link rel="stylesheet" href="{{ m }}?ver=1000"> | ||||
|         {% endfor %} | ||||
|     {% endif %} | ||||
| </head> | ||||
| <body> | ||||
|  | ||||
| <div class="header"> | ||||
|  | ||||
|     <div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu"> | ||||
|         {% if has_password and not current_user.is_authenticated %} | ||||
|             <a class="pure-menu-heading" href="https://github.com/dgtlmoon/changedetection.io" rel="noopener"><strong>Change</strong>Detection.io</a> | ||||
|         {% else %} | ||||
|             <a class="pure-menu-heading" href="{{url_for('index')}}"><strong>Change</strong>Detection.io</a> | ||||
|         {% endif %} | ||||
|         {% if current_diff_url %} | ||||
|         <a class=current-diff-url href="{{ current_diff_url }}"><span style="max-width: 30%; overflow: hidden;">{{ current_diff_url }}</span></a> | ||||
|         {% else %} | ||||
|         {% if new_version_available %} | ||||
|         <span id="new-version-text" class="pure-menu-heading"><a href="https://github.com/dgtlmoon/changedetection.io">A new version is available</a></span> | ||||
|         {% endif %} | ||||
|         {% endif %} | ||||
|  | ||||
|         <ul class="pure-menu-list"  id="top-right-menu"> | ||||
|         {% if current_user.is_authenticated or not has_password %} | ||||
|             {% if not current_diff_url %} | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('get_backup')}}" class="pure-menu-link">BACKUP</a> | ||||
|             </li> | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('import_page')}}" class="pure-menu-link">IMPORT</a> | ||||
|             </li> | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('settings_page')}}" class="pure-menu-link">SETTINGS</a> | ||||
|             </li> | ||||
|             {% else %} | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="{{ url_for('edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">EDIT</a> | ||||
|             </li> | ||||
|             {% endif %} | ||||
|         {% else %} | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a class="pure-menu-link" href="https://github.com/dgtlmoon/changedetection.io">Website Change Detection and Notification.</a> | ||||
|             </li> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if current_user.is_authenticated %} | ||||
|             <li class="pure-menu-item"><a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a></li> | ||||
|         {% endif %} | ||||
|             <li class="pure-menu-item"><a class="github-link" href="https://github.com/dgtlmoon/changedetection.io"> | ||||
|                 <svg class="octicon octicon-mark-github v-align-middle" height="32" viewBox="0 0 16 16" | ||||
|                      version="1.1" | ||||
|                      width="32" aria-hidden="true"> | ||||
|                     <path fill-rule="evenodd" | ||||
|                           d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> | ||||
|                 </svg> | ||||
|             </a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% if left_sticky %}<div class="sticky-tab" id="left-sticky"><a href="{{url_for('preview_page', uuid=uuid)}}">Show current snapshot</a></div> {% endif %} | ||||
| {% if right_sticky %}<div class="sticky-tab" id="right-sticky">{{ right_sticky }}</div> {% endif %} | ||||
| <section class="content"> | ||||
|     <header> | ||||
|         {% block header %}{% endblock %} | ||||
|     </header> | ||||
|  | ||||
|     {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|       {% if messages %} | ||||
|         <ul class=messages> | ||||
|         {% for category, message in messages %} | ||||
|           <li class="{{ category }}">{{ message }}</li> | ||||
|         {% endfor %} | ||||
|         </ul> | ||||
|       {% endif %} | ||||
|     {% endwith %} | ||||
|     {% block content %} | ||||
|  | ||||
|     {% endblock %} | ||||
| </section> | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
| @@ -1,172 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div id="settings"> | ||||
|     <h1>Differences</h1> | ||||
|     <form class="pure-form " action="" method="GET"> | ||||
|         <fieldset> | ||||
|  | ||||
|             <label for="diffWords" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffWords" value="diffWords"/> Words</label> | ||||
|             <label for="diffLines" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""/> Lines</label> | ||||
|  | ||||
|             <label for="diffChars" class="pure-checkbox"> | ||||
|                 <input type="radio" name="diff_type" id="diffChars" value="diffChars"/> Chars</label> | ||||
|  | ||||
|             {% if versions|length >= 1 %} | ||||
|             <label for="diff-version">Compare newest (<span id="current-v-date"></span>) with</label> | ||||
|             <select id="diff-version" name="previous_version"> | ||||
|                 {% for version in versions %} | ||||
|                 <option value="{{version}}" {% if version== current_previous_version %} selected="" {% endif %}> | ||||
|                     {{version}} | ||||
|                 </option> | ||||
|                 {% endfor %} | ||||
|             </select> | ||||
|             <button type="submit" class="pure-button pure-button-primary">Go</button> | ||||
|             {% endif %} | ||||
|         </fieldset> | ||||
|     </form> | ||||
|     <del>Removed text</del> | ||||
|     <ins>Inserted Text</ins> | ||||
| </div> | ||||
|  | ||||
| <div id="diff-jump"> | ||||
|     <a onclick="next_diff();">Jump</a> | ||||
| </div> | ||||
| <div id="diff-ui"> | ||||
|     <table> | ||||
|         <tbody> | ||||
|         <tr> | ||||
|             <!-- just proof of concept copied straight from github.com/kpdecker/jsdiff --> | ||||
|             <td id="a" style="display: none;">{{previous}}</td> | ||||
|             <td id="b" style="display: none;">{{newest}}</td> | ||||
|             <td id="diff-col"> | ||||
|                 <span id="result"></span> | ||||
|             </td> | ||||
|         </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
|     Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <script src="/static/js/diff.js"></script> | ||||
| <script defer=""> | ||||
|  | ||||
|  | ||||
| var a = document.getElementById('a'); | ||||
| var b = document.getElementById('b'); | ||||
| var result = document.getElementById('result'); | ||||
|  | ||||
| function changed() { | ||||
| 	var diff = JsDiff[window.diffType](a.textContent, b.textContent); | ||||
| 	var fragment = document.createDocumentFragment(); | ||||
| 	for (var i=0; i < diff.length; i++) { | ||||
|  | ||||
| 		if (diff[i].added && diff[i + 1] && diff[i + 1].removed) { | ||||
| 			var swap = diff[i]; | ||||
| 			diff[i] = diff[i + 1]; | ||||
| 			diff[i + 1] = swap; | ||||
| 		} | ||||
|  | ||||
| 		var node; | ||||
| 		if (diff[i].removed) { | ||||
| 			node = document.createElement('del'); | ||||
| 			node.classList.add("change"); | ||||
| 			node.appendChild(document.createTextNode(diff[i].value)); | ||||
|  | ||||
| 		} else if (diff[i].added) { | ||||
| 			node = document.createElement('ins'); | ||||
| 			node.classList.add("change"); | ||||
| 			node.appendChild(document.createTextNode(diff[i].value)); | ||||
| 		} else { | ||||
| 			node = document.createTextNode(diff[i].value); | ||||
| 		} | ||||
| 		fragment.appendChild(node); | ||||
| 	} | ||||
|  | ||||
| 	result.textContent = ''; | ||||
| 	result.appendChild(fragment); | ||||
|  | ||||
| 	// Jump at start | ||||
| 	inputs.current=0; | ||||
|     next_diff(); | ||||
| } | ||||
|  | ||||
| window.onload = function() { | ||||
|  | ||||
|  | ||||
|     /* Convert what is options from UTC time.time() to local browser time */ | ||||
|     var diffList=document.getElementById("diff-version"); | ||||
|     if (typeof(diffList) != 'undefined' && diffList != null) { | ||||
|         for (var option of diffList.options) { | ||||
|           var dateObject = new Date(option.value*1000); | ||||
|           option.label=dateObject.toLocaleString(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /* Set current version date as local time in the browser also */ | ||||
|     var current_v = document.getElementById("current-v-date"); | ||||
|     var dateObject = new Date({{ newest_version_timestamp }}*1000); | ||||
|     current_v.innerHTML=dateObject.toLocaleString(); | ||||
|  | ||||
|  | ||||
| 	onDiffTypeChange(document.querySelector('#settings [name="diff_type"]:checked')); | ||||
| 	changed(); | ||||
|  | ||||
| }; | ||||
|  | ||||
| a.onpaste = a.onchange = | ||||
| b.onpaste = b.onchange = changed; | ||||
|  | ||||
| if ('oninput' in a) { | ||||
| 	a.oninput = b.oninput = changed; | ||||
| } else { | ||||
| 	a.onkeyup = b.onkeyup = changed; | ||||
| } | ||||
|  | ||||
| function onDiffTypeChange(radio) { | ||||
| 	window.diffType = radio.value; | ||||
| // Not necessary  | ||||
| //	document.title = "Diff " + radio.value.slice(4); | ||||
| } | ||||
|  | ||||
| var radio = document.getElementsByName('diff_type'); | ||||
| for (var i = 0; i < radio.length; i++) { | ||||
| 	radio[i].onchange = function(e) { | ||||
| 		onDiffTypeChange(e.target); | ||||
| 		changed(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| var inputs = document.getElementsByClassName('change'); | ||||
| inputs.current=0; | ||||
|  | ||||
|  | ||||
| function next_diff() { | ||||
|  | ||||
|     var element = inputs[inputs.current]; | ||||
|     var headerOffset = 80; | ||||
|     var elementPosition = element.getBoundingClientRect().top; | ||||
|     var offsetPosition = elementPosition - headerOffset +  window.scrollY; | ||||
|  | ||||
|     window.scrollTo({ | ||||
|          top: offsetPosition, | ||||
|          behavior: "smooth" | ||||
|     }); | ||||
|  | ||||
|     inputs.current++; | ||||
|     if(inputs.current >= inputs.length) { | ||||
|       inputs.current=0; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,79 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field %} | ||||
| <div class="edit-form monospaced-textarea"> | ||||
|     <form class="pure-form pure-form-stacked" action="{{ url_for('edit_page', uuid=uuid, next = request.args.get('next') ) }}" method="POST"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.url, placeholder="https://...", size=30, required=true) }} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.title, size=30) }} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.tag, size=10) }} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.minutes_between_check, size=5) }} | ||||
|                 {% if using_default_minutes %} | ||||
|                     <span class="pure-form-message-inline">Currently using the <a href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>, change to another value if you want to be specific.</span> | ||||
|                 {% else %} | ||||
|                     <span class="pure-form-message-inline">Set to blank to use the <a href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>.</span> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.css_filter, size=25, placeholder=".class-name or #some-id, or other CSS selector rule.") }} | ||||
|                 <span class="pure-form-message-inline"> | ||||
|                     <ul> | ||||
|                         <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li> | ||||
|                         <li>JSON - Limit text to this JSON rule, using <a href="https://pypi.org/project/jsonpath-ng/">JSONPath</a>, prefix with <b>"json:"</b>, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a></li> | ||||
|                     </ul> | ||||
|                     Please be sure that you thoroughly understand how to write CSS or JSONPath selector rules before filing an issue on GitHub! <a href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br/> | ||||
|                 </span> | ||||
|             </div> | ||||
|             <!-- @todo: move to tabs ---> | ||||
|             <fieldset class="pure-group"> | ||||
|                 {{ render_field(form.ignore_text, rows=5,  placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/   for case-INsensitive regex | ||||
| ") }} | ||||
|                 <span class="pure-form-message-inline"> | ||||
|                     Each line processed separately, any line matching will be ignored.<br/> | ||||
|                     Regular Expression support, wrap the line in forward slash <b>/regex/</b>. | ||||
|                 </span> | ||||
|  | ||||
|             </fieldset> | ||||
|  | ||||
|             <fieldset class="pure-group"> | ||||
|                 {{ render_field(form.headers, rows=5, placeholder="Example | ||||
| Cookie: foobar | ||||
| User-Agent: wonderbra 1.0") }} | ||||
|             </fieldset> | ||||
|  | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.notification_urls, rows=5, placeholder="Examples: | ||||
| Gitter - gitter://token/room | ||||
| Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail | ||||
| AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo | ||||
| SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com | ||||
| ") }} | ||||
|                 <span class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service!</span> | ||||
|             </div> | ||||
|  | ||||
|             <div class="pure-controls"> | ||||
|                 {{ render_field(form.trigger_check, rows=5) }} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Save</button> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Cancel</a> | ||||
|                 <a href="{{url_for('api_delete', uuid=uuid)}}" | ||||
|                    class="pure-button button-small button-error ">Delete</a> | ||||
|             </div> | ||||
|         </fieldset> | ||||
|     </form> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,26 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|  | ||||
|  | ||||
|     <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST"> | ||||
|  | ||||
|         <fieldset class="pure-group"> | ||||
|             <legend>One URL per line, URLs that do not pass validation will stay in the textarea.</legend> | ||||
|  | ||||
|             <textarea name="urls" class="pure-input-1-2" placeholder="https://" | ||||
|                       style="width: 100%; | ||||
|                             font-family:monospace; | ||||
|                             white-space: pre; | ||||
|                             overflow-wrap: normal; | ||||
|                             overflow-x: scroll;" rows="25">{{ remaining }}</textarea> | ||||
|         </fieldset> | ||||
|         <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> | ||||
|  | ||||
|     </form> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
|  | ||||
| @@ -1,26 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div id="settings"> | ||||
|     <h1>Current</h1> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| <div id="diff-ui"> | ||||
|  | ||||
|     <table> | ||||
|         <tbody> | ||||
|         <tr> | ||||
|             <!-- just proof of concept copied straight from github.com/kpdecker/jsdiff --> | ||||
|  | ||||
|             <td id="diff-col"> | ||||
|                 <span id="result">{% for row in content %}<pre>{{row}}</pre>{% endfor %}</span> | ||||
|             </td> | ||||
|         </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,35 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|     <form class="pure-form pure-form-stacked" action="{{url_for('scrub_page')}}" method="POST"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 This will remove all version snapshots/data, but keep your list of URLs. <br/> | ||||
|                 You may like to use the <strong>BACKUP</strong> link first.<br/> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="confirmtext">Confirmation text</label> | ||||
|                 <input type="text" id="confirmtext" required="" name="confirmtext" value="" size="10"/> | ||||
|                 <span class="pure-form-message-inline">Type in the word <strong>scrub</strong> to confirm that you understand!</span> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="confirmtext">Optional: Limit deletion of snapshots to snapshots <i>newer</i> than date/time</label> | ||||
|                 <input type="datetime-local" id="limit_date" name="limit_date"  /> | ||||
|                 <span class="pure-form-message-inline">dd/mm/yyyy hh:mm (24 hour format)</span> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Scrub!</button> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Cancel</a> | ||||
|             </div> | ||||
|         </fieldset> | ||||
|     </form> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,107 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field %} | ||||
| <script type="text/javascript" src="static/js/settings.js"></script> | ||||
| <div class="edit-form"> | ||||
|     <form class="pure-form pure-form-stacked settings" action="{{url_for('settings_page')}}" method="POST"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.minutes_between_check, size=5) }} | ||||
|                 <span class="pure-form-message-inline">Default time for all watches, when the watch does not have a specific time setting.</span> | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {% if current_user.is_authenticated %} | ||||
|                     <a href="{{url_for('settings_page', removepassword='yes')}}" class="pure-button pure-button-primary">Remove password</a> | ||||
|                 {% else %} | ||||
|                 {{ render_field(form.password, size=10) }} | ||||
|                 <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 {{ render_field(form.extract_title_as_title) }} | ||||
|                 <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> | ||||
|             </div> | ||||
|  | ||||
|             <div class="field-group"> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_field(form.notification_urls, rows=5, placeholder="Examples: | ||||
| Gitter - gitter://token/room | ||||
| Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail | ||||
| AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo | ||||
| SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com") }} | ||||
|                     <div class="pure-form-message-inline">Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! | ||||
|                     <a id="toggle-customise-notifications">Customise notification body: <i | ||||
|                             class="arrow down"></i></a> | ||||
|                         </div> | ||||
|                 </div> | ||||
|                 <div id="notification-customisation" style="display:none;"> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.notification_title, size=80) }} | ||||
|                         <span class="pure-form-message-inline">Title for all notifications</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.notification_body , rows=5) }} | ||||
|                         <span class="pure-form-message-inline">Body for all notifications</span> | ||||
|                     </div> | ||||
|                     <div class="pure-controls"> | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             These tokens can be used in the notification body and title to | ||||
|                             customise the notification text. | ||||
|                         </span> | ||||
|                         <table class="pure-table" id="token-table"> | ||||
|                             <thead> | ||||
|                             <tr> | ||||
|                                 <th>Token</th> | ||||
|                                 <th>Description</th> | ||||
|                             </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                             <tr> | ||||
|                                 <td><code>{base_url}</code></td> | ||||
|                                 <td>The URL of the changedetection.io instance you are running.</td> | ||||
|                             </tr> | ||||
|                             <tr> | ||||
|                                 <td><code>{watch_url}</code></td> | ||||
|                                 <td>The URL being watched.</td> | ||||
|                             </tr> | ||||
|                             <tr> | ||||
|                                 <td><code>{preview_url}</code></td> | ||||
|                                 <td>The URL of the preview page generated by changedetection.io.</td> | ||||
|                             </tr> | ||||
|                             <tr> | ||||
|                                 <td><code>{diff_url}</code></td> | ||||
|                                 <td>The URL of the diff page generated by changedetection.io.</td> | ||||
|                             </tr> | ||||
|                             <tr> | ||||
|                                 <td><code>{current_snapshot}</code></td> | ||||
|                                 <td>The current snapshot value, useful when combined with JSON or CSS filters</td> | ||||
|                             </tr> | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             URLs generated by changedetection.io (such as <code>{diff_url}</code>) require the <code>BASE_URL</code> environment variable set. | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_field(form.trigger_check) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 <button type="submit" class="pure-button pure-button-primary">Save</button> | ||||
|             </div> | ||||
|             <br/> | ||||
|             <div class="pure-control-group"> | ||||
|                 <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> | ||||
|                 <a href="{{url_for('scrub_page')}}" class="pure-button button-small button-cancel">Delete History Snapshot Data</a> | ||||
|             </div> | ||||
|         </fieldset> | ||||
|     </form> | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,98 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_simple_field %} | ||||
|  | ||||
| <div class="box"> | ||||
|  | ||||
|     <form class="pure-form" action="{{ url_for('api_watch_add') }}" method="POST" id="new-watch-form"> | ||||
|         <fieldset> | ||||
|             <legend>Add a new change detection watch</legend> | ||||
|                 {{ render_simple_field(form.url, placeholder="https://...", size=30, required=true) }} | ||||
|                 {{ render_simple_field(form.tag, size=10, value=active_tag if active_tag else '', placeholder="tag") }} | ||||
|             <button type="submit" class="pure-button pure-button-primary">Watch</button> | ||||
|         </fieldset> | ||||
|         <!-- add extra stuff, like do a http POST and send headers --> | ||||
|         <!-- user/pass r = requests.get('https://api.github.com/user', auth=('user', 'pass')) --> | ||||
|     </form> | ||||
|     <div> | ||||
|         <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag }}">All</a> | ||||
|         {% for tag in tags %} | ||||
|             {% if tag != "" %} | ||||
|                 <a href="{{url_for('index', tag=tag) }}" class="pure-button button-tag {{'active' if active_tag == tag }}">{{ tag }}</a> | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|     </div> | ||||
|  | ||||
|     <div id="watch-table-wrapper"> | ||||
|         <table class="pure-table pure-table-striped watch-table"> | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th>#</th> | ||||
|                 <th></th> | ||||
|                 <th></th> | ||||
|                 <th>Last Checked</th> | ||||
|                 <th>Last Changed</th> | ||||
|                 <th></th> | ||||
|             </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|  | ||||
|  | ||||
|             {% for watch in watches %} | ||||
|             <tr id="{{ watch.uuid }}" | ||||
|                 class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} | ||||
|                 {% if watch.last_error is defined and watch.last_error != False %}error{% endif %} | ||||
|                 {% if watch.paused is defined and watch.paused != False %}paused{% endif %} | ||||
|                 {% if watch.newest_history_key| int > watch.last_viewed| int %}unviewed{% endif %}"> | ||||
|                 <td class="inline">{{ loop.index }}</td> | ||||
|                 <td class="inline paused-state state-{{watch.paused}}"><a href="{{url_for('index', pause=watch.uuid, tag=active_tag)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause"/></a></td> | ||||
|  | ||||
|                 <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} | ||||
|                     <a class="external" target="_blank" rel="noopener" href="{{ watch.url }}"></a> | ||||
|                     {% if watch.last_error is defined and watch.last_error != False %} | ||||
|                     <div class="fetch-error">{{ watch.last_error }}</div> | ||||
|                     {% endif %} | ||||
|                     {% if not active_tag %} | ||||
|                     <span class="watch-tag-list">{{ watch.tag}}</span> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 <td class="last-checked">{{watch|format_last_checked_time}}</td> | ||||
|                 <td class="last-changed">{% if watch.history|length >= 2 and watch.last_changed %} | ||||
|                     {{watch.last_changed|format_timestamp_timeago}} | ||||
|                     {% else %} | ||||
|                     Not yet | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 <td> | ||||
|                     <a href="{{ url_for('api_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" | ||||
|                        class="pure-button button-small pure-button-primary">Recheck</a> | ||||
|                     <a href="{{ url_for('edit_page', uuid=watch.uuid)}}" class="pure-button button-small pure-button-primary">Edit</a> | ||||
|                     {% if watch.history|length >= 2 %} | ||||
|                     <a href="{{ url_for('diff_history_page', uuid=watch.uuid) }}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Diff</a> | ||||
|                     {% else %} | ||||
|                         {% if watch.history|length == 1 %} | ||||
|                             <a href="{{ url_for('preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button button-small pure-button-primary">Preview</a> | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|             {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <ul id="post-list-buttons"> | ||||
|             {% if has_unviewed %} | ||||
|             <li> | ||||
|                 <a href="{{url_for('mark_all_viewed', tag=request.args.get('tag')) }}" class="pure-button button-tag ">Mark all viewed</a> | ||||
|             </li> | ||||
|             {% endif %} | ||||
|             <li> | ||||
|                <a href="{{ url_for('api_watch_checknow', tag=active_tag) }}" class="pure-button button-tag ">Recheck | ||||
|                 all {% if active_tag%}in "{{active_tag}}"{%endif%}</a> | ||||
|             </li> | ||||
|             <li> | ||||
|                 <a href="{{ url_for('index', tag=active_tag , rss=true)}}"><img id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15px"></a> | ||||
|             </li> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -1,55 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import pytest | ||||
| from backend import changedetection_app | ||||
| from backend import store | ||||
| import os | ||||
|  | ||||
|  | ||||
| # https://github.com/pallets/flask/blob/1.1.2/examples/tutorial/tests/test_auth.py | ||||
| # Much better boilerplate than the docs | ||||
| # https://www.python-boilerplate.com/py3+flask+pytest/ | ||||
|  | ||||
| global app | ||||
|  | ||||
| @pytest.fixture(scope='session') | ||||
| def app(request): | ||||
|     """Create application for the tests.""" | ||||
|     datastore_path = "./test-datastore" | ||||
|  | ||||
|     try: | ||||
|         os.mkdir(datastore_path) | ||||
|     except FileExistsError: | ||||
|         pass | ||||
|  | ||||
|     # Enable a BASE_URL for notifications to work (so we can look for diff/ etc URLs) | ||||
|     os.environ["BASE_URL"] = "http://mysite.com/" | ||||
|  | ||||
|     # Unlink test output files | ||||
|     files = ['test-datastore/output.txt', | ||||
|              "{}/url-watches.json".format(datastore_path), | ||||
|              'test-datastore/notification.txt'] | ||||
|     for file in files: | ||||
|         try: | ||||
|             os.unlink(file) | ||||
|         except FileNotFoundError: | ||||
|             pass | ||||
|  | ||||
|     app_config = {'datastore_path': datastore_path} | ||||
|     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False) | ||||
|     app = changedetection_app(app_config, datastore) | ||||
|     app.config['STOP_THREADS'] = True | ||||
|  | ||||
|     def teardown(): | ||||
|         datastore.stop_thread = True | ||||
|         app.config.exit.set() | ||||
|         for fname in ["url-watches.json", "count.txt", "output.txt"]: | ||||
|             try: | ||||
|                 os.unlink("{}/{}".format(datastore_path, fname)) | ||||
|             except FileNotFoundError: | ||||
|                 # This is fine in the case of a failure. | ||||
|                 pass | ||||
|  | ||||
|     request.addfinalizer(teardown) | ||||
|     yield app | ||||
|  | ||||
| @@ -1,102 +0,0 @@ | ||||
| from flask import url_for | ||||
|  | ||||
|  | ||||
| def test_check_access_control(app, client): | ||||
|     # Still doesnt work, but this is closer. | ||||
|  | ||||
|     with app.test_client() as c: | ||||
|         # Check we dont have any password protection enabled yet. | ||||
|         res = c.get(url_for("settings_page")) | ||||
|         assert b"Remove password" not in res.data | ||||
|  | ||||
|         # Enable password check. | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             data={"password": "foobar", "minutes_between_check": 180}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         assert b"Password protection enabled." in res.data | ||||
|         assert b"LOG OUT" not in res.data | ||||
|  | ||||
|         # Check we hit the login | ||||
|         res = c.get(url_for("index"), follow_redirects=True) | ||||
|  | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         # Menu should not be available yet | ||||
|         #        assert b"SETTINGS" not in res.data | ||||
|         #        assert b"BACKUP" not in res.data | ||||
|         #        assert b"IMPORT" not in res.data | ||||
|  | ||||
|         # defaultuser@changedetection.io is actually hardcoded for now, we only use a single password | ||||
|         res = c.post( | ||||
|             url_for("login"), | ||||
|             data={"password": "foobar"}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         assert b"LOG OUT" in res.data | ||||
|         res = c.get(url_for("settings_page")) | ||||
|  | ||||
|         # Menu should be available now | ||||
|         assert b"SETTINGS" in res.data | ||||
|         assert b"BACKUP" in res.data | ||||
|         assert b"IMPORT" in res.data | ||||
|         assert b"LOG OUT" in res.data | ||||
|  | ||||
|         # Now remove the password so other tests function, @todo this should happen before each test automatically | ||||
|         res = c.get(url_for("settings_page", removepassword="yes"), | ||||
|               follow_redirects=True) | ||||
|         assert b"Password protection removed." in res.data | ||||
|  | ||||
|         res = c.get(url_for("index")) | ||||
|         assert b"LOG OUT" not in res.data | ||||
|  | ||||
|  | ||||
| # There was a bug where saving the settings form would submit a blank password | ||||
| def test_check_access_control_no_blank_password(app, client): | ||||
|     # Still doesnt work, but this is closer. | ||||
|  | ||||
|     with app.test_client() as c: | ||||
|         # Check we dont have any password protection enabled yet. | ||||
|         res = c.get(url_for("settings_page")) | ||||
|         assert b"Remove password" not in res.data | ||||
|  | ||||
|         # Enable password check. | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             data={"password": "", "minutes_between_check": 180}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         assert b"Password protection enabled." not in res.data | ||||
|         assert b"Login" not in res.data | ||||
|  | ||||
|  | ||||
| # There was a bug where saving the settings form would submit a blank password | ||||
| def test_check_access_no_remote_access_to_remove_password(app, client): | ||||
|     # Still doesnt work, but this is closer. | ||||
|  | ||||
|     with app.test_client() as c: | ||||
|         # Check we dont have any password protection enabled yet. | ||||
|         res = c.get(url_for("settings_page")) | ||||
|         assert b"Remove password" not in res.data | ||||
|  | ||||
|         # Enable password check. | ||||
|         res = c.post( | ||||
|             url_for("settings_page"), | ||||
|             data={"password": "password", "minutes_between_check": 180}, | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|  | ||||
|         assert b"Password protection enabled." in res.data | ||||
|         assert b"Login" in res.data | ||||
|  | ||||
|         res = c.get(url_for("settings_page", removepassword="yes"), | ||||
|               follow_redirects=True) | ||||
|         assert b"Password protection removed." not in res.data | ||||
|  | ||||
|         res = c.get(url_for("index"), | ||||
|               follow_redirects=True) | ||||
|         assert b"watch-table-wrapper" not in res.data | ||||
| @@ -1,107 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from urllib.request import urlopen | ||||
| from . util import set_original_response, set_modified_response, live_server_setup | ||||
|  | ||||
| sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|  | ||||
| def test_check_basic_change_detection_functionality(client, live_server): | ||||
|     set_original_response() | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": url_for('test_endpoint', _external=True)}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|     for n in range(3): | ||||
|         client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|         # Give the thread time to pick it up | ||||
|         time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|         # It should report nothing found (no new 'unviewed' class) | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'unviewed' not in res.data | ||||
|         assert b'test-endpoint' in res.data | ||||
|  | ||||
|         # Default no password set, this stuff should be always available. | ||||
|  | ||||
|         assert b"SETTINGS" in res.data | ||||
|         assert b"BACKUP" in res.data | ||||
|         assert b"IMPORT" in res.data | ||||
|  | ||||
|     ##################### | ||||
|  | ||||
|     # Make a change | ||||
|     set_modified_response() | ||||
|  | ||||
|     res = urlopen(url_for('test_endpoint', _external=True)) | ||||
|     assert b'which has this one new line' in res.read() | ||||
|  | ||||
|     # Force recheck | ||||
|     res = client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches are rechecking.' in res.data | ||||
|  | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Now something should be ready, indicated by having a 'unviewed' class | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # #75, and it should be in the RSS feed | ||||
|     res = client.get(url_for("index", rss="true")) | ||||
|     expected_url = url_for('test_endpoint', _external=True) | ||||
|     assert b'<rss' in res.data | ||||
|     assert expected_url.encode('utf-8') in res.data | ||||
|  | ||||
|     # Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     assert b'Compare newest' in res.data | ||||
|  | ||||
|     time.sleep(2) | ||||
|  | ||||
|     # Do this a few times.. ensures we dont accidently set the status | ||||
|     for n in range(2): | ||||
|         client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|         # Give the thread time to pick it up | ||||
|         time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|         # It should report nothing found (no new 'unviewed' class) | ||||
|         res = client.get(url_for("index")) | ||||
|         assert b'unviewed' not in res.data | ||||
|         assert b'head title' not in res.data # Should not be present because this is off by default | ||||
|         assert b'test-endpoint' in res.data | ||||
|  | ||||
|     set_original_response() | ||||
|  | ||||
|     # Enable auto pickup of <title> in settings | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"extract_title_as_title": "1", "minutes_between_check": 180}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|     # It should have picked up the <title> | ||||
|     assert b'head title' in res.data | ||||
|  | ||||
|     # | ||||
|     # Cleanup everything | ||||
|     res = client.get(url_for("api_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| @@ -1,128 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
|  | ||||
| from ..html_tools import * | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that will change</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      <div id="sametext">Some text thats the same</div> | ||||
|      <div id="changetext">Some text that changes</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| # Test that the CSS extraction works how we expect, important here is the right placing of new lines \n's | ||||
| def test_css_filter_output(): | ||||
|     from backend import fetch_site_status | ||||
|     from inscriptis import get_text | ||||
|  | ||||
|     # Check text with sub-parts renders correctly | ||||
|     content = """<html> <body><div id="thingthing" >  Some really <b>bold</b> text  </div> </body> </html>""" | ||||
|     html_blob = css_filter(css_filter="#thingthing", html_content=content) | ||||
|     text = get_text(html_blob) | ||||
|     assert text == "  Some really bold text" | ||||
|  | ||||
|     content = """<html> <body> | ||||
|     <p>foo bar blah</p> | ||||
|     <div class="parts">Block A</div> <div class="parts">Block B</div></body>  | ||||
|     </html> | ||||
| """ | ||||
|     html_blob = css_filter(css_filter=".parts", html_content=content) | ||||
|     text = get_text(html_blob) | ||||
|  | ||||
|     # Divs are converted to 4 whitespaces by inscriptis | ||||
|     assert text == "    Block A\n    Block B" | ||||
|  | ||||
|  | ||||
| # Tests the whole stack works with the CSS Filter | ||||
| def test_check_markup_css_filter_restriction(client, live_server): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     css_filter = "#sametext" | ||||
|  | ||||
|     set_original_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"css_filter": css_filter, "url": test_url, "tag": "", "headers": ""}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(css_filter.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     #  Make a change | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # It should have 'unviewed' still | ||||
|     # Because it should be looking at only that 'sametext' id | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
| @@ -1,31 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| # Unit test of the stripper | ||||
| # Always we are dealing in utf-8 | ||||
| def test_strip_regex_text_func(): | ||||
|     from backend import fetch_site_status | ||||
|  | ||||
|     test_content = """ | ||||
|     but sometimes we want to remove the lines. | ||||
|      | ||||
|     but 1 lines | ||||
|     but including 1234 lines | ||||
|     igNORe-cAse text we dont want to keep     | ||||
|     but not always.""" | ||||
|  | ||||
|     ignore_lines = ["sometimes", "/\s\d{2,3}\s/", "/ignore-case text/"] | ||||
|  | ||||
|     fetcher = fetch_site_status.perform_site_check(datastore=False) | ||||
|     stripped_content = fetcher.strip_ignore_text(test_content, ignore_lines) | ||||
|  | ||||
|     assert b"but 1 lines" in stripped_content | ||||
|     assert b"igNORe-cAse text" not in stripped_content | ||||
|     assert b"but 1234 lines" not in stripped_content | ||||
|  | ||||
| @@ -1,153 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| # Unit test of the stripper | ||||
| # Always we are dealing in utf-8 | ||||
| def test_strip_text_func(): | ||||
|     from backend import fetch_site_status | ||||
|  | ||||
|     test_content = """ | ||||
|     Some content | ||||
|     is listed here | ||||
|  | ||||
|     but sometimes we want to remove the lines. | ||||
|  | ||||
|     but not always.""" | ||||
|  | ||||
|     ignore_lines = ["sometimes"] | ||||
|  | ||||
|     fetcher = fetch_site_status.perform_site_check(datastore=False) | ||||
|     stripped_content = fetcher.strip_ignore_text(test_content, ignore_lines) | ||||
|  | ||||
|     assert b"sometimes" not in stripped_content | ||||
|     assert b"Some content" in stripped_content | ||||
|  | ||||
|  | ||||
| def set_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def set_modified_original_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some NEW nice initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| # Is the same but includes ZZZZZ, 'ZZZZZ' is the last line in ignore_text | ||||
| def set_modified_ignore_response(): | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <P>ZZZZZ</P> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      </body> | ||||
|      </html> | ||||
|  | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|  | ||||
| def test_check_ignore_text_functionality(client, live_server): | ||||
|     sleep_time_for_fetch_thread = 3 | ||||
|  | ||||
|     ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ" | ||||
|     set_original_ignore_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"ignore_text": ignore_text, "url": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(ignore_text.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'/test-endpoint' in res.data | ||||
|  | ||||
|     #  Make a change | ||||
|     set_modified_ignore_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|  | ||||
|     # It should report nothing found (no new 'unviewed' class) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|     assert b'/test-endpoint' in res.data | ||||
|  | ||||
|     # Just to be sure.. set a regular modified change.. | ||||
|     set_modified_original_ignore_response() | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     time.sleep(sleep_time_for_fetch_thread) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     res = client.get(url_for("api_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
| @@ -1,121 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import live_server_setup | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """ | ||||
|     { | ||||
|       "employees": [ | ||||
|         { | ||||
|           "id": 1, | ||||
|           "name": "Pankaj", | ||||
|           "salary": "10000" | ||||
|         }, | ||||
|         { | ||||
|           "name": "David", | ||||
|           "salary": "5000", | ||||
|           "id": 2 | ||||
|         } | ||||
|       ], | ||||
|       "boss": { | ||||
|         "name": "Fat guy" | ||||
|       } | ||||
|     } | ||||
|     """ | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def set_modified_response(): | ||||
|     test_return_data = """ | ||||
|     { | ||||
|       "employees": [ | ||||
|         { | ||||
|           "id": 1, | ||||
|           "name": "Pankaj", | ||||
|           "salary": "10000" | ||||
|         }, | ||||
|         { | ||||
|           "name": "David", | ||||
|           "salary": "5000", | ||||
|           "id": 2 | ||||
|         } | ||||
|       ], | ||||
|       "boss": { | ||||
|         "name": "Foobar" | ||||
|       } | ||||
|     } | ||||
|         """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
|  | ||||
| def test_check_json_filter(client, live_server): | ||||
|  | ||||
|     json_filter = 'json:boss.name' | ||||
|  | ||||
|     set_original_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(1) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"css_filter": json_filter, "url": test_url, "tag": "", "headers": ""}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     # Check it saved | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|     ) | ||||
|     assert bytes(json_filter.encode('utf-8')) in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|     #  Make a change | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # It should have 'unviewed' still | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data | ||||
|  | ||||
|     # Should not see this, because its not in the JSONPath we entered | ||||
|     res = client.get(url_for("diff_history_page", uuid="first")) | ||||
|     # But the change should be there, tho its hard to test the change was detected because it will show old and new versions | ||||
|     assert b'Foobar' in res.data | ||||
| @@ -1,125 +0,0 @@ | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from . util import set_original_response, set_modified_response, live_server_setup | ||||
|  | ||||
| # Hard to just add more live server URLs when one test is already running (I think) | ||||
| # So we add our test here (was in a different file) | ||||
| def test_check_notification(client, live_server): | ||||
|  | ||||
|     live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|  | ||||
|     # Give the endpoint time to spin up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Add our URL to the import page | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Give the thread time to pick up the first version | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Goto the edit page, add our ignore text | ||||
|     # Add our URL to the import page | ||||
|     url = url_for('test_notification_endpoint', _external=True) | ||||
|     notification_url = url.replace('http', 'json') | ||||
|  | ||||
|     print (">>>> Notification URL: "+notification_url) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"notification_urls": notification_url, | ||||
|               "url": test_url, | ||||
|               "tag": "", | ||||
|               "headers": "", | ||||
|               "trigger_check": "y"}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|     assert b"Notifications queued" in res.data | ||||
|  | ||||
|     # Hit the edit page, be sure that we saved it | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first")) | ||||
|     assert bytes(notification_url.encode('utf-8')) in res.data | ||||
|  | ||||
|  | ||||
|     # Because we hit 'send test notification on save' | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Verify what was sent as a notification, this file should exist | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         notification_submission = f.read() | ||||
|         # Did we see the URL that had a change, in the notification? | ||||
|         assert test_url in notification_submission | ||||
|  | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|  | ||||
|     set_modified_response() | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Did the front end see it? | ||||
|     res = client.get( | ||||
|         url_for("index")) | ||||
|  | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|  | ||||
|     # Verify what was sent as a notification | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         notification_submission = f.read() | ||||
|         # Did we see the URL that had a change, in the notification? | ||||
|         assert test_url in notification_submission | ||||
|  | ||||
|         # Re #65 - did we see our foobar.com BASE_URL ? | ||||
|         #assert bytes("https://foobar.com".encode('utf-8')) in notification_submission | ||||
|  | ||||
|  | ||||
|     ##  Now configure something clever, we go into custom config (non-default) mode | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(";jasdhflkjadshf kjhsdfkjl ahslkjf haslkjd hfaklsj hf\njl;asdhfkasj stuff we will detect\n") | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"notification_title": "New ChangeDetection.io Notification - {watch_url}", | ||||
|               "notification_body": "{base_url}\n{watch_url}\n{preview_url}\n{diff_url}\n{current_snapshot}\n:-)", | ||||
|               "notification_urls": "json://foobar.com", #Re #143 should not see that it sent without [test checkbox] | ||||
|               "minutes_between_check": 180}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|     # Re #143 - should not see this if we didnt hit the test box | ||||
|     assert b"Notifications queued" not in res.data | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("api_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Did the front end see it? | ||||
|     res = client.get( | ||||
|         url_for("index")) | ||||
|  | ||||
|     assert bytes("just now".encode('utf-8')) in res.data | ||||
|  | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         notification_submission = f.read() | ||||
|  | ||||
|         assert "diff/" in notification_submission | ||||
|         assert "preview/" in notification_submission | ||||
|         assert ":-)" in notification_submission | ||||
|         assert "New ChangeDetection.io Notification - {}".format(test_url) in notification_submission | ||||
|         # This should insert the {current_snapshot} | ||||
|         assert "stuff we will detect" in notification_submission | ||||
| @@ -1,145 +0,0 @@ | ||||
| import time | ||||
| from flask import url_for | ||||
| from urllib.request import urlopen | ||||
| from . util import set_original_response, set_modified_response, live_server_setup | ||||
|  | ||||
|  | ||||
| def test_check_watch_field_storage(client, live_server): | ||||
|     set_original_response() | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|     test_url = "http://somerandomsitewewatch.com" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={ "notification_urls": "http://myapi.com", | ||||
|                "minutes_between_check": 126, | ||||
|                "css_filter" : ".fooclass", | ||||
|                "title" : "My title", | ||||
|                "ignore_text" : "ignore this", | ||||
|                "url": test_url, | ||||
|                "tag": "woohoo", | ||||
|                "headers": "curl:foo", | ||||
|  | ||||
|                }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"http://myapi.com" in res.data | ||||
|     assert b"126" in res.data | ||||
|     assert b".fooclass" in res.data | ||||
|     assert b"My title" in res.data | ||||
|     assert b"ignore this" in res.data | ||||
|     assert b"http://somerandomsitewewatch.com" in res.data | ||||
|     assert b"woohoo" in res.data | ||||
|     assert b"curl: foo" in res.data | ||||
|  | ||||
|  | ||||
|  | ||||
| # Re https://github.com/dgtlmoon/changedetection.io/issues/110 | ||||
| def test_check_recheck_global_setting(client, live_server): | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={ | ||||
|                "minutes_between_check": 1566, | ||||
|                }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     # Now add a record | ||||
|  | ||||
|     test_url = "http://somerandomsitewewatch.com" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("import_page"), | ||||
|         data={"urls": test_url}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"1 Imported" in res.data | ||||
|  | ||||
|     # Now visit the edit page, it should have the default minutes | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Should show the default minutes | ||||
|     assert b"change to another value if you want to be specific" in res.data | ||||
|     assert b"1566" in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={ | ||||
|                "minutes_between_check": 222, | ||||
|                }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     # Should show the default minutes | ||||
|     assert b"change to another value if you want to be specific" in res.data | ||||
|     assert b"222" in res.data | ||||
|  | ||||
|     # Now change it specifically, it should show the new minutes | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"url": test_url, | ||||
|               "minutes_between_check": 55, | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"55" in res.data | ||||
|  | ||||
|     # Now submit an empty field, it should give back the default global minutes | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={ | ||||
|                "minutes_between_check": 666, | ||||
|                }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         data={"url": test_url, | ||||
|               "minutes_between_check": "", | ||||
|               }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Updated watch." in res.data | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid="first"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"666" in res.data | ||||
|  | ||||
| @@ -1,60 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
|  | ||||
| def set_original_response(): | ||||
|     test_return_data = """<html> | ||||
|     <head><title>head title</title></head> | ||||
|     <body> | ||||
|      Some initial text</br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|     return None | ||||
|  | ||||
| def set_modified_response(): | ||||
|     test_return_data = """<html> | ||||
|     <head><title>modified head title</title></head> | ||||
|     <body> | ||||
|      Some initial text</br> | ||||
|      <p>which has this one new line</p> | ||||
|      </br> | ||||
|      So let's see what happens.  </br> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/output.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def live_server_setup(live_server): | ||||
|  | ||||
|     @live_server.app.route('/test-endpoint') | ||||
|     def test_endpoint(): | ||||
|         # Tried using a global var here but didn't seem to work, so reading from a file instead. | ||||
|         with open("test-datastore/output.txt", "r") as f: | ||||
|             return f.read() | ||||
|  | ||||
|     # Where we POST to as a notification | ||||
|     @live_server.app.route('/test_notification_endpoint', methods=['POST']) | ||||
|     def test_notification_endpoint(): | ||||
|         from flask import request | ||||
|  | ||||
|         with open("test-datastore/notification.txt", "wb") as f: | ||||
|             # Debug method, dump all POST to file also, used to prove #65 | ||||
|             data = request.stream.read() | ||||
|             if data != None: | ||||
|                 f.write(data) | ||||
|  | ||||
|         print("\n>> Test notification endpoint was hit.\n") | ||||
|         return "Text was set" | ||||
|  | ||||
|     live_server.start() | ||||
| @@ -1,78 +0,0 @@ | ||||
| import threading | ||||
| import queue | ||||
|  | ||||
| # Requests for checking on the site use a pool of thread Workers managed by a Queue. | ||||
| class update_worker(threading.Thread): | ||||
|     current_uuid = None | ||||
|  | ||||
|     def __init__(self, q, notification_q, app, datastore, *args, **kwargs): | ||||
|         self.q = q | ||||
|         self.app = app | ||||
|         self.notification_q = notification_q | ||||
|         self.datastore = datastore | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def run(self): | ||||
|         from backend import fetch_site_status | ||||
|  | ||||
|         update_handler = fetch_site_status.perform_site_check(datastore=self.datastore) | ||||
|  | ||||
|         while not self.app.config.exit.is_set(): | ||||
|  | ||||
|             try: | ||||
|                 uuid = self.q.get(block=False) | ||||
|             except queue.Empty: | ||||
|                 pass | ||||
|  | ||||
|             else: | ||||
|                 self.current_uuid = uuid | ||||
|  | ||||
|                 if uuid in list(self.datastore.data['watching'].keys()): | ||||
|                     try: | ||||
|                         changed_detected, result, contents = update_handler.run(uuid) | ||||
|  | ||||
|                     except PermissionError as e: | ||||
|                         self.app.logger.error("File permission error updating", uuid, str(e)) | ||||
|                     except Exception as e: | ||||
|                         self.app.logger.error("Exception reached", uuid, str(e)) | ||||
|                     else: | ||||
|                         if result: | ||||
|                             try: | ||||
|                                 self.datastore.update_watch(uuid=uuid, update_obj=result) | ||||
|                                 if changed_detected: | ||||
|  | ||||
|                                     # A change was detected | ||||
|                                     newest_version_file_contents = "" | ||||
|                                     self.datastore.save_history_text(uuid=uuid, contents=contents, result_obj=result) | ||||
|                                     watch = self.datastore.data['watching'][uuid] | ||||
|  | ||||
|                                     newest_key = self.datastore.get_newest_history_key(uuid) | ||||
|                                     if newest_key: | ||||
|                                         with open(watch['history'][newest_key], 'r') as f: | ||||
|                                             newest_version_file_contents = f.read().strip() | ||||
|  | ||||
|                                     n_object = { | ||||
|                                         'watch_url': self.datastore.data['watching'][uuid]['url'], | ||||
|                                         'uuid': uuid, | ||||
|                                         'current_snapshot': newest_version_file_contents | ||||
|                                     } | ||||
|  | ||||
|                                     # Did it have any notification alerts to hit? | ||||
|                                     if len(watch['notification_urls']): | ||||
|                                         print("Processing notifications for UUID: {}".format(uuid)) | ||||
|                                         n_object['notification_urls'] = watch['notification_urls'] | ||||
|                                         self.notification_q.put(n_object) | ||||
|  | ||||
|                                     # No? maybe theres a global setting, queue them all | ||||
|                                     elif len(self.datastore.data['settings']['application']['notification_urls']): | ||||
|                                         print("Processing GLOBAL notifications for UUID: {}".format(uuid)) | ||||
|                                         n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] | ||||
|                                         self.notification_q.put(n_object) | ||||
|  | ||||
|                             except Exception as e: | ||||
|                                 print("!!!! Exception in update_worker !!!\n", e) | ||||
|  | ||||
|                 self.current_uuid = None  # Done | ||||
|                 self.q.task_done() | ||||
|  | ||||
|             self.app.config.exit.wait(1) | ||||
| @@ -1,92 +1,8 @@ | ||||
| #!/usr/bin/python3 | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| # Launch as a eventlet.wsgi server instance. | ||||
|  | ||||
| import getopt | ||||
| import os | ||||
| import sys | ||||
|  | ||||
| import eventlet | ||||
| import eventlet.wsgi | ||||
| import backend | ||||
|  | ||||
| from backend import store | ||||
|  | ||||
| def main(argv): | ||||
|     ssl_mode = False | ||||
|     port = 5000 | ||||
|     do_cleanup = False | ||||
|  | ||||
|     # Must be absolute so that send_from_directory doesnt try to make it relative to backend/ | ||||
|     datastore_path = os.path.join(os.getcwd(), "datastore") | ||||
|  | ||||
|     try: | ||||
|         opts, args = getopt.getopt(argv, "csd:p:", "port") | ||||
|     except getopt.GetoptError: | ||||
|         print('backend.py -s SSL enable -p [port] -d [datastore path]') | ||||
|         sys.exit(2) | ||||
|  | ||||
|     for opt, arg in opts: | ||||
|         #        if opt == '--purge': | ||||
|         # Remove history, the actual files you need to delete manually. | ||||
|         #            for uuid, watch in datastore.data['watching'].items(): | ||||
|         #                watch.update({'history': {}, 'last_checked': 0, 'last_changed': 0, 'previous_md5': None}) | ||||
|  | ||||
|         if opt == '-s': | ||||
|             ssl_mode = True | ||||
|  | ||||
|         if opt == '-p': | ||||
|             port = int(arg) | ||||
|  | ||||
|         if opt == '-d': | ||||
|             datastore_path = arg | ||||
|  | ||||
|         # Cleanup (remove text files that arent in the index) | ||||
|         if opt == '-c': | ||||
|             do_cleanup = True | ||||
|  | ||||
|     # isnt there some @thingy to attach to each route to tell it, that this route needs a datastore | ||||
|     app_config = {'datastore_path': datastore_path} | ||||
|  | ||||
|     datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path']) | ||||
|     app = backend.changedetection_app(app_config, datastore) | ||||
|  | ||||
|     # Go into cleanup mode | ||||
|     if do_cleanup: | ||||
|         datastore.remove_unused_snapshots() | ||||
|  | ||||
|     app.config['datastore_path'] = datastore_path | ||||
|  | ||||
|  | ||||
|     @app.context_processor | ||||
|     def inject_version(): | ||||
|         return dict(right_sticky="v{}".format(datastore.data['version_tag']), | ||||
|                     new_version_available=app.config['NEW_VERSION_AVAILABLE'], | ||||
|                     has_password=datastore.data['settings']['application']['password'] != False | ||||
|                     ) | ||||
|  | ||||
|     # Proxy sub-directory support | ||||
|     # Set environment var USE_X_SETTINGS=1 on this script | ||||
|     # And then in your proxy_pass settings | ||||
|     # | ||||
|     #         proxy_set_header Host "localhost"; | ||||
|     #         proxy_set_header X-Forwarded-Prefix /app; | ||||
|  | ||||
|     if os.getenv('USE_X_SETTINGS'): | ||||
|         print ("USE_X_SETTINGS is ENABLED\n") | ||||
|         from werkzeug.middleware.proxy_fix import ProxyFix | ||||
|         app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1, x_host=1) | ||||
|  | ||||
|     if ssl_mode: | ||||
|         # @todo finalise SSL config, but this should get you in the right direction if you need it. | ||||
|         eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen(('', port)), | ||||
|                                                certfile='cert.pem', | ||||
|                                                keyfile='privkey.pem', | ||||
|                                                server_side=True), app) | ||||
|  | ||||
|     else: | ||||
|         eventlet.wsgi.server(eventlet.listen(('', port)), app) | ||||
| # Only exists for direct CLI usage | ||||
|  | ||||
| import changedetectionio | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     main(sys.argv[1:]) | ||||
|     changedetectionio.main() | ||||
|   | ||||
							
								
								
									
										2
									
								
								changedetectionio/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								changedetectionio/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| test-datastore | ||||
| package-lock.json | ||||
							
								
								
									
										208
									
								
								changedetectionio/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								changedetectionio/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| __version__ = '0.49.15' | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
| import os | ||||
| os.environ['EVENTLET_NO_GREENDNS'] = 'yes' | ||||
| import eventlet | ||||
| import eventlet.wsgi | ||||
| import getopt | ||||
| import platform | ||||
| import signal | ||||
| import socket | ||||
| import sys | ||||
|  | ||||
| from changedetectionio import store | ||||
| from changedetectionio.flask_app import changedetection_app | ||||
| from loguru import logger | ||||
|  | ||||
| # Only global so we can access it in the signal handler | ||||
| app = None | ||||
| datastore = None | ||||
|  | ||||
| def get_version(): | ||||
|     return __version__ | ||||
|  | ||||
| # Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown | ||||
| def sigshutdown_handler(_signo, _stack_frame): | ||||
|     name = signal.Signals(_signo).name | ||||
|     logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown') | ||||
|     datastore.sync_to_json() | ||||
|     logger.success('Sync JSON to disk complete.') | ||||
|     # This will throw a SystemExit exception, because eventlet.wsgi.server doesn't know how to deal with it. | ||||
|     # Solution: move to gevent or other server in the future (#2014) | ||||
|     datastore.stop_thread = True | ||||
|     app.config.exit.set() | ||||
|     sys.exit() | ||||
|  | ||||
| def main(): | ||||
|     global datastore | ||||
|     global app | ||||
|  | ||||
|     datastore_path = None | ||||
|     do_cleanup = False | ||||
|     host = '' | ||||
|     ipv6_enabled = False | ||||
|     port = os.environ.get('PORT') or 5000 | ||||
|     ssl_mode = False | ||||
|  | ||||
|     # On Windows, create and use a default path. | ||||
|     if os.name == 'nt': | ||||
|         datastore_path = os.path.expandvars(r'%APPDATA%\changedetection.io') | ||||
|         os.makedirs(datastore_path, exist_ok=True) | ||||
|     else: | ||||
|         # Must be absolute so that send_from_directory doesnt try to make it relative to backend/ | ||||
|         datastore_path = os.path.join(os.getcwd(), "../datastore") | ||||
|  | ||||
|     try: | ||||
|         opts, args = getopt.getopt(sys.argv[1:], "6Ccsd:h:p:l:", "port") | ||||
|     except getopt.GetoptError: | ||||
|         print('backend.py -s SSL enable -h [host] -p [port] -d [datastore path] -l [debug level - TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL]') | ||||
|         sys.exit(2) | ||||
|  | ||||
|     create_datastore_dir = False | ||||
|  | ||||
|     # Set a default logger level | ||||
|     logger_level = 'DEBUG' | ||||
|     # Set a logger level via shell env variable | ||||
|     # Used: Dockerfile for CICD | ||||
|     # To set logger level for pytest, see the app function in tests/conftest.py | ||||
|     if os.getenv("LOGGER_LEVEL"): | ||||
|         level = os.getenv("LOGGER_LEVEL") | ||||
|         logger_level = int(level) if level.isdigit() else level.upper() | ||||
|  | ||||
|     for opt, arg in opts: | ||||
|         if opt == '-s': | ||||
|             ssl_mode = True | ||||
|  | ||||
|         if opt == '-h': | ||||
|             host = arg | ||||
|  | ||||
|         if opt == '-p': | ||||
|             port = int(arg) | ||||
|  | ||||
|         if opt == '-d': | ||||
|             datastore_path = arg | ||||
|  | ||||
|         if opt == '-6': | ||||
|             logger.success("Enabling IPv6 listen support") | ||||
|             ipv6_enabled = True | ||||
|  | ||||
|         # Cleanup (remove text files that arent in the index) | ||||
|         if opt == '-c': | ||||
|             do_cleanup = True | ||||
|  | ||||
|         # Create the datadir if it doesnt exist | ||||
|         if opt == '-C': | ||||
|             create_datastore_dir = True | ||||
|  | ||||
|         if opt == '-l': | ||||
|             logger_level = int(arg) if arg.isdigit() else arg.upper() | ||||
|  | ||||
|     # Without this, a logger will be duplicated | ||||
|     logger.remove() | ||||
|     try: | ||||
|         log_level_for_stdout = { 'TRACE', 'DEBUG', 'INFO', 'SUCCESS' } | ||||
|         logger.configure(handlers=[ | ||||
|             {"sink": sys.stdout, "level": logger_level, | ||||
|              "filter" : lambda record: record['level'].name in log_level_for_stdout}, | ||||
|             {"sink": sys.stderr, "level": logger_level, | ||||
|              "filter": lambda record: record['level'].name not in log_level_for_stdout}, | ||||
|             ]) | ||||
|     # Catch negative number or wrong log level name | ||||
|     except ValueError: | ||||
|         print("Available log level names: TRACE, DEBUG(default), INFO, SUCCESS," | ||||
|               " WARNING, ERROR, CRITICAL") | ||||
|         sys.exit(2) | ||||
|  | ||||
|     # isnt there some @thingy to attach to each route to tell it, that this route needs a datastore | ||||
|     app_config = {'datastore_path': datastore_path} | ||||
|  | ||||
|     if not os.path.isdir(app_config['datastore_path']): | ||||
|         if create_datastore_dir: | ||||
|             os.mkdir(app_config['datastore_path']) | ||||
|         else: | ||||
|             logger.critical( | ||||
|                 f"ERROR: Directory path for the datastore '{app_config['datastore_path']}'" | ||||
|                 f" does not exist, cannot start, please make sure the" | ||||
|                 f" directory exists or specify a directory with the -d option.\n" | ||||
|                 f"Or use the -C parameter to create the directory.") | ||||
|             sys.exit(2) | ||||
|  | ||||
|     try: | ||||
|         datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__) | ||||
|     except JSONDecodeError as e: | ||||
|         # Dont' start if the JSON DB looks corrupt | ||||
|         logger.critical(f"ERROR: JSON DB or Proxy List JSON at '{app_config['datastore_path']}' appears to be corrupt, aborting.") | ||||
|         logger.critical(str(e)) | ||||
|         return | ||||
|  | ||||
|     app = changedetection_app(app_config, datastore) | ||||
|  | ||||
|     signal.signal(signal.SIGTERM, sigshutdown_handler) | ||||
|     signal.signal(signal.SIGINT, sigshutdown_handler) | ||||
|      | ||||
|     # Custom signal handler for memory cleanup | ||||
|     def sigusr_clean_handler(_signo, _stack_frame): | ||||
|         from changedetectionio.gc_cleanup import memory_cleanup | ||||
|         logger.info('SIGUSR1 received: Running memory cleanup') | ||||
|         return memory_cleanup(app) | ||||
|  | ||||
|     # Register the SIGUSR1 signal handler | ||||
|     # Only register the signal handler if running on Linux | ||||
|     if platform.system() == "Linux": | ||||
|         signal.signal(signal.SIGUSR1, sigusr_clean_handler) | ||||
|     else: | ||||
|         logger.info("SIGUSR1 handler only registered on Linux, skipped.") | ||||
|  | ||||
|     # Go into cleanup mode | ||||
|     if do_cleanup: | ||||
|         datastore.remove_unused_snapshots() | ||||
|  | ||||
|     app.config['datastore_path'] = datastore_path | ||||
|  | ||||
|  | ||||
|     @app.context_processor | ||||
|     def inject_version(): | ||||
|         return dict(right_sticky="v{}".format(datastore.data['version_tag']), | ||||
|                     new_version_available=app.config['NEW_VERSION_AVAILABLE'], | ||||
|                     has_password=datastore.data['settings']['application']['password'] != False | ||||
|                     ) | ||||
|  | ||||
|     # Monitored websites will not receive a Referer header when a user clicks on an outgoing link. | ||||
|     @app.after_request | ||||
|     def hide_referrer(response): | ||||
|         if strtobool(os.getenv("HIDE_REFERER", 'false')): | ||||
|             response.headers["Referrer-Policy"] = "same-origin" | ||||
|  | ||||
|         return response | ||||
|  | ||||
|     # Proxy sub-directory support | ||||
|     # Set environment var USE_X_SETTINGS=1 on this script | ||||
|     # And then in your proxy_pass settings | ||||
|     # | ||||
|     #         proxy_set_header Host "localhost"; | ||||
|     #         proxy_set_header X-Forwarded-Prefix /app; | ||||
|  | ||||
|  | ||||
|     if os.getenv('USE_X_SETTINGS'): | ||||
|         logger.info("USE_X_SETTINGS is ENABLED") | ||||
|         from werkzeug.middleware.proxy_fix import ProxyFix | ||||
|         app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1, x_host=1) | ||||
|  | ||||
|     s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET | ||||
|  | ||||
|     if ssl_mode: | ||||
|         # @todo finalise SSL config, but this should get you in the right direction if you need it. | ||||
|         eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type), | ||||
|                                                certfile='cert.pem', | ||||
|                                                keyfile='privkey.pem', | ||||
|                                                server_side=True), app) | ||||
|  | ||||
|     else: | ||||
|         eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app) | ||||
|  | ||||
							
								
								
									
										62
									
								
								changedetectionio/api/Import.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								changedetectionio/api/Import.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import os | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request | ||||
| import validators | ||||
| from . import auth | ||||
|  | ||||
|  | ||||
| class Import(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def post(self): | ||||
|         """ | ||||
|         @api {post} /api/v1/import Import a list of watched URLs | ||||
|         @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag  id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line. | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a" | ||||
|         @apiName Import | ||||
|         @apiGroup Watch | ||||
|         @apiSuccess (200) {List} OK List of watch UUIDs added | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|  | ||||
|         extras = {} | ||||
|  | ||||
|         if request.args.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not request.args.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|             else: | ||||
|                 extras['proxy'] = request.args.get('proxy') | ||||
|  | ||||
|         dedupe = strtobool(request.args.get('dedupe', 'true')) | ||||
|  | ||||
|         tags = request.args.get('tag') | ||||
|         tag_uuids = request.args.get('tag_uuids') | ||||
|  | ||||
|         if tag_uuids: | ||||
|             tag_uuids = tag_uuids.split(',') | ||||
|  | ||||
|         urls = request.get_data().decode('utf8').splitlines() | ||||
|         added = [] | ||||
|         allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) | ||||
|         for url in urls: | ||||
|             url = url.strip() | ||||
|             if not len(url): | ||||
|                 continue | ||||
|  | ||||
|             # If hosts that only contain alphanumerics are allowed ("localhost" for example) | ||||
|             if not validators.url(url, simple_host=allow_simplehost): | ||||
|                 return f"Invalid or unsupported URL - {url}", 400 | ||||
|  | ||||
|             if dedupe and self.datastore.url_exists(url): | ||||
|                 continue | ||||
|  | ||||
|             new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids) | ||||
|             added.append(new_uuid) | ||||
|  | ||||
|         return added | ||||
							
								
								
									
										145
									
								
								changedetectionio/api/Notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								changedetectionio/api/Notifications.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| from flask_expects_json import expects_json | ||||
| from flask_restful import Resource | ||||
| from . import auth | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request | ||||
| from . import auth | ||||
| from . import schema_create_notification_urls, schema_delete_notification_urls | ||||
|  | ||||
| class Notifications(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/notifications Return Notification URL List | ||||
|         @apiDescription Return the Notification URL List from the configuration | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             HTTP/1.0 200 | ||||
|             { | ||||
|                 'notification_urls': ["notification-urls-list"] | ||||
|             } | ||||
|         @apiName Get | ||||
|         @apiGroup Notifications | ||||
|         """ | ||||
|  | ||||
|         notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])         | ||||
|  | ||||
|         return { | ||||
|                 'notification_urls': notification_urls, | ||||
|                }, 200 | ||||
|      | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_create_notification_urls) | ||||
|     def post(self): | ||||
|         """ | ||||
|         @api {post} /api/v1/notifications Create Notification URLs | ||||
|         @apiDescription Add one or more notification URLs from the configuration | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}' | ||||
|         @apiName CreateBatch | ||||
|         @apiGroup Notifications | ||||
|         @apiSuccess (201) {Object[]} notification_urls List of added notification URLs | ||||
|         @apiError (400) {String} Invalid input | ||||
|         """ | ||||
|  | ||||
|         json_data = request.get_json() | ||||
|         notification_urls = json_data.get("notification_urls", []) | ||||
|  | ||||
|         from wtforms import ValidationError | ||||
|         try: | ||||
|             validate_notification_urls(notification_urls) | ||||
|         except ValidationError as e: | ||||
|             return str(e), 400 | ||||
|  | ||||
|         added_urls = [] | ||||
|  | ||||
|         for url in notification_urls: | ||||
|             clean_url = url.strip() | ||||
|             added_url = self.datastore.add_notification_url(clean_url) | ||||
|             if added_url: | ||||
|                 added_urls.append(added_url) | ||||
|  | ||||
|         if not added_urls: | ||||
|             return "No valid notification URLs were added", 400 | ||||
|  | ||||
|         return {'notification_urls': added_urls}, 201 | ||||
|      | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_create_notification_urls) | ||||
|     def put(self): | ||||
|         """ | ||||
|         @api {put} /api/v1/notifications Replace Notification URLs | ||||
|         @apiDescription Replace all notification URLs with the provided list (can be empty) | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl -X PUT http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}' | ||||
|         @apiName Replace | ||||
|         @apiGroup Notifications | ||||
|         @apiSuccess (200) {Object[]} notification_urls List of current notification URLs | ||||
|         @apiError (400) {String} Invalid input | ||||
|         """ | ||||
|         json_data = request.get_json() | ||||
|         notification_urls = json_data.get("notification_urls", []) | ||||
|  | ||||
|         from wtforms import ValidationError | ||||
|         try: | ||||
|             validate_notification_urls(notification_urls) | ||||
|         except ValidationError as e: | ||||
|             return str(e), 400 | ||||
|          | ||||
|         if not isinstance(notification_urls, list): | ||||
|             return "Invalid input format", 400 | ||||
|  | ||||
|         clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)] | ||||
|         self.datastore.data['settings']['application']['notification_urls'] = clean_urls | ||||
|         self.datastore.needs_write = True | ||||
|  | ||||
|         return {'notification_urls': clean_urls}, 200 | ||||
|          | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_delete_notification_urls) | ||||
|     def delete(self): | ||||
|         """ | ||||
|         @api {delete} /api/v1/notifications Delete Notification URLs | ||||
|         @apiDescription Deletes one or more notification URLs from the configuration | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}' | ||||
|         @apiParam {String[]} notification_urls The notification URLs to delete. | ||||
|         @apiName Delete | ||||
|         @apiGroup Notifications | ||||
|         @apiSuccess (204) {String} OK Deleted | ||||
|         @apiError (400) {String} No matching notification URLs found. | ||||
|         """ | ||||
|  | ||||
|         json_data = request.get_json() | ||||
|         urls_to_delete = json_data.get("notification_urls", []) | ||||
|         if not isinstance(urls_to_delete, list): | ||||
|             abort(400, message="Expected a list of notification URLs.") | ||||
|  | ||||
|         notification_urls = self.datastore.data['settings']['application'].get('notification_urls', []) | ||||
|         deleted = [] | ||||
|  | ||||
|         for url in urls_to_delete: | ||||
|             clean_url = url.strip() | ||||
|             if clean_url in notification_urls: | ||||
|                 notification_urls.remove(clean_url) | ||||
|                 deleted.append(clean_url) | ||||
|  | ||||
|         if not deleted: | ||||
|             abort(400, message="No matching notification URLs found.") | ||||
|  | ||||
|         self.datastore.data['settings']['application']['notification_urls'] = notification_urls | ||||
|         self.datastore.needs_write = True | ||||
|  | ||||
|         return 'OK', 204 | ||||
|      | ||||
| def validate_notification_urls(notification_urls): | ||||
|     from changedetectionio.forms import ValidateAppRiseServers | ||||
|     validator = ValidateAppRiseServers() | ||||
|     class DummyForm: pass | ||||
|     dummy_form = DummyForm() | ||||
|     field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})() | ||||
|     validator(dummy_form, field) | ||||
							
								
								
									
										51
									
								
								changedetectionio/api/Search.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								changedetectionio/api/Search.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| from flask_restful import Resource, abort | ||||
| from flask import request | ||||
| from . import auth | ||||
|  | ||||
| class Search(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/search Search for watches | ||||
|         @apiDescription Search watches by URL or title text | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiName Search | ||||
|         @apiGroup Watch Management | ||||
|         @apiQuery {String} q Search query to match against watch URLs and titles | ||||
|         @apiQuery {String} [tag] Optional name of tag to limit results (name not UUID) | ||||
|         @apiQuery {String} [partial] Allow partial matching of URL query | ||||
|         @apiSuccess (200) {Object} JSON Object containing matched watches | ||||
|         """ | ||||
|         query = request.args.get('q', '').strip() | ||||
|         tag_limit = request.args.get('tag', '').strip() | ||||
|         from changedetectionio.strtobool import strtobool | ||||
|         partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False | ||||
|  | ||||
|         # Require a search query | ||||
|         if not query: | ||||
|             abort(400, message="Search query 'q' parameter is required") | ||||
|  | ||||
|         # Use the search function from the datastore | ||||
|         matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial) | ||||
|  | ||||
|         # Build the response with watch details | ||||
|         results = {} | ||||
|         for uuid in matching_uuids: | ||||
|             watch = self.datastore.data['watching'].get(uuid) | ||||
|             results[uuid] = { | ||||
|                 'last_changed': watch.last_changed, | ||||
|                 'last_checked': watch['last_checked'], | ||||
|                 'last_error': watch['last_error'], | ||||
|                 'title': watch['title'], | ||||
|                 'url': watch['url'], | ||||
|                 'viewed': watch.viewed | ||||
|             } | ||||
|  | ||||
|         return results, 200 | ||||
							
								
								
									
										54
									
								
								changedetectionio/api/SystemInfo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								changedetectionio/api/SystemInfo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| from flask_restful import Resource | ||||
| from . import auth | ||||
|  | ||||
|  | ||||
| class SystemInfo(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|         self.update_q = kwargs['update_q'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/systeminfo Return system info | ||||
|         @apiDescription Return some info about the current system state | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             HTTP/1.0 200 | ||||
|             { | ||||
|                 'queue_size': 10 , | ||||
|                 'overdue_watches': ["watch-uuid-list"], | ||||
|                 'uptime': 38344.55, | ||||
|                 'watch_count': 800, | ||||
|                 'version': "0.40.1" | ||||
|             } | ||||
|         @apiName Get Info | ||||
|         @apiGroup System Information | ||||
|         """ | ||||
|         import time | ||||
|         overdue_watches = [] | ||||
|  | ||||
|         # Check all watches and report which have not been checked but should have been | ||||
|  | ||||
|         for uuid, watch in self.datastore.data.get('watching', {}).items(): | ||||
|             # see if now - last_checked is greater than the time that should have been | ||||
|             # this is not super accurate (maybe they just edited it) but better than nothing | ||||
|             t = watch.threshold_seconds() | ||||
|             if not t: | ||||
|                 # Use the system wide default | ||||
|                 t = self.datastore.threshold_seconds | ||||
|  | ||||
|             time_since_check = time.time() - watch.get('last_checked') | ||||
|  | ||||
|             # Allow 5 minutes of grace time before we decide it's overdue | ||||
|             if time_since_check - (5 * 60) > t: | ||||
|                 overdue_watches.append(uuid) | ||||
|         from changedetectionio import __version__ as main_version | ||||
|         return { | ||||
|                    'queue_size': self.update_q.qsize(), | ||||
|                    'overdue_watches': overdue_watches, | ||||
|                    'uptime': round(time.time() - self.datastore.start_time, 2), | ||||
|                    'watch_count': len(self.datastore.data.get('watching', {})), | ||||
|                    'version': main_version | ||||
|                }, 200 | ||||
							
								
								
									
										156
									
								
								changedetectionio/api/Tags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								changedetectionio/api/Tags.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| from flask_expects_json import expects_json | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request | ||||
| from . import auth | ||||
|  | ||||
| # Import schemas from __init__.py | ||||
| from . import schema_tag, schema_create_tag, schema_update_tag | ||||
|  | ||||
|  | ||||
| class Tag(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     # Get information about a single tag | ||||
|     # curl http://localhost:5000/api/v1/tag/<string:uuid> | ||||
|     @auth.check_token | ||||
|     def get(self, uuid): | ||||
|         """ | ||||
|         @api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting. | ||||
|         @apiDescription Retrieve tag information and set notification_muted status | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiName Tag | ||||
|         @apiGroup Tag | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state | ||||
|         @apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag | ||||
|         @apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag | ||||
|         """ | ||||
|         from copy import deepcopy | ||||
|         tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid)) | ||||
|         if not tag: | ||||
|             abort(404, message=f'No tag exists with the UUID of {uuid}') | ||||
|  | ||||
|         if request.args.get('muted', '') == 'muted': | ||||
|             self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True | ||||
|             return "OK", 200 | ||||
|         elif request.args.get('muted', '') == 'unmuted': | ||||
|             self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False | ||||
|             return "OK", 200 | ||||
|  | ||||
|         return tag | ||||
|  | ||||
|     @auth.check_token | ||||
|     def delete(self, uuid): | ||||
|         """ | ||||
|         @api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiName DeleteTag | ||||
|         @apiGroup Tag | ||||
|         @apiSuccess (200) {String} OK Was deleted | ||||
|         """ | ||||
|         if not self.datastore.data['settings']['application']['tags'].get(uuid): | ||||
|             abort(400, message='No tag exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         # Delete the tag, and any tag reference | ||||
|         del self.datastore.data['settings']['application']['tags'][uuid] | ||||
|          | ||||
|         # Remove tag from all watches | ||||
|         for watch_uuid, watch in self.datastore.data['watching'].items(): | ||||
|             if watch.get('tags') and uuid in watch['tags']: | ||||
|                 watch['tags'].remove(uuid) | ||||
|  | ||||
|         return 'OK', 204 | ||||
|  | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_update_tag) | ||||
|     def put(self, uuid): | ||||
|         """ | ||||
|         @api {put} /api/v1/tag/:uuid Update tag information | ||||
|         @apiExample {curl} Example usage: | ||||
|             Update (PUT) | ||||
|             curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}' | ||||
|  | ||||
|         @apiDescription Updates an existing tag using JSON | ||||
|         @apiParam {uuid} uuid Tag unique ID. | ||||
|         @apiName UpdateTag | ||||
|         @apiGroup Tag | ||||
|         @apiSuccess (200) {String} OK Was updated | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|         tag = self.datastore.data['settings']['application']['tags'].get(uuid) | ||||
|         if not tag: | ||||
|             abort(404, message='No tag exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         tag.update(request.json) | ||||
|         self.datastore.needs_write_urgent = True | ||||
|  | ||||
|         return "OK", 200 | ||||
|  | ||||
|  | ||||
|     @auth.check_token | ||||
|     # Only cares for {'title': 'xxxx'} | ||||
|     def post(self): | ||||
|         """ | ||||
|         @api {post} /api/v1/watch Create a single tag | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}' | ||||
|         @apiName Create | ||||
|         @apiGroup Tag | ||||
|         @apiSuccess (200) {String} OK Was created | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|  | ||||
|         json_data = request.get_json() | ||||
|         title = json_data.get("title",'').strip() | ||||
|  | ||||
|  | ||||
|         new_uuid = self.datastore.add_tag(title=title) | ||||
|         if new_uuid: | ||||
|             return {'uuid': new_uuid}, 201 | ||||
|         else: | ||||
|             return "Invalid or unsupported tag", 400 | ||||
|  | ||||
| class Tags(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/tags List tags | ||||
|         @apiDescription Return list of available tags | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             { | ||||
|                 "cc0cfffa-f449-477b-83ea-0caafd1dc091": { | ||||
|                     "title": "Tech News", | ||||
|                     "notification_muted": false, | ||||
|                     "date_created": 1677103794 | ||||
|                 }, | ||||
|                 "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": { | ||||
|                     "title": "Shopping", | ||||
|                     "notification_muted": true, | ||||
|                     "date_created": 1676662819 | ||||
|                 } | ||||
|             } | ||||
|         @apiName ListTags | ||||
|         @apiGroup Tag Management | ||||
|         @apiSuccess (200) {String} OK JSON dict | ||||
|         """ | ||||
|         result = {} | ||||
|         for uuid, tag in self.datastore.data['settings']['application']['tags'].items(): | ||||
|             result[uuid] = { | ||||
|                 'date_created': tag.get('date_created', 0), | ||||
|                 'notification_muted': tag.get('notification_muted', False), | ||||
|                 'title': tag.get('title', ''), | ||||
|                 'uuid': tag.get('uuid') | ||||
|             } | ||||
|  | ||||
|         return result, 200 | ||||
							
								
								
									
										297
									
								
								changedetectionio/api/Watch.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								changedetectionio/api/Watch.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,297 @@ | ||||
| import os | ||||
| from changedetectionio.strtobool import strtobool | ||||
|  | ||||
| from flask_expects_json import expects_json | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| from flask_restful import abort, Resource | ||||
| from flask import request, make_response | ||||
| import validators | ||||
| from . import auth | ||||
| import copy | ||||
|  | ||||
| # Import schemas from __init__.py | ||||
| from . import schema, schema_create_watch, schema_update_watch | ||||
|  | ||||
|  | ||||
| class Watch(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|         self.update_q = kwargs['update_q'] | ||||
|  | ||||
|     # Get information about a single watch, excluding the history list (can be large) | ||||
|     # curl http://localhost:5000/api/v1/watch/<string:uuid> | ||||
|     # @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK" | ||||
|     # ?recheck=true | ||||
|     @auth.check_token | ||||
|     def get(self, uuid): | ||||
|         """ | ||||
|         @api {get} /api/v1/watch/:uuid Single watch - get data, recheck, pause, mute. | ||||
|         @apiDescription Retrieve watch information and set muted/paused status | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091  -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted"  -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?paused=unpaused"  -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiName Watch | ||||
|         @apiGroup Watch | ||||
|         @apiParam {uuid} uuid Watch unique ID. | ||||
|         @apiQuery {Boolean} [recheck] Recheck this watch `recheck=1` | ||||
|         @apiQuery {String} [paused] =`paused` or =`unpaused` , Sets the PAUSED state | ||||
|         @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state | ||||
|         @apiSuccess (200) {String} OK When paused/muted/recheck operation OR full JSON object of the watch | ||||
|         @apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch | ||||
|         """ | ||||
|         from copy import deepcopy | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         if request.args.get('recheck'): | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             return "OK", 200 | ||||
|         if request.args.get('paused', '') == 'paused': | ||||
|             self.datastore.data['watching'].get(uuid).pause() | ||||
|             return "OK", 200 | ||||
|         elif request.args.get('paused', '') == 'unpaused': | ||||
|             self.datastore.data['watching'].get(uuid).unpause() | ||||
|             return "OK", 200 | ||||
|         if request.args.get('muted', '') == 'muted': | ||||
|             self.datastore.data['watching'].get(uuid).mute() | ||||
|             return "OK", 200 | ||||
|         elif request.args.get('muted', '') == 'unmuted': | ||||
|             self.datastore.data['watching'].get(uuid).unmute() | ||||
|             return "OK", 200 | ||||
|  | ||||
|         # Return without history, get that via another API call | ||||
|         # Properties are not returned as a JSON, so add the required props manually | ||||
|         watch['history_n'] = watch.history_n | ||||
|         # attr .last_changed will check for the last written text snapshot on change | ||||
|         watch['last_changed'] = watch.last_changed | ||||
|         watch['viewed'] = watch.viewed | ||||
|         return watch | ||||
|  | ||||
|     @auth.check_token | ||||
|     def delete(self, uuid): | ||||
|         """ | ||||
|         @api {delete} /api/v1/watch/:uuid Delete a watch and related history | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|         @apiParam {uuid} uuid Watch unique ID. | ||||
|         @apiName Delete | ||||
|         @apiGroup Watch | ||||
|         @apiSuccess (200) {String} OK Was deleted | ||||
|         """ | ||||
|         if not self.datastore.data['watching'].get(uuid): | ||||
|             abort(400, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         self.datastore.delete(uuid) | ||||
|         return 'OK', 204 | ||||
|  | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_update_watch) | ||||
|     def put(self, uuid): | ||||
|         """ | ||||
|         @api {put} /api/v1/watch/:uuid Update watch information | ||||
|         @apiExample {curl} Example usage: | ||||
|             Update (PUT) | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}' | ||||
|  | ||||
|         @apiDescription Updates an existing watch using JSON, accepts the same structure as returned in <a href="#api-Watch-Watch">get single watch information</a> | ||||
|         @apiParam {uuid} uuid Watch unique ID. | ||||
|         @apiName Update a watch | ||||
|         @apiGroup Watch | ||||
|         @apiSuccess (200) {String} OK Was updated | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         if request.json.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not request.json.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|  | ||||
|         watch.update(request.json) | ||||
|  | ||||
|         return "OK", 200 | ||||
|  | ||||
|  | ||||
| class WatchHistory(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     # Get a list of available history for a watch by UUID | ||||
|     # curl http://localhost:5000/api/v1/watch/<string:uuid>/history | ||||
|     @auth.check_token | ||||
|     def get(self, uuid): | ||||
|         """ | ||||
|         @api {get} /api/v1/watch/<string:uuid>/history Get a list of all historical snapshots available for a watch | ||||
|         @apiDescription Requires `uuid`, returns list | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" | ||||
|             { | ||||
|                 "1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt", | ||||
|                 "1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt", | ||||
|                 "1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt" | ||||
|             } | ||||
|         @apiName Get list of available stored snapshots for watch | ||||
|         @apiGroup Watch History | ||||
|         @apiSuccess (200) {String} OK | ||||
|         @apiSuccess (404) {String} ERR Not found | ||||
|         """ | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|         return watch.history, 200 | ||||
|  | ||||
|  | ||||
| class WatchSingleHistory(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self, uuid, timestamp): | ||||
|         """ | ||||
|         @api {get} /api/v1/watch/<string:uuid>/history/<int:timestamp> Get single snapshot from watch | ||||
|         @apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or <a href="#api-Watch_History-Get_list_of_available_stored_snapshots_for_watch">use the list returned here</a> | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" | ||||
|         @apiName Get single snapshot content | ||||
|         @apiGroup Watch History | ||||
|         @apiParam {String} [html]       Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp) | ||||
|         @apiSuccess (200) {String} OK | ||||
|         @apiSuccess (404) {String} ERR Not found | ||||
|         """ | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message=f"No watch exists with the UUID of {uuid}") | ||||
|  | ||||
|         if not len(watch.history): | ||||
|             abort(404, message=f"Watch found but no history exists for the UUID {uuid}") | ||||
|  | ||||
|         if timestamp == 'latest': | ||||
|             timestamp = list(watch.history.keys())[-1] | ||||
|  | ||||
|         if request.args.get('html'): | ||||
|             content = watch.get_fetched_html(timestamp) | ||||
|             if content: | ||||
|                 response = make_response(content, 200) | ||||
|                 response.mimetype = "text/html" | ||||
|             else: | ||||
|                 response = make_response("No content found", 404) | ||||
|                 response.mimetype = "text/plain" | ||||
|         else: | ||||
|             content = watch.get_history_snapshot(timestamp) | ||||
|             response = make_response(content, 200) | ||||
|             response.mimetype = "text/plain" | ||||
|  | ||||
|         return response | ||||
|  | ||||
|  | ||||
| class CreateWatch(Resource): | ||||
|     def __init__(self, **kwargs): | ||||
|         # datastore is a black box dependency | ||||
|         self.datastore = kwargs['datastore'] | ||||
|         self.update_q = kwargs['update_q'] | ||||
|  | ||||
|     @auth.check_token | ||||
|     @expects_json(schema_create_watch) | ||||
|     def post(self): | ||||
|         """ | ||||
|         @api {post} /api/v1/watch Create a single watch | ||||
|         @apiDescription Requires atleast `url` set, can accept the same structure as <a href="#api-Watch-Watch">get single watch information</a> to create. | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}' | ||||
|         @apiName Create | ||||
|         @apiGroup Watch | ||||
|         @apiSuccess (200) {String} OK Was created | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|  | ||||
|         json_data = request.get_json() | ||||
|         url = json_data['url'].strip() | ||||
|  | ||||
|         # If hosts that only contain alphanumerics are allowed ("localhost" for example) | ||||
|         allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) | ||||
|         if not validators.url(url, simple_host=allow_simplehost): | ||||
|             return "Invalid or unsupported URL", 400 | ||||
|  | ||||
|         if json_data.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not json_data.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|  | ||||
|         extras = copy.deepcopy(json_data) | ||||
|  | ||||
|         # Because we renamed 'tag' to 'tags' but don't want to change the API (can do this in v2 of the API) | ||||
|         tags = None | ||||
|         if extras.get('tag'): | ||||
|             tags = extras.get('tag') | ||||
|             del extras['tag'] | ||||
|  | ||||
|         del extras['url'] | ||||
|  | ||||
|         new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags) | ||||
|         if new_uuid: | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid})) | ||||
|             return {'uuid': new_uuid}, 201 | ||||
|         else: | ||||
|             return "Invalid or unsupported URL", 400 | ||||
|  | ||||
|     @auth.check_token | ||||
|     def get(self): | ||||
|         """ | ||||
|         @api {get} /api/v1/watch List watches | ||||
|         @apiDescription Return concise list of available watches and some very basic info | ||||
|         @apiExample {curl} Example usage: | ||||
|             curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             { | ||||
|                 "6a4b7d5c-fee4-4616-9f43-4ac97046b595": { | ||||
|                     "last_changed": 1677103794, | ||||
|                     "last_checked": 1677103794, | ||||
|                     "last_error": false, | ||||
|                     "title": "", | ||||
|                     "url": "http://www.quotationspage.com/random.php" | ||||
|                 }, | ||||
|                 "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": { | ||||
|                     "last_changed": 0, | ||||
|                     "last_checked": 1676662819, | ||||
|                     "last_error": false, | ||||
|                     "title": "QuickLook", | ||||
|                     "url": "https://github.com/QL-Win/QuickLook/tags" | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         @apiParam {String} [recheck_all]       Optional Set to =1 to force recheck of all watches | ||||
|         @apiParam {String} [tag]               Optional name of tag to limit results | ||||
|         @apiName ListWatches | ||||
|         @apiGroup Watch Management | ||||
|         @apiSuccess (200) {String} OK JSON dict | ||||
|         """ | ||||
|         list = {} | ||||
|  | ||||
|         tag_limit = request.args.get('tag', '').lower() | ||||
|         for uuid, watch in self.datastore.data['watching'].items(): | ||||
|             # Watch tags by name (replace the other calls?) | ||||
|             tags = self.datastore.get_all_tags_for_watch(uuid=uuid) | ||||
|             if tag_limit and not any(v.get('title').lower() == tag_limit for k, v in tags.items()): | ||||
|                 continue | ||||
|  | ||||
|             list[uuid] = { | ||||
|                 'last_changed': watch.last_changed, | ||||
|                 'last_checked': watch['last_checked'], | ||||
|                 'last_error': watch['last_error'], | ||||
|                 'title': watch['title'], | ||||
|                 'url': watch['url'], | ||||
|                 'viewed': watch.viewed | ||||
|             } | ||||
|  | ||||
|         if request.args.get('recheck_all'): | ||||
|             for uuid in self.datastore.data['watching'].keys(): | ||||
|                 self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             return {'status': "OK"}, 200 | ||||
|  | ||||
|         return list, 200 | ||||
							
								
								
									
										33
									
								
								changedetectionio/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								changedetectionio/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import copy | ||||
| from . import api_schema | ||||
| from ..model import watch_base | ||||
|  | ||||
| # Build a JSON Schema atleast partially based on our Watch model | ||||
| watch_base_config = watch_base() | ||||
| schema = api_schema.build_watch_json_schema(watch_base_config) | ||||
|  | ||||
| schema_create_watch = copy.deepcopy(schema) | ||||
| schema_create_watch['required'] = ['url'] | ||||
|  | ||||
| schema_update_watch = copy.deepcopy(schema) | ||||
| schema_update_watch['additionalProperties'] = False | ||||
|  | ||||
| # Tag schema is also based on watch_base since Tag inherits from it | ||||
| schema_tag = copy.deepcopy(schema) | ||||
| schema_create_tag = copy.deepcopy(schema_tag) | ||||
| schema_create_tag['required'] = ['title'] | ||||
| schema_update_tag = copy.deepcopy(schema_tag) | ||||
| schema_update_tag['additionalProperties'] = False | ||||
|  | ||||
| schema_notification_urls = copy.deepcopy(schema) | ||||
| schema_create_notification_urls = copy.deepcopy(schema_notification_urls) | ||||
| schema_create_notification_urls['required'] = ['notification_urls'] | ||||
| schema_delete_notification_urls = copy.deepcopy(schema_notification_urls) | ||||
| schema_delete_notification_urls['required'] = ['notification_urls'] | ||||
|  | ||||
| # Import all API resources | ||||
| from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch | ||||
| from .Tags import Tags, Tag | ||||
| from .Import import Import | ||||
| from .SystemInfo import SystemInfo | ||||
| from .Notifications import Notifications | ||||
							
								
								
									
										146
									
								
								changedetectionio/api/api_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								changedetectionio/api/api_schema.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| # Responsible for building the storage dict into a set of rules ("JSON Schema") acceptable via the API | ||||
| # Probably other ways to solve this when the backend switches to some ORM | ||||
| from changedetectionio.notification import valid_notification_formats | ||||
|  | ||||
|  | ||||
| def build_time_between_check_json_schema(): | ||||
|     # Setup time between check schema | ||||
|     schema_properties_time_between_check = { | ||||
|         "type": "object", | ||||
|         "additionalProperties": False, | ||||
|         "properties": {} | ||||
|     } | ||||
|     for p in ['weeks', 'days', 'hours', 'minutes', 'seconds']: | ||||
|         schema_properties_time_between_check['properties'][p] = { | ||||
|             "anyOf": [ | ||||
|                 { | ||||
|                     "type": "integer" | ||||
|                 }, | ||||
|                 { | ||||
|                     "type": "null" | ||||
|                 } | ||||
|             ] | ||||
|         } | ||||
|  | ||||
|     return schema_properties_time_between_check | ||||
|  | ||||
| def build_watch_json_schema(d): | ||||
|     # Base JSON schema | ||||
|     schema = { | ||||
|         'type': 'object', | ||||
|         'properties': {}, | ||||
|     } | ||||
|  | ||||
|     for k, v in d.items(): | ||||
|         # @todo 'integer' is not covered here because its almost always for internal usage | ||||
|  | ||||
|         if isinstance(v, type(None)): | ||||
|             schema['properties'][k] = { | ||||
|                 "anyOf": [ | ||||
|                     {"type": "null"}, | ||||
|                 ] | ||||
|             } | ||||
|         elif isinstance(v, list): | ||||
|             schema['properties'][k] = { | ||||
|                 "anyOf": [ | ||||
|                     {"type": "array", | ||||
|                      # Always is an array of strings, like text or regex or something | ||||
|                      "items": { | ||||
|                          "type": "string", | ||||
|                          "maxLength": 5000 | ||||
|                      } | ||||
|                      }, | ||||
|                 ] | ||||
|             } | ||||
|         elif isinstance(v, bool): | ||||
|             schema['properties'][k] = { | ||||
|                 "anyOf": [ | ||||
|                     {"type": "boolean"}, | ||||
|                 ] | ||||
|             } | ||||
|         elif isinstance(v, str): | ||||
|             schema['properties'][k] = { | ||||
|                 "anyOf": [ | ||||
|                     {"type": "string", | ||||
|                      "maxLength": 5000}, | ||||
|                 ] | ||||
|             } | ||||
|  | ||||
|     # Can also be a string (or None by default above) | ||||
|     for v in ['body', | ||||
|               'notification_body', | ||||
|               'notification_format', | ||||
|               'notification_title', | ||||
|               'proxy', | ||||
|               'tag', | ||||
|               'title', | ||||
|               'webdriver_js_execute_code' | ||||
|               ]: | ||||
|         schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000}) | ||||
|  | ||||
|     # None or Boolean | ||||
|     schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'}) | ||||
|  | ||||
|     schema['properties']['method'] = {"type": "string", | ||||
|                                       "enum": ["GET", "POST", "DELETE", "PUT"] | ||||
|                                       } | ||||
|  | ||||
|     schema['properties']['fetch_backend']['anyOf'].append({"type": "string", | ||||
|                                                            "enum": ["html_requests", "html_webdriver"] | ||||
|                                                            }) | ||||
|  | ||||
|  | ||||
|  | ||||
|     # All headers must be key/value type dict | ||||
|     schema['properties']['headers'] = { | ||||
|         "type": "object", | ||||
|         "patternProperties": { | ||||
|             # Should always be a string:string type value | ||||
|             ".*": {"type": "string"}, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     schema['properties']['notification_format'] = {'type': 'string', | ||||
|                                                    'enum': list(valid_notification_formats.keys()) | ||||
|                                                    } | ||||
|  | ||||
|     # Stuff that shouldn't be available but is just state-storage | ||||
|     for v in ['previous_md5', 'last_error', 'has_ldjson_price_data', 'previous_md5_before_filters', 'uuid']: | ||||
|         del schema['properties'][v] | ||||
|  | ||||
|     schema['properties']['webdriver_delay']['anyOf'].append({'type': 'integer'}) | ||||
|  | ||||
|     schema['properties']['time_between_check'] = build_time_between_check_json_schema() | ||||
|  | ||||
|     schema['properties']['browser_steps'] = { | ||||
|         "anyOf": [ | ||||
|             { | ||||
|                 "type": "array", | ||||
|                 "items": { | ||||
|                     "type": "object", | ||||
|                     "properties": { | ||||
|                         "operation": { | ||||
|                             "type": ["string", "null"], | ||||
|                             "maxLength": 5000  # Allows null and any string up to 5000 chars (including "") | ||||
|                         }, | ||||
|                         "selector": { | ||||
|                             "type": ["string", "null"], | ||||
|                             "maxLength": 5000 | ||||
|                         }, | ||||
|                         "optional_value": { | ||||
|                             "type": ["string", "null"], | ||||
|                             "maxLength": 5000 | ||||
|                         } | ||||
|                     }, | ||||
|                     "required": ["operation", "selector", "optional_value"], | ||||
|                     "additionalProperties": False  # No extra keys allowed | ||||
|                 } | ||||
|             }, | ||||
|             {"type": "null"},  # Allows null for `browser_steps` | ||||
|             {"type": "array", "maxItems": 0}  # Allows empty array [] | ||||
|         ] | ||||
|     } | ||||
|  | ||||
|     # headers ? | ||||
|     return schema | ||||
|  | ||||
							
								
								
									
										25
									
								
								changedetectionio/api/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								changedetectionio/api/auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| from flask import request, make_response, jsonify | ||||
| from functools import wraps | ||||
|  | ||||
|  | ||||
| # Simple API auth key comparison | ||||
| # @todo - Maybe short lived token in the future? | ||||
|  | ||||
| def check_token(f): | ||||
|     @wraps(f) | ||||
|     def decorated(*args, **kwargs): | ||||
|         datastore = args[0].datastore | ||||
|  | ||||
|         config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled') | ||||
|         config_api_token = datastore.data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|         # config_api_token_enabled - a UI option in settings if access should obey the key or not | ||||
|         if config_api_token_enabled: | ||||
|             if request.headers.get('x-api-key') != config_api_token: | ||||
|                 return make_response( | ||||
|                     jsonify("Invalid access - API key invalid."), 403 | ||||
|                 ) | ||||
|  | ||||
|         return f(*args, **kwargs) | ||||
|  | ||||
|     return decorated | ||||
							
								
								
									
										33
									
								
								changedetectionio/auth_decorator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								changedetectionio/auth_decorator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import os | ||||
| from functools import wraps | ||||
| from flask import current_app, redirect, request | ||||
| from loguru import logger | ||||
|  | ||||
| def login_optionally_required(func): | ||||
|     """ | ||||
|     If password authentication is enabled, verify the user is logged in. | ||||
|     To be used as a decorator for routes that should optionally require login. | ||||
|     This version is blueprint-friendly as it uses current_app instead of directly accessing app. | ||||
|     """ | ||||
|     @wraps(func) | ||||
|     def decorated_view(*args, **kwargs): | ||||
|         from flask import current_app | ||||
|         import flask_login | ||||
|         from flask_login import current_user | ||||
|  | ||||
|         # Access datastore through the app config | ||||
|         datastore = current_app.config['DATASTORE'] | ||||
|         has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False) | ||||
|  | ||||
|         # Permitted | ||||
|         if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'): | ||||
|             return func(*args, **kwargs) | ||||
|         elif request.method in flask_login.config.EXEMPT_METHODS: | ||||
|             return func(*args, **kwargs) | ||||
|         elif current_app.config.get('LOGIN_DISABLED'): | ||||
|             return func(*args, **kwargs) | ||||
|         elif has_password_enabled and not current_user.is_authenticated: | ||||
|             return current_app.login_manager.unauthorized() | ||||
|  | ||||
|         return func(*args, **kwargs) | ||||
|     return decorated_view | ||||
							
								
								
									
										0
									
								
								changedetectionio/blueprint/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								changedetectionio/blueprint/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										164
									
								
								changedetectionio/blueprint/backups/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								changedetectionio/blueprint/backups/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| import datetime | ||||
| import glob | ||||
| import threading | ||||
|  | ||||
| from flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort | ||||
| import os | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.flask_app import login_optionally_required | ||||
| from loguru import logger | ||||
|  | ||||
| BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip" | ||||
|  | ||||
|  | ||||
| def create_backup(datastore_path, watches: dict): | ||||
|     logger.debug("Creating backup...") | ||||
|     import zipfile | ||||
|     from pathlib import Path | ||||
|  | ||||
|     # create a ZipFile object | ||||
|     timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") | ||||
|     backupname = BACKUP_FILENAME_FORMAT.format(timestamp) | ||||
|     backup_filepath = os.path.join(datastore_path, backupname) | ||||
|  | ||||
|     with zipfile.ZipFile(backup_filepath.replace('.zip', '.tmp'), "w", | ||||
|                          compression=zipfile.ZIP_DEFLATED, | ||||
|                          compresslevel=8) as zipObj: | ||||
|  | ||||
|         # Add the index | ||||
|         zipObj.write(os.path.join(datastore_path, "url-watches.json"), arcname="url-watches.json") | ||||
|  | ||||
|         # Add the flask app secret | ||||
|         zipObj.write(os.path.join(datastore_path, "secret.txt"), arcname="secret.txt") | ||||
|  | ||||
|         # Add any data in the watch data directory. | ||||
|         for uuid, w in watches.items(): | ||||
|             for f in Path(w.watch_data_dir).glob('*'): | ||||
|                 zipObj.write(f, | ||||
|                              # Use the full path to access the file, but make the file 'relative' in the Zip. | ||||
|                              arcname=os.path.join(f.parts[-2], f.parts[-1]), | ||||
|                              compress_type=zipfile.ZIP_DEFLATED, | ||||
|                              compresslevel=8) | ||||
|  | ||||
|         # Create a list file with just the URLs, so it's easier to port somewhere else in the future | ||||
|         list_file = "url-list.txt" | ||||
|         with open(os.path.join(datastore_path, list_file), "w") as f: | ||||
|             for uuid in watches: | ||||
|                 url = watches[uuid]["url"] | ||||
|                 f.write("{}\r\n".format(url)) | ||||
|         list_with_tags_file = "url-list-with-tags.txt" | ||||
|         with open( | ||||
|                 os.path.join(datastore_path, list_with_tags_file), "w" | ||||
|         ) as f: | ||||
|             for uuid in watches: | ||||
|                 url = watches[uuid].get('url') | ||||
|                 tag = watches[uuid].get('tags', {}) | ||||
|                 f.write("{} {}\r\n".format(url, tag)) | ||||
|  | ||||
|         # Add it to the Zip | ||||
|         zipObj.write( | ||||
|             os.path.join(datastore_path, list_file), | ||||
|             arcname=list_file, | ||||
|             compress_type=zipfile.ZIP_DEFLATED, | ||||
|             compresslevel=8, | ||||
|         ) | ||||
|         zipObj.write( | ||||
|             os.path.join(datastore_path, list_with_tags_file), | ||||
|             arcname=list_with_tags_file, | ||||
|             compress_type=zipfile.ZIP_DEFLATED, | ||||
|             compresslevel=8, | ||||
|         ) | ||||
|  | ||||
|     # Now it's done, rename it so it shows up finally and its completed being written. | ||||
|     os.rename(backup_filepath.replace('.zip', '.tmp'), backup_filepath.replace('.tmp', '.zip')) | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     backups_blueprint = Blueprint('backups', __name__, template_folder="templates") | ||||
|     backup_threads = [] | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/request-backup", methods=['GET']) | ||||
|     def request_backup(): | ||||
|         if any(thread.is_alive() for thread in backup_threads): | ||||
|             flash("A backup is already running, check back in a few minutes", "error") | ||||
|             return redirect(url_for('backups.index')) | ||||
|  | ||||
|         if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)): | ||||
|             flash("Maximum number of backups reached, please remove some", "error") | ||||
|             return redirect(url_for('backups.index')) | ||||
|  | ||||
|         # Be sure we're written fresh | ||||
|         datastore.sync_to_json() | ||||
|         zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching"))) | ||||
|         zip_thread.start() | ||||
|         backup_threads.append(zip_thread) | ||||
|         flash("Backup building in background, check back in a few minutes.") | ||||
|  | ||||
|         return redirect(url_for('backups.index')) | ||||
|  | ||||
|     def find_backups(): | ||||
|         backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*")) | ||||
|         backups = glob.glob(backup_filepath) | ||||
|         backup_info = [] | ||||
|  | ||||
|         for backup in backups: | ||||
|             size = os.path.getsize(backup) / (1024 * 1024) | ||||
|             creation_time = os.path.getctime(backup) | ||||
|             backup_info.append({ | ||||
|                 'filename': os.path.basename(backup), | ||||
|                 'filesize': f"{size:.2f}", | ||||
|                 'creation_time': creation_time | ||||
|             }) | ||||
|  | ||||
|         backup_info.sort(key=lambda x: x['creation_time'], reverse=True) | ||||
|  | ||||
|         return backup_info | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/download/<string:filename>", methods=['GET']) | ||||
|     def download_backup(filename): | ||||
|         import re | ||||
|         filename = filename.strip() | ||||
|         backup_filename_regex = BACKUP_FILENAME_FORMAT.format("\d+") | ||||
|  | ||||
|         full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename) | ||||
|         if not full_path.startswith(os.path.abspath(datastore.datastore_path)): | ||||
|             abort(404) | ||||
|  | ||||
|         if filename == 'latest': | ||||
|             backups = find_backups() | ||||
|             filename = backups[0]['filename'] | ||||
|  | ||||
|         if not re.match(r"^" + backup_filename_regex + "$", filename): | ||||
|             abort(400)  # Bad Request if the filename doesn't match the pattern | ||||
|  | ||||
|         logger.debug(f"Backup download request for '{full_path}'") | ||||
|         return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True) | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("", methods=['GET']) | ||||
|     def index(): | ||||
|         backups = find_backups() | ||||
|         output = render_template("overview.html", | ||||
|                                  available_backups=backups, | ||||
|                                  backup_running=any(thread.is_alive() for thread in backup_threads) | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @backups_blueprint.route("/remove-backups", methods=['GET']) | ||||
|     def remove_backups(): | ||||
|  | ||||
|         backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*")) | ||||
|         backups = glob.glob(backup_filepath) | ||||
|         for backup in backups: | ||||
|             os.unlink(backup) | ||||
|  | ||||
|         flash("Backups were deleted.") | ||||
|  | ||||
|         return redirect(url_for('backups.index')) | ||||
|  | ||||
|     return backups_blueprint | ||||
							
								
								
									
										36
									
								
								changedetectionio/blueprint/backups/templates/overview.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								changedetectionio/blueprint/backups/templates/overview.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
|     {% from '_helpers.html' import render_simple_field, render_field %} | ||||
|     <div class="edit-form"> | ||||
|         <div class="box-wrap inner"> | ||||
|             <h4>Backups</h4> | ||||
|             {% if backup_running %} | ||||
|                 <p> | ||||
|                     <strong>A backup is running!</strong> | ||||
|                 </p> | ||||
|             {% endif %} | ||||
|             <p> | ||||
|                 Here you can download and request a new backup, when a backup is completed you will see it listed below. | ||||
|             </p> | ||||
|             <br> | ||||
|                 {% if available_backups %} | ||||
|                     <ul> | ||||
|                     {% for backup in available_backups %} | ||||
|                         <li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{  backup["filesize"] }} Mb</li> | ||||
|                     {% endfor %} | ||||
|                     </ul> | ||||
|                 {% else %} | ||||
|                     <p> | ||||
|                     <strong>No backups found.</strong> | ||||
|                     </p> | ||||
|                 {% endif %} | ||||
|  | ||||
|             <a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">Create backup</a> | ||||
|             {% if available_backups %} | ||||
|                 <a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">Remove backups</a> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
							
								
								
									
										7
									
								
								changedetectionio/blueprint/browser_steps/TODO.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								changedetectionio/blueprint/browser_steps/TODO.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| - This needs an abstraction to directly handle the puppeteer connection methods | ||||
| - Then remove the playwright stuff | ||||
| - Remove hack redirect at line 65 changedetectionio/processors/__init__.py | ||||
|  | ||||
| The screenshots are base64 encoded/decoded which is very CPU intensive for large screenshots (in playwright) but not | ||||
| in the direct puppeteer connection (they are binary end to end) | ||||
|  | ||||
							
								
								
									
										224
									
								
								changedetectionio/blueprint/browser_steps/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								changedetectionio/blueprint/browser_steps/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | ||||
|  | ||||
| # HORRIBLE HACK BUT WORKS :-) PR anyone? | ||||
| # | ||||
| # Why? | ||||
| # `browsersteps_playwright_browser_interface.chromium.connect_over_cdp()` will only run once without async() | ||||
| # - this flask app is not async() | ||||
| # - A single timeout/keepalive which applies to the session made at .connect_over_cdp() | ||||
| # | ||||
| # So it means that we must unfortunately for now just keep a single timer since .connect_over_cdp() was run | ||||
| # and know when that reaches timeout/keepalive :( when that time is up, restart the connection and tell the user | ||||
| # that their time is up, insert another coin. (reload) | ||||
| # | ||||
| # | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask import Blueprint, request, make_response | ||||
| import os | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.flask_app import login_optionally_required | ||||
| from loguru import logger | ||||
|  | ||||
| browsersteps_sessions = {} | ||||
| io_interface_context = None | ||||
| import json | ||||
| import hashlib | ||||
| from flask import Response | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates") | ||||
|  | ||||
|     def start_browsersteps_session(watch_uuid): | ||||
|         from . import nonContext | ||||
|         from . import browser_steps | ||||
|         import time | ||||
|         global io_interface_context | ||||
|  | ||||
|         # We keep the playwright session open for many minutes | ||||
|         keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60 | ||||
|  | ||||
|         browsersteps_start_session = {'start_time': time.time()} | ||||
|  | ||||
|         # You can only have one of these running | ||||
|         # This should be very fine to leave running for the life of the application | ||||
|         # @idea - Make it global so the pool of watch fetchers can use it also | ||||
|         if not io_interface_context: | ||||
|             io_interface_context = nonContext.c_sync_playwright() | ||||
|             # Start the Playwright context, which is actually a nodejs sub-process and communicates over STDIN/STDOUT pipes | ||||
|             io_interface_context = io_interface_context.start() | ||||
|  | ||||
|         keepalive_ms = ((keepalive_seconds + 3) * 1000) | ||||
|         base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '').strip('"') | ||||
|         a = "?" if not '?' in base_url else '&' | ||||
|         base_url += a + f"timeout={keepalive_ms}" | ||||
|  | ||||
|         browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(base_url) | ||||
|  | ||||
|         proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid) | ||||
|         proxy = None | ||||
|         if proxy_id: | ||||
|             proxy_url = datastore.proxy_list.get(proxy_id).get('url') | ||||
|             if proxy_url: | ||||
|  | ||||
|                 # Playwright needs separate username and password values | ||||
|                 from urllib.parse import urlparse | ||||
|                 parsed = urlparse(proxy_url) | ||||
|                 proxy = {'server': proxy_url} | ||||
|  | ||||
|                 if parsed.username: | ||||
|                     proxy['username'] = parsed.username | ||||
|  | ||||
|                 if parsed.password: | ||||
|                     proxy['password'] = parsed.password | ||||
|  | ||||
|                 logger.debug(f"Browser Steps: UUID {watch_uuid} selected proxy {proxy_url}") | ||||
|  | ||||
|         # Tell Playwright to connect to Chrome and setup a new session via our stepper interface | ||||
|         browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui( | ||||
|             playwright_browser=browsersteps_start_session['browser'], | ||||
|             proxy=proxy, | ||||
|             start_url=datastore.data['watching'][watch_uuid].link, | ||||
|             headers=datastore.data['watching'][watch_uuid].get('headers') | ||||
|         ) | ||||
|  | ||||
|         # For test | ||||
|         #browsersteps_start_session['browserstepper'].action_goto_url(value="http://example.com?time="+str(time.time())) | ||||
|  | ||||
|         return browsersteps_start_session | ||||
|  | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET']) | ||||
|     def browsersteps_start_session(): | ||||
|         # A new session was requested, return sessionID | ||||
|  | ||||
|         import uuid | ||||
|         browsersteps_session_id = str(uuid.uuid4()) | ||||
|         watch_uuid = request.args.get('uuid') | ||||
|  | ||||
|         if not watch_uuid: | ||||
|             return make_response('No Watch UUID specified', 500) | ||||
|  | ||||
|         logger.debug("Starting connection with playwright") | ||||
|         logger.debug("browser_steps.py connecting") | ||||
|  | ||||
|         try: | ||||
|             browsersteps_sessions[browsersteps_session_id] = start_browsersteps_session(watch_uuid) | ||||
|         except Exception as e: | ||||
|             if 'ECONNREFUSED' in str(e): | ||||
|                 return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401) | ||||
|             else: | ||||
|                 # Other errors, bad URL syntax, bad reply etc | ||||
|                 return make_response(str(e), 401) | ||||
|  | ||||
|         logger.debug("Starting connection with playwright - done") | ||||
|         return {'browsersteps_session_id': browsersteps_session_id} | ||||
|  | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_image", methods=['GET']) | ||||
|     def browser_steps_fetch_screenshot_image(): | ||||
|         from flask import ( | ||||
|             make_response, | ||||
|             request, | ||||
|             send_from_directory, | ||||
|         ) | ||||
|         uuid = request.args.get('uuid') | ||||
|         step_n = int(request.args.get('step_n')) | ||||
|  | ||||
|         watch = datastore.data['watching'].get(uuid) | ||||
|         filename = f"step_before-{step_n}.jpeg" if request.args.get('type', '') == 'before' else f"step_{step_n}.jpeg" | ||||
|  | ||||
|         if step_n and watch and os.path.isfile(os.path.join(watch.watch_data_dir, filename)): | ||||
|             response = make_response(send_from_directory(directory=watch.watch_data_dir, path=filename)) | ||||
|             response.headers['Content-type'] = 'image/jpeg' | ||||
|             response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' | ||||
|             response.headers['Pragma'] = 'no-cache' | ||||
|             response.headers['Expires'] = 0 | ||||
|             return response | ||||
|  | ||||
|         else: | ||||
|             return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401) | ||||
|  | ||||
|     # A request for an action was received | ||||
|     @login_optionally_required | ||||
|     @browser_steps_blueprint.route("/browsersteps_update", methods=['POST']) | ||||
|     def browsersteps_ui_update(): | ||||
|         import base64 | ||||
|         import playwright._impl._errors | ||||
|         from changedetectionio.blueprint.browser_steps import browser_steps | ||||
|  | ||||
|         remaining =0 | ||||
|         uuid = request.args.get('uuid') | ||||
|  | ||||
|         browsersteps_session_id = request.args.get('browsersteps_session_id') | ||||
|  | ||||
|         if not browsersteps_session_id: | ||||
|             return make_response('No browsersteps_session_id specified', 500) | ||||
|  | ||||
|         if not browsersteps_sessions.get(browsersteps_session_id): | ||||
|             return make_response('No session exists under that ID', 500) | ||||
|  | ||||
|         is_last_step = False | ||||
|         # Actions - step/apply/etc, do the thing and return state | ||||
|         if request.method == 'POST': | ||||
|             # @todo - should always be an existing session | ||||
|             step_operation = request.form.get('operation') | ||||
|             step_selector = request.form.get('selector') | ||||
|             step_optional_value = request.form.get('optional_value') | ||||
|             is_last_step = strtobool(request.form.get('is_last_step')) | ||||
|  | ||||
|             # @todo try.. accept.. nice errors not popups.. | ||||
|             try: | ||||
|  | ||||
|                 browsersteps_sessions[browsersteps_session_id]['browserstepper'].call_action(action_name=step_operation, | ||||
|                                          selector=step_selector, | ||||
|                                          optional_value=step_optional_value) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Exception when calling step operation {step_operation} {str(e)}") | ||||
|                 # Try to find something of value to give back to the user | ||||
|                 return make_response(str(e).splitlines()[0], 401) | ||||
|  | ||||
|  | ||||
| #        if not this_session.page: | ||||
| #            cleanup_playwright_session() | ||||
| #            return make_response('Browser session ran out of time :( Please reload this page.', 401) | ||||
|  | ||||
|         # Screenshots and other info only needed on requesting a step (POST) | ||||
|         try: | ||||
|             (screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() | ||||
|             if is_last_step: | ||||
|                 watch = datastore.data['watching'].get(uuid) | ||||
|                 u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|                 if watch and u: | ||||
|                     watch.save_screenshot(screenshot=screenshot) | ||||
|                     watch.save_xpath_data(data=xpath_data) | ||||
|  | ||||
|         except playwright._impl._api_types.Error as e: | ||||
|             return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||
|         except Exception as e: | ||||
|             return make_response("Error fetching screenshot and element data - " + str(e), 401) | ||||
|  | ||||
|         # SEND THIS BACK TO THE BROWSER | ||||
|  | ||||
|         output = { | ||||
|             "screenshot": f"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}", | ||||
|             "xpath_data": xpath_data, | ||||
|             "session_age_start": browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, | ||||
|             "browser_time_remaining": round(remaining) | ||||
|         } | ||||
|         json_data = json.dumps(output) | ||||
|  | ||||
|         # Generate an ETag (hash of the response body) | ||||
|         etag_hash = hashlib.md5(json_data.encode('utf-8')).hexdigest() | ||||
|  | ||||
|         # Create the response with ETag | ||||
|         response = Response(json_data, mimetype="application/json; charset=UTF-8") | ||||
|         response.set_etag(etag_hash) | ||||
|  | ||||
|         return response | ||||
|  | ||||
|     return browser_steps_blueprint | ||||
|  | ||||
|  | ||||
							
								
								
									
										534
									
								
								changedetectionio/blueprint/browser_steps/browser_steps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										534
									
								
								changedetectionio/blueprint/browser_steps/browser_steps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,534 @@ | ||||
| import os | ||||
| import time | ||||
| import re | ||||
| import sys | ||||
| import traceback | ||||
| from random import randint | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT | ||||
| from changedetectionio.content_fetchers.base import manage_user_agent | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
|  | ||||
|  | ||||
| # Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end | ||||
| # 0- off, 1- on | ||||
| browser_step_ui_config = {'Choose one': '0 0', | ||||
|                           #                 'Check checkbox': '1 0', | ||||
|                           #                 'Click button containing text': '0 1', | ||||
|                           #                 'Scroll to bottom': '0 0', | ||||
|                           #                 'Scroll to element': '1 0', | ||||
|                           #                 'Scroll to top': '0 0', | ||||
|                           #                 'Switch to iFrame by index number': '0 1' | ||||
|                           #                 'Uncheck checkbox': '1 0', | ||||
|                           # @todo | ||||
|                           'Check checkbox': '1 0', | ||||
|                           'Click X,Y': '0 1', | ||||
|                           'Click element if exists': '1 0', | ||||
|                           'Click element': '1 0', | ||||
|                           'Click element containing text': '0 1', | ||||
|                           'Click element containing text if exists': '0 1', | ||||
|                           'Enter text in field': '1 1', | ||||
|                           'Execute JS': '0 1', | ||||
| #                          'Extract text and use as filter': '1 0', | ||||
|                           'Goto site': '0 0', | ||||
|                           'Goto URL': '0 1', | ||||
|                           'Make all child elements visible': '1 0', | ||||
|                           'Press Enter': '0 0', | ||||
|                           'Select by label': '1 1', | ||||
|                           'Scroll down': '0 0', | ||||
|                           'Uncheck checkbox': '1 0', | ||||
|                           'Wait for seconds': '0 1', | ||||
|                           'Wait for text': '0 1', | ||||
|                           'Wait for text in element': '1 1', | ||||
|                           'Remove elements': '1 0', | ||||
|                           #                          'Press Page Down': '0 0', | ||||
|                           #                          'Press Page Up': '0 0', | ||||
|                           # weird bug, come back to it later | ||||
|                           } | ||||
|  | ||||
|  | ||||
| # Good reference - https://playwright.dev/python/docs/input | ||||
| #                  https://pythonmana.com/2021/12/202112162236307035.html | ||||
| # | ||||
| # ONLY Works in Playwright because we need the fullscreen screenshot | ||||
| class steppable_browser_interface(): | ||||
|     page = None | ||||
|     start_url = None | ||||
|     action_timeout = 10 * 1000 | ||||
|  | ||||
|     def __init__(self, start_url): | ||||
|         self.start_url = start_url | ||||
|          | ||||
|     def safe_page_operation(self, operation_fn, default_return=None): | ||||
|         """Safely execute a page operation with error handling""" | ||||
|         if self.page is None: | ||||
|             logger.warning("Attempted operation on None page object") | ||||
|             return default_return | ||||
|              | ||||
|         try: | ||||
|             return operation_fn() | ||||
|         except Exception as e: | ||||
|             logger.debug(f"Page operation failed: {str(e)}") | ||||
|             # Try to reclaim memory if possible | ||||
|             try: | ||||
|                 self.page.request_gc() | ||||
|             except: | ||||
|                 pass | ||||
|             return default_return | ||||
|  | ||||
|     # Convert and perform "Click Button" for example | ||||
|     def call_action(self, action_name, selector=None, optional_value=None): | ||||
|         if self.page is None: | ||||
|             logger.warning("Cannot call action on None page object") | ||||
|             return | ||||
|              | ||||
|         now = time.time() | ||||
|         call_action_name = re.sub('[^0-9a-zA-Z]+', '_', action_name.lower()) | ||||
|         if call_action_name == 'choose_one': | ||||
|             return | ||||
|  | ||||
|         logger.debug(f"> Action calling '{call_action_name}'") | ||||
|         # https://playwright.dev/python/docs/selectors#xpath-selectors | ||||
|         if selector and selector.startswith('/') and not selector.startswith('//'): | ||||
|             selector = "xpath=" + selector | ||||
|  | ||||
|         # Check if action handler exists | ||||
|         if not hasattr(self, "action_" + call_action_name): | ||||
|             logger.warning(f"Action handler for '{call_action_name}' not found") | ||||
|             return | ||||
|              | ||||
|         action_handler = getattr(self, "action_" + call_action_name) | ||||
|  | ||||
|         # Support for Jinja2 variables in the value and selector | ||||
|         if selector and ('{%' in selector or '{{' in selector): | ||||
|             selector = jinja_render(template_str=selector) | ||||
|  | ||||
|         if optional_value and ('{%' in optional_value or '{{' in optional_value): | ||||
|             optional_value = jinja_render(template_str=optional_value) | ||||
|  | ||||
|         try: | ||||
|             action_handler(selector, optional_value) | ||||
|             # Safely wait for timeout | ||||
|             def wait_timeout(): | ||||
|                 self.page.wait_for_timeout(1.5 * 1000) | ||||
|             self.safe_page_operation(wait_timeout) | ||||
|             logger.debug(f"Call action done in {time.time()-now:.2f}s") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error executing action '{call_action_name}': {str(e)}") | ||||
|             # Request garbage collection to free up resources after error | ||||
|             try: | ||||
|                 self.page.request_gc() | ||||
|             except: | ||||
|                 pass | ||||
|  | ||||
|     def action_goto_url(self, selector=None, value=None): | ||||
|         if not value: | ||||
|             logger.warning("No URL provided for goto_url action") | ||||
|             return None | ||||
|              | ||||
|         now = time.time() | ||||
|          | ||||
|         def goto_operation(): | ||||
|             return self.page.goto(value, timeout=0, wait_until='load') | ||||
|              | ||||
|         response = self.safe_page_operation(goto_operation) | ||||
|         logger.debug(f"Time to goto URL {time.time()-now:.2f}s") | ||||
|         return response | ||||
|  | ||||
|     # Incase they request to go back to the start | ||||
|     def action_goto_site(self, selector=None, value=None): | ||||
|         return self.action_goto_url(value=self.start_url) | ||||
|  | ||||
|     def action_click_element_containing_text(self, selector=None, value=''): | ||||
|         logger.debug("Clicking element containing text") | ||||
|         if not value or not len(value.strip()): | ||||
|             return | ||||
|              | ||||
|         def click_operation(): | ||||
|             elem = self.page.get_by_text(value) | ||||
|             if elem.count(): | ||||
|                 elem.first.click(delay=randint(200, 500), timeout=self.action_timeout) | ||||
|                  | ||||
|         self.safe_page_operation(click_operation) | ||||
|  | ||||
|     def action_click_element_containing_text_if_exists(self, selector=None, value=''): | ||||
|         logger.debug("Clicking element containing text if exists") | ||||
|         if not value or not len(value.strip()): | ||||
|             return | ||||
|              | ||||
|         def click_if_exists_operation(): | ||||
|             elem = self.page.get_by_text(value) | ||||
|             logger.debug(f"Clicking element containing text - {elem.count()} elements found") | ||||
|             if elem.count(): | ||||
|                 elem.first.click(delay=randint(200, 500), timeout=self.action_timeout) | ||||
|                  | ||||
|         self.safe_page_operation(click_if_exists_operation) | ||||
|  | ||||
|     def action_enter_text_in_field(self, selector, value): | ||||
|         if not selector or not len(selector.strip()): | ||||
|             return | ||||
|  | ||||
|         def fill_operation(): | ||||
|             self.page.fill(selector, value, timeout=self.action_timeout) | ||||
|              | ||||
|         self.safe_page_operation(fill_operation) | ||||
|  | ||||
|     def action_execute_js(self, selector, value): | ||||
|         if not value: | ||||
|             return None | ||||
|              | ||||
|         def evaluate_operation(): | ||||
|             return self.page.evaluate(value) | ||||
|              | ||||
|         return self.safe_page_operation(evaluate_operation) | ||||
|  | ||||
|     def action_click_element(self, selector, value): | ||||
|         logger.debug("Clicking element") | ||||
|         if not selector or not len(selector.strip()): | ||||
|             return | ||||
|  | ||||
|         def click_operation(): | ||||
|             self.page.click(selector=selector, timeout=self.action_timeout + 20 * 1000, delay=randint(200, 500)) | ||||
|              | ||||
|         self.safe_page_operation(click_operation) | ||||
|  | ||||
|     def action_click_element_if_exists(self, selector, value): | ||||
|         import playwright._impl._errors as _api_types | ||||
|         logger.debug("Clicking element if exists") | ||||
|         if not selector or not len(selector.strip()): | ||||
|             return | ||||
|              | ||||
|         def click_if_exists_operation(): | ||||
|             try: | ||||
|                 self.page.click(selector, timeout=self.action_timeout, delay=randint(200, 500)) | ||||
|             except _api_types.TimeoutError: | ||||
|                 return | ||||
|             except _api_types.Error: | ||||
|                 # Element was there, but page redrew and now its long long gone | ||||
|                 return | ||||
|                  | ||||
|         self.safe_page_operation(click_if_exists_operation) | ||||
|  | ||||
|     def action_click_x_y(self, selector, value): | ||||
|         if not value or not re.match(r'^\s?\d+\s?,\s?\d+\s?$', value): | ||||
|             logger.warning("'Click X,Y' step should be in the format of '100 , 90'") | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             x, y = value.strip().split(',') | ||||
|             x = int(float(x.strip())) | ||||
|             y = int(float(y.strip())) | ||||
|              | ||||
|             def click_xy_operation(): | ||||
|                 self.page.mouse.click(x=x, y=y, delay=randint(200, 500)) | ||||
|                  | ||||
|             self.safe_page_operation(click_xy_operation) | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error parsing x,y coordinates: {str(e)}") | ||||
|  | ||||
|     def action_scroll_down(self, selector, value): | ||||
|         def scroll_operation(): | ||||
|             # Some sites this doesnt work on for some reason | ||||
|             self.page.mouse.wheel(0, 600) | ||||
|             self.page.wait_for_timeout(1000) | ||||
|              | ||||
|         self.safe_page_operation(scroll_operation) | ||||
|  | ||||
|     def action_wait_for_seconds(self, selector, value): | ||||
|         try: | ||||
|             seconds = float(value.strip()) if value else 1.0 | ||||
|              | ||||
|             def wait_operation(): | ||||
|                 self.page.wait_for_timeout(seconds * 1000) | ||||
|                  | ||||
|             self.safe_page_operation(wait_operation) | ||||
|         except (ValueError, TypeError) as e: | ||||
|             logger.error(f"Invalid value for wait_for_seconds: {str(e)}") | ||||
|  | ||||
|     def action_wait_for_text(self, selector, value): | ||||
|         if not value: | ||||
|             return | ||||
|              | ||||
|         import json | ||||
|         v = json.dumps(value) | ||||
|          | ||||
|         def wait_for_text_operation(): | ||||
|             self.page.wait_for_function( | ||||
|                 f'document.querySelector("body").innerText.includes({v});',  | ||||
|                 timeout=30000 | ||||
|             ) | ||||
|              | ||||
|         self.safe_page_operation(wait_for_text_operation) | ||||
|  | ||||
|     def action_wait_for_text_in_element(self, selector, value): | ||||
|         if not selector or not value: | ||||
|             return | ||||
|              | ||||
|         import json | ||||
|         s = json.dumps(selector) | ||||
|         v = json.dumps(value) | ||||
|          | ||||
|         def wait_for_text_in_element_operation(): | ||||
|             self.page.wait_for_function( | ||||
|                 f'document.querySelector({s}).innerText.includes({v});',  | ||||
|                 timeout=30000 | ||||
|             ) | ||||
|              | ||||
|         self.safe_page_operation(wait_for_text_in_element_operation) | ||||
|  | ||||
|     # @todo - in the future make some popout interface to capture what needs to be set | ||||
|     # https://playwright.dev/python/docs/api/class-keyboard | ||||
|     def action_press_enter(self, selector, value): | ||||
|         def press_operation(): | ||||
|             self.page.keyboard.press("Enter", delay=randint(200, 500)) | ||||
|              | ||||
|         self.safe_page_operation(press_operation) | ||||
|  | ||||
|     def action_press_page_up(self, selector, value): | ||||
|         def press_operation(): | ||||
|             self.page.keyboard.press("PageUp", delay=randint(200, 500)) | ||||
|              | ||||
|         self.safe_page_operation(press_operation) | ||||
|  | ||||
|     def action_press_page_down(self, selector, value): | ||||
|         def press_operation(): | ||||
|             self.page.keyboard.press("PageDown", delay=randint(200, 500)) | ||||
|              | ||||
|         self.safe_page_operation(press_operation) | ||||
|  | ||||
|     def action_check_checkbox(self, selector, value): | ||||
|         if not selector: | ||||
|             return | ||||
|              | ||||
|         def check_operation(): | ||||
|             self.page.locator(selector).check(timeout=self.action_timeout) | ||||
|              | ||||
|         self.safe_page_operation(check_operation) | ||||
|  | ||||
|     def action_uncheck_checkbox(self, selector, value): | ||||
|         if not selector: | ||||
|             return | ||||
|              | ||||
|         def uncheck_operation(): | ||||
|             self.page.locator(selector).uncheck(timeout=self.action_timeout) | ||||
|              | ||||
|         self.safe_page_operation(uncheck_operation) | ||||
|  | ||||
|     def action_remove_elements(self, selector, value): | ||||
|         """Removes all elements matching the given selector from the DOM.""" | ||||
|         if not selector: | ||||
|             return | ||||
|              | ||||
|         def remove_operation(): | ||||
|             self.page.locator(selector).evaluate_all("els => els.forEach(el => el.remove())") | ||||
|              | ||||
|         self.safe_page_operation(remove_operation) | ||||
|  | ||||
|     def action_make_all_child_elements_visible(self, selector, value): | ||||
|         """Recursively makes all child elements inside the given selector fully visible.""" | ||||
|         if not selector: | ||||
|             return | ||||
|              | ||||
|         def make_visible_operation(): | ||||
|             self.page.locator(selector).locator("*").evaluate_all(""" | ||||
|                 els => els.forEach(el => { | ||||
|                     el.style.display = 'block';   // Forces it to be displayed | ||||
|                     el.style.visibility = 'visible';   // Ensures it's not hidden | ||||
|                     el.style.opacity = '1';   // Fully opaque | ||||
|                     el.style.position = 'relative';   // Avoids 'absolute' hiding | ||||
|                     el.style.height = 'auto';   // Expands collapsed elements | ||||
|                     el.style.width = 'auto';   // Ensures full visibility | ||||
|                     el.removeAttribute('hidden');   // Removes hidden attribute | ||||
|                     el.classList.remove('hidden', 'd-none');  // Removes common CSS hidden classes | ||||
|                 }) | ||||
|             """) | ||||
|              | ||||
|         self.safe_page_operation(make_visible_operation) | ||||
|  | ||||
| # Responsible for maintaining a live 'context' with the chrome CDP | ||||
| # @todo - how long do contexts live for anyway? | ||||
| class browsersteps_live_ui(steppable_browser_interface): | ||||
|     context = None | ||||
|     page = None | ||||
|     render_extra_delay = 1 | ||||
|     stale = False | ||||
|     # bump and kill this if idle after X sec | ||||
|     age_start = 0 | ||||
|     headers = {} | ||||
|     # Track if resources are properly cleaned up | ||||
|     _is_cleaned_up = False | ||||
|      | ||||
|     # use a special driver, maybe locally etc | ||||
|     command_executor = os.getenv( | ||||
|         "PLAYWRIGHT_BROWSERSTEPS_DRIVER_URL" | ||||
|     ) | ||||
|     # if not.. | ||||
|     if not command_executor: | ||||
|         command_executor = os.getenv( | ||||
|             "PLAYWRIGHT_DRIVER_URL", | ||||
|             'ws://playwright-chrome:3000' | ||||
|         ).strip('"') | ||||
|  | ||||
|     browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') | ||||
|  | ||||
|     def __init__(self, playwright_browser, proxy=None, headers=None, start_url=None): | ||||
|         self.headers = headers or {} | ||||
|         self.age_start = time.time() | ||||
|         self.playwright_browser = playwright_browser | ||||
|         self.start_url = start_url | ||||
|         self._is_cleaned_up = False | ||||
|         if self.context is None: | ||||
|             self.connect(proxy=proxy) | ||||
|  | ||||
|     def __del__(self): | ||||
|         # Ensure cleanup happens if object is garbage collected | ||||
|         self.cleanup() | ||||
|  | ||||
|     # Connect and setup a new context | ||||
|     def connect(self, proxy=None): | ||||
|         # Should only get called once - test that | ||||
|         keep_open = 1000 * 60 * 5 | ||||
|         now = time.time() | ||||
|  | ||||
|         # @todo handle multiple contexts, bind a unique id from the browser on each req? | ||||
|         self.context = self.playwright_browser.new_context( | ||||
|             accept_downloads=False,  # Should never be needed | ||||
|             bypass_csp=True,  # This is needed to enable JavaScript execution on GitHub and others | ||||
|             extra_http_headers=self.headers, | ||||
|             ignore_https_errors=True, | ||||
|             proxy=proxy, | ||||
|             service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), | ||||
|             # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers | ||||
|             user_agent=manage_user_agent(headers=self.headers), | ||||
|         ) | ||||
|  | ||||
|         self.page = self.context.new_page() | ||||
|  | ||||
|         # self.page.set_default_navigation_timeout(keep_open) | ||||
|         self.page.set_default_timeout(keep_open) | ||||
|         # Set event handlers | ||||
|         self.page.on("close", self.mark_as_closed) | ||||
|         # Listen for all console events and handle errors | ||||
|         self.page.on("console", lambda msg: print(f"Browser steps console - {msg.type}: {msg.text} {msg.args}")) | ||||
|  | ||||
|         logger.debug(f"Time to browser setup {time.time()-now:.2f}s") | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|     def mark_as_closed(self): | ||||
|         logger.debug("Page closed, cleaning up..") | ||||
|         self.cleanup() | ||||
|  | ||||
|     def cleanup(self): | ||||
|         """Properly clean up all resources to prevent memory leaks""" | ||||
|         if self._is_cleaned_up: | ||||
|             return | ||||
|              | ||||
|         logger.debug("Cleaning up browser steps resources") | ||||
|          | ||||
|         # Clean up page | ||||
|         if hasattr(self, 'page') and self.page is not None: | ||||
|             try: | ||||
|                 # Force garbage collection before closing | ||||
|                 self.page.request_gc() | ||||
|             except Exception as e: | ||||
|                 logger.debug(f"Error during page garbage collection: {str(e)}") | ||||
|                  | ||||
|             try: | ||||
|                 # Remove event listeners before closing | ||||
|                 self.page.remove_listener("close", self.mark_as_closed) | ||||
|             except Exception as e: | ||||
|                 logger.debug(f"Error removing event listeners: {str(e)}") | ||||
|                  | ||||
|             try: | ||||
|                 self.page.close() | ||||
|             except Exception as e: | ||||
|                 logger.debug(f"Error closing page: {str(e)}") | ||||
|              | ||||
|             self.page = None | ||||
|  | ||||
|         # Clean up context | ||||
|         if hasattr(self, 'context') and self.context is not None: | ||||
|             try: | ||||
|                 self.context.close() | ||||
|             except Exception as e: | ||||
|                 logger.debug(f"Error closing context: {str(e)}") | ||||
|              | ||||
|             self.context = None | ||||
|              | ||||
|         self._is_cleaned_up = True | ||||
|         logger.debug("Browser steps resources cleanup complete") | ||||
|  | ||||
|     @property | ||||
|     def has_expired(self): | ||||
|         if not self.page or self._is_cleaned_up: | ||||
|             return True | ||||
|          | ||||
|         # Check if session has expired based on age | ||||
|         max_age_seconds = int(os.getenv("BROWSER_STEPS_MAX_AGE_SECONDS", 60 * 10))  # Default 10 minutes | ||||
|         if (time.time() - self.age_start) > max_age_seconds: | ||||
|             logger.debug(f"Browser steps session expired after {max_age_seconds} seconds") | ||||
|             return True | ||||
|              | ||||
|         return False | ||||
|  | ||||
|     def get_current_state(self): | ||||
|         """Return the screenshot and interactive elements mapping, generally always called after action_()""" | ||||
|         import importlib.resources | ||||
|         import json | ||||
|         # because we for now only run browser steps in playwright mode (not puppeteer mode) | ||||
|         from changedetectionio.content_fetchers.playwright import capture_full_page | ||||
|  | ||||
|         # Safety check - don't proceed if resources are cleaned up | ||||
|         if self._is_cleaned_up or self.page is None: | ||||
|             logger.warning("Attempted to get current state after cleanup") | ||||
|             return (None, None) | ||||
|  | ||||
|         xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text() | ||||
|  | ||||
|         now = time.time() | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|         screenshot = None | ||||
|         xpath_data = None | ||||
|          | ||||
|         try: | ||||
|             # Get screenshot first | ||||
|             screenshot = capture_full_page(page=self.page) | ||||
|             logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s") | ||||
|  | ||||
|             # Then get interactive elements | ||||
|             now = time.time() | ||||
|             self.page.evaluate("var include_filters=''") | ||||
|             self.page.request_gc() | ||||
|  | ||||
|             scan_elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span' | ||||
|  | ||||
|             MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT)) | ||||
|             xpath_data = json.loads(self.page.evaluate(xpath_element_js, { | ||||
|                 "visualselector_xpath_selectors": scan_elements, | ||||
|                 "max_height": MAX_TOTAL_HEIGHT | ||||
|             })) | ||||
|             self.page.request_gc() | ||||
|  | ||||
|             # Sort elements by size | ||||
|             xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True) | ||||
|             logger.debug(f"Time to scrape xPath element data in browser {time.time()-now:.2f}s") | ||||
|              | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error getting current state: {str(e)}") | ||||
|             # Attempt recovery - force garbage collection | ||||
|             try: | ||||
|                 self.page.request_gc() | ||||
|             except: | ||||
|                 pass | ||||
|          | ||||
|         # Request garbage collection one final time | ||||
|         try: | ||||
|             self.page.request_gc() | ||||
|         except: | ||||
|             pass | ||||
|              | ||||
|         return (screenshot, xpath_data) | ||||
|  | ||||
							
								
								
									
										17
									
								
								changedetectionio/blueprint/browser_steps/nonContext.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								changedetectionio/blueprint/browser_steps/nonContext.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| from playwright.sync_api import PlaywrightContextManager | ||||
|  | ||||
| # So playwright wants to run as a context manager, but we do something horrible and hacky | ||||
| # we are holding the session open for as long as possible, then shutting it down, and opening a new one | ||||
| # So it means we don't get to use PlaywrightContextManager' __enter__ __exit__ | ||||
| # To work around this, make goodbye() act the same as the __exit__() | ||||
| # | ||||
| # But actually I think this is because the context is opened correctly with __enter__() but we timeout the connection | ||||
| # then theres some lock condition where we cant destroy it without it hanging | ||||
|  | ||||
| class c_PlaywrightContextManager(PlaywrightContextManager): | ||||
|  | ||||
|     def goodbye(self) -> None: | ||||
|         self.__exit__() | ||||
|  | ||||
| def c_sync_playwright() -> PlaywrightContextManager: | ||||
|     return c_PlaywrightContextManager() | ||||
							
								
								
									
										124
									
								
								changedetectionio/blueprint/check_proxies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								changedetectionio/blueprint/check_proxies/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| import importlib | ||||
| from concurrent.futures import ThreadPoolExecutor | ||||
|  | ||||
| from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
|  | ||||
| from functools import wraps | ||||
|  | ||||
| from flask import Blueprint | ||||
| from flask_login import login_required | ||||
|  | ||||
| STATUS_CHECKING = 0 | ||||
| STATUS_FAILED = 1 | ||||
| STATUS_OK = 2 | ||||
| THREADPOOL_MAX_WORKERS = 3 | ||||
| _DEFAULT_POOL = ThreadPoolExecutor(max_workers=THREADPOOL_MAX_WORKERS) | ||||
|  | ||||
|  | ||||
| # Maybe use fetch-time if its >5 to show some expected load time? | ||||
| def threadpool(f, executor=None): | ||||
|     @wraps(f) | ||||
|     def wrap(*args, **kwargs): | ||||
|         return (executor or _DEFAULT_POOL).submit(f, *args, **kwargs) | ||||
|  | ||||
|     return wrap | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     check_proxies_blueprint = Blueprint('check_proxies', __name__) | ||||
|     checks_in_progress = {} | ||||
|  | ||||
|     @threadpool | ||||
|     def long_task(uuid, preferred_proxy): | ||||
|         import time | ||||
|         from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions | ||||
|         from changedetectionio.safe_jinja import render as jinja_render | ||||
|  | ||||
|         status = {'status': '', 'length': 0, 'text': ''} | ||||
|  | ||||
|         contents = '' | ||||
|         now = time.time() | ||||
|         try: | ||||
|             processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor") | ||||
|             update_handler = processor_module.perform_site_check(datastore=datastore, | ||||
|                                                                  watch_uuid=uuid | ||||
|                                                                  ) | ||||
|  | ||||
|             update_handler.call_browser(preferred_proxy_id=preferred_proxy) | ||||
|         # title, size is len contents not len xfer | ||||
|         except content_fetcher_exceptions.Non200ErrorCodeReceived as e: | ||||
|             if e.status_code == 404: | ||||
|                 status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but 404 (page not found)"}) | ||||
|             elif e.status_code == 403 or e.status_code == 401: | ||||
|                 status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"}) | ||||
|             else: | ||||
|                 status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"}) | ||||
|         except FilterNotFoundInResponse: | ||||
|             status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"}) | ||||
|         except content_fetcher_exceptions.EmptyReply as e: | ||||
|             if e.status_code == 403 or e.status_code == 401: | ||||
|                 status.update({'status': 'ERROR OTHER', 'length': len(contents), 'text': f"Got empty reply with code {e.status_code} - Access denied"}) | ||||
|             else: | ||||
|                 status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': f"Empty reply with code {e.status_code}, needs chrome?"}) | ||||
|         except content_fetcher_exceptions.ReplyWithContentButNoText as e: | ||||
|             txt = f"Got reply but with no content - Status code {e.status_code} - It's possible that the filters were found, but contained no usable text (or contained only an image)." | ||||
|             status.update({'status': 'ERROR', 'text': txt}) | ||||
|         except Exception as e: | ||||
|             status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': 'Error: '+type(e).__name__+str(e)}) | ||||
|         else: | ||||
|             status.update({'status': 'OK', 'length': len(contents), 'text': ''}) | ||||
|  | ||||
|         if status.get('text'): | ||||
|             # parse 'text' as text for safety | ||||
|             v = {'text': status['text']} | ||||
|             status['text'] = jinja_render(template_str='{{text|e}}', **v) | ||||
|  | ||||
|         status['time'] = "{:.2f}s".format(time.time() - now) | ||||
|  | ||||
|         return status | ||||
|  | ||||
|     def _recalc_check_status(uuid): | ||||
|  | ||||
|         results = {} | ||||
|         for k, v in checks_in_progress.get(uuid, {}).items(): | ||||
|             try: | ||||
|                 r_1 = v.result(timeout=0.05) | ||||
|             except Exception as e: | ||||
|                 # If timeout error? | ||||
|                 results[k] = {'status': 'RUNNING'} | ||||
|  | ||||
|             else: | ||||
|                 results[k] = r_1 | ||||
|  | ||||
|         return results | ||||
|  | ||||
|     @login_required | ||||
|     @check_proxies_blueprint.route("/<string:uuid>/status", methods=['GET']) | ||||
|     def get_recheck_status(uuid): | ||||
|         results = _recalc_check_status(uuid=uuid) | ||||
|         return results | ||||
|  | ||||
|     @login_required | ||||
|     @check_proxies_blueprint.route("/<string:uuid>/start", methods=['GET']) | ||||
|     def start_check(uuid): | ||||
|  | ||||
|         if not datastore.proxy_list: | ||||
|             return | ||||
|  | ||||
|         if checks_in_progress.get(uuid): | ||||
|             state = _recalc_check_status(uuid=uuid) | ||||
|             for proxy_key, v in state.items(): | ||||
|                 if v.get('status') == 'RUNNING': | ||||
|                     return state | ||||
|         else: | ||||
|             checks_in_progress[uuid] = {} | ||||
|  | ||||
|         for k, v in datastore.proxy_list.items(): | ||||
|             if not checks_in_progress[uuid].get(k): | ||||
|                 checks_in_progress[uuid][k] = long_task(uuid=uuid, preferred_proxy=k) | ||||
|  | ||||
|         results = _recalc_check_status(uuid=uuid) | ||||
|         return results | ||||
|  | ||||
|     return check_proxies_blueprint | ||||
							
								
								
									
										74
									
								
								changedetectionio/blueprint/imports/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								changedetectionio/blueprint/imports/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
| from changedetectionio.blueprint.imports.importer import ( | ||||
|     import_url_list,  | ||||
|     import_distill_io_json,  | ||||
|     import_xlsx_wachete,  | ||||
|     import_xlsx_custom | ||||
| ) | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): | ||||
|     import_blueprint = Blueprint('imports', __name__, template_folder="templates") | ||||
|      | ||||
|     @import_blueprint.route("/import", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     def import_page(): | ||||
|         remaining_urls = [] | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             # URL List import | ||||
|             if request.values.get('urls') and len(request.values.get('urls').strip()): | ||||
|                 # Import and push into the queue for immediate update check | ||||
|                 importer_handler = import_url_list() | ||||
|                 importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff')) | ||||
|                 for uuid in importer_handler.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|                 if len(importer_handler.remaining_data) == 0: | ||||
|                     return redirect(url_for('watchlist.index')) | ||||
|                 else: | ||||
|                     remaining_urls = importer_handler.remaining_data | ||||
|  | ||||
|             # Distill.io import | ||||
|             if request.values.get('distill-io') and len(request.values.get('distill-io').strip()): | ||||
|                 # Import and push into the queue for immediate update check | ||||
|                 d_importer = import_distill_io_json() | ||||
|                 d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore) | ||||
|                 for uuid in d_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # XLSX importer | ||||
|             if request.files and request.files.get('xlsx_file'): | ||||
|                 file = request.files['xlsx_file'] | ||||
|  | ||||
|                 if request.values.get('file_mapping') == 'wachete': | ||||
|                     w_importer = import_xlsx_wachete() | ||||
|                     w_importer.run(data=file, flash=flash, datastore=datastore) | ||||
|                 else: | ||||
|                     w_importer = import_xlsx_custom() | ||||
|                     # Building mapping of col # to col # type | ||||
|                     map = {} | ||||
|                     for i in range(10): | ||||
|                         c = request.values.get(f"custom_xlsx[col_{i}]") | ||||
|                         v = request.values.get(f"custom_xlsx[col_type_{i}]") | ||||
|                         if c and v: | ||||
|                             map[int(c)] = v | ||||
|  | ||||
|                     w_importer.import_profile = map | ||||
|                     w_importer.run(data=file, flash=flash, datastore=datastore) | ||||
|  | ||||
|                 for uuid in w_importer.new_uuids: | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|         # Could be some remaining, or we could be on GET | ||||
|         form = forms.importForm(formdata=request.form if request.method == 'POST' else None) | ||||
|         output = render_template("import.html", | ||||
|                                 form=form, | ||||
|                                 import_url_list_remaining="\n".join(remaining_urls), | ||||
|                                 original_distill_json='' | ||||
|                                 ) | ||||
|         return output | ||||
|  | ||||
|     return import_blueprint | ||||
							
								
								
									
										302
									
								
								changedetectionio/blueprint/imports/importer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								changedetectionio/blueprint/imports/importer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,302 @@ | ||||
| from abc import abstractmethod | ||||
| import time | ||||
| from wtforms import ValidationError | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.forms import validate_url | ||||
|  | ||||
|  | ||||
| class Importer(): | ||||
|     remaining_data = [] | ||||
|     new_uuids = [] | ||||
|     good = 0 | ||||
|  | ||||
|     def __init__(self): | ||||
|         self.new_uuids = [] | ||||
|         self.good = 0 | ||||
|         self.remaining_data = [] | ||||
|         self.import_profile = None | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run(self, | ||||
|             data, | ||||
|             flash, | ||||
|             datastore): | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class import_url_list(Importer): | ||||
|     """ | ||||
|     Imports a list, can be in <code>https://example.com tag1, tag2, last tag</code> format | ||||
|     """ | ||||
|     def run(self, | ||||
|             data, | ||||
|             flash, | ||||
|             datastore, | ||||
|             processor=None | ||||
|             ): | ||||
|  | ||||
|         urls = data.split("\n") | ||||
|         good = 0 | ||||
|         now = time.time() | ||||
|  | ||||
|         if (len(urls) > 5000): | ||||
|             flash("Importing 5,000 of the first URLs from your list, the rest can be imported again.") | ||||
|  | ||||
|         for url in urls: | ||||
|             url = url.strip() | ||||
|             if not len(url): | ||||
|                 continue | ||||
|  | ||||
|             tags = "" | ||||
|  | ||||
|             # 'tags' should be a csv list after the URL | ||||
|             if ' ' in url: | ||||
|                 url, tags = url.split(" ", 1) | ||||
|  | ||||
|             # Flask wtform validators wont work with basic auth, use validators package | ||||
|             # Up to 5000 per batch so we dont flood the server | ||||
|             # @todo validators.url will fail when you add your own IP etc | ||||
|             if len(url) and 'http' in url.lower() and good < 5000: | ||||
|                 extras = None | ||||
|                 if processor: | ||||
|                     extras = {'processor': processor} | ||||
|                 new_uuid = datastore.add_watch(url=url.strip(), tag=tags, write_to_disk_now=False, extras=extras) | ||||
|  | ||||
|                 if new_uuid: | ||||
|                     # Straight into the queue. | ||||
|                     self.new_uuids.append(new_uuid) | ||||
|                     good += 1 | ||||
|                     continue | ||||
|  | ||||
|             # Worked past the 'continue' above, append it to the bad list | ||||
|             if self.remaining_data is None: | ||||
|                 self.remaining_data = [] | ||||
|             self.remaining_data.append(url) | ||||
|  | ||||
|         flash("{} Imported from list in {:.2f}s, {} Skipped.".format(good, time.time() - now, len(self.remaining_data))) | ||||
|  | ||||
|  | ||||
| class import_distill_io_json(Importer): | ||||
|     def run(self, | ||||
|             data, | ||||
|             flash, | ||||
|             datastore, | ||||
|             ): | ||||
|  | ||||
|         import json | ||||
|         good = 0 | ||||
|         now = time.time() | ||||
|         self.new_uuids=[] | ||||
|  | ||||
|         # @todo Use JSONSchema like in the API to validate here. | ||||
|          | ||||
|         try: | ||||
|             data = json.loads(data.strip()) | ||||
|         except json.decoder.JSONDecodeError: | ||||
|             flash("Unable to read JSON file, was it broken?", 'error') | ||||
|             return | ||||
|  | ||||
|         if not data.get('data'): | ||||
|             flash("JSON structure looks invalid, was it broken?", 'error') | ||||
|             return | ||||
|  | ||||
|         for d in data.get('data'): | ||||
|             d_config = json.loads(d['config']) | ||||
|             extras = {'title': d.get('name', None)} | ||||
|  | ||||
|             if len(d['uri']) and good < 5000: | ||||
|                 try: | ||||
|                     # @todo we only support CSS ones at the moment | ||||
|                     if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css': | ||||
|                         extras['subtractive_selectors'] = d_config['selections'][0]['frames'][0]['excludes'][0]['expr'] | ||||
|                 except KeyError: | ||||
|                     pass | ||||
|                 except IndexError: | ||||
|                     pass | ||||
|                 extras['include_filters'] = [] | ||||
|                 try: | ||||
|                     if d_config['selections'][0]['frames'][0]['includes'][0]['type'] == 'xpath': | ||||
|                         extras['include_filters'].append('xpath:' + d_config['selections'][0]['frames'][0]['includes'][0]['expr']) | ||||
|                     else: | ||||
|                         extras['include_filters'].append(d_config['selections'][0]['frames'][0]['includes'][0]['expr']) | ||||
|                 except KeyError: | ||||
|                     pass | ||||
|                 except IndexError: | ||||
|                     pass | ||||
|  | ||||
|                 new_uuid = datastore.add_watch(url=d['uri'].strip(), | ||||
|                                                tag=",".join(d.get('tags', [])), | ||||
|                                                extras=extras, | ||||
|                                                write_to_disk_now=False) | ||||
|  | ||||
|                 if new_uuid: | ||||
|                     # Straight into the queue. | ||||
|                     self.new_uuids.append(new_uuid) | ||||
|                     good += 1 | ||||
|  | ||||
|         flash("{} Imported from Distill.io in {:.2f}s, {} Skipped.".format(len(self.new_uuids), time.time() - now, len(self.remaining_data))) | ||||
|  | ||||
|  | ||||
| class import_xlsx_wachete(Importer): | ||||
|  | ||||
|     def run(self, | ||||
|             data, | ||||
|             flash, | ||||
|             datastore, | ||||
|             ): | ||||
|  | ||||
|         good = 0 | ||||
|         now = time.time() | ||||
|         self.new_uuids = [] | ||||
|  | ||||
|         from openpyxl import load_workbook | ||||
|  | ||||
|         try: | ||||
|             wb = load_workbook(data) | ||||
|         except Exception as e: | ||||
|             # @todo correct except | ||||
|             flash("Unable to read export XLSX file, something wrong with the file?", 'error') | ||||
|             return | ||||
|  | ||||
|         row_id = 2 | ||||
|         for row in wb.active.iter_rows(min_row=row_id): | ||||
|             try: | ||||
|                 extras = {} | ||||
|                 data = {} | ||||
|                 for cell in row: | ||||
|                     if not cell.value: | ||||
|                         continue | ||||
|                     column_title = wb.active.cell(row=1, column=cell.column).value.strip().lower() | ||||
|                     data[column_title] = cell.value | ||||
|  | ||||
|                 # Forced switch to webdriver/playwright/etc | ||||
|                 dynamic_wachet = str(data.get('dynamic wachet', '')).strip().lower()  # Convert bool to str to cover all cases | ||||
|                 # libreoffice and others can have it as =FALSE() =TRUE(), or bool(true) | ||||
|                 if 'true' in dynamic_wachet or dynamic_wachet == '1': | ||||
|                     extras['fetch_backend'] = 'html_webdriver' | ||||
|                 elif 'false' in dynamic_wachet or dynamic_wachet == '0': | ||||
|                     extras['fetch_backend'] = 'html_requests' | ||||
|  | ||||
|                 if data.get('xpath'): | ||||
|                     # @todo split by || ? | ||||
|                     extras['include_filters'] = [data.get('xpath')] | ||||
|                 if data.get('name'): | ||||
|                     extras['title'] = data.get('name').strip() | ||||
|                 if data.get('interval (min)'): | ||||
|                     minutes = int(data.get('interval (min)')) | ||||
|                     hours, minutes = divmod(minutes, 60) | ||||
|                     days, hours = divmod(hours, 24) | ||||
|                     weeks, days = divmod(days, 7) | ||||
|                     extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0} | ||||
|  | ||||
|                 # At minimum a URL is required. | ||||
|                 if data.get('url'): | ||||
|                     try: | ||||
|                         validate_url(data.get('url')) | ||||
|                     except ValidationError as e: | ||||
|                         logger.error(f">> Import URL error {data.get('url')} {str(e)}") | ||||
|                         flash(f"Error processing row number {row_id}, URL value was incorrect, row was skipped.", 'error') | ||||
|                         # Don't bother processing anything else on this row | ||||
|                         continue | ||||
|  | ||||
|                     new_uuid = datastore.add_watch(url=data['url'].strip(), | ||||
|                                                    extras=extras, | ||||
|                                                    tag=data.get('folder'), | ||||
|                                                    write_to_disk_now=False) | ||||
|                     if new_uuid: | ||||
|                         # Straight into the queue. | ||||
|                         self.new_uuids.append(new_uuid) | ||||
|                         good += 1 | ||||
|             except Exception as e: | ||||
|                 logger.error(e) | ||||
|                 flash(f"Error processing row number {row_id}, check all cell data types are correct, row was skipped.", 'error') | ||||
|             else: | ||||
|                 row_id += 1 | ||||
|  | ||||
|         flash( | ||||
|             "{} imported from Wachete .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now)) | ||||
|  | ||||
|  | ||||
| class import_xlsx_custom(Importer): | ||||
|  | ||||
|     def run(self, | ||||
|             data, | ||||
|             flash, | ||||
|             datastore, | ||||
|             ): | ||||
|  | ||||
|         good = 0 | ||||
|         now = time.time() | ||||
|         self.new_uuids = [] | ||||
|  | ||||
|         from openpyxl import load_workbook | ||||
|  | ||||
|         try: | ||||
|             wb = load_workbook(data) | ||||
|         except Exception as e: | ||||
|             # @todo correct except | ||||
|             flash("Unable to read export XLSX file, something wrong with the file?", 'error') | ||||
|             return | ||||
|  | ||||
|         # @todo cehck atleast 2 rows, same in other method | ||||
|         from changedetectionio.forms import validate_url | ||||
|         row_i = 1 | ||||
|  | ||||
|         try: | ||||
|             for row in wb.active.iter_rows(): | ||||
|                 url = None | ||||
|                 tags = None | ||||
|                 extras = {} | ||||
|  | ||||
|                 for cell in row: | ||||
|                     if not self.import_profile.get(cell.col_idx): | ||||
|                         continue | ||||
|                     if not cell.value: | ||||
|                         continue | ||||
|  | ||||
|                     cell_map = self.import_profile.get(cell.col_idx) | ||||
|  | ||||
|                     cell_val = str(cell.value).strip()  # could be bool | ||||
|  | ||||
|                     if cell_map == 'url': | ||||
|                         url = cell.value.strip() | ||||
|                         try: | ||||
|                             validate_url(url) | ||||
|                         except ValidationError as e: | ||||
|                             logger.error(f">> Import URL error {url} {str(e)}") | ||||
|                             flash(f"Error processing row number {row_i}, URL value was incorrect, row was skipped.", 'error') | ||||
|                             # Don't bother processing anything else on this row | ||||
|                             url = None | ||||
|                             break | ||||
|                     elif cell_map == 'tag': | ||||
|                         tags = cell.value.strip() | ||||
|                     elif cell_map == 'include_filters': | ||||
|                         # @todo validate? | ||||
|                         extras['include_filters'] = [cell.value.strip()] | ||||
|                     elif cell_map == 'interval_minutes': | ||||
|                         hours, minutes = divmod(int(cell_val), 60) | ||||
|                         days, hours = divmod(hours, 24) | ||||
|                         weeks, days = divmod(days, 7) | ||||
|                         extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0} | ||||
|                     else: | ||||
|                         extras[cell_map] = cell_val | ||||
|  | ||||
|                 # At minimum a URL is required. | ||||
|                 if url: | ||||
|                     new_uuid = datastore.add_watch(url=url, | ||||
|                                                    extras=extras, | ||||
|                                                    tag=tags, | ||||
|                                                    write_to_disk_now=False) | ||||
|                     if new_uuid: | ||||
|                         # Straight into the queue. | ||||
|                         self.new_uuids.append(new_uuid) | ||||
|                         good += 1 | ||||
|         except Exception as e: | ||||
|             logger.error(e) | ||||
|             flash(f"Error processing row number {row_i}, check all cell data types are correct, row was skipped.", 'error') | ||||
|         else: | ||||
|             row_i += 1 | ||||
|  | ||||
|         flash( | ||||
|             "{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now)) | ||||
							
								
								
									
										123
									
								
								changedetectionio/blueprint/imports/templates/import.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								changedetectionio/blueprint/imports/templates/import.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.html' import render_field %} | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <div class="edit-form monospaced-textarea"> | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id=""><a href="#url-list">URL List</a></li> | ||||
|             <li class="tab"><a href="#distill-io">Distill.io</a></li> | ||||
|             <li class="tab"><a href="#xlsx">.XLSX & Wachete</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form" action="{{url_for('imports.import_page')}}" method="POST" enctype="multipart/form-data"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|             <div class="tab-pane-inner" id="url-list"> | ||||
|                 <div class="pure-control-group"> | ||||
|                         Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma | ||||
|                         (,): | ||||
|                         <br> | ||||
|                         <p><strong>Example:  </strong><code>https://example.com tag1, tag2, last tag</code></p> | ||||
|                         URLs which do not pass validation will stay in the textarea. | ||||
|                 </div> | ||||
|                 {{ render_field(form.processor, class="processor") }} | ||||
|                  | ||||
|                 <div class="pure-control-group"> | ||||
|                     <textarea name="urls" class="pure-input-1-2" placeholder="https://" | ||||
|                               style="width: 100%; | ||||
|                                 font-family:monospace; | ||||
|                                 white-space: pre; | ||||
|                                 overflow-wrap: normal; | ||||
|                                 overflow-x: scroll;" rows="25">{{ import_url_list_remaining }}</textarea> | ||||
|                  </div> | ||||
|                  <div id="quick-watch-processor-type"></div> | ||||
|  | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="distill-io"> | ||||
|  | ||||
|  | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.<br> | ||||
|                         This is <i>experimental</i>, supported fields are <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, the rest (including <code>schedule</code>) are ignored. | ||||
|                         <br> | ||||
|                         <p> | ||||
|                         How to export? <a href="https://distill.io/docs/web-monitor/how-export-and-import-monitors/">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br> | ||||
|                         Be sure to set your default fetcher to Chrome if required.<br> | ||||
|                         </p> | ||||
|                     </div> | ||||
|  | ||||
|  | ||||
|                     <textarea name="distill-io" class="pure-input-1-2" style="width: 100%; | ||||
|                                 font-family:monospace; | ||||
|                                 white-space: pre; | ||||
|                                 overflow-wrap: normal; | ||||
|                                 overflow-x: scroll;" placeholder="Example Distill.io JSON export file | ||||
|  | ||||
| { | ||||
|     "client": { | ||||
|         "local": 1 | ||||
|     }, | ||||
|     "data": [ | ||||
|         { | ||||
|             "name": "Unraid | News", | ||||
|             "uri": "https://unraid.net/blog", | ||||
|             "config": "{\"selections\":[{\"frames\":[{\"index\":0,\"excludes\":[],\"includes\":[{\"type\":\"xpath\",\"expr\":\"(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]\"}]}],\"dynamic\":true,\"delay\":2}],\"ignoreEmptyText\":true,\"includeStyle\":false,\"dataAttr\":\"text\"}", | ||||
|             "tags": [], | ||||
|             "content_type": 2, | ||||
|             "state": 40, | ||||
|             "schedule": "{\"type\":\"INTERVAL\",\"params\":{\"interval\":4447}}", | ||||
|             "ts": "2022-03-27T15:51:15.667Z" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
| " rows="25">{{ original_distill_json }}</textarea> | ||||
|  | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="xlsx"> | ||||
|             <fieldset> | ||||
|                 <div class="pure-control-group"> | ||||
|                 {{ render_field(form.xlsx_file, class="processor") }} | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_field(form.file_mapping, class="processor") }} | ||||
|                 </div> | ||||
|             </fieldset> | ||||
|                 <div class="pure-control-group"> | ||||
|                 <span class="pure-form-message-inline"> | ||||
|                     Table of custom column and data types mapping for the <strong>Custom mapping</strong> File mapping type. | ||||
|                 </span> | ||||
|                     <table style="border: 1px solid #aaa; padding: 0.5rem; border-radius: 4px;"> | ||||
|                         <tr> | ||||
|                             <td><strong>Column #</strong></td> | ||||
|                             {% for n in range(4) %} | ||||
|                                 <td><input type="number" name="custom_xlsx[col_{{n}}]" style="width: 4rem;" min="1"></td> | ||||
|                             {%  endfor %} | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td><strong>Type</strong></td> | ||||
|                             {% for n in range(4) %} | ||||
|                                 <td><select name="custom_xlsx[col_type_{{n}}]"> | ||||
|                                     <option value="" style="color: #aaa"> -- none --</option> | ||||
|                                     <option value="url">URL</option> | ||||
|                                     <option value="title">Title</option> | ||||
|                                     <option value="include_filters">CSS/xPath filter</option> | ||||
|                                     <option value="tag">Group / Tag name(s)</option> | ||||
|                                     <option value="interval_minutes">Recheck time (minutes)</option> | ||||
|                                 </select></td> | ||||
|                             {%  endfor %} | ||||
|                         </tr> | ||||
|                     </table> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button> | ||||
|         </form> | ||||
|  | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
							
								
								
									
										34
									
								
								changedetectionio/blueprint/price_data_follower/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								changedetectionio/blueprint/price_data_follower/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask import Blueprint, flash, redirect, url_for | ||||
| from flask_login import login_required | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| from queue import PriorityQueue | ||||
|  | ||||
| PRICE_DATA_TRACK_ACCEPT = 'accepted' | ||||
| PRICE_DATA_TRACK_REJECT = 'rejected' | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue): | ||||
|  | ||||
|     price_data_follower_blueprint = Blueprint('price_data_follower', __name__) | ||||
|  | ||||
|     @login_required | ||||
|     @price_data_follower_blueprint.route("/<string:uuid>/accept", methods=['GET']) | ||||
|     def accept(uuid): | ||||
|         datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT | ||||
|         datastore.data['watching'][uuid]['processor'] = 'restock_diff' | ||||
|         datastore.data['watching'][uuid].clear_watch() | ||||
|         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|         return redirect(url_for("watchlist.index")) | ||||
|  | ||||
|     @login_required | ||||
|     @price_data_follower_blueprint.route("/<string:uuid>/reject", methods=['GET']) | ||||
|     def reject(uuid): | ||||
|         datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT | ||||
|         return redirect(url_for("watchlist.index")) | ||||
|  | ||||
|  | ||||
|     return price_data_follower_blueprint | ||||
|  | ||||
|  | ||||
							
								
								
									
										1
									
								
								changedetectionio/blueprint/rss/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								changedetectionio/blueprint/rss/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| RSS_FORMAT_TYPES = [('plaintext', 'Plain text'), ('html', 'HTML Color')] | ||||
							
								
								
									
										147
									
								
								changedetectionio/blueprint/rss/blueprint.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								changedetectionio/blueprint/rss/blueprint.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
|  | ||||
| from changedetectionio.safe_jinja import render as jinja_render | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from feedgen.feed import FeedGenerator | ||||
| from flask import Blueprint, make_response, request, url_for, redirect | ||||
| from loguru import logger | ||||
| import datetime | ||||
| import pytz | ||||
| import re | ||||
| import time | ||||
|  | ||||
|  | ||||
| BAD_CHARS_REGEX=r'[\x00-\x08\x0B\x0C\x0E-\x1F]' | ||||
|  | ||||
| # Anything that is not text/UTF-8 should be stripped before it breaks feedgen (such as binary data etc) | ||||
| def scan_invalid_chars_in_rss(content): | ||||
|     for match in re.finditer(BAD_CHARS_REGEX, content): | ||||
|         i = match.start() | ||||
|         bad_char = content[i] | ||||
|         hex_value = f"0x{ord(bad_char):02x}" | ||||
|         # Grab context | ||||
|         start = max(0, i - 20) | ||||
|         end = min(len(content), i + 21) | ||||
|         context = content[start:end].replace('\n', '\\n').replace('\r', '\\r') | ||||
|         logger.warning(f"Invalid char {hex_value} at pos {i}: ...{context}...") | ||||
|         # First match is enough | ||||
|         return True | ||||
|  | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def clean_entry_content(content): | ||||
|     cleaned = re.sub(BAD_CHARS_REGEX, '', content) | ||||
|     return cleaned | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     rss_blueprint = Blueprint('rss', __name__) | ||||
|  | ||||
|     # Some RSS reader situations ended up with rss/ (forward slash after RSS) due | ||||
|     # to some earlier blueprint rerouting work, it should goto feed. | ||||
|     @rss_blueprint.route("/", methods=['GET']) | ||||
|     def extraslash(): | ||||
|         return redirect(url_for('rss.feed')) | ||||
|  | ||||
|     # Import the login decorator if needed | ||||
|     # from changedetectionio.auth_decorator import login_optionally_required | ||||
|     @rss_blueprint.route("", methods=['GET']) | ||||
|     def feed(): | ||||
|         now = time.time() | ||||
|         # Always requires token set | ||||
|         app_rss_token = datastore.data['settings']['application'].get('rss_access_token') | ||||
|         rss_url_token = request.args.get('token') | ||||
|         if rss_url_token != app_rss_token: | ||||
|             return "Access denied, bad token", 403 | ||||
|  | ||||
|         from changedetectionio import diff | ||||
|         limit_tag = request.args.get('tag', '').lower().strip() | ||||
|         # Be sure limit_tag is a uuid | ||||
|         for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): | ||||
|             if limit_tag == tag.get('title', '').lower().strip(): | ||||
|                 limit_tag = uuid | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
|         sorted_watches = [] | ||||
|  | ||||
|         # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|             # @todo tag notification_muted skip also (improve Watch model) | ||||
|             if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'): | ||||
|                 continue | ||||
|             if limit_tag and not limit_tag in watch['tags']: | ||||
|                 continue | ||||
|             watch['uuid'] = uuid | ||||
|             sorted_watches.append(watch) | ||||
|  | ||||
|         sorted_watches.sort(key=lambda x: x.last_changed, reverse=False) | ||||
|  | ||||
|         fg = FeedGenerator() | ||||
|         fg.title('changedetection.io') | ||||
|         fg.description('Feed description') | ||||
|         fg.link(href='https://changedetection.io') | ||||
|  | ||||
|         html_colour_enable = False | ||||
|         if datastore.data['settings']['application'].get('rss_content_format') == 'html': | ||||
|             html_colour_enable = True | ||||
|  | ||||
|         for watch in sorted_watches: | ||||
|  | ||||
|             dates = list(watch.history.keys()) | ||||
|             # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected. | ||||
|             if len(dates) < 2: | ||||
|                 continue | ||||
|  | ||||
|             if not watch.viewed: | ||||
|                 # Re #239 - GUID needs to be individual for each event | ||||
|                 # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228) | ||||
|                 guid = "{}/{}".format(watch['uuid'], watch.last_changed) | ||||
|                 fe = fg.add_entry() | ||||
|  | ||||
|                 # Include a link to the diff page, they will have to login here to see if password protection is enabled. | ||||
|                 # Description is the page you watch, link takes you to the diff JS UI page | ||||
|                 # Dict val base_url will get overriden with the env var if it is set. | ||||
|                 ext_base_url = datastore.data['settings']['application'].get('active_base_url') | ||||
|                 # @todo fix | ||||
|  | ||||
|                 # Because we are called via whatever web server, flask should figure out the right path ( | ||||
|                 diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)} | ||||
|  | ||||
|                 fe.link(link=diff_link) | ||||
|  | ||||
|                 # @todo watch should be a getter - watch.get('title') (internally if URL else..) | ||||
|  | ||||
|                 watch_title = watch.get('title') if watch.get('title') else watch.get('url') | ||||
|                 fe.title(title=watch_title) | ||||
|                 try: | ||||
|  | ||||
|                     html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]), | ||||
|                                                  newest_version_file_contents=watch.get_history_snapshot(dates[-1]), | ||||
|                                                  include_equal=False, | ||||
|                                                  line_feed_sep="<br>", | ||||
|                                                  html_colour=html_colour_enable | ||||
|                                                  ) | ||||
|                 except FileNotFoundError as e: | ||||
|                     html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found." | ||||
|  | ||||
|                 # @todo Make this configurable and also consider html-colored markup | ||||
|                 # @todo User could decide if <link> goes to the diff page, or to the watch link | ||||
|                 rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n" | ||||
|  | ||||
|                 content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link) | ||||
|  | ||||
|                 # Out of range chars could also break feedgen | ||||
|                 if scan_invalid_chars_in_rss(content): | ||||
|                     content = clean_entry_content(content) | ||||
|  | ||||
|                 fe.content(content=content, type='CDATA') | ||||
|                 fe.guid(guid, permalink=False) | ||||
|                 dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key)) | ||||
|                 dt = dt.replace(tzinfo=pytz.UTC) | ||||
|                 fe.pubDate(dt) | ||||
|  | ||||
|         response = make_response(fg.rss_str()) | ||||
|         response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8') | ||||
|         logger.trace(f"RSS generated in {time.time() - now:.3f}s") | ||||
|         return response | ||||
|  | ||||
|     return rss_blueprint | ||||
							
								
								
									
										120
									
								
								changedetectionio/blueprint/settings/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								changedetectionio/blueprint/settings/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import os | ||||
| from copy import deepcopy | ||||
| from datetime import datetime | ||||
| from zoneinfo import ZoneInfo, available_timezones | ||||
| import secrets | ||||
| import flask_login | ||||
| from flask import Blueprint, render_template, request, redirect, url_for, flash | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     settings_blueprint = Blueprint('settings', __name__, template_folder="templates") | ||||
|  | ||||
|     @settings_blueprint.route("", methods=['GET', "POST"]) | ||||
|     @login_optionally_required | ||||
|     def settings_page(): | ||||
|         from changedetectionio import forms | ||||
|  | ||||
|         default = deepcopy(datastore.data['settings']) | ||||
|         if datastore.proxy_list is not None: | ||||
|             available_proxies = list(datastore.proxy_list.keys()) | ||||
|             # When enabled | ||||
|             system_proxy = datastore.data['settings']['requests']['proxy'] | ||||
|             # In the case it doesnt exist anymore | ||||
|             if not system_proxy in available_proxies: | ||||
|                 system_proxy = None | ||||
|  | ||||
|             default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0] | ||||
|             # Used by the form handler to keep or remove the proxy settings | ||||
|             default['proxy_list'] = available_proxies[0] | ||||
|  | ||||
|         # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status | ||||
|         form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None, | ||||
|                                         data=default, | ||||
|                                         extra_notification_tokens=datastore.get_unique_notification_tokens_available() | ||||
|                                         ) | ||||
|  | ||||
|         # Remove the last option 'System default' | ||||
|         form.application.form.notification_format.choices.pop() | ||||
|  | ||||
|         if datastore.proxy_list is None: | ||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||
|             del form.requests.form.proxy | ||||
|         else: | ||||
|             form.requests.form.proxy.choices = [] | ||||
|             for p in datastore.proxy_list: | ||||
|                 form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||
|  | ||||
|         if request.method == 'POST': | ||||
|             # Password unset is a GET, but we can lock the session to a salted env password to always need the password | ||||
|             if form.application.form.data.get('removepassword_button', False): | ||||
|                 # SALTED_PASS means the password is "locked" to what we set in the Env var | ||||
|                 if not os.getenv("SALTED_PASS", False): | ||||
|                     datastore.remove_password() | ||||
|                     flash("Password protection removed.", 'notice') | ||||
|                     flask_login.logout_user() | ||||
|                     return redirect(url_for('settings.settings_page')) | ||||
|  | ||||
|             if form.validate(): | ||||
|                 # Don't set password to False when a password is set - should be only removed with the `removepassword` button | ||||
|                 app_update = dict(deepcopy(form.data['application'])) | ||||
|  | ||||
|                 # Never update password with '' or False (Added by wtforms when not in submission) | ||||
|                 if 'password' in app_update and not app_update['password']: | ||||
|                     del (app_update['password']) | ||||
|  | ||||
|                 datastore.data['settings']['application'].update(app_update) | ||||
|                 datastore.data['settings']['requests'].update(form.data['requests']) | ||||
|  | ||||
|                 if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password): | ||||
|                     datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password | ||||
|                     datastore.needs_write_urgent = True | ||||
|                     flash("Password protection enabled.", 'notice') | ||||
|                     flask_login.logout_user() | ||||
|                     return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|                 datastore.needs_write_urgent = True | ||||
|                 flash("Settings updated.") | ||||
|  | ||||
|             else: | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|         # Convert to ISO 8601 format, all date/time relative events stored as UTC time | ||||
|         utc_time = datetime.now(ZoneInfo("UTC")).isoformat() | ||||
|  | ||||
|         output = render_template("settings.html", | ||||
|                                 api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||
|                                 available_timezones=sorted(available_timezones()), | ||||
|                                 emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                                 extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(), | ||||
|                                 form=form, | ||||
|                                 hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                 min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), | ||||
|                                 settings_application=datastore.data['settings']['application'], | ||||
|                                 timezone_default_config=datastore.data['settings']['application'].get('timezone'), | ||||
|                                 utc_time=utc_time, | ||||
|                                 ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @settings_blueprint.route("/reset-api-key", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def settings_reset_api_key(): | ||||
|         secret = secrets.token_hex(16) | ||||
|         datastore.data['settings']['application']['api_access_token'] = secret | ||||
|         datastore.needs_write_urgent = True | ||||
|         flash("API Key was regenerated.") | ||||
|         return redirect(url_for('settings.settings_page')+'#api') | ||||
|          | ||||
|     @settings_blueprint.route("/notification-logs", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def notification_logs(): | ||||
|         from changedetectionio.flask_app import notification_debug_log | ||||
|         output = render_template("notification-log.html", | ||||
|                                logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."]) | ||||
|         return output | ||||
|  | ||||
|     return settings_blueprint | ||||
| @@ -0,0 +1,19 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|      <div class="inner"> | ||||
|  | ||||
|          <h4 style="margin-top: 0px;">Notification debug log</h4> | ||||
|                 <div id="notification-error-log"> | ||||
|                 <ul style="font-size: 80%; margin:0px; padding: 0 0 0 7px"> | ||||
|                 {% for log in logs|reverse %} | ||||
|                     <li>{{log}}</li> | ||||
|                 {% endfor %} | ||||
|                 </ul> | ||||
|                 </div> | ||||
|  | ||||
|      </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
							
								
								
									
										320
									
								
								changedetectionio/blueprint/settings/templates/settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										320
									
								
								changedetectionio/blueprint/settings/templates/settings.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,320 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}"; | ||||
| {% if emailprefix %} | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| </script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script> | ||||
| <div class="edit-form"> | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#fetching">Fetching</a></li> | ||||
|             <li class="tab"><a href="#filters">Global Filters</a></li> | ||||
|             <li class="tab"><a href="#ui-options">UI Options</a></li> | ||||
|             <li class="tab"><a href="#api">API</a></li> | ||||
|             <li class="tab"><a href="#timedate">Time & Date</a></li> | ||||
|             <li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked settings" action="{{url_for('settings.settings_page')}}" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} | ||||
|                         <span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span> | ||||
|                             <div id="time-between-check-schedule"> | ||||
|                                 <!-- Start Time and End Time --> | ||||
|                                 <div id="limit-between-time"> | ||||
|                                     {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }} | ||||
|                                 </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} | ||||
|                         <span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} | ||||
|                         <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification | ||||
|                             <br> | ||||
|                         Set to <strong>0</strong> to disable | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% if not hide_remove_pass %} | ||||
|                             {% if current_user.is_authenticated %} | ||||
|                                 {{ render_button(form.application.form.removepassword_button) }} | ||||
|                             {% else %} | ||||
|                             {{ render_field(form.application.form.password) }} | ||||
|                             <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> | ||||
|                             {% endif %} | ||||
|                         {% else %} | ||||
|                             <span class="pure-form-message-inline">Password is locked.</span> | ||||
|                         {% endif %} | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.shared_diff_access, class="shared_diff_access") }} | ||||
|                         <span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page) | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }} | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.pager_size) }} | ||||
|                         <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.rss_content_format) }} | ||||
|                         <span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.extract_title_as_title) }} | ||||
|                         <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} | ||||
|                         <span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span> | ||||
|                     </div> | ||||
|                 {% if form.requests.proxy %} | ||||
|                     <div class="pure-control-group inline-radio"> | ||||
|                         {{ render_field(form.requests.form.proxy, class="fetch-backend-proxy") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                         Choose a default proxy for all watches | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 {% endif %} | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
|                     <div class="field-group"> | ||||
|                         {{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <div class="pure-control-group" id="notification-base-url"> | ||||
|                     {{ render_field(form.application.form.base_url, class="m-d") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notification links.<br> | ||||
|                         Default value is the system environment variable '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. | ||||
|                     </span> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="fetching"> | ||||
|                 <div class="pure-control-group inline-radio"> | ||||
|                     {{ render_field(form.application.form.fetch_backend, class="fetch-backend") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> | ||||
|                         <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
|                         <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> | ||||
|                         <br> | ||||
|                         This will wait <i>n</i> seconds before extracting the text. | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.webdriver_delay) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <div class="pure-control-group inline-radio"> | ||||
|                     {{ render_field(form.requests.form.default_ua) }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         Applied to all requests.<br><br> | ||||
|                         Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>. | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                                         <br> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|  | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="filters"> | ||||
|  | ||||
|                     <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.ignore_whitespace) }} | ||||
|                     <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> | ||||
|                     <i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} | ||||
|                     <span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> | ||||
|                         <br> | ||||
|                     <i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
|                     <fieldset class="pure-group"> | ||||
|                       {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker | ||||
| //*[contains(text(), 'Advertisement')]") }} | ||||
|                       <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li> | ||||
|                           <li> Don't paste HTML here, use only CSS and XPath selectors </li> | ||||
|                           <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                         </ul> | ||||
|                       </span> | ||||
|                     </fieldset> | ||||
|                     <fieldset class="pure-group"> | ||||
|                     {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
|                     ") }} | ||||
|                     <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                             <li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li> | ||||
|                             <li>Note: This is applied globally in addition to the per-watch rules.</li> | ||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||
|                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||
|                         </ul> | ||||
|                      </span> | ||||
|                     </fieldset> | ||||
|            </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="api"> | ||||
|                 <h4>API Access</h4> | ||||
|                 <p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br> | ||||
|                     <div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> | ||||
|                         <span style="display:none;" id="api-key-copy" >copy</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <h4>Chrome Extension</h4> | ||||
|                     <p>Easily add any web-page to your changedetection.io installation from within Chrome.</p> | ||||
|                     <strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page, | ||||
|                     <strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>" | ||||
|                     <p> | ||||
|                         <a id="chrome-extension-link" | ||||
|                            title="Try our new Chrome Extension!" | ||||
|                            href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> | ||||
|                             <img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" alt="Chrome"> | ||||
|                             Chrome Webstore | ||||
|                         </a> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="timedate"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches. | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p> | ||||
|                     <p><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p> | ||||
|                     <p> | ||||
|                        {{ render_field(form.application.form.timezone) }} | ||||
|                         <datalist id="timezones" style="display: none;"> | ||||
|                             {% for tz_name in available_timezones %} | ||||
|                                 <option value="{{ tz_name }}">{{ tz_name }}</option> | ||||
|                             {% endfor %} | ||||
|                         </datalist> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="ui-options"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }} | ||||
|                     <span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="proxies"> | ||||
|                 <div id="recommended-proxy"> | ||||
|                     <div> | ||||
|                         <img style="height: 2em;" src="{{url_for('static_content', group='images', filename='brightdata.svg')}}" alt="BrightData Proxy Provider"> | ||||
|                         <p>BrightData offer world-class proxy services, "Data Center" proxies are a very affordable way to proxy your requests, whilst <strong><a href="https://brightdata.grsm.io/n0r16zf7eivq">WebUnlocker</a></strong> can help solve most CAPTCHAs.</p> | ||||
|                         <p> | ||||
|                             BrightData offer many <a href="https://brightdata.com/proxy-types" target="new">many different types of proxies</a>, it is worth reading about what is best for your use-case. | ||||
|                         </p> | ||||
|  | ||||
|                         <p> | ||||
|                             When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the access Proxy URL into the "Extra Proxies" boxes below.<br> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             The Proxy URL with BrightData should start with <code>http://brd-customer...</code> | ||||
|                         </p> | ||||
|                         <p>When you sign up using <a href="https://brightdata.grsm.io/n0r16zf7eivq">https://brightdata.grsm.io/n0r16zf7eivq</a> BrightData will match any first deposit up to $150</p> | ||||
|                     </div> | ||||
|                     <div> | ||||
|                         <img style="height: 2em;" | ||||
|                              src="{{url_for('static_content', group='images', filename='oxylabs.svg')}}" | ||||
|                              alt="Oxylabs Proxy Provider"> | ||||
|                         <p> | ||||
|                             Collect public data at scale with industry-leading web scraping solutions and the world’s | ||||
|                             largest ethical proxy network. | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Oxylabs also provide a <a href="https://oxylabs.io/products/web-unblocker"><strong>WebUnlocker</strong></a> | ||||
|                             proxy that bypasses sophisticated anti-bot systems, so you don’t have to.<br> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Serve over <a href="https://oxylabs.io/location-proxy">195 countries</a>, providing <a | ||||
|                                 href="https://oxylabs.io/products/residential-proxy-pool">Residential</a>, <a | ||||
|                                 href="https://oxylabs.io/products/mobile-proxies">Mobile</a> and <a | ||||
|                                 href="https://oxylabs.io/products/rotating-isp-proxies">ISP proxies</a> and much more. | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Use the promo code <strong>boost35</strong> with this link <a href="https://oxylabs.go2cloud.org/SH2d">https://oxylabs.go2cloud.org/SH2d</a> for 35% off Residential, Mobile proxies, Web Unblocker, and Scraper APIs. Built-in proxies enable you to access data from all around the world and help overcome anti-bot solutions. | ||||
|  | ||||
|                         </p> | ||||
|  | ||||
|                          | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                <p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites. | ||||
|  | ||||
|                 <div class="pure-control-group" id="extra-proxies-setting"> | ||||
|                 {{ render_field(form.requests.form.extra_proxies) }} | ||||
|                 <span class="pure-form-message-inline">"Name" will be used for selecting the proxy in the Watch Edit settings</span><br> | ||||
|                 <span class="pure-form-message-inline">SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should whitelist the IP access instead</span> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group" id="extra-browsers-setting"> | ||||
|                     <p> | ||||
|                     <span class="pure-form-message-inline"><i>Extra Browsers</i> can be attached to further defeat CAPTCHA's on websites that are particularly hard to scrape.</span><br> | ||||
|                     <span class="pure-form-message-inline">Simply paste the connection address into the box, <a href="https://changedetection.io/tutorial/using-bright-datas-scraping-browser-pass-captchas-and-other-protection-when-monitoring">More instructions and examples here</a> </span> | ||||
|                     </p> | ||||
|                     {{ render_field(form.requests.form.extra_browsers) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('watchlist.index')}}" class="pure-button button-small button-cancel">Back</a> | ||||
|                     <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
							
								
								
									
										9
									
								
								changedetectionio/blueprint/tags/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								changedetectionio/blueprint/tags/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| # Groups tags | ||||
|  | ||||
| ## How it works | ||||
|  | ||||
| Watch has a list() of tag UUID's, which relate to a config under application.settings.tags | ||||
|  | ||||
| The 'tag' is actually a watch, because they basically will eventually share 90% of the same config. | ||||
|  | ||||
| So a tag is like an abstract of a watch | ||||
							
								
								
									
										191
									
								
								changedetectionio/blueprint/tags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								changedetectionio/blueprint/tags/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| from flask import Blueprint, request, render_template, flash, url_for, redirect | ||||
|  | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.flask_app import login_optionally_required | ||||
|  | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     tags_blueprint = Blueprint('tags', __name__, template_folder="templates") | ||||
|  | ||||
|     @tags_blueprint.route("/list", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def tags_overview_page(): | ||||
|         from .form import SingleTag | ||||
|         add_form = SingleTag(request.form) | ||||
|  | ||||
|         sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) | ||||
|  | ||||
|         from collections import Counter | ||||
|  | ||||
|         tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags']) | ||||
|  | ||||
|         output = render_template("groups-overview.html", | ||||
|                                  available_tags=sorted_tags, | ||||
|                                  form=add_form, | ||||
|                                  tag_count=tag_count | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @tags_blueprint.route("/add", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_tag_add(): | ||||
|         from .form import SingleTag | ||||
|         add_form = SingleTag(request.form) | ||||
|  | ||||
|         if not add_form.validate(): | ||||
|             for widget, l in add_form.errors.items(): | ||||
|                 flash(','.join(l), 'error') | ||||
|             return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|         title = request.form.get('name').strip() | ||||
|  | ||||
|         if datastore.tag_exists_by_name(title): | ||||
|             flash(f'The tag "{title}" already exists', "error") | ||||
|             return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|         datastore.add_tag(title) | ||||
|         flash("Tag added") | ||||
|  | ||||
|  | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/mute/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def mute(uuid): | ||||
|         if datastore.data['settings']['application']['tags'].get(uuid): | ||||
|             datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = not datastore.data['settings']['application']['tags'][uuid]['notification_muted'] | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/delete/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def delete(uuid): | ||||
|         removed = 0 | ||||
|         # Delete the tag, and any tag reference | ||||
|         if datastore.data['settings']['application']['tags'].get(uuid): | ||||
|             del datastore.data['settings']['application']['tags'][uuid] | ||||
|  | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if watch.get('tags') and uuid in watch['tags']: | ||||
|                 removed += 1 | ||||
|                 watch['tags'].remove(uuid) | ||||
|  | ||||
|         flash(f"Tag deleted and removed from {removed} watches") | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/unlink/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def unlink(uuid): | ||||
|         unlinked = 0 | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if watch.get('tags') and uuid in watch['tags']: | ||||
|                 unlinked += 1 | ||||
|                 watch['tags'].remove(uuid) | ||||
|  | ||||
|         flash(f"Tag unlinked removed from {unlinked} watches") | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/delete_all", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def delete_all(): | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             watch['tags'] = [] | ||||
|         datastore.data['settings']['application']['tags'] = {} | ||||
|  | ||||
|         flash(f"All tags deleted") | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/edit/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_tag_edit(uuid): | ||||
|         from changedetectionio.blueprint.tags.form import group_restock_settings_form | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['settings']['application']['tags'].keys()).pop() | ||||
|  | ||||
|         default = datastore.data['settings']['application']['tags'].get(uuid) | ||||
|         if not default: | ||||
|             flash("Tag not found", "error") | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         form = group_restock_settings_form( | ||||
|                                        formdata=request.form if request.method == 'POST' else None, | ||||
|                                        data=default, | ||||
|                                        extra_notification_tokens=datastore.get_unique_notification_tokens_available(), | ||||
|                                        default_system_settings = datastore.data['settings'], | ||||
|                                        ) | ||||
|  | ||||
|         template_args = { | ||||
|             'data': default, | ||||
|             'form': form, | ||||
|             'watch': default, | ||||
|             'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), | ||||
|         } | ||||
|  | ||||
|         included_content = {} | ||||
|         if form.extra_form_content(): | ||||
|             # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/ | ||||
|             # And then render the code from the module | ||||
|             from jinja2 import Environment, FileSystemLoader | ||||
|             import importlib.resources | ||||
|             templates_dir = str(importlib.resources.files("changedetectionio").joinpath('templates')) | ||||
|             env = Environment(loader=FileSystemLoader(templates_dir)) | ||||
|             template_str = """{% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
|         <script>         | ||||
|             $(document).ready(function () { | ||||
|                 toggleOpacity('#overrides_watch', '#restock-fieldset-price-group', true); | ||||
|             }); | ||||
|         </script>             | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         <fieldset class="pure-group"> | ||||
|                         {{ render_checkbox_field(form.overrides_watch) }} | ||||
|                         <span class="pure-form-message-inline">Used for watches in "Restock & Price detection" mode</span> | ||||
|                         </fieldset> | ||||
|                 </fieldset> | ||||
|                 """ | ||||
|             template_str += form.extra_form_content() | ||||
|             template = env.from_string(template_str) | ||||
|             included_content = template.render(**template_args) | ||||
|  | ||||
|         output = render_template("edit-tag.html", | ||||
|                                  settings_application=datastore.data['settings']['application'], | ||||
|                                  extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None, | ||||
|                                  extra_form_content=included_content, | ||||
|                                  **template_args | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|  | ||||
|     @tags_blueprint.route("/edit/<string:uuid>", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_tag_edit_submit(uuid): | ||||
|         from changedetectionio.blueprint.tags.form import group_restock_settings_form | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['settings']['application']['tags'].keys()).pop() | ||||
|  | ||||
|         default = datastore.data['settings']['application']['tags'].get(uuid) | ||||
|  | ||||
|         form = group_restock_settings_form(formdata=request.form if request.method == 'POST' else None, | ||||
|                                data=default, | ||||
|                                extra_notification_tokens=datastore.get_unique_notification_tokens_available() | ||||
|                                ) | ||||
|         # @todo subclass form so validation works | ||||
|         #if not form.validate(): | ||||
| #            for widget, l in form.errors.items(): | ||||
| #                flash(','.join(l), 'error') | ||||
| #           return redirect(url_for('tags.form_tag_edit_submit', uuid=uuid)) | ||||
|  | ||||
|         datastore.data['settings']['application']['tags'][uuid].update(form.data) | ||||
|         datastore.data['settings']['application']['tags'][uuid]['processor'] = 'restock_diff' | ||||
|         datastore.needs_write_urgent = True | ||||
|         flash("Updated") | ||||
|  | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|  | ||||
|     @tags_blueprint.route("/delete/<string:uuid>", methods=['GET']) | ||||
|     def form_tag_delete(uuid): | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|     return tags_blueprint | ||||
							
								
								
									
										21
									
								
								changedetectionio/blueprint/tags/form.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								changedetectionio/blueprint/tags/form.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| from wtforms import ( | ||||
|     Form, | ||||
|     StringField, | ||||
|     SubmitField, | ||||
|     validators, | ||||
| ) | ||||
| from wtforms.fields.simple import BooleanField | ||||
|  | ||||
| from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form | ||||
|  | ||||
| class group_restock_settings_form(restock_settings_form): | ||||
|     overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False) | ||||
|  | ||||
| class SingleTag(Form): | ||||
|  | ||||
|     name = StringField('Tag name', [validators.InputRequired()], render_kw={"placeholder": "Name"}) | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										100
									
								
								changedetectionio/blueprint/tags/templates/edit-tag.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								changedetectionio/blueprint/tags/templates/edit-tag.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}"; | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script> | ||||
|  | ||||
| /*{% if emailprefix %}*/ | ||||
|     /*const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');*/ | ||||
| /*{% endif %}*/ | ||||
|  | ||||
| {% set has_tag_filters_extra='' %} | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
|  | ||||
| <div class="edit-form monospaced-textarea"> | ||||
|  | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li> | ||||
|             {% if extra_tab_content %} | ||||
|             <li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked" | ||||
|               action="{{ url_for('tags.form_tag_edit', uuid=data.uuid) }}" method="POST"> | ||||
|              <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.title, placeholder="https://...", required=true, class="m-d") }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="filters-and-triggers"> | ||||
|                 <p>These settings are <strong><i>added</i></strong> to any existing watch configurations.</p> | ||||
|                 {% include "edit/include_subtract.html" %} | ||||
|                 <div class="text-filtering border-fieldset"> | ||||
|                     <h3>Text filtering</h3> | ||||
|                     {% include "edit/text-options.html" %} | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|         {# rendered sub Template #} | ||||
|         {% if extra_form_content %} | ||||
|             <div class="tab-pane-inner" id="extras_tab"> | ||||
|             {{ extra_form_content|safe }} | ||||
|             </div> | ||||
|         {% endif %} | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_muted) }} | ||||
|                     </div> | ||||
|                     {% if 1 %} | ||||
|                     <div class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_screenshot) }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                             <strong>Use with caution!</strong> This will easily fill up your email storage quota or flood other storages. | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     {% endif %} | ||||
|                     <div class="field-group" id="notification-field-group"> | ||||
|                         {% if has_default_notification_urls %} | ||||
|                         <div class="inline-warning"> | ||||
|                             <img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="Look out!" title="Lookout!" > | ||||
|                             There are <a href="{{ url_for('settings.settings_page')}}#notifications">system-wide notification URLs enabled</a>, this form will override notification settings for this watch only ‐ an empty Notification URL list here will still send notifications. | ||||
|                         </div> | ||||
|                         {% endif %} | ||||
|                         <a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a> | ||||
|  | ||||
|                         {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -0,0 +1,62 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.html' import render_simple_field, render_field %} | ||||
| <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> | ||||
|  | ||||
| <div class="box"> | ||||
|     <form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form"> | ||||
|         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|         <fieldset> | ||||
|             <legend>Add a new organisational tag</legend> | ||||
|             <div id="watch-add-wrapper-zone"> | ||||
|                 <div> | ||||
|                     {{ render_simple_field(form.name, placeholder="watch label / tag") }} | ||||
|                 </div> | ||||
|                 <div> | ||||
|                     {{ render_simple_field(form.save_button, title="Save" ) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <br> | ||||
|             <div style="color: #fff;">Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.</div> | ||||
|         </fieldset> | ||||
|     </form> | ||||
|     <!-- @todo maybe some overview matrix, 'tick' with which has notification, filter rules etc --> | ||||
|     <div id="watch-table-wrapper"> | ||||
|  | ||||
|         <table class="pure-table pure-table-striped watch-table group-overview-table"> | ||||
|             <thead> | ||||
|             <tr> | ||||
|                 <th></th> | ||||
|                 <th># Watches</th> | ||||
|                 <th>Tag / Label name</th> | ||||
|                 <th></th> | ||||
|             </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|             <!-- | ||||
|             @Todo - connect Last checked, Last Changed, Number of Watches etc | ||||
|             ---> | ||||
|             {% if not available_tags|length %} | ||||
|             <tr> | ||||
|                 <td colspan="3">No website organisational tags/groups configured</td> | ||||
|             </tr> | ||||
|             {% endif %} | ||||
|             {% for uuid, tag in available_tags  %} | ||||
|             <tr id="{{ uuid }}" class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }}"> | ||||
|                 <td class="watch-controls"> | ||||
|                     <a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a> | ||||
|                 </td> | ||||
|                 <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td> | ||||
|                 <td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td> | ||||
|                 <td> | ||||
|                     <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>  | ||||
|                     <a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a> | ||||
|                     <a class="pure-button pure-button-primary" href="{{ url_for('tags.unlink', uuid=uuid) }}" title="Keep the tag but unlink any watches">Unlink</a> | ||||
|                 </td> | ||||
|             </tr> | ||||
|             {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										305
									
								
								changedetectionio/blueprint/ui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								changedetectionio/blueprint/ui/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | ||||
| import time | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template, session | ||||
| from loguru import logger | ||||
| from functools import wraps | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint | ||||
| from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint | ||||
| from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData): | ||||
|     ui_blueprint = Blueprint('ui', __name__, template_folder="templates") | ||||
|      | ||||
|     # Register the edit blueprint | ||||
|     edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData) | ||||
|     ui_blueprint.register_blueprint(edit_blueprint) | ||||
|      | ||||
|     # Register the notification blueprint | ||||
|     notification_blueprint = construct_notification_blueprint(datastore) | ||||
|     ui_blueprint.register_blueprint(notification_blueprint) | ||||
|      | ||||
|     # Register the views blueprint | ||||
|     views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData) | ||||
|     ui_blueprint.register_blueprint(views_blueprint) | ||||
|      | ||||
|     # Import the login decorator | ||||
|     from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
|     @ui_blueprint.route("/clear_history/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def clear_watch_history(uuid): | ||||
|         try: | ||||
|             datastore.clear_watch_history(uuid) | ||||
|         except KeyError: | ||||
|             flash('Watch not found', 'error') | ||||
|         else: | ||||
|             flash("Cleared snapshot history for watch {}".format(uuid)) | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|     @ui_blueprint.route("/clear_history", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     def clear_all_history(): | ||||
|         if request.method == 'POST': | ||||
|             confirmtext = request.form.get('confirmtext') | ||||
|  | ||||
|             if confirmtext == 'clear': | ||||
|                 for uuid in datastore.data['watching'].keys(): | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|  | ||||
|                 flash("Cleared snapshot history for all watches") | ||||
|             else: | ||||
|                 flash('Incorrect confirmation text.', 'error') | ||||
|  | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         output = render_template("clear_all_history.html") | ||||
|         return output | ||||
|  | ||||
|     # Clear all statuses, so we do not see the 'unviewed' class | ||||
|     @ui_blueprint.route("/form/mark-all-viewed", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def mark_all_viewed(): | ||||
|         # Save the current newest history as the most recently viewed | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if with_errors and not watch.get('last_error'): | ||||
|                 continue | ||||
|             datastore.set_last_viewed(watch_uuid, int(time.time())) | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|     @ui_blueprint.route("/delete", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_delete(): | ||||
|         uuid = request.args.get('uuid') | ||||
|  | ||||
|         if uuid != 'all' and not uuid in datastore.data['watching'].keys(): | ||||
|             flash('The watch by UUID {} does not exist.'.format(uuid), 'error') | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|         datastore.delete(uuid) | ||||
|         flash('Deleted.') | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|     @ui_blueprint.route("/clone", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_clone(): | ||||
|         uuid = request.args.get('uuid') | ||||
|         # More for testing, possible to return the first/only | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         new_uuid = datastore.clone(uuid) | ||||
|  | ||||
|         if not datastore.data['watching'].get(uuid).get('paused'): | ||||
|             update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid})) | ||||
|  | ||||
|         flash('Cloned, you are editing the new watch.') | ||||
|  | ||||
|         return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid)) | ||||
|  | ||||
|     @ui_blueprint.route("/checknow", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_watch_checknow(): | ||||
|         # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True}))) | ||||
|         tag = request.args.get('tag') | ||||
|         uuid = request.args.get('uuid') | ||||
|         with_errors = request.args.get('with_errors') == "1" | ||||
|  | ||||
|         i = 0 | ||||
|  | ||||
|         running_uuids = [] | ||||
|         for t in running_update_threads: | ||||
|             running_uuids.append(t.current_uuid) | ||||
|  | ||||
|         if uuid: | ||||
|             if uuid not in running_uuids: | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|                 i += 1 | ||||
|  | ||||
|         else: | ||||
|             # Recheck all, including muted | ||||
|             # Get most overdue first | ||||
|             for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)): | ||||
|                 watch_uuid = k[0] | ||||
|                 watch = k[1] | ||||
|                 if not watch['paused']: | ||||
|                     if watch_uuid not in running_uuids: | ||||
|                         if with_errors and not watch.get('last_error'): | ||||
|                             continue | ||||
|  | ||||
|                         if tag != None and tag not in watch['tags']: | ||||
|                             continue | ||||
|  | ||||
|                         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})) | ||||
|                         i += 1 | ||||
|  | ||||
|         if i == 1: | ||||
|             flash("Queued 1 watch for rechecking.") | ||||
|         if i > 1: | ||||
|             flash(f"Queued {i} watches for rechecking.") | ||||
|         if i == 0: | ||||
|             flash("No watches available to recheck.") | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|     @ui_blueprint.route("/form/checkbox-operations", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_watch_list_checkbox_operations(): | ||||
|         op = request.form['op'] | ||||
|         uuids = request.form.getlist('uuids') | ||||
|  | ||||
|         if (op == 'delete'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.delete(uuid.strip()) | ||||
|             flash("{} watches deleted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'pause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = True | ||||
|             flash("{} watches paused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unpause'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|             flash("{} watches unpaused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mark-viewed'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.set_last_viewed(uuid, int(time.time())) | ||||
|             flash("{} watches updated".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mute'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = True | ||||
|             flash("{} watches muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unmute'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = False | ||||
|             flash("{} watches un-muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'recheck'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     # Recheck and require a full reprocessing | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|             flash("{} watches queued for rechecking".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'clear-errors'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid]["last_error"] = False | ||||
|             flash(f"{len(uuids)} watches errors cleared") | ||||
|  | ||||
|         elif (op == 'clear-history'): | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|             flash("{} watches cleared/reset.".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'notification-default'): | ||||
|             from changedetectionio.notification import ( | ||||
|                 default_notification_format_for_watch | ||||
|             ) | ||||
|             for uuid in uuids: | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_title'] = None | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_body'] = None | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_urls'] = [] | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch | ||||
|             flash("{} watches set to use default notification settings".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'assign-tag'): | ||||
|             op_extradata = request.form.get('op_extradata', '').strip() | ||||
|             if op_extradata: | ||||
|                 tag_uuid = datastore.add_tag(title=op_extradata) | ||||
|                 if op_extradata and tag_uuid: | ||||
|                     for uuid in uuids: | ||||
|                         uuid = uuid.strip() | ||||
|                         if datastore.data['watching'].get(uuid): | ||||
|                             # Bug in old versions caused by bad edit page/tag handler | ||||
|                             if isinstance(datastore.data['watching'][uuid]['tags'], str): | ||||
|                                 datastore.data['watching'][uuid]['tags'] = [] | ||||
|  | ||||
|                             datastore.data['watching'][uuid]['tags'].append(tag_uuid) | ||||
|  | ||||
|             flash(f"{len(uuids)} watches were tagged") | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|  | ||||
|     @ui_blueprint.route("/share-url/<string:uuid>", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def form_share_put_watch(uuid): | ||||
|         """Given a watch UUID, upload the info and return a share-link | ||||
|            the share-link can be imported/added""" | ||||
|         import requests | ||||
|         import json | ||||
|         from copy import deepcopy | ||||
|  | ||||
|         # more for testing | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         # copy it to memory as trim off what we dont need (history) | ||||
|         watch = deepcopy(datastore.data['watching'].get(uuid)) | ||||
|         # For older versions that are not a @property | ||||
|         if (watch.get('history')): | ||||
|             del (watch['history']) | ||||
|  | ||||
|         # for safety/privacy | ||||
|         for k in list(watch.keys()): | ||||
|             if k.startswith('notification_'): | ||||
|                 del watch[k] | ||||
|  | ||||
|         for r in['uuid', 'last_checked', 'last_changed']: | ||||
|             if watch.get(r): | ||||
|                 del (watch[r]) | ||||
|  | ||||
|         # Add the global stuff which may have an impact | ||||
|         watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text'] | ||||
|         watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors'] | ||||
|  | ||||
|         watch_json = json.dumps(watch) | ||||
|  | ||||
|         try: | ||||
|             r = requests.request(method="POST", | ||||
|                                  data={'watch': watch_json}, | ||||
|                                  url="https://changedetection.io/share/share", | ||||
|                                  headers={'App-Guid': datastore.data['app_guid']}) | ||||
|             res = r.json() | ||||
|  | ||||
|             # Add to the flask session | ||||
|             session['share-link'] = f"https://changedetection.io/share/{res['share_key']}" | ||||
|  | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error sharing -{str(e)}") | ||||
|             flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error') | ||||
|  | ||||
|         return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|     return ui_blueprint | ||||
							
								
								
									
										334
									
								
								changedetectionio/blueprint/ui/edit.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										334
									
								
								changedetectionio/blueprint/ui/edit.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,334 @@ | ||||
| import time | ||||
| from copy import deepcopy | ||||
| import os | ||||
| import importlib.resources | ||||
| from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort | ||||
| from loguru import logger | ||||
| from jinja2 import Environment, FileSystemLoader | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
| from changedetectionio.time_handler import is_within_schedule | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): | ||||
|     edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates") | ||||
|      | ||||
|     def _watch_has_tag_options_set(watch): | ||||
|         """This should be fixed better so that Tag is some proper Model, a tag is just a Watch also""" | ||||
|         for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): | ||||
|             if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')): | ||||
|                 return True | ||||
|  | ||||
|     @edit_blueprint.route("/edit/<string:uuid>", methods=['GET', 'POST']) | ||||
|     @login_optionally_required | ||||
|     # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists | ||||
|     # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ? | ||||
|     def edit_page(uuid): | ||||
|         from changedetectionio import forms | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|         from changedetectionio import processors | ||||
|         import importlib | ||||
|  | ||||
|         # More for testing, possible to return the first/only | ||||
|         if not datastore.data['watching'].keys(): | ||||
|             flash("No watches to edit", "error") | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['watching'].keys()).pop() | ||||
|  | ||||
|         if not uuid in datastore.data['watching']: | ||||
|             flash("No watch with the UUID %s found." % (uuid), "error") | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         switch_processor = request.args.get('switch_processor') | ||||
|         if switch_processor: | ||||
|             for p in processors.available_processors(): | ||||
|                 if p[0] == switch_processor: | ||||
|                     datastore.data['watching'][uuid]['processor'] = switch_processor | ||||
|                     flash(f"Switched to mode - {p[1]}.") | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|                     redirect(url_for('ui_edit.edit_page', uuid=uuid)) | ||||
|  | ||||
|         # be sure we update with a copy instead of accidently editing the live object by reference | ||||
|         default = deepcopy(datastore.data['watching'][uuid]) | ||||
|  | ||||
|         # Defaults for proxy choice | ||||
|         if datastore.proxy_list is not None:  # When enabled | ||||
|             # @todo | ||||
|             # Radio needs '' not None, or incase that the chosen one no longer exists | ||||
|             if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list): | ||||
|                 default['proxy'] = '' | ||||
|         # proxy_override set to the json/text list of the items | ||||
|  | ||||
|         # Does it use some custom form? does one exist? | ||||
|         processor_name = datastore.data['watching'][uuid].get('processor', '') | ||||
|         processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None) | ||||
|         if not processor_classes: | ||||
|             flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error') | ||||
|             return redirect(url_for('watchlist.index')) | ||||
|  | ||||
|         parent_module = processors.get_parent_module(processor_classes[0]) | ||||
|  | ||||
|         try: | ||||
|             # Get the parent of the "processor.py" go up one, get the form (kinda spaghetti but its reusing existing code) | ||||
|             forms_module = importlib.import_module(f"{parent_module.__name__}.forms") | ||||
|             # Access the 'processor_settings_form' class from the 'forms' module | ||||
|             form_class = getattr(forms_module, 'processor_settings_form') | ||||
|         except ModuleNotFoundError as e: | ||||
|             # .forms didnt exist | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|         except AttributeError as e: | ||||
|             # .forms exists but no useful form | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|  | ||||
|         form = form_class(formdata=request.form if request.method == 'POST' else None, | ||||
|                           data=default, | ||||
|                           extra_notification_tokens=default.extra_notification_token_values(), | ||||
|                           default_system_settings=datastore.data['settings'] | ||||
|                           ) | ||||
|  | ||||
|         # For the form widget tag UUID back to "string name" for the field | ||||
|         form.tags.datastore = datastore | ||||
|  | ||||
|         # Used by some forms that need to dig deeper | ||||
|         form.datastore = datastore | ||||
|         form.watch = default | ||||
|  | ||||
|         for p in datastore.extra_browsers: | ||||
|             form.fetch_backend.choices.append(p) | ||||
|  | ||||
|         form.fetch_backend.choices.append(("system", 'System settings default')) | ||||
|  | ||||
|         # form.browser_steps[0] can be assumed that we 'goto url' first | ||||
|  | ||||
|         if datastore.proxy_list is None: | ||||
|             # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead | ||||
|             del form.proxy | ||||
|         else: | ||||
|             form.proxy.choices = [('', 'Default')] | ||||
|             for p in datastore.proxy_list: | ||||
|                 form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||
|  | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
|             # If they changed processor, it makes sense to reset it. | ||||
|             if datastore.data['watching'][uuid].get('processor') != form.data.get('processor'): | ||||
|                 datastore.data['watching'][uuid].clear_watch() | ||||
|                 flash("Reset watch history due to change of processor") | ||||
|  | ||||
|             extra_update_obj = { | ||||
|                 'consecutive_filter_failures': 0, | ||||
|                 'last_error' : False | ||||
|             } | ||||
|  | ||||
|             if request.args.get('unpause_on_save'): | ||||
|                 extra_update_obj['paused'] = False | ||||
|  | ||||
|             extra_update_obj['time_between_check'] = form.time_between_check.data | ||||
|  | ||||
|              # Ignore text | ||||
|             form_ignore_text = form.ignore_text.data | ||||
|             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text | ||||
|  | ||||
|             # Be sure proxy value is None | ||||
|             if datastore.proxy_list is not None and form.data['proxy'] == '': | ||||
|                 extra_update_obj['proxy'] = None | ||||
|  | ||||
|             # Unsetting all filter_text methods should make it go back to default | ||||
|             # This particularly affects tests running | ||||
|             if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \ | ||||
|                     and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \ | ||||
|                     and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'): | ||||
|                 extra_update_obj['filter_text_added'] = True | ||||
|                 extra_update_obj['filter_text_replaced'] = True | ||||
|                 extra_update_obj['filter_text_removed'] = True | ||||
|  | ||||
|             # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs | ||||
|             tag_uuids = [] | ||||
|             if form.data.get('tags'): | ||||
|                 # Sometimes in testing this can be list, dont know why | ||||
|                 if type(form.data.get('tags')) == list: | ||||
|                     extra_update_obj['tags'] = form.data.get('tags') | ||||
|                 else: | ||||
|                     for t in form.data.get('tags').split(','): | ||||
|                         tag_uuids.append(datastore.add_tag(title=t)) | ||||
|                     extra_update_obj['tags'] = tag_uuids | ||||
|  | ||||
|             datastore.data['watching'][uuid].update(form.data) | ||||
|             datastore.data['watching'][uuid].update(extra_update_obj) | ||||
|  | ||||
|             if not datastore.data['watching'][uuid].get('tags'): | ||||
|                 # Force it to be a list, because form.data['tags'] will be string if nothing found | ||||
|                 # And del(form.data['tags'] ) wont work either for some reason | ||||
|                 datastore.data['watching'][uuid]['tags'] = [] | ||||
|  | ||||
|             # Recast it if need be to right data Watch handler | ||||
|             watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor')) | ||||
|             datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid]) | ||||
|             flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.") | ||||
|  | ||||
|             # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds | ||||
|             # But in the case something is added we should save straight away | ||||
|             datastore.needs_write_urgent = True | ||||
|  | ||||
|             # Do not queue on edit if its not within the time range | ||||
|  | ||||
|             # @todo maybe it should never queue anyway on edit... | ||||
|             is_in_schedule = True | ||||
|             watch = datastore.data['watching'].get(uuid) | ||||
|  | ||||
|             if watch.get('time_between_check_use_default'): | ||||
|                 time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {}) | ||||
|             else: | ||||
|                 time_schedule_limit = watch.get('time_schedule_limit') | ||||
|  | ||||
|             tz_name = time_schedule_limit.get('timezone') | ||||
|             if not tz_name: | ||||
|                 tz_name = datastore.data['settings']['application'].get('timezone', 'UTC') | ||||
|  | ||||
|             if time_schedule_limit and time_schedule_limit.get('enabled'): | ||||
|                 try: | ||||
|                     is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit, | ||||
|                                                       default_tz=tz_name | ||||
|                                                       ) | ||||
|                 except Exception as e: | ||||
|                     logger.error( | ||||
|                         f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") | ||||
|                     return False | ||||
|  | ||||
|             ############################# | ||||
|             if not datastore.data['watching'][uuid].get('paused') and is_in_schedule: | ||||
|                 # Queue the watch for immediate recheck, with a higher priority | ||||
|                 update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|             # Diff page [edit] link should go back to diff page | ||||
|             if request.args.get("next") and request.args.get("next") == 'diff': | ||||
|                 return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid)) | ||||
|  | ||||
|             return redirect(url_for('watchlist.index', tag=request.args.get("tag",''))) | ||||
|  | ||||
|         else: | ||||
|             if request.method == 'POST' and not form.validate(): | ||||
|                 flash("An error occurred, please see below.", "error") | ||||
|  | ||||
|             # JQ is difficult to install on windows and must be manually added (outside requirements.txt) | ||||
|             jq_support = True | ||||
|             try: | ||||
|                 import jq | ||||
|             except ModuleNotFoundError: | ||||
|                 jq_support = False | ||||
|  | ||||
|             watch = datastore.data['watching'].get(uuid) | ||||
|  | ||||
|             # if system or watch is configured to need a chrome type browser | ||||
|             system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' | ||||
|             watch_needs_selenium_or_playwright = False | ||||
|             if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): | ||||
|                 watch_needs_selenium_or_playwright = True | ||||
|  | ||||
|  | ||||
|             from zoneinfo import available_timezones | ||||
|  | ||||
|             # Only works reliably with Playwright | ||||
|  | ||||
|             template_args = { | ||||
|                 'available_processors': processors.available_processors(), | ||||
|                 'available_timezones': sorted(available_timezones()), | ||||
|                 'browser_steps_config': browser_step_ui_config, | ||||
|                 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|                 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), | ||||
|                 'extra_processor_config': form.extra_tab_content(), | ||||
|                 'extra_title': f" - Edit - {watch.label}", | ||||
|                 'form': form, | ||||
|                 'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False, | ||||
|                 'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, | ||||
|                 'has_special_tag_options': _watch_has_tag_options_set(watch=watch), | ||||
|                 'jq_support': jq_support, | ||||
|                 'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False), | ||||
|                 'settings_application': datastore.data['settings']['application'], | ||||
|                 'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'), | ||||
|                 'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'), | ||||
|                 'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid), | ||||
|                 'timezone_default_config': datastore.data['settings']['application'].get('timezone'), | ||||
|                 'using_global_webdriver_wait': not default['webdriver_delay'], | ||||
|                 'uuid': uuid, | ||||
|                 'watch': watch, | ||||
|                 'watch_needs_selenium_or_playwright': watch_needs_selenium_or_playwright, | ||||
|             } | ||||
|  | ||||
|             included_content = None | ||||
|             if form.extra_form_content(): | ||||
|                 # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/ | ||||
|                 # And then render the code from the module | ||||
|                 templates_dir = str(importlib.resources.files("changedetectionio").joinpath('templates')) | ||||
|                 env = Environment(loader=FileSystemLoader(templates_dir)) | ||||
|                 template = env.from_string(form.extra_form_content()) | ||||
|                 included_content = template.render(**template_args) | ||||
|  | ||||
|             output = render_template("edit.html", | ||||
|                                      extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None, | ||||
|                                      extra_form_content=included_content, | ||||
|                                      **template_args | ||||
|                                      ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|     @edit_blueprint.route("/edit/<string:uuid>/get-html", methods=['GET']) | ||||
|     @login_optionally_required | ||||
|     def watch_get_latest_html(uuid): | ||||
|         from io import BytesIO | ||||
|         from flask import send_file | ||||
|         import brotli | ||||
|  | ||||
|         watch = datastore.data['watching'].get(uuid) | ||||
|         if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir): | ||||
|             latest_filename = list(watch.history.keys())[-1] | ||||
|             html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br") | ||||
|             with open(html_fname, 'rb') as f: | ||||
|                 if html_fname.endswith('.br'): | ||||
|                     # Read and decompress the Brotli file | ||||
|                     decompressed_data = brotli.decompress(f.read()) | ||||
|                 else: | ||||
|                     decompressed_data = f.read() | ||||
|  | ||||
|             buffer = BytesIO(decompressed_data) | ||||
|  | ||||
|             return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html') | ||||
|  | ||||
|         # Return a 500 error | ||||
|         abort(500) | ||||
|  | ||||
|     # Ajax callback | ||||
|     @edit_blueprint.route("/edit/<string:uuid>/preview-rendered", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def watch_get_preview_rendered(uuid): | ||||
|         '''For when viewing the "preview" of the rendered text from inside of Edit''' | ||||
|         from flask import jsonify | ||||
|         from changedetectionio.processors.text_json_diff import prepare_filter_prevew | ||||
|         result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore) | ||||
|         return jsonify(result) | ||||
|  | ||||
|     @edit_blueprint.route("/highlight_submit_ignore_url", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def highlight_submit_ignore_url(): | ||||
|         import re | ||||
|         mode = request.form.get('mode') | ||||
|         selection = request.form.get('selection') | ||||
|  | ||||
|         uuid = request.args.get('uuid','') | ||||
|         if datastore.data["watching"].get(uuid): | ||||
|             if mode == 'exact': | ||||
|                 for l in selection.splitlines(): | ||||
|                     datastore.data["watching"][uuid]['ignore_text'].append(l.strip()) | ||||
|             elif mode == 'digit-regex': | ||||
|                 for l in selection.splitlines(): | ||||
|                     # Replace any series of numbers with a regex | ||||
|                     s = re.escape(l.strip()) | ||||
|                     s = re.sub(r'[0-9]+', r'\\d+', s) | ||||
|                     datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/') | ||||
|  | ||||
|         return f"<a href={url_for('ui.ui_views.preview_page', uuid=uuid)}>Click to preview</a>" | ||||
|      | ||||
|     return edit_blueprint | ||||
							
								
								
									
										108
									
								
								changedetectionio/blueprint/ui/notification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								changedetectionio/blueprint/ui/notification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| from flask import Blueprint, request, make_response | ||||
| import random | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio.auth_decorator import login_optionally_required | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates") | ||||
|      | ||||
|     # AJAX endpoint for sending a test | ||||
|     @notification_blueprint.route("/notification/send-test/<string:watch_uuid>", methods=['POST']) | ||||
|     @notification_blueprint.route("/notification/send-test", methods=['POST']) | ||||
|     @notification_blueprint.route("/notification/send-test/", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def ajax_callback_send_notification_test(watch_uuid=None): | ||||
|  | ||||
|         # Watch_uuid could be unset in the case it`s used in tag editor, global settings | ||||
|         import apprise | ||||
|         from changedetectionio.notification.handler import process_notification | ||||
|         from changedetectionio.notification.apprise_plugin.assets import apprise_asset | ||||
|  | ||||
|         from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler | ||||
|  | ||||
|         apobj = apprise.Apprise(asset=apprise_asset) | ||||
|  | ||||
|         is_global_settings_form = request.args.get('mode', '') == 'global-settings' | ||||
|         is_group_settings_form = request.args.get('mode', '') == 'group-settings' | ||||
|  | ||||
|         # Use an existing random one on the global/main settings form | ||||
|         if not watch_uuid and (is_global_settings_form or is_group_settings_form) \ | ||||
|                 and datastore.data.get('watching'): | ||||
|             logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}") | ||||
|             watch_uuid = random.choice(list(datastore.data['watching'].keys())) | ||||
|  | ||||
|         if not watch_uuid: | ||||
|             return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400) | ||||
|  | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|  | ||||
|         notification_urls = None | ||||
|  | ||||
|         if request.form.get('notification_urls'): | ||||
|             notification_urls = request.form['notification_urls'].strip().splitlines() | ||||
|  | ||||
|         if not notification_urls: | ||||
|             logger.debug("Test notification - Trying by group/tag in the edit form if available") | ||||
|             # On an edit page, we should also fire off to the tags if they have notifications | ||||
|             if request.form.get('tags') and request.form['tags'].strip(): | ||||
|                 for k in request.form['tags'].split(','): | ||||
|                     tag = datastore.tag_exists_by_name(k.strip()) | ||||
|                     notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None | ||||
|  | ||||
|         if not notification_urls and not is_global_settings_form and not is_group_settings_form: | ||||
|             # In the global settings, use only what is typed currently in the text box | ||||
|             logger.debug("Test notification - Trying by global system settings notifications") | ||||
|             if datastore.data['settings']['application'].get('notification_urls'): | ||||
|                 notification_urls = datastore.data['settings']['application']['notification_urls'] | ||||
|  | ||||
|         if not notification_urls: | ||||
|             return 'Error: No Notification URLs set/found' | ||||
|  | ||||
|         for n_url in notification_urls: | ||||
|             if len(n_url.strip()): | ||||
|                 if not apobj.add(n_url): | ||||
|                     return f'Error:  {n_url} is not a valid AppRise URL.' | ||||
|  | ||||
|         try: | ||||
|             # use the same as when it is triggered, but then override it with the form test values | ||||
|             n_object = { | ||||
|                 'watch_url': request.form.get('window_url', "https://changedetection.io"), | ||||
|                 'notification_urls': notification_urls | ||||
|             } | ||||
|  | ||||
|             # Only use if present, if not set in n_object it should use the default system value | ||||
|             if 'notification_format' in request.form and request.form['notification_format'].strip(): | ||||
|                 n_object['notification_format'] = request.form.get('notification_format', '').strip() | ||||
|  | ||||
|             if 'notification_title' in request.form and request.form['notification_title'].strip(): | ||||
|                 n_object['notification_title'] = request.form.get('notification_title', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_title'): | ||||
|                 n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title') | ||||
|             else: | ||||
|                 n_object['notification_title'] = "Test title" | ||||
|  | ||||
|             if 'notification_body' in request.form and request.form['notification_body'].strip(): | ||||
|                 n_object['notification_body'] = request.form.get('notification_body', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_body'): | ||||
|                 n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body') | ||||
|             else: | ||||
|                 n_object['notification_body'] = "Test body" | ||||
|  | ||||
|             n_object['as_async'] = False | ||||
|             n_object.update(watch.extra_notification_token_values()) | ||||
|             sent_obj = process_notification(n_object, datastore) | ||||
|  | ||||
|         except Exception as e: | ||||
|             e_str = str(e) | ||||
|             # Remove this text which is not important and floods the container | ||||
|             e_str = e_str.replace( | ||||
|                 "DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>", | ||||
|                 '') | ||||
|  | ||||
|             return make_response(e_str, 400) | ||||
|  | ||||
|         return 'OK - Sent test notifications' | ||||
|  | ||||
|     return notification_blueprint | ||||
| @@ -0,0 +1,49 @@ | ||||
| {% extends 'base.html' %} {% block content %} | ||||
| <div class="edit-form"> | ||||
|   <div class="box-wrap inner"> | ||||
|     <form | ||||
|       class="pure-form pure-form-stacked" | ||||
|       action="{{url_for('ui.clear_all_history')}}" | ||||
|       method="POST" | ||||
|     > | ||||
|       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|       <fieldset> | ||||
|         <div class="pure-control-group"> | ||||
|           This will remove version history (snapshots) for ALL watches, but keep | ||||
|           your list of URLs! <br /> | ||||
|           You may like to use the <strong>BACKUP</strong> link first.<br /> | ||||
|         </div> | ||||
|         <br /> | ||||
|         <div class="pure-control-group"> | ||||
|           <label for="confirmtext">Confirmation text</label> | ||||
|           <input | ||||
|             type="text" | ||||
|             id="confirmtext" | ||||
|             required="" | ||||
|             name="confirmtext" | ||||
|             value="" | ||||
|             size="10" | ||||
|           /> | ||||
|           <span class="pure-form-message-inline" | ||||
|             >Type in the word <strong>clear</strong> to confirm that you | ||||
|             understand.</span | ||||
|           > | ||||
|         </div> | ||||
|         <br /> | ||||
|         <div class="pure-control-group"> | ||||
|           <button type="submit" class="pure-button pure-button-primary"> | ||||
|             Clear History! | ||||
|           </button> | ||||
|         </div> | ||||
|         <br /> | ||||
|         <div class="pure-control-group"> | ||||
|           <a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel" | ||||
|             >Cancel</a | ||||
|           > | ||||
|         </div> | ||||
|       </fieldset> | ||||
|     </form> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user