Compare commits
	
		
			1205 Commits
		
	
	
		
			0.23
			...
			selectable
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 6fb6d01e2a | ||
|   | bfe69de549 | ||
|   | c437e5d740 | ||
|   | 7cc2afbb8f | ||
|   | 2877a639dc | ||
|   | 2f16aee0dd | ||
|   | cdf611f173 | ||
|   | 77ec1da0ff | ||
|   | c8dcc072c8 | ||
|   | 7c97a5a403 | ||
|   | 7dd967be8e | ||
|   | 3607d15185 | ||
|   | 3382b4cb3f | ||
|   | 5f030d3668 | ||
|   | 06975d6d8f | ||
|   | f58e5b7f19 | ||
|   | e50eff8e35 | ||
|   | 07a853ce59 | ||
|   | 80f8d23309 | ||
|   | 9f41d15908 | ||
|   | 89797dfe02 | ||
|   | c905652780 | ||
|   | 99246d3e6d | ||
|   | f9f69bf0dd | ||
|   | 7477ce11d6 | ||
|   | 858b66efb4 | ||
|   | 68efb25e9b | ||
|   | 0bcbcb80f1 | ||
|   | b6bdc2738b | ||
|   | ebc7a7e568 | ||
|   | d7bc2bd3f6 | ||
|   | 2bd32b261a | ||
|   | 572a169a47 | ||
|   | 68d1e2736c | ||
|   | 97e591fa24 | ||
|   | 5d9a5d9fa8 | ||
|   | 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 | ||
|   | f877cbfe8c | ||
|   | fe4963ec04 | ||
|   | 32a798128c | ||
|   | cf4e294a9c | ||
|   | b008269a70 | ||
|   | 50026ee6d9 | ||
|   | aa5ba7b3a9 | ||
|   | 4110d05bf8 | ||
|   | 6c02bc9cd3 | ||
|   | 0a9b5f801f | ||
|   | b4630d4200 | ||
|   | 2238b7d660 | ||
|   | e6fadc44fa | ||
|   | c0b6233912 | ||
|   | 9669f8248e | ||
|   | b2b8958f7b | ||
|   | 83daa6f630 | ||
|   | dad48402f1 | ||
|   | 655a350f50 | ||
|   | ae0fc5ec0f | ||
|   | 851142446d | ||
|   | dc2896c452 | ||
|   | 306814f47f | ||
|   | e073521f4d | ||
|   | f2643c1b65 | ||
|   | 0e291de045 | ||
|   | 2f22d627fa | ||
|   | cd622261e9 | ||
|   | 39a696fc7c | ||
|   | db5afa1fa2 | ||
|   | 56c56c63e8 | ||
|   | cb0d69801f | ||
|   | 99ddc0490b | ||
|   | b27d03e8c7 | ||
|   | f852bdda0e | ||
|   | b85af8904a | ||
|   | db18866b0a | ||
|   | 3fa6bc5ffd | ||
|   | 25185e6d00 | ||
|   | 9af1ea9fc0 | ||
|   | aa51c7d34c | ||
|   | f215adbbe5 | ||
|   | 8d59ef2e10 | ||
|   | e3a9847f74 | ||
|   | 47f7698b32 | ||
|   | c6a4709987 | ||
|   | 6c35995cff | ||
|   | fa6c31fd50 | ||
|   | 58dfeaeec8 | ||
|   | f717ad1bb6 | ||
|   | 8a0b33c1e8 | ||
|   | f762d889f9 | ||
|   | d82465d428 | ||
|   | 74cf72c9cd | ||
|   | 03c1ad3989 | ||
|   | ed7c2f01da | ||
|   | 0923aa5b73 | ||
|   | 04acd8b2f8 | ||
|   | 45bd454e26 | ||
|   | a429223858 | ||
|   | 59eb83974e | ||
|   | d4928e34eb | ||
|   | 8bcc277310 | ||
|   | 53b9640ac5 | ||
|   | 854520005d | ||
|   | 4dbfd376f2 | ||
|   | af24079053 | ||
|   | a91c4dbe92 | ||
|   | 3f9fab3944 | ||
|   | 1772568559 | ||
|   | fa3ce97634 | ||
|   | fed2de66a0 | ||
|   | e761405f58 | ||
|   | 23738c98bc | ||
|   | 07c7663e56 | ||
|   | cec45a7ad7 | ||
|   | dc62bcdfca | ||
|   | d304449cb1 | ||
|   | 878584f043 | ||
|   | b4fa7d2089 | ||
|   | b0592df3cb | ||
|   | ddd8bd34f2 | ||
|   | afea79adf9 | ||
|   | 444510c9ca | ||
|   | 1f1d2708c6 | ||
|   | bae6641777 | ||
|   | 17830de489 | ||
|   | 0acf9cc9cb | ||
|   | cff8959462 | ||
|   | 4b6522469b | ||
|   | 609a0a3aad | ||
|   | ad8065c072 | ||
|   | 2346b42ef2 | ||
|   | 1a0c3f1250 | ||
|   | 91f69b92a2 | ||
|   | dd211d166c | ||
|   | a6b0a23143 | ||
|   | a03e53d826 | ||
|   | 5d93009605 | ||
|   | d4f3e744de | ||
|   | 13de31cf98 | ||
|   | 54ae82395a | ||
|   | dba8944625 | ||
|   | 270343b276 | ||
|   | f3ce9b732c | ||
|   | baaee30499 | ||
|   | d50ff0b31c | ||
|   | 395a6fca62 | ||
|   | f582810ad0 | ||
|   | 18b71edd6d | ||
|   | 28f6af9153 | ||
|   | 63a3492547 | ||
|   | 454fc26341 | ||
|   | e5409f8d16 | ||
|   | 1b736b3726 | ||
|   | 96f2b0d248 | ||
|   | 308527f45e | ||
|   | 70d766b647 | ||
|   | 40be9c615f | ||
|   | f380754ff5 | ||
|   | bee6bd9fe0 | ||
|   | fec2862ebe | ||
|   | 969420e40b | ||
|   | afba06dd1f | ||
|   | 1d66160e8c | ||
|   | f877af75b9 | ||
|   | b752690f89 | ||
|   | a10efa951b | ||
|   | 24a38f26f8 | ||
|   | 1d0018dced | ||
|   | 18c7a18be8 | ||
|   | c11adcbe4a | ||
|   | cd6ce89587 | ||
|   | 4164ad29e3 | ||
|   | 4953e253e9 | ||
|   | 64e172433a | ||
|   | 92c0fa90ee | ||
|   | ee8053e0e8 | ||
|   | 7f5b592f6f | ||
|   | 1e45156bc0 | ||
|   | c7169ebba1 | ||
|   | a58679f983 | ||
|   | 661542b056 | ||
|   | 2ea48cb90a | ||
|   | 2a80022cd9 | ||
|   | 8861f70ac4 | ||
|   | 07113216d5 | ||
|   | 02062c5893 | ||
|   | a11f09062b | ||
|   | 0bb48cbd43 | ||
|   | 7109a17a8e | ||
|   | 4ed026aba6 | ||
|   | 3b79f8ed4e | ||
|   | 5d02c4fe6f | ||
|   | f2b06c63bf | ||
|   | ab6f4d11ed | ||
|   | 5311a95140 | ||
|   | fb723c264d | ||
|   | 3ad722d63c | ||
|   | 9c16695932 | ||
|   | 35fc76c02c | ||
|   | 934d8c6211 | ||
|   | 294256d5c3 | ||
|   | b7efdfd52c | ||
|   | 6a78b5ad1d | ||
|   | 98f3e61314 | ||
|   | e322c44d3e | ||
|   | 7b226e1d54 | ||
|   | 35e597a4c8 | ||
|   | 0a1a8340c2 | ||
|   | 8b5cd40593 | ||
|   | 7d978a6e65 | ||
|   | fdab52d400 | ||
|   | 782795310f | ||
|   | 2280e6d497 | ||
|   | 822f3e6d20 | ||
|   | 35546c331c | ||
|   | 982a0d7781 | ||
|   | c5c3e8c6c2 | ||
|   | ff1b19cdb8 | ||
|   | df96b8d76c | ||
|   | 89134b5b6c | ||
|   | b31bf34890 | ||
|   | 5b2fda1a6e | ||
|   | fb38b06eae | ||
|   | e0578acca2 | ||
|   | 187523d8d6 | ||
|   | b0975694c8 | ||
|   | b1fb47e689 | ||
|   | a82e9243a6 | ||
|   | e3e36b3cef | ||
|   | cd6465f844 | ||
|   | 30d53c353f | ||
|   | 47fcb8b4f8 | ||
|   | 0ec9edb971 | ||
|   | f1da8f96b6 | ||
|   | 8bc7b5be40 | ||
|   | 022826493b | ||
|   | 092f77f066 | ||
|   | 013cbcabd4 | ||
|   | 66be95ecc6 | ||
|   | efe0356f37 | ||
|   | ec1ac300af | ||
|   | 468184bc3a | ||
|   | 0855017dca | ||
|   | ae0f640ff4 | ||
|   | cd6629ac2d | ||
|   | 3c3ca7944b | ||
|   | b0fb52017c | ||
|   | fc6fba377a | ||
|   | 7ea39ada7c | ||
|   | e98ea37342 | ||
|   | e20577df15 | ||
|   | 19dcbc2f08 | ||
|   | c59838a6e4 | ||
|   | 0a8c339535 | ||
|   | cd5b703037 | ||
|   | 90642742bd | ||
|   | 96221598e7 | ||
|   | 98623de38c | ||
|   | 33985dbd9d | ||
|   | a3a5ca78bf | ||
|   | 3fcbbb3fbf | ||
|   | 70252b24f9 | ||
|   | 0a08616c87 | ||
|   | beebba487c | ||
|   | cbeafcbaa0 | ||
|   | e200cd3289 | ||
|   | 22c7a1a88d | ||
|   | 63eea2d6db | ||
|   | 3e9a110671 | ||
|   | 22bc8fabd1 | ||
|   | 9030070b3d | ||
|   | fca7bb8583 | ||
|   | 3c175bfc4a | ||
|   | fd5475ba38 | ||
|   | b0c5dbd88e | ||
|   | 1718e2e86f | ||
|   | b46a7fc4b1 | ||
|   | 4770ebb2ea | ||
|   | d4db082c01 | ||
|   | c8607ae8bb | ||
|   | b361a61d18 | ||
|   | 87f4347fe5 | ||
|   | 93ee65fe53 | ||
|   | 9f964b6d3f | ||
|   | 426b09b7e1 | ||
|   | ec98415c4d | ||
|   | 47e5a7cf09 | ||
|   | d07cf53a07 | ||
|   | b9f73a6240 | 
							
								
								
									
										18
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| .git | ||||
| .github | ||||
| changedetectionio/processors/__pycache__ | ||||
| changedetectionio/api/__pycache__ | ||||
| changedetectionio/model/__pycache__ | ||||
| changedetectionio/blueprint/price_data_follower/__pycache__ | ||||
| changedetectionio/blueprint/tags/__pycache__ | ||||
| changedetectionio/blueprint/__pycache__ | ||||
| changedetectionio/blueprint/browser_steps/__pycache__ | ||||
| changedetectionio/fetchers/__pycache__ | ||||
| changedetectionio/tests/visualselector/__pycache__ | ||||
| changedetectionio/tests/restock/__pycache__ | ||||
| changedetectionio/tests/__pycache__ | ||||
| changedetectionio/tests/fetchers/__pycache__ | ||||
| changedetectionio/tests/unit/__pycache__ | ||||
| changedetectionio/tests/proxy_list/__pycache__ | ||||
| changedetectionio/__pycache__ | ||||
|  | ||||
							
								
								
									
										9
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,12 +1,3 @@ | ||||
| # These are supported funding model platforms | ||||
|  | ||||
| github: dgtlmoon | ||||
| patreon: # Replace with a single Patreon username | ||||
| open_collective: # Replace with a single Open Collective username | ||||
| ko_fi: # Replace with a single Ko-fi username | ||||
| tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel | ||||
| community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry | ||||
| liberapay: # Replace with a single Liberapay username | ||||
| issuehunt: # Replace with a single IssueHunt username | ||||
| otechie: # Replace with a single Otechie username | ||||
| custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] | ||||
|   | ||||
							
								
								
									
										58
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | ||||
| --- | ||||
| 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.... | ||||
|  | ||||
| **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
									
								
							
							
						
						| @@ -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. | ||||
							
								
								
									
										31
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | ||||
| # 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.18 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN \ | ||||
|   apk add --update --no-cache --virtual=build-dependencies \ | ||||
|     cargo \ | ||||
|     g++ \ | ||||
|     gcc \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libxslt-dev \ | ||||
|     make \ | ||||
|     openssl-dev \ | ||||
|     py3-wheel \ | ||||
|     python3-dev \ | ||||
|     zlib-dev && \ | ||||
|   apk add --update --no-cache \ | ||||
|     libxslt \ | ||||
|     python3 \ | ||||
|     py3-pip && \ | ||||
|   echo "**** pip3 install test of changedetection.io ****" && \ | ||||
|   pip3 install -U pip wheel setuptools && \ | ||||
|   pip3 install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.18/ -r /requirements.txt && \ | ||||
|   apk del --purge \ | ||||
|     build-dependencies | ||||
							
								
								
									
										62
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| # For most projects, this workflow file will not need changing; you simply need | ||||
| # to commit it to your repository. | ||||
| # | ||||
| # You may wish to alter this file to override the set of languages analyzed, | ||||
| # or to provide custom queries or build logic. | ||||
| # | ||||
| # ******** NOTE ******** | ||||
| # We have attempted to detect the languages in your repository. Please check | ||||
| # the `language` matrix defined below to confirm you have the correct set of | ||||
| # supported CodeQL languages. | ||||
| # | ||||
| name: "CodeQL" | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
|     - cron: '27 9 * * 4' | ||||
|  | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: Analyze | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         language: [ 'javascript', 'python' ] | ||||
|         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] | ||||
|         # Learn more: | ||||
|         # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed | ||||
|  | ||||
|     steps: | ||||
|     - name: Checkout repository | ||||
|       uses: actions/checkout@v4 | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v2 | ||||
|       with: | ||||
|         languages: ${{ matrix.language }} | ||||
|         # If you wish to specify custom queries, you can do so here or in a config file. | ||||
|         # By default, queries listed here will override any specified in a config file. | ||||
|         # Prefix the list here with "+" to use these queries and those in the config file. | ||||
|         # queries: ./path/to/local/query, your-org/your-repo/queries@main | ||||
|  | ||||
|     # 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@v2 | ||||
|  | ||||
|     # ℹ️ Command-line programs to run using the OS shell. | ||||
|     # 📚 https://git.io/JvXDl | ||||
|  | ||||
|     # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines | ||||
|     #    and modify them (or add more) to build your code if your project | ||||
|     #    uses a compiled language | ||||
|  | ||||
|     #- run: | | ||||
|     #   make bootstrap | ||||
|     #   make release | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v2 | ||||
							
								
								
									
										127
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,127 @@ | ||||
| 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@v4 | ||||
|         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@v5 | ||||
|         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/v6,linux/arm/v7,linux/arm/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: 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@v5 | ||||
|         with: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: | | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ github.event.release.tag_name }} | ||||
|             ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }} | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest | ||||
|             ghcr.io/dgtlmoon/changedetection.io:latest | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/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 }} | ||||
|  | ||||
							
								
								
									
										68
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,68 @@ | ||||
| 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/* | ||||
|  | ||||
|   pull_request: | ||||
|     paths: | ||||
|       - requirements.txt | ||||
|       - Dockerfile | ||||
|       - .github/workflows/* | ||||
|  | ||||
|   # 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@v4 | ||||
|           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@v5 | ||||
|           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@v5 | ||||
|           # https://github.com/docker/build-push-action#customizing | ||||
|           with: | ||||
|             context: ./ | ||||
|             file: ./Dockerfile | ||||
|             platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/v8 | ||||
|             cache-from: type=local,src=/tmp/.buildx-cache | ||||
|             cache-to: type=local,dest=/tmp/.buildx-cache | ||||
|  | ||||
							
								
								
									
										111
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,111 @@ | ||||
| name: ChangeDetection.io App Test | ||||
|  | ||||
| # Triggers the workflow on push or pull request events | ||||
| on: [push, pull_request] | ||||
|  | ||||
| jobs: | ||||
|   test-application: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       # Mainly just for link/flake8 | ||||
|       - name: Set up Python 3.11 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: '3.11' | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         run: | | ||||
|           pip3 install flake8 | ||||
|           # 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: Spin up ancillary testable services | ||||
|         run: | | ||||
|            | ||||
|           docker network create changedet-network | ||||
|  | ||||
|           # Selenium+browserless | ||||
|           docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4 | ||||
|           docker run --network changedet-network -d --name browserless --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.60-chrome-stable | ||||
|            | ||||
|           # For accessing custom browser tests | ||||
|           docker run --network changedet-network -d --name browserless-custom-url --hostname browserless-custom-url -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm --shm-size="2g"  browserless/chrome:1.60-chrome-stable | ||||
|  | ||||
|       - name: Build changedetection.io container for testing | ||||
|         run: |          | ||||
|           # Build a changedetection.io container and start testing inside | ||||
|           docker build . -t test-changedetectionio | ||||
|           # Debug info | ||||
|           docker run test-changedetectionio  bash -c 'pip list' | ||||
|  | ||||
|       - 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 'python changedetectionio/tests/smtp/smtp-test-server.py'  | ||||
|  | ||||
|       - name: Test built container with pytest | ||||
|         run: | | ||||
|           # Unit tests | ||||
|           docker run test-changedetectionio  bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff' | ||||
|            | ||||
|           # All tests | ||||
|           docker run --network changedet-network  test-changedetectionio  bash -c 'cd changedetectionio && ./run_basic_tests.sh' | ||||
|  | ||||
|       - name: Test built container selenium+browserless/playwright | ||||
|         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' | ||||
|            | ||||
|           # Playwright/Browserless fetch | ||||
|           docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' | ||||
|            | ||||
|           # Settings headers playwright tests - Call back in from Browserless, check headers | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless: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' | ||||
|           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' | ||||
|           docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "USE_EXPERIMENTAL_PUPPETEER_FETCH=yes" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless: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'           | ||||
|            | ||||
|           # restock detection via playwright - added name=changedet here so that playwright/browserless can connect to it | ||||
|           docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless: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' | ||||
|  | ||||
|       - name: Test SMTP notification mime types | ||||
|         run: | | ||||
|           # SMTP content types - needs the 'Debug SMTP server/echo message back server' container from above | ||||
|           docker run --rm  --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py' | ||||
|  | ||||
|       - name: Test with puppeteer fetcher and disk cache | ||||
|         run: | | ||||
|           docker run --rm -e "PUPPETEER_DISK_CACHE=/tmp/data/" -e "USE_EXPERIMENTAL_PUPPETEER_FETCH=yes" -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/fetchers/test_content.py && pytest tests/test_errorhandling.py && pytest tests/visualselector/test_fetch_data.py' | ||||
|           # Browserless would have had -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" added above | ||||
|  | ||||
|       - name: Test proxy interaction | ||||
|         run: | | ||||
|           cd changedetectionio | ||||
|           ./run_proxy_tests.sh | ||||
|           # And again with PLAYWRIGHT_DRIVER_URL=.. | ||||
|           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 -p 5556:5000 -d test-changedetectionio | ||||
|           sleep 3 | ||||
|           # Should return 0 (no error) when grep finds it | ||||
|           curl -s http://localhost:5556 |grep -q checkbox-uuid | ||||
|            | ||||
|           # and IPv6 | ||||
|           curl -s -g -6 "http://[::1]:5556"|grep -q checkbox-uuid | ||||
|            | ||||
|  | ||||
| #export WEBDRIVER_URL=http://localhost:4444/wd/hub | ||||
| #pytest tests/fetchers/test_content.py | ||||
| #pytest tests/test_errorhandling.py | ||||
							
								
								
									
										36
									
								
								.github/workflows/test-pip-build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,36 @@ | ||||
| name: ChangeDetection.io PIP package 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] | ||||
|  | ||||
|   # 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-pip-build-basics: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|         - uses: actions/checkout@v4 | ||||
|  | ||||
|         - name: Set up Python 3.11 | ||||
|           uses: actions/setup-python@v4 | ||||
|           with: | ||||
|             python-version: 3.11 | ||||
|  | ||||
|  | ||||
|         - name: Test that the basic pip built package runs without error | ||||
|           run: | | ||||
|             set -e | ||||
|             mkdir dist | ||||
|             pip3 install wheel | ||||
|             python3 setup.py bdist_wheel             | ||||
|             pip3 install -r requirements.txt | ||||
|             rm ./changedetection.py | ||||
|             rm -rf changedetectio | ||||
|              | ||||
|             pip3 install dist/changedetection.io*.whl | ||||
|             changedetection.io -d /tmp -p 10000 & | ||||
|             sleep 3 | ||||
|             curl http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null | ||||
|             killall -9 changedetection.io | ||||
							
								
								
									
										11
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -2,4 +2,13 @@ __pycache__ | ||||
| .idea | ||||
| *.pyc | ||||
| datastore/url-watches.json | ||||
| datastore/* | ||||
| datastore/* | ||||
| __pycache__ | ||||
| .pytest_cache | ||||
| build | ||||
| dist | ||||
| venv | ||||
| test-datastore/* | ||||
| test-datastore | ||||
| *.egg-info* | ||||
| .vscode/settings.json | ||||
|   | ||||
							
								
								
									
										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 `dev` 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 | ||||
							
								
								
									
										68
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -1,24 +1,64 @@ | ||||
| FROM python:3.8-slim | ||||
| COPY requirements.txt /tmp/requirements.txt | ||||
| RUN pip3 install -r /tmp/requirements.txt | ||||
| # pip dependencies install stage | ||||
| FROM python:3.11-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 \ | ||||
|     g++ \ | ||||
|     gcc \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libjpeg-dev \ | ||||
|     libssl-dev \ | ||||
|     libxslt-dev \ | ||||
|     make \ | ||||
|     zlib1g-dev | ||||
|  | ||||
| RUN mkdir /install | ||||
| WORKDIR /install | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN pip install --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.39 \ | ||||
|     || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." | ||||
|  | ||||
| # Final image stage | ||||
| FROM python:3.11-slim-bookworm | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libxslt1.1 \ | ||||
|     # For pdftohtml | ||||
|     poppler-utils \ | ||||
|     zlib1g \ | ||||
|     && apt-get clean && rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
| COPY backend /app | ||||
| WORKDIR /app | ||||
|  | ||||
| # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | ||||
|  | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| # Attempt to store the triggered commit | ||||
|  | ||||
| ARG SOURCE_COMMIT | ||||
| ARG SOURCE_BRANCH | ||||
| RUN echo "commit: $SOURCE_COMMIT branch: $SOURCE_BRANCH" >/source.txt | ||||
|  | ||||
|  | ||||
| RUN [ ! -d "/datastore" ] && mkdir /datastore | ||||
|  | ||||
| CMD [ "python", "./backend.py" ] | ||||
| # 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 | ||||
|  | ||||
| EXPOSE 5000 | ||||
|  | ||||
| # The actual flask app | ||||
| COPY changedetectionio /app/changedetectionio | ||||
|  | ||||
| # The eventlet server wrapper | ||||
| COPY changedetection.py /app/changedetection.py | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| CMD [ "python", "./changedetection.py" , "-d", "/datastore"] | ||||
|   | ||||
							
								
								
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,201 @@ | ||||
|                                  Apache License | ||||
|                            Version 2.0, January 2004 | ||||
|                         http://www.apache.org/licenses/ | ||||
|  | ||||
|    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | ||||
|  | ||||
|    1. Definitions. | ||||
|  | ||||
|       "License" shall mean the terms and conditions for use, reproduction, | ||||
|       and distribution as defined by Sections 1 through 9 of this document. | ||||
|  | ||||
|       "Licensor" shall mean the copyright owner or entity authorized by | ||||
|       the copyright owner that is granting the License. | ||||
|  | ||||
|       "Legal Entity" shall mean the union of the acting entity and all | ||||
|       other entities that control, are controlled by, or are under common | ||||
|       control with that entity. For the purposes of this definition, | ||||
|       "control" means (i) the power, direct or indirect, to cause the | ||||
|       direction or management of such entity, whether by contract or | ||||
|       otherwise, or (ii) ownership of fifty percent (50%) or more of the | ||||
|       outstanding shares, or (iii) beneficial ownership of such entity. | ||||
|  | ||||
|       "You" (or "Your") shall mean an individual or Legal Entity | ||||
|       exercising permissions granted by this License. | ||||
|  | ||||
|       "Source" form shall mean the preferred form for making modifications, | ||||
|       including but not limited to software source code, documentation | ||||
|       source, and configuration files. | ||||
|  | ||||
|       "Object" form shall mean any form resulting from mechanical | ||||
|       transformation or translation of a Source form, including but | ||||
|       not limited to compiled object code, generated documentation, | ||||
|       and conversions to other media types. | ||||
|  | ||||
|       "Work" shall mean the work of authorship, whether in Source or | ||||
|       Object form, made available under the License, as indicated by a | ||||
|       copyright notice that is included in or attached to the work | ||||
|       (an example is provided in the Appendix below). | ||||
|  | ||||
|       "Derivative Works" shall mean any work, whether in Source or Object | ||||
|       form, that is based on (or derived from) the Work and for which the | ||||
|       editorial revisions, annotations, elaborations, or other modifications | ||||
|       represent, as a whole, an original work of authorship. For the purposes | ||||
|       of this License, Derivative Works shall not include works that remain | ||||
|       separable from, or merely link (or bind by name) to the interfaces of, | ||||
|       the Work and Derivative Works thereof. | ||||
|  | ||||
|       "Contribution" shall mean any work of authorship, including | ||||
|       the original version of the Work and any modifications or additions | ||||
|       to that Work or Derivative Works thereof, that is intentionally | ||||
|       submitted to Licensor for inclusion in the Work by the copyright owner | ||||
|       or by an individual or Legal Entity authorized to submit on behalf of | ||||
|       the copyright owner. For the purposes of this definition, "submitted" | ||||
|       means any form of electronic, verbal, or written communication sent | ||||
|       to the Licensor or its representatives, including but not limited to | ||||
|       communication on electronic mailing lists, source code control systems, | ||||
|       and issue tracking systems that are managed by, or on behalf of, the | ||||
|       Licensor for the purpose of discussing and improving the Work, but | ||||
|       excluding communication that is conspicuously marked or otherwise | ||||
|       designated in writing by the copyright owner as "Not a Contribution." | ||||
|  | ||||
|       "Contributor" shall mean Licensor and any individual or Legal Entity | ||||
|       on behalf of whom a Contribution has been received by Licensor and | ||||
|       subsequently incorporated within the Work. | ||||
|  | ||||
|    2. Grant of Copyright License. Subject to the terms and conditions of | ||||
|       this License, each Contributor hereby grants to You a perpetual, | ||||
|       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||
|       copyright license to reproduce, prepare Derivative Works of, | ||||
|       publicly display, publicly perform, sublicense, and distribute the | ||||
|       Work and such Derivative Works in Source or Object form. | ||||
|  | ||||
|    3. Grant of Patent License. Subject to the terms and conditions of | ||||
|       this License, each Contributor hereby grants to You a perpetual, | ||||
|       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||
|       (except as stated in this section) patent license to make, have made, | ||||
|       use, offer to sell, sell, import, and otherwise transfer the Work, | ||||
|       where such license applies only to those patent claims licensable | ||||
|       by such Contributor that are necessarily infringed by their | ||||
|       Contribution(s) alone or by combination of their Contribution(s) | ||||
|       with the Work to which such Contribution(s) was submitted. If You | ||||
|       institute patent litigation against any entity (including a | ||||
|       cross-claim or counterclaim in a lawsuit) alleging that the Work | ||||
|       or a Contribution incorporated within the Work constitutes direct | ||||
|       or contributory patent infringement, then any patent licenses | ||||
|       granted to You under this License for that Work shall terminate | ||||
|       as of the date such litigation is filed. | ||||
|  | ||||
|    4. Redistribution. You may reproduce and distribute copies of the | ||||
|       Work or Derivative Works thereof in any medium, with or without | ||||
|       modifications, and in Source or Object form, provided that You | ||||
|       meet the following conditions: | ||||
|  | ||||
|       (a) You must give any other recipients of the Work or | ||||
|           Derivative Works a copy of this License; and | ||||
|  | ||||
|       (b) You must cause any modified files to carry prominent notices | ||||
|           stating that You changed the files; and | ||||
|  | ||||
|       (c) You must retain, in the Source form of any Derivative Works | ||||
|           that You distribute, all copyright, patent, trademark, and | ||||
|           attribution notices from the Source form of the Work, | ||||
|           excluding those notices that do not pertain to any part of | ||||
|           the Derivative Works; and | ||||
|  | ||||
|       (d) If the Work includes a "NOTICE" text file as part of its | ||||
|           distribution, then any Derivative Works that You distribute must | ||||
|           include a readable copy of the attribution notices contained | ||||
|           within such NOTICE file, excluding those notices that do not | ||||
|           pertain to any part of the Derivative Works, in at least one | ||||
|           of the following places: within a NOTICE text file distributed | ||||
|           as part of the Derivative Works; within the Source form or | ||||
|           documentation, if provided along with the Derivative Works; or, | ||||
|           within a display generated by the Derivative Works, if and | ||||
|           wherever such third-party notices normally appear. The contents | ||||
|           of the NOTICE file are for informational purposes only and | ||||
|           do not modify the License. You may add Your own attribution | ||||
|           notices within Derivative Works that You distribute, alongside | ||||
|           or as an addendum to the NOTICE text from the Work, provided | ||||
|           that such additional attribution notices cannot be construed | ||||
|           as modifying the License. | ||||
|  | ||||
|       You may add Your own copyright statement to Your modifications and | ||||
|       may provide additional or different license terms and conditions | ||||
|       for use, reproduction, or distribution of Your modifications, or | ||||
|       for any such Derivative Works as a whole, provided Your use, | ||||
|       reproduction, and distribution of the Work otherwise complies with | ||||
|       the conditions stated in this License. | ||||
|  | ||||
|    5. Submission of Contributions. Unless You explicitly state otherwise, | ||||
|       any Contribution intentionally submitted for inclusion in the Work | ||||
|       by You to the Licensor shall be under the terms and conditions of | ||||
|       this License, without any additional terms or conditions. | ||||
|       Notwithstanding the above, nothing herein shall supersede or modify | ||||
|       the terms of any separate license agreement you may have executed | ||||
|       with Licensor regarding such Contributions. | ||||
|  | ||||
|    6. Trademarks. This License does not grant permission to use the trade | ||||
|       names, trademarks, service marks, or product names of the Licensor, | ||||
|       except as required for reasonable and customary use in describing the | ||||
|       origin of the Work and reproducing the content of the NOTICE file. | ||||
|  | ||||
|    7. Disclaimer of Warranty. Unless required by applicable law or | ||||
|       agreed to in writing, Licensor provides the Work (and each | ||||
|       Contributor provides its Contributions) on an "AS IS" BASIS, | ||||
|       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | ||||
|       implied, including, without limitation, any warranties or conditions | ||||
|       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | ||||
|       PARTICULAR PURPOSE. You are solely responsible for determining the | ||||
|       appropriateness of using or redistributing the Work and assume any | ||||
|       risks associated with Your exercise of permissions under this License. | ||||
|  | ||||
|    8. Limitation of Liability. In no event and under no legal theory, | ||||
|       whether in tort (including negligence), contract, or otherwise, | ||||
|       unless required by applicable law (such as deliberate and grossly | ||||
|       negligent acts) or agreed to in writing, shall any Contributor be | ||||
|       liable to You for damages, including any direct, indirect, special, | ||||
|       incidental, or consequential damages of any character arising as a | ||||
|       result of this License or out of the use or inability to use the | ||||
|       Work (including but not limited to damages for loss of goodwill, | ||||
|       work stoppage, computer failure or malfunction, or any and all | ||||
|       other commercial damages or losses), even if such Contributor | ||||
|       has been advised of the possibility of such damages. | ||||
|  | ||||
|    9. Accepting Warranty or Additional Liability. While redistributing | ||||
|       the Work or Derivative Works thereof, You may choose to offer, | ||||
|       and charge a fee for, acceptance of support, warranty, indemnity, | ||||
|       or other liability obligations and/or rights consistent with this | ||||
|       License. However, in accepting such obligations, You may act only | ||||
|       on Your own behalf and on Your sole responsibility, not on behalf | ||||
|       of any other Contributor, and only if You agree to indemnify, | ||||
|       defend, and hold each Contributor harmless for any liability | ||||
|       incurred by, or claims asserted against, such Contributor by reason | ||||
|       of your accepting any such warranty or additional liability. | ||||
|  | ||||
|    END OF TERMS AND CONDITIONS | ||||
|  | ||||
|    APPENDIX: How to apply the Apache License to your work. | ||||
|  | ||||
|       To apply the Apache License to your work, attach the following | ||||
|       boilerplate notice, with the fields enclosed by brackets "[]" | ||||
|       replaced with your own identifying information. (Don't include | ||||
|       the brackets!)  The text should be enclosed in the appropriate | ||||
|       comment syntax for the file format. We also recommend that a | ||||
|       file or class name and description of purpose be included on the | ||||
|       same "printed page" as the copyright notice for easier | ||||
|       identification within third-party archives. | ||||
|  | ||||
|    Copyright [yyyy] [name of copyright owner] | ||||
|  | ||||
|    Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|    you may not use this file except in compliance with the License. | ||||
|    You may obtain a copy of the License at | ||||
|  | ||||
|        http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
|    Unless required by applicable law or agreed to in writing, software | ||||
|    distributed under the License is distributed on an "AS IS" BASIS, | ||||
|    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|    See the License for the specific language governing permissions and | ||||
|    limitations under the License. | ||||
							
								
								
									
										19
									
								
								MANIFEST.in
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| recursive-include changedetectionio/api * | ||||
| recursive-include changedetectionio/blueprint * | ||||
| recursive-include changedetectionio/model * | ||||
| recursive-include changedetectionio/processors * | ||||
| recursive-include changedetectionio/res * | ||||
| 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 | ||||
| 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 | ||||
							
								
								
									
										1
									
								
								Procfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| web: python3 ./changedetection.py -C -d ./datastore -p $PORT | ||||
							
								
								
									
										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. | ||||
|  | ||||
							
								
								
									
										272
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,58 +1,270 @@ | ||||
| #  changedetection.io | ||||
| ## Web Site Change Detection, Restock monitoring and notifications. | ||||
|  | ||||
| ## Self-hosted change monitoring of web pages. | ||||
| **_Detect website content changes and perform meaningful actions - trigger notifications via Discord, Email, Slack, Telegram, API calls and many more._** | ||||
|  | ||||
| _Know when web pages change! Stay ontop of new information!_ | ||||
| _Live your data-life pro-actively._  | ||||
|  | ||||
|  | ||||
| #### Example use cases | ||||
| [<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) | ||||
|  | ||||
| Know when ... | ||||
| [![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md) | ||||
|  | ||||
| - Government department updates (changes are often only on their websites) | ||||
| - Local government news (changes are often only on their websites) | ||||
| - New software releases  | ||||
|  | ||||
|  | ||||
| [**Don't have time? Let us host it for you! try our $8.99/month subscription - use our proxies and support!**](https://changedetection.io) , _half the price of other website change monitoring services!_ | ||||
|  | ||||
| - Chrome browser included. | ||||
| - Super fast, no registration needed setup. | ||||
| - Get started watching and receiving website change notifications straight away. | ||||
|  | ||||
|  | ||||
| ### 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) | ||||
|  | ||||
|  | ||||
| ### 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=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. | ||||
|  | ||||
|  | ||||
| ### 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>_ | ||||
|  | ||||
| **Get monitoring now! super simple, one command!** | ||||
| #### 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/ | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| ### Docker | ||||
|  | ||||
| With Docker composer, just clone this repository and.. | ||||
|  | ||||
| ```bash | ||||
| docker run -d --restart always -p "127.0.0.1:5000:5000" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io | ||||
| ```   | ||||
| $ docker-compose up -d | ||||
| ``` | ||||
|  | ||||
| Now visit http://127.0.0.1:5000 , You should now be able to access the UI. | ||||
| 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 | ||||
| ``` | ||||
|  | ||||
| #### Updating to latest version | ||||
| `:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch. | ||||
|  | ||||
| Highly recommended :) | ||||
| 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|grep changedetection.io|awk '{print $1}') | ||||
| docker rm $(docker ps -a|grep changedetection.io|awk '{print $1}') | ||||
| 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 | ||||
| ``` | ||||
|    | ||||
| ### Screenshots | ||||
|  | ||||
| Application running. | ||||
| ### docker-compose | ||||
|  | ||||
|  | ||||
| ```bash | ||||
| docker-compose pull && docker-compose up -d | ||||
| ``` | ||||
|  | ||||
| Examining differences in content. | ||||
| See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
|  | ||||
|  | ||||
| ### Future plans | ||||
| ## Filters | ||||
|  | ||||
| - Greater configuration of check interval times, page request headers. | ||||
| - ~~General options for timeout, default headers~~ | ||||
| - On change detection, callout to another API (handy for notices/issue trackers) | ||||
| - ~~Explore the differences that were detected~~  | ||||
| - Add more options to explore versions of differences | ||||
| - Use a graphic/rendered page difference instead of text (see the experimental `selenium-screenshot-diff` branch) | ||||
| 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. | ||||
|  | ||||
| Just some examples | ||||
|  | ||||
|     discord://webhook_id/webhook_token | ||||
|     flock://app_token/g:channel_id | ||||
|     gitter://token/room | ||||
|     gchat://workspace/key/token | ||||
|     msteams://TokenA/TokenB/TokenC/ | ||||
|     o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail | ||||
|     rocket://user:password@hostname/#Channel | ||||
|     mailto://user:pass@example.com?to=receivingAddress@example.com | ||||
|     json://someserver.com/custom-api | ||||
|     syslog:// | ||||
|   | ||||
| Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/ | ||||
| <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/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 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 | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
|  | ||||
| ### JSONPath or jq? | ||||
|  | ||||
| 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. | ||||
|  | ||||
| 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. | ||||
|  | ||||
| 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.  | ||||
|  | ||||
| ``` | ||||
| <html> | ||||
| ... | ||||
| <script type="application/ld+json"> | ||||
|  | ||||
| { | ||||
|    "@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> | ||||
| ```   | ||||
|  | ||||
| `json:$..price` or `jq:..price` would give `3949.99`, or you can extract the whole structure (use a JSONpath test website to validate with) | ||||
|  | ||||
| The application also supports notifying you that it can follow this information automatically | ||||
|  | ||||
|  | ||||
| ## 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. | ||||
|  | ||||
|  | ||||
| Firstly, consider taking out a [change detection monthly subscription - unlimited checks and watches](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!) | ||||
|  | ||||
| Or directly donate an amount PayPal [](https://www.paypal.com/donate/?hosted_button_id=7CP6HR9ZCNDYJ) | ||||
|  | ||||
| Or BTC `1PLFN327GyUarpJd7nVe7Reqg9qHx5frNn` | ||||
|  | ||||
| <img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/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 | ||||
|   | ||||
							
								
								
									
										21
									
								
								app.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "name": "ChangeDetection.io", | ||||
|   "description": "The best and simplest self-hosted open source website change detection monitoring and notification service.", | ||||
|   "keywords": [ | ||||
|     "changedetection", | ||||
|     "website monitoring" | ||||
|   ], | ||||
|   "repository": "https://github.com/dgtlmoon/changedetection.io", | ||||
|   "success_url": "/", | ||||
|   "scripts": { | ||||
|   }, | ||||
|   "env": { | ||||
|   }, | ||||
|   "formation": { | ||||
|     "web": { | ||||
|       "quantity": 1, | ||||
|       "size": "free" | ||||
|     } | ||||
|   }, | ||||
|   "image": "heroku/python" | ||||
| } | ||||
| @@ -1,489 +0,0 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
|  | ||||
| # @todo logging | ||||
| # @todo sort by last_changed | ||||
| # @todo extra options for url like , verify=False etc. | ||||
| # @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option? | ||||
| # @todo maybe a button to reset all 'last-changed'.. so you can see it clearly when something happens since your last visit | ||||
| # @todo option for interval day/6 hour/etc | ||||
| # @todo on change detected, config for calling some API | ||||
| # @todo make tables responsive! | ||||
| # @todo fetch title into json | ||||
| # https://distill.io/features | ||||
| # proxy per check | ||||
| #i | ||||
| import json | ||||
| import eventlet | ||||
| import eventlet.wsgi | ||||
|  | ||||
| import time | ||||
| import os | ||||
| import getopt | ||||
| import sys | ||||
| import datetime | ||||
| import timeago | ||||
|  | ||||
| import threading | ||||
| import queue | ||||
|  | ||||
|  | ||||
| from flask import Flask, render_template, request, send_file, send_from_directory, safe_join, abort, redirect, url_for | ||||
|  | ||||
|  | ||||
| # Local | ||||
| import store | ||||
| running_update_threads = [] | ||||
| ticker_thread = None | ||||
|  | ||||
| datastore = store.ChangeDetectionStore() | ||||
| messages = [] | ||||
| extra_stylesheets = [] | ||||
|  | ||||
| update_q = queue.Queue() | ||||
|  | ||||
|  | ||||
| app = Flask(__name__, static_url_path='/static') | ||||
| app.config['STATIC_RESOURCES'] = "/app/static" | ||||
| app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 | ||||
|  | ||||
| # app.config['SECRET_KEY'] = 'secret!' | ||||
|  | ||||
| # Disables caching of the templates | ||||
| app.config['TEMPLATES_AUTO_RELOAD'] = True | ||||
|  | ||||
|  | ||||
| # 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"): | ||||
|     if timestamp == 0: | ||||
|         return 'Not yet' | ||||
|     return timeago.format(timestamp, time.time()) | ||||
|     # return timeago.format(timestamp, time.time()) | ||||
|     # return datetime.datetime.utcfromtimestamp(timestamp).strftime(format) | ||||
|  | ||||
|  | ||||
| @app.route("/", methods=['GET']) | ||||
| def main_page(): | ||||
|     global messages | ||||
|  | ||||
|     limit_tag = request.args.get('tag') | ||||
|  | ||||
|     # 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() | ||||
|     output = render_template("watch-overview.html", | ||||
|                              watches=sorted_watches, | ||||
|                              messages=messages, | ||||
|                              tags=existing_tags, | ||||
|                              active_tag=limit_tag) | ||||
|  | ||||
|     # Show messages but once. | ||||
|     messages = [] | ||||
|     return output | ||||
|  | ||||
| @app.route("/scrub", methods=['GET', 'POST']) | ||||
| def scrub_page(): | ||||
|     from pathlib import Path | ||||
|  | ||||
|     global messages | ||||
|  | ||||
|     if request.method == 'POST': | ||||
|         confirmtext = request.form.get('confirmtext') | ||||
|  | ||||
|         if confirmtext == 'scrub': | ||||
|  | ||||
|             for txt_file_path in Path('/datastore').rglob('*.txt'): | ||||
|                 os.unlink(txt_file_path) | ||||
|  | ||||
|             for uuid, watch in datastore.data['watching'].items(): | ||||
|                 watch['last_checked'] = 0 | ||||
|                 watch['last_changed'] = 0 | ||||
|                 watch['previous_md5'] = None | ||||
|                 watch['history'] = {} | ||||
|  | ||||
|             datastore.needs_write = True | ||||
|             messages.append({'class': 'ok', 'message': 'Cleaned all version history.'}) | ||||
|         else: | ||||
|             messages.append({'class': 'error', 'message': 'Wrong confirm text.'}) | ||||
|  | ||||
|         return redirect(url_for('main_page')) | ||||
|  | ||||
|     return render_template("scrub.html") | ||||
|  | ||||
|  | ||||
| @app.route("/edit", methods=['GET', 'POST']) | ||||
| def edit_page(): | ||||
|     global messages | ||||
|     import validators | ||||
|  | ||||
|     if request.method == 'POST': | ||||
|         uuid = request.args.get('uuid') | ||||
|  | ||||
|         url = request.form.get('url').strip() | ||||
|         tag = request.form.get('tag').strip() | ||||
|  | ||||
|         form_headers = request.form.get('headers').strip().split("\n") | ||||
|         extra_headers = {} | ||||
|         if form_headers: | ||||
|             for header in form_headers: | ||||
|                 if len(header): | ||||
|                     parts = header.split(':', 1) | ||||
|                     extra_headers.update({parts[0].strip(): parts[1].strip()}) | ||||
|  | ||||
|         validators.url(url)  # @todo switch to prop/attr/observer | ||||
|         datastore.data['watching'][uuid].update({'url': url, | ||||
|                                                  'tag': tag, | ||||
|                                                  'headers': extra_headers}) | ||||
|         datastore.needs_write = True | ||||
|  | ||||
|         messages.append({'class': 'ok', 'message': 'Updated watch.'}) | ||||
|  | ||||
|         return redirect(url_for('main_page')) | ||||
|  | ||||
|     else: | ||||
|  | ||||
|         uuid = request.args.get('uuid') | ||||
|         output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid], messages=messages) | ||||
|  | ||||
|     return output | ||||
|  | ||||
|  | ||||
| @app.route("/settings", methods=['GET', "POST"]) | ||||
| def settings_page(): | ||||
|     global messages | ||||
|     if request.method == 'POST': | ||||
|         try: | ||||
|             minutes = int(request.values.get('minutes').strip()) | ||||
|         except ValueError: | ||||
|             messages.append({'class': 'error', 'message': "Invalid value given, use an integer."}) | ||||
|  | ||||
|         else: | ||||
|             if minutes >= 5 and minutes <= 600: | ||||
|                 datastore.data['settings']['requests']['minutes_between_check'] = minutes | ||||
|                 datastore.needs_write = True | ||||
|  | ||||
|                 messages.append({'class': 'ok', 'message': "Updated"}) | ||||
|             else: | ||||
|                 messages.append({'class': 'error', 'message': "Must be equal to or greater than 5 and less than 600 minutes"}) | ||||
|  | ||||
|     output = render_template("settings.html", messages=messages, minutes=datastore.data['settings']['requests']['minutes_between_check']) | ||||
|     messages =[] | ||||
|  | ||||
|     return output | ||||
|  | ||||
| @app.route("/import", methods=['GET', "POST"]) | ||||
| def import_page(): | ||||
|     import validators | ||||
|     global messages | ||||
|     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): | ||||
|                 datastore.add_watch(url=url.strip(), tag="") | ||||
|                 good += 1 | ||||
|             else: | ||||
|                 if len(url): | ||||
|                     remaining_urls.append(url) | ||||
|  | ||||
|         messages.append({'class': 'ok', 'message': "{} Imported, {} Skipped.".format(good, len(remaining_urls))}) | ||||
|  | ||||
|     output = render_template("import.html", | ||||
|                              messages=messages, | ||||
|                              remaining="\n".join(remaining_urls) | ||||
|                              ) | ||||
|     messages = [] | ||||
|     return output | ||||
|  | ||||
|  | ||||
| @app.route("/diff/<string:uuid>", methods=['GET']) | ||||
| def diff_history_page(uuid): | ||||
|     global messages | ||||
|  | ||||
|     extra_stylesheets=['/static/css/diff.css'] | ||||
|  | ||||
|     watch = datastore.data['watching'][uuid] | ||||
|  | ||||
|     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] | ||||
|  | ||||
|     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, | ||||
|                              messages=messages, | ||||
|                              newest=newest_version_file_contents, | ||||
|                              previous=previous_version_file_contents, | ||||
|                              extra_stylesheets=extra_stylesheets, | ||||
|                              versions=dates[1:], | ||||
|                              newest_version_timestamp=dates[0], | ||||
|                              current_previous_version=str(previous_version), | ||||
|                              current_diff_url=watch['url']) | ||||
|  | ||||
|     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']) | ||||
| def get_backup(): | ||||
|     import zipfile | ||||
|     from pathlib import Path | ||||
|     import zlib | ||||
|  | ||||
|     # 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()) | ||||
|  | ||||
|     with zipfile.ZipFile(os.path.join("/datastore", backupname), 'w', compression=zipfile.ZIP_DEFLATED, | ||||
|                          compresslevel=6) as zipObj: | ||||
|  | ||||
|         # Be sure we're written fresh | ||||
|         datastore.sync_to_json() | ||||
|  | ||||
|         # Add the index | ||||
|         zipObj.write(os.path.join("/datastore", "url-watches.json")) | ||||
|         # Add any snapshot data we find | ||||
|         for txt_file_path in Path('/datastore').rglob('*.txt'): | ||||
|             parent_p = txt_file_path.parent | ||||
|             if parent_p.name in uuids: | ||||
|                 zipObj.write(txt_file_path) | ||||
|  | ||||
|     return send_file(os.path.join("/datastore", backupname), | ||||
|                      as_attachment=True, | ||||
|                      mimetype="application/zip", | ||||
|                      attachment_filename=backupname) | ||||
|  | ||||
|  | ||||
|  | ||||
| # A few self sanity checks, mostly for developer/bug check | ||||
| @app.route("/self-check", methods=['GET']) | ||||
| def selfcheck(): | ||||
|     output = "All fine" | ||||
|     # In earlier versions before a single threaded write of the JSON store, sometimes histories could get mixed. | ||||
|     # Could also maybe affect people who manually fiddle with their JSON store? | ||||
|     for uuid, watch in datastore.data['watching'].items(): | ||||
|         for timestamp, path in watch['history'].items(): | ||||
|             # Each history snapshot should include a full path, which contains the {uuid} | ||||
|             if not uuid in path: | ||||
|                 output = "Something weird in {}, suspected incorrect snapshot path.".format(uuid) | ||||
|  | ||||
|     return output | ||||
|  | ||||
| @app.route("/static/<string:group>/<string:filename>", methods=['GET']) | ||||
| def static_content(group, filename): | ||||
|     try: | ||||
|         return send_from_directory("/app/static/{}".format(group), filename=filename) | ||||
|     except FileNotFoundError: | ||||
|         abort(404) | ||||
|  | ||||
|  | ||||
| @app.route("/api/add", methods=['POST']) | ||||
| def api_watch_add(): | ||||
|     global messages | ||||
|  | ||||
|     # @todo add_watch should throw a custom Exception for validation etc | ||||
|     new_uuid = datastore.add_watch(url=request.form.get('url').strip(), tag=request.form.get('tag').strip()) | ||||
|     # Straight into the queue. | ||||
|     update_q.put(new_uuid) | ||||
|  | ||||
|     messages.append({'class': 'ok', 'message': 'Watch added.'}) | ||||
|     return redirect(url_for('main_page')) | ||||
|  | ||||
|  | ||||
| @app.route("/api/delete", methods=['GET']) | ||||
| def api_delete(): | ||||
|     global messages | ||||
|     uuid = request.args.get('uuid') | ||||
|     datastore.delete(uuid) | ||||
|     messages.append({'class': 'ok', 'message': 'Deleted.'}) | ||||
|  | ||||
|     return redirect(url_for('main_page')) | ||||
|  | ||||
|  | ||||
| @app.route("/api/checknow", methods=['GET']) | ||||
| def api_watch_checknow(): | ||||
|     global messages | ||||
|  | ||||
|     tag = request.args.get('tag') | ||||
|     uuid = request.args.get('uuid') | ||||
|     i=0 | ||||
|  | ||||
|     if uuid: | ||||
|         update_q.put(uuid) | ||||
|         i = 1 | ||||
|  | ||||
|     elif tag != None: | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             if (tag != None and tag in watch['tag']): | ||||
|                 i += 1 | ||||
|                 update_q.put(watch_uuid) | ||||
|     else: | ||||
|         # No tag, no uuid, add everything. | ||||
|         for watch_uuid, watch in datastore.data['watching'].items(): | ||||
|             i += 1 | ||||
|             update_q.put(watch_uuid) | ||||
|  | ||||
|     messages.append({'class': 'ok', 'message': "{} watches are rechecking.".format(i)}) | ||||
|     return redirect(url_for('main_page', tag=tag)) | ||||
|  | ||||
|  | ||||
|  | ||||
| # Requests for checking on the site use a pool of thread Workers managed by a Queue. | ||||
| class Worker(threading.Thread): | ||||
|  | ||||
|     current_uuid = None | ||||
|  | ||||
|     def __init__(self, q, *args, **kwargs): | ||||
|         self.q = q | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def run(self): | ||||
|         import fetch_site_status | ||||
|  | ||||
|         try: | ||||
|             while True: | ||||
|                 uuid = self.q.get()  # Blocking | ||||
|                 self.current_uuid = uuid | ||||
|  | ||||
|                 if uuid in list(datastore.data['watching'].keys()): | ||||
|                     update_handler = fetch_site_status.perform_site_check(uuid=uuid, datastore=datastore) | ||||
|                     datastore.update_watch(uuid=uuid, update_obj=update_handler.update_data) | ||||
|  | ||||
|                 self.current_uuid = None  # Done | ||||
|                 self.q.task_done() | ||||
|  | ||||
|         except KeyboardInterrupt: | ||||
|             return | ||||
|  | ||||
| # Thread runner to check every minute, look for new watches to feed into the Queue. | ||||
| def ticker_thread_check_time_launch_checks(): | ||||
|  | ||||
|     # Spin up Workers. | ||||
|     for _ in range(datastore.data['settings']['requests']['workers']): | ||||
|         new_worker = Worker(update_q) | ||||
|         running_update_threads.append(new_worker) | ||||
|         new_worker.start() | ||||
|  | ||||
|     # Every minute check for new UUIDs to follow up on | ||||
|     while True: | ||||
|         minutes = datastore.data['settings']['requests']['minutes_between_check'] | ||||
|         for uuid, watch in datastore.data['watching'].items(): | ||||
|             if watch['last_checked'] <= time.time() - (minutes * 60): | ||||
|                 update_q.put(uuid) | ||||
|  | ||||
|         time.sleep(60) | ||||
|  | ||||
|  | ||||
| # 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(): | ||||
|     try: | ||||
|         while True: | ||||
|             if datastore.needs_write: | ||||
|                 datastore.sync_to_json() | ||||
|             time.sleep(5) | ||||
|  | ||||
|     except KeyboardInterrupt: | ||||
|         return | ||||
|  | ||||
| def main(argv): | ||||
|     ssl_mode = False | ||||
|     port = 5000 | ||||
|  | ||||
|     try: | ||||
|         opts, args = getopt.getopt(argv, "sp:", "purge") | ||||
|     except getopt.GetoptError: | ||||
|         print('backend.py -s SSL enable -p [port]') | ||||
|         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 = arg | ||||
|  | ||||
|     # @todo handle ctrl break | ||||
|     ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() | ||||
|  | ||||
|     save_data_thread = threading.Thread(target=save_datastore).start() | ||||
|  | ||||
|     # @todo finalise SSL config, but this should get you in the right direction if you need it. | ||||
|     if ssl_mode: | ||||
|         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) | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     main(sys.argv[1:]) | ||||
| @@ -1,16 +0,0 @@ | ||||
| FROM python:3.8-slim | ||||
|  | ||||
| # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| # Should be mounted from docker-compose-development.yml | ||||
| RUN pip3 install -r /requirements.txt | ||||
|  | ||||
|  | ||||
| RUN [ ! -d "/datastore" ] && mkdir /datastore | ||||
|  | ||||
| COPY sleep.py / | ||||
| CMD [ "python", "/sleep.py" ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -1,9 +0,0 @@ | ||||
| import time | ||||
| import sys | ||||
|  | ||||
| print ("Sleep loop, you should run your script from the console") | ||||
|  | ||||
| while True:  | ||||
|     # Wait for 5 seconds | ||||
|  | ||||
|     time.sleep(2) | ||||
| @@ -1,132 +0,0 @@ | ||||
| import time | ||||
| import requests | ||||
| import hashlib | ||||
| import os | ||||
| import re | ||||
| from inscriptis import get_text | ||||
|  | ||||
| # Some common stuff here that can be moved to a base class | ||||
| class perform_site_check(): | ||||
|  | ||||
|     # New state that is set after a check | ||||
|     # Return value dict | ||||
|     update_obj = {} | ||||
|  | ||||
|  | ||||
|     def __init__(self, *args, uuid=False, datastore, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.timestamp = int(time.time())  # used for storage etc too | ||||
|         self.uuid = uuid | ||||
|         self.datastore = datastore | ||||
|         self.url = datastore.get_val(uuid, 'url') | ||||
|         self.current_md5 = datastore.get_val(uuid, 'previous_md5') | ||||
|         self.output_path = "/datastore/{}".format(self.uuid) | ||||
|  | ||||
|         self.ensure_output_path() | ||||
|         self.run() | ||||
|  | ||||
|     # Current state of what needs to be updated | ||||
|     @property | ||||
|     def update_data(self): | ||||
|         return self.update_obj | ||||
|  | ||||
|     def save_firefox_screenshot(self, uuid, output): | ||||
|         # @todo call selenium or whatever | ||||
|         return | ||||
|  | ||||
|     def ensure_output_path(self): | ||||
|  | ||||
|         try: | ||||
|             os.stat(self.output_path) | ||||
|         except: | ||||
|             os.mkdir(self.output_path) | ||||
|  | ||||
|     def save_response_html_output(self, output): | ||||
|  | ||||
|         # @todo Saving the original HTML can be very large, better to set as an option, these files could be important to some. | ||||
|         with open("{}/{}.html".format(self.output_path, self.timestamp), 'w') as f: | ||||
|             f.write(output) | ||||
|             f.close() | ||||
|  | ||||
|     def save_response_stripped_output(self, output): | ||||
|         fname = "{}/{}.stripped.txt".format(self.output_path, self.timestamp) | ||||
|         with open(fname, 'w') as f: | ||||
|             f.write(output) | ||||
|             f.close() | ||||
|  | ||||
|         return fname | ||||
|  | ||||
|     def run(self): | ||||
|  | ||||
|         extra_headers = self.datastore.get_val(self.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: | ||||
|             r = requests.get(self.url, | ||||
|                              headers=request_headers, | ||||
|                              timeout=timeout, | ||||
|                              verify=False) | ||||
|  | ||||
|             stripped_text_from_html = get_text(r.text) | ||||
|  | ||||
|  | ||||
|  | ||||
|         # Usually from networkIO/requests level | ||||
|         except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as e: | ||||
|             self.update_obj["last_error"] = str(e) | ||||
|  | ||||
|             print(str(e)) | ||||
|  | ||||
|         except requests.exceptions.MissingSchema: | ||||
|             print("Skipping {} due to missing schema/bad url".format(self.uuid)) | ||||
|  | ||||
|         # Usually from html2text level | ||||
|         except UnicodeDecodeError as e: | ||||
|  | ||||
|             self.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. | ||||
|  | ||||
|             self.update_obj["last_check_status"] = r.status_code | ||||
|             self.update_obj["last_error"] = False | ||||
|  | ||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html.encode('utf-8')).hexdigest() | ||||
|  | ||||
|  | ||||
|             if self.current_md5 != fetched_md5: | ||||
|  | ||||
|                 # Don't confuse people by updating as last-changed, when it actually just changed from None.. | ||||
|                 if self.datastore.get_val(self.uuid, 'previous_md5') is not None: | ||||
|                     self.update_obj["last_changed"] = self.timestamp | ||||
|  | ||||
|                 self.update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|                 self.save_response_html_output(r.text) | ||||
|                 output_filepath = self.save_response_stripped_output(stripped_text_from_html) | ||||
|  | ||||
|                 # Update history with the stripped text for future reference, this will also mean we save the first | ||||
|                 timestamp = str(self.timestamp) | ||||
|                 self.update_obj.update({"history": {timestamp: output_filepath}}) | ||||
|  | ||||
|             self.update_obj["last_checked"] = self.timestamp | ||||
|  | ||||
| @@ -1,14 +0,0 @@ | ||||
|  | ||||
| from flask import make_response | ||||
| from functools import wraps, update_wrapper | ||||
| from datetime import datetime | ||||
|  | ||||
| def nocache(view): | ||||
|     @wraps(view) | ||||
|     def no_cache(*args, **kwargs): | ||||
|         response = make_response(view(*args, **kwargs)) | ||||
|         response.headers['hmm'] = datetime.now() | ||||
|  | ||||
|         return response | ||||
|  | ||||
|     return update_wrapper(no_cache, view) | ||||
| @@ -1,66 +0,0 @@ | ||||
| table { | ||||
| 	table-layout: fixed; | ||||
| 	width: 100%; | ||||
| } | ||||
| td { | ||||
| 	width: 33%; | ||||
| 	padding: 3px 4px; | ||||
| 	border: 1px solid transparent; | ||||
| 	vertical-align: top; | ||||
| 	font: 1em monospace; | ||||
| 	text-align: left; | ||||
| 	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%; | ||||
| } | ||||
| #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 */ | ||||
| 	} | ||||
| } | ||||
|  | ||||
| #diff-ui { | ||||
|     background: #fff; | ||||
|     padding: 2em; | ||||
|     margin: 1em; | ||||
|     border-radius: 5px; | ||||
|     font-size: 9px; | ||||
| } | ||||
| @@ -1,210 +0,0 @@ | ||||
| /* | ||||
|  * -- BASE STYLES -- | ||||
|  * Most of these are inherited from Base, but I want to change a few. | ||||
|  */ | ||||
| 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; | ||||
| } | ||||
|  | ||||
| .pure-table.watch-table td { | ||||
|   font-size: 80%; | ||||
| } | ||||
|  | ||||
| /* table related */ | ||||
| .watch-table { | ||||
|   width: 100%; | ||||
| } | ||||
| .watch-tag-list { | ||||
|   color: #e70069; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .box { | ||||
|   max-width: 80%; | ||||
|   flex-direction: column; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| .watch-table .error { | ||||
|   color: #a00; | ||||
| } | ||||
|  | ||||
| .watch-table td { | ||||
|   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, .current-diff-url::after { | ||||
|   content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); | ||||
|   margin: 0 3px 0 5px; | ||||
| } | ||||
|  | ||||
| #check-all-button { | ||||
|   text-align:right; | ||||
| } | ||||
|  | ||||
| #check-all-button 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%) | ||||
| } | ||||
|  | ||||
|  | ||||
| .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; | ||||
| } | ||||
| .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; | ||||
| } | ||||
| .button-tag.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 { | ||||
|     padding: 1em; | ||||
|     background: rgba(255,255,255,.2); | ||||
|     border-radius: 10px; | ||||
|     color: #fff; | ||||
|     font-weight: bold; | ||||
| } | ||||
|  | ||||
| .pure-form label  { | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| #new-watch-form { | ||||
|     background: rgba(0,0,0,.05); | ||||
|     padding: 1em; | ||||
|     border-radius: 10px; | ||||
|     margin-bottom: 1em; | ||||
| } | ||||
| #new-watch-form legend { | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| Before Width: | Height: | Size: 4.2 KiB | 
| Before Width: | Height: | Size: 43 KiB | 
							
								
								
									
										170
									
								
								backend/store.py
									
									
									
									
									
								
							
							
						
						| @@ -1,170 +0,0 @@ | ||||
| import json | ||||
| import uuid as uuid_builder | ||||
| import validators | ||||
| import os.path | ||||
| from os import path | ||||
| from threading import Lock, Thread | ||||
|  | ||||
|  | ||||
| # 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): | ||||
|         self.needs_write = False | ||||
|  | ||||
|         self.__data = { | ||||
|             'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", | ||||
|             'watching': {}, | ||||
|             'tag': "0.23", | ||||
|             '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 | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         # Base definition for all watchers | ||||
|         self.generic_definition = { | ||||
|             'url': None, | ||||
|             'tag': None, | ||||
|             'last_checked': 0, | ||||
|             'last_changed': 0, | ||||
|             'title': None, | ||||
|             'previous_md5': None, | ||||
|             'uuid': str(uuid_builder.uuid4()), | ||||
|             'headers': {},  # Extra headers to send | ||||
|             'history': {}  # Dict of timestamp and output stripped filename | ||||
|         } | ||||
|  | ||||
|         if path.isfile('/source.txt'): | ||||
|             with open('/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: | ||||
|             with open('/datastore/url-watches.json') 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 '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']) | ||||
|  | ||||
|                 # 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! | ||||
|                 i = 0 | ||||
|                 for uuid, watch in self.data['watching'].items(): | ||||
|                     _blank = self.generic_definition.copy() | ||||
|                     _blank.update(watch) | ||||
|                     self.__data['watching'].update({uuid: _blank}) | ||||
|                     print("Watching:", uuid, _blank['url']) | ||||
|  | ||||
|         # First time ran, doesnt exist. | ||||
|         except (FileNotFoundError, json.decoder.JSONDecodeError): | ||||
|             print("Creating JSON store") | ||||
|             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') | ||||
|  | ||||
|     def update_watch(self, uuid, update_obj): | ||||
|  | ||||
|         self.lock.acquire() | ||||
|  | ||||
|         # 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) and dict_key in update_obj: | ||||
|                 self.__data['watching'][uuid][dict_key].update(update_obj[dict_key]) | ||||
|                 del(update_obj[dict_key]) | ||||
|  | ||||
|         # Update with the remaining values | ||||
|         self.__data['watching'][uuid].update(update_obj) | ||||
|  | ||||
|         self.needs_write = True | ||||
|         self.lock.release() | ||||
|  | ||||
|     @property | ||||
|     def data(self): | ||||
|         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 not tag in tags: | ||||
|                     tags.append(tag) | ||||
|  | ||||
|         tags.sort() | ||||
|         return tags | ||||
|  | ||||
|     def delete(self, uuid): | ||||
|  | ||||
|         self.lock.acquire() | ||||
|         del (self.__data['watching'][uuid]) | ||||
|         self.needs_write = True | ||||
|         self.lock.release() | ||||
|  | ||||
|     def url_exists(self, url): | ||||
|  | ||||
|         # Probably their should be dict... | ||||
|         for watch in self.data['watching']: | ||||
|             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) | ||||
|  | ||||
|     def add_watch(self, url, tag): | ||||
|         self.lock.acquire() | ||||
|         print("Adding", url, tag) | ||||
|         #        # @todo deal with exception | ||||
|         #        validators.url(url) | ||||
|  | ||||
|         # @todo use a common generic version of this | ||||
|         new_uuid = str(uuid_builder.uuid4()) | ||||
|         _blank = self.generic_definition.copy() | ||||
|         _blank.update({ | ||||
|             'url': url, | ||||
|             'tag': tag, | ||||
|             'uuid': new_uuid | ||||
|         }) | ||||
|  | ||||
|         self.data['watching'][new_uuid] = _blank | ||||
|         self.needs_write = True | ||||
|         self.lock.release() | ||||
|         return new_uuid | ||||
|  | ||||
|     def sync_to_json(self): | ||||
|         print("Saving index") | ||||
|         self.lock.acquire() | ||||
|         with open('/datastore/url-watches.json', 'w') as json_file: | ||||
|             json.dump(self.data, json_file, indent=4) | ||||
|         self.needs_write = False | ||||
|         self.lock.release() | ||||
|  | ||||
| # body of the constructor | ||||
| @@ -1,70 +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</title> | ||||
|     <link rel="stylesheet" href="/static/css/pure-min.css"> | ||||
|     <link rel="stylesheet" href="/static/css/styles.css?ver=1000"> | ||||
|     {% 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"> | ||||
|         <a class="pure-menu-heading" href="/"><strong>Change</strong>Detection.io</a> | ||||
|         {% if current_diff_url %} | ||||
|             <a class=current-diff-url href="{{ current_diff_url }}"><span style="max-width: 30%; overflow: hidden;">{{ current_diff_url }}</a> | ||||
|         {% endif %} | ||||
|  | ||||
|         <ul class="pure-menu-list"> | ||||
|  | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="/backup" class="pure-menu-link">BACKUP</a> | ||||
|             </li> | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="/import" class="pure-menu-link">IMPORT</a> | ||||
|             </li> | ||||
|             <li class="pure-menu-item"> | ||||
|                 <a href="/settings" class="pure-menu-link">SETTINGS</a> | ||||
|             </li> | ||||
|             <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> | ||||
|             <!-- | ||||
|             <li class="pure-menu-item"><a href="#" class="pure-menu-link">Tour</a></li> | ||||
|             <li class="pure-menu-item"><a href="#" class="pure-menu-link">Sign Up</a></li> | ||||
|             --> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <section class="content"> | ||||
|     <header> | ||||
|         {% block header %}{% endblock %} | ||||
|     </header> | ||||
|  | ||||
|     {% if messages %} | ||||
|     <div class="messages"> | ||||
|         {% for message in messages %} | ||||
|         <div class="flash-message {{ message['class'] }}">{{ message['message'] }}</div> | ||||
|         {% endfor %} | ||||
|     </div> | ||||
|     {% endif %} | ||||
|  | ||||
|  | ||||
|     {% block content %} | ||||
|  | ||||
|     {% endblock %} | ||||
| </section> | ||||
| </body> | ||||
| </html> | ||||
| @@ -1,140 +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-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> | ||||
|                 <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.appendChild(document.createTextNode(diff[i].value)); | ||||
| 		} else if (diff[i].added) { | ||||
| 			node = document.createElement('ins'); | ||||
| 			node.appendChild(document.createTextNode(diff[i].value)); | ||||
| 		} else { | ||||
| 			node = document.createTextNode(diff[i].value); | ||||
| 		} | ||||
| 		fragment.appendChild(node); | ||||
| 	} | ||||
|  | ||||
| 	result.textContent = ''; | ||||
| 	result.appendChild(fragment); | ||||
| } | ||||
|  | ||||
| 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; | ||||
| 	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(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,55 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|  | ||||
|  | ||||
|     <form class="pure-form pure-form-stacked" action="/edit?uuid={{uuid}}" method="POST"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="url">URL</label> | ||||
|                 <input type="url" id="url" required="" placeholder="https://..." name="url" value="{{ watch.url}}" | ||||
|                        size="50"/> | ||||
|                 <span class="pure-form-message-inline">This is a required field.</span> | ||||
|             </div> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="tag">Tag</label> | ||||
|                 <input type="text" placeholder="tag" size="10" id="tag" name="tag" value="{{ watch.tag}}"/> | ||||
|                 <span class="pure-form-message-inline">Grouping tags, can be a comma separated list.</span> | ||||
|             </div> | ||||
|  | ||||
|             <fieldset class="pure-group"> | ||||
|                 <label for="headers">Extra request headers</label> | ||||
|  | ||||
|                 <textarea id=headers name="headers" class="pure-input-1-2" placeholder="Example | ||||
| Cookie: foobar | ||||
| User-Agent: wonderbra 1.0" | ||||
|                           style="width: 100%; | ||||
|                             font-family:monospace; | ||||
|                             white-space: pre; | ||||
|                             overflow-wrap: normal; | ||||
|                             overflow-x: scroll;" rows="5">{% for key, value in watch.headers.items() %}{{ key }}: {{ value }} | ||||
| {% endfor %}</textarea> | ||||
|                 <br/> | ||||
|  | ||||
|             </fieldset> | ||||
|             <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="/" class="pure-button button-small button-cancel">Cancel</a> | ||||
|                 <a href="/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="/import" 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,43 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|  | ||||
|  | ||||
|     <form class="pure-form pure-form-stacked" action="/scrub" 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/> | ||||
|  | ||||
|                 Type in the word <strong>scrub</strong> to confirm that you understand! | ||||
|                 <br/> | ||||
|  | ||||
|             </div> | ||||
|  | ||||
|             <div class="pure-control-group"> | ||||
|                 <br/> | ||||
|                 <label for="confirmtext">Confirm</label><br/> | ||||
|                 <input type="text" id="confirmtext" required="" name="confirmtext" value="" size="10"/> | ||||
|                 <br/> | ||||
|  | ||||
|  | ||||
|             </div> | ||||
|  | ||||
|  | ||||
|             <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="/" class="pure-button button-small button-cancel">Cancel</a> | ||||
|             </div> | ||||
|  | ||||
|  | ||||
|         </fieldset> | ||||
|     </form> | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,35 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="edit-form"> | ||||
|  | ||||
|  | ||||
|     <form class="pure-form pure-form-stacked" action="/settings" method="POST"> | ||||
|         <fieldset> | ||||
|             <div class="pure-control-group"> | ||||
|                 <label for="minutes">Maximum time in minutes until recheck.</label> | ||||
|                 <input type="text" id="minutes" required="" name="minutes" value="{{minutes}}" | ||||
|                        size="5"/> | ||||
|                 <span class="pure-form-message-inline">This is a required field.</span> | ||||
|             </div> | ||||
|  | ||||
|  | ||||
|             <br/> | ||||
|             <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="/" class="pure-button button-small button-cancel">Back</a> | ||||
|                 <a href="/scrub" class="pure-button button-small button-cancel">Reset all version data</a> | ||||
|             </div> | ||||
|  | ||||
|  | ||||
|         </fieldset> | ||||
|     </form> | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -1,75 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="box"> | ||||
|  | ||||
|     <form class="pure-form" action="/api/add" method="POST" id="new-watch-form"> | ||||
|         <fieldset> | ||||
|             <legend>Add a new change detection watch</legend> | ||||
|             <input type="url" placeholder="https://..." name="url"/> | ||||
|             <input type="text" placeholder="tag" size="10" name="tag" value="{{active_tag if active_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> | ||||
|  | ||||
|         {% for tag in tags %} | ||||
|             {% if tag == "" %} | ||||
|             <a  href="/" class="pure-button button-tag {{'active' if active_tag == tag }}">All</a> | ||||
|             {% else %} | ||||
|             <a  href="/?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>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 %}"> | ||||
|                 <td>{{ loop.index }}</td> | ||||
|                 <td class="title-col">{{watch.title if watch.title is not none else watch.url}} | ||||
|                     <a class="external" target=_blank 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>{{watch|format_last_checked_time}}</td> | ||||
|                 <td>{{watch.last_changed|format_timestamp_timeago}}</td> | ||||
|                 <td><a href="/api/checknow?uuid={{ watch.uuid}}{% if request.args.get('tag') %}&tag={{request.args.get('tag')}}{% endif %}" class="pure-button button-small pure-button-primary">Recheck</a> | ||||
|                     <a href="/edit?uuid={{ watch.uuid}}" class="pure-button button-small pure-button-primary">Edit</a> | ||||
|                     {% if watch.history|length >= 2 %} | ||||
|                     <a href="/diff/{{ watch.uuid}}" class="pure-button button-small pure-button-primary">Diff</a> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|             </tr> | ||||
|             {% endfor %} | ||||
|  | ||||
|  | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <div id="check-all-button"> | ||||
|  | ||||
|             <a href="/api/checknow{% if active_tag%}?tag={{active_tag}}{%endif%}" class="pure-button button-tag " >Recheck all {% if active_tag%}in "{{active_tag}}"{%endif%}</a> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										44
									
								
								changedetection.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| # Entry-point for running from the CLI when not installed via Pip, Pip will handle the console_scripts entry_points's from setup.py | ||||
| # It's recommended to use `pip3 install changedetection.io` and start with `changedetection.py` instead, it will be linkd to your global path. | ||||
| # or Docker. | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| from changedetectionio import changedetection | ||||
| import multiprocessing | ||||
| import sys | ||||
| import os | ||||
|  | ||||
| def sigchld_handler(_signo, _stack_frame): | ||||
|     import sys | ||||
|     print('Shutdown: Got SIGCHLD') | ||||
|     # https://stackoverflow.com/questions/40453496/python-multiprocessing-capturing-signals-to-restart-child-processes-or-shut-do | ||||
|     pid, status = os.waitpid(-1, os.WNOHANG | os.WUNTRACED | os.WCONTINUED) | ||||
|  | ||||
|     print('Sub-process: pid %d status %d' % (pid, status)) | ||||
|     if status != 0: | ||||
|         sys.exit(1) | ||||
|  | ||||
|     raise SystemExit | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|  | ||||
|     #signal.signal(signal.SIGCHLD, sigchld_handler) | ||||
|  | ||||
|     # The only way I could find to get Flask to shutdown, is to wrap it and then rely on the subsystem issuing SIGTERM/SIGKILL | ||||
|     parse_process = multiprocessing.Process(target=changedetection.main) | ||||
|     parse_process.daemon = True | ||||
|     parse_process.start() | ||||
|     import time | ||||
|  | ||||
|     try: | ||||
|         while True: | ||||
|             time.sleep(1) | ||||
|             if not parse_process.is_alive(): | ||||
|                 # Process died/crashed for some reason, exit with error set | ||||
|                 sys.exit(1) | ||||
|  | ||||
|     except KeyboardInterrupt: | ||||
|         #parse_process.terminate() not needed, because this process will issue it to the sub-process anyway | ||||
|         print ("Exited - CTRL+C") | ||||
							
								
								
									
										2
									
								
								changedetectionio/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| test-datastore | ||||
| package-lock.json | ||||
							
								
								
									
										1712
									
								
								changedetectionio/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										0
									
								
								changedetectionio/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										117
									
								
								changedetectionio/api/api_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,117 @@ | ||||
| # 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 | ||||
|  | ||||
| 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"}, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     from changedetectionio.notification import valid_notification_formats | ||||
|  | ||||
|     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() | ||||
|  | ||||
|     # headers ? | ||||
|     return schema | ||||
|  | ||||
							
								
								
									
										345
									
								
								changedetectionio/api/api_v1.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,345 @@ | ||||
| import os | ||||
| from distutils.util 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 | ||||
|  | ||||
| # See docs/README.md for rebuilding the docs/apidoc information | ||||
|  | ||||
| from . import api_schema | ||||
|  | ||||
| # Build a JSON Schema atleast partially based on our Watch model | ||||
| from changedetectionio.model.Watch import base_config as watch_base_config | ||||
| 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 | ||||
|  | ||||
| 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:4000/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:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091  -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:4000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted"  -H"x-api-key:813031b16330fe25e3780cf0325daa45" | ||||
|             curl "http://localhost:4000/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, 'skip_when_checksum_same': True})) | ||||
|             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 | ||||
|         watch['last_changed'] = watch.last_changed | ||||
|  | ||||
|         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:4000/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:4000/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:4000/api/v1/watch/<string:uuid>/history | ||||
|     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:4000/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:4000/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 | ||||
|         @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)) | ||||
|  | ||||
|         if not len(watch.history): | ||||
|             abort(404, message='Watch found but no history exists for the UUID {}'.format(uuid)) | ||||
|  | ||||
|         if timestamp == 'latest': | ||||
|             timestamp = list(watch.history.keys())[-1] | ||||
|  | ||||
|         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:4000/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, 'skip_when_checksum_same': True})) | ||||
|             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:4000/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] = {'url': watch['url'], | ||||
|                        'title': watch['title'], | ||||
|                        'last_checked': watch['last_checked'], | ||||
|                        'last_changed': watch.last_changed, | ||||
|                        'last_error': watch['last_error']} | ||||
|  | ||||
|         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, 'skip_when_checksum_same': True})) | ||||
|             return {'status': "OK"}, 200 | ||||
|  | ||||
|         return list, 200 | ||||
|  | ||||
| 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:4000/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 | ||||
							
								
								
									
										33
									
								
								changedetectionio/api/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | ||||
| 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') | ||||
|         if not config_api_token_enabled: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             api_key_header = request.headers['x-api-key'] | ||||
|         except KeyError: | ||||
|             return make_response( | ||||
|                 jsonify("No authorization x-api-key header."), 403 | ||||
|             ) | ||||
|  | ||||
|         config_api_token = datastore.data['settings']['application'].get('api_access_token') | ||||
|  | ||||
|         if api_key_header != config_api_token: | ||||
|             return make_response( | ||||
|                 jsonify("Invalid access - API key invalid."), 403 | ||||
|             ) | ||||
|  | ||||
|         return f(*args, **kwargs) | ||||
|  | ||||
|     return decorated | ||||
							
								
								
									
										11
									
								
								changedetectionio/apprise_asset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| import apprise | ||||
|  | ||||
| # Create our AppriseAsset and populate it with some of our new values: | ||||
| # https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object | ||||
| asset = apprise.AppriseAsset( | ||||
|    image_url_logo='https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' | ||||
| ) | ||||
|  | ||||
| asset.app_id = "changedetection.io" | ||||
| asset.app_desc = "ChangeDetection.io best and simplest website monitoring and change detection" | ||||
| asset.app_url = "https://changedetection.io" | ||||
							
								
								
									
										0
									
								
								changedetectionio/blueprint/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										242
									
								
								changedetectionio/blueprint/browser_steps/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,242 @@ | ||||
|  | ||||
| # 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() | ||||
| # - browserless has 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) | ||||
| # | ||||
| # Bigger picture | ||||
| # - It's horrible that we have this click+wait deal, some nice socket.io solution using something similar | ||||
| # to what the browserless debug UI already gives us would be smarter.. | ||||
| # | ||||
| # OR | ||||
| # - Some API call that should be hacked into browserless or playwright that we can "/api/bump-keepalive/{session_id}/60" | ||||
| # So we can tell it that we need more time (run this on each action) | ||||
| # | ||||
| # OR | ||||
| # - use multiprocessing to bump this over to its own process and add some transport layer (queue/pipes) | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from flask import Blueprint, request, make_response | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
|  | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio import login_optionally_required | ||||
|  | ||||
| browsersteps_sessions = {} | ||||
| io_interface_context = None | ||||
|  | ||||
|  | ||||
| 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 browsersteps_sessions | ||||
|         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', '') | ||||
|         a = "?" if not '?' in base_url else '&' | ||||
|         base_url += a + f"timeout={keepalive_ms}" | ||||
|  | ||||
|         try: | ||||
|             browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(base_url) | ||||
|         except Exception as e: | ||||
|             if 'ECONNREFUSED' in str(e): | ||||
|                 return make_response('Unable to start the Playwright Browser session, is it running?', 401) | ||||
|             else: | ||||
|                 # Other errors, bad URL syntax, bad reply etc | ||||
|                 return make_response(str(e), 401) | ||||
|  | ||||
|         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 | ||||
|  | ||||
|                 print("Browser Steps: UUID {} selected proxy {}".format(watch_uuid, 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) | ||||
|  | ||||
|         # 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 | ||||
|         global browsersteps_sessions | ||||
|  | ||||
|         browsersteps_session_id = str(uuid.uuid4()) | ||||
|         watch_uuid = request.args.get('uuid') | ||||
|  | ||||
|         if not watch_uuid: | ||||
|             return make_response('No Watch UUID specified', 500) | ||||
|  | ||||
|         print("Starting connection with playwright") | ||||
|         logging.debug("browser_steps.py connecting") | ||||
|         browsersteps_sessions[browsersteps_session_id] = start_browsersteps_session(watch_uuid) | ||||
|         print("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._api_types | ||||
|         global browsersteps_sessions | ||||
|         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) | ||||
|  | ||||
|  | ||||
|         # 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') | ||||
|             step_n = int(request.form.get('step_n')) | ||||
|             is_last_step = strtobool(request.form.get('is_last_step')) | ||||
|  | ||||
|             if step_operation == 'Goto site': | ||||
|                 step_operation = 'goto_url' | ||||
|                 step_optional_value = datastore.data['watching'][uuid].get('url') | ||||
|                 step_selector = None | ||||
|  | ||||
|             # @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: | ||||
|                 print("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) | ||||
|  | ||||
|             # Get visual selector ready/update its data (also use the current filter info from the page?) | ||||
|             # When the last 'apply' button was pressed | ||||
|             # @todo this adds overhead because the xpath selection is happening twice | ||||
|             u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url | ||||
|             if is_last_step and u: | ||||
|                 (screenshot, xpath_data) = browsersteps_sessions[browsersteps_session_id]['browserstepper'].request_visualselector_data() | ||||
|                 datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot) | ||||
|                 datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data) | ||||
|  | ||||
| #        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: | ||||
|             state = browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state() | ||||
|         except playwright._impl._api_types.Error as e: | ||||
|             return make_response("Browser session ran out of time :( Please reload this page."+str(e), 401) | ||||
|  | ||||
|         # Use send_file() which is way faster than read/write loop on bytes | ||||
|         import json | ||||
|         from tempfile import mkstemp | ||||
|         from flask import send_file | ||||
|         tmp_fd, tmp_file = mkstemp(text=True, suffix=".json", prefix="changedetectionio-") | ||||
|  | ||||
|         output = json.dumps({'screenshot': "data:image/jpeg;base64,{}".format( | ||||
|             base64.b64encode(state[0]).decode('ascii')), | ||||
|             'xpath_data': state[1], | ||||
|             'session_age_start': browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start, | ||||
|             'browser_time_remaining': round(remaining) | ||||
|         }) | ||||
|  | ||||
|         with os.fdopen(tmp_fd, 'w') as f: | ||||
|             f.write(output) | ||||
|  | ||||
|         response = make_response(send_file(path_or_file=tmp_file, | ||||
|                                            mimetype='application/json; charset=UTF-8', | ||||
|                                            etag=True)) | ||||
|         # No longer needed | ||||
|         os.unlink(tmp_file) | ||||
|  | ||||
|         return response | ||||
|  | ||||
|     return browser_steps_blueprint | ||||
|  | ||||
|  | ||||
							
								
								
									
										283
									
								
								changedetectionio/blueprint/browser_steps/browser_steps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,283 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| import os | ||||
| import time | ||||
| import re | ||||
| from random import randint | ||||
|  | ||||
| # 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', | ||||
|                           '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', | ||||
|                           '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', | ||||
|                           #                          '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 | ||||
|  | ||||
|     # Convert and perform "Click Button" for example | ||||
|     def call_action(self, action_name, selector=None, optional_value=None): | ||||
|         now = time.time() | ||||
|         call_action_name = re.sub('[^0-9a-zA-Z]+', '_', action_name.lower()) | ||||
|         if call_action_name == 'choose_one': | ||||
|             return | ||||
|  | ||||
|         print("> 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 | ||||
|  | ||||
|         action_handler = getattr(self, "action_" + call_action_name) | ||||
|  | ||||
|         # Support for Jinja2 variables in the value and selector | ||||
|         from jinja2 import Environment | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|  | ||||
|         if selector and ('{%' in selector or '{{' in selector): | ||||
|             selector = str(jinja2_env.from_string(selector).render()) | ||||
|  | ||||
|         if optional_value and ('{%' in optional_value or '{{' in optional_value): | ||||
|             optional_value = str(jinja2_env.from_string(optional_value).render()) | ||||
|  | ||||
|         action_handler(selector, optional_value) | ||||
|         self.page.wait_for_timeout(1.5 * 1000) | ||||
|         print("Call action done in", time.time() - now) | ||||
|  | ||||
|     def action_goto_url(self, selector=None, value=None): | ||||
|         # self.page.set_viewport_size({"width": 1280, "height": 5000}) | ||||
|         now = time.time() | ||||
|         response = self.page.goto(value, timeout=0, wait_until='load') | ||||
|         # Should be the same as the puppeteer_fetch.js methods, means, load with no timeout set (skip timeout) | ||||
|         #and also wait for seconds ? | ||||
|         #await page.waitForTimeout(1000); | ||||
|         #await page.waitForTimeout(extra_wait_ms); | ||||
|         print("Time to goto URL ", time.time() - now) | ||||
|         return response | ||||
|  | ||||
|     def action_click_element_containing_text(self, selector=None, value=''): | ||||
|         if not len(value.strip()): | ||||
|             return | ||||
|         elem = self.page.get_by_text(value) | ||||
|         if elem.count(): | ||||
|             elem.first.click(delay=randint(200, 500), timeout=3000) | ||||
|  | ||||
|     def action_enter_text_in_field(self, selector, value): | ||||
|         if not len(selector.strip()): | ||||
|             return | ||||
|  | ||||
|         self.page.fill(selector, value, timeout=10 * 1000) | ||||
|  | ||||
|     def action_execute_js(self, selector, value): | ||||
|         response = self.page.evaluate(value) | ||||
|         return response | ||||
|  | ||||
|     def action_click_element(self, selector, value): | ||||
|         print("Clicking element") | ||||
|         if not len(selector.strip()): | ||||
|             return | ||||
|  | ||||
|         self.page.click(selector=selector, timeout=30 * 1000, delay=randint(200, 500)) | ||||
|  | ||||
|     def action_click_element_if_exists(self, selector, value): | ||||
|         import playwright._impl._api_types as _api_types | ||||
|         print("Clicking element if exists") | ||||
|         if not len(selector.strip()): | ||||
|             return | ||||
|         try: | ||||
|             self.page.click(selector, timeout=10 * 1000, delay=randint(200, 500)) | ||||
|         except _api_types.TimeoutError as e: | ||||
|             return | ||||
|         except _api_types.Error as e: | ||||
|             # Element was there, but page redrew and now its long long gone | ||||
|             return | ||||
|  | ||||
|     def action_click_x_y(self, selector, value): | ||||
|         x, y = value.strip().split(',') | ||||
|         x = int(float(x.strip())) | ||||
|         y = int(float(y.strip())) | ||||
|         self.page.mouse.click(x=x, y=y, delay=randint(200, 500)) | ||||
|  | ||||
|     def action_scroll_down(self, selector, value): | ||||
|         # Some sites this doesnt work on for some reason | ||||
|         self.page.mouse.wheel(0, 600) | ||||
|         self.page.wait_for_timeout(1000) | ||||
|  | ||||
|     def action_wait_for_seconds(self, selector, value): | ||||
|         self.page.wait_for_timeout(float(value.strip()) * 1000) | ||||
|  | ||||
|     def action_wait_for_text(self, selector, value): | ||||
|         import json | ||||
|         v = json.dumps(value) | ||||
|         self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=30000) | ||||
|  | ||||
|     def action_wait_for_text_in_element(self, selector, value): | ||||
|         import json | ||||
|         s = json.dumps(selector) | ||||
|         v = json.dumps(value) | ||||
|         self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=30000) | ||||
|  | ||||
|     # @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): | ||||
|         self.page.keyboard.press("Enter", delay=randint(200, 500)) | ||||
|  | ||||
|     def action_press_page_up(self, selector, value): | ||||
|         self.page.keyboard.press("PageUp", delay=randint(200, 500)) | ||||
|  | ||||
|     def action_press_page_down(self, selector, value): | ||||
|         self.page.keyboard.press("PageDown", delay=randint(200, 500)) | ||||
|  | ||||
|     def action_check_checkbox(self, selector, value): | ||||
|         self.page.locator(selector).check(timeout=1000) | ||||
|  | ||||
|     def action_uncheck_checkbox(self, selector, value): | ||||
|         self.page.locator(selector, timeout=1000).uncheck(timeout=1000) | ||||
|  | ||||
|  | ||||
| # Responsible for maintaining a live 'context' with browserless | ||||
| # @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 | ||||
|  | ||||
|     # 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): | ||||
|         self.age_start = time.time() | ||||
|         self.playwright_browser = playwright_browser | ||||
|         if self.context is None: | ||||
|             self.connect(proxy=proxy) | ||||
|  | ||||
|     # 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( | ||||
|             # @todo | ||||
|             #                user_agent=request_headers['User-Agent'] if request_headers.get('User-Agent') else 'Mozilla/5.0', | ||||
|             #               proxy=self.proxy, | ||||
|             # This is needed to enable JavaScript execution on GitHub and others | ||||
|             bypass_csp=True, | ||||
|             # Should never be needed | ||||
|             accept_downloads=False, | ||||
|             proxy=proxy | ||||
|         ) | ||||
|  | ||||
|         self.page = self.context.new_page() | ||||
|  | ||||
|         # self.page.set_default_navigation_timeout(keep_open) | ||||
|         self.page.set_default_timeout(keep_open) | ||||
|         # @todo probably this doesnt work | ||||
|         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}")) | ||||
|  | ||||
|         print("Time to browser setup", time.time() - now) | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|     def mark_as_closed(self): | ||||
|         print("Page closed, cleaning up..") | ||||
|  | ||||
|     @property | ||||
|     def has_expired(self): | ||||
|         if not self.page: | ||||
|             return True | ||||
|  | ||||
|  | ||||
|     def get_current_state(self): | ||||
|         """Return the screenshot and interactive elements mapping, generally always called after action_()""" | ||||
|         from pkg_resources import resource_string | ||||
|         xpath_element_js = resource_string(__name__, "../../res/xpath_element_scraper.js").decode('utf-8') | ||||
|         now = time.time() | ||||
|         self.page.wait_for_timeout(1 * 1000) | ||||
|  | ||||
|         # The actual screenshot | ||||
|         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=40) | ||||
|  | ||||
|         self.page.evaluate("var include_filters=''") | ||||
|         # Go find the interactive elements | ||||
|         # @todo in the future, something smarter that can scan for elements with .click/focus etc event handlers? | ||||
|         elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span' | ||||
|         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', elements) | ||||
|         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||
|         # So the JS will find the smallest one first | ||||
|         xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True) | ||||
|         print("Time to complete get_current_state of browser", time.time() - now) | ||||
|         # except | ||||
|         # playwright._impl._api_types.Error: Browser closed. | ||||
|         # @todo show some countdown timer? | ||||
|         return (screenshot, xpath_data) | ||||
|  | ||||
|     def request_visualselector_data(self): | ||||
|         """ | ||||
|         Does the same that the playwright operation in content_fetcher does | ||||
|         This is used to just bump the VisualSelector data so it' ready to go if they click on the tab | ||||
|         @todo refactor and remove duplicate code, add include_filters | ||||
|         :param xpath_data: | ||||
|         :param screenshot: | ||||
|         :param current_include_filters: | ||||
|         :return: | ||||
|         """ | ||||
|  | ||||
|         self.page.evaluate("var include_filters=''") | ||||
|         from pkg_resources import resource_string | ||||
|         # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector | ||||
|         xpath_element_js = resource_string(__name__, "../../res/xpath_element_scraper.js").decode('utf-8') | ||||
|         from changedetectionio.content_fetcher import visualselector_xpath_selectors | ||||
|         xpath_element_js = xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||
|         xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}") | ||||
|         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|  | ||||
|         return (screenshot, xpath_data) | ||||
							
								
								
									
										18
									
								
								changedetectionio/blueprint/browser_steps/nonContext.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| from playwright.sync_api import PlaywrightContextManager | ||||
| import asyncio | ||||
|  | ||||
| # 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() | ||||
							
								
								
									
										118
									
								
								changedetectionio/blueprint/check_proxies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,118 @@ | ||||
| from concurrent.futures import ThreadPoolExecutor | ||||
|  | ||||
| from functools import wraps | ||||
|  | ||||
| from flask import Blueprint | ||||
| from flask_login import login_required | ||||
|  | ||||
| from changedetectionio.processors import text_json_diff | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
|  | ||||
|  | ||||
| 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 import content_fetcher | ||||
|  | ||||
|         status = {'status': '', 'length': 0, 'text': ''} | ||||
|         from jinja2 import Environment, BaseLoader | ||||
|  | ||||
|         contents = '' | ||||
|         now = time.time() | ||||
|         try: | ||||
|             update_handler = text_json_diff.perform_site_check(datastore=datastore, watch_uuid=uuid) | ||||
|             update_handler.call_browser() | ||||
|         # title, size is len contents not len xfer | ||||
|         except content_fetcher.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 text_json_diff.FilterNotFoundInResponse: | ||||
|             status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"}) | ||||
|         except content_fetcher.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.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'): | ||||
|             status['text'] = Environment(loader=BaseLoader()).from_string('{{text|e}}').render({'text': status['text']}) | ||||
|  | ||||
|         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 | ||||
							
								
								
									
										33
									
								
								changedetectionio/blueprint/price_data_follower/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | ||||
|  | ||||
| from distutils.util 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 | ||||
|         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False})) | ||||
|         return redirect(url_for("form_watch_checknow", uuid=uuid)) | ||||
|  | ||||
|  | ||||
|     @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("index")) | ||||
|  | ||||
|  | ||||
|     return price_data_follower_blueprint | ||||
|  | ||||
|  | ||||
							
								
								
									
										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 | ||||
							
								
								
									
										141
									
								
								changedetectionio/blueprint/tags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,141 @@ | ||||
| from flask import Blueprint, request, make_response, render_template, flash, url_for, redirect | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio 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) | ||||
|         output = render_template("groups-overview.html", | ||||
|                                  form=add_form, | ||||
|                                  available_tags=datastore.data['settings']['application'].get('tags', {}), | ||||
|                                  ) | ||||
|  | ||||
|         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 import forms | ||||
|  | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['settings']['application']['tags'].keys()).pop() | ||||
|  | ||||
|         default = datastore.data['settings']['application']['tags'].get(uuid) | ||||
|  | ||||
|         form = forms.watchForm(formdata=request.form if request.method == 'POST' else None, | ||||
|                                data=default, | ||||
|                                ) | ||||
|         form.datastore=datastore # needed? | ||||
|  | ||||
|         output = render_template("edit-tag.html", | ||||
|                                  data=default, | ||||
|                                  form=form, | ||||
|                                  settings_application=datastore.data['settings']['application'], | ||||
|                                  ) | ||||
|  | ||||
|         return output | ||||
|  | ||||
|  | ||||
|     @tags_blueprint.route("/edit/<string:uuid>", methods=['POST']) | ||||
|     @login_optionally_required | ||||
|     def form_tag_edit_submit(uuid): | ||||
|         from changedetectionio import forms | ||||
|         if uuid == 'first': | ||||
|             uuid = list(datastore.data['settings']['application']['tags'].keys()).pop() | ||||
|  | ||||
|         default = datastore.data['settings']['application']['tags'].get(uuid) | ||||
|  | ||||
|         form = forms.watchForm(formdata=request.form if request.method == 'POST' else None, | ||||
|                                data=default, | ||||
|                                ) | ||||
|         # @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.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 | ||||
							
								
								
									
										22
									
								
								changedetectionio/blueprint/tags/form.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| from wtforms import ( | ||||
|     BooleanField, | ||||
|     Form, | ||||
|     IntegerField, | ||||
|     RadioField, | ||||
|     SelectField, | ||||
|     StringField, | ||||
|     SubmitField, | ||||
|     TextAreaField, | ||||
|     validators, | ||||
| ) | ||||
|  | ||||
|  | ||||
|  | ||||
| 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"}) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										135
									
								
								changedetectionio/blueprint/tags/templates/edit-tag.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,135 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %} | ||||
| {% from '_common_fields.jinja' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}"; | ||||
| </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 %}*/ | ||||
|  | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script> | ||||
| <!--<script src="{{url_for('static_content', group='js', filename='limit.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> | ||||
|             <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"> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% set field = render_field(form.include_filters, | ||||
|                             rows=5, | ||||
|                             placeholder="#example | ||||
| xpath://body/div/span[contains(@class, 'example-class')]", | ||||
|                             class="m-d") | ||||
|                         %} | ||||
|                         {{ field }} | ||||
|                         {% if '/text()' in  field %} | ||||
|                           <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br> | ||||
|                         {% endif %} | ||||
|                         <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br> | ||||
|  | ||||
|                     <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 either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed). | ||||
|                             <ul> | ||||
|                                 <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required,  <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li> | ||||
|                                 {% if jq_support %} | ||||
|                                 <li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>.</li> | ||||
|                                 {% else %} | ||||
|                                 <li>jq support not installed</li> | ||||
|                                 {% endif %} | ||||
|                             </ul> | ||||
|                         </li> | ||||
|                         <li>XPath - Limit text to this XPath rule, simply start with a forward-slash, | ||||
|                             <ul> | ||||
|                                 <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath://*[contains(@class, 'sametext')]</code>, <a | ||||
|                                 href="http://xpather.com/" target="new">test your XPath here</a></li> | ||||
|                                 <li>Example: Get all titles from an RSS feed <code>//title/text()</code></li> | ||||
|                             </ul> | ||||
|                             </li> | ||||
|                     </ul> | ||||
|                     Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} 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> | ||||
|                 <fieldset class="pure-control-group"> | ||||
|                     {{ render_field(form.subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS selector before text conversion. </li> | ||||
|                           <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                         </ul> | ||||
|                       </span> | ||||
|                 </fieldset> | ||||
|  | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
|                     <div  class="pure-control-group inline-radio"> | ||||
|                       {{ render_checkbox_field(form.notification_muted) }} | ||||
|                     </div> | ||||
|                     {% if is_html_webdriver %} | ||||
|                     <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_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) }} | ||||
|                     </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,60 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% block content %} | ||||
| {% from '_helpers.jinja' 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>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.items()  %} | ||||
|             <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 class="title-col inline">{{tag.title}}</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 %} | ||||
							
								
								
									
										153
									
								
								changedetectionio/changedetection.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,153 @@ | ||||
| #!/usr/bin/python3 | ||||
|  | ||||
| # Launch as a eventlet.wsgi server instance. | ||||
|  | ||||
| from distutils.util import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
|  | ||||
| import eventlet | ||||
| import eventlet.wsgi | ||||
| import getopt | ||||
| import os | ||||
| import signal | ||||
| import socket | ||||
| import sys | ||||
|  | ||||
| from . import store, changedetection_app, content_fetcher | ||||
| from . import __version__ | ||||
|  | ||||
| # Only global so we can access it in the signal handler | ||||
| app = None | ||||
| datastore = None | ||||
|  | ||||
| def sigterm_handler(_signo, _stack_frame): | ||||
|     global app | ||||
|     global datastore | ||||
| #    app.config.exit.set() | ||||
|     print('Shutdown: Got SIGTERM, DB saved to disk') | ||||
|     datastore.sync_to_json() | ||||
| #    raise SystemExit | ||||
|  | ||||
| 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:", "port") | ||||
|     except getopt.GetoptError: | ||||
|         print('backend.py -s SSL enable -h [host] -p [port] -d [datastore path]') | ||||
|         sys.exit(2) | ||||
|  | ||||
|     create_datastore_dir = False | ||||
|  | ||||
|     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': | ||||
|             print ("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 | ||||
|  | ||||
|     # 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: | ||||
|             print( | ||||
|                 "ERROR: Directory path for the datastore '{}' does not exist, cannot start, please make sure the directory exists or specify a directory with the -d option.\n" | ||||
|                 "Or use the -C parameter to create the directory.".format(app_config['datastore_path']), file=sys.stderr) | ||||
|             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 | ||||
|         print ("ERROR: JSON DB or Proxy List JSON at '{}' appears to be corrupt, aborting".format(app_config['datastore_path'])) | ||||
|         print(str(e)) | ||||
|         return | ||||
|  | ||||
|     app = changedetection_app(app_config, datastore) | ||||
|  | ||||
|     signal.signal(signal.SIGTERM, sigterm_handler) | ||||
|  | ||||
|     # 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. | ||||
|     # @Note: Incompatible with password login (and maybe other features) for now, submit a PR! | ||||
|     @app.after_request | ||||
|     def hide_referrer(response): | ||||
|         if strtobool(os.getenv("HIDE_REFERER", 'false')): | ||||
|             response.headers["Referrer-Policy"] = "no-referrer" | ||||
|  | ||||
|         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'): | ||||
|         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) | ||||
|  | ||||
|     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) | ||||
|  | ||||
							
								
								
									
										757
									
								
								changedetectionio/content_fetcher.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,757 @@ | ||||
| from abc import abstractmethod | ||||
| from distutils.util import strtobool | ||||
| from urllib.parse import urlparse | ||||
| import chardet | ||||
| import hashlib | ||||
| import json | ||||
| import logging | ||||
| import os | ||||
| import requests | ||||
| import sys | ||||
| import time | ||||
| import urllib.parse | ||||
|  | ||||
| visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4, header, footer, section, article, aside, details, main, nav, section, summary' | ||||
|  | ||||
|  | ||||
| class Non200ErrorCodeReceived(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.xpath_data = xpath_data | ||||
|         self.page_text = None | ||||
|  | ||||
|         if page_html: | ||||
|             from changedetectionio import html_tools | ||||
|             self.page_text = html_tools.html_to_text(page_html) | ||||
|         return | ||||
|  | ||||
|  | ||||
| class checksumFromPreviousCheckWasTheSame(Exception): | ||||
|     def __init__(self): | ||||
|         return | ||||
|  | ||||
|  | ||||
| class JSActionExceptions(Exception): | ||||
|     def __init__(self, status_code, url, screenshot, message=''): | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.message = message | ||||
|         return | ||||
|  | ||||
|  | ||||
| class BrowserStepsStepTimout(Exception): | ||||
|     def __init__(self, step_n): | ||||
|         self.step_n = step_n | ||||
|         return | ||||
|  | ||||
|  | ||||
| class PageUnloadable(Exception): | ||||
|     def __init__(self, status_code, url, message, screenshot=False): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.message = message | ||||
|         return | ||||
|  | ||||
|  | ||||
| class EmptyReply(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         return | ||||
|  | ||||
|  | ||||
| class ScreenshotUnavailable(Exception): | ||||
|     def __init__(self, status_code, url, page_html=None): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         if page_html: | ||||
|             from html_tools import html_to_text | ||||
|             self.page_text = html_to_text(page_html) | ||||
|         return | ||||
|  | ||||
|  | ||||
| class ReplyWithContentButNoText(Exception): | ||||
|     def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content=''): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         self.url = url | ||||
|         self.screenshot = screenshot | ||||
|         self.has_filters = has_filters | ||||
|         self.html_content = html_content | ||||
|         return | ||||
|  | ||||
|  | ||||
| class Fetcher(): | ||||
|     browser_steps = None | ||||
|     browser_steps_screenshot_path = None | ||||
|     content = None | ||||
|     error = None | ||||
|     fetcher_description = "No description" | ||||
|     browser_connection_url = None | ||||
|     headers = {} | ||||
|     status_code = None | ||||
|     webdriver_js_execute_code = None | ||||
|     xpath_data = None | ||||
|     xpath_element_js = "" | ||||
|     instock_data = None | ||||
|     instock_data_js = "" | ||||
|  | ||||
|     # Will be needed in the future by the VisualSelector, always get this where possible. | ||||
|     screenshot = False | ||||
|     system_http_proxy = os.getenv('HTTP_PROXY') | ||||
|     system_https_proxy = os.getenv('HTTPS_PROXY') | ||||
|  | ||||
|     # Time ONTOP of the system defined env minimum time | ||||
|     render_extract_delay = 0 | ||||
|  | ||||
|     def __init__(self): | ||||
|         from pkg_resources import resource_string | ||||
|         # The code that scrapes elements and makes a list of elements/size/position to click on in the VisualSelector | ||||
|         self.xpath_element_js = resource_string(__name__, "res/xpath_element_scraper.js").decode('utf-8') | ||||
|         self.instock_data_js = resource_string(__name__, "res/stock-not-in-stock.js").decode('utf-8') | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_error(self): | ||||
|         return self.error | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|         # Should set self.error, self.status_code and self.content | ||||
|         pass | ||||
|  | ||||
|     @abstractmethod | ||||
|     def quit(self): | ||||
|         return | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_last_status_code(self): | ||||
|         return self.status_code | ||||
|  | ||||
|     @abstractmethod | ||||
|     def screenshot_step(self, step_n): | ||||
|         return None | ||||
|  | ||||
|     @abstractmethod | ||||
|     # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc | ||||
|     def is_ready(self): | ||||
|         return True | ||||
|  | ||||
|     def get_all_headers(self): | ||||
|         """ | ||||
|         Get all headers but ensure all keys are lowercase | ||||
|         :return: | ||||
|         """ | ||||
|         return {k.lower(): v for k, v in self.headers.items()} | ||||
|  | ||||
|     def browser_steps_get_valid_steps(self): | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             valid_steps = filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.browser_steps) | ||||
|  | ||||
|             return valid_steps | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def iterate_browser_steps(self): | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|         from playwright._impl._api_types import TimeoutError | ||||
|         from jinja2 import Environment | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|  | ||||
|         step_n = 0 | ||||
|  | ||||
|         if self.browser_steps is not None and len(self.browser_steps): | ||||
|             interface = steppable_browser_interface() | ||||
|             interface.page = self.page | ||||
|             valid_steps = self.browser_steps_get_valid_steps() | ||||
|  | ||||
|             for step in valid_steps: | ||||
|                 step_n += 1 | ||||
|                 print(">> Iterating check - browser Step n {} - {}...".format(step_n, step['operation'])) | ||||
|                 self.screenshot_step("before-" + str(step_n)) | ||||
|                 self.save_step_html("before-" + str(step_n)) | ||||
|                 try: | ||||
|                     optional_value = step['optional_value'] | ||||
|                     selector = step['selector'] | ||||
|                     # Support for jinja2 template in step values, with date module added | ||||
|                     if '{%' in step['optional_value'] or '{{' in step['optional_value']: | ||||
|                         optional_value = str(jinja2_env.from_string(step['optional_value']).render()) | ||||
|                     if '{%' in step['selector'] or '{{' in step['selector']: | ||||
|                         selector = str(jinja2_env.from_string(step['selector']).render()) | ||||
|  | ||||
|                     getattr(interface, "call_action")(action_name=step['operation'], | ||||
|                                                       selector=selector, | ||||
|                                                       optional_value=optional_value) | ||||
|                     self.screenshot_step(step_n) | ||||
|                     self.save_step_html(step_n) | ||||
|                 except TimeoutError as e: | ||||
|                     print(str(e)) | ||||
|                     # Stop processing here | ||||
|                     raise BrowserStepsStepTimout(step_n=step_n) | ||||
|  | ||||
|     # It's always good to reset these | ||||
|     def delete_browser_steps_screenshots(self): | ||||
|         import glob | ||||
|         if self.browser_steps_screenshot_path is not None: | ||||
|             dest = os.path.join(self.browser_steps_screenshot_path, 'step_*.jpeg') | ||||
|             files = glob.glob(dest) | ||||
|             for f in files: | ||||
|                 if os.path.isfile(f): | ||||
|                     os.unlink(f) | ||||
|  | ||||
|  | ||||
| #   Maybe for the future, each fetcher provides its own diff output, could be used for text, image | ||||
| #   the current one would return javascript output (as we use JS to generate the diff) | ||||
| # | ||||
| def available_fetchers(): | ||||
|     # See the if statement at the bottom of this file for how we switch between playwright and webdriver | ||||
|     import inspect | ||||
|     p = [] | ||||
|     for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass): | ||||
|         if inspect.isclass(obj): | ||||
|             # @todo html_ is maybe better as fetcher_ or something | ||||
|             # In this case, make sure to edit the default one in store.py and fetch_site_status.py | ||||
|             if name.startswith('html_'): | ||||
|                 t = tuple([name, obj.fetcher_description]) | ||||
|                 p.append(t) | ||||
|  | ||||
|     return p | ||||
|  | ||||
|  | ||||
| class base_html_playwright(Fetcher): | ||||
|     fetcher_description = "Playwright {}/Javascript".format( | ||||
|         os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize() | ||||
|     ) | ||||
|     if os.getenv("PLAYWRIGHT_DRIVER_URL"): | ||||
|         fetcher_description += " via '{}'".format(os.getenv("PLAYWRIGHT_DRIVER_URL")) | ||||
|  | ||||
|     browser_type = '' | ||||
|     command_executor = '' | ||||
|  | ||||
|     # Configs for Proxy setup | ||||
|     # In the ENV vars, is prefixed with "playwright_proxy_", so it is for example "playwright_proxy_server" | ||||
|     playwright_proxy_settings_mappings = ['bypass', 'server', 'username', 'password'] | ||||
|  | ||||
|     proxy = None | ||||
|  | ||||
|     def __init__(self, proxy_override=None, browser_connection_url=None): | ||||
|         super().__init__() | ||||
|  | ||||
|         self.browser_type = os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').strip('"') | ||||
|  | ||||
|         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value | ||||
|         if not browser_connection_url: | ||||
|             self.browser_connection_url = os.getenv("PLAYWRIGHT_DRIVER_URL", 'ws://playwright-chrome:3000').strip('"') | ||||
|         else: | ||||
|             self.browser_connection_url = browser_connection_url | ||||
|  | ||||
|         # If any proxy settings are enabled, then we should setup the proxy object | ||||
|         proxy_args = {} | ||||
|         for k in self.playwright_proxy_settings_mappings: | ||||
|             v = os.getenv('playwright_proxy_' + k, False) | ||||
|             if v: | ||||
|                 proxy_args[k] = v.strip('"') | ||||
|  | ||||
|         if proxy_args: | ||||
|             self.proxy = proxy_args | ||||
|  | ||||
|         # allow per-watch proxy selection override | ||||
|         if proxy_override: | ||||
|             self.proxy = {'server': proxy_override} | ||||
|  | ||||
|         if self.proxy: | ||||
|             # Playwright needs separate username and password values | ||||
|             parsed = urlparse(self.proxy.get('server')) | ||||
|             if parsed.username: | ||||
|                 self.proxy['username'] = parsed.username | ||||
|                 self.proxy['password'] = parsed.password | ||||
|  | ||||
|     def screenshot_step(self, step_n=''): | ||||
|         screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=85) | ||||
|  | ||||
|         if self.browser_steps_screenshot_path is not None: | ||||
|             destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n)) | ||||
|             logging.debug("Saving step screenshot to {}".format(destination)) | ||||
|             with open(destination, 'wb') as f: | ||||
|                 f.write(screenshot) | ||||
|  | ||||
|     def save_step_html(self, step_n): | ||||
|         content = self.page.content() | ||||
|         destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n)) | ||||
|         logging.debug("Saving step HTML to {}".format(destination)) | ||||
|         with open(destination, 'w') as f: | ||||
|             f.write(content) | ||||
|  | ||||
|     def run_fetch_browserless_puppeteer(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         from pkg_resources import resource_string | ||||
|  | ||||
|         extra_wait_ms = (int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay) * 1000 | ||||
|  | ||||
|         self.xpath_element_js = self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) | ||||
|         code = resource_string(__name__, "res/puppeteer_fetch.js").decode('utf-8') | ||||
|         # In the future inject this is a proper JS package | ||||
|         code = code.replace('%xpath_scrape_code%', self.xpath_element_js) | ||||
|         code = code.replace('%instock_scrape_code%', self.instock_data_js) | ||||
|  | ||||
|         from requests.exceptions import ConnectTimeout, ReadTimeout | ||||
|         wait_browserless_seconds = 240 | ||||
|  | ||||
|         browserless_function_url = os.getenv('BROWSERLESS_FUNCTION_URL') | ||||
|         from urllib.parse import urlparse | ||||
|         if not browserless_function_url: | ||||
|             # Convert/try to guess from PLAYWRIGHT_DRIVER_URL | ||||
|             o = urlparse(os.getenv('PLAYWRIGHT_DRIVER_URL')) | ||||
|             browserless_function_url = o._replace(scheme="http")._replace(path="function").geturl() | ||||
|  | ||||
|  | ||||
|         # Append proxy connect string | ||||
|         if self.proxy: | ||||
|             # Remove username/password if it exists in the URL or you will receive "ERR_NO_SUPPORTED_PROXIES" error | ||||
|             # Actual authentication handled by Puppeteer/node | ||||
|             o = urlparse(self.proxy.get('server')) | ||||
|             proxy_url = urllib.parse.quote(o._replace(netloc="{}:{}".format(o.hostname, o.port)).geturl()) | ||||
|             browserless_function_url = f"{browserless_function_url}&--proxy-server={proxy_url}" | ||||
|  | ||||
|         try: | ||||
|             amp = '&' if '?' in browserless_function_url else '?' | ||||
|             response = requests.request( | ||||
|                 method="POST", | ||||
|                 json={ | ||||
|                     "code": code, | ||||
|                     "context": { | ||||
|                         # Very primitive disk cache - USE WITH EXTREME CAUTION | ||||
|                         # Run browserless container  with -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" | ||||
|                         'disk_cache_dir': os.getenv("PUPPETEER_DISK_CACHE", False), # or path to disk cache ending in /, ie /tmp/cache/ | ||||
|                         'execute_js': self.webdriver_js_execute_code, | ||||
|                         'extra_wait_ms': extra_wait_ms, | ||||
|                         'include_filters': current_include_filters, | ||||
|                         'req_headers': request_headers, | ||||
|                         'screenshot_quality': int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72)), | ||||
|                         'url': url, | ||||
|                         'user_agent': {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None), | ||||
|                         'proxy_username': self.proxy.get('username', '') if self.proxy else False, | ||||
|                         'proxy_password': self.proxy.get('password', '') if self.proxy and self.proxy.get('username') else False, | ||||
|                         'no_cache_list': [ | ||||
|                             'twitter', | ||||
|                             '.pdf' | ||||
|                         ], | ||||
|                         # Could use https://github.com/easylist/easylist here, or install a plugin | ||||
|                         'block_url_list': [ | ||||
|                             'adnxs.com', | ||||
|                             'analytics.twitter.com', | ||||
|                             'doubleclick.net', | ||||
|                             'google-analytics.com', | ||||
|                             'googletagmanager', | ||||
|                             'trustpilot.com' | ||||
|                         ] | ||||
|                     } | ||||
|                 }, | ||||
|                 # @todo /function needs adding ws:// to http:// rebuild this | ||||
|                 url=browserless_function_url+f"{amp}--disable-features=AudioServiceOutOfProcess&dumpio=true&--disable-remote-fonts", | ||||
|                 timeout=wait_browserless_seconds) | ||||
|  | ||||
|         except ReadTimeout: | ||||
|             raise PageUnloadable(url=url, status_code=None, message=f"No response from browserless in {wait_browserless_seconds}s") | ||||
|         except ConnectTimeout: | ||||
|             raise PageUnloadable(url=url, status_code=None, message=f"Timed out connecting to browserless, retrying..") | ||||
|         else: | ||||
|             # 200 Here means that the communication to browserless worked only, not the page state | ||||
|             if response.status_code == 200: | ||||
|                 import base64 | ||||
|  | ||||
|                 x = response.json() | ||||
|                 if not x.get('screenshot'): | ||||
|                     # https://github.com/puppeteer/puppeteer/blob/v1.0.0/docs/troubleshooting.md#tips | ||||
|                     # https://github.com/puppeteer/puppeteer/issues/1834 | ||||
|                     # https://github.com/puppeteer/puppeteer/issues/1834#issuecomment-381047051 | ||||
|                     # Check your memory is shared and big enough | ||||
|                     raise ScreenshotUnavailable(url=url, status_code=None) | ||||
|  | ||||
|                 if not x.get('content', '').strip(): | ||||
|                     raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|                 if x.get('status_code', 200) != 200 and not ignore_status_codes: | ||||
|                     raise Non200ErrorCodeReceived(url=url, status_code=x.get('status_code', 200), page_html=x['content']) | ||||
|  | ||||
|                 self.content = x.get('content') | ||||
|                 self.headers = x.get('headers') | ||||
|                 self.instock_data = x.get('instock_data') | ||||
|                 self.screenshot = base64.b64decode(x.get('screenshot')) | ||||
|                 self.status_code = x.get('status_code') | ||||
|                 self.xpath_data = x.get('xpath_data') | ||||
|  | ||||
|             else: | ||||
|                 # Some other error from browserless | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=response.content.decode('utf-8')) | ||||
|  | ||||
|     def run(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         # For now, USE_EXPERIMENTAL_PUPPETEER_FETCH is not supported by watches with BrowserSteps (for now!) | ||||
|         if not self.browser_steps and os.getenv('USE_EXPERIMENTAL_PUPPETEER_FETCH'): | ||||
|             if strtobool(os.getenv('USE_EXPERIMENTAL_PUPPETEER_FETCH')): | ||||
|                 # Temporary backup solution until we rewrite the playwright code | ||||
|                 return self.run_fetch_browserless_puppeteer( | ||||
|                     url, | ||||
|                     timeout, | ||||
|                     request_headers, | ||||
|                     request_body, | ||||
|                     request_method, | ||||
|                     ignore_status_codes, | ||||
|                     current_include_filters, | ||||
|                     is_binary) | ||||
|  | ||||
|         from playwright.sync_api import sync_playwright | ||||
|         import playwright._impl._api_types | ||||
|  | ||||
|         self.delete_browser_steps_screenshots() | ||||
|         response = None | ||||
|  | ||||
|         with sync_playwright() as p: | ||||
|             browser_type = getattr(p, self.browser_type) | ||||
|  | ||||
|             # Seemed to cause a connection Exception even tho I can see it connect | ||||
|             # self.browser = browser_type.connect(self.command_executor, timeout=timeout*1000) | ||||
|             # 60,000 connection timeout only | ||||
|             browser = browser_type.connect_over_cdp(self.browser_connection_url, timeout=60000) | ||||
|  | ||||
|             # SOCKS5 with authentication is not supported (yet) | ||||
|             # https://github.com/microsoft/playwright/issues/10567 | ||||
|  | ||||
|             # Set user agent to prevent Cloudflare from blocking the browser | ||||
|             # Use the default one configured in the App.py model that's passed from fetch_site_status.py | ||||
|             context = browser.new_context( | ||||
|                 user_agent={k.lower(): v for k, v in request_headers.items()}.get('user-agent', None), | ||||
|                 proxy=self.proxy, | ||||
|                 # This is needed to enable JavaScript execution on GitHub and others | ||||
|                 bypass_csp=True, | ||||
|                 # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers | ||||
|                 service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), | ||||
|                 # Should never be needed | ||||
|                 accept_downloads=False | ||||
|             ) | ||||
|  | ||||
|             self.page = context.new_page() | ||||
|             if len(request_headers): | ||||
|                 context.set_extra_http_headers(request_headers) | ||||
|  | ||||
|             # Listen for all console events and handle errors | ||||
|             self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}")) | ||||
|  | ||||
|             # Re-use as much code from browser steps as possible so its the same | ||||
|             from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface | ||||
|             browsersteps_interface = steppable_browser_interface() | ||||
|             browsersteps_interface.page = self.page | ||||
|  | ||||
|             response = browsersteps_interface.action_goto_url(value=url) | ||||
|             self.headers = response.all_headers() | ||||
|  | ||||
|             if response is None: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print("Content Fetcher > Response object was none") | ||||
|                 raise EmptyReply(url=url, status_code=None) | ||||
|  | ||||
|             try: | ||||
|                 if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code): | ||||
|                     browsersteps_interface.action_execute_js(value=self.webdriver_js_execute_code, selector=None) | ||||
|             except playwright._impl._api_types.TimeoutError as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 # This can be ok, we will try to grab what we could retrieve | ||||
|                 pass | ||||
|             except Exception as e: | ||||
|                 print("Content Fetcher > Other exception when executing custom JS code", str(e)) | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise PageUnloadable(url=url, status_code=None, message=str(e)) | ||||
|  | ||||
|             extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|  | ||||
|             self.status_code = response.status | ||||
|  | ||||
|             if self.status_code != 200 and not ignore_status_codes: | ||||
|  | ||||
|                 screenshot=self.page.screenshot(type='jpeg', full_page=True, | ||||
|                                      quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|  | ||||
|                 raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot) | ||||
|  | ||||
|             if len(self.page.content().strip()) == 0: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 print("Content Fetcher > Content was empty") | ||||
|                 raise EmptyReply(url=url, status_code=response.status) | ||||
|  | ||||
|             # Run Browser Steps here | ||||
|             if self.browser_steps_get_valid_steps(): | ||||
|                 self.iterate_browser_steps() | ||||
|                  | ||||
|             self.page.wait_for_timeout(extra_wait * 1000) | ||||
|  | ||||
|             # So we can find an element on the page where its selector was entered manually (maybe not xPath etc) | ||||
|             if current_include_filters is not None: | ||||
|                 self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters))) | ||||
|             else: | ||||
|                 self.page.evaluate("var include_filters=''") | ||||
|  | ||||
|             self.xpath_data = self.page.evaluate( | ||||
|                 "async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}") | ||||
|             self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}") | ||||
|  | ||||
|             self.content = self.page.content() | ||||
|             # Bug 3 in Playwright screenshot handling | ||||
|             # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it | ||||
|             # JPEG is better here because the screenshots can be very very large | ||||
|  | ||||
|             # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded | ||||
|             # which will significantly increase the IO size between the server and client, it's recommended to use the lowest | ||||
|             # acceptable screenshot quality here | ||||
|             try: | ||||
|                 # The actual screenshot | ||||
|                 self.screenshot = self.page.screenshot(type='jpeg', full_page=True, | ||||
|                                                        quality=int(os.getenv("PLAYWRIGHT_SCREENSHOT_QUALITY", 72))) | ||||
|             except Exception as e: | ||||
|                 context.close() | ||||
|                 browser.close() | ||||
|                 raise ScreenshotUnavailable(url=url, status_code=response.status_code) | ||||
|  | ||||
|             context.close() | ||||
|             browser.close() | ||||
|  | ||||
|  | ||||
| class base_html_webdriver(Fetcher): | ||||
|     if os.getenv("WEBDRIVER_URL"): | ||||
|         fetcher_description = "WebDriver Chrome/Javascript via '{}'".format(os.getenv("WEBDRIVER_URL")) | ||||
|     else: | ||||
|         fetcher_description = "WebDriver Chrome/Javascript" | ||||
|  | ||||
|     # Configs for Proxy setup | ||||
|     # In the ENV vars, is prefixed with "webdriver_", so it is for example "webdriver_sslProxy" | ||||
|     selenium_proxy_settings_mappings = ['proxyType', 'ftpProxy', 'httpProxy', 'noProxy', | ||||
|                                         'proxyAutoconfigUrl', 'sslProxy', 'autodetect', | ||||
|                                         'socksProxy', 'socksVersion', 'socksUsername', 'socksPassword'] | ||||
|     proxy = None | ||||
|  | ||||
|     def __init__(self, proxy_override=None, browser_connection_url=None): | ||||
|         super().__init__() | ||||
|         from selenium.webdriver.common.proxy import Proxy as SeleniumProxy | ||||
|  | ||||
|         # .strip('"') is going to save someone a lot of time when they accidently wrap the env value | ||||
|         if not browser_connection_url: | ||||
|             self.browser_connection_url = os.getenv("WEBDRIVER_URL", 'http://browser-chrome:4444/wd/hub').strip('"') | ||||
|         else: | ||||
|             self.browser_connection_url = browser_connection_url | ||||
|  | ||||
|         # If any proxy settings are enabled, then we should setup the proxy object | ||||
|         proxy_args = {} | ||||
|         for k in self.selenium_proxy_settings_mappings: | ||||
|             v = os.getenv('webdriver_' + k, False) | ||||
|             if v: | ||||
|                 proxy_args[k] = v.strip('"') | ||||
|  | ||||
|         # Map back standard HTTP_ and HTTPS_PROXY to webDriver httpProxy/sslProxy | ||||
|         if not proxy_args.get('webdriver_httpProxy') and self.system_http_proxy: | ||||
|             proxy_args['httpProxy'] = self.system_http_proxy | ||||
|         if not proxy_args.get('webdriver_sslProxy') and self.system_https_proxy: | ||||
|             proxy_args['httpsProxy'] = self.system_https_proxy | ||||
|  | ||||
|         # Allows override the proxy on a per-request basis | ||||
|         if proxy_override is not None: | ||||
|             proxy_args['httpProxy'] = proxy_override | ||||
|  | ||||
|         if proxy_args: | ||||
|             self.proxy = SeleniumProxy(raw=proxy_args) | ||||
|  | ||||
|     def run(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         from selenium import webdriver | ||||
|         from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
|         from selenium.common.exceptions import WebDriverException | ||||
|         # request_body, request_method unused for now, until some magic in the future happens. | ||||
|  | ||||
|         options = ChromeOptions() | ||||
|         if self.proxy: | ||||
|             options.proxy = self.proxy | ||||
|  | ||||
|         self.driver = webdriver.Remote( | ||||
|             command_executor=self.browser_connection_url, | ||||
|             options=options) | ||||
|  | ||||
|         try: | ||||
|             self.driver.get(url) | ||||
|         except WebDriverException as e: | ||||
|             # Be sure we close the session window | ||||
|             self.quit() | ||||
|             raise | ||||
|  | ||||
|         self.driver.set_window_size(1280, 1024) | ||||
|         self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) | ||||
|  | ||||
|         if self.webdriver_js_execute_code is not None: | ||||
|             self.driver.execute_script(self.webdriver_js_execute_code) | ||||
|             # Selenium doesn't automatically wait for actions as good as Playwright, so wait again | ||||
|             self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5))) | ||||
|  | ||||
|         # @todo - how to check this? is it possible? | ||||
|         self.status_code = 200 | ||||
|         # @todo somehow we should try to get this working for WebDriver | ||||
|         # raise EmptyReply(url=url, status_code=r.status_code) | ||||
|  | ||||
|         # @todo - dom wait loaded? | ||||
|         time.sleep(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay) | ||||
|         self.content = self.driver.page_source | ||||
|         self.headers = {} | ||||
|  | ||||
|         self.screenshot = self.driver.get_screenshot_as_png() | ||||
|  | ||||
|     # Does the connection to the webdriver work? run a test connection. | ||||
|     def is_ready(self): | ||||
|         from selenium import webdriver | ||||
|         from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
|  | ||||
|         self.driver = webdriver.Remote( | ||||
|             command_executor=self.command_executor, | ||||
|             options=ChromeOptions()) | ||||
|  | ||||
|         # driver.quit() seems to cause better exceptions | ||||
|         self.quit() | ||||
|         return True | ||||
|  | ||||
|     def quit(self): | ||||
|         if self.driver: | ||||
|             try: | ||||
|                 self.driver.quit() | ||||
|             except Exception as e: | ||||
|                 print("Content Fetcher > Exception in chrome shutdown/quit" + str(e)) | ||||
|  | ||||
|  | ||||
| # "html_requests" is listed as the default fetcher in store.py! | ||||
| class html_requests(Fetcher): | ||||
|     fetcher_description = "Basic fast Plaintext/HTTP Client" | ||||
|  | ||||
|     def __init__(self, proxy_override=None, browser_connection_url=None): | ||||
|         super().__init__() | ||||
|         self.proxy_override = proxy_override | ||||
|         # browser_connection_url is none because its always 'launched locally' | ||||
|  | ||||
|     def run(self, | ||||
|             url, | ||||
|             timeout, | ||||
|             request_headers, | ||||
|             request_body, | ||||
|             request_method, | ||||
|             ignore_status_codes=False, | ||||
|             current_include_filters=None, | ||||
|             is_binary=False): | ||||
|  | ||||
|         # Make requests use a more modern looking user-agent | ||||
|         if not {k.lower(): v for k, v in request_headers.items()}.get('user-agent', None): | ||||
|             request_headers['User-Agent'] = os.getenv("DEFAULT_SETTINGS_HEADERS_USERAGENT", | ||||
|                                                       'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36') | ||||
|  | ||||
|         proxies = {} | ||||
|  | ||||
|         # Allows override the proxy on a per-request basis | ||||
|  | ||||
|         # https://requests.readthedocs.io/en/latest/user/advanced/#socks | ||||
|         # Should also work with `socks5://user:pass@host:port` type syntax. | ||||
|  | ||||
|         if self.proxy_override: | ||||
|             proxies = {'http': self.proxy_override, 'https': self.proxy_override, 'ftp': self.proxy_override} | ||||
|         else: | ||||
|             if self.system_http_proxy: | ||||
|                 proxies['http'] = self.system_http_proxy | ||||
|             if self.system_https_proxy: | ||||
|                 proxies['https'] = self.system_https_proxy | ||||
|  | ||||
|         r = requests.request(method=request_method, | ||||
|                              data=request_body, | ||||
|                              url=url, | ||||
|                              headers=request_headers, | ||||
|                              timeout=timeout, | ||||
|                              proxies=proxies, | ||||
|                              verify=False) | ||||
|  | ||||
|         # If the response did not tell us what encoding format to expect, Then use chardet to override what `requests` thinks. | ||||
|         # For example - some sites don't tell us it's utf-8, but return utf-8 content | ||||
|         # This seems to not occur when using webdriver/selenium, it seems to detect the text encoding more reliably. | ||||
|         # https://github.com/psf/requests/issues/1604 good info about requests encoding detection | ||||
|         if not is_binary: | ||||
|             # Don't run this for PDF (and requests identified as binary) takes a _long_ time | ||||
|             if not r.headers.get('content-type') or not 'charset=' in r.headers.get('content-type'): | ||||
|                 encoding = chardet.detect(r.content)['encoding'] | ||||
|                 if encoding: | ||||
|                     r.encoding = encoding | ||||
|  | ||||
|         if not r.content or not len(r.content): | ||||
|             raise EmptyReply(url=url, status_code=r.status_code) | ||||
|  | ||||
|         # @todo test this | ||||
|         # @todo maybe you really want to test zero-byte return pages? | ||||
|         if r.status_code != 200 and not ignore_status_codes: | ||||
|             # maybe check with content works? | ||||
|             raise Non200ErrorCodeReceived(url=url, status_code=r.status_code, page_html=r.text) | ||||
|  | ||||
|         self.status_code = r.status_code | ||||
|         if is_binary: | ||||
|             # Binary files just return their checksum until we add something smarter | ||||
|             self.content = hashlib.md5(r.content).hexdigest() | ||||
|         else: | ||||
|             self.content = r.text | ||||
|  | ||||
|         self.headers = r.headers | ||||
|         self.raw_content = r.content | ||||
|  | ||||
|  | ||||
| # Decide which is the 'real' HTML webdriver, this is more a system wide config | ||||
| # rather than site-specific. | ||||
| use_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False) | ||||
| if use_playwright_as_chrome_fetcher: | ||||
|     html_webdriver = base_html_playwright | ||||
| else: | ||||
|     html_webdriver = base_html_webdriver | ||||
							
								
								
									
										62
									
								
								changedetectionio/diff.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| # used for the notifications, the front-end is using a JS library | ||||
|  | ||||
| import difflib | ||||
|  | ||||
|  | ||||
| def same_slicer(l, a, b): | ||||
|     if a == b: | ||||
|         return [l[a]] | ||||
|     else: | ||||
|         return l[a:b] | ||||
|  | ||||
| # like .compare but a little different output | ||||
| def customSequenceMatcher(before, after, include_equal=False, include_removed=True, include_added=True, include_replaced=True, include_change_type_prefix=True): | ||||
|     cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in " \\t", a=before, b=after) | ||||
|  | ||||
|     # @todo Line-by-line mode instead of buncghed, including `after` that is not in `before` (maybe unset?) | ||||
|     for tag, alo, ahi, blo, bhi in cruncher.get_opcodes(): | ||||
|         if include_equal and tag == 'equal': | ||||
|             g = before[alo:ahi] | ||||
|             yield g | ||||
|         elif include_removed and tag == 'delete': | ||||
|             row_prefix = "(removed) " if include_change_type_prefix else '' | ||||
|             g = [ row_prefix + i for i in same_slicer(before, alo, ahi)] | ||||
|             yield g | ||||
|         elif include_replaced and tag == 'replace': | ||||
|             row_prefix = "(changed) " if include_change_type_prefix else '' | ||||
|             g = [row_prefix + i for i in same_slicer(before, alo, ahi)] | ||||
|             row_prefix = "(into) " if include_change_type_prefix else '' | ||||
|             g += [row_prefix + i for i in same_slicer(after, blo, bhi)] | ||||
|             yield g | ||||
|         elif include_added and tag == 'insert': | ||||
|             row_prefix = "(added) " if include_change_type_prefix else '' | ||||
|             g = [row_prefix + i for i in same_slicer(after, blo, bhi)] | ||||
|             yield g | ||||
|  | ||||
| # only_differences - only return info about the differences, no context | ||||
| # line_feed_sep could be "<br>" or "<li>" or "\n" etc | ||||
| def render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=True, include_replaced=True, line_feed_sep="\n", include_change_type_prefix=True, patch_format=False): | ||||
|  | ||||
|     newest_version_file_contents = [line.rstrip() for line in newest_version_file_contents.splitlines()] | ||||
|  | ||||
|     if previous_version_file_contents: | ||||
|         previous_version_file_contents = [line.rstrip() for line in previous_version_file_contents.splitlines()] | ||||
|     else: | ||||
|         previous_version_file_contents = "" | ||||
|  | ||||
|     if patch_format: | ||||
|         patch = difflib.unified_diff(previous_version_file_contents, newest_version_file_contents) | ||||
|         return line_feed_sep.join(patch) | ||||
|  | ||||
|     rendered_diff = customSequenceMatcher(before=previous_version_file_contents, | ||||
|                                           after=newest_version_file_contents, | ||||
|                                           include_equal=include_equal, | ||||
|                                           include_removed=include_removed, | ||||
|                                           include_added=include_added, | ||||
|                                           include_replaced=include_replaced, | ||||
|                                           include_change_type_prefix=include_change_type_prefix) | ||||
|  | ||||
|     # Recursively join lists | ||||
|     f = lambda L: line_feed_sep.join([f(x) if type(x) is list else x for x in L]) | ||||
|     p= f(rendered_diff) | ||||
|     return p | ||||
							
								
								
									
										564
									
								
								changedetectionio/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,564 @@ | ||||
| import os | ||||
| import re | ||||
| from distutils.util import strtobool | ||||
|  | ||||
| from wtforms import ( | ||||
|     BooleanField, | ||||
|     Form, | ||||
|     IntegerField, | ||||
|     RadioField, | ||||
|     SelectField, | ||||
|     StringField, | ||||
|     SubmitField, | ||||
|     TextAreaField, | ||||
|     fields, | ||||
|     validators, | ||||
|     widgets | ||||
| ) | ||||
| from flask_wtf.file import FileField, FileAllowed | ||||
| from wtforms.fields import FieldList | ||||
|  | ||||
| from wtforms.validators import ValidationError | ||||
|  | ||||
| from validators.url import url as url_validator | ||||
|  | ||||
|  | ||||
| # default | ||||
| # each select <option data-enabled="enabled-0-0" | ||||
| from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|  | ||||
| from changedetectionio import content_fetcher, html_tools | ||||
|  | ||||
| from changedetectionio.notification import ( | ||||
|     valid_notification_formats, | ||||
| ) | ||||
|  | ||||
| from wtforms.fields import FormField | ||||
|  | ||||
| dictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ]) | ||||
|  | ||||
| valid_method = { | ||||
|     'GET', | ||||
|     'POST', | ||||
|     'PUT', | ||||
|     'PATCH', | ||||
|     'DELETE', | ||||
| } | ||||
|  | ||||
| default_method = 'GET' | ||||
| allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False')) | ||||
|  | ||||
| class StringListField(StringField): | ||||
|     widget = widgets.TextArea() | ||||
|  | ||||
|     def _value(self): | ||||
|         if self.data: | ||||
|             # ignore empty lines in the storage | ||||
|             data = list(filter(lambda x: len(x.strip()), self.data)) | ||||
|             # Apply strip to each line | ||||
|             data = list(map(lambda x: x.strip(), data)) | ||||
|             return "\r\n".join(data) | ||||
|         else: | ||||
|             return u'' | ||||
|  | ||||
|     # incoming | ||||
|     def process_formdata(self, valuelist): | ||||
|         if valuelist and len(valuelist[0].strip()): | ||||
|             # Remove empty strings, stripping and splitting \r\n, only \n etc. | ||||
|             self.data = valuelist[0].splitlines() | ||||
|             # Remove empty lines from the final data | ||||
|             self.data = list(filter(lambda x: len(x.strip()), self.data)) | ||||
|         else: | ||||
|             self.data = [] | ||||
|  | ||||
|  | ||||
| class SaltyPasswordField(StringField): | ||||
|     widget = widgets.PasswordInput() | ||||
|     encrypted_password = "" | ||||
|  | ||||
|     def build_password(self, password): | ||||
|         import base64 | ||||
|         import hashlib | ||||
|         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 | ||||
|  | ||||
| class StringTagUUID(StringField): | ||||
|  | ||||
|    # process_formdata(self, valuelist) handled manually in POST handler | ||||
|  | ||||
|     # Is what is shown when field <input> is rendered | ||||
|     def _value(self): | ||||
|         # Tag UUID to name, on submit it will convert it back (in the submit handler of init.py) | ||||
|         if self.data and type(self.data) is list: | ||||
|             tag_titles = [] | ||||
|             for i in self.data: | ||||
|                 tag = self.datastore.data['settings']['application']['tags'].get(i) | ||||
|                 if tag: | ||||
|                     tag_title = tag.get('title') | ||||
|                     if tag_title: | ||||
|                         tag_titles.append(tag_title) | ||||
|  | ||||
|             return ', '.join(tag_titles) | ||||
|  | ||||
|         if not self.data: | ||||
|             return '' | ||||
|  | ||||
|         return 'error' | ||||
|  | ||||
| class TimeBetweenCheckForm(Form): | ||||
|     weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     hours = IntegerField('Hours', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     minutes = IntegerField('Minutes', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     seconds = IntegerField('Seconds', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     # @todo add total seconds minimum validatior = minimum_seconds_recheck_time | ||||
|  | ||||
| # 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(':', 1) | ||||
|                 if len(parts) == 2: | ||||
|                     self.data.update({parts[0].strip(): parts[1].strip()}) | ||||
|  | ||||
|         else: | ||||
|             self.data = {} | ||||
|  | ||||
| class ValidateContentFetcherIsReady(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 urllib3.exceptions | ||||
|         from changedetectionio import content_fetcher | ||||
|         return | ||||
|  | ||||
| # AttributeError: module 'changedetectionio.content_fetcher' has no attribute 'extra_browser_unlocked<>ASDF213r123r' | ||||
|         # Better would be a radiohandler that keeps a reference to each class | ||||
|         if field.data is not None and field.data != 'system': | ||||
|             klass = getattr(content_fetcher, field.data) | ||||
|             some_object = klass() | ||||
|             try: | ||||
|                 ready = some_object.is_ready() | ||||
|  | ||||
|             except urllib3.exceptions.MaxRetryError as e: | ||||
|                 driver_url = some_object.command_executor | ||||
|                 message = field.gettext('Content fetcher \'%s\' did not respond.' % (field.data)) | ||||
|                 message += '<br>' + field.gettext( | ||||
|                     'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.') | ||||
|                 message += '<br>' + field.gettext('Did you follow the instructions in the wiki?') | ||||
|                 message += '<br><br>' + field.gettext('WebDriver Host: %s' % (driver_url)) | ||||
|                 message += '<br><a href="https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver">Go here for more information</a>' | ||||
|                 message += '<br>'+field.gettext('Content fetcher did not respond properly, unable to use it.\n %s' % (str(e))) | ||||
|  | ||||
|                 raise ValidationError(message) | ||||
|  | ||||
|             except Exception as e: | ||||
|                 message = field.gettext('Content fetcher \'%s\' did not respond properly, unable to use it.\n %s') | ||||
|                 raise ValidationError(message % (field.data, e)) | ||||
|  | ||||
|  | ||||
| class ValidateNotificationBodyAndTitleWhenURLisSet(object): | ||||
|     """ | ||||
|        Validates that they entered something in both notification title+body when the URL is set | ||||
|        Due to https://github.com/dgtlmoon/changedetection.io/issues/360 | ||||
|        """ | ||||
|  | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         if len(field.data): | ||||
|             if not len(form.notification_title.data) or not len(form.notification_body.data): | ||||
|                 message = field.gettext('Notification Body and Title is required when a Notification URL is used') | ||||
|                 raise ValidationError(message) | ||||
|  | ||||
| class ValidateAppRiseServers(object): | ||||
|     """ | ||||
|        Validates that each URL given is compatible with AppRise | ||||
|        """ | ||||
|  | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         import apprise | ||||
|         apobj = apprise.Apprise() | ||||
|  | ||||
|         for server_url in field.data: | ||||
|             if not apobj.add(server_url): | ||||
|                 message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url)) | ||||
|                 raise ValidationError(message) | ||||
|  | ||||
| class ValidateJinja2Template(object): | ||||
|     """ | ||||
|     Validates that a {token} is from a valid set | ||||
|     """ | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         from changedetectionio import notification | ||||
|  | ||||
|         from jinja2 import Environment, BaseLoader, TemplateSyntaxError, UndefinedError | ||||
|         from jinja2.meta import find_undeclared_variables | ||||
|  | ||||
|  | ||||
|         try: | ||||
|             jinja2_env = Environment(loader=BaseLoader) | ||||
|             jinja2_env.globals.update(notification.valid_tokens) | ||||
|  | ||||
|             rendered = jinja2_env.from_string(field.data).render() | ||||
|         except TemplateSyntaxError as e: | ||||
|             raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e | ||||
|         except UndefinedError as e: | ||||
|             raise ValidationError(f"A variable or function is not defined: {e}") from e | ||||
|  | ||||
|         ast = jinja2_env.parse(field.data) | ||||
|         undefined = ", ".join(find_undeclared_variables(ast)) | ||||
|         if undefined: | ||||
|             raise ValidationError( | ||||
|                 f"The following tokens used in the notification are not valid: {undefined}" | ||||
|             ) | ||||
|  | ||||
| class validateURL(object): | ||||
|  | ||||
|     """ | ||||
|        Flask wtform validators wont work with basic auth | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, message=None): | ||||
|         self.message = message | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|         # This should raise a ValidationError() or not | ||||
|         validate_url(field.data) | ||||
|  | ||||
| def validate_url(test_url): | ||||
|     # If hosts that only contain alphanumerics are allowed ("localhost" for example) | ||||
|     try: | ||||
|         url_validator(test_url, simple_host=allow_simplehost) | ||||
|     except validators.ValidationError: | ||||
|         #@todo check for xss | ||||
|         message = f"'{test_url}' is not a valid URL." | ||||
|         # This should be wtforms.validators. | ||||
|         raise ValidationError(message) | ||||
|  | ||||
|     from .model.Watch import is_safe_url | ||||
|     if not is_safe_url(test_url): | ||||
|         # This should be wtforms.validators. | ||||
|         raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX or incorrect URL format') | ||||
|  | ||||
| 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): | ||||
|  | ||||
|         for line in field.data: | ||||
|             if re.search(html_tools.PERL_STYLE_REGEX, line, re.IGNORECASE): | ||||
|                 try: | ||||
|                     regex = html_tools.perl_style_slash_enclosed_regex_to_options(line) | ||||
|                     re.compile(regex) | ||||
|                 except re.error: | ||||
|                     message = field.gettext('RegEx \'%s\' is not a valid regular expression.') | ||||
|                     raise ValidationError(message % (line)) | ||||
|  | ||||
| class ValidateCSSJSONXPATHInput(object): | ||||
|     """ | ||||
|     Filter validation | ||||
|     @todo CSS validator ;) | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, message=None, allow_xpath=True, allow_json=True): | ||||
|         self.message = message | ||||
|         self.allow_xpath = allow_xpath | ||||
|         self.allow_json = allow_json | ||||
|  | ||||
|     def __call__(self, form, field): | ||||
|  | ||||
|         if isinstance(field.data, str): | ||||
|             data = [field.data] | ||||
|         else: | ||||
|             data = field.data | ||||
|  | ||||
|         for line in data: | ||||
|         # Nothing to see here | ||||
|             if not len(line.strip()): | ||||
|                 return | ||||
|  | ||||
|             # Does it look like XPath? | ||||
|             if line.strip()[0] == '/': | ||||
|                 if not self.allow_xpath: | ||||
|                     raise ValidationError("XPath not permitted in this field!") | ||||
|                 from lxml import etree, html | ||||
|                 tree = html.fromstring("<html></html>") | ||||
|  | ||||
|                 try: | ||||
|                     tree.xpath(line.strip()) | ||||
|                 except etree.XPathEvalError as e: | ||||
|                     message = field.gettext('\'%s\' is not a valid XPath expression. (%s)') | ||||
|                     raise ValidationError(message % (line, str(e))) | ||||
|                 except: | ||||
|                     raise ValidationError("A system-error occurred when validating your XPath expression") | ||||
|  | ||||
|             if 'json:' in line: | ||||
|                 if not self.allow_json: | ||||
|                     raise ValidationError("JSONPath not permitted in this field!") | ||||
|  | ||||
|                 from jsonpath_ng.exceptions import ( | ||||
|                     JsonPathLexerError, | ||||
|                     JsonPathParserError, | ||||
|                 ) | ||||
|                 from jsonpath_ng.ext import parse | ||||
|  | ||||
|                 input = line.replace('json:', '') | ||||
|  | ||||
|                 try: | ||||
|                     parse(input) | ||||
|                 except (JsonPathParserError, JsonPathLexerError) as e: | ||||
|                     message = field.gettext('\'%s\' is not a valid JSONPath expression. (%s)') | ||||
|                     raise ValidationError(message % (input, str(e))) | ||||
|                 except: | ||||
|                     raise ValidationError("A system-error occurred when validating your JSONPath expression") | ||||
|  | ||||
|                 # Re #265 - maybe in the future fetch the page and offer a | ||||
|                 # warning/notice that its possible the rule doesnt yet match anything? | ||||
|                 if not self.allow_json: | ||||
|                     raise ValidationError("jq not permitted in this field!") | ||||
|  | ||||
|             if 'jq:' in line: | ||||
|                 try: | ||||
|                     import jq | ||||
|                 except ModuleNotFoundError: | ||||
|                     # `jq` requires full compilation in windows and so isn't generally available | ||||
|                     raise ValidationError("jq not support not found") | ||||
|  | ||||
|                 input = line.replace('jq:', '') | ||||
|  | ||||
|                 try: | ||||
|                     jq.compile(input) | ||||
|                 except (ValueError) as e: | ||||
|                     message = field.gettext('\'%s\' is not a valid jq expression. (%s)') | ||||
|                     raise ValidationError(message % (input, str(e))) | ||||
|                 except: | ||||
|                     raise ValidationError("A system-error occurred when validating your jq expression") | ||||
|  | ||||
| class quickWatchForm(Form): | ||||
|     from . import processors | ||||
|  | ||||
|     url = fields.URLField('URL', validators=[validateURL()]) | ||||
|     tags = StringTagUUID('Group tag', [validators.Optional()]) | ||||
|     watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|  | ||||
|     notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()]) | ||||
|     notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) | ||||
|     notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()]) | ||||
|     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) | ||||
|     fetch_backend = RadioField(u'Fetch Method', choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) | ||||
|     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) | ||||
|     webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, | ||||
|                                                                                                                                     message="Should contain one or more seconds")]) | ||||
| class importForm(Form): | ||||
|     from . import processors | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     urls = TextAreaField('URLs') | ||||
|     xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')]) | ||||
|     file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')}) | ||||
|  | ||||
|  | ||||
| class SingleBrowserStep(Form): | ||||
|  | ||||
|     operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys()) | ||||
|  | ||||
|     # maybe better to set some <script>var.. | ||||
|     selector = StringField('Selector', [validators.Optional()], render_kw={"placeholder": "CSS or xPath selector"}) | ||||
|     optional_value = StringField('value', [validators.Optional()], render_kw={"placeholder": "Value"}) | ||||
| #   @todo move to JS? ajax fetch new field? | ||||
| #    remove_button = SubmitField('-', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Remove'}) | ||||
| #    add_button = SubmitField('+', render_kw={"type": "button", "class": "pure-button pure-button-primary", 'title': 'Add new step after'}) | ||||
|  | ||||
| class watchForm(commonSettingsForm): | ||||
|  | ||||
|     url = fields.URLField('URL', validators=[validateURL()]) | ||||
|     tags = StringTagUUID('Group tag', [validators.Optional()], default='') | ||||
|  | ||||
|     time_between_check = FormField(TimeBetweenCheckForm) | ||||
|  | ||||
|     include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') | ||||
|  | ||||
|     subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||
|  | ||||
|     extract_text = StringListField('Extract text', [ValidateListRegex()]) | ||||
|  | ||||
|     title = StringField('Title', default='') | ||||
|  | ||||
|     ignore_text = StringListField('Ignore text', [ValidateListRegex()]) | ||||
|     headers = StringDictKeyValue('Request headers') | ||||
|     body = TextAreaField('Request body', [validators.Optional()]) | ||||
|     method = SelectField('Request method', choices=valid_method, default=default_method) | ||||
|     ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False) | ||||
|     check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False) | ||||
|  | ||||
|     filter_text_added = BooleanField('Added lines', default=True) | ||||
|     filter_text_replaced = BooleanField('Replaced/changed lines', default=True) | ||||
|     filter_text_removed = BooleanField('Removed lines', default=True) | ||||
|  | ||||
|     # @todo this class could be moved to its own text_json_diff_watchForm and this goes to restock_diff_Watchform perhaps | ||||
|     in_stock_only = BooleanField('Only trigger when product goes BACK to in-stock', default=True) | ||||
|  | ||||
|     trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()]) | ||||
|     if os.getenv("PLAYWRIGHT_DRIVER_URL"): | ||||
|         browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10) | ||||
|     text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()]) | ||||
|     webdriver_js_execute_code = TextAreaField('Execute JavaScript before change detection', render_kw={"rows": "5"}, validators=[validators.Optional()]) | ||||
|  | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|     proxy = RadioField('Proxy') | ||||
|     filter_failure_notification_send = BooleanField( | ||||
|         'Send a notification when the filter can no longer be found on the page', default=False) | ||||
|  | ||||
|     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||
|     notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False) | ||||
|  | ||||
|     def validate(self, **kwargs): | ||||
|         if not super().validate(): | ||||
|             return False | ||||
|  | ||||
|         result = True | ||||
|  | ||||
|         # Fail form validation when a body is set for a GET | ||||
|         if self.method.data == 'GET' and self.body.data: | ||||
|             self.body.errors.append('Body must be empty when Request Method is set to GET') | ||||
|             result = False | ||||
|  | ||||
|         # Attempt to validate jinja2 templates in the URL | ||||
|         from jinja2 import Environment | ||||
|         # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||
|         jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|         try: | ||||
|             ready_url = str(jinja2_env.from_string(self.url.data).render()) | ||||
|         except Exception as e: | ||||
|             self.url.errors.append('Invalid template syntax') | ||||
|             result = False | ||||
|         return result | ||||
|  | ||||
|  | ||||
| class SingleExtraProxy(Form): | ||||
|  | ||||
|     # maybe better to set some <script>var.. | ||||
|     proxy_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"}) | ||||
|     proxy_url = StringField('Proxy URL', [validators.Optional()], render_kw={"placeholder": "socks5:// or regular proxy http://user:pass@...:3128", "size":50}) | ||||
|     # @todo do the validation here instead | ||||
|  | ||||
| class SingleExtraBrowser(Form): | ||||
|     browser_name = StringField('Name', [validators.Optional()], render_kw={"placeholder": "Name"}) | ||||
|     browser_connection_url = StringField('Browser connection URL', [validators.Optional()], render_kw={"placeholder": "wss://brightdata... wss://oxylabs etc", "size":50}) | ||||
|     # @todo do the validation here instead | ||||
|  | ||||
|  | ||||
| # datastore.data['settings']['requests'].. | ||||
| class globalSettingsRequestForm(Form): | ||||
|     time_between_check = FormField(TimeBetweenCheckForm) | ||||
|     proxy = RadioField('Proxy') | ||||
|     jitter_seconds = IntegerField('Random jitter seconds ± check', | ||||
|                                   render_kw={"style": "width: 5em;"}, | ||||
|                                   validators=[validators.NumberRange(min=0, message="Should contain zero or more seconds")]) | ||||
|     extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5) | ||||
|     extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5) | ||||
|  | ||||
|     def validate_extra_proxies(self, extra_validators=None): | ||||
|         for e in self.data['extra_proxies']: | ||||
|             if e.get('proxy_name') or e.get('proxy_url'): | ||||
|                 if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip(): | ||||
|                     self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.') | ||||
|                     return False | ||||
|  | ||||
|  | ||||
| # datastore.data['settings']['application'].. | ||||
| class globalSettingsApplicationForm(commonSettingsForm): | ||||
|  | ||||
|     api_access_token_enabled = BooleanField('API access token security check enabled', default=True, validators=[validators.Optional()]) | ||||
|     base_url = StringField('Notification base URL override', | ||||
|                            validators=[validators.Optional()], | ||||
|                            render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')} | ||||
|                            ) | ||||
|     empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False) | ||||
|     fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) | ||||
|     global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) | ||||
|     global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)]) | ||||
|     ignore_whitespace = BooleanField('Ignore whitespace') | ||||
|     password = SaltyPasswordField() | ||||
|     pager_size = IntegerField('Pager size', | ||||
|                               render_kw={"style": "width: 5em;"}, | ||||
|                               validators=[validators.NumberRange(min=0, | ||||
|                                                                  message="Should be atleast zero (disabled)")]) | ||||
|     removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) | ||||
|     shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()]) | ||||
|     filter_failure_notification_threshold_attempts = IntegerField('Number of times the filter can be missing before sending a notification', | ||||
|                                                                   render_kw={"style": "width: 5em;"}, | ||||
|                                                                   validators=[validators.NumberRange(min=0, | ||||
|                                                                                                      message="Should contain zero or more attempts")]) | ||||
|  | ||||
|  | ||||
| class globalSettingsForm(Form): | ||||
|     # Define these as FormFields/"sub forms", this way it matches the JSON storage | ||||
|     # datastore.data['settings']['application'].. | ||||
|     # datastore.data['settings']['requests'].. | ||||
|  | ||||
|     requests = FormField(globalSettingsRequestForm) | ||||
|     application = FormField(globalSettingsApplicationForm) | ||||
|     save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
| class extractDataForm(Form): | ||||
|     extract_regex = StringField('RegEx to extract', validators=[validators.Length(min=1, message="Needs a RegEx")]) | ||||
|     extract_submit_button = SubmitField('Extract as CSV', render_kw={"class": "pure-button pure-button-primary"}) | ||||
							
								
								
									
										362
									
								
								changedetectionio/html_tools.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,362 @@ | ||||
|  | ||||
| from bs4 import BeautifulSoup | ||||
| from inscriptis import get_text | ||||
| from jsonpath_ng.ext import parse | ||||
| from typing import List | ||||
| from inscriptis.css_profiles import CSS_PROFILES, HtmlElement | ||||
| from inscriptis.html_properties import Display | ||||
| from inscriptis.model.config import ParserConfig | ||||
| from xml.sax.saxutils import escape as xml_escape | ||||
| import json | ||||
| import re | ||||
|  | ||||
|  | ||||
| # HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis | ||||
| TEXT_FILTER_LIST_LINE_SUFFIX = "<br>" | ||||
|  | ||||
| PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$' | ||||
| # 'price' , 'lowPrice', 'highPrice' are usually under here | ||||
| # All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here | ||||
| LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"] | ||||
|  | ||||
| class JSONNotFound(ValueError): | ||||
|     def __init__(self, msg): | ||||
|         ValueError.__init__(self, msg) | ||||
|  | ||||
|  | ||||
| # Doesn't look like python supports forward slash auto enclosure in re.findall | ||||
| # So convert it to inline flag "(?i)foobar" type configuration | ||||
| def perl_style_slash_enclosed_regex_to_options(regex): | ||||
|  | ||||
|     res = re.search(PERL_STYLE_REGEX, regex, re.IGNORECASE) | ||||
|  | ||||
|     if res: | ||||
|         flags = res.group(2) if res.group(2) else 'i' | ||||
|         regex = f"(?{flags}){res.group(1)}" | ||||
|     else: | ||||
|         # Fall back to just ignorecase as an option | ||||
|         regex = f"(?i){regex}" | ||||
|  | ||||
|     return regex | ||||
|  | ||||
| # Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches | ||||
| def include_filters(include_filters, html_content, append_pretty_line_formatting=False): | ||||
|     soup = BeautifulSoup(html_content, "html.parser") | ||||
|     html_block = "" | ||||
|     r = soup.select(include_filters, separator="") | ||||
|  | ||||
|     for element in r: | ||||
|         # When there's more than 1 match, then add the suffix to separate each line | ||||
|         # And where the matched result doesn't include something that will cause Inscriptis to add a newline | ||||
|         # (This way each 'match' reliably has a new-line in the diff) | ||||
|         # Divs are converted to 4 whitespaces by inscriptis | ||||
|         if append_pretty_line_formatting and len(html_block) and not element.name in (['br', 'hr', 'div', 'p']): | ||||
|             html_block += TEXT_FILTER_LIST_LINE_SUFFIX | ||||
|  | ||||
|         html_block += str(element) | ||||
|  | ||||
|     return html_block | ||||
|  | ||||
| def subtractive_css_selector(css_selector, html_content): | ||||
|     soup = BeautifulSoup(html_content, "html.parser") | ||||
|     for item in soup.select(css_selector): | ||||
|         item.decompose() | ||||
|     return str(soup) | ||||
|  | ||||
|  | ||||
| def element_removal(selectors: List[str], html_content): | ||||
|     """Joins individual filters into one css filter.""" | ||||
|     selector = ",".join(selectors) | ||||
|     return subtractive_css_selector(selector, html_content) | ||||
|  | ||||
|  | ||||
| # Return str Utf-8 of matched rules | ||||
| def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_rss=False): | ||||
|     from lxml import etree, html | ||||
|  | ||||
|     parser = None | ||||
|     if is_rss: | ||||
|         # So that we can keep CDATA for cdata_in_document_to_text() to process | ||||
|         parser = etree.XMLParser(strip_cdata=False) | ||||
|  | ||||
|     tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser) | ||||
|     html_block = "" | ||||
|  | ||||
|     r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}) | ||||
|     #@note: //title/text() wont work where <title>CDATA.. | ||||
|  | ||||
|     for element in r: | ||||
|         # When there's more than 1 match, then add the suffix to separate each line | ||||
|         # And where the matched result doesn't include something that will cause Inscriptis to add a newline | ||||
|         # (This way each 'match' reliably has a new-line in the diff) | ||||
|         # Divs are converted to 4 whitespaces by inscriptis | ||||
|         if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])): | ||||
|             html_block += TEXT_FILTER_LIST_LINE_SUFFIX | ||||
|  | ||||
|         if type(element) == etree._ElementStringResult: | ||||
|             html_block += str(element) | ||||
|         elif type(element) == etree._ElementUnicodeResult: | ||||
|             html_block += str(element) | ||||
|         else: | ||||
|             html_block += etree.tostring(element, pretty_print=True).decode('utf-8') | ||||
|  | ||||
|     return html_block | ||||
|  | ||||
| # 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 | ||||
|  | ||||
| # | ||||
| def _parse_json(json_data, json_filter): | ||||
|     if 'json:' in json_filter: | ||||
|         jsonpath_expression = parse(json_filter.replace('json:', '')) | ||||
|         match = jsonpath_expression.find(json_data) | ||||
|         return _get_stripped_text_from_json_match(match) | ||||
|  | ||||
|     if 'jq:' in json_filter: | ||||
|  | ||||
|         try: | ||||
|             import jq | ||||
|         except ModuleNotFoundError: | ||||
|             # `jq` requires full compilation in windows and so isn't generally available | ||||
|             raise Exception("jq not support not found") | ||||
|  | ||||
|         jq_expression = jq.compile(json_filter.replace('jq:', '')) | ||||
|         match = jq_expression.input(json_data).all() | ||||
|  | ||||
|         return _get_stripped_text_from_json_match(match) | ||||
|  | ||||
| def _get_stripped_text_from_json_match(match): | ||||
|     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 if hasattr(i, 'value') else i) | ||||
|  | ||||
|     # 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 if hasattr(match[0], 'value') else match[0] | ||||
|  | ||||
|     # Re #257 - Better handling where it does not exist, in the case the original 's' value was False.. | ||||
|     if not match: | ||||
|         # Re 265 - Just return an empty string when filter not found | ||||
|         return '' | ||||
|  | ||||
|     # Ticket #462 - allow the original encoding through, usually it's UTF-8 or similar | ||||
|     stripped_text_from_html = json.dumps(s, indent=4, ensure_ascii=False) | ||||
|  | ||||
|     return stripped_text_from_html | ||||
|  | ||||
| # content - json | ||||
| # json_filter - ie json:$..price | ||||
| # ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector) | ||||
| def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None): | ||||
|     stripped_text_from_html = False | ||||
|  | ||||
|     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags | ||||
|     try: | ||||
|         stripped_text_from_html = _parse_json(json.loads(content), json_filter) | ||||
|     except json.JSONDecodeError: | ||||
|  | ||||
|         # Foreach <script json></script> blob.. just return the first that matches json_filter | ||||
|         # As a last resort, try to parse the whole <body> | ||||
|         soup = BeautifulSoup(content, 'html.parser') | ||||
|  | ||||
|         if ensure_is_ldjson_info_type: | ||||
|             bs_result = soup.findAll('script', {"type": "application/ld+json"}) | ||||
|         else: | ||||
|             bs_result = soup.findAll('script') | ||||
|         bs_result += soup.findAll('body') | ||||
|  | ||||
|         bs_jsons = [] | ||||
|         for result in bs_result: | ||||
|             # Skip empty tags, and things that dont even look like JSON | ||||
|             if not result.text or '{' not in result.text: | ||||
|                 continue | ||||
|             try: | ||||
|                 json_data = json.loads(result.text) | ||||
|                 bs_jsons.append(json_data) | ||||
|             except json.JSONDecodeError: | ||||
|                 # Skip objects which cannot be parsed | ||||
|                 continue | ||||
|  | ||||
|         if not bs_jsons: | ||||
|             raise JSONNotFound("No parsable JSON found in this document") | ||||
|          | ||||
|         for json_data in bs_jsons: | ||||
|             stripped_text_from_html = _parse_json(json_data, json_filter) | ||||
|  | ||||
|             if ensure_is_ldjson_info_type: | ||||
|                 # Could sometimes be list, string or something else random | ||||
|                 if isinstance(json_data, dict): | ||||
|                     # If it has LD JSON 'key' @type, and @type is 'product', and something was found for the search | ||||
|                     # (Some sites have multiple of the same ld+json @type='product', but some have the review part, some have the 'price' part) | ||||
|                     # @type could also be a list (Product, SubType) | ||||
|                     # LD_JSON auto-extract also requires some content PLUS the ldjson to be present | ||||
|                     # 1833 - could be either str or dict, should not be anything else | ||||
|                     if json_data.get('@type') and stripped_text_from_html: | ||||
|                         try: | ||||
|                             if json_data.get('@type') == str or json_data.get('@type') == dict: | ||||
|                                 types = [json_data.get('@type')] if isinstance(json_data.get('@type'), str) else json_data.get('@type') | ||||
|                                 if ensure_is_ldjson_info_type.lower() in [x.lower().strip() for x in types]: | ||||
|                                     break | ||||
|                         except: | ||||
|                             continue | ||||
|  | ||||
|             elif stripped_text_from_html: | ||||
|                 break | ||||
|  | ||||
|     if not stripped_text_from_html: | ||||
|         # Re 265 - Just return an empty string when filter not found | ||||
|         return '' | ||||
|  | ||||
|     return stripped_text_from_html | ||||
|  | ||||
| # Mode     - "content" return the content without the matches (default) | ||||
| #          - "line numbers" return a list of line numbers that match (int list) | ||||
| # | ||||
| # wordlist - list of regex's (str) or words (str) | ||||
| def strip_ignore_text(content, wordlist, mode="content"): | ||||
|     i = 0 | ||||
|     output = [] | ||||
|     ignore_text = [] | ||||
|     ignore_regex = [] | ||||
|     ignored_line_numbers = [] | ||||
|  | ||||
|     for k in wordlist: | ||||
|         # Is it a regex? | ||||
|         res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE) | ||||
|         if res: | ||||
|             ignore_regex.append(re.compile(perl_style_slash_enclosed_regex_to_options(k))) | ||||
|         else: | ||||
|             ignore_text.append(k.strip()) | ||||
|  | ||||
|     for line in content.splitlines(): | ||||
|         i += 1 | ||||
|         # Always ignore blank lines in this mode. (when this function gets called) | ||||
|         got_match = False | ||||
|         if len(line.strip()): | ||||
|             for l in ignore_text: | ||||
|                 if l.lower() in line.lower(): | ||||
|                     got_match = True | ||||
|  | ||||
|             if not got_match: | ||||
|                 for r in ignore_regex: | ||||
|                     if r.search(line): | ||||
|                         got_match = True | ||||
|  | ||||
|             if not got_match: | ||||
|                 # Not ignored | ||||
|                 output.append(line.encode('utf8')) | ||||
|             else: | ||||
|                 ignored_line_numbers.append(i) | ||||
|  | ||||
|  | ||||
|     # Used for finding out what to highlight | ||||
|     if mode == "line numbers": | ||||
|         return ignored_line_numbers | ||||
|  | ||||
|     return "\n".encode('utf8').join(output) | ||||
|  | ||||
| def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str: | ||||
|     pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>' | ||||
|     def repl(m): | ||||
|         text = m.group(1) | ||||
|         return xml_escape(html_to_text(html_content=text)).strip() | ||||
|  | ||||
|     return re.sub(pattern, repl, html_content) | ||||
|  | ||||
| def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str: | ||||
|     """Converts html string to a string with just the text. If ignoring | ||||
|     rendering anchor tag content is enable, anchor tag content are also | ||||
|     included in the text | ||||
|  | ||||
|     :param html_content: string with html content | ||||
|     :param render_anchor_tag_content: boolean flag indicating whether to extract | ||||
|     hyperlinks (the anchor tag content) together with text. This refers to the | ||||
|     'href' inside 'a' tags. | ||||
|     Anchor tag content is rendered in the following manner: | ||||
|     '[ text ](anchor tag content)' | ||||
|     :return: extracted text from the HTML | ||||
|     """ | ||||
|     #  if anchor tag content flag is set to True define a config for | ||||
|     #  extracting this content | ||||
|     if render_anchor_tag_content: | ||||
|         parser_config = ParserConfig( | ||||
|             annotation_rules={"a": ["hyperlink"]}, | ||||
|             display_links=True | ||||
|         ) | ||||
|     # otherwise set config to None/default | ||||
|     else: | ||||
|         parser_config = None | ||||
|  | ||||
|     # RSS Mode - Inscriptis will treat `title` as something else. | ||||
|     # Make it as a regular block display element (//item/title) | ||||
|     # This is a bit of a hack - the real way it to use XSLT to convert it to HTML #1874 | ||||
|     if is_rss: | ||||
|         html_content = re.sub(r'<title([\s>])', r'<h1\1', html_content) | ||||
|         html_content = re.sub(r'</title>', r'</h1>', html_content) | ||||
|  | ||||
|     text_content = get_text(html_content, config=parser_config) | ||||
|  | ||||
|     return text_content | ||||
|  | ||||
|  | ||||
| # Does LD+JSON exist with a @type=='product' and a .price set anywhere? | ||||
| def has_ldjson_product_info(content): | ||||
|     pricing_data = '' | ||||
|  | ||||
|     try: | ||||
|         if not 'application/ld+json' in content: | ||||
|             return False | ||||
|  | ||||
|         for filter in LD_JSON_PRODUCT_OFFER_SELECTORS: | ||||
|             pricing_data += extract_json_as_string(content=content, | ||||
|                                                   json_filter=filter, | ||||
|                                                   ensure_is_ldjson_info_type="product") | ||||
|  | ||||
|     except Exception as e: | ||||
|         # Totally fine | ||||
|         return False | ||||
|     x=bool(pricing_data) | ||||
|     return x | ||||
|  | ||||
|  | ||||
| def workarounds_for_obfuscations(content): | ||||
|     """ | ||||
|     Some sites are using sneaky tactics to make prices and other information un-renderable by Inscriptis | ||||
|     This could go into its own Pip package in the future, for faster updates | ||||
|     """ | ||||
|  | ||||
|     # HomeDepot.com style <span>$<!-- -->90<!-- -->.<!-- -->74</span> | ||||
|     # https://github.com/weblyzard/inscriptis/issues/45 | ||||
|     if not content: | ||||
|         return content | ||||
|  | ||||
|     content = re.sub('<!--\s+-->', '', content) | ||||
|  | ||||
|     return content | ||||
|  | ||||
|  | ||||
| def get_triggered_text(content, trigger_text): | ||||
|     triggered_text = [] | ||||
|     result = strip_ignore_text(content=content, | ||||
|                                wordlist=trigger_text, | ||||
|                                mode="line numbers") | ||||
|  | ||||
|     i = 1 | ||||
|     for p in content.splitlines(): | ||||
|         if i in result: | ||||
|             triggered_text.append(p) | ||||
|         i += 1 | ||||
|  | ||||
|     return triggered_text | ||||
							
								
								
									
										302
									
								
								changedetectionio/importer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,302 @@ | ||||
| from abc import ABC, abstractmethod | ||||
| import time | ||||
| import validators | ||||
| from wtforms import ValidationError | ||||
|  | ||||
| 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 failed on local hostnames (such as referring to ourself when using browserless) | ||||
|             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: | ||||
|                         print(">> 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: | ||||
|                 print(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 .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: | ||||
|                             print(">> 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: | ||||
|             print(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)) | ||||
							
								
								
									
										66
									
								
								changedetectionio/model/App.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,66 @@ | ||||
| from os import getenv | ||||
| from changedetectionio.notification import ( | ||||
|     default_notification_body, | ||||
|     default_notification_format, | ||||
|     default_notification_title, | ||||
| ) | ||||
|  | ||||
| _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6 | ||||
|  | ||||
| class model(dict): | ||||
|     base_config = { | ||||
|             'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!", | ||||
|             'watching': {}, | ||||
|             'settings': { | ||||
|                 'headers': { | ||||
|                 }, | ||||
|                 'requests': { | ||||
|                     'extra_proxies': [], # Configurable extra proxies via the UI | ||||
|                     'extra_browsers': [],  # Configurable extra proxies via the UI | ||||
|                     'jitter_seconds': 0, | ||||
|                     'proxy': None, # Preferred proxy connection | ||||
|                     'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None}, | ||||
|                     'timeout': int(getenv("DEFAULT_SETTINGS_REQUESTS_TIMEOUT", "45")),  # Default 45 seconds | ||||
|                     'workers': int(getenv("DEFAULT_SETTINGS_REQUESTS_WORKERS", "10")),  # Number of threads, lower is better for slow connections | ||||
|                 }, | ||||
|                 'application': { | ||||
|                     # Custom notification content | ||||
|                     'api_access_token_enabled': True, | ||||
|                     'base_url' : None, | ||||
|                     'empty_pages_are_a_change': False, | ||||
|                     'extract_title_as_title': False, | ||||
|                     'fetch_backend': getenv("DEFAULT_FETCH_BACKEND", "html_requests"), | ||||
|                     'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT, | ||||
|                     'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum | ||||
|                     'global_subtractive_selectors': [], | ||||
|                     'ignore_whitespace': True, | ||||
|                     'notification_body': default_notification_body, | ||||
|                     'notification_format': default_notification_format, | ||||
|                     'notification_title': default_notification_title, | ||||
|                     'notification_urls': [], # Apprise URL list | ||||
|                     'pager_size': 50, | ||||
|                     'password': False, | ||||
|                     'render_anchor_tag_content': False, | ||||
|                     'schema_version' : 0, | ||||
|                     'shared_diff_access': False, | ||||
|                     'webdriver_delay': None , # Extra delay in seconds before extracting text | ||||
|                     'tags': {} #@todo use Tag.model initialisers | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|         self.update(self.base_config) | ||||
|  | ||||
|  | ||||
| def parse_headers_from_text_file(filepath): | ||||
|     headers = {} | ||||
|     with open(filepath, 'r') as f: | ||||
|         for l in f.readlines(): | ||||
|             l = l.strip() | ||||
|             if not l.startswith('#') and ':' in l: | ||||
|                 (k, v) = l.split(':') | ||||
|                 headers[k.strip()] = v.strip() | ||||
|  | ||||
|     return headers | ||||
							
								
								
									
										19
									
								
								changedetectionio/model/Tag.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| from .Watch import base_config | ||||
| import uuid | ||||
|  | ||||
| class model(dict): | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|  | ||||
|         self.update(base_config) | ||||
|  | ||||
|         self['uuid'] = str(uuid.uuid4()) | ||||
|  | ||||
|         if kw.get('default'): | ||||
|             self.update(kw['default']) | ||||
|             del kw['default'] | ||||
|  | ||||
|  | ||||
|         # Goes at the end so we update the default object with the initialiser | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|  | ||||
							
								
								
									
										519
									
								
								changedetectionio/model/Watch.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,519 @@ | ||||
| from distutils.util import strtobool | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
| import time | ||||
| import uuid | ||||
| from pathlib import Path | ||||
|  | ||||
| # Allowable protocols, protects against javascript: etc | ||||
| # file:// is further checked by ALLOW_FILE_URI | ||||
| SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):' | ||||
|  | ||||
| minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60)) | ||||
| mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} | ||||
|  | ||||
| from changedetectionio.notification import ( | ||||
|     default_notification_format_for_watch | ||||
| ) | ||||
|  | ||||
| base_config = { | ||||
|     'body': None, | ||||
|     'browser_steps': [], | ||||
|     'browser_steps_last_error_step': None, | ||||
|     'check_unique_lines': False,  # On change-detected, compare against all history if its something new | ||||
|     'check_count': 0, | ||||
|     'date_created': None, | ||||
|     'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine. | ||||
|     'extract_text': [],  # Extract text by regex after filters | ||||
|     'extract_title_as_title': False, | ||||
|     'fetch_backend': 'system', # plaintext, playwright etc | ||||
|     'fetch_time': 0.0, | ||||
|     'processor': 'text_json_diff', # could be restock_diff or others from .processors | ||||
|     'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), | ||||
|     'filter_text_added': True, | ||||
|     'filter_text_replaced': True, | ||||
|     'filter_text_removed': True, | ||||
|     'has_ldjson_price_data': None, | ||||
|     'track_ldjson_price_data': None, | ||||
|     'headers': {},  # Extra headers to send | ||||
|     'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||
|     'in_stock_only' : True, # Only trigger change on going to instock from out-of-stock | ||||
|     'include_filters': [], | ||||
|     'last_checked': 0, | ||||
|     'last_error': False, | ||||
|     'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||
|     'method': 'GET', | ||||
|     # Custom notification content | ||||
|     'notification_body': None, | ||||
|     'notification_format': default_notification_format_for_watch, | ||||
|     'notification_muted': False, | ||||
|     'notification_title': None, | ||||
|     'notification_screenshot': False,  # Include the latest screenshot if available and supported by the apprise URL | ||||
|     'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||
|     'paused': False, | ||||
|     'previous_md5': False, | ||||
|     'previous_md5_before_filters': False,  # Used for skipping changedetection entirely | ||||
|     'proxy': None,  # Preferred proxy connection | ||||
|     'subtractive_selectors': [], | ||||
|     'tag': '', # Old system of text name for a tag, to be removed | ||||
|     'tags': [], # list of UUIDs to App.Tags | ||||
|     'text_should_not_be_present': [],  # Text that should not present | ||||
|     # 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 | ||||
|     # Should be all None by default, so we use the system default in this case. | ||||
|     'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None}, | ||||
|     'title': None, | ||||
|     'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||
|     'url': '', | ||||
|     'uuid': str(uuid.uuid4()), | ||||
|     'webdriver_delay': None, | ||||
|     'webdriver_js_execute_code': None,  # Run before change-detection | ||||
| } | ||||
|  | ||||
|  | ||||
| def is_safe_url(test_url): | ||||
|     # See https://github.com/dgtlmoon/changedetection.io/issues/1358 | ||||
|  | ||||
|     # Remove 'source:' prefix so we dont get 'source:javascript:' etc | ||||
|     # 'source:' is a valid way to tell us to return the source | ||||
|  | ||||
|     r = re.compile(re.escape('source:'), re.IGNORECASE) | ||||
|     test_url = r.sub('', test_url) | ||||
|  | ||||
|     pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', SAFE_PROTOCOL_REGEX), re.IGNORECASE) | ||||
|     if not pattern.match(test_url.strip()): | ||||
|         return False | ||||
|  | ||||
|     return True | ||||
|  | ||||
| class model(dict): | ||||
|     __newest_history_key = None | ||||
|     __history_n = 0 | ||||
|     jitter_seconds = 0 | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|  | ||||
|         self.update(base_config) | ||||
|         self.__datastore_path = kw['datastore_path'] | ||||
|  | ||||
|         self['uuid'] = str(uuid.uuid4()) | ||||
|  | ||||
|         del kw['datastore_path'] | ||||
|  | ||||
|         if kw.get('default'): | ||||
|             self.update(kw['default']) | ||||
|             del kw['default'] | ||||
|  | ||||
|         # Be sure the cached timestamp is ready | ||||
|         bump = self.history | ||||
|  | ||||
|         # Goes at the end so we update the default object with the initialiser | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|  | ||||
|     @property | ||||
|     def viewed(self): | ||||
|         if int(self['last_viewed']) >= int(self.newest_history_key) : | ||||
|             return True | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def ensure_data_dir_exists(self): | ||||
|         if not os.path.isdir(self.watch_data_dir): | ||||
|             print ("> Creating data dir {}".format(self.watch_data_dir)) | ||||
|             os.mkdir(self.watch_data_dir) | ||||
|  | ||||
|     @property | ||||
|     def link(self): | ||||
|  | ||||
|         url = self.get('url', '') | ||||
|         if not is_safe_url(url): | ||||
|             return 'DISABLED' | ||||
|  | ||||
|         ready_url = url | ||||
|         if '{%' in url or '{{' in url: | ||||
|             from jinja2 import Environment | ||||
|             # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/ | ||||
|             jinja2_env = Environment(extensions=['jinja2_time.TimeExtension']) | ||||
|             try: | ||||
|                 ready_url = str(jinja2_env.from_string(url).render()) | ||||
|             except Exception as e: | ||||
|                 from flask import ( | ||||
|                     flash, Markup, url_for | ||||
|                 ) | ||||
|                 message = Markup('<a href="{}#general">The URL {} is invalid and cannot be used, click to edit</a>'.format( | ||||
|                     url_for('edit_page', uuid=self.get('uuid')), self.get('url', ''))) | ||||
|                 flash(message, 'error') | ||||
|                 return '' | ||||
|  | ||||
|         if ready_url.startswith('source:'): | ||||
|             ready_url=ready_url.replace('source:', '') | ||||
|         return ready_url | ||||
|  | ||||
|     @property | ||||
|     def is_source_type_url(self): | ||||
|         return self.get('url', '').startswith('source:') | ||||
|  | ||||
|     @property | ||||
|     def get_fetch_backend(self): | ||||
|         """ | ||||
|         Like just using the `fetch_backend` key but there could be some logic | ||||
|         :return: | ||||
|         """ | ||||
|         # Maybe also if is_image etc? | ||||
|         # This is because chrome/playwright wont render the PDF in the browser and we will just fetch it and use pdf2html to see the text. | ||||
|         if self.is_pdf: | ||||
|             return 'html_requests' | ||||
|  | ||||
|         return self.get('fetch_backend') | ||||
|  | ||||
|     @property | ||||
|     def is_pdf(self): | ||||
|         # content_type field is set in the future | ||||
|         # https://github.com/dgtlmoon/changedetection.io/issues/1392 | ||||
|         # Not sure the best logic here | ||||
|         return self.get('url', '').lower().endswith('.pdf') or 'pdf' in self.get('content_type', '').lower() | ||||
|  | ||||
|     @property | ||||
|     def label(self): | ||||
|         # Used for sorting | ||||
|         return self.get('title') if self.get('title') else self.get('url') | ||||
|  | ||||
|     @property | ||||
|     def last_changed(self): | ||||
|         # last_changed will be the newest snapshot, but when we have just one snapshot, it should be 0 | ||||
|         if self.__history_n <= 1: | ||||
|             return 0 | ||||
|         if self.__newest_history_key: | ||||
|             return int(self.__newest_history_key) | ||||
|         return 0 | ||||
|  | ||||
|     @property | ||||
|     def history_n(self): | ||||
|         return self.__history_n | ||||
|  | ||||
|     @property | ||||
|     def history(self): | ||||
|         """History index is just a text file as a list | ||||
|             {watch-uuid}/history.txt | ||||
|  | ||||
|             contains a list like | ||||
|  | ||||
|             {epoch-time},{filename}\n | ||||
|  | ||||
|             We read in this list as the history information | ||||
|  | ||||
|         """ | ||||
|         tmp_history = {} | ||||
|  | ||||
|         # Read the history file as a dict | ||||
|         fname = os.path.join(self.watch_data_dir, "history.txt") | ||||
|         if os.path.isfile(fname): | ||||
|             logging.debug("Reading history index " + str(time.time())) | ||||
|             with open(fname, "r") as f: | ||||
|                 for i in f.readlines(): | ||||
|                     if ',' in i: | ||||
|                         k, v = i.strip().split(',', 2) | ||||
|  | ||||
|                         # The index history could contain a relative path, so we need to make the fullpath | ||||
|                         # so that python can read it | ||||
|                         if not '/' in v and not '\'' in v: | ||||
|                             v = os.path.join(self.watch_data_dir, v) | ||||
|                         else: | ||||
|                             # It's possible that they moved the datadir on older versions | ||||
|                             # So the snapshot exists but is in a different path | ||||
|                             snapshot_fname = v.split('/')[-1] | ||||
|                             proposed_new_path = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|                             if not os.path.exists(v) and os.path.exists(proposed_new_path): | ||||
|                                 v = proposed_new_path | ||||
|  | ||||
|                         tmp_history[k] = v | ||||
|  | ||||
|         if len(tmp_history): | ||||
|             self.__newest_history_key = list(tmp_history.keys())[-1] | ||||
|  | ||||
|         self.__history_n = len(tmp_history) | ||||
|  | ||||
|         return tmp_history | ||||
|  | ||||
|     @property | ||||
|     def has_history(self): | ||||
|         fname = os.path.join(self.watch_data_dir, "history.txt") | ||||
|         return os.path.isfile(fname) | ||||
|  | ||||
|     @property | ||||
|     def has_browser_steps(self): | ||||
|         has_browser_steps = self.get('browser_steps') and list(filter( | ||||
|                 lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'), | ||||
|                 self.get('browser_steps'))) | ||||
|  | ||||
|         return  has_browser_steps | ||||
|  | ||||
|     # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0. | ||||
|     @property | ||||
|     def newest_history_key(self): | ||||
|         if self.__newest_history_key is not None: | ||||
|             return self.__newest_history_key | ||||
|  | ||||
|         if len(self.history) <= 1: | ||||
|             return 0 | ||||
|  | ||||
|  | ||||
|         bump = self.history | ||||
|         return self.__newest_history_key | ||||
|  | ||||
|     def get_history_snapshot(self, timestamp): | ||||
|         import brotli | ||||
|         filepath = self.history[timestamp] | ||||
|  | ||||
|         # See if a brotli versions exists and switch to that | ||||
|         if not filepath.endswith('.br') and os.path.isfile(f"{filepath}.br"): | ||||
|             filepath = f"{filepath}.br" | ||||
|  | ||||
|         # OR in the backup case that the .br does not exist, but the plain one does | ||||
|         if filepath.endswith('.br') and not os.path.isfile(filepath): | ||||
|             if os.path.isfile(filepath.replace('.br', '')): | ||||
|                 filepath = filepath.replace('.br', '') | ||||
|  | ||||
|         if filepath.endswith('.br'): | ||||
|             # Brotli doesnt have a fileheader to detect it, so we rely on filename | ||||
|             # https://www.rfc-editor.org/rfc/rfc7932 | ||||
|             with open(filepath, 'rb') as f: | ||||
|                 return(brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|         with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: | ||||
|             return f.read() | ||||
|  | ||||
|     # Save some text file to the appropriate path and bump the history | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, contents, timestamp, snapshot_id): | ||||
|         import brotli | ||||
|  | ||||
|         self.ensure_data_dir_exists() | ||||
|  | ||||
|         # Small hack so that we sleep just enough to allow 1 second  between history snapshots | ||||
|         # this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys | ||||
|         if self.__newest_history_key and int(timestamp) == int(self.__newest_history_key): | ||||
|             time.sleep(timestamp - self.__newest_history_key) | ||||
|  | ||||
|         threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) | ||||
|         skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) | ||||
|  | ||||
|         if not skip_brotli and len(contents) > threshold: | ||||
|             snapshot_fname = f"{snapshot_id}.txt.br" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(brotli.compress(contents, mode=brotli.MODE_TEXT)) | ||||
|         else: | ||||
|             snapshot_fname = f"{snapshot_id}.txt" | ||||
|             dest = os.path.join(self.watch_data_dir, snapshot_fname) | ||||
|             if not os.path.exists(dest): | ||||
|                 with open(dest, 'wb') as f: | ||||
|                     f.write(contents) | ||||
|  | ||||
|         # Append to index | ||||
|         # @todo check last char was \n | ||||
|         index_fname = os.path.join(self.watch_data_dir, "history.txt") | ||||
|         with open(index_fname, 'a') as f: | ||||
|             f.write("{},{}\n".format(timestamp, snapshot_fname)) | ||||
|             f.close() | ||||
|  | ||||
|         self.__newest_history_key = timestamp | ||||
|         self.__history_n += 1 | ||||
|  | ||||
|         # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status | ||||
|         return snapshot_fname | ||||
|  | ||||
|     @property | ||||
|     def has_empty_checktime(self): | ||||
|         # using all() + dictionary comprehension | ||||
|         # Check if all values are 0 in dictionary | ||||
|         res = all(x == None or x == False or x==0 for x in self.get('time_between_check', {}).values()) | ||||
|         return res | ||||
|  | ||||
|     def threshold_seconds(self): | ||||
|         seconds = 0 | ||||
|         for m, n in mtable.items(): | ||||
|             x = self.get('time_between_check', {}).get(m, None) | ||||
|             if x: | ||||
|                 seconds += x * n | ||||
|         return seconds | ||||
|  | ||||
|     # Iterate over all history texts and see if something new exists | ||||
|     def lines_contain_something_unique_compared_to_history(self, lines: list): | ||||
|         local_lines = set([l.decode('utf-8').strip().lower() for l in lines]) | ||||
|  | ||||
|         # Compare each lines (set) against each history text file (set) looking for something new.. | ||||
|         existing_history = set({}) | ||||
|         for k, v in self.history.items(): | ||||
|             content = self.get_history_snapshot(k) | ||||
|             alist = set([line.strip().lower() for line in content.splitlines()]) | ||||
|             existing_history = existing_history.union(alist) | ||||
|  | ||||
|         # Check that everything in local_lines(new stuff) already exists in existing_history - it should | ||||
|         # if not, something new happened | ||||
|         return not local_lines.issubset(existing_history) | ||||
|  | ||||
|     def get_screenshot(self): | ||||
|         fname = os.path.join(self.watch_data_dir, "last-screenshot.png") | ||||
|         if os.path.isfile(fname): | ||||
|             return fname | ||||
|  | ||||
|         # False is not an option for AppRise, must be type None | ||||
|         return None | ||||
|  | ||||
|     def __get_file_ctime(self, filename): | ||||
|         fname = os.path.join(self.watch_data_dir, filename) | ||||
|         if os.path.isfile(fname): | ||||
|             return int(os.path.getmtime(fname)) | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def error_text_ctime(self): | ||||
|         return self.__get_file_ctime('last-error.txt') | ||||
|  | ||||
|     @property | ||||
|     def snapshot_text_ctime(self): | ||||
|         if self.history_n==0: | ||||
|             return False | ||||
|  | ||||
|         timestamp = list(self.history.keys())[-1] | ||||
|         return int(timestamp) | ||||
|  | ||||
|     @property | ||||
|     def snapshot_screenshot_ctime(self): | ||||
|         return self.__get_file_ctime('last-screenshot.png') | ||||
|  | ||||
|     @property | ||||
|     def snapshot_error_screenshot_ctime(self): | ||||
|         return self.__get_file_ctime('last-error-screenshot.png') | ||||
|  | ||||
|     @property | ||||
|     def watch_data_dir(self): | ||||
|         # The base dir of the watch data | ||||
|         return os.path.join(self.__datastore_path, self['uuid']) | ||||
|      | ||||
|     def get_error_text(self): | ||||
|         """Return the text saved from a previous request that resulted in a non-200 error""" | ||||
|         fname = os.path.join(self.watch_data_dir, "last-error.txt") | ||||
|         if os.path.isfile(fname): | ||||
|             with open(fname, 'r') as f: | ||||
|                 return f.read() | ||||
|         return False | ||||
|  | ||||
|     def get_error_snapshot(self): | ||||
|         """Return path to the screenshot that resulted in a non-200 error""" | ||||
|         fname = os.path.join(self.watch_data_dir, "last-error-screenshot.png") | ||||
|         if os.path.isfile(fname): | ||||
|             return fname | ||||
|         return False | ||||
|  | ||||
|  | ||||
|     def pause(self): | ||||
|         self['paused'] = True | ||||
|  | ||||
|     def unpause(self): | ||||
|         self['paused'] = False | ||||
|  | ||||
|     def toggle_pause(self): | ||||
|         self['paused'] ^= True | ||||
|  | ||||
|     def mute(self): | ||||
|         self['notification_muted'] = True | ||||
|  | ||||
|     def unmute(self): | ||||
|         self['notification_muted'] = False | ||||
|  | ||||
|     def toggle_mute(self): | ||||
|         self['notification_muted'] ^= True | ||||
|  | ||||
|     def extract_regex_from_all_history(self, regex): | ||||
|         import csv | ||||
|         import re | ||||
|         import datetime | ||||
|         csv_output_filename = False | ||||
|         csv_writer = False | ||||
|         f = None | ||||
|  | ||||
|         # self.history will be keyed with the full path | ||||
|         for k, fname in self.history.items(): | ||||
|             if os.path.isfile(fname): | ||||
|                 if True: | ||||
|                     contents = self.get_history_snapshot(k) | ||||
|                     res = re.findall(regex, contents, re.MULTILINE) | ||||
|                     if res: | ||||
|                         if not csv_writer: | ||||
|                             # A file on the disk can be transferred much faster via flask than a string reply | ||||
|                             csv_output_filename = 'report.csv' | ||||
|                             f = open(os.path.join(self.watch_data_dir, csv_output_filename), 'w') | ||||
|                             # @todo some headers in the future | ||||
|                             #fieldnames = ['Epoch seconds', 'Date'] | ||||
|                             csv_writer = csv.writer(f, | ||||
|                                                     delimiter=',', | ||||
|                                                     quotechar='"', | ||||
|                                                     quoting=csv.QUOTE_MINIMAL, | ||||
|                                                     #fieldnames=fieldnames | ||||
|                                                     ) | ||||
|                             csv_writer.writerow(['Epoch seconds', 'Date']) | ||||
|                             # csv_writer.writeheader() | ||||
|  | ||||
|                         date_str = datetime.datetime.fromtimestamp(int(k)).strftime('%Y-%m-%d %H:%M:%S') | ||||
|                         for r in res: | ||||
|                             row = [k, date_str] | ||||
|                             if isinstance(r, str): | ||||
|                                 row.append(r) | ||||
|                             else: | ||||
|                                 row+=r | ||||
|                             csv_writer.writerow(row) | ||||
|  | ||||
|         if f: | ||||
|             f.close() | ||||
|  | ||||
|         return csv_output_filename | ||||
|  | ||||
|  | ||||
|     def has_special_diff_filter_options_set(self): | ||||
|  | ||||
|         # All False - nothing would be done, so act like it's not processable | ||||
|         if not self.get('filter_text_added', True) and not self.get('filter_text_replaced', True) and not self.get('filter_text_removed', True): | ||||
|             return False | ||||
|  | ||||
|         # Or one is set | ||||
|         if not self.get('filter_text_added', True) or not self.get('filter_text_replaced', True) or not self.get('filter_text_removed', True): | ||||
|             return True | ||||
|  | ||||
|         # None is set | ||||
|         return False | ||||
|  | ||||
|  | ||||
|     def get_last_fetched_before_filters(self): | ||||
|         import brotli | ||||
|         filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') | ||||
|  | ||||
|         if not os.path.isfile(filepath): | ||||
|             # If a previous attempt doesnt yet exist, just snarf the previous snapshot instead | ||||
|             dates = list(self.history.keys()) | ||||
|             if len(dates): | ||||
|                 return self.get_history_snapshot(dates[-1]) | ||||
|             else: | ||||
|                 return '' | ||||
|  | ||||
|         with open(filepath, 'rb') as f: | ||||
|             return(brotli.decompress(f.read()).decode('utf-8')) | ||||
|  | ||||
|     def save_last_fetched_before_filters(self, contents): | ||||
|         import brotli | ||||
|         filepath = os.path.join(self.watch_data_dir, 'last-fetched.br') | ||||
|         with open(filepath, 'wb') as f: | ||||
|             f.write(brotli.compress(contents, mode=brotli.MODE_TEXT)) | ||||
|  | ||||
|     @property | ||||
|     def get_browsersteps_available_screenshots(self): | ||||
|         "For knowing which screenshots are available to show the user in BrowserSteps UI" | ||||
|         available = [] | ||||
|         for f in Path(self.watch_data_dir).glob('step_before-*.jpeg'): | ||||
|             step_n=re.search(r'step_before-(\d+)', f.name) | ||||
|             if step_n: | ||||
|                 available.append(step_n.group(1)) | ||||
|         return available | ||||
							
								
								
									
										0
									
								
								changedetectionio/model/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										241
									
								
								changedetectionio/notification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,241 @@ | ||||
| import apprise | ||||
| from jinja2 import Environment, BaseLoader | ||||
| from apprise import NotifyFormat | ||||
| import json | ||||
|  | ||||
| valid_tokens = { | ||||
|     'base_url': '', | ||||
|     'current_snapshot': '', | ||||
|     'diff': '', | ||||
|     'diff_added': '', | ||||
|     'diff_full': '', | ||||
|     'diff_patch': '', | ||||
|     'diff_removed': '', | ||||
|     'diff_url': '', | ||||
|     'preview_url': '', | ||||
|     'triggered_text': '', | ||||
|     'watch_tag': '', | ||||
|     'watch_title': '', | ||||
|     'watch_url': '', | ||||
|     'watch_uuid': '', | ||||
| } | ||||
|  | ||||
| default_notification_format_for_watch = 'System default' | ||||
| default_notification_format = 'Text' | ||||
| default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n' | ||||
| default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}' | ||||
|  | ||||
| valid_notification_formats = { | ||||
|     'Text': NotifyFormat.TEXT, | ||||
|     'Markdown': NotifyFormat.MARKDOWN, | ||||
|     'HTML': NotifyFormat.HTML, | ||||
|     # Used only for editing a watch (not for global) | ||||
|     default_notification_format_for_watch: default_notification_format_for_watch | ||||
| } | ||||
|  | ||||
| # include the decorator | ||||
| from apprise.decorators import notify | ||||
|  | ||||
| @notify(on="delete") | ||||
| @notify(on="deletes") | ||||
| @notify(on="get") | ||||
| @notify(on="gets") | ||||
| @notify(on="post") | ||||
| @notify(on="posts") | ||||
| @notify(on="put") | ||||
| @notify(on="puts") | ||||
| def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|     import requests | ||||
|     url = kwargs['meta'].get('url') | ||||
|  | ||||
|     if url.startswith('post'): | ||||
|         r = requests.post | ||||
|     elif url.startswith('get'): | ||||
|         r = requests.get | ||||
|     elif url.startswith('put'): | ||||
|         r = requests.put | ||||
|     elif url.startswith('delete'): | ||||
|         r = requests.delete | ||||
|  | ||||
|     url = url.replace('post://', 'http://') | ||||
|     url = url.replace('posts://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('get://', 'http://') | ||||
|     url = url.replace('gets://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('delete://', 'http://') | ||||
|     url = url.replace('deletes://', 'https://') | ||||
|  | ||||
|     # Try to auto-guess if it's JSON | ||||
|     headers = {} | ||||
|     try: | ||||
|         json.loads(body) | ||||
|         headers = {'Content-Type': 'application/json; charset=utf-8'} | ||||
|     except ValueError as e: | ||||
|         pass | ||||
|  | ||||
|  | ||||
|     r(url, headers=headers, data=body) | ||||
|  | ||||
|  | ||||
| def process_notification(n_object, datastore): | ||||
|  | ||||
|     # Insert variables into the notification content | ||||
|     notification_parameters = create_notification_parameters(n_object, datastore) | ||||
|  | ||||
|     # Get the notification body from datastore | ||||
|     jinja2_env = Environment(loader=BaseLoader) | ||||
|     n_body = jinja2_env.from_string(n_object.get('notification_body', default_notification_body)).render(**notification_parameters) | ||||
|     n_title = jinja2_env.from_string(n_object.get('notification_title', default_notification_title)).render(**notification_parameters) | ||||
|     n_format = valid_notification_formats.get( | ||||
|         n_object.get('notification_format', default_notification_format), | ||||
|         valid_notification_formats[default_notification_format], | ||||
|     ) | ||||
|  | ||||
|     # If we arrived with 'System default' then look it up | ||||
|     if n_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch: | ||||
|         # Initially text or whatever | ||||
|         n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]) | ||||
|  | ||||
|  | ||||
|     # https://github.com/caronc/apprise/wiki/Development_LogCapture | ||||
|     # Anything higher than or equal to WARNING (which covers things like Connection errors) | ||||
|     # raise it as an exception | ||||
|     apobjs=[] | ||||
|     sent_objs=[] | ||||
|     from .apprise_asset import asset | ||||
|     for url in n_object['notification_urls']: | ||||
|         url = jinja2_env.from_string(url).render(**notification_parameters) | ||||
|         apobj = apprise.Apprise(debug=True, asset=asset) | ||||
|         url = url.strip() | ||||
|         if len(url): | ||||
|             print(">> Process Notification: AppRise notifying {}".format(url)) | ||||
|             with apprise.LogCapture(level=apprise.logging.DEBUG) as logs: | ||||
|                 # Re 323 - Limit discord length to their 2000 char limit total or it wont send. | ||||
|                 # Because different notifications may require different pre-processing, run each sequentially :( | ||||
|                 # 2000 bytes minus - | ||||
|                 #     200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers | ||||
|                 #     Length of URL - Incase they specify a longer custom avatar_url | ||||
|  | ||||
|                 # So if no avatar_url is specified, add one so it can be correctly calculated into the total payload | ||||
|                 k = '?' if not '?' in url else '&' | ||||
|                 if not 'avatar_url' in url \ | ||||
|                         and not url.startswith('mail') \ | ||||
|                         and not url.startswith('post') \ | ||||
|                         and not url.startswith('get') \ | ||||
|                         and not url.startswith('delete') \ | ||||
|                         and not url.startswith('put'): | ||||
|                     url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' | ||||
|  | ||||
|                 if url.startswith('tgram://'): | ||||
|                     # Telegram only supports a limit subset of HTML, remove the '<br>' we place in. | ||||
|                     # re https://github.com/dgtlmoon/changedetection.io/issues/555 | ||||
|                     # @todo re-use an existing library we have already imported to strip all non-allowed tags | ||||
|                     n_body = n_body.replace('<br>', '\n') | ||||
|                     n_body = n_body.replace('</br>', '\n') | ||||
|                     # real limit is 4096, but minus some for extra metadata | ||||
|                     payload_max_size = 3600 | ||||
|                     body_limit = max(0, payload_max_size - len(n_title)) | ||||
|                     n_title = n_title[0:payload_max_size] | ||||
|                     n_body = n_body[0:body_limit] | ||||
|  | ||||
|                 elif url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks') or url.startswith('https://discord.com/api'): | ||||
|                     # real limit is 2000, but minus some for extra metadata | ||||
|                     payload_max_size = 1700 | ||||
|                     body_limit = max(0, payload_max_size - len(n_title)) | ||||
|                     n_title = n_title[0:payload_max_size] | ||||
|                     n_body = n_body[0:body_limit] | ||||
|  | ||||
|                 elif url.startswith('mailto'): | ||||
|                     # Apprise will default to HTML, so we need to override it | ||||
|                     # So that whats' generated in n_body is in line with what is going to be sent. | ||||
|                     # https://github.com/caronc/apprise/issues/633#issuecomment-1191449321 | ||||
|                     if not 'format=' in url and (n_format == 'Text' or n_format == 'Markdown'): | ||||
|                         prefix = '?' if not '?' in url else '&' | ||||
|                         # Apprise format is lowercase text https://github.com/caronc/apprise/issues/633 | ||||
|                         n_format = n_format.tolower() | ||||
|                         url = "{}{}format={}".format(url, prefix, n_format) | ||||
|                     # If n_format == HTML, then apprise email should default to text/html and we should be sending HTML only | ||||
|  | ||||
|                 apobj.add(url) | ||||
|  | ||||
|                 apobj.notify( | ||||
|                     title=n_title, | ||||
|                     body=n_body, | ||||
|                     body_format=n_format, | ||||
|                     # False is not an option for AppRise, must be type None | ||||
|                     attach=n_object.get('screenshot', None) | ||||
|                 ) | ||||
|  | ||||
|                 apobj.clear() | ||||
|  | ||||
|                 # Incase it needs to exist in memory for a while after to process(?) | ||||
|                 apobjs.append(apobj) | ||||
|  | ||||
|                 # Returns empty string if nothing found, multi-line string otherwise | ||||
|                 log_value = logs.getvalue() | ||||
|                 if log_value and 'WARNING' in log_value or 'ERROR' in log_value: | ||||
|                     raise Exception(log_value) | ||||
|  | ||||
|                 sent_objs.append({'title': n_title, | ||||
|                                   'body': n_body, | ||||
|                                   'url' : url, | ||||
|                                   'body_format': n_format}) | ||||
|  | ||||
|     # Return what was sent for better logging - after the for loop | ||||
|     return sent_objs | ||||
|  | ||||
|  | ||||
| # Notification title + body content parameters get created here. | ||||
| def create_notification_parameters(n_object, datastore): | ||||
|     from copy import deepcopy | ||||
|  | ||||
|     # 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 '' | ||||
|  | ||||
|     if uuid != '': | ||||
|         watch_title = datastore.data['watching'][uuid].get('title', '') | ||||
|         tag_list = [] | ||||
|         tags = datastore.get_all_tags_for_watch(uuid) | ||||
|         if tags: | ||||
|             for tag_uuid, tag in tags.items(): | ||||
|                 tag_list.append(tag.get('title')) | ||||
|         watch_tag = ', '.join(tag_list) | ||||
|     else: | ||||
|         watch_title = 'Change Detection' | ||||
|         watch_tag = '' | ||||
|  | ||||
|     # Create URLs to customise the notification with | ||||
|     # active_base_url - set in store.py data property | ||||
|     base_url = datastore.data['settings']['application'].get('active_base_url') | ||||
|  | ||||
|     watch_url = n_object['watch_url'] | ||||
|  | ||||
|     diff_url = "{}/diff/{}".format(base_url, uuid) | ||||
|     preview_url = "{}/preview/{}".format(base_url, uuid) | ||||
|  | ||||
|     # Not sure deepcopy is needed here, but why not | ||||
|     tokens = deepcopy(valid_tokens) | ||||
|  | ||||
|     # Valid_tokens also used as a field validator | ||||
|     tokens.update( | ||||
|         { | ||||
|             'base_url': base_url, | ||||
|             'current_snapshot': n_object['current_snapshot'] if 'current_snapshot' in n_object else '', | ||||
|             'diff': n_object.get('diff', ''),  # Null default in the case we use a test | ||||
|             'diff_added': n_object.get('diff_added', ''),  # Null default in the case we use a test | ||||
|             'diff_full': n_object.get('diff_full', ''),  # Null default in the case we use a test | ||||
|             'diff_patch': n_object.get('diff_patch', ''),  # Null default in the case we use a test | ||||
|             'diff_removed': n_object.get('diff_removed', ''),  # Null default in the case we use a test | ||||
|             'diff_url': diff_url, | ||||
|             'preview_url': preview_url, | ||||
|             'triggered_text': n_object.get('triggered_text', ''), | ||||
|             'watch_tag': watch_tag if watch_tag is not None else '', | ||||
|             'watch_title': watch_title if watch_title is not None else '', | ||||
|             'watch_url': watch_url, | ||||
|             'watch_uuid': uuid, | ||||
|         }) | ||||
|  | ||||
|     return tokens | ||||
							
								
								
									
										11
									
								
								changedetectionio/processors/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| # Change detection post-processors | ||||
|  | ||||
| The concept here is to be able to switch between different domain specific problems to solve. | ||||
|  | ||||
| - `text_json_diff` The traditional text and JSON comparison handler | ||||
| - `restock_diff` Only cares about detecting if a product looks like it has some text that suggests that it's out of stock, otherwise assumes that it's in stock. | ||||
|  | ||||
| Some suggestions for the future | ||||
|  | ||||
| - `graphical`  | ||||
| - `restock_and_price` - extract price AND stock text | ||||
							
								
								
									
										131
									
								
								changedetectionio/processors/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,131 @@ | ||||
| from abc import abstractmethod | ||||
| import os | ||||
| import hashlib | ||||
| import re | ||||
| from changedetectionio import content_fetcher | ||||
| from copy import deepcopy | ||||
| from distutils.util import strtobool | ||||
|  | ||||
| class difference_detection_processor(): | ||||
|  | ||||
|     browser_steps = None | ||||
|     datastore = None | ||||
|     fetcher = None | ||||
|     screenshot = None | ||||
|     watch = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def __init__(self, *args, datastore, watch_uuid, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.datastore = datastore | ||||
|         self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid)) | ||||
|  | ||||
|     def call_browser(self): | ||||
|  | ||||
|         # Protect against file:// access | ||||
|         if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE): | ||||
|             if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|                 raise Exception( | ||||
|                     "file:// type access is denied for security reasons." | ||||
|                 ) | ||||
|  | ||||
|         url = self.watch.link | ||||
|  | ||||
|         # Requests, playwright, other browser via wss:// etc, fetch_extra_something | ||||
|         prefer_fetch_backend = self.watch.get('fetch_backend', 'system') | ||||
|  | ||||
|         # Proxy ID "key" | ||||
|         preferred_proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid')) | ||||
|  | ||||
|         # Pluggable content self.fetcher | ||||
|         if not prefer_fetch_backend or prefer_fetch_backend == 'system': | ||||
|             prefer_fetch_backend = self.datastore.data['settings']['application'].get('fetch_backend') | ||||
|  | ||||
|         # In the case that the preferred fetcher was a browser config with custom connection URL.. | ||||
|         # @todo - on save watch, if its extra_browser_ then it should be obvious it will use playwright (like if its requests now..) | ||||
|         browser_connection_url = None | ||||
|         if prefer_fetch_backend.startswith('extra_browser_'): | ||||
|             (t, key) = prefer_fetch_backend.split('extra_browser_') | ||||
|             connection = list( | ||||
|                 filter(lambda s: (s['browser_name'] == key), self.datastore.data['settings']['requests'].get('extra_browsers', []))) | ||||
|             if connection: | ||||
|                 prefer_fetch_backend = 'base_html_playwright' | ||||
|                 browser_connection_url = connection[0].get('browser_connection_url') | ||||
|  | ||||
|  | ||||
|         # Grab the right kind of 'fetcher', (playwright, requests, etc) | ||||
|         if hasattr(content_fetcher, prefer_fetch_backend): | ||||
|             fetcher_obj = getattr(content_fetcher, prefer_fetch_backend) | ||||
|         else: | ||||
|             # If the klass doesnt exist, just use a default | ||||
|             fetcher_obj = getattr(content_fetcher, "html_requests") | ||||
|  | ||||
|  | ||||
|         proxy_url = None | ||||
|         if preferred_proxy_id: | ||||
|             proxy_url = self.datastore.proxy_list.get(preferred_proxy_id).get('url') | ||||
|             print(f"Using proxy Key: {preferred_proxy_id} as Proxy URL {proxy_url}") | ||||
|  | ||||
|         # Now call the fetcher (playwright/requests/etc) with arguments that only a fetcher would need. | ||||
|         # When browser_connection_url is None, it method should default to working out whats the best defaults (os env vars etc) | ||||
|         self.fetcher = fetcher_obj(proxy_override=proxy_url, | ||||
|                                    browser_connection_url=browser_connection_url | ||||
|                                    ) | ||||
|  | ||||
|         if self.watch.has_browser_steps: | ||||
|             self.fetcher.browser_steps = self.watch.get('browser_steps', []) | ||||
|             self.fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, self.watch.get('uuid')) | ||||
|  | ||||
|         # Tweak the base config with the per-watch ones | ||||
|         request_headers = self.watch.get('headers', []) | ||||
|         request_headers.update(self.datastore.get_all_base_headers()) | ||||
|         request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid'))) | ||||
|  | ||||
|         # 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', '') | ||||
|  | ||||
|         timeout = self.datastore.data['settings']['requests'].get('timeout') | ||||
|  | ||||
|         request_body = self.watch.get('body') | ||||
|         request_method = self.watch.get('method') | ||||
|         ignore_status_codes = self.watch.get('ignore_status_codes', False) | ||||
|  | ||||
|         # Configurable per-watch or global extra delay before extracting text (for webDriver types) | ||||
|         system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None) | ||||
|         if self.watch.get('webdriver_delay'): | ||||
|             self.fetcher.render_extract_delay = self.watch.get('webdriver_delay') | ||||
|         elif system_webdriver_delay is not None: | ||||
|             self.fetcher.render_extract_delay = system_webdriver_delay | ||||
|  | ||||
|         if self.watch.get('webdriver_js_execute_code') is not None and self.watch.get('webdriver_js_execute_code').strip(): | ||||
|             self.fetcher.webdriver_js_execute_code = self.watch.get('webdriver_js_execute_code') | ||||
|  | ||||
|         # Requests for PDF's, images etc should be passwd the is_binary flag | ||||
|         is_binary = self.watch.is_pdf | ||||
|  | ||||
|         # And here we go! call the right browser with browser-specific settings | ||||
|         self.fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, self.watch.get('include_filters'), | ||||
|                     is_binary=is_binary) | ||||
|  | ||||
|         #@todo .quit here could go on close object, so we can run JS if change-detected | ||||
|         self.fetcher.quit() | ||||
|  | ||||
|         # After init, call run_changedetection() which will do the actual change-detection | ||||
|  | ||||
|     @abstractmethod | ||||
|     def run_changedetection(self, uuid, skip_when_checksum_same=True): | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|         some_data = 'xxxxx' | ||||
|         update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest() | ||||
|         changed_detected = False | ||||
|         return changed_detected, update_obj, ''.encode('utf-8') | ||||
|  | ||||
|  | ||||
| def available_processors(): | ||||
|     from . import restock_diff, text_json_diff | ||||
|     x=[('text_json_diff', text_json_diff.name), ('restock_diff', restock_diff.name)] | ||||
|     # @todo Make this smarter with introspection of sorts. | ||||
|     return x | ||||
							
								
								
									
										64
									
								
								changedetectionio/processors/restock_diff.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,64 @@ | ||||
|  | ||||
| import hashlib | ||||
| import urllib3 | ||||
| from . import difference_detection_processor | ||||
| from copy import deepcopy | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| name = 'Re-stock detection for single product pages' | ||||
| description = 'Detects if the product goes back to in-stock' | ||||
|  | ||||
| class UnableToExtractRestockData(Exception): | ||||
|     def __init__(self, status_code): | ||||
|         # Set this so we can use it in other parts of the app | ||||
|         self.status_code = status_code | ||||
|         return | ||||
|  | ||||
| class perform_site_check(difference_detection_processor): | ||||
|     screenshot = None | ||||
|     xpath_data = None | ||||
|  | ||||
|     def run_changedetection(self, uuid, skip_when_checksum_same=True): | ||||
|  | ||||
|         # DeepCopy so we can be sure we don't accidently change anything by reference | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
|  | ||||
|         if not watch: | ||||
|             raise Exception("Watch no longer exists.") | ||||
|  | ||||
|         # Unset any existing notification error | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|  | ||||
|         self.screenshot = self.fetcher.screenshot | ||||
|         self.xpath_data = self.fetcher.xpath_data | ||||
|  | ||||
|         # Track the content type | ||||
|         update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '') | ||||
|         update_obj["last_check_status"] = self.fetcher.get_last_status_code() | ||||
|  | ||||
|         # Main detection method | ||||
|         fetched_md5 = None | ||||
|         if self.fetcher.instock_data: | ||||
|             fetched_md5 = hashlib.md5(self.fetcher.instock_data.encode('utf-8')).hexdigest() | ||||
|             # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold. | ||||
|             update_obj["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False | ||||
|         else: | ||||
|             raise UnableToExtractRestockData(status_code=self.fetcher.status_code) | ||||
|  | ||||
|         # The main thing that all this at the moment comes down to :) | ||||
|         changed_detected = False | ||||
|  | ||||
|         if watch.get('previous_md5') and watch.get('previous_md5') != fetched_md5: | ||||
|             # Yes if we only care about it going to instock, AND we are in stock | ||||
|             if watch.get('in_stock_only') and update_obj["in_stock"]: | ||||
|                 changed_detected = True | ||||
|  | ||||
|             if not watch.get('in_stock_only'): | ||||
|                 # All cases | ||||
|                 changed_detected = True | ||||
|  | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, self.fetcher.instock_data.encode('utf-8') | ||||
							
								
								
									
										350
									
								
								changedetectionio/processors/text_json_diff.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,350 @@ | ||||
| # HTML to TEXT/JSON DIFFERENCE self.fetcher | ||||
|  | ||||
| import hashlib | ||||
| import json | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
| import urllib3 | ||||
|  | ||||
| from changedetectionio import content_fetcher, html_tools | ||||
| from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
| from copy import deepcopy | ||||
| from . import difference_detection_processor | ||||
| from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| name = 'Webpage Text/HTML, JSON and PDF changes' | ||||
| description = 'Detects all text changes where possible' | ||||
| json_filter_prefixes = ['json:', 'jq:'] | ||||
|  | ||||
| class FilterNotFoundInResponse(ValueError): | ||||
|     def __init__(self, msg): | ||||
|         ValueError.__init__(self, msg) | ||||
|  | ||||
|  | ||||
| class PDFToHTMLToolNotFound(ValueError): | ||||
|     def __init__(self, msg): | ||||
|         ValueError.__init__(self, msg) | ||||
|  | ||||
|  | ||||
| # Some common stuff here that can be moved to a base class | ||||
| # (set_proxy_from_list) | ||||
| class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|     def run_changedetection(self, uuid, skip_when_checksum_same=True): | ||||
|         changed_detected = False | ||||
|         html_content = "" | ||||
|         screenshot = False  # as bytes | ||||
|         stripped_text_from_html = "" | ||||
|  | ||||
|         # DeepCopy so we can be sure we don't accidently change anything by reference | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
|         if not watch: | ||||
|             raise Exception("Watch no longer exists.") | ||||
|  | ||||
|         # Unset any existing notification error | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False} | ||||
|  | ||||
|         url = watch.link | ||||
|  | ||||
|         self.screenshot = self.fetcher.screenshot | ||||
|         self.xpath_data = self.fetcher.xpath_data | ||||
|  | ||||
|         # Track the content type | ||||
|         update_obj['content_type'] = self.fetcher.get_all_headers().get('content-type', '').lower() | ||||
|  | ||||
|         # Watches added automatically in the queue manager will skip if its the same checksum as the previous run | ||||
|         # Saves a lot of CPU | ||||
|         update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest() | ||||
|         if skip_when_checksum_same: | ||||
|             if update_obj['previous_md5_before_filters'] == watch.get('previous_md5_before_filters'): | ||||
|                 raise content_fetcher.checksumFromPreviousCheckWasTheSame() | ||||
|  | ||||
|         # Fetching complete, now filters | ||||
|  | ||||
|         # @note: I feel like the following should be in a more obvious chain system | ||||
|         #  - Check filter text | ||||
|         #  - Is the checksum different? | ||||
|         #  - Do we convert to JSON? | ||||
|         # https://stackoverflow.com/questions/41817578/basic-method-chaining ? | ||||
|         # return content().textfilter().jsonextract().checksumcompare() ? | ||||
|  | ||||
|         is_json = 'application/json' in self.fetcher.get_all_headers().get('content-type', '').lower() | ||||
|         is_html = not is_json | ||||
|         is_rss = False | ||||
|  | ||||
|         ctype_header = self.fetcher.get_all_headers().get('content-type', '').lower() | ||||
|         # Go into RSS preprocess for converting CDATA/comment to usable text | ||||
|         if any(substring in ctype_header for substring in ['application/xml', 'application/rss', 'text/xml']): | ||||
|             if '<rss' in self.fetcher.content[:100].lower(): | ||||
|                 self.fetcher.content = cdata_in_document_to_text(html_content=self.fetcher.content) | ||||
|                 is_rss = True | ||||
|  | ||||
|         # source: support, basically treat it as plaintext | ||||
|         if watch.is_source_type_url: | ||||
|             is_html = False | ||||
|             is_json = False | ||||
|  | ||||
|         inline_pdf = self.fetcher.get_all_headers().get('content-disposition', '') and '%PDF-1' in self.fetcher.content[:10] | ||||
|         if watch.is_pdf or 'application/pdf' in self.fetcher.get_all_headers().get('content-type', '').lower() or inline_pdf: | ||||
|             from shutil import which | ||||
|             tool = os.getenv("PDF_TO_HTML_TOOL", "pdftohtml") | ||||
|             if not which(tool): | ||||
|                 raise PDFToHTMLToolNotFound("Command-line `{}` tool was not found in system PATH, was it installed?".format(tool)) | ||||
|  | ||||
|             import subprocess | ||||
|             proc = subprocess.Popen( | ||||
|                 [tool, '-stdout', '-', '-s', 'out.pdf', '-i'], | ||||
|                 stdout=subprocess.PIPE, | ||||
|                 stdin=subprocess.PIPE) | ||||
|             proc.stdin.write(self.fetcher.raw_content) | ||||
|             proc.stdin.close() | ||||
|             self.fetcher.content = proc.stdout.read().decode('utf-8') | ||||
|             proc.wait(timeout=60) | ||||
|  | ||||
|             # Add a little metadata so we know if the file changes (like if an image changes, but the text is the same | ||||
|             # @todo may cause problems with non-UTF8? | ||||
|             metadata = "<p>Added by changedetection.io: Document checksum - {} Filesize - {} bytes</p>".format( | ||||
|                 hashlib.md5(self.fetcher.raw_content).hexdigest().upper(), | ||||
|                 len(self.fetcher.content)) | ||||
|  | ||||
|             self.fetcher.content = self.fetcher.content.replace('</body>', metadata + '</body>') | ||||
|  | ||||
|         # Better would be if Watch.model could access the global data also | ||||
|         # and then use getattr https://docs.python.org/3/reference/datamodel.html#object.__getitem__ | ||||
|         # https://realpython.com/inherit-python-dict/ instead of doing it procedurely | ||||
|         include_filters_from_tags = self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='include_filters') | ||||
|         include_filters_rule = [*watch.get('include_filters', []), *include_filters_from_tags] | ||||
|  | ||||
|         subtractive_selectors = [*self.datastore.get_tag_overrides_for_watch(uuid=uuid, attr='subtractive_selectors'), | ||||
|                                  *watch.get("subtractive_selectors", []), | ||||
|                                  *self.datastore.data["settings"]["application"].get("global_subtractive_selectors", []) | ||||
|                                  ] | ||||
|  | ||||
|         # Inject a virtual LD+JSON price tracker rule | ||||
|         if watch.get('track_ldjson_price_data', '') == PRICE_DATA_TRACK_ACCEPT: | ||||
|             include_filters_rule += html_tools.LD_JSON_PRODUCT_OFFER_SELECTORS | ||||
|  | ||||
|         has_filter_rule = len(include_filters_rule) and len(include_filters_rule[0].strip()) | ||||
|         has_subtractive_selectors = len(subtractive_selectors) and len(subtractive_selectors[0].strip()) | ||||
|  | ||||
|         if is_json and not has_filter_rule: | ||||
|             include_filters_rule.append("json:$") | ||||
|             has_filter_rule = True | ||||
|  | ||||
|         if is_json: | ||||
|             # Sort the JSON so we dont get false alerts when the content is just re-ordered | ||||
|             try: | ||||
|                 self.fetcher.content = json.dumps(json.loads(self.fetcher.content), sort_keys=True) | ||||
|             except Exception as e: | ||||
|                 # Might have just been a snippet, or otherwise bad JSON, continue | ||||
|                 pass | ||||
|  | ||||
|         if has_filter_rule: | ||||
|             for filter in include_filters_rule: | ||||
|                 if any(prefix in filter for prefix in json_filter_prefixes): | ||||
|                     stripped_text_from_html += html_tools.extract_json_as_string(content=self.fetcher.content, json_filter=filter) | ||||
|                     is_html = False | ||||
|  | ||||
|         if is_html or watch.is_source_type_url: | ||||
|  | ||||
|             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||
|             self.fetcher.content = html_tools.workarounds_for_obfuscations(self.fetcher.content) | ||||
|             html_content = self.fetcher.content | ||||
|  | ||||
|             # If not JSON,  and if it's not text/plain.. | ||||
|             if 'text/plain' in self.fetcher.get_all_headers().get('content-type', '').lower(): | ||||
|                 # Don't run get_text or xpath/css filters on plaintext | ||||
|                 stripped_text_from_html = html_content | ||||
|             else: | ||||
|                 # Does it have some ld+json price data? used for easier monitoring | ||||
|                 update_obj['has_ldjson_price_data'] = html_tools.has_ldjson_product_info(self.fetcher.content) | ||||
|  | ||||
|                 # Then we assume HTML | ||||
|                 if has_filter_rule: | ||||
|                     html_content = "" | ||||
|  | ||||
|                     for filter_rule in include_filters_rule: | ||||
|                         # For HTML/XML we offer xpath as an option, just start a regular xPath "/.." | ||||
|                         if filter_rule[0] == '/' or filter_rule.startswith('xpath:'): | ||||
|                             html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''), | ||||
|                                                                     html_content=self.fetcher.content, | ||||
|                                                                     append_pretty_line_formatting=not watch.is_source_type_url, | ||||
|                                                                     is_rss=is_rss) | ||||
|                         else: | ||||
|                             # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text | ||||
|                             html_content += html_tools.include_filters(include_filters=filter_rule, | ||||
|                                                                        html_content=self.fetcher.content, | ||||
|                                                                        append_pretty_line_formatting=not watch.is_source_type_url) | ||||
|  | ||||
|                     if not html_content.strip(): | ||||
|                         raise FilterNotFoundInResponse(include_filters_rule) | ||||
|  | ||||
|                 if has_subtractive_selectors: | ||||
|                     html_content = html_tools.element_removal(subtractive_selectors, html_content) | ||||
|  | ||||
|                 if watch.is_source_type_url: | ||||
|                     stripped_text_from_html = html_content | ||||
|                 else: | ||||
|                     # extract text | ||||
|                     do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False) | ||||
|                     stripped_text_from_html = \ | ||||
|                         html_tools.html_to_text( | ||||
|                             html_content=html_content, | ||||
|                             render_anchor_tag_content=do_anchor, | ||||
|                             is_rss=is_rss # #1874 activate the <title workaround hack | ||||
|                         ) | ||||
|  | ||||
|         # Re #340 - return the content before the 'ignore text' was applied | ||||
|         text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') | ||||
|  | ||||
|         # @todo whitespace coming from missing rtrim()? | ||||
|         # stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about. | ||||
|         # Rewrite's the processing text based on only what diff result they want to see | ||||
|         if watch.has_special_diff_filter_options_set() and len(watch.history.keys()): | ||||
|             # Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences | ||||
|             from .. import diff | ||||
|             # needs to not include (added) etc or it may get used twice | ||||
|             # Replace the processed text with the preferred result | ||||
|             rendered_diff = diff.render_diff(previous_version_file_contents=watch.get_last_fetched_before_filters(), | ||||
|                                              newest_version_file_contents=stripped_text_from_html, | ||||
|                                              include_equal=False,  # not the same lines | ||||
|                                              include_added=watch.get('filter_text_added', True), | ||||
|                                              include_removed=watch.get('filter_text_removed', True), | ||||
|                                              include_replaced=watch.get('filter_text_replaced', True), | ||||
|                                              line_feed_sep="\n", | ||||
|                                              include_change_type_prefix=False) | ||||
|  | ||||
|             watch.save_last_fetched_before_filters(text_content_before_ignored_filter) | ||||
|  | ||||
|             if not rendered_diff and stripped_text_from_html: | ||||
|                 # We had some content, but no differences were found | ||||
|                 # Store our new file as the MD5 so it will trigger in the future | ||||
|                 c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest() | ||||
|                 return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8') | ||||
|             else: | ||||
|                 stripped_text_from_html = rendered_diff | ||||
|  | ||||
|         # Treat pages with no renderable text content as a change? No by default | ||||
|         empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||
|         if not is_json and not empty_pages_are_a_change and len(stripped_text_from_html.strip()) == 0: | ||||
|             raise content_fetcher.ReplyWithContentButNoText(url=url, | ||||
|                                                             status_code=self.fetcher.get_last_status_code(), | ||||
|                                                             screenshot=screenshot, | ||||
|                                                             has_filters=has_filter_rule, | ||||
|                                                             html_content=html_content | ||||
|                                                             ) | ||||
|  | ||||
|         # 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"] = self.fetcher.get_last_status_code() | ||||
|  | ||||
|         # If there's text to skip | ||||
|         # @todo we could abstract out the get_text() to handle this cleaner | ||||
|         text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', []) | ||||
|         if len(text_to_ignore): | ||||
|             stripped_text_from_html = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore) | ||||
|         else: | ||||
|             stripped_text_from_html = stripped_text_from_html.encode('utf8') | ||||
|  | ||||
|         # 615 Extract text by regex | ||||
|         extract_text = watch.get('extract_text', []) | ||||
|         if len(extract_text) > 0: | ||||
|             regex_matched_output = [] | ||||
|             for s_re in extract_text: | ||||
|                 # incase they specified something in '/.../x' | ||||
|                 if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE): | ||||
|                     regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re) | ||||
|                     result = re.findall(regex.encode('utf-8'), stripped_text_from_html) | ||||
|  | ||||
|                     for l in result: | ||||
|                         if type(l) is tuple: | ||||
|                             # @todo - some formatter option default (between groups) | ||||
|                             regex_matched_output += list(l) + [b'\n'] | ||||
|                         else: | ||||
|                             # @todo - some formatter option default (between each ungrouped result) | ||||
|                             regex_matched_output += [l] + [b'\n'] | ||||
|                 else: | ||||
|                     # Doesnt look like regex, just hunt for plaintext and return that which matches | ||||
|                     # `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes | ||||
|                     r = re.compile(re.escape(s_re.encode('utf-8')), re.IGNORECASE) | ||||
|                     res = r.findall(stripped_text_from_html) | ||||
|                     if res: | ||||
|                         for match in res: | ||||
|                             regex_matched_output += [match] + [b'\n'] | ||||
|  | ||||
|             # Now we will only show what the regex matched | ||||
|             stripped_text_from_html = b'' | ||||
|             text_content_before_ignored_filter = b'' | ||||
|             if regex_matched_output: | ||||
|                 # @todo some formatter for presentation? | ||||
|                 stripped_text_from_html = b''.join(regex_matched_output) | ||||
|                 text_content_before_ignored_filter = stripped_text_from_html | ||||
|  | ||||
|         # Re #133 - if we should strip whitespaces from triggering the change detected comparison | ||||
|         if self.datastore.data['settings']['application'].get('ignore_whitespace', False): | ||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest() | ||||
|         else: | ||||
|             fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest() | ||||
|  | ||||
|         ############ Blocking rules, after checksum ################# | ||||
|         blocked = False | ||||
|  | ||||
|         trigger_text = watch.get('trigger_text', []) | ||||
|         if len(trigger_text): | ||||
|             # Assume blocked | ||||
|             blocked = True | ||||
|             # Filter and trigger works the same, so reuse it | ||||
|             # It should return the line numbers that match | ||||
|             # Unblock flow if the trigger was found (some text remained after stripped what didnt match) | ||||
|             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), | ||||
|                                                   wordlist=trigger_text, | ||||
|                                                   mode="line numbers") | ||||
|             # Unblock if the trigger was found | ||||
|             if result: | ||||
|                 blocked = False | ||||
|  | ||||
|         text_should_not_be_present = watch.get('text_should_not_be_present', []) | ||||
|         if len(text_should_not_be_present): | ||||
|             # If anything matched, then we should block a change from happening | ||||
|             result = html_tools.strip_ignore_text(content=str(stripped_text_from_html), | ||||
|                                                   wordlist=text_should_not_be_present, | ||||
|                                                   mode="line numbers") | ||||
|             if result: | ||||
|                 blocked = True | ||||
|  | ||||
|         # The main thing that all this at the moment comes down to :) | ||||
|         if watch.get('previous_md5') != fetched_md5: | ||||
|             changed_detected = True | ||||
|  | ||||
|         # Looks like something changed, but did it match all the rules? | ||||
|         if blocked: | ||||
|             changed_detected = False | ||||
|  | ||||
|         # Extract title as title | ||||
|         if is_html: | ||||
|             if self.datastore.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']: | ||||
|                 if not watch['title'] or not len(watch['title']): | ||||
|                     update_obj['title'] = html_tools.extract_element(find='title', html_content=self.fetcher.content) | ||||
|  | ||||
|         if changed_detected: | ||||
|             if watch.get('check_unique_lines', False): | ||||
|                 has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines()) | ||||
|                 # One or more lines? unsure? | ||||
|                 if not has_unique_lines: | ||||
|                     logging.debug("check_unique_lines: UUID {} didnt have anything new setting change_detected=False".format(uuid)) | ||||
|                     changed_detected = False | ||||
|                 else: | ||||
|                     logging.debug("check_unique_lines: UUID {} had unique content".format(uuid)) | ||||
|  | ||||
|         # Always record the new checksum | ||||
|         update_obj["previous_md5"] = fetched_md5 | ||||
|  | ||||
|         # On the first run of a site, watch['previous_md5'] will be None, set it the current one. | ||||
|         if not watch.get('previous_md5'): | ||||
|             watch['previous_md5'] = fetched_md5 | ||||
|  | ||||
|         return changed_detected, update_obj, text_content_before_ignored_filter | ||||
							
								
								
									
										12
									
								
								changedetectionio/pytest.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| [pytest] | ||||
| addopts = --no-start-live-server --live-server-port=5005 | ||||
| #testpaths = tests pytest_invenio | ||||
| #live_server_scope = function | ||||
|  | ||||
| filterwarnings = | ||||
|     ignore::DeprecationWarning:urllib3.*: | ||||
|  | ||||
| ; logging options | ||||
| log_cli = 1 | ||||
| log_cli_level = DEBUG | ||||
| log_cli_format = %(asctime)s %(name)s: %(levelname)s %(message)s | ||||
							
								
								
									
										10
									
								
								changedetectionio/queuedWatchMetaData.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| from dataclasses import dataclass, field | ||||
| from typing import Any | ||||
|  | ||||
| # So that we can queue some metadata in `item` | ||||
| # https://docs.python.org/3/library/queue.html#queue.PriorityQueue | ||||
| # | ||||
| @dataclass(order=True) | ||||
| class PrioritizedItem: | ||||
|     priority: int | ||||
|     item: Any=field(compare=False) | ||||
							
								
								
									
										190
									
								
								changedetectionio/res/puppeteer_fetch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,190 @@ | ||||
| module.exports = async ({page, context}) => { | ||||
|  | ||||
|     var { | ||||
|         url, | ||||
|         execute_js, | ||||
|         user_agent, | ||||
|         extra_wait_ms, | ||||
|         req_headers, | ||||
|         include_filters, | ||||
|         xpath_element_js, | ||||
|         screenshot_quality, | ||||
|         proxy_username, | ||||
|         proxy_password, | ||||
|         disk_cache_dir, | ||||
|         no_cache_list, | ||||
|         block_url_list, | ||||
|     } = context; | ||||
|  | ||||
|     await page.setBypassCSP(true) | ||||
|     await page.setExtraHTTPHeaders(req_headers); | ||||
|  | ||||
|     if (user_agent) { | ||||
|         await page.setUserAgent(user_agent); | ||||
|     } | ||||
|     // https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded | ||||
|  | ||||
|     await page.setDefaultNavigationTimeout(0); | ||||
|  | ||||
|     if (proxy_username) { | ||||
|         // Setting Proxy-Authentication header is deprecated, and doing so can trigger header change errors from Puppeteer | ||||
|         // https://github.com/puppeteer/puppeteer/issues/676 ? | ||||
|         // https://help.brightdata.com/hc/en-us/articles/12632549957649-Proxy-Manager-How-to-Guides#h_01HAKWR4Q0AFS8RZTNYWRDFJC2 | ||||
|         // https://cri.dev/posts/2020-03-30-How-to-solve-Puppeteer-Chrome-Error-ERR_INVALID_ARGUMENT/ | ||||
|         await page.authenticate({ | ||||
|             username: proxy_username, | ||||
|             password: proxy_password | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     await page.setViewport({ | ||||
|         width: 1024, | ||||
|         height: 768, | ||||
|         deviceScaleFactor: 1, | ||||
|     }); | ||||
|  | ||||
|     await page.setRequestInterception(true); | ||||
|     if (disk_cache_dir) { | ||||
|         console.log(">>>>>>>>>>>>>>> LOCAL DISK CACHE ENABLED <<<<<<<<<<<<<<<<<<<<<"); | ||||
|     } | ||||
|     const fs = require('fs'); | ||||
|     const crypto = require('crypto'); | ||||
|  | ||||
|     function file_is_expired(file_path) { | ||||
|         if (!fs.existsSync(file_path)) { | ||||
|             return true; | ||||
|         } | ||||
|         var stats = fs.statSync(file_path); | ||||
|         const now_date = new Date(); | ||||
|         const expire_seconds = 300; | ||||
|         if ((now_date / 1000) - (stats.mtime.getTime() / 1000) > expire_seconds) { | ||||
|             console.log("CACHE EXPIRED: " + file_path); | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|  | ||||
|     } | ||||
|  | ||||
|     page.on('request', async (request) => { | ||||
|         // General blocking of requests that waste traffic | ||||
|         if (block_url_list.some(substring => request.url().toLowerCase().includes(substring))) return request.abort(); | ||||
|  | ||||
|         if (disk_cache_dir) { | ||||
|             const url = request.url(); | ||||
|             const key = crypto.createHash('md5').update(url).digest("hex"); | ||||
|             const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/'; | ||||
|  | ||||
|             // https://stackoverflow.com/questions/4482686/check-synchronously-if-file-directory-exists-in-node-js | ||||
|  | ||||
|             if (fs.existsSync(dir_path + key)) { | ||||
|                 console.log("* CACHE HIT , using - " + dir_path + key + " - " + url); | ||||
|                 const cached_data = fs.readFileSync(dir_path + key); | ||||
|                 // @todo headers can come from dir_path+key+".meta" json file | ||||
|                 request.respond({ | ||||
|                     status: 200, | ||||
|                     //contentType: 'text/html', //@todo | ||||
|                     body: cached_data | ||||
|                 }); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         request.continue(); | ||||
|     }); | ||||
|  | ||||
|  | ||||
|     if (disk_cache_dir) { | ||||
|         page.on('response', async (response) => { | ||||
|             const url = response.url(); | ||||
|             // Basic filtering for sane responses | ||||
|             if (response.request().method() != 'GET' || response.request().resourceType() == 'xhr' || response.request().resourceType() == 'document' || response.status() != 200) { | ||||
|                 console.log("Skipping (not useful) - Status:" + response.status() + " Method:" + response.request().method() + " ResourceType:" + response.request().resourceType() + " " + url); | ||||
|                 return; | ||||
|             } | ||||
|             if (no_cache_list.some(substring => url.toLowerCase().includes(substring))) { | ||||
|                 console.log("Skipping (no_cache_list) - " + url); | ||||
|                 return; | ||||
|             } | ||||
|             if (url.toLowerCase().includes('data:')) { | ||||
|                 console.log("Skipping (embedded-data) - " + url); | ||||
|                 return; | ||||
|             } | ||||
|             response.buffer().then(buffer => { | ||||
|                 if (buffer.length > 100) { | ||||
|                     console.log("Cache - Saving " + response.request().method() + " - " + url + " - " + response.request().resourceType()); | ||||
|  | ||||
|                     const key = crypto.createHash('md5').update(url).digest("hex"); | ||||
|                     const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/'; | ||||
|  | ||||
|                     if (!fs.existsSync(dir_path)) { | ||||
|                         fs.mkdirSync(dir_path, {recursive: true}) | ||||
|                     } | ||||
|  | ||||
|                     if (fs.existsSync(dir_path + key)) { | ||||
|                         if (file_is_expired(dir_path + key)) { | ||||
|                             fs.writeFileSync(dir_path + key, buffer); | ||||
|                         } | ||||
|                     } else { | ||||
|                         fs.writeFileSync(dir_path + key, buffer); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     const r = await page.goto(url, { | ||||
|         waitUntil: 'load' | ||||
|     }); | ||||
|  | ||||
|     await page.waitForTimeout(1000); | ||||
|     await page.waitForTimeout(extra_wait_ms); | ||||
|  | ||||
|     if (execute_js) { | ||||
|         await page.evaluate(execute_js); | ||||
|         await page.waitForTimeout(200); | ||||
|     } | ||||
|  | ||||
|     var xpath_data; | ||||
|     var instock_data; | ||||
|     try { | ||||
|         // Not sure the best way here, in the future this should be a new package added to npm then run in browserless | ||||
|         // (Once the old playwright is removed) | ||||
|         xpath_data = await page.evaluate((include_filters) => {%xpath_scrape_code%}, include_filters); | ||||
|         instock_data = await page.evaluate(() => {%instock_scrape_code%}); | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // Protocol error (Page.captureScreenshot): Cannot take screenshot with 0 width can come from a proxy auth failure | ||||
|     // Wrap it here (for now) | ||||
|  | ||||
|     var b64s = false; | ||||
|     try { | ||||
|         b64s = await page.screenshot({encoding: "base64", fullPage: true, quality: screenshot_quality, type: 'jpeg'}); | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // May fail on very large pages with 'WARNING: tile memory limits exceeded, some content may not draw' | ||||
|     if (!b64s) { | ||||
|         // @todo after text extract, we can place some overlay text with red background to say 'croppped' | ||||
|         console.error('ERROR: content-fetcher page was maybe too large for a screenshot, reverting to viewport only screenshot'); | ||||
|         try { | ||||
|             b64s = await page.screenshot({encoding: "base64", quality: screenshot_quality, type: 'jpeg'}); | ||||
|         } catch (e) { | ||||
|             console.log(e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var html = await page.content(); | ||||
|     return { | ||||
|         data: { | ||||
|             'content': html, | ||||
|             'headers': r.headers(), | ||||
|             'instock_data': instock_data, | ||||
|             'screenshot': b64s, | ||||
|             'status_code': r.status(), | ||||
|             'xpath_data': xpath_data | ||||
|         }, | ||||
|         type: 'application/json', | ||||
|     }; | ||||
| }; | ||||
							
								
								
									
										108
									
								
								changedetectionio/res/stock-not-in-stock.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,108 @@ | ||||
| function isItemInStock() { | ||||
|   // @todo Pass these in so the same list can be used in non-JS fetchers | ||||
|   const outOfStockTexts = [ | ||||
|     '0 in stock', | ||||
|     'agotado', | ||||
|     'artikel zurzeit vergriffen', | ||||
|     'as soon as stock is available', | ||||
|     'ausverkauft', // sold out | ||||
|     'available for back order', | ||||
|     'back-order or out of stock', | ||||
|     'backordered', | ||||
|     'benachrichtigt mich', // notify me | ||||
|     'brak na stanie', | ||||
|     'brak w magazynie', | ||||
|     'coming soon', | ||||
|     'currently have any tickets for this', | ||||
|     'currently unavailable', | ||||
|     'dostępne wkrótce', | ||||
|     'en rupture de stock', | ||||
|     'ist derzeit nicht auf lager', | ||||
|     'item is no longer available', | ||||
|     'message if back in stock', | ||||
|     'nachricht bei', | ||||
|     'nicht auf lager', | ||||
|     'nicht lieferbar', | ||||
|     'nicht zur verfügung', | ||||
|     'no disponible temporalmente', | ||||
|     'no longer in stock', | ||||
|     'no tickets available', | ||||
|     'not available', | ||||
|     'not currently available', | ||||
|     'not in stock', | ||||
|     'notify me when available', | ||||
|     'não estamos a aceitar encomendas', | ||||
|     'out of stock', | ||||
|     'out-of-stock', | ||||
|     'produkt niedostępny', | ||||
|     'sold out', | ||||
|     'temporarily out of stock', | ||||
|     'temporarily unavailable', | ||||
|     'tickets unavailable', | ||||
|     'unavailable tickets', | ||||
|     'we do not currently have an estimate of when this product will be back in stock.', | ||||
|     'zur zeit nicht an lager', | ||||
|     '已售完', | ||||
|   ]; | ||||
|  | ||||
|  | ||||
|   const negateOutOfStockRegexs = [ | ||||
|       '[0-9] in stock' | ||||
|   ] | ||||
|   var negateOutOfStockRegexs_r = []; | ||||
|   for (let i = 0; i < negateOutOfStockRegexs.length; i++) { | ||||
|     negateOutOfStockRegexs_r.push(new RegExp(negateOutOfStockRegexs[0], 'g')); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   const elementsWithZeroChildren = Array.from(document.getElementsByTagName('*')).filter(element => element.children.length === 0); | ||||
|  | ||||
|   // REGEXS THAT REALLY MEAN IT'S IN STOCK | ||||
|   for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) { | ||||
|     const element = elementsWithZeroChildren[i]; | ||||
|     if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) { | ||||
|       var elementText=""; | ||||
|       if (element.tagName.toLowerCase() === "input") { | ||||
|         elementText = element.value.toLowerCase(); | ||||
|       } else { | ||||
|         elementText = element.textContent.toLowerCase(); | ||||
|       } | ||||
|  | ||||
|       if (elementText.length) { | ||||
|         // try which ones could mean its in stock | ||||
|         for (let i = 0; i < negateOutOfStockRegexs.length; i++) { | ||||
|           if (negateOutOfStockRegexs_r[i].test(elementText)) { | ||||
|             return 'Possibly in stock'; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK | ||||
|   for (let i = elementsWithZeroChildren.length - 1; i >= 0; i--) { | ||||
|     const element = elementsWithZeroChildren[i]; | ||||
|     if (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0) { | ||||
|       var elementText=""; | ||||
|       if (element.tagName.toLowerCase() === "input") { | ||||
|         elementText = element.value.toLowerCase(); | ||||
|       } else { | ||||
|         elementText = element.textContent.toLowerCase(); | ||||
|       } | ||||
|  | ||||
|       if (elementText.length) { | ||||
|         // and these mean its out of stock | ||||
|         for (const outOfStockText of outOfStockTexts) { | ||||
|           if (elementText.includes(outOfStockText)) { | ||||
|             return elementText; // item is out of stock | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return 'Possibly in stock'; // possibly in stock, cant decide otherwise. | ||||
| } | ||||
|  | ||||
| // returns the element text that makes it think it's out of stock | ||||
| return isItemInStock(); | ||||
							
								
								
									
										221
									
								
								changedetectionio/res/xpath_element_scraper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,221 @@ | ||||
| // Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com) | ||||
| // All rights reserved. | ||||
|  | ||||
| // @file Scrape the page looking for elements of concern (%ELEMENTS%) | ||||
| // http://matatk.agrip.org.uk/tests/position-and-width/ | ||||
| // https://stackoverflow.com/questions/26813480/when-is-element-getboundingclientrect-guaranteed-to-be-updated-accurate | ||||
| // | ||||
| // Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis | ||||
| // will automatically force a scroll somewhere, so include the position offset | ||||
| // Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing | ||||
| var scroll_y = 0; | ||||
| try { | ||||
|     scroll_y = +document.documentElement.scrollTop || document.body.scrollTop | ||||
| } catch (e) { | ||||
|     console.log(e); | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| // Include the getXpath script directly, easier than fetching | ||||
| function getxpath(e) { | ||||
|         var n = e; | ||||
|         if (n && n.id) return '//*[@id="' + n.id + '"]'; | ||||
|         for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) { | ||||
|             for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling; | ||||
|             for (d = n.nextSibling; d;) { | ||||
|                 if (d.nodeName === n.nodeName) { | ||||
|                     r = !0; | ||||
|                     break | ||||
|                 } | ||||
|                 d = d.nextSibling | ||||
|             } | ||||
|             o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode | ||||
|         } | ||||
|         return o.length ? "/" + o.reverse().join("/") : "" | ||||
|     } | ||||
|  | ||||
| const findUpTag = (el) => { | ||||
|     let r = el | ||||
|     chained_css = []; | ||||
|     depth = 0; | ||||
|  | ||||
|     //  Strategy 1: If it's an input, with name, and there's only one, prefer that | ||||
|     if (el.name !== undefined && el.name.length) { | ||||
|         var proposed = el.tagName + "[name=" + el.name + "]"; | ||||
|         var proposed_element = window.document.querySelectorAll(proposed); | ||||
|         if (proposed_element.length) { | ||||
|             if (proposed_element.length === 1) { | ||||
|                 return proposed; | ||||
|             } else { | ||||
|                 // Some sites change ID but name= stays the same, we can hit it if we know the index | ||||
|                 // Find all the elements that match and work out the input[n] | ||||
|                 var n = Array.from(proposed_element).indexOf(el); | ||||
|                 // Return a Playwright selector for nthinput[name=zipcode] | ||||
|                 return proposed + " >> nth=" + n; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Strategy 2: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4 | ||||
|     while (r.parentNode) { | ||||
|         if (depth == 5) { | ||||
|             break; | ||||
|         } | ||||
|         if ('' !== r.id) { | ||||
|             chained_css.unshift("#" + CSS.escape(r.id)); | ||||
|             final_selector = chained_css.join(' > '); | ||||
|             // Be sure theres only one, some sites have multiples of the same ID tag :-( | ||||
|             if (window.document.querySelectorAll(final_selector).length == 1) { | ||||
|                 return final_selector; | ||||
|             } | ||||
|             return null; | ||||
|         } else { | ||||
|             chained_css.unshift(r.tagName.toLowerCase()); | ||||
|         } | ||||
|         r = r.parentNode; | ||||
|         depth += 1; | ||||
|     } | ||||
|     return null; | ||||
| } | ||||
|  | ||||
|  | ||||
| // @todo - if it's SVG or IMG, go into image diff mode | ||||
| // %ELEMENTS% replaced at injection time because different interfaces use it with different settings | ||||
| var elements = window.document.querySelectorAll("%ELEMENTS%"); | ||||
| var size_pos = []; | ||||
| // after page fetch, inject this JS | ||||
| // build a map of all elements and their positions (maybe that only include text?) | ||||
| var bbox; | ||||
| for (var i = 0; i < elements.length; i++) { | ||||
|     bbox = elements[i].getBoundingClientRect(); | ||||
|  | ||||
|     // Exclude items that are not interactable or visible | ||||
|     if(elements[i].style.opacity === "0") { | ||||
|         continue | ||||
|     } | ||||
|     if(elements[i].style.display === "none" || elements[i].style.pointerEvents === "none" ) { | ||||
|         continue | ||||
|     } | ||||
|  | ||||
|     // Skip really small ones, and where width or height ==0 | ||||
|     if (bbox['width'] * bbox['height'] < 100) { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     // Don't include elements that are offset from canvas | ||||
|     if (bbox['top']+scroll_y < 0 || bbox['left'] < 0) { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes | ||||
|     // it should not traverse when we know we can anchor off just an ID one level up etc.. | ||||
|     // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match | ||||
|  | ||||
|     // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us. | ||||
|     xpath_result = false; | ||||
|  | ||||
|     try { | ||||
|         var d = findUpTag(elements[i]); | ||||
|         if (d) { | ||||
|             xpath_result = d; | ||||
|         } | ||||
|     } catch (e) { | ||||
|         console.log(e); | ||||
|     } | ||||
|  | ||||
|     // You could swap it and default to getXpath and then try the smarter one | ||||
|     // default back to the less intelligent one | ||||
|     if (!xpath_result) { | ||||
|         try { | ||||
|             // I've seen on FB and eBay that this doesnt work | ||||
|             // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>:1:44) | ||||
|             xpath_result = getxpath(elements[i]); | ||||
|         } catch (e) { | ||||
|             console.log(e); | ||||
|             continue; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (window.getComputedStyle(elements[i]).visibility === "hidden") { | ||||
|         continue; | ||||
|     } | ||||
|  | ||||
|     // @todo Possible to ONLY list where it's clickable to save JSON xfer size | ||||
|     size_pos.push({ | ||||
|         xpath: xpath_result, | ||||
|         width: Math.round(bbox['width']), | ||||
|         height: Math.round(bbox['height']), | ||||
|         left: Math.floor(bbox['left']), | ||||
|         top: Math.floor(bbox['top'])+scroll_y, | ||||
|         tagName: (elements[i].tagName) ? elements[i].tagName.toLowerCase() : '', | ||||
|         tagtype: (elements[i].tagName == 'INPUT' && elements[i].type) ? elements[i].type.toLowerCase() : '', | ||||
|         isClickable: (elements[i].onclick) || window.getComputedStyle(elements[i]).cursor == "pointer" | ||||
|     }); | ||||
|  | ||||
| } | ||||
|  | ||||
| // Inject the current one set in the include_filters, which may be a CSS rule | ||||
| // used for displaying the current one in VisualSelector, where its not one we generated. | ||||
| if (include_filters.length) { | ||||
|     // Foreach filter, go and find it on the page and add it to the results so we can visualise it again | ||||
|     for (const f of include_filters) { | ||||
|         bbox = false; | ||||
|         q = false; | ||||
|  | ||||
|         if (!f.length) { | ||||
|             console.log("xpath_element_scraper: Empty filter, skipping"); | ||||
|             continue; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // is it xpath? | ||||
|             if (f.startsWith('/') || f.startsWith('xpath:')) { | ||||
|                 q = document.evaluate(f.replace('xpath:', ''), document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | ||||
|             } else { | ||||
|                 q = document.querySelector(f); | ||||
|             } | ||||
|         } catch (e) { | ||||
|             // Maybe catch DOMException and alert? | ||||
|             console.log("xpath_element_scraper: Exception selecting element from filter "+f); | ||||
|             console.log(e); | ||||
|         } | ||||
|  | ||||
|         if (q) { | ||||
|             // #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element. | ||||
|             if (q.hasOwnProperty('getBoundingClientRect')) { | ||||
|                 bbox = q.getBoundingClientRect(); | ||||
|                 console.log("xpath_element_scraper: Got filter element, scroll from top was " + scroll_y) | ||||
|             } else { | ||||
|                 try { | ||||
|                     // Try and see we can find its ownerElement | ||||
|                     bbox = q.ownerElement.getBoundingClientRect(); | ||||
|                     console.log("xpath_element_scraper: Got filter by ownerElement element, scroll from top was " + scroll_y) | ||||
|                 } catch (e) { | ||||
|                     console.log("xpath_element_scraper: error looking up ownerElement") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         if(!q) { | ||||
|             console.log("xpath_element_scraper: filter element " + f + " was not found"); | ||||
|         } | ||||
|  | ||||
|         if (bbox && bbox['width'] > 0 && bbox['height'] > 0) { | ||||
|             size_pos.push({ | ||||
|                 xpath: f, | ||||
|                 width: parseInt(bbox['width']), | ||||
|                 height: parseInt(bbox['height']), | ||||
|                 left: parseInt(bbox['left']), | ||||
|                 top: parseInt(bbox['top'])+scroll_y | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area | ||||
| // so that we dont select the wrapping element by mistake and be unable to select what we want | ||||
| size_pos.sort((a, b) => (a.width*a.height > b.width*b.height) ? 1 : -1) | ||||
|  | ||||
| // Window.width required for proper scaling in the frontend | ||||
| return {'size_pos': size_pos, 'browser_width': window.innerWidth}; | ||||
							
								
								
									
										38
									
								
								changedetectionio/run_basic_tests.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,38 @@ | ||||
| #!/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 | ||||
|  | ||||
| SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) | ||||
|  | ||||
| find tests/test_*py -type f|while read test_name | ||||
| do | ||||
|   echo "TEST RUNNING $test_name" | ||||
|   pytest $test_name | ||||
| done | ||||
|  | ||||
| echo "RUNNING WITH BASE_URL SET" | ||||
|  | ||||
| # Now re-run some tests with BASE_URL enabled | ||||
| # Re #65 - Ability to include a link back to the installation, in the notification. | ||||
| export BASE_URL="https://really-unique-domain.io" | ||||
| pytest tests/test_notification.py | ||||
|  | ||||
|  | ||||
| # Re-run with HIDE_REFERER set - could affect login | ||||
| export HIDE_REFERER=True | ||||
| pytest tests/test_access_control.py | ||||
|  | ||||
| # Re-run a few tests that will trigger brotli based storage | ||||
| export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 | ||||
| pytest tests/test_access_control.py | ||||
| pytest tests/test_notification.py | ||||
| pytest tests/test_backend.py | ||||
| pytest tests/test_rss.py | ||||
| pytest tests/test_unique_lines.py | ||||
							
								
								
									
										44
									
								
								changedetectionio/run_custom_browser_url_tests.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # run some tests and look if the 'custom-browser-search-string=1' connect string appeared in the correct containers | ||||
|  | ||||
| # enable debug | ||||
| set -x | ||||
|  | ||||
| # A extra browser is configured, but we never chose to use it, so it should NOT show in the logs | ||||
| docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_not_via_custom_browser_url' | ||||
| docker logs browserless-custom-url &>log.txt | ||||
| grep 'custom-browser-search-string=1' log.txt | ||||
| if [ $? -ne 1 ] | ||||
| then | ||||
|   echo "Saw a request in 'browserless-custom-url' container with 'custom-browser-search-string=1' when I should not" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| docker logs browserless &>log.txt | ||||
| grep 'custom-browser-search-string=1' log.txt | ||||
| if [ $? -ne 1 ] | ||||
| then | ||||
|   echo "Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Special connect string should appear in the custom-url container, but not in the 'default' one | ||||
| docker run --rm -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_via_custom_browser_url' | ||||
| docker logs browserless-custom-url &>log.txt | ||||
| grep 'custom-browser-search-string=1' log.txt | ||||
| if [ $? -ne 0 ] | ||||
| then | ||||
|   echo "Did not see request in 'browserless-custom-url' container with 'custom-browser-search-string=1' when I should" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| docker logs browserless &>log.txt | ||||
| grep 'custom-browser-search-string=1' log.txt | ||||
| if [ $? -ne 1 ] | ||||
| then | ||||
|   echo "Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
|  | ||||
							
								
								
									
										116
									
								
								changedetectionio/run_proxy_tests.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,116 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # exit when any command fails | ||||
| set -e | ||||
| # enable debug | ||||
| set -x | ||||
|  | ||||
| # Test proxy list handling, starting two squids on different ports | ||||
| # Each squid adds a different header to the response, which is the main thing we test for. | ||||
| docker run --network changedet-network -d --name squid-one --hostname squid-one --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge | ||||
| docker run --network changedet-network -d --name squid-two --hostname squid-two --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge | ||||
|  | ||||
| # SOCKS5 related - start simple Socks5 proxy server | ||||
| # SOCKSTEST=xyz should show in the logs of this service to confirm it fetched | ||||
| docker run --network changedet-network -d --hostname socks5proxy --name socks5proxy -p 1080:1080 -e PROXY_USER=proxy_user123 -e PROXY_PASSWORD=proxy_pass123 serjs/go-socks5-proxy | ||||
| docker run --network changedet-network -d --hostname socks5proxy-noauth -p 1081:1080 --name socks5proxy-noauth  serjs/go-socks5-proxy | ||||
|  | ||||
| echo "---------------------------------- SOCKS5 -------------------" | ||||
| # SOCKS5 related - test from proxies.json | ||||
| docker run --network changedet-network \ | ||||
|   -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \ | ||||
|   --rm \ | ||||
|   -e "SOCKSTEST=proxiesjson" \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|  | ||||
| # SOCKS5 related - by manually entering in UI | ||||
| docker run --network changedet-network \ | ||||
|   --rm \ | ||||
|   -e "SOCKSTEST=manual" \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy.py' | ||||
|  | ||||
| # SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY | ||||
| docker run --network changedet-network \ | ||||
|   -e "SOCKSTEST=manual-playwright" \ | ||||
|   -v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \ | ||||
|   -e "PLAYWRIGHT_DRIVER_URL=ws://browserless:3000" \ | ||||
|   --rm \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py' | ||||
|  | ||||
| echo "socks5 server logs" | ||||
| docker logs socks5proxy | ||||
| echo "----------------------------------" | ||||
|  | ||||
| # Used for configuring a custom proxy URL via the UI | ||||
| docker run --network changedet-network -d \ | ||||
|   --name squid-custom \ | ||||
|   --hostname squid-custom \ | ||||
|   --rm \ | ||||
|   -v `pwd`/tests/proxy_list/squid-auth.conf:/etc/squid/conf.d/debian.conf \ | ||||
|   -v `pwd`/tests/proxy_list/squid-passwords.txt:/etc/squid3/passwords \ | ||||
|   ubuntu/squid:4.13-21.10_edge | ||||
|  | ||||
|  | ||||
| ## 2nd test actually choose the preferred proxy from proxies.json | ||||
| docker run --network changedet-network \ | ||||
|   -v `pwd`/tests/proxy_list/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_list/test_multiple_proxy.py' | ||||
|  | ||||
|  | ||||
| ## Should be a request in the default "first" squid | ||||
| docker logs squid-one 2>/dev/null|grep chosen.changedetection.io | ||||
| if [ $? -ne 0 ] | ||||
| then | ||||
|   echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid one)" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # And one in the 'second' squid (user selects this as preferred) | ||||
| docker logs squid-two 2>/dev/null|grep chosen.changedetection.io | ||||
| if [ $? -ne 0 ] | ||||
| then | ||||
|   echo "Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid two)" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
|  | ||||
| # Test the UI configurable proxies | ||||
| docker run --network changedet-network \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_list/test_select_custom_proxy.py' | ||||
|  | ||||
|  | ||||
| # Should see a request for one.changedetection.io in there | ||||
| docker logs squid-custom 2>/dev/null|grep "TCP_TUNNEL.200.*changedetection.io" | ||||
| if [ $? -ne 0 ] | ||||
| then | ||||
|   echo "Did not see a valid request to changedetection.io in the squid logs (while checking preferred proxy - squid two)" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Test "no-proxy" option | ||||
| docker run --network changedet-network \ | ||||
|   test-changedetectionio \ | ||||
|   bash -c 'cd changedetectionio && pytest tests/proxy_list/test_noproxy.py' | ||||
|  | ||||
| # We need to handle grep returning 1 | ||||
| set +e | ||||
| # Check request was never seen in any container | ||||
| for c in $(echo "squid-one squid-two squid-custom"); do | ||||
|   echo Checking $c | ||||
|   docker logs $c &> $c.txt | ||||
|   grep noproxy $c.txt | ||||
|   if [ $? -ne 1 ] | ||||
|   then | ||||
|     echo "Saw request for noproxy in $c container" | ||||
|     cat $c.txt | ||||
|     exit 1 | ||||
|   fi | ||||
| done | ||||
|  | ||||
|  | ||||
| docker kill squid-one squid-two squid-custom | ||||
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/android-chrome-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 33 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/android-chrome-256x256.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 40 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 31 KiB | 
							
								
								
									
										9
									
								
								changedetectionio/static/favicons/browserconfig.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <browserconfig> | ||||
|     <msapplication> | ||||
|         <tile> | ||||
|             <square150x150logo src="favicons/mstile-150x150.png"/> | ||||
|             <TileColor>#da532c</TileColor> | ||||
|         </tile> | ||||
|     </msapplication> | ||||
| </browserconfig> | ||||
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon-16x16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 13 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon-32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/favicons/mstile-150x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 15 KiB | 
							
								
								
									
										35
									
								
								changedetectionio/static/favicons/safari-pinned-tab.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | ||||
| <?xml version="1.0" standalone="no"?> | ||||
| <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" | ||||
|  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> | ||||
| <svg version="1.0" xmlns="http://www.w3.org/2000/svg" | ||||
|  width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000" | ||||
|  preserveAspectRatio="xMidYMid meet"> | ||||
| <metadata> | ||||
| Created by potrace 1.14, written by Peter Selinger 2001-2017 | ||||
| </metadata> | ||||
| <g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)" | ||||
| fill="#000000" stroke="none"> | ||||
| <path d="M0 1280 l0 -1280 1280 0 1280 0 0 1280 0 1280 -1280 0 -1280 0 0 | ||||
| -1280z m1555 936 c387 -112 675 -426 741 -810 24 -138 15 -352 -20 -470 -106 | ||||
| -353 -360 -606 -713 -712 -75 -22 -113 -27 -253 -31 -144 -5 -176 -2 -252 16 | ||||
| -316 75 -564 271 -707 557 -67 136 -92 237 -98 401 -7 164 5 253 47 378 106 | ||||
| 315 349 556 665 659 114 37 180 45 350 41 125 -2 165 -7 240 -29z"/> | ||||
| <path d="M1091 2165 c-364 -82 -629 -328 -738 -682 -24 -80 -27 -103 -27 -258 | ||||
| -1 -146 2 -182 21 -251 74 -271 259 -497 508 -621 477 -238 1061 -35 1294 450 | ||||
| 61 126 83 220 88 379 7 194 -15 307 -93 461 -126 251 -340 428 -614 507 -99 | ||||
| 29 -343 37 -439 15z m829 -473 c55 -54 100 -106 100 -116 0 -21 -184 -213 | ||||
| -212 -222 -24 -7 -48 12 -48 38 0 11 26 47 58 80 l57 60 -151 -3 c-145 -4 | ||||
| -152 -5 -190 -31 -22 -15 -78 -73 -124 -128 l-85 -99 -32 31 -32 31 30 38 c17 | ||||
| 22 70 79 117 128 66 67 97 92 127 100 22 6 106 11 188 11 81 0 147 3 147 8 0 | ||||
| 4 -25 31 -55 61 -55 55 -65 77 -43 99 25 25 50 10 148 -86z m-1002 -101 c46 | ||||
| -24 141 -121 312 -321 203 -236 290 -330 322 -346 22 -11 60 -14 169 -12 l141 | ||||
| 3 -51 58 c-28 32 -51 64 -51 71 0 18 21 36 43 36 24 0 217 -193 217 -217 0 | ||||
| -19 -185 -210 -212 -219 -24 -7 -48 12 -48 38 0 10 23 43 50 72 l50 53 -52 7 | ||||
| c-29 3 -93 6 -142 6 -104 0 -152 12 -200 52 -19 15 -135 144 -258 286 -274 | ||||
| 316 -305 347 -354 361 -22 6 -94 11 -161 11 -67 0 -128 3 -137 6 -22 9 -21 61 | ||||
| 2 67 9 3 86 5 170 6 133 1 158 -2 190 -18z m227 -468 c23 -34 17 -43 -103 | ||||
| -172 -119 -128 -131 -133 -343 -129 l-154 3 0 35 c0 34 1 35 50 42 28 3 96 7 | ||||
| 153 7 64 1 115 6 136 15 20 8 71 56 127 120 52 58 99 106 105 106 7 0 20 -12 | ||||
| 29 -27z"/> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										19
									
								
								changedetectionio/static/favicons/site.webmanifest
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| { | ||||
|     "name": "", | ||||
|     "short_name": "", | ||||
|     "icons": [ | ||||
|         { | ||||
|             "src": "android-chrome-192x192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "android-chrome-256x256.png", | ||||
|             "sizes": "256x256", | ||||
|             "type": "image/png" | ||||
|         } | ||||
|     ], | ||||
|     "theme_color": "#ffffff", | ||||
|     "background_color": "#ffffff", | ||||
|     "display": "standalone" | ||||
| } | ||||
							
								
								
									
										18
									
								
								changedetectionio/static/images/Generic_Feed-icon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" | ||||
|      id="RSSicon" | ||||
|      viewBox="0 0 8 8" width="256" height="256"> | ||||
|  | ||||
|   <title>RSS feed icon</title> | ||||
|  | ||||
|   <style type="text/css"> | ||||
|     .button {stroke: none; fill: orange;} | ||||
|     .symbol {stroke: none; fill: white;} | ||||
|   </style> | ||||
|  | ||||
|   <rect   class="button" width="8" height="8" rx="1.5" /> | ||||
|   <circle class="symbol" cx="2" cy="6" r="1" /> | ||||
|   <path   class="symbol" d="m 1,4 a 3,3 0 0 1 3,3 h 1 a 4,4 0 0 0 -4,-4 z" /> | ||||
|   <path   class="symbol" d="m 1,2 a 5,5 0 0 1 5,5 h 1 a 6,6 0 0 0 -6,-6 z" /> | ||||
|  | ||||
| </svg> | ||||
| After Width: | Height: | Size: 569 B | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/Google-Chrome-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/Playwright-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/avatar-256x256.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 38 KiB | 
							
								
								
									
										4
									
								
								changedetectionio/static/images/bell-off.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg width="15" height="16.363636" viewBox="0 0 15 16.363636" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <path d="m 14.318182,11.762045 v 1.1925 H 5.4102273 L 11.849318,7.1140909 C 12.234545,9.1561364 12.54,11.181818 14.318182,11.762045 Z m -6.7984093,4.601591 c 1.0759091,0 2.0256823,-0.955909 2.0256823,-2.045454 H 5.4545455 c 0,1.089545 0.9879545,2.045454 2.0652272,2.045454 z M 15,2.8622727 0.9177273,15.636136 0,14.627045 l 1.8443182,-1.6725 h -1.1625 v -1.1925 C 4.0070455,10.677273 2.1784091,4.5388636 5.3611364,2.6897727 5.8009091,2.4347727 6.0709091,1.9609091 6.0702273,1.4488636 v -0.00205 C 6.0702273,0.64772727 6.7104545,0 7.5,0 8.2895455,0 8.9297727,0.64772727 8.9297727,1.4468182 v 0.00205 C 8.9290909,1.9602319 9.199773,2.4354591 9.638864,2.6897773 10.364318,3.111141 10.827273,3.7568228 11.1525,4.5129591 L 14.085682,1.8531818 Z M 6.8181818,1.3636364 C 6.8181818,1.74 7.1236364,2.0454545 7.5,2.0454545 7.8763636,2.0454545 8.1818182,1.74 8.1818182,1.3636364 8.1818182,0.98795455 7.8763636,0.68181818 7.5,0.68181818 c -0.3763636,0 -0.6818182,0.30613637 -0.6818182,0.68181822 z" id="path2" style="fill:#f8321b;stroke-width:0.681818;fill-opacity:1"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/beta-logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										58
									
								
								changedetectionio/static/images/brightdata.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    height="59.553207" | ||||
|    viewBox="-0.36 95.21 25.082135 59.553208" | ||||
|    width="249.99138" | ||||
|    version="1.1" | ||||
|    id="svg12" | ||||
|    sodipodi:docname="brightdata.svg" | ||||
|    inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs16" /> | ||||
|   <sodipodi:namedview | ||||
|      id="namedview14" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      showgrid="false" | ||||
|      fit-margin-top="0" | ||||
|      fit-margin-left="0" | ||||
|      fit-margin-right="0" | ||||
|      fit-margin-bottom="0" | ||||
|      inkscape:zoom="0.9464" | ||||
|      inkscape:cx="22.189349" | ||||
|      inkscape:cy="-90.870668" | ||||
|      inkscape:window-width="1920" | ||||
|      inkscape:window-height="1051" | ||||
|      inkscape:window-x="1920" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="1" | ||||
|      inkscape:current-layer="svg12" /> | ||||
|   <path | ||||
|      d="m -34.416031,129.28 c -3.97,-2.43 -5.1,-6.09 -4.32,-10.35 0.81,-4.4 3.95,-6.75 8.04,-7.75 4.23,-1.04 8.44,-0.86 12.3,1.5 0.63,0.39 0.93,0.03 1.31,-0.29 1.5,-1.26 3.27,-1.72 5.189999,-1.83 0.79,-0.05 1.04,0.24 1.01,1.01 -0.05,1.31 -0.04,2.63 0,3.95 0.02,0.65 -0.19,0.93 -0.87,0.89 -0.889999,-0.04 -1.789999,0.03 -2.669999,-0.02 -0.82,-0.04 -1.08,0.1 -0.88,1.04 0.83,3.9 -0.06,7.37 -3.1,10.06 -2.76,2.44 -6.13,3.15 -9.72,3.04 -0.51,-0.02 -1.03,-0.02 -1.52,-0.13 -1.22,-0.25 -1.96,0.14 -2.19,1.41 -0.28,1.54 0.16,2.62 1.37,3.07 0.84,0.31 1.74,0.35 2.63,0.39 2.97,0.13 5.95,-0.18 8.91,0.21 2.93,0.39 5.69,1.16 6.85,4.25 1.269999,3.38 0.809999,6.62 -1.48,9.47 -2.73,3.39 -6.52,4.78 -10.66,5.33 -3.53,0.48 -7.04,0.27 -10.39,-1.11 -3.89,-1.6 -5.75,-4.95 -4.84,-8.72 0.51,-2.11 1.85,-3.58 3.69,-4.65 0.38,-0.22 0.93,-0.32 0.28,-0.96 -2.91,-2.83 -2.85,-6.16 0.1,-8.95 0.28,-0.26 0.6,-0.53 0.96,-0.86 z m 8.07,21.5 c 0.95,0.04 1.87,-0.13 2.78,-0.33 1.89,-0.42 3.51,-1.3 4.49,-3.06 1.82,-3.25 0.24,-6.2 -3.37,-6.58 -2.88,-0.3 -5.76,0.24 -8.63,-0.13 -0.53,-0.07 -0.75,0.34 -0.95,0.71 -1.16,2.24 -1.08,4.53 0,6.73 1.15,2.34 3.46,2.48 5.68,2.66 z m -5,-30.61 c -0.03,1.67 0.08,3.19 0.74,4.61 0.76,1.62 2.17,2.42 4.03,2.31 1.62,-0.1 2.9,-1.12 3.36,-2.84 0.66,-2.46 0.69,-4.95 0.01,-7.42 -0.49,-1.76 -1.7,-2.64 -3.56,-2.7 -2.08,-0.07 -3.37,0.7 -4.04,2.42 -0.47,1.21 -0.6,2.47 -0.54,3.62 z m 32.9399993,6.56 c 0,2.59 0.05,5.18 -0.02,7.77 -0.03,1.03 0.31,1.46 1.32,1.52 0.65,0.04 1.61,-0.09 1.82,0.57 0.26,0.81 0.11,1.76 0.06,2.65 -0.03,0.48 -0.81,0.39 -0.81,0.39 l -11.47,0.01 c 0,0 -0.95,-0.21 -0.88,-0.88 0.03,-0.29 0.04,-0.6 0,-0.89 -0.19,-1.24 0.21,-1.92 1.58,-1.9 0.99,0.01 1.28,-0.52 1.28,-1.53 -0.05,-8.75 -0.05,-17.49 0,-26.24 0.01,-1.15 -0.36,-1.62 -1.44,-1.67 -0.17,-0.01 -0.34,-0.04 -0.5,-0.07 -1.43,-0.22 -2.12,-1.57 -1.53,-2.91 0.15,-0.35 0.43,-0.36 0.72,-0.4 2.94,-0.41 5.88,-0.81 8.82000002,-1.23 0.81999998,-0.12 0.99999998,0.27 0.98999998,1.01 -0.02,3.35 0,6.71 0.02,10.06 0,0.35 -0.23,0.84 0.18,1.03 0.38,0.17 0.69,-0.25 0.99,-0.45 2.56,-1.74 5.33,-2.73 8.4900007,-2.56 3.51005,0.19 5.65005,1.95 6.35005,5.46 0.42,2.09 0.52,4.21 0.51,6.33 -0.02,3.86 0.05,7.73 -0.04,11.59 -0.02,1.12 0.37,1.5 1.39,1.6 0.61,0.05 1.55,-0.13 1.74,0.47 0.26,0.85 0.12,1.84 0.1,2.77 -0.01,0.41 -0.69,0.37 -0.69,0.37 l -11.4700504,0.01 c 0,0 -0.81,-0.29 -0.8,-0.85 0.01,-0.38 0.04,-0.77 -0.01,-1.15 -0.13,-1.01 0.32,-1.52 1.31,-1.56 1.0600004,-0.05 1.3800004,-0.55 1.3500004,-1.63 -0.14,-4.84 0.16,-9.68 -0.18,-14.51 -0.26,-3.66 -2.1100004,-4.95 -5.6700007,-3.99 -0.25,0.07 -0.49,0.15 -0.73,0.22 -2.57,0.8 -2.79,1.09 -2.79,3.71 0.01,2.3 0.01,4.59 0.01,6.88 z M -109.26603,122.56 c 0,-4.75 -0.02,-9.51 0.02,-14.26 0.01,-0.92 -0.17,-1.47 -1.19,-1.45 -0.16,0 -0.33,-0.07 -0.5,-0.1 -1.56,-0.27 -2.24,-1.47 -1.69,-2.92 0.14,-0.37 0.41,-0.38 0.7,-0.42 2.98,-0.41 5.97,-0.81 8.94,-1.24 0.85,-0.12 0.88,0.33 0.88,0.96 -0.01,3.01 -0.01,6.03 0,9.04 0,0.4 -0.18,0.96 0.27,1.16 0.36,0.16 0.66,-0.3 0.96,-0.52 4.729999,-3.51 12.459999,-2.61 14.889999,4.48 1.89,5.51 1.91,11.06 -0.96,16.28 -2.37,4.31 -6.19,6.49 -11.15,6.59 -3.379999,0.07 -6.679999,-0.3 -9.909999,-1.37 -0.93,-0.31 -1.3,-0.78 -1.28,-1.83 0.05,-4.81 0.02,-9.6 0.02,-14.4 z m 7.15,3.89 c 0,2.76 0.02,5.52 -0.01,8.28 -0.01,0.76 0.18,1.29 0.91,1.64 1.899999,0.9 4.299999,0.5 5.759999,-1.01 0.97,-1 1.56,-2.21 1.96,-3.52 1.03,-3.36 0.97,-6.78 0.61,-10.22 a 9.991,9.991 0 0 0 -0.93,-3.29 c -1.47,-3.06 -4.67,-3.85 -7.439999,-1.86 -0.6,0.43 -0.88,0.93 -0.87,1.7 0.04,2.76 0.01,5.52 0.01,8.28 z" | ||||
|      fill="#4280f6" | ||||
|      id="path2" /> | ||||
|   <path | ||||
|      d="m 68.644019,137.2 c -1.62,1.46 -3.41,2.56 -5.62,2.96 -4.4,0.8 -8.7,-1.39 -10.49,-5.49 -2.31,-5.31 -2.3,-10.67 -0.1,-15.98 2.31,-5.58 8.29,-8.65 14.24,-7.46 1.71,0.34 1.9,0.18 1.9,-1.55 0,-0.68 -0.05,-1.36 0.01,-2.04 0.09,-1.02 -0.25,-1.54 -1.34,-1.43 -0.64,0.06 -1.26,-0.1 -1.88,-0.21 -1.32,-0.24 -1.6,-0.62 -1.37,-1.97 0.07,-0.41 0.25,-0.57 0.65,-0.62 2.63,-0.33 5.27,-0.66 7.9,-1.02 1.04,-0.14 1.17,0.37 1.17,1.25 -0.02,10.23 -0.02,20.45 -0.01,30.68 v 1.02 c 0.02,0.99 0.35,1.6 1.52,1.47 0.52,-0.06 1.35,-0.27 1.25,0.73 -0.08,0.8 0.58,1.93 -0.94,2.18 -1.29,0.22 -2.51,0.69 -3.86,0.65 -2.04,-0.06 -2.3,-0.23 -2.76,-2.19 -0.09,-0.3 0.06,-0.67 -0.27,-0.98 z m -0.07,-12.46 c 0,-2.8 -0.04,-5.6 0.02,-8.39 0.02,-0.9 -0.28,-1.47 -1.05,-1.81 -3.18,-1.4 -7.54,-0.8 -9.3,2.87 -0.83,1.74 -1.31,3.54 -1.49,5.46 -0.28,2.93 -0.38,5.83 0.61,8.65 0.73,2.09 1.81,3.9 4.11,4.67 2.49,0.83 4.55,-0.04 6.5,-1.48 0.54,-0.4 0.62,-0.95 0.61,-1.57 -0.02,-2.8 -0.01,-5.6 -0.01,-8.4 z m 28.79,2.53 c 0,3.24 0.04,5.83 -0.02,8.41 -0.02,1 0.19,1.49 1.309998,1.41 0.55,-0.04 1.460003,-0.46 1.520003,0.73 0.05,1.02 0.1,1.89 -1.330003,2.08 -1.289998,0.17 -2.559998,0.51 -3.889998,0.48 -1.88,-0.05 -2.15,-0.26 -2.42,-2.15 -0.04,-0.27 0.14,-0.65 -0.22,-0.79 -0.34,-0.13 -0.5,0.24 -0.72,0.42 -3.61,3 -8.15,3.4 -11.64,1.08 -1.61,-1.07 -2.49,-2.63 -2.67,-4.43 -0.51,-5.13 0.77,-7.91 6.3,-10.22 2.44,-1.02 5.07,-1.27 7.68,-1.49 0.77,-0.07 1.03,-0.28 1.02,-1.05 -0.03,-1.48 -0.05,-2.94 -0.64,-4.36 -0.59,-1.42 -1.67,-1.92 -3.08,-2.03 -3.04,-0.24 -5.88,0.5 -8.63,1.71 -0.51,0.23 -1.19,0.75 -1.48,-0.13 -0.26,-0.77 -1.35,-1.61 0.05,-2.47 3.27,-2 6.7,-3.44 10.61,-3.42 1.44,0.01 2.88,0.27 4.21,0.81 2.67,1.08 3.44,3.4 3.8,5.99 0.46,3.37 0.1,6.73 0.24,9.42 z m -5.09,2.9 c 0,-1.23 -0.01,-2.46 0,-3.69 0,-0.52 -0.06,-0.98 -0.75,-0.84 -1.45,0.3 -2.93,0.28 -4.37,0.69 -3.71,1.04 -5.46,4.48 -3.97,8.03 0.51,1.22 1.48,1.98 2.79,2.16 2.01,0.28 3.86,-0.29 5.6,-1.28 0.54,-0.31 0.73,-0.76 0.72,-1.37 -0.05,-1.23 -0.02,-2.47 -0.02,-3.7 z m 43.060001,-2.89 c 0,2.72 0.01,5.43 -0.01,8.15 0,0.66 0.02,1.21 0.91,1.12 0.54,-0.06 0.99,0.12 0.86,0.75 -0.15,0.71 0.56,1.7 -0.58,2.09 -1.55,0.52 -3.16,0.59 -4.77,0.4 -0.99,-0.12 -1.12,-1.01 -1.18,-1.73 -0.08,-1.15 -0.16,-1.45 -1.24,-0.54 -3.41,2.87 -8.05,3.17 -11.43,0.88 -1.75,-1.18 -2.49,-2.91 -2.7,-4.94 -0.64,-6.24 3.16,-8.74 7.83,-10.17 2.04,-0.62 4.14,-0.8 6.24,-0.99 0.81,-0.07 1,-0.36 0.98,-1.09 -0.04,-1.31 0.04,-2.62 -0.42,-3.89 -0.57,-1.57 -1.53,-2.34 -3.18,-2.45 -3.03,-0.21 -5.88,0.46 -8.64,1.66 -0.6,0.26 -1.25,0.81 -1.68,-0.2 -0.34,-0.8 -1.08,-1.61 0.16,-2.36 4.12,-2.5 8.44,-4.16 13.36,-3.07 3.21,0.71 4.89,2.91 5.26,6.34 0.18,1.69 0.22,3.37 0.22,5.07 0.01,1.66 0.01,3.32 0.01,4.97 z m -5.09,2.54 c 0,-1.27 -0.03,-2.54 0.01,-3.81 0.02,-0.74 -0.27,-1.02 -0.98,-0.92 -1.21,0.17 -2.43,0.28 -3.62,0.55 -3.72,0.83 -5.47,3.48 -4.82,7.21 0.29,1.66 1.57,2.94 3.21,3.16 2.02,0.27 3.85,-0.34 5.57,-1.34 0.49,-0.29 0.64,-0.73 0.63,-1.29 -0.02,-1.18 0,-2.37 0,-3.56 z" | ||||
|      fill="#c8dbfb" | ||||
|      id="path4" /> | ||||
|   <path | ||||
|      d="m 26.314019,125.77 c 0,-2.89 -0.05,-5.77 0.02,-8.66 0.03,-1.04 -0.33,-1.39 -1.31,-1.24 a 0.7,0.7 0 0 1 -0.25,0 c -0.57,-0.18 -1.44,0.48 -1.68,-0.58 -0.35,-1.48 -0.02,-2.3 1.21,-2.7 1.3,-0.43 2.16,-1.26 2.76,-2.46 0.78,-1.56 1.44,-3.17 1.91,-4.84 0.18,-0.63 0.47,-0.86 1.15,-0.88 3.28,-0.09 3.27,-0.11 3.32,3.17 0.01,1.06 0.09,2.12 0.09,3.18 -0.01,0.67 0.27,0.89 0.91,0.88 1.61,-0.02 3.23,0.03 4.84,-0.02 0.77,-0.02 1.01,0.23 1.03,1.01 0.08,3.27 0.1,3.27 -3.09,3.27 -0.93,0 -1.87,0.03 -2.8,-0.01 -0.67,-0.02 -0.89,0.26 -0.88,0.91 0.04,5.43 0.04,10.86 0.12,16.29 0.02,1.7 0.75,2.26 2.46,2.1 1.1,-0.1 2.19,-0.26 3.23,-0.65 0.59,-0.22 0.89,-0.09 1.14,0.53 0.93,2.29 0.92,2.37 -1.32,3.52 -2.54,1.3 -5.22,1.99 -8.1,1.79 -2.27,-0.16 -3.68,-1.27 -4.35,-3.45 -0.3,-0.98 -0.41,-1.99 -0.41,-3.01 z m -97.67005,-8.99 c 0.57,-0.84 1.11,-1.74 1.76,-2.55 1.68,-2.09 3.68,-3.62 6.54,-3.66 1.08,-0.01 1.63,0.28 1.57,1.52 -0.1,2.08 -0.05,4.16 -0.02,6.24 0.01,0.74 -0.17,0.96 -0.96,0.76 -2.36,-0.59 -4.71,-0.42 -7.03,0.28 -0.8,0.24 -1.16,0.62 -1.15,1.52 0.05,4.5 0.04,9 0,13.5 -0.01,0.89 0.29,1.16 1.15,1.2 1.23,0.06 2.44,0.32 3.67,0.39 0.75,0.05 0.91,0.38 0.89,1.04 -0.06,2.86 0.29,2.28 -2.25,2.3 -4.2,0.04 -8.41,-0.02 -12.61,0.03 -0.91,0.01 -1.39,-0.18 -1.22,-1.18 0.02,-0.12 0,-0.25 0,-0.38 0.02,-2.1 -0.24,-1.88 1.77,-2.04 1.33,-0.11 1.6,-0.67 1.58,-1.9 -0.07,-5.35 -0.04,-10.7 -0.02,-16.05 0,-0.78 -0.17,-1.2 -1,-1.46 -2.21,-0.68 -2.7,-1.69 -2.22,-3.99 0.11,-0.52 0.45,-0.56 0.82,-0.62 2.22,-0.34 4.44,-0.7 6.67,-0.99 0.99,-0.13 1.82,0.7 1.84,1.76 0.03,1.4 0.03,2.8 0.04,4.2 -0.01,0.02 0.06,0.04 0.18,0.08 z m 25.24,6.59 c 0,3.69 0.04,7.38 -0.03,11.07 -0.02,1.04 0.31,1.48 1.32,1.49 0.29,0 0.59,0.12 0.88,0.13 0.93,0.01 1.18,0.47 1.16,1.37 -0.05,2.19 0,2.19 -2.24,2.19 -3.48,0 -6.96,-0.04 -10.44,0.03 -1.09,0.02 -1.47,-0.33 -1.3,-1.36 0.02,-0.12 0.02,-0.26 0,-0.38 -0.28,-1.39 0.39,-1.96 1.7,-1.9 1.36,0.06 1.76,-0.51 1.74,-1.88 -0.09,-5.17 -0.08,-10.35 0,-15.53 0.02,-1.22 -0.32,-1.87 -1.52,-2.17 -0.57,-0.14 -1.47,-0.11 -1.57,-0.85 -0.15,-1.04 -0.05,-2.11 0.01,-3.17 0.02,-0.34 0.44,-0.35 0.73,-0.39 2.81,-0.39 5.63,-0.77 8.44,-1.18 0.92,-0.14 1.15,0.2 1.14,1.09 -0.04,3.8 -0.02,7.62 -0.02,11.44 z" | ||||
|      fill="#4280f6" | ||||
|      id="path6" /> | ||||
|   <path | ||||
|      d="m 101.44402,125.64 c 0,-3.18 -0.03,-6.37 0.02,-9.55 0.02,-0.94 -0.26,-1.36 -1.22,-1.22 -0.21,0.03 -0.430003,0.04 -0.630003,0 -0.51,-0.12 -1.35,0.39 -1.44,-0.55 -0.08,-0.85 -0.429998,-1.87 0.93,-2.24 2.080003,-0.57 2.720003,-2.39 3.350003,-4.17 0.31,-0.88 0.62,-1.76 0.87,-2.66 0.18,-0.64 0.52,-0.85 1.19,-0.84 2.46,0.05 2,-0.15 2.04,2.04 0.02,1.1 0.08,2.21 -0.02,3.31 -0.11,1.16 0.46,1.52 1.46,1.53 1.78,0.01 3.57,0.04 5.35,-0.01 0.82,-0.02 1.12,0.23 1.11,1.08 -0.05,2.86 0.19,2.49 -2.42,2.51 -1.53,0.01 -3.06,0.02 -4.59,-0.01 -0.65,-0.01 -0.9,0.22 -0.9,0.89 0.02,5.52 0,11.04 0.03,16.56 0,0.67 0.14,1.34 0.25,2.01 0.17,1.04 1.17,1.62 2.59,1.42 1.29,-0.19 2.57,-0.49 3.86,-0.69 0.43,-0.07 1.05,-0.47 1.19,0.4 0.12,0.75 1.05,1.61 -0.09,2.24 -2.09,1.16 -4.28,2.07 -6.71,2.16 -1.05,0.04 -2.13,0.2 -3.16,-0.14 -1.92,-0.65 -3.03,-2.28 -3.05,-4.51 -0.02,-3.19 -0.01,-6.37 -0.01,-9.56 z" | ||||
|      fill="#c8dbfb" | ||||
|      id="path8" /> | ||||
|   <path | ||||
|      d="m -50.816031,95.21 c 0.19,2.160002 1.85,3.240002 2.82,4.740002 0.25,0.379998 0.48,0.109998 0.67,-0.16 0.21,-0.31 0.6,-1.21 1.15,-1.28 -0.35,1.38 -0.04,3.149998 0.16,4.449998 0.49,3.05 -1.22,5.64 -4.07,6.18 -3.38,0.65 -6.22,-2.21 -5.6,-5.62 0.23,-1.24 1.37,-2.5 0.77,-3.699998 -0.85,-1.7 0.54,-0.52 0.79,-0.22 1.04,1.199998 1.21,0.09 1.45,-0.55 0.24,-0.63 0.31,-1.31 0.47,-1.97 0.19,-0.770002 0.55,-1.400002 1.39,-1.870002 z" | ||||
|      fill="#4280f6" | ||||
|      id="path10" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										40
									
								
								changedetectionio/static/images/copy.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    x="0px" | ||||
|    y="0px" | ||||
|    viewBox="0 0 115.77 122.88" | ||||
|    style="enable-background:new 0 0 115.77 122.88" | ||||
|    xml:space="preserve" | ||||
|    sodipodi:docname="copy.svg" | ||||
|    inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|      id="defs11" /><sodipodi:namedview | ||||
|      id="namedview9" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      showgrid="false" | ||||
|      inkscape:zoom="5.5501303" | ||||
|      inkscape:cx="57.83648" | ||||
|      inkscape:cy="61.439999" | ||||
|      inkscape:window-width="1920" | ||||
|      inkscape:window-height="1056" | ||||
|      inkscape:window-x="1920" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="1" | ||||
|      inkscape:current-layer="g6" /><style | ||||
|      type="text/css" | ||||
|      id="style2">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g | ||||
|      id="g6"><path | ||||
|        class="st0" | ||||
|        d="M89.62,13.96v7.73h12.19h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02v0.02 v73.27v0.01h-0.02c-0.01,3.84-1.57,7.33-4.1,9.86c-2.51,2.5-5.98,4.06-9.82,4.07v0.02h-0.02h-61.7H40.1v-0.02 c-3.84-0.01-7.34-1.57-9.86-4.1c-2.5-2.51-4.06-5.98-4.07-9.82h-0.02v-0.02V92.51H13.96h-0.01v-0.02c-3.84-0.01-7.34-1.57-9.86-4.1 c-2.5-2.51-4.06-5.98-4.07-9.82H0v-0.02V13.96v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07V0h0.02h61.7 h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02V13.96L89.62,13.96z M79.04,21.69v-7.73v-0.02h0.02 c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v64.59v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h12.19V35.65 v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07v-0.02h0.02H79.04L79.04,21.69z M105.18,108.92V35.65v-0.02 h0.02c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v73.27v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h61.7h0.02 v0.02c0.91,0,1.75-0.39,2.37-1.01c0.61-0.61,1-1.46,1-2.37h-0.02V108.92L105.18,108.92z" | ||||
|        id="path4" | ||||
|        style="fill:#ffffff;fill-opacity:1" /></g></svg> | ||||
| After Width: | Height: | Size: 2.5 KiB | 
							
								
								
									
										37
									
								
								changedetectionio/static/images/email.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,37 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||
|  | ||||
| <svg | ||||
|    fill="#FFFFFF" | ||||
|    height="7.5005589" | ||||
|    width="11.248507" | ||||
|    version="1.1" | ||||
|    id="Layer_1" | ||||
|    viewBox="0 0 7.1975545 4.7993639" | ||||
|    xml:space="preserve" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|    id="defs19" /> | ||||
| <g | ||||
|    id="g14" | ||||
|    transform="matrix(-0.01406065,0,0,0.01406065,7.1975543,-1.1990922)"> | ||||
| 	<g | ||||
|    id="g12"> | ||||
| 		<g | ||||
|    id="g10"> | ||||
| 			<path | ||||
|    d="M 468.373,85.28 H 45.333 C 21.227,85.28 0,105.76 0,129.014 V 383.2 c 0,23.147 21.227,43.413 45.333,43.413 h 422.933 c 23.68,0 43.627,-19.84 43.627,-43.413 V 129.014 C 512,105.334 492.053,85.28 468.373,85.28 Z m 0,320 H 45.333 c -12.373,0 -24,-10.773 -24,-22.08 V 129.014 c 0,-11.307 11.84,-22.4 24,-22.4 h 422.933 c 11.733,0 22.293,10.667 22.293,22.4 V 383.2 h 0.107 c 10e-4,11.734 -10.453,22.08 -22.293,22.08 z" | ||||
|    id="path2" /> | ||||
| 			<path | ||||
|    d="m 440.853,153.974 c -3.307,-4.907 -9.92,-6.187 -14.827,-2.987 L 256,264.48 85.973,151.094 c -4.907,-3.2 -11.52,-1.707 -14.72,3.2 -3.093,4.8 -1.813,11.307 2.88,14.507 l 176,117.333 c 3.627,2.347 8.213,2.347 11.84,0 l 176,-117.333 c 4.8,-3.201 6.187,-9.921 2.88,-14.827 z" | ||||
|    id="path4" /> | ||||
| 			<path | ||||
|    d="m 143.573,257.654 c -0.107,0.107 -0.32,0.213 -0.427,0.32 L 68.48,311.307 c -4.907,3.307 -6.187,9.92 -2.88,14.827 3.307,4.907 9.92,6.187 14.827,2.88 0.107,-0.107 0.32,-0.213 0.427,-0.32 l 74.667,-53.333 c 4.907,-3.307 6.187,-9.92 2.88,-14.827 -3.308,-4.907 -9.921,-6.187 -14.828,-2.88 z" | ||||
|    id="path6" /> | ||||
| 			<path | ||||
|    d="m 443.947,311.627 c -0.107,-0.107 -0.32,-0.213 -0.427,-0.32 l -74.667,-53.333 c -4.693,-3.52 -11.413,-2.56 -14.933,2.133 -3.52,4.693 -2.56,11.413 2.133,14.933 0.107,0.107 0.32,0.213 0.427,0.32 l 74.667,53.333 c 4.693,3.52 11.413,2.56 14.933,-2.133 3.52,-4.693 2.56,-11.413 -2.133,-14.933 z" | ||||
|    id="path8" /> | ||||
| 		</g> | ||||
| 	</g> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										3
									
								
								changedetectionio/static/images/generic-icon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
| <svg width="61.649mm" height="61.649mm" version="1.1" viewBox="0 0 61.649 61.649" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(66.269 -15.463)" fill="#3056d3"><g transform="matrix(1.423 0 0 1.423 101.16 69.23)" fill="#3056d3"><g transform="matrix(.8229 0 0 .8229 -23.378 -2.3935)" fill="#3056d3"><path d="m-88.248-43.007a26.323 26.323 0 0 0-26.323 26.323 26.323 26.323 0 0 0 26.323 26.323 26.323 26.323 0 0 0 26.323-26.323 26.323 26.323 0 0 0-26.323-26.323zm0 2.8417a23.482 23.482 0 0 1 23.482 23.482 23.482 23.482 0 0 1-23.482 23.482 23.482 23.482 0 0 1-23.482-23.482 23.482 23.482 0 0 1 23.482-23.482z"/><g transform="matrix(.26458 0 0 .26458 -115.65 -44.085)"><path d="m33.02 64.43c0.35-0.05 2.04-0.13 2.04-0.13h25.53s3.17 0.32 3.67 0.53c2.5 1.05 3.98 1.89 6.04 3.57 0.72 0.58 4.12 4.01 4.12 4.01l51.67 57.39s1.61 1.65 1.97 1.94c1.2 0.97 2.48 1.96 3.98 2.32 0.5 0.12 2.72 0.21 2.72 0.21h27.32l-8.83-9.04s-1.31-1.65-1.44-1.94c-0.45-0.93-0.59-2.59-0.13-3.51 0.35-0.69 1.46-1.87 2.23-1.98 1.03-0.14 2.12-0.39 3.02 0.14 0.33 0.2 1.64 1.32 1.64 1.32l17.49 17.49s1.35 1.09 1.6 1.6c0.17 0.34 0.29 0.82 0.15 1.18-0.17 0.42-1.42 1.63-1.42 1.63l-0.94 0.98-15.69 16.37s-1.44 1.4-1.79 1.67c-0.76 0.6-1.99 0.89-2.96 0.9-1.03 0-2.62-1.11-3.26-1.91-0.6-0.76-1.1-2.22-0.77-3.13 0.16-0.45 1.28-1.85 1.28-1.85l11.36-11.3-29.47-0.02-1.68 0.09s-4.16-0.66-5.26-1.03c-1.63-0.56-3.44-1.82-4.75-2.93-0.39-0.33-1.8-1.92-1.8-1.92l-51.7-59.28s-2-2.06-2.43-2.43c-1.37-1.17-2-1.62-3.76-2.34-0.44-0.18-3.45-0.55-3.45-0.55l-24.13-0.22s-2.23-0.15-2.61-0.22c-1.08-0.21-2.16-1.07-2.81-1.83-0.79-0.92-0.59-3.06 0.06-4.09 0.57-0.89 2.14-1.52 3.19-1.66z"/><path d="m86.1 109.7-17.13 19.65s-2 2.06-2.43 2.43c-1.37 1.17-2 1.62-3.76 2.34-0.44 0.18-3.45 0.55-3.45 0.55l-24.13 0.22s-2.23 0.15-2.61 0.22c-1.08 0.21-2.16 1.07-2.81 1.83-0.79 0.92-0.59 3.06 0.06 4.09 0.57 0.89 2.14 1.52 3.19 1.66 0.35 0.05 2.04 0.13 2.04 0.13h25.53s3.17-0.32 3.67-0.53c2.5-1.05 3.98-1.89 6.04-3.57 0.72-0.58 4.12-4.01 4.12-4.01l17.38-19.3z"/><path d="m177.81 67.6c-0.17-0.42-1.42-1.63-1.42-1.63l-0.94-0.98-15.69-16.37s-1.44-1.4-1.79-1.67c-0.76-0.6-1.99-0.89-2.96-0.9-1.03 0-2.62 1.11-3.26 1.91-0.6 0.76-1.1 2.22-0.77 3.13 0.16 0.45 1.28 1.85 1.28 1.85l11.36 11.3-29.47 0.02-1.68-0.09s-4.16 0.66-5.26 1.03c-1.63 0.56-3.44 1.82-4.75 2.93-0.39 0.33-1.8 1.92-1.8 1.92l-18.91 21.69 5.98 5.98 18.38-20.41s1.61-1.65 1.97-1.94c1.2-0.97 2.48-1.96 3.98-2.32 0.5-0.12 2.72-0.21 2.72-0.21h27.32l-8.83 9.04s-1.31 1.65-1.44 1.94c-0.45 0.93-0.59 2.59-0.13 3.51 0.35 0.69 1.46 1.87 2.23 1.98 1.03 0.14 2.12 0.39 3.02-0.14 0.33-0.2 1.64-1.32 1.64-1.32l17.49-17.49s1.35-1.09 1.6-1.6c0.17-0.34 0.29-0.82 0.15-1.18z"/></g></g></g></g></svg> | ||||
| After Width: | Height: | Size: 2.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/gradient-border.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 22 KiB |