mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 16:08:02 -05:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39daa11f2d | ||
|
|
93de38a845 | ||
|
|
43caaca1f2 | ||
|
|
7bcc0195fe | ||
|
|
dac6541e28 | ||
|
|
2504a34a34 | ||
|
|
e19639ad0d | ||
|
|
b8084e02b5 | ||
|
|
97906281e6 | ||
|
|
2cea119657 | ||
|
|
6f16d289dd | ||
|
|
a96575c6b3 | ||
|
|
0a82e83352 | ||
|
|
d5e1cdec61 | ||
|
|
ef40c25b09 | ||
|
|
6370a2976a | ||
|
|
d8180299ea | ||
|
|
ac409dce3d | ||
|
|
56c007c20d | ||
|
|
487f13f704 | ||
|
|
00b9d87cdc | ||
|
|
2c797e0b9b | ||
|
|
4a2b27bfbf | ||
|
|
463a4dc0eb | ||
|
|
4b3bea661d | ||
|
|
976f310f51 | ||
|
|
4d8d3dc266 | ||
|
|
ce9e678c4c | ||
|
|
8cf30b6b7d | ||
|
|
2b6d08f8a5 | ||
|
|
f8fc63991f | ||
|
|
d96a1f677c | ||
|
|
b14689791c | ||
|
|
b70c877e44 | ||
|
|
041655376a | ||
|
|
e1eab7696b | ||
|
|
65d1d36d53 | ||
|
|
120d776fc2 | ||
|
|
425e16295b | ||
|
|
dd7e9d72cc | ||
|
|
55535ddd62 |
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,10 @@
|
||||
FROM node:16-alpine3.12
|
||||
FROM node:16-alpine3.14
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN apk update
|
||||
|
||||
# required dependencies in order to compile linux-musl (node-canvas) on alpine
|
||||
# https://github.com/node-gfx/node-canvas-prebuilt/issues/77#issuecomment-884365161
|
||||
RUN apk add --no-cache build-base g++ cairo-dev jpeg-dev pango-dev giflib-dev
|
||||
# required dependencies in order to compile linux-musl (node-canvas) on alpine
|
||||
RUN apk add --update --repository http://dl-3.alpinelinux.org/alpine/edge/testing libmount ttf-dejavu ttf-droid ttf-freefont ttf-liberation ttf-ubuntu-font-family fontconfig
|
||||
# vips required to run sharp library for image comparison
|
||||
RUN echo "http://dl-4.alpinelinux.org/alpine/v3.14/community" >> /etc/apk/repositories \
|
||||
&& apk --update add vips
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
@@ -17,9 +13,7 @@ WORKDIR /usr/app
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json .
|
||||
|
||||
# no prebuild support for node-canvas on alpine so need to compile
|
||||
# https://github.com/Automattic/node-canvas#compiling
|
||||
RUN npm install --build-from-source
|
||||
RUN npm install
|
||||
|
||||
ADD . /usr/app
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Some feature highlights:
|
||||
* Author criteria (name, css flair/text, age, karma, moderator status, and [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes))
|
||||
* Activity state (removed, locked, distinguished, etc.)
|
||||
* Rules and Actions support named references (write once, reference anywhere)
|
||||
* [**Image Comparisons**](/docs/imageComparison.md) via fingerprinting and/or pixel differences
|
||||
* Global/subreddit-level **API caching**
|
||||
* Support for [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) as criteria or Actions (writing notes)
|
||||
* Docker container support
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* [Activities `window`](#activities-window)
|
||||
* [Comparisons](#thresholds-and-comparisons)
|
||||
* [Activity Templating](/docs/actionTemplating.md)
|
||||
* [Image Comparisons](#image-comparisons)
|
||||
* [Best Practices](#best-practices)
|
||||
* [Named Rules](#named-rules)
|
||||
* [Rule Order](#rule-order)
|
||||
@@ -268,6 +269,12 @@ The duration value compares a time range from **now** to `duration value` time i
|
||||
|
||||
Refer to [duration values in activity window documentation](/docs/activitiesWindow.md#duration-values) as well as the individual rule/criteria schema to see what this duration is comparing against.
|
||||
|
||||
### Image Comparisons
|
||||
|
||||
ContextMod implements two methods for comparing **image content**, perceptual hashing and pixel-to-pixel comparisons. Comparisons can be used to filter activities in some activities.
|
||||
|
||||
See [image comparison documentation](/docs/imageComparison.md) for a full reference.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Named Rules
|
||||
|
||||
201
docs/imageComparison.md
Normal file
201
docs/imageComparison.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Overview
|
||||
|
||||
ContextMod supports comparing image content, for the purpose of detecting duplicates, with two different but complimentary systems. Image comparison behavior is available for the following rules:
|
||||
|
||||
* [Recent Activity](/docs/examples/recentActivity)
|
||||
* Repeat Activity (In-progress)
|
||||
|
||||
To enable comparisons reference the example below (at the top-level of your rule) and configure as needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "ruleWithImageDetection",
|
||||
"kind": "recentActivity",
|
||||
// Add block below...
|
||||
//
|
||||
"imageDetection": {
|
||||
// enables image comparison
|
||||
"enable": true,
|
||||
// The difference, in percentage, between the reference submission and the submissions being checked
|
||||
// must be less than this number to consider the images "the same"
|
||||
"threshold": 5,
|
||||
// optional
|
||||
// set the behavior for determining if image comparison should occur on a URL:
|
||||
//
|
||||
// "extension" => try image detection if URL ends in a known image extension (jpeg, gif, png, bmp, etc.)
|
||||
// "unknown" => try image detection if URL ends in known image extension OR there is no extension OR the extension is unknown (not video, html, doc, etc...)
|
||||
// "all" => ALWAYS try image detection, regardless of URL extension
|
||||
//
|
||||
// if fetchBehavior is not defined then "extension" is the default
|
||||
"fetchBehavior": "extension",
|
||||
},
|
||||
//
|
||||
// And above ^^^
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Perceptual Hashing** (`hash`) and **Pixel Comparisons** (`pixel`) may be used at the same time. Refer to the documentation below to see how they interact.
|
||||
|
||||
**Note:** Regardless of `fetchBehavior`, if the response from the URL does not indicate it is an image then image detection will not occur. IE Response `Content-Type` must contain `image`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Both image comparison systems require [Sharp](https://sharp.pixelplumbing.com/) as a dependency. Most modern operating systems running Node.js >= 12.13.0 do not require installing additional dependencies in order to use Sharp.
|
||||
|
||||
If you are using the docker image for ContextMod (`foxxmd/context-mod`) Sharp is built-in.
|
||||
|
||||
If you are installing ContextMod using npm then **Sharp should be installed automatically as an optional dependency.**
|
||||
|
||||
**If you do not want to install it automatically** install ContextMod with the following command:
|
||||
|
||||
```
|
||||
npm install --no-optional
|
||||
```
|
||||
|
||||
If you are using ContextMod as part of a larger project you may want to require Sharp in your own package:
|
||||
|
||||
```
|
||||
npm install sharp@0.29.1 --save
|
||||
```
|
||||
|
||||
# Comparison Systems
|
||||
|
||||
## Perceptual Hashing
|
||||
|
||||
[Perceptual Hashing](https://en.wikipedia.org/wiki/Perceptual_hashing) creates a text fingerprint of an image by:
|
||||
|
||||
* Dividing up the image into a grid
|
||||
* Using an algorithm to derive a value from the pixels in each grid
|
||||
* Adding up all the values to create a unique string (the "fingerprint")
|
||||
|
||||
An example of how a perceptual hash can work [can be found here.](https://www.hackerfactor.com/blog/?/archives/432-Looks-Like-It.html)
|
||||
|
||||
ContextMod uses [blockhash-js](https://github.com/commonsmachinery/blockhash-js) which is a javascript implementation of the algorithm described in the paper [Block Mean Value Based Image Perceptual Hashing by Bian Yang, Fan Gu and Xiamu Niu.](https://ieeexplore.ieee.org/document/4041692)
|
||||
|
||||
|
||||
**Advantages**
|
||||
|
||||
* Low memory requirements and not CPU intensive
|
||||
* Does not require any image transformations
|
||||
* Hash results can be stored to make future comparisons even faster and skip downloading images (cached by url)
|
||||
* Resolution-independent
|
||||
|
||||
**Disadvantages**
|
||||
|
||||
* Hash is weak when image differences are based only on color
|
||||
* Hash is weak when image contains lots of text
|
||||
* Higher accuracy requires larger calculation (more bits required)
|
||||
|
||||
**When should I use it?**
|
||||
|
||||
* General duplicate detection
|
||||
* Comparing many images
|
||||
* Comparing the same images often
|
||||
|
||||
### How To Use
|
||||
|
||||
If `imageDetection.enable` is `true` then hashing is enabled by default and no further configuration is required.
|
||||
|
||||
To further configure hashing refer to this code block:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "ruleWithImageDetectionAndConfiguredHashing",
|
||||
"kind": "recentActivity",
|
||||
"imageDetection": {
|
||||
"enable": true,
|
||||
// Add block below...
|
||||
//
|
||||
"hash": {
|
||||
// enable or disable hash comparisons (enabled by default)
|
||||
"enable": true,
|
||||
// determines accuracy of hash and granularity of hash comparison (comparison to other hashes)
|
||||
// the higher the bits the more accurate the comparison
|
||||
//
|
||||
// NOTE: Hashes of different sizes (bits) cannot be compared. If you are caching hashes make sure all rules where results may be shared use the same bit count to ensure hashes can be compared. Otherwise hashes will be recomputed.
|
||||
"bits": 32, // default is 32 if not defined
|
||||
//
|
||||
// number of seconds to cache an image hash
|
||||
"ttl": 60, // default is 60 if not defined
|
||||
//
|
||||
// "High Confidence" Threshold
|
||||
// If the difference in comparison is equal to or less than this number the images are considered the same and pixel comparison WILL NOT occur
|
||||
//
|
||||
// Defaults to the parent-level `threshold` value if not present
|
||||
//
|
||||
// Use null if you want pixel comparison to ALWAYS occur (softThreshold must be present)
|
||||
"hardThreshold": 5,
|
||||
//
|
||||
// "Low Confidence" Threshold -- only used if `pixel` is enabled
|
||||
// If the difference in comparison is:
|
||||
//
|
||||
// 1) equal to or less than this value and
|
||||
// 2) the value is greater than `hardThreshold`
|
||||
//
|
||||
// the images will be compared using the `pixel` method
|
||||
"softThreshold": 0,
|
||||
},
|
||||
//
|
||||
// And above ^^^
|
||||
//"pixel": {...}
|
||||
},
|
||||
//...
|
||||
```
|
||||
|
||||
## Pixel Comparison
|
||||
|
||||
This approach is as straight forward as it sounds. Both images are compared, pixel by pixel, to determine the difference between the two. ContextMod uses [pixelmatch](https://github.com/mapbox/pixelmatch) to do the comparison.
|
||||
|
||||
**Advantages**
|
||||
|
||||
* Extremely accurate, high-confidence on difference percentage
|
||||
* Strong when comparing text-based images or color-only differences
|
||||
|
||||
**Disadvantages**
|
||||
|
||||
* High memory requirements (10-30MB per comparison) and CPU intensive
|
||||
* Weak against similar images with different aspect ratios
|
||||
* Requires image transformations (resize, crop) before comparison
|
||||
* Can only store image-to-image results (no single image fingerprints)
|
||||
|
||||
**When should I use it?**
|
||||
|
||||
* Require very high accuracy in comparison results
|
||||
* Comparing mostly text-based images or subtle color/detail differences
|
||||
* As a secondary, high-confidence confirmation of comparison result after hashing
|
||||
|
||||
### How To Use
|
||||
|
||||
By default pixel comparisons **are not enabled.** They must be explicitly enabled in configuration.
|
||||
|
||||
Pixel comparisons will be performed in either of these scenarios:
|
||||
|
||||
* pixel is enabled, hashing is enabled and `hash.softThreshold` is defined
|
||||
* When a comparison occurs that is less different than `softThreshold` but more different then `hardThreshold` (or `"hardThreshold": null`), then pixel comparison will occur as a high-confidence check
|
||||
* Example
|
||||
* hash comparison => 7% difference
|
||||
* `"softThreshold": 10`
|
||||
* `"hardThreshold": 4`
|
||||
* `hash.enable` is `false` and `pixel.enable` is true
|
||||
* hashing is skipped entirely and only pixel comparisons are performed
|
||||
|
||||
To configure pixel comparisons refer to this code block:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "ruleWithImageDetectionAndPixelEnabled",
|
||||
"kind": "recentActivity",
|
||||
"imageDetection": {
|
||||
//"hash": {...}
|
||||
"pixel": {
|
||||
// enable or disable pixel comparisons (disabled by default)
|
||||
"enable": true,
|
||||
// if the comparison difference percentage is equal to or less than this value the images are considered the same
|
||||
//
|
||||
// if not defined the value from imageDetection.threshold will be used
|
||||
"threshold": 5
|
||||
}
|
||||
},
|
||||
//...
|
||||
```
|
||||
1025
package-lock.json
generated
1025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"json5": "^2.2.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"leven": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^6.0.0",
|
||||
"monaco-editor": "^0.27.0",
|
||||
@@ -62,12 +63,14 @@
|
||||
"normalize-url": "^6.1.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"p-event": "^4.2.0",
|
||||
"p-map": "^4.0.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pixelmatch": "^5.2.1",
|
||||
"pretty-print-json": "^1.0.3",
|
||||
"resemblejs": "^4.0.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"set-random-interval": "^1.1.0",
|
||||
"snoostorm": "^1.5.2",
|
||||
"snoowrap": "^1.23.0",
|
||||
"socket.io": "^4.1.3",
|
||||
@@ -104,7 +107,7 @@
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/pixelmatch": "^5.2.4",
|
||||
"@types/resemblejs": "^3.2.1",
|
||||
"@types/sharp": "^0.29.2",
|
||||
"@types/tcp-port-used": "^1.0.0",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"ts-auto-guard": "*",
|
||||
@@ -112,6 +115,6 @@
|
||||
"typescript-json-schema": "^0.50.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-canvas": "^2.7.0"
|
||||
"sharp": "^0.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {getLogger} from "./Utils/loggerFactory";
|
||||
import {Invokee, OperatorConfig} from "./Common/interfaces";
|
||||
import Bot from "./Bot";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
import {sleep} from "./util";
|
||||
|
||||
export class App {
|
||||
|
||||
@@ -66,6 +67,7 @@ export class App {
|
||||
try {
|
||||
await b.testClient();
|
||||
await b.buildManagers();
|
||||
await sleep(2000);
|
||||
b.runManagers(causedBy).catch((err) => {
|
||||
this.logger.error(`Unexpected error occurred while running Bot ${b.botName}. Bot must be re-built to restart`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {DurationComparor, UserNoteCriteria} from "../Rule";
|
||||
import {CompareValue, CompareValueOrPercent} from "../Common/interfaces";
|
||||
import {UserNoteCriteria} from "../Rule";
|
||||
import {CompareValue, CompareValueOrPercent, DurationComparor} from "../Common/interfaces";
|
||||
import {parseStringToRegex} from "../util";
|
||||
|
||||
/**
|
||||
* If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.
|
||||
@@ -106,6 +107,17 @@ export interface AuthorCriteria {
|
||||
* This is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned
|
||||
* */
|
||||
shadowBanned?: boolean
|
||||
|
||||
/**
|
||||
* An (array of) string/regular expression to test contents of an Author's profile description against
|
||||
*
|
||||
* If no flags are specified then the **insensitive** flag is used by default
|
||||
*
|
||||
* If using an array then if **any** value in the array passes the description test passes
|
||||
*
|
||||
* @examples [["/test$/i", "look for this string literal"]]
|
||||
* */
|
||||
description?: string | string[]
|
||||
}
|
||||
|
||||
export class Author implements AuthorCriteria {
|
||||
@@ -120,6 +132,7 @@ export class Author implements AuthorCriteria {
|
||||
totalKarma?: string;
|
||||
verified?: boolean;
|
||||
shadowBanned?: boolean;
|
||||
description?: string[];
|
||||
|
||||
constructor(options: AuthorCriteria) {
|
||||
this.name = options.name;
|
||||
@@ -132,6 +145,7 @@ export class Author implements AuthorCriteria {
|
||||
this.linkKarma = options.linkKarma;
|
||||
this.totalKarma = options.totalKarma;
|
||||
this.shadowBanned = options.shadowBanned;
|
||||
this.description = options.description === undefined ? undefined : Array.isArray(options.description) ? options.description : [options.description];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Snoowrap, {Subreddit} from "snoowrap";
|
||||
import Snoowrap, {Comment, Submission, Subreddit} from "snoowrap";
|
||||
import {Logger} from "winston";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
snooLogWrapper
|
||||
} from "../util";
|
||||
import {Manager} from "../Subreddit/Manager";
|
||||
import {ProxiedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import {ExtendedSnoowrap, ProxiedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import {ModQueueStream, UnmoderatedStream} from "../Subreddit/Streams";
|
||||
import {BotResourcesManager} from "../Subreddit/SubredditResources";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
@@ -24,7 +24,7 @@ import pEvent from "p-event";
|
||||
|
||||
class Bot {
|
||||
|
||||
client!: Snoowrap;
|
||||
client!: ExtendedSnoowrap;
|
||||
logger!: Logger;
|
||||
wikiLocation: string;
|
||||
dryRun?: true | undefined;
|
||||
@@ -49,6 +49,7 @@ class Bot {
|
||||
maxWorkers: number;
|
||||
startedAt: Dayjs = dayjs();
|
||||
sharedModqueue: boolean = false;
|
||||
streamListedOnce: string[] = [];
|
||||
|
||||
apiSample: number[] = [];
|
||||
apiRollingAvg: number = 0;
|
||||
@@ -91,6 +92,7 @@ class Bot {
|
||||
},
|
||||
polling: {
|
||||
sharedMod,
|
||||
stagger,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
@@ -166,7 +168,7 @@ class Bot {
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = proxy === undefined ? new Snoowrap(creds) : new ProxiedSnoowrap({...creds, proxy});
|
||||
this.client = proxy === undefined ? new ExtendedSnoowrap(creds) : new ProxiedSnoowrap({...creds, proxy});
|
||||
this.client.config({
|
||||
warnings: true,
|
||||
maxRetryAttempts: 5,
|
||||
@@ -199,12 +201,32 @@ class Bot {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUnmoderatedStream = new UnmoderatedStream(this.client, {subreddit: 'mod'});
|
||||
const modStreamListingListener = (name: string) => async (listing: (Comment|Submission)[]) => {
|
||||
// dole out in order they were received
|
||||
if(!this.streamListedOnce.includes(name)) {
|
||||
this.streamListedOnce.push(name);
|
||||
return;
|
||||
}
|
||||
for(const i of listing) {
|
||||
const foundManager = this.subManagers.find(x => x.subreddit.display_name === i.subreddit.display_name && x.modStreamCallbacks.get(name) !== undefined);
|
||||
if(foundManager !== undefined) {
|
||||
foundManager.modStreamCallbacks.get(name)(i);
|
||||
if(stagger !== undefined) {
|
||||
await sleep(stagger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const defaultUnmoderatedStream = new UnmoderatedStream(this.client, {subreddit: 'mod', limit: 100, clearProcessed: { size: 100, retain: 100 }});
|
||||
// @ts-ignore
|
||||
defaultUnmoderatedStream.on('error', modStreamErrorListener('unmoderated'));
|
||||
const defaultModqueueStream = new ModQueueStream(this.client, {subreddit: 'mod'});
|
||||
defaultUnmoderatedStream.on('listing', modStreamListingListener('unmoderated'));
|
||||
const defaultModqueueStream = new ModQueueStream(this.client, {subreddit: 'mod', limit: 100, clearProcessed: { size: 100, retain: 100 }});
|
||||
// @ts-ignore
|
||||
defaultModqueueStream.on('error', modStreamErrorListener('modqueue'));
|
||||
defaultModqueueStream.on('listing', modStreamListingListener('modqueue'));
|
||||
this.cacheManager.modStreams.set('unmoderated', defaultUnmoderatedStream);
|
||||
this.cacheManager.modStreams.set('modqueue', defaultModqueueStream);
|
||||
|
||||
@@ -337,7 +359,7 @@ class Bot {
|
||||
|
||||
async runModStreams(notify = false) {
|
||||
for(const [k,v] of this.cacheManager.modStreams) {
|
||||
if(!v.running && v.listeners('item').length > 0) {
|
||||
if(!v.running && this.subManagers.some(x => x.modStreamCallbacks.get(k) !== undefined)) {
|
||||
v.startInterval();
|
||||
this.logger.info(`Starting default ${k.toUpperCase()} mod stream`);
|
||||
if(notify) {
|
||||
@@ -347,6 +369,7 @@ class Bot {
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,6 +382,7 @@ class Bot {
|
||||
for (const manager of this.subManagers) {
|
||||
if (manager.validConfigLoaded && manager.botState.state !== RUNNING) {
|
||||
await manager.start(causedBy, {reason: 'Caused by application startup'});
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
248
src/Common/ImageData.ts
Normal file
248
src/Common/ImageData.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import fetch from "node-fetch";
|
||||
import {Submission} from "snoowrap/dist/objects";
|
||||
import {URL} from "url";
|
||||
import {absPercentDifference, getSharpAsync, isValidImageURL} from "../util";
|
||||
import sizeOf from "image-size";
|
||||
import SimpleError from "../Utils/SimpleError";
|
||||
import {Sharp} from "sharp";
|
||||
import {blockhash} from "./blockhash/blockhash";
|
||||
|
||||
export interface ImageDataOptions {
|
||||
width?: number,
|
||||
height?: number,
|
||||
url: string,
|
||||
variants?: ImageData[]
|
||||
}
|
||||
|
||||
class ImageData {
|
||||
|
||||
width?: number
|
||||
height?: number
|
||||
url: URL
|
||||
variants: ImageData[] = []
|
||||
preferredResolution?: [number, number]
|
||||
sharpImg!: Sharp
|
||||
hashResult!: string
|
||||
actualResolution?: [number, number]
|
||||
|
||||
constructor(data: ImageDataOptions, aggressive = false) {
|
||||
this.width = data.width;
|
||||
this.height = data.height;
|
||||
this.url = new URL(data.url);
|
||||
if (!aggressive && !isValidImageURL(`${this.url.origin}${this.url.pathname}`)) {
|
||||
throw new Error('URL did not end with a valid image extension');
|
||||
}
|
||||
this.variants = data.variants || [];
|
||||
}
|
||||
|
||||
async data(format = 'raw'): Promise<Buffer> {
|
||||
// @ts-ignore
|
||||
return await (await this.sharp()).clone().toFormat(format).toBuffer();
|
||||
}
|
||||
|
||||
async hash(bits: number, useVariantIfPossible = true): Promise<string> {
|
||||
if(this.hashResult === undefined) {
|
||||
let ref: ImageData | undefined;
|
||||
if(useVariantIfPossible && this.preferredResolution !== undefined) {
|
||||
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||
}
|
||||
if(ref === undefined) {
|
||||
ref = this;
|
||||
}
|
||||
this.hashResult = await blockhash((await ref.sharp()).clone(), bits);
|
||||
}
|
||||
return this.hashResult;
|
||||
}
|
||||
|
||||
async sharp(): Promise<Sharp> {
|
||||
if (this.sharpImg === undefined) {
|
||||
try {
|
||||
const response = await fetch(this.url.toString())
|
||||
if (response.ok) {
|
||||
const ct = response.headers.get('Content-Type');
|
||||
if (ct !== null && ct.includes('image')) {
|
||||
const sFunc = await getSharpAsync();
|
||||
// if image is animated then we want to extract the first frame and convert it to a regular image
|
||||
// so we can compare two static images later (also because sharp can't use resize() on animated images)
|
||||
if(['gif','webp'].some(x => ct.includes(x))) {
|
||||
this.sharpImg = await sFunc(await (await sFunc(await response.buffer(), {pages: 1, animated: false})).png().toBuffer());
|
||||
} else {
|
||||
this.sharpImg = await sFunc(await response.buffer());
|
||||
}
|
||||
const meta = await this.sharpImg.metadata();
|
||||
if (this.width === undefined || this.height === undefined) {
|
||||
this.width = meta.width;
|
||||
this.height = meta.height;
|
||||
}
|
||||
this.actualResolution = [meta.width as number, meta.height as number];
|
||||
} else {
|
||||
throw new SimpleError(`Content-Type for fetched URL ${this.url} did not contain "image"`);
|
||||
}
|
||||
} else {
|
||||
throw new SimpleError(`URL response was not OK: (${response.status})${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
if(!(err instanceof SimpleError)) {
|
||||
throw new Error(`Error occurred while fetching response from URL: ${err.message}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.sharpImg;
|
||||
}
|
||||
|
||||
get pixels() {
|
||||
if (this.actualResolution !== undefined) {
|
||||
return this.actualResolution[0] * this.actualResolution[1];
|
||||
}
|
||||
if (this.width === undefined || this.height === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.width * this.height;
|
||||
}
|
||||
|
||||
get hasDimensions() {
|
||||
return this.width !== undefined && this.height !== undefined;
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return `${this.url.origin}${this.url.pathname}`;
|
||||
}
|
||||
|
||||
setPreferredResolutionByWidth(prefWidth: number) {
|
||||
let height: number | undefined = undefined,
|
||||
width: number | undefined = undefined;
|
||||
if (this.variants.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const v of this.variants) {
|
||||
if (v.hasDimensions && (v.width as number) <= prefWidth) {
|
||||
width = v.width as number;
|
||||
height = v.height as number;
|
||||
}
|
||||
}
|
||||
if (width !== undefined) {
|
||||
this.preferredResolution = [width, (height as number)];
|
||||
}
|
||||
}
|
||||
|
||||
getSimilarResolutionVariant(width: number, height: number, allowablePercentDiff = 0): ImageData | undefined {
|
||||
if (this.variants.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return this.variants.find(x => {
|
||||
return x.hasDimensions && (absPercentDifference(width, x.width as number) <= allowablePercentDiff) && (absPercentDifference(height, x.height as number) <= allowablePercentDiff);
|
||||
});
|
||||
}
|
||||
|
||||
isSameDimensions(otherImage: ImageData) {
|
||||
if (!this.hasDimensions || !otherImage.hasDimensions) {
|
||||
return false;
|
||||
}
|
||||
return this.width === otherImage.width && this.height === otherImage.height;
|
||||
}
|
||||
|
||||
async sameAspectRatio(otherImage: ImageData) {
|
||||
let thisRes = this.actualResolution;
|
||||
let otherRes = otherImage.actualResolution;
|
||||
if(thisRes === undefined) {
|
||||
const tMeta = await (await this.sharp()).metadata();
|
||||
const thisMeta = {width: tMeta.width as number, height: tMeta.height as number };
|
||||
this.actualResolution = [thisMeta.width, thisMeta.height];
|
||||
thisRes = this.actualResolution;
|
||||
}
|
||||
if(otherRes === undefined) {
|
||||
const otherMeta = await (await otherImage.sharp()).metadata();
|
||||
otherRes = [otherMeta.width as number, otherMeta.height as number];
|
||||
}
|
||||
const thisRatio = thisRes[0] / thisRes[1];
|
||||
const otherRatio = otherRes[0] / otherRes[1];
|
||||
|
||||
// a little leeway
|
||||
return Math.abs(thisRatio - otherRatio) < 0.1;
|
||||
}
|
||||
|
||||
static async dimensionsFromMetadata(img: Sharp) {
|
||||
const {width, height, ...rest} = await img.metadata();
|
||||
return {width: width as number, height: height as number};
|
||||
}
|
||||
|
||||
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData): Promise<[Sharp, Sharp, number, number]> {
|
||||
const sFunc = await getSharpAsync();
|
||||
|
||||
let refImage = this as ImageData;
|
||||
let compareImage = imgToCompare;
|
||||
if (this.preferredResolution !== undefined) {
|
||||
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||
if (matchingVariant !== undefined) {
|
||||
compareImage = matchingVariant;
|
||||
refImage = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]) as ImageData;
|
||||
}
|
||||
}
|
||||
|
||||
let refSharp = (await refImage.sharp()).clone();
|
||||
let refMeta = await ImageData.dimensionsFromMetadata(refSharp);
|
||||
let compareSharp = (await compareImage.sharp()).clone();
|
||||
let compareMeta = await ImageData.dimensionsFromMetadata(compareSharp);
|
||||
|
||||
// if dimensions on not the same we need to crop or resize before final resize
|
||||
if (refMeta.width !== compareMeta.width || refMeta.height !== compareMeta.height) {
|
||||
const thisRatio = refMeta.width / (refMeta.height);
|
||||
const otherRatio = compareMeta.width / compareMeta.height;
|
||||
|
||||
const sameRatio = Math.abs(thisRatio - otherRatio) < 0.04;
|
||||
if (sameRatio) {
|
||||
// then resize first since its most likely the same image
|
||||
// can be fairly sure a downscale will get pixels close to the same
|
||||
if (refMeta.width > compareMeta.width) {
|
||||
refSharp = sFunc(await refSharp.resize(compareMeta.width, null, {fit: 'outside'}).toBuffer());
|
||||
} else {
|
||||
compareSharp = sFunc(await compareSharp.resize(refMeta.width, null, {fit: 'outside'}).toBuffer());
|
||||
}
|
||||
refMeta = await ImageData.dimensionsFromMetadata(refSharp);
|
||||
compareMeta = await ImageData.dimensionsFromMetadata(compareSharp);
|
||||
}
|
||||
// find smallest common dimensions
|
||||
const sWidth = refMeta.width <= compareMeta.width ? refMeta.width : compareMeta.width;
|
||||
const sHeight = refMeta.height <= compareMeta.height ? refMeta.height : compareMeta.height;
|
||||
|
||||
// crop if necessary
|
||||
if(sWidth !== refMeta.width || sHeight !== refMeta.height) {
|
||||
refSharp = sFunc(await refSharp.extract({left: 0, top: 0, width: sWidth, height: sHeight}).toBuffer());
|
||||
}
|
||||
if(sWidth !== compareMeta.width || sHeight !== compareMeta.height) {
|
||||
compareSharp = sFunc(await compareSharp.extract({left: 0, top: 0, width: sWidth, height: sHeight}).toBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
// final resize to reduce memory/cpu usage during comparison
|
||||
refSharp = sFunc(await refSharp.resize(400, null, {fit: 'outside'}).toBuffer());
|
||||
compareSharp = sFunc(await compareSharp.resize(400, null, {fit: 'outside'}).toBuffer());
|
||||
|
||||
const {width, height} = await ImageData.dimensionsFromMetadata(refSharp);
|
||||
return [refSharp, compareSharp, width, height];
|
||||
}
|
||||
|
||||
static fromSubmission(sub: Submission, aggressive = false): ImageData {
|
||||
const url = new URL(sub.url);
|
||||
const data: any = {
|
||||
url,
|
||||
};
|
||||
let variants = [];
|
||||
if (sub.preview !== undefined && sub.preview.enabled && sub.preview.images.length > 0) {
|
||||
const firstImg = sub.preview.images[0];
|
||||
const ref = sub.preview.images[0].source;
|
||||
data.width = ref.width;
|
||||
data.height = ref.height;
|
||||
|
||||
variants = firstImg.resolutions.map(x => new ImageData(x));
|
||||
data.variants = variants;
|
||||
}
|
||||
return new ImageData(data, aggressive);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageData;
|
||||
234
src/Common/blockhash/blockhash.ts
Normal file
234
src/Common/blockhash/blockhash.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// Perceptual image hash calculation tool based on algorithm descibed in
|
||||
// Block Mean Value Based Image Perceptual Hashing by Bian Yang, Fan Gu and Xiamu Niu
|
||||
//
|
||||
// Copyright 2014 Commons Machinery http://commonsmachinery.se/
|
||||
// Distributed under an MIT license, please see LICENSE in the top dir.
|
||||
|
||||
|
||||
// https://github.com/commonsmachinery/blockhash-js/blob/master/index.js
|
||||
|
||||
import {Sharp} from "sharp";
|
||||
|
||||
interface BlockImageData {
|
||||
data: Buffer,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
var one_bits = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
|
||||
|
||||
/* Calculate the hamming distance for two hashes in hex format */
|
||||
export const hammingDistance = (hash1: string, hash2: string) => {
|
||||
var d = 0;
|
||||
var i;
|
||||
|
||||
if (hash1.length !== hash2.length) {
|
||||
throw new Error("Can't compare hashes with different length");
|
||||
}
|
||||
|
||||
for (i = 0; i < hash1.length; i++) {
|
||||
var n1 = parseInt(hash1[i], 16);
|
||||
var n2 = parseInt(hash2[i], 16);
|
||||
d += one_bits[n1 ^ n2];
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
var median = function(data: number[]) {
|
||||
var mdarr = data.slice(0);
|
||||
mdarr.sort(function(a, b) { return a-b; });
|
||||
if (mdarr.length % 2 === 0) {
|
||||
return (mdarr[mdarr.length/2 - 1] + mdarr[mdarr.length/2]) / 2.0;
|
||||
}
|
||||
return mdarr[Math.floor(mdarr.length/2)];
|
||||
};
|
||||
|
||||
var translate_blocks_to_bits = function(blocks: number[], pixels_per_block: number) {
|
||||
var half_block_value = pixels_per_block * 256 * 3 / 2;
|
||||
var bandsize = blocks.length / 4;
|
||||
|
||||
// Compare medians across four horizontal bands
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var m = median(blocks.slice(i * bandsize, (i + 1) * bandsize));
|
||||
for (var j = i * bandsize; j < (i + 1) * bandsize; j++) {
|
||||
var v = blocks[j];
|
||||
|
||||
// Output a 1 if the block is brighter than the median.
|
||||
// With images dominated by black or white, the median may
|
||||
// end up being 0 or the max value, and thus having a lot
|
||||
// of blocks of value equal to the median. To avoid
|
||||
// generating hashes of all zeros or ones, in that case output
|
||||
// 0 if the median is in the lower value space, 1 otherwise
|
||||
blocks[j] = Number(v > m || (Math.abs(v - m) < 1 && m > half_block_value));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var bits_to_hexhash = function(bitsArray: number[]) {
|
||||
var hex = [];
|
||||
for (var i = 0; i < bitsArray.length; i += 4) {
|
||||
var nibble = bitsArray.slice(i, i + 4);
|
||||
hex.push(parseInt(nibble.join(''), 2).toString(16));
|
||||
}
|
||||
|
||||
return hex.join('');
|
||||
};
|
||||
|
||||
var bmvbhash_even = function(data: BlockImageData, bits: number) {
|
||||
var blocksize_x = Math.floor(data.width / bits);
|
||||
var blocksize_y = Math.floor(data.height / bits);
|
||||
|
||||
var result = [];
|
||||
|
||||
for (var y = 0; y < bits; y++) {
|
||||
for (var x = 0; x < bits; x++) {
|
||||
var total = 0;
|
||||
|
||||
for (var iy = 0; iy < blocksize_y; iy++) {
|
||||
for (var ix = 0; ix < blocksize_x; ix++) {
|
||||
var cx = x * blocksize_x + ix;
|
||||
var cy = y * blocksize_y + iy;
|
||||
var ii = (cy * data.width + cx) * 4;
|
||||
|
||||
var alpha = data.data[ii+3];
|
||||
if (alpha === 0) {
|
||||
total += 765;
|
||||
} else {
|
||||
total += data.data[ii] + data.data[ii+1] + data.data[ii+2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(total);
|
||||
}
|
||||
}
|
||||
|
||||
translate_blocks_to_bits(result, blocksize_x * blocksize_y);
|
||||
return bits_to_hexhash(result);
|
||||
};
|
||||
|
||||
var bmvbhash = function(data: BlockImageData, bits: number) {
|
||||
var result = [];
|
||||
|
||||
var i, j, x, y;
|
||||
var block_width, block_height;
|
||||
var weight_top, weight_bottom, weight_left, weight_right;
|
||||
var block_top, block_bottom, block_left, block_right;
|
||||
var y_mod, y_frac, y_int;
|
||||
var x_mod, x_frac, x_int;
|
||||
var blocks: number[][] = [];
|
||||
|
||||
var even_x = data.width % bits === 0;
|
||||
var even_y = data.height % bits === 0;
|
||||
|
||||
if (even_x && even_y) {
|
||||
return bmvbhash_even(data, bits);
|
||||
}
|
||||
|
||||
// initialize blocks array with 0s
|
||||
for (i = 0; i < bits; i++) {
|
||||
blocks.push([]);
|
||||
for (j = 0; j < bits; j++) {
|
||||
blocks[i].push(0);
|
||||
}
|
||||
}
|
||||
|
||||
block_width = data.width / bits;
|
||||
block_height = data.height / bits;
|
||||
|
||||
for (y = 0; y < data.height; y++) {
|
||||
if (even_y) {
|
||||
// don't bother dividing y, if the size evenly divides by bits
|
||||
block_top = block_bottom = Math.floor(y / block_height);
|
||||
weight_top = 1;
|
||||
weight_bottom = 0;
|
||||
} else {
|
||||
y_mod = (y + 1) % block_height;
|
||||
y_frac = y_mod - Math.floor(y_mod);
|
||||
y_int = y_mod - y_frac;
|
||||
|
||||
weight_top = (1 - y_frac);
|
||||
weight_bottom = (y_frac);
|
||||
|
||||
// y_int will be 0 on bottom/right borders and on block boundaries
|
||||
if (y_int > 0 || (y + 1) === data.height) {
|
||||
block_top = block_bottom = Math.floor(y / block_height);
|
||||
} else {
|
||||
block_top = Math.floor(y / block_height);
|
||||
block_bottom = Math.ceil(y / block_height);
|
||||
}
|
||||
}
|
||||
|
||||
for (x = 0; x < data.width; x++) {
|
||||
var ii = (y * data.width + x) * 4;
|
||||
|
||||
var avgvalue, alpha = data.data[ii+3];
|
||||
if (alpha === 0) {
|
||||
avgvalue = 765;
|
||||
} else {
|
||||
avgvalue = data.data[ii] + data.data[ii+1] + data.data[ii+2];
|
||||
}
|
||||
|
||||
if (even_x) {
|
||||
block_left = block_right = Math.floor(x / block_width);
|
||||
weight_left = 1;
|
||||
weight_right = 0;
|
||||
} else {
|
||||
x_mod = (x + 1) % block_width;
|
||||
x_frac = x_mod - Math.floor(x_mod);
|
||||
x_int = x_mod - x_frac;
|
||||
|
||||
weight_left = (1 - x_frac);
|
||||
weight_right = x_frac;
|
||||
|
||||
// x_int will be 0 on bottom/right borders and on block boundaries
|
||||
if (x_int > 0 || (x + 1) === data.width) {
|
||||
block_left = block_right = Math.floor(x / block_width);
|
||||
} else {
|
||||
block_left = Math.floor(x / block_width);
|
||||
block_right = Math.ceil(x / block_width);
|
||||
}
|
||||
}
|
||||
|
||||
// add weighted pixel value to relevant blocks
|
||||
blocks[block_top][block_left] += avgvalue * weight_top * weight_left;
|
||||
blocks[block_top][block_right] += avgvalue * weight_top * weight_right;
|
||||
blocks[block_bottom][block_left] += avgvalue * weight_bottom * weight_left;
|
||||
blocks[block_bottom][block_right] += avgvalue * weight_bottom * weight_right;
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < bits; i++) {
|
||||
for (j = 0; j < bits; j++) {
|
||||
result.push(blocks[i][j]);
|
||||
}
|
||||
}
|
||||
|
||||
translate_blocks_to_bits(result, block_width * block_height);
|
||||
return bits_to_hexhash(result);
|
||||
};
|
||||
|
||||
var blockhashData = function(imgData: BlockImageData, bits: number, method: number) {
|
||||
var hash;
|
||||
|
||||
if (method === 1) {
|
||||
hash = bmvbhash_even(imgData, bits);
|
||||
}
|
||||
else if (method === 2) {
|
||||
hash = bmvbhash(imgData, bits);
|
||||
}
|
||||
else {
|
||||
throw new Error("Bad hashing method");
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const blockhash = async function(src: Sharp, bits: number, method: number = 2): Promise<string> {
|
||||
const {data: buff, info} = await src.ensureAlpha().raw().toBuffer({resolveWithObject: true});
|
||||
return blockhashData({
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
data: buff,
|
||||
}, bits, method);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import Poll from "snoostorm/out/util/Poll";
|
||||
import Snoowrap from "snoowrap";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {IncomingMessage} from "http";
|
||||
import {ResembleSingleCallbackComparisonResult} from "resemblejs";
|
||||
|
||||
/**
|
||||
* An ISO 8601 Duration
|
||||
@@ -258,24 +257,151 @@ export interface ImageDetection {
|
||||
* */
|
||||
fetchBehavior?: 'extension' | 'unknown' | 'all',
|
||||
/**
|
||||
* The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.
|
||||
* The percentage, as a whole number, of difference between two images at which point they will not be considered the same.
|
||||
*
|
||||
* Will be used as `hash.hardThreshold` and `pixel.threshold` if those values are not specified
|
||||
*
|
||||
* Default is `5`
|
||||
*
|
||||
* @default 5
|
||||
* */
|
||||
threshold?: number
|
||||
|
||||
/**
|
||||
* Use perceptual hashing (blockhash-js) to compare images
|
||||
*
|
||||
* Pros:
|
||||
*
|
||||
* * very fast
|
||||
* * low cpu/memory usage
|
||||
* * results can be cached
|
||||
*
|
||||
* Cons:
|
||||
*
|
||||
* * not as accurate as pixel comparison
|
||||
* * weaker for text-heavy images
|
||||
* * mostly color-blind
|
||||
*
|
||||
* Best uses:
|
||||
*
|
||||
* * Detecting (general) duplicate images
|
||||
* * Comparing large number of images
|
||||
* */
|
||||
hash?: {
|
||||
/**
|
||||
* Enabled by default.
|
||||
*
|
||||
* If both `hash` and `pixel` are enabled then `pixel` will be used to verify image comparison when hashes matches
|
||||
*
|
||||
* @default true
|
||||
* */
|
||||
enable?: boolean
|
||||
|
||||
/**
|
||||
* Bit count determines accuracy of hash and granularity of hash comparison (comparison to other hashes)
|
||||
*
|
||||
* Default is `32`
|
||||
*
|
||||
* **NOTE:** Hashes of different sizes (bits) cannot be compared. If you are caching results make sure all rules where results may be shared use the same bit count to ensure hashes can be compared. Otherwise hashes will be recomputed.
|
||||
*
|
||||
* @default 32
|
||||
* */
|
||||
bits?: number
|
||||
|
||||
/**
|
||||
* Number of seconds to cache image hash
|
||||
* */
|
||||
ttl?: number
|
||||
/**
|
||||
* High Confidence Threshold
|
||||
*
|
||||
* If the difference in comparison is equal to or less than this number the images are considered the same and pixel comparison WILL NOT occur
|
||||
*
|
||||
* Defaults to the parent-level `threshold` value if not present
|
||||
*
|
||||
* Use `null` if you want pixel comparison to ALWAYS occur (softThreshold must be present)
|
||||
* */
|
||||
hardThreshold?: number | null
|
||||
/**
|
||||
* Low Confidence Threshold -- only used if `pixel` is enabled
|
||||
*
|
||||
* If the difference in comparison is
|
||||
*
|
||||
* 1) equal to or less than this value and
|
||||
* 2) the value is greater than `hardThreshold`
|
||||
*
|
||||
* the images will be compared using the `pixel` method
|
||||
* */
|
||||
softThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pixel counting to compare images
|
||||
*
|
||||
* Pros:
|
||||
*
|
||||
* * most accurate
|
||||
* * strong with text or color-only changes
|
||||
*
|
||||
* Cons:
|
||||
*
|
||||
* * much slower than hashing
|
||||
* * memory/cpu intensive
|
||||
*
|
||||
* Best uses:
|
||||
*
|
||||
* * Comparison text-only images
|
||||
* * Comparison requires high degree of accuracy or changes are subtle
|
||||
* */
|
||||
pixel?: {
|
||||
/**
|
||||
* Disabled by default.
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
enable?: boolean
|
||||
/**
|
||||
* The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.
|
||||
* */
|
||||
threshold?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageData {
|
||||
data: Buffer,
|
||||
width: number,
|
||||
height: number
|
||||
pixels: number
|
||||
export interface StrongImageDetection {
|
||||
enable: boolean,
|
||||
fetchBehavior: 'extension' | 'unknown' | 'all'
|
||||
threshold: number,
|
||||
hash: {
|
||||
enable: boolean
|
||||
bits: number
|
||||
ttl?: number
|
||||
hardThreshold: number | null
|
||||
softThreshold?: number
|
||||
}
|
||||
pixel: {
|
||||
enable: boolean
|
||||
threshold: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResembleResult extends ResembleSingleCallbackComparisonResult {
|
||||
rawMisMatchPercentage: number
|
||||
// export interface ImageData {
|
||||
// data: Promise<Buffer>,
|
||||
// buf?: Buffer,
|
||||
// width: number,
|
||||
// height: number
|
||||
// pixels?: number
|
||||
// url: string
|
||||
// variants?: ImageData[]
|
||||
// }
|
||||
|
||||
export interface ImageComparisonResult {
|
||||
isSameDimensions: boolean
|
||||
dimensionDifference: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
misMatchPercentage: number;
|
||||
analysisTime: number;
|
||||
}
|
||||
|
||||
export interface RichContent {
|
||||
@@ -360,6 +486,38 @@ export type PollOn = 'unmoderated' | 'modqueue' | 'newSub' | 'newComm';
|
||||
export interface PollingOptionsStrong extends PollingOptions {
|
||||
limit: number,
|
||||
interval: number,
|
||||
clearProcessed: ClearProcessedOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* For very long-running, high-volume subreddits clearing the list of processed activities helps manage memory bloat
|
||||
*
|
||||
* All of these options have default values based on the limit and/or interval set for polling options on each subreddit stream. They only need to modified if the defaults are not sufficient.
|
||||
*
|
||||
* If both `after` and `size` are defined whichever is hit first will trigger the list to clear. `after` will be reset after ever clear.
|
||||
* */
|
||||
export interface ClearProcessedOptions {
|
||||
/**
|
||||
* An interval the processed list should be cleared after.
|
||||
*
|
||||
* * EX `9 days`
|
||||
* * EX `3 months`
|
||||
* * EX `5 minutes`
|
||||
* @pattern ^\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$
|
||||
* */
|
||||
after?: string,
|
||||
/**
|
||||
* Number of activities found in processed list after which the list should be cleared.
|
||||
*
|
||||
* Defaults to the `limit` value from `PollingOptions`
|
||||
* */
|
||||
size?: number,
|
||||
/**
|
||||
* The number of activities to retain in processed list after clearing.
|
||||
*
|
||||
* Defaults to `limit` value from `PollingOptions`
|
||||
* */
|
||||
retain?: number,
|
||||
}
|
||||
|
||||
export interface PollingDefaults {
|
||||
@@ -433,6 +591,8 @@ export interface PollingOptions extends PollingDefaults {
|
||||
*
|
||||
* */
|
||||
pollOn: 'unmoderated' | 'modqueue' | 'newSub' | 'newComm'
|
||||
|
||||
clearProcessed?: ClearProcessedOptions
|
||||
}
|
||||
|
||||
export interface TTLConfig {
|
||||
@@ -685,6 +845,22 @@ export interface ManagerOptions {
|
||||
* */
|
||||
export type CompareValue = string;
|
||||
|
||||
/**
|
||||
* A duration and how to compare it against a value
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`
|
||||
*
|
||||
* * EX `> 100 days` => Passes if the date being compared is before 100 days ago
|
||||
* * EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months
|
||||
*
|
||||
* Unit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)
|
||||
*
|
||||
* [See] https://regexr.com/609n8 for example
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\s*$
|
||||
* */
|
||||
export type DurationComparor = string;
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare against
|
||||
*
|
||||
@@ -732,6 +908,7 @@ export interface ActivityState {
|
||||
approved?: boolean
|
||||
score?: CompareValue
|
||||
reports?: CompareValue
|
||||
age?: DurationComparor
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -755,6 +932,9 @@ export interface SubmissionState extends ActivityState {
|
||||
link_flair_css_class?: string
|
||||
}
|
||||
|
||||
// properties calculated/derived by CM -- not provided as plain values by reddit
|
||||
export const cmActivityProperties = ['submissionState','score','reports','removed','deleted','filtered','age','title'];
|
||||
|
||||
/**
|
||||
* Different attributes a `Comment` can be in. Only include a property if you want to check it.
|
||||
* @examples [{"op": true, "removed": false}]
|
||||
@@ -1199,6 +1379,13 @@ export interface BotInstanceJsonConfig {
|
||||
* @default false
|
||||
* */
|
||||
sharedMod?: boolean,
|
||||
|
||||
/**
|
||||
* If sharing a mod stream stagger pushing relevant Activities to individual subreddits.
|
||||
*
|
||||
* Useful when running many subreddits and rules are potentially cpu/memory/traffic heavy -- allows spreading out load
|
||||
* */
|
||||
stagger?: number,
|
||||
},
|
||||
/**
|
||||
* Settings related to default configurations for queue behavior for subreddits
|
||||
@@ -1485,6 +1672,7 @@ export interface BotInstanceConfig extends BotInstanceJsonConfig {
|
||||
},
|
||||
polling: {
|
||||
sharedMod: boolean,
|
||||
stagger?: number,
|
||||
limit: number,
|
||||
interval: number,
|
||||
},
|
||||
|
||||
@@ -142,15 +142,30 @@ export const buildPollingOptions = (values: (string | PollingOptions)[]): Pollin
|
||||
let opts: PollingOptionsStrong[] = [];
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string') {
|
||||
opts.push({pollOn: v as PollOn, interval: DEFAULT_POLLING_INTERVAL, limit: DEFAULT_POLLING_LIMIT});
|
||||
opts.push({
|
||||
pollOn: v as PollOn,
|
||||
interval: DEFAULT_POLLING_INTERVAL,
|
||||
limit: DEFAULT_POLLING_LIMIT,
|
||||
clearProcessed: {
|
||||
size: DEFAULT_POLLING_LIMIT,
|
||||
retain: DEFAULT_POLLING_LIMIT,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const {
|
||||
pollOn: p,
|
||||
interval = DEFAULT_POLLING_INTERVAL,
|
||||
limit = DEFAULT_POLLING_LIMIT,
|
||||
delayUntil,
|
||||
clearProcessed = {size: limit, retain: limit},
|
||||
} = v;
|
||||
opts.push({pollOn: p as PollOn, interval, limit, delayUntil});
|
||||
opts.push({
|
||||
pollOn: p as PollOn,
|
||||
interval,
|
||||
limit,
|
||||
delayUntil,
|
||||
clearProcessed
|
||||
});
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
@@ -604,6 +619,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
name: botName,
|
||||
polling: {
|
||||
sharedMod = false,
|
||||
stagger,
|
||||
limit = 100,
|
||||
interval = 30,
|
||||
} = {},
|
||||
@@ -640,10 +656,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
...cacheTTLDefaults,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
provider: {
|
||||
store: 'memory',
|
||||
...cacheOptDefaults
|
||||
}
|
||||
provider: {...defaultProvider}
|
||||
};
|
||||
} else {
|
||||
const {
|
||||
@@ -654,7 +667,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
} = caching;
|
||||
|
||||
botActionedEventsDefault = actionedEventsDefault;
|
||||
if(actionedEventsMax !== undefined) {
|
||||
if (actionedEventsMax !== undefined) {
|
||||
botActionedEventsDefault = Math.min(actionedEventsDefault, actionedEventsMax);
|
||||
}
|
||||
|
||||
@@ -708,6 +721,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
caching: botCache,
|
||||
polling: {
|
||||
sharedMod,
|
||||
stagger,
|
||||
limit,
|
||||
interval,
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {SubmissionRule, SubmissionRuleJSONConfig} from "./SubmissionRule";
|
||||
import {ActivityWindowType, DomainInfo, ReferenceSubmission} from "../Common/interfaces";
|
||||
import {ActivityWindowType, CommentState, DomainInfo, ReferenceSubmission, SubmissionState} from "../Common/interfaces";
|
||||
import {Rule, RuleOptions, RuleResult} from "./index";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {getAttributionIdentifier} from "../Utils/SnoowrapUtils";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
asSubmission,
|
||||
comparisonTextOp,
|
||||
comparisonTextOp, convertSubredditsRawToStrong,
|
||||
FAIL,
|
||||
formatNumber, getActivitySubredditName, isSubmission,
|
||||
parseGenericValueOrPercentComparison,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../util";
|
||||
import { Comment } from "snoowrap/dist/objects";
|
||||
import SimpleError from "../Utils/SimpleError";
|
||||
import as from "async";
|
||||
|
||||
|
||||
export interface AttributionCriteria {
|
||||
@@ -76,25 +77,41 @@ export interface AttributionCriteria {
|
||||
domainsCombined?: boolean,
|
||||
|
||||
/**
|
||||
* Only include Activities from this list of Subreddits (by name, case-insensitive)
|
||||
* When present, Activities WILL ONLY be counted if they are found in this list of Subreddits
|
||||
*
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
include?: string[],
|
||||
/**
|
||||
* Do not include Activities from this list of Subreddits (by name, case-insensitive)
|
||||
* When present, Activities WILL NOT be counted if they are found in this list of Subreddits
|
||||
*
|
||||
* Will be ignored if `include` is present.
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
exclude?: string[],
|
||||
|
||||
/**
|
||||
* When present, Submissions from `window` will only be counted if they meet this criteria
|
||||
* */
|
||||
submissionState?: SubmissionState
|
||||
/**
|
||||
* When present, Comments from `window` will only be counted if they meet this criteria
|
||||
* */
|
||||
commentState?: CommentState
|
||||
|
||||
/**
|
||||
* This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`
|
||||
*
|
||||
@@ -178,21 +195,36 @@ export class AttributionRule extends Rule {
|
||||
consolidateMediaDomains = false,
|
||||
domains = [],
|
||||
domainsCombined = false,
|
||||
include: includeRaw = [],
|
||||
exclude: excludeRaw = [],
|
||||
include = [],
|
||||
exclude = [],
|
||||
commentState,
|
||||
submissionState,
|
||||
} = criteria;
|
||||
|
||||
const include = includeRaw.map(x => parseSubredditName(x).toLowerCase());
|
||||
const exclude = excludeRaw.map(x => parseSubredditName(x).toLowerCase());
|
||||
|
||||
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(threshold);
|
||||
|
||||
let activities = thresholdOn === 'submissions' ? await this.resources.getAuthorSubmissions(item.author, {window: window}) : await this.resources.getAuthorActivities(item.author, {window: window});
|
||||
activities = activities.filter(act => {
|
||||
if (include.length > 0) {
|
||||
return include.some(x => x === getActivitySubredditName(act).toLowerCase());
|
||||
} else if (exclude.length > 0) {
|
||||
return !exclude.some(x => x === getActivitySubredditName(act).toLowerCase())
|
||||
|
||||
if(include.length > 0 || exclude.length > 0) {
|
||||
const defaultOpts = {
|
||||
defaultFlags: 'i',
|
||||
generateDescription: true
|
||||
};
|
||||
if(include.length > 0) {
|
||||
const subStates = include.map(x => convertSubredditsRawToStrong(x, defaultOpts));
|
||||
activities = await this.resources.batchTestSubredditCriteria(activities, subStates);
|
||||
} else {
|
||||
const subStates = exclude.map(x => convertSubredditsRawToStrong(x, defaultOpts));
|
||||
const toExclude = (await this.resources.batchTestSubredditCriteria(activities, subStates)).map(x => x.id);
|
||||
activities = activities.filter(x => !toExclude.includes(x.id));
|
||||
}
|
||||
}
|
||||
|
||||
activities = await as.filter(activities, async (activity) => {
|
||||
if (asSubmission(activity) && submissionState !== undefined) {
|
||||
return await this.resources.testItemCriteria(activity, [submissionState]);
|
||||
} else if (commentState !== undefined) {
|
||||
return await this.resources.testItemCriteria(activity, [commentState]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -12,11 +12,11 @@ export interface AuthorRuleConfig {
|
||||
/**
|
||||
* Will "pass" if any set of AuthorCriteria passes
|
||||
* */
|
||||
include: AuthorCriteria[];
|
||||
include?: AuthorCriteria[];
|
||||
/**
|
||||
* Only runs if include is not present. Will "pass" if any of set of the AuthorCriteria does not pass
|
||||
* */
|
||||
exclude: AuthorCriteria[];
|
||||
exclude?: AuthorCriteria[];
|
||||
}
|
||||
|
||||
export interface AuthorRuleOptions extends AuthorRuleConfig, RuleOptions {
|
||||
@@ -34,8 +34,13 @@ export class AuthorRule extends Rule {
|
||||
constructor(options: AuthorRuleOptions) {
|
||||
super(options);
|
||||
|
||||
this.include = options.include.map(x => new Author(x));
|
||||
this.exclude = options.exclude.map(x => new Author(x));
|
||||
const {
|
||||
include,
|
||||
exclude,
|
||||
} = options;
|
||||
|
||||
this.include = include !== undefined ? include.map(x => new Author(x)) : [];
|
||||
this.exclude = exclude !== undefined ? exclude.map(x => new Author(x)) : [];
|
||||
|
||||
if(this.include.length === 0 && this.exclude.length === 0) {
|
||||
throw new Error('At least one of the properties [include,exclude] on Author Rule must not be empty');
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import {Rule, RuleJSONConfig, RuleOptions, RulePremise, RuleResult} from "./index";
|
||||
import {Comment, VoteableContent} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import as from 'async';
|
||||
import pMap from 'p-map';
|
||||
// @ts-ignore
|
||||
import subImageMatch from 'matches-subimage';
|
||||
import {
|
||||
activityWindowText,
|
||||
asSubmission, compareImages,
|
||||
comparisonTextOp,
|
||||
asSubmission, bitsToHexLength,
|
||||
// blockHashImage,
|
||||
compareImages,
|
||||
comparisonTextOp, convertSubredditsRawToStrong,
|
||||
FAIL,
|
||||
formatNumber,
|
||||
getActivitySubredditName, getImageDataFromUrl,
|
||||
getActivitySubredditName, imageCompareMaxConcurrencyGuess,
|
||||
//getImageDataFromUrl,
|
||||
isSubmission,
|
||||
isValidImageURL,
|
||||
objectToStringSummary,
|
||||
@@ -17,16 +22,21 @@ import {
|
||||
parseStringToRegex,
|
||||
parseSubredditName,
|
||||
parseUsableLinkIdentifier,
|
||||
PASS,
|
||||
PASS, sleep,
|
||||
toStrongSubredditState
|
||||
} from "../util";
|
||||
import {
|
||||
ActivityWindow,
|
||||
ActivityWindowCriteria,
|
||||
ActivityWindowType, CommentState, ImageDetection,
|
||||
ReferenceSubmission, StrongSubredditState, SubmissionState,
|
||||
ActivityWindowType, CommentState,
|
||||
//ImageData,
|
||||
ImageDetection,
|
||||
ReferenceSubmission, StrongImageDetection, StrongSubredditState, SubmissionState,
|
||||
SubredditCriteria, SubredditState
|
||||
} from "../Common/interfaces";
|
||||
import ImageData from "../Common/ImageData";
|
||||
import {blockhash, hammingDistance} from "../Common/blockhash/blockhash";
|
||||
import leven from "leven";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
@@ -34,7 +44,7 @@ export class RecentActivityRule extends Rule {
|
||||
window: ActivityWindowType;
|
||||
thresholds: ActivityThreshold[];
|
||||
useSubmissionAsReference: boolean;
|
||||
imageDetection: Required<ImageDetection>
|
||||
imageDetection: StrongImageDetection
|
||||
lookAt?: 'comments' | 'submissions';
|
||||
|
||||
constructor(options: RecentActivityRuleOptions) {
|
||||
@@ -49,13 +59,39 @@ export class RecentActivityRule extends Rule {
|
||||
const {
|
||||
enable = false,
|
||||
fetchBehavior = 'extension',
|
||||
threshold = 5
|
||||
threshold = 5,
|
||||
hash = {},
|
||||
pixel = {},
|
||||
} = imageDetection || {};
|
||||
|
||||
const {
|
||||
enable: hEnable = true,
|
||||
bits = 16,
|
||||
ttl = 60,
|
||||
hardThreshold = threshold,
|
||||
softThreshold
|
||||
} = hash || {};
|
||||
|
||||
const {
|
||||
enable: pEnable = true,
|
||||
threshold: pThreshold = threshold,
|
||||
} = pixel || {};
|
||||
|
||||
this.imageDetection = {
|
||||
enable,
|
||||
fetchBehavior,
|
||||
threshold
|
||||
threshold,
|
||||
hash: {
|
||||
enable: hEnable,
|
||||
hardThreshold,
|
||||
softThreshold,
|
||||
bits,
|
||||
ttl,
|
||||
},
|
||||
pixel: {
|
||||
enable: pEnable,
|
||||
threshold: pThreshold
|
||||
}
|
||||
};
|
||||
this.lookAt = lookAt;
|
||||
this.useSubmissionAsReference = useSubmissionAsReference;
|
||||
@@ -101,50 +137,132 @@ export class RecentActivityRule extends Rule {
|
||||
const itemId = item.id;
|
||||
const referenceUrl = await item.url;
|
||||
const usableUrl = parseLink(referenceUrl);
|
||||
const filteredActivity = [];
|
||||
let referenceImage;
|
||||
if(this.imageDetection.enable) {
|
||||
const [response, imgData, reason] = await getImageDataFromUrl(referenceUrl);
|
||||
if(reason !== undefined) {
|
||||
this.logger.verbose(reason);
|
||||
} else {
|
||||
referenceImage = imgData
|
||||
let filteredActivity: (Submission|Comment)[] = [];
|
||||
let analysisTimes: number[] = [];
|
||||
let referenceImage: ImageData | undefined;
|
||||
if (this.imageDetection.enable) {
|
||||
try {
|
||||
referenceImage = ImageData.fromSubmission(item);
|
||||
referenceImage.setPreferredResolutionByWidth(800);
|
||||
if(this.imageDetection.hash.enable) {
|
||||
let refHash: string | undefined;
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
refHash = await this.resources.getImageHash(referenceImage);
|
||||
if(refHash === undefined) {
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
await this.resources.setImageHash(referenceImage, refHash, this.imageDetection.hash.ttl);
|
||||
} else if(refHash.length !== bitsToHexLength(this.imageDetection.hash.bits)) {
|
||||
this.logger.warn('Reference image hash length did not correspond to bits specified in config. Recomputing...');
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
await this.resources.setImageHash(referenceImage, refHash, this.imageDetection.hash.ttl);
|
||||
}
|
||||
} else {
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
}
|
||||
}
|
||||
//await referenceImage.sharp();
|
||||
// await referenceImage.hash();
|
||||
// if (referenceImage.preferredResolution !== undefined) {
|
||||
// await (referenceImage.getSimilarResolutionVariant(...referenceImage.preferredResolution) as ImageData).sharp();
|
||||
// }
|
||||
} catch (err) {
|
||||
this.logger.verbose(err.message);
|
||||
}
|
||||
}
|
||||
let longRun;
|
||||
if(referenceImage !== undefined) {
|
||||
if (referenceImage !== undefined) {
|
||||
const l = this.logger;
|
||||
longRun = setTimeout(() => {
|
||||
l.verbose('FYI: Image processing is causing rule to take longer than normal');
|
||||
}, 2500);
|
||||
}
|
||||
for(const x of viableActivity) {
|
||||
// @ts-ignore
|
||||
const ci = async (x: (Submission|Comment)) => {
|
||||
if (!asSubmission(x) || x.id === itemId) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
if (x.url === undefined) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
if(parseLink(x.url) === usableUrl) {
|
||||
filteredActivity.push(x);
|
||||
if (parseLink(x.url) === usableUrl) {
|
||||
return x;
|
||||
}
|
||||
// only do image detection if regular URL comparison and other conditions fail first
|
||||
// to reduce CPU/bandwidth usage
|
||||
if(referenceImage !== undefined) {
|
||||
const [response, imgData, reason] = await getImageDataFromUrl(x.url);
|
||||
if(imgData !== undefined) {
|
||||
try {
|
||||
const [compareResult, sameImage] = await compareImages(referenceImage, imgData, this.imageDetection.threshold);
|
||||
if(sameImage) {
|
||||
filteredActivity.push(x);
|
||||
if (referenceImage !== undefined) {
|
||||
try {
|
||||
let imgData = ImageData.fromSubmission(x);
|
||||
imgData.setPreferredResolutionByWidth(800);
|
||||
if(this.imageDetection.hash.enable) {
|
||||
let compareHash: string | undefined;
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
compareHash = await this.resources.getImageHash(imgData);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Unexpected error encountered while comparing images, will skip comparison: ${err.message}`);
|
||||
if(compareHash === undefined)
|
||||
{
|
||||
compareHash = await imgData.hash(this.imageDetection.hash.bits);
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
await this.resources.setImageHash(imgData, compareHash, this.imageDetection.hash.ttl);
|
||||
}
|
||||
}
|
||||
const refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
if(refHash.length !== compareHash.length) {
|
||||
this.logger.debug(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${referenceImage.baseUrl} has is ${refHash.length} char long | Comparing: ${imgData.baseUrl} has is ${compareHash} ${compareHash.length} long`);
|
||||
compareHash = await imgData.hash(this.imageDetection.hash.bits)
|
||||
}
|
||||
const distance = leven(refHash, compareHash);
|
||||
const diff = (distance/refHash.length)*100;
|
||||
|
||||
|
||||
// return image if hard is defined and diff is less
|
||||
if(null !== this.imageDetection.hash.hardThreshold && diff <= this.imageDetection.hash.hardThreshold) {
|
||||
return x;
|
||||
}
|
||||
// hard is either not defined or diff was gerater than hard
|
||||
|
||||
// if soft is defined
|
||||
if (this.imageDetection.hash.softThreshold !== undefined) {
|
||||
// and diff is greater than soft allowance
|
||||
if(diff > this.imageDetection.hash.softThreshold) {
|
||||
// not similar enough
|
||||
return null;
|
||||
}
|
||||
// similar enough, will continue on to pixel (if enabled!)
|
||||
} else {
|
||||
// only hard was defined and did not pass
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// at this point either hash was not enabled or it was and we hit soft threshold but not hard
|
||||
if(this.imageDetection.pixel.enable) {
|
||||
try {
|
||||
const [compareResult, sameImage] = await compareImages(referenceImage, imgData, this.imageDetection.pixel.threshold / 100);
|
||||
analysisTimes.push(compareResult.analysisTime);
|
||||
if (sameImage) {
|
||||
return x;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Unexpected error encountered while pixel-comparing images, will skip comparison => ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if(!err.message.includes('did not end with a valid image extension')) {
|
||||
this.logger.warn(`Will not compare image from Submission ${x.id} due to error while parsing image URL => ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if(longRun !== undefined) {
|
||||
// parallel all the things
|
||||
this.logger.profile('asyncCompare');
|
||||
const results = await pMap(viableActivity, ci, {concurrency: imageCompareMaxConcurrencyGuess});
|
||||
this.logger.profile('asyncCompare', {level: 'debug', message: 'Total time for image comparison (incl download/cache calls)'});
|
||||
const totalAnalysisTime = analysisTimes.reduce((acc, x) => acc + x,0);
|
||||
if(analysisTimes.length > 0) {
|
||||
this.logger.debug(`Reference image pixel-compared ${analysisTimes.length} times. Timings: Avg ${formatNumber(totalAnalysisTime / analysisTimes.length, {toFixed: 0})}ms | Max: ${Math.max(...analysisTimes)}ms | Min: ${Math.min(...analysisTimes)}ms | Total: ${totalAnalysisTime}ms (${formatNumber(totalAnalysisTime/1000)}s)`);
|
||||
}
|
||||
filteredActivity = filteredActivity.concat(results.filter(x => x !== null));
|
||||
if (longRun !== undefined) {
|
||||
clearTimeout(longRun);
|
||||
}
|
||||
viableActivity = filteredActivity;
|
||||
@@ -166,43 +284,71 @@ export class RecentActivityRule extends Rule {
|
||||
} = triggerSet;
|
||||
|
||||
// convert subreddits array into entirely StrongSubredditState
|
||||
const subStates: StrongSubredditState[] = subreddits.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
const defaultOpts = {
|
||||
defaultFlags: 'i',
|
||||
generateDescription: true
|
||||
};
|
||||
const subStates: StrongSubredditState[] = subreddits.map((x) => convertSubredditsRawToStrong(x, defaultOpts));
|
||||
|
||||
let validActivity: (Comment | Submission)[] = await as.filter(viableActivity, async (activity) => {
|
||||
if (asSubmission(activity) && submissionState !== undefined) {
|
||||
return await this.resources.testItemCriteria(activity, [submissionState]);
|
||||
} else if (commentState !== undefined) {
|
||||
return await this.resources.testItemCriteria(activity, [commentState]);
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
return true;
|
||||
});
|
||||
|
||||
for(const activity of viableActivity) {
|
||||
if(asSubmission(activity) && submissionState !== undefined) {
|
||||
if(!(await this.resources.testItemCriteria(activity, [submissionState]))) {
|
||||
validActivity = await this.resources.batchTestSubredditCriteria(validActivity, subStates);
|
||||
for (const activity of validActivity) {
|
||||
currCount++;
|
||||
// @ts-ignore
|
||||
combinedKarma += activity.score;
|
||||
const pSub = getActivitySubredditName(activity);
|
||||
if (!presentSubs.includes(pSub)) {
|
||||
presentSubs.push(pSub);
|
||||
}
|
||||
}
|
||||
|
||||
for (const activity of viableActivity) {
|
||||
if (asSubmission(activity) && submissionState !== undefined) {
|
||||
if (!(await this.resources.testItemCriteria(activity, [submissionState]))) {
|
||||
continue;
|
||||
}
|
||||
} else if(commentState !== undefined) {
|
||||
if(!(await this.resources.testItemCriteria(activity, [commentState]))) {
|
||||
} else if (commentState !== undefined) {
|
||||
if (!(await this.resources.testItemCriteria(activity, [commentState]))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let inSubreddits = false;
|
||||
for(const ss of subStates) {
|
||||
for (const ss of subStates) {
|
||||
const res = await this.resources.testSubredditCriteria(activity, ss);
|
||||
if(res) {
|
||||
if (res) {
|
||||
inSubreddits = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(inSubreddits) {
|
||||
if (inSubreddits) {
|
||||
currCount++;
|
||||
combinedKarma += activity.score;
|
||||
const pSub = getActivitySubredditName(activity);
|
||||
if(!presentSubs.includes(pSub)) {
|
||||
if (!presentSubs.includes(pSub)) {
|
||||
presentSubs.push(pSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(threshold);
|
||||
let sum = {subsWithActivity: presentSubs, combinedKarma, karmaThreshold, subreddits: subStates.map(x => x.stateDescription), count: currCount, threshold, triggered: false, testValue: currCount.toString()};
|
||||
let sum = {
|
||||
subsWithActivity: presentSubs,
|
||||
combinedKarma,
|
||||
karmaThreshold,
|
||||
subreddits: subStates.map(x => x.stateDescription),
|
||||
count: currCount,
|
||||
threshold,
|
||||
triggered: false,
|
||||
testValue: currCount.toString()
|
||||
};
|
||||
if (isPercent) {
|
||||
sum.testValue = `${formatNumber((currCount / viableActivity.length) * 100)}%`;
|
||||
if (comparisonTextOp(currCount / viableActivity.length, operator, value / 100)) {
|
||||
@@ -214,9 +360,9 @@ export class RecentActivityRule extends Rule {
|
||||
totalTriggeredOn = sum;
|
||||
}
|
||||
// if we would trigger on threshold need to also test for karma
|
||||
if(totalTriggeredOn !== undefined && karmaThreshold !== undefined) {
|
||||
if (totalTriggeredOn !== undefined && karmaThreshold !== undefined) {
|
||||
const {operator: opKarma, value: valueKarma} = parseGenericValueOrPercentComparison(karmaThreshold);
|
||||
if(!comparisonTextOp(combinedKarma, opKarma, valueKarma)) {
|
||||
if (!comparisonTextOp(combinedKarma, opKarma, valueKarma)) {
|
||||
sum.triggered = false;
|
||||
totalTriggeredOn = undefined;
|
||||
}
|
||||
@@ -234,7 +380,7 @@ export class RecentActivityRule extends Rule {
|
||||
result = `${PASS} ${resultData.result}`;
|
||||
this.logger.verbose(result);
|
||||
return Promise.resolve([true, this.getResult(true, resultData)]);
|
||||
} else if(summaries.length === 1) {
|
||||
} else if (summaries.length === 1) {
|
||||
// can display result if its only one summary otherwise need to log to debug
|
||||
const res = this.generateResultData(summaries[0], viableActivity);
|
||||
result = `${FAIL} ${res.result}`;
|
||||
@@ -247,7 +393,7 @@ export class RecentActivityRule extends Rule {
|
||||
|
||||
return Promise.resolve([false, this.getResult(false, {result})]);
|
||||
}
|
||||
|
||||
|
||||
generateResultData(summary: any, activities: (Submission | Comment)[] = []) {
|
||||
const {
|
||||
count,
|
||||
@@ -261,7 +407,7 @@ export class RecentActivityRule extends Rule {
|
||||
} = summary;
|
||||
const relevantSubs = subsWithActivity.length === 0 ? subreddits : subsWithActivity;
|
||||
let totalSummary = `${testValue} activities over ${relevantSubs.length} subreddits${karmaThreshold !== undefined ? ` with ${combinedKarma} combined karma` : ''} ${triggered ? 'met' : 'did not meet'} threshold of ${threshold}${karmaThreshold !== undefined ? ` and ${karmaThreshold} combined karma` : ''}`;
|
||||
if(triggered && subsWithActivity.length > 0) {
|
||||
if (triggered && subsWithActivity.length > 0) {
|
||||
totalSummary = `${totalSummary} -- subreddits: ${subsWithActivity.join(', ')}`;
|
||||
}
|
||||
return {
|
||||
@@ -288,12 +434,12 @@ export class RecentActivityRule extends Rule {
|
||||
* */
|
||||
export interface ActivityThreshold {
|
||||
/**
|
||||
* When present, a Submission will only be counted if it meets this criteria
|
||||
* */
|
||||
* When present, a Submission will only be counted if it meets this criteria
|
||||
* */
|
||||
submissionState?: SubmissionState
|
||||
/**
|
||||
* When present, a Comment will only be counted if it meets this criteria
|
||||
* */
|
||||
* When present, a Comment will only be counted if it meets this criteria
|
||||
* */
|
||||
commentState?: CommentState
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
activityWindowText, asSubmission,
|
||||
comparisonTextOp, FAIL, getActivitySubredditName, isExternalUrlSubmission, isRedditMedia,
|
||||
parseGenericValueComparison, parseSubredditName,
|
||||
parseUsableLinkIdentifier as linkParser, PASS, toStrongSubredditState
|
||||
parseUsableLinkIdentifier as linkParser, PASS, subredditStateIsNameOnly, toStrongSubredditState
|
||||
} from "../util";
|
||||
import {
|
||||
ActivityWindow,
|
||||
@@ -58,6 +58,7 @@ export class RepeatActivityRule extends Rule {
|
||||
lookAt: 'submissions' | 'all';
|
||||
include: (string | SubredditState)[];
|
||||
exclude: (string | SubredditState)[];
|
||||
hasFullSubredditCrits: boolean = false;
|
||||
activityFilterFunc: (x: Submission|Comment) => Promise<boolean> = async (x) => true;
|
||||
keepRemoved: boolean;
|
||||
minWordCount: number;
|
||||
@@ -91,6 +92,7 @@ export class RepeatActivityRule extends Rule {
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.hasFullSubredditCrits = !subStates.every(x => subredditStateIsNameOnly(x));
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
@@ -106,6 +108,7 @@ export class RepeatActivityRule extends Rule {
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.hasFullSubredditCrits = !subStates.every(x => subredditStateIsNameOnly(x));
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
@@ -149,6 +152,12 @@ export class RepeatActivityRule extends Rule {
|
||||
break;
|
||||
}
|
||||
|
||||
if(this.hasFullSubredditCrits) {
|
||||
// go ahead and cache subreddits now
|
||||
// because we can't use batch test since testing activities in order is important for this rule
|
||||
await this.resources.cacheSubreddits(activities.map(x => x.subreddit));
|
||||
}
|
||||
|
||||
const condensedActivities = await activities.reduce(async (accProm: Promise<RepeatActivityReducer>, activity: (Submission | Comment), index: number) => {
|
||||
const acc = await accProm;
|
||||
const {openSets = [], allSets = []} = acc;
|
||||
|
||||
@@ -211,22 +211,6 @@ export interface UserNoteCriteria {
|
||||
search?: 'current' | 'consecutive' | 'total'
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration and how to compare it against a value
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`
|
||||
*
|
||||
* * EX `> 100 days` => Passes if the date being compared is before 100 days ago
|
||||
* * EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months
|
||||
*
|
||||
* Unit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)
|
||||
*
|
||||
* [See] https://regexr.com/609n8 for example
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\s*$
|
||||
* */
|
||||
export type DurationComparor = string;
|
||||
|
||||
export interface IRule extends ChecksActivityState {
|
||||
/**
|
||||
* An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.
|
||||
|
||||
@@ -29,6 +29,26 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "An (array of) string/regular expression to test contents of an Author's profile description against\n\nIf no flags are specified then the **insensitive** flag is used by default\n\nIf using an array then if **any** value in the array passes the description test passes",
|
||||
"examples": [
|
||||
[
|
||||
"/test$/i",
|
||||
"look for this string literal"
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairCssClass": {
|
||||
"description": "A list of (user) flair css class values from the subreddit to match against",
|
||||
"examples": [
|
||||
@@ -136,6 +156,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -193,6 +218,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -249,6 +249,16 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, Comments from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"consolidateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the criteria consolidate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)",
|
||||
@@ -277,27 +287,37 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Activities from this list of Subreddits (by name, case-insensitive)\n\nWill be ignored if `include` is present.\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL NOT be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Activities from this list of Subreddits (by name, case-insensitive)\n\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL ONLY be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"minActivityCount": {
|
||||
@@ -308,6 +328,16 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, Submissions from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"threshold": {
|
||||
"default": "> 10%",
|
||||
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution",
|
||||
@@ -454,6 +484,26 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "An (array of) string/regular expression to test contents of an Author's profile description against\n\nIf no flags are specified then the **insensitive** flag is used by default\n\nIf using an array then if **any** value in the array passes the description test passes",
|
||||
"examples": [
|
||||
[
|
||||
"/test$/i",
|
||||
"look for this string literal"
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairCssClass": {
|
||||
"description": "A list of (user) flair css class values from the subreddit to match against",
|
||||
"examples": [
|
||||
@@ -621,8 +671,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exclude",
|
||||
"include",
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -922,6 +970,25 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ClearProcessedOptions": {
|
||||
"description": "For very long-running, high-volume subreddits clearing the list of processed activities helps manage memory bloat\n\nAll of these options have default values based on the limit and/or interval set for polling options on each subreddit stream. They only need to modified if the defaults are not sufficient.\n\nIf both `after` and `size` are defined whichever is hit first will trigger the list to clear. `after` will be reset after ever clear.",
|
||||
"properties": {
|
||||
"after": {
|
||||
"description": "An interval the processed list should be cleared after.\n\n* EX `9 days`\n* EX `3 months`\n* EX `5 minutes`",
|
||||
"pattern": "^\\s*(?<time>\\d+)\\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"retain": {
|
||||
"description": "The number of activities to retain in processed list after clearing.\n\nDefaults to `limit` value from `PollingOptions`",
|
||||
"type": "number"
|
||||
},
|
||||
"size": {
|
||||
"description": "Number of activities found in processed list after which the list should be cleared.\n\nDefaults to the `limit` value from `PollingOptions`",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CommentActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"properties": {
|
||||
@@ -1216,6 +1283,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1621,9 +1693,55 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"hash": {
|
||||
"description": "Use perceptual hashing (blockhash-js) to compare images\n\nPros:\n\n* very fast\n* low cpu/memory usage\n* results can be cached\n\nCons:\n\n* not as accurate as pixel comparison\n* weaker for text-heavy images\n* mostly color-blind\n\nBest uses:\n\n* Detecting (general) duplicate images\n* Comparing large number of images",
|
||||
"properties": {
|
||||
"bits": {
|
||||
"default": 32,
|
||||
"description": "Bit count determines accuracy of hash and granularity of hash comparison (comparison to other hashes)\n\nDefault is `32`\n\n**NOTE:** Hashes of different sizes (bits) cannot be compared. If you are caching results make sure all rules where results may be shared use the same bit count to ensure hashes can be compared. Otherwise hashes will be recomputed.",
|
||||
"type": "number"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Enabled by default.\n\nIf both `hash` and `pixel` are enabled then `pixel` will be used to verify image comparison when hashes matches",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hardThreshold": {
|
||||
"description": "High Confidence Threshold\n\nIf the difference in comparison is equal to or less than this number the images are considered the same and pixel comparison WILL NOT occur\n\nDefaults to the parent-level `threshold` value if not present\n\nUse `null` if you want pixel comparison to ALWAYS occur (softThreshold must be present)",
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"softThreshold": {
|
||||
"description": "Low Confidence Threshold -- only used if `pixel` is enabled\n\nIf the difference in comparison is\n\n1) equal to or less than this value and\n2) the value is greater than `hardThreshold`\n\nthe images will be compared using the `pixel` method",
|
||||
"type": "number"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Number of seconds to cache image hash",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"pixel": {
|
||||
"description": "Use pixel counting to compare images\n\nPros:\n\n* most accurate\n* strong with text or color-only changes\n\nCons:\n\n* much slower than hashing\n* memory/cpu intensive\n\nBest uses:\n\n* Comparison text-only images\n* Comparison requires high degree of accuracy or changes are subtle",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"default": false,
|
||||
"description": "Disabled by default.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"threshold": {
|
||||
"default": 5,
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
|
||||
"description": "The percentage, as a whole number, of difference between two images at which point they will not be considered the same.\n\nWill be used as `hash.hardThreshold` and `pixel.threshold` if those values are not specified\n\nDefault is `5`",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -1896,6 +2014,10 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"clearProcessed": {
|
||||
"$ref": "#/definitions/ClearProcessedOptions",
|
||||
"description": "For very long-running, high-volume subreddits clearing the list of processed activities helps manage memory bloat\n\nAll of these options have default values based on the limit and/or interval set for polling options on each subreddit stream. They only need to modified if the defaults are not sufficient.\n\nIf both `after` and `size` are defined whichever is hit first will trigger the list to clear. `after` will be reset after ever clear."
|
||||
},
|
||||
"delayUntil": {
|
||||
"description": "Delay processing Activity until it is `N` seconds old\n\nUseful if there are other bots that may process an Activity and you want this bot to run first/last/etc.\n\nIf the Activity is already `N` seconds old when it is initially retrieved no refresh of the Activity occurs (no API request is made) and it is immediately processed.",
|
||||
"type": "number"
|
||||
@@ -2831,6 +2953,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
"default": false,
|
||||
"description": "If set to `true` all subreddits polling unmoderated/modqueue with default polling settings will share a request to \"r/mod\"\notherwise each subreddit will poll its own mod view\n\n* ENV => `SHARE_MOD`\n* ARG => `--shareMod`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stagger": {
|
||||
"description": "If sharing a mod stream stagger pushing relevant Activities to individual subreddits.\n\nUseful when running many subreddits and rules are potentially cpu/memory/traffic heavy -- allows spreading out load",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -195,6 +195,16 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, Comments from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"consolidateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the criteria consolidate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)",
|
||||
@@ -223,27 +233,37 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Activities from this list of Subreddits (by name, case-insensitive)\n\nWill be ignored if `include` is present.\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL NOT be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Activities from this list of Subreddits (by name, case-insensitive)\n\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL ONLY be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"minActivityCount": {
|
||||
@@ -254,6 +274,16 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, Submissions from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"threshold": {
|
||||
"default": "> 10%",
|
||||
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution",
|
||||
@@ -400,6 +430,26 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "An (array of) string/regular expression to test contents of an Author's profile description against\n\nIf no flags are specified then the **insensitive** flag is used by default\n\nIf using an array then if **any** value in the array passes the description test passes",
|
||||
"examples": [
|
||||
[
|
||||
"/test$/i",
|
||||
"look for this string literal"
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairCssClass": {
|
||||
"description": "A list of (user) flair css class values from the subreddit to match against",
|
||||
"examples": [
|
||||
@@ -567,8 +617,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exclude",
|
||||
"include",
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -582,6 +630,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -880,9 +933,55 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"hash": {
|
||||
"description": "Use perceptual hashing (blockhash-js) to compare images\n\nPros:\n\n* very fast\n* low cpu/memory usage\n* results can be cached\n\nCons:\n\n* not as accurate as pixel comparison\n* weaker for text-heavy images\n* mostly color-blind\n\nBest uses:\n\n* Detecting (general) duplicate images\n* Comparing large number of images",
|
||||
"properties": {
|
||||
"bits": {
|
||||
"default": 32,
|
||||
"description": "Bit count determines accuracy of hash and granularity of hash comparison (comparison to other hashes)\n\nDefault is `32`\n\n**NOTE:** Hashes of different sizes (bits) cannot be compared. If you are caching results make sure all rules where results may be shared use the same bit count to ensure hashes can be compared. Otherwise hashes will be recomputed.",
|
||||
"type": "number"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Enabled by default.\n\nIf both `hash` and `pixel` are enabled then `pixel` will be used to verify image comparison when hashes matches",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hardThreshold": {
|
||||
"description": "High Confidence Threshold\n\nIf the difference in comparison is equal to or less than this number the images are considered the same and pixel comparison WILL NOT occur\n\nDefaults to the parent-level `threshold` value if not present\n\nUse `null` if you want pixel comparison to ALWAYS occur (softThreshold must be present)",
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"softThreshold": {
|
||||
"description": "Low Confidence Threshold -- only used if `pixel` is enabled\n\nIf the difference in comparison is\n\n1) equal to or less than this value and\n2) the value is greater than `hardThreshold`\n\nthe images will be compared using the `pixel` method",
|
||||
"type": "number"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Number of seconds to cache image hash",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"pixel": {
|
||||
"description": "Use pixel counting to compare images\n\nPros:\n\n* most accurate\n* strong with text or color-only changes\n\nCons:\n\n* much slower than hashing\n* memory/cpu intensive\n\nBest uses:\n\n* Comparison text-only images\n* Comparison requires high degree of accuracy or changes are subtle",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"default": false,
|
||||
"description": "Disabled by default.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"threshold": {
|
||||
"default": 5,
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
|
||||
"description": "The percentage, as a whole number, of difference between two images at which point they will not be considered the same.\n\nWill be used as `hash.hardThreshold` and `pixel.threshold` if those values are not specified\n\nDefault is `5`",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -1396,6 +1495,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -172,6 +172,16 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, Comments from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"consolidateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the criteria consolidate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)",
|
||||
@@ -200,27 +210,37 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Activities from this list of Subreddits (by name, case-insensitive)\n\nWill be ignored if `include` is present.\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL NOT be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Activities from this list of Subreddits (by name, case-insensitive)\n\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "When present, Activities WILL ONLY be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"minActivityCount": {
|
||||
@@ -231,6 +251,16 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, Submissions from `window` will only be counted if they meet this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"threshold": {
|
||||
"default": "> 10%",
|
||||
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution",
|
||||
@@ -377,6 +407,26 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "An (array of) string/regular expression to test contents of an Author's profile description against\n\nIf no flags are specified then the **insensitive** flag is used by default\n\nIf using an array then if **any** value in the array passes the description test passes",
|
||||
"examples": [
|
||||
[
|
||||
"/test$/i",
|
||||
"look for this string literal"
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairCssClass": {
|
||||
"description": "A list of (user) flair css class values from the subreddit to match against",
|
||||
"examples": [
|
||||
@@ -544,8 +594,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exclude",
|
||||
"include",
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -559,6 +607,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -857,9 +910,55 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"hash": {
|
||||
"description": "Use perceptual hashing (blockhash-js) to compare images\n\nPros:\n\n* very fast\n* low cpu/memory usage\n* results can be cached\n\nCons:\n\n* not as accurate as pixel comparison\n* weaker for text-heavy images\n* mostly color-blind\n\nBest uses:\n\n* Detecting (general) duplicate images\n* Comparing large number of images",
|
||||
"properties": {
|
||||
"bits": {
|
||||
"default": 32,
|
||||
"description": "Bit count determines accuracy of hash and granularity of hash comparison (comparison to other hashes)\n\nDefault is `32`\n\n**NOTE:** Hashes of different sizes (bits) cannot be compared. If you are caching results make sure all rules where results may be shared use the same bit count to ensure hashes can be compared. Otherwise hashes will be recomputed.",
|
||||
"type": "number"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Enabled by default.\n\nIf both `hash` and `pixel` are enabled then `pixel` will be used to verify image comparison when hashes matches",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hardThreshold": {
|
||||
"description": "High Confidence Threshold\n\nIf the difference in comparison is equal to or less than this number the images are considered the same and pixel comparison WILL NOT occur\n\nDefaults to the parent-level `threshold` value if not present\n\nUse `null` if you want pixel comparison to ALWAYS occur (softThreshold must be present)",
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"softThreshold": {
|
||||
"description": "Low Confidence Threshold -- only used if `pixel` is enabled\n\nIf the difference in comparison is\n\n1) equal to or less than this value and\n2) the value is greater than `hardThreshold`\n\nthe images will be compared using the `pixel` method",
|
||||
"type": "number"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Number of seconds to cache image hash",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"pixel": {
|
||||
"description": "Use pixel counting to compare images\n\nPros:\n\n* most accurate\n* strong with text or color-only changes\n\nCons:\n\n* much slower than hashing\n* memory/cpu intensive\n\nBest uses:\n\n* Comparison text-only images\n* Comparison requires high degree of accuracy or changes are subtle",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"default": false,
|
||||
"description": "Disabled by default.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"threshold": {
|
||||
"default": 5,
|
||||
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
|
||||
"description": "The percentage, as a whole number, of difference between two images at which point they will not be considered the same.\n\nWill be used as `hash.hardThreshold` and `pixel.threshold` if those values are not specified\n\nDefault is `5`",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -1373,6 +1472,11 @@
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "A duration and how to compare it against a value\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`\n\n* EX `> 100 days` => Passes if the date being compared is before 100 days ago\n* EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
|
||||
"type": "string"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ import {CheckStructuredJson} from "../Check";
|
||||
import NotificationManager from "../Notification/NotificationManager";
|
||||
import action from "../Web/Server/routes/authenticated/user/action";
|
||||
import {createHistoricalDefaults, historicalDefaults} from "../Common/defaults";
|
||||
import {ExtendedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
|
||||
export interface RunningState {
|
||||
state: RunState,
|
||||
@@ -74,7 +75,7 @@ interface QueuedIdentifier {
|
||||
|
||||
export class Manager {
|
||||
subreddit: Subreddit;
|
||||
client: Snoowrap;
|
||||
client: ExtendedSnoowrap;
|
||||
logger: Logger;
|
||||
botName: string;
|
||||
pollOptions: PollingOptionsStrong[] = [];
|
||||
@@ -180,7 +181,7 @@ export class Manager {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, cacheManager: BotResourcesManager, opts: RuntimeManagerOptions = {botName: 'ContextMod', maxWorkers: 1}) {
|
||||
constructor(sub: Subreddit, client: ExtendedSnoowrap, logger: Logger, cacheManager: BotResourcesManager, opts: RuntimeManagerOptions = {botName: 'ContextMod', maxWorkers: 1}) {
|
||||
const {dryRun, sharedModqueue = false, wikiLocation = 'botconfig/contextbot', botName, maxWorkers} = opts;
|
||||
this.displayLabel = opts.nickname || `${sub.display_name_prefixed}`;
|
||||
const getLabels = this.getCurrentLabels;
|
||||
@@ -672,7 +673,8 @@ export class Manager {
|
||||
pollOn,
|
||||
limit,
|
||||
interval,
|
||||
delayUntil
|
||||
delayUntil,
|
||||
clearProcessed,
|
||||
} = pollOpt;
|
||||
let stream: SPoll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
let modStreamType: string | undefined;
|
||||
@@ -688,6 +690,7 @@ export class Manager {
|
||||
subreddit: this.subreddit.display_name,
|
||||
limit: limit,
|
||||
pollTime: interval * 1000,
|
||||
clearProcessed,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -701,6 +704,7 @@ export class Manager {
|
||||
subreddit: this.subreddit.display_name,
|
||||
limit: limit,
|
||||
pollTime: interval * 1000,
|
||||
clearProcessed
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -709,6 +713,7 @@ export class Manager {
|
||||
subreddit: this.subreddit.display_name,
|
||||
limit: limit,
|
||||
pollTime: interval * 1000,
|
||||
clearProcessed
|
||||
});
|
||||
break;
|
||||
case 'newComm':
|
||||
@@ -716,6 +721,7 @@ export class Manager {
|
||||
subreddit: this.subreddit.display_name,
|
||||
limit: limit,
|
||||
pollTime: interval * 1000,
|
||||
clearProcessed
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -753,11 +759,10 @@ export class Manager {
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('item', onItem);
|
||||
|
||||
if (modStreamType !== undefined) {
|
||||
this.modStreamCallbacks.set(pollOn, onItem);
|
||||
} else {
|
||||
stream.on('item', onItem);
|
||||
// @ts-ignore
|
||||
stream.on('error', async (err: any) => {
|
||||
|
||||
@@ -937,10 +942,11 @@ export class Manager {
|
||||
s.end();
|
||||
}
|
||||
this.streams = [];
|
||||
for (const [k, v] of this.modStreamCallbacks) {
|
||||
const stream = this.cacheManager.modStreams.get(k) as Poll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
stream.removeListener('item', v);
|
||||
}
|
||||
// for (const [k, v] of this.modStreamCallbacks) {
|
||||
// const stream = this.cacheManager.modStreams.get(k) as Poll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
// stream.removeListener('item', v);
|
||||
// }
|
||||
this.modStreamCallbacks = new Map();
|
||||
this.startedAt = undefined;
|
||||
this.logger.info(`Events STOPPED by ${causedBy}`);
|
||||
this.eventsState = {
|
||||
|
||||
@@ -2,51 +2,107 @@ import {Poll, SnooStormOptions} from "snoostorm"
|
||||
import Snoowrap from "snoowrap";
|
||||
import {EventEmitter} from "events";
|
||||
import {PollConfiguration} from "snoostorm/out/util/Poll";
|
||||
import {DEFAULT_POLLING_INTERVAL} from "../Common/interfaces";
|
||||
import {ClearProcessedOptions, DEFAULT_POLLING_INTERVAL} from "../Common/interfaces";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import { Duration } from "dayjs/plugin/duration";
|
||||
import {parseDuration, sleep} from "../util";
|
||||
import setRandomInterval from 'set-random-interval';
|
||||
|
||||
type Awaitable<T> = Promise<T> | T;
|
||||
|
||||
interface RCBPollingOptions extends SnooStormOptions {
|
||||
subreddit: string,
|
||||
clearProcessed?: ClearProcessedOptions
|
||||
}
|
||||
|
||||
interface RCBPollConfiguration<T> extends PollConfiguration<T> {
|
||||
clearProcessed?: ClearProcessedOptions
|
||||
}
|
||||
|
||||
export class SPoll<T extends object> extends Poll<T> {
|
||||
identifier: keyof T;
|
||||
getter: () => Awaitable<T[]>;
|
||||
frequency;
|
||||
running: boolean = false;
|
||||
clearProcessedDuration?: Duration;
|
||||
clearProcessedSize?: number;
|
||||
clearProcessedAfter?: Dayjs;
|
||||
retainProcessed: number = 0;
|
||||
randInterval?: { clear: () => void };
|
||||
|
||||
constructor(options: PollConfiguration<T>) {
|
||||
constructor(options: RCBPollConfiguration<T>) {
|
||||
super(options);
|
||||
this.identifier = options.identifier;
|
||||
this.getter = options.get;
|
||||
this.frequency = options.frequency;
|
||||
const {
|
||||
after,
|
||||
size,
|
||||
retain = 0,
|
||||
} = options.clearProcessed || {};
|
||||
if(after !== undefined) {
|
||||
this.clearProcessedDuration = parseDuration(after);
|
||||
}
|
||||
this.clearProcessedSize = size;
|
||||
this.retainProcessed = retain;
|
||||
if (this.clearProcessedDuration !== undefined) {
|
||||
this.clearProcessedAfter = dayjs().add(this.clearProcessedDuration.asSeconds(), 's');
|
||||
}
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
startInterval = () => {
|
||||
this.running = true;
|
||||
this.interval = setInterval(async () => {
|
||||
try {
|
||||
const batch = await this.getter();
|
||||
const newItems: T[] = [];
|
||||
for (const item of batch) {
|
||||
const id = item[this.identifier];
|
||||
if (this.processed.has(id)) continue;
|
||||
this.randInterval = setRandomInterval((function (self) {
|
||||
return async () => {
|
||||
try {
|
||||
// clear the tracked, processed activity ids after a set period or number of activities have been processed
|
||||
// because when RCB is long-running and has streams from high-volume subreddits this list never gets smaller...
|
||||
|
||||
// Emit for new items and add it to the list
|
||||
newItems.push(item);
|
||||
this.processed.add(id);
|
||||
this.emit("item", item);
|
||||
// so clear if after time period
|
||||
if ((self.clearProcessedAfter !== undefined && dayjs().isSameOrAfter(self.clearProcessedAfter))
|
||||
// or clear if processed list is larger than defined max allowable size (default setting, 2 * polling option limit)
|
||||
|| (self.clearProcessedSize !== undefined && self.processed.size >= self.clearProcessedSize)) {
|
||||
if (self.retainProcessed === 0) {
|
||||
self.processed = new Set();
|
||||
} else {
|
||||
// retain some processed so we have continuity between processed list resets -- this is default behavior and retains polling option limit # of activities
|
||||
// we can slice from the set here because ID order is guaranteed for Set object so list is oldest -> newest
|
||||
// -- retain last LIMIT number of activities (or all if retain # is larger than list due to user config error)
|
||||
self.processed = new Set(Array.from(self.processed).slice(Math.max(0, self.processed.size - self.retainProcessed)));
|
||||
}
|
||||
// reset time interval if there is one
|
||||
if (self.clearProcessedAfter !== undefined && self.clearProcessedDuration !== undefined) {
|
||||
self.clearProcessedAfter = dayjs().add(self.clearProcessedDuration.asSeconds(), 's');
|
||||
}
|
||||
}
|
||||
const batch = await self.getter();
|
||||
const newItems: T[] = [];
|
||||
for (const item of batch) {
|
||||
const id = item[self.identifier];
|
||||
if (self.processed.has(id)) continue;
|
||||
|
||||
// Emit for new items and add it to the list
|
||||
newItems.push(item);
|
||||
self.processed.add(id);
|
||||
self.emit("item", item);
|
||||
}
|
||||
|
||||
// Emit the new listing of all new items
|
||||
self.emit("listing", newItems);
|
||||
} catch (err) {
|
||||
self.emit('error', err);
|
||||
self.end();
|
||||
}
|
||||
|
||||
// Emit the new listing of all new items
|
||||
this.emit("listing", newItems);
|
||||
} catch (err) {
|
||||
this.emit('error', err);
|
||||
this.end();
|
||||
}
|
||||
}, this.frequency);
|
||||
})(this), this.frequency - 1, this.frequency + 1);
|
||||
}
|
||||
|
||||
end = () => {
|
||||
this.running = false;
|
||||
if(this.randInterval !== undefined) {
|
||||
this.randInterval.clear();
|
||||
}
|
||||
super.end();
|
||||
}
|
||||
}
|
||||
@@ -54,11 +110,12 @@ export class SPoll<T extends object> extends Poll<T> {
|
||||
export class UnmoderatedStream extends SPoll<Snoowrap.Submission | Snoowrap.Comment> {
|
||||
constructor(
|
||||
client: Snoowrap,
|
||||
options: SnooStormOptions & { subreddit: string }) {
|
||||
options: RCBPollingOptions) {
|
||||
super({
|
||||
frequency: options.pollTime || DEFAULT_POLLING_INTERVAL * 1000,
|
||||
get: async () => client.getSubreddit(options.subreddit).getUnmoderated(options),
|
||||
identifier: "id",
|
||||
clearProcessed: options.clearProcessed
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -66,11 +123,12 @@ export class UnmoderatedStream extends SPoll<Snoowrap.Submission | Snoowrap.Comm
|
||||
export class ModQueueStream extends SPoll<Snoowrap.Submission | Snoowrap.Comment> {
|
||||
constructor(
|
||||
client: Snoowrap,
|
||||
options: SnooStormOptions & { subreddit: string }) {
|
||||
options: RCBPollingOptions) {
|
||||
super({
|
||||
frequency: options.pollTime || DEFAULT_POLLING_INTERVAL * 1000,
|
||||
get: async () => client.getSubreddit(options.subreddit).getModqueue(options),
|
||||
identifier: "id",
|
||||
clearProcessed: options.clearProcessed
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -78,11 +136,12 @@ export class ModQueueStream extends SPoll<Snoowrap.Submission | Snoowrap.Comment
|
||||
export class SubmissionStream extends SPoll<Snoowrap.Submission | Snoowrap.Comment> {
|
||||
constructor(
|
||||
client: Snoowrap,
|
||||
options: SnooStormOptions & { subreddit: string }) {
|
||||
options: RCBPollingOptions) {
|
||||
super({
|
||||
frequency: options.pollTime || DEFAULT_POLLING_INTERVAL * 1000,
|
||||
get: async () => client.getNew(options.subreddit, options),
|
||||
identifier: "id",
|
||||
clearProcessed: options.clearProcessed
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -90,11 +149,12 @@ export class SubmissionStream extends SPoll<Snoowrap.Submission | Snoowrap.Comme
|
||||
export class CommentStream extends SPoll<Snoowrap.Submission | Snoowrap.Comment> {
|
||||
constructor(
|
||||
client: Snoowrap,
|
||||
options: SnooStormOptions & { subreddit: string }) {
|
||||
options: RCBPollingOptions) {
|
||||
super({
|
||||
frequency: options.pollTime || DEFAULT_POLLING_INTERVAL * 1000,
|
||||
get: async () => client.getNewComments(options.subreddit, options),
|
||||
identifier: "id",
|
||||
clearProcessed: options.clearProcessed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Snoowrap, {RedditUser, Subreddit} from "snoowrap";
|
||||
import Snoowrap, {RedditUser} from "snoowrap";
|
||||
import objectHash from 'object-hash';
|
||||
import {
|
||||
activityIsDeleted, activityIsFiltered,
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
testAuthorCriteria
|
||||
} from "../Utils/SnoowrapUtils";
|
||||
import winston, {Logger} from "winston";
|
||||
import as from 'async';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
asSubmission,
|
||||
buildCacheOptionsFromProvider, buildCachePrefix,
|
||||
cacheStats, comparisonTextOp, createCacheManager, createHistoricalStatsDisplay,
|
||||
cacheStats, compareDurationValue, comparisonTextOp, createCacheManager, createHistoricalStatsDisplay,
|
||||
formatNumber, getActivityAuthorName, getActivitySubredditName, isStrongSubredditState,
|
||||
mergeArr,
|
||||
mergeArr, parseDurationComparison,
|
||||
parseExternalUrl, parseGenericValueComparison,
|
||||
parseWikiContext, toStrongSubredditState
|
||||
parseWikiContext, shouldCacheSubredditStateCriteriaResult, subredditStateIsNameOnly, toStrongSubredditState
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
@@ -36,7 +37,10 @@ import {
|
||||
ActionedEvent,
|
||||
SubredditState,
|
||||
StrongSubredditState,
|
||||
HistoricalStats, HistoricalStatUpdateData, SubredditHistoricalStats, SubredditHistoricalStatsDisplay
|
||||
HistoricalStats,
|
||||
HistoricalStatUpdateData,
|
||||
SubredditHistoricalStats,
|
||||
SubredditHistoricalStatsDisplay,
|
||||
} from "../Common/interfaces";
|
||||
import UserNotes from "./UserNotes";
|
||||
import Mustache from "mustache";
|
||||
@@ -44,9 +48,12 @@ import he from "he";
|
||||
import {AuthorCriteria} from "../Author/Author";
|
||||
import {SPoll} from "./Streams";
|
||||
import {Cache} from 'cache-manager';
|
||||
import {Submission, Comment} from "snoowrap/dist/objects";
|
||||
import {Submission, Comment, Subreddit} from "snoowrap/dist/objects";
|
||||
import {cacheTTLDefaults, createHistoricalDefaults, historicalDefaults} from "../Common/defaults";
|
||||
import {check} from "tcp-port-used";
|
||||
import {ExtendedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import dayjs from "dayjs";
|
||||
import ImageData from "../Common/ImageData";
|
||||
|
||||
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
|
||||
|
||||
@@ -54,7 +61,7 @@ export interface SubredditResourceConfig extends Footer {
|
||||
caching?: CacheConfig,
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: Snoowrap
|
||||
client: ExtendedSnoowrap
|
||||
}
|
||||
|
||||
interface SubredditResourceOptions extends Footer {
|
||||
@@ -64,7 +71,7 @@ interface SubredditResourceOptions extends Footer {
|
||||
cacheSettingsHash: string
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: Snoowrap;
|
||||
client: ExtendedSnoowrap;
|
||||
prefix?: string;
|
||||
actionedEventsMax: number;
|
||||
}
|
||||
@@ -86,7 +93,7 @@ export class SubredditResources {
|
||||
userNotes: UserNotes;
|
||||
footer: false | string = DEFAULT_FOOTER;
|
||||
subreddit: Subreddit
|
||||
client: Snoowrap
|
||||
client: ExtendedSnoowrap
|
||||
cache: Cache
|
||||
cacheType: string
|
||||
cacheSettingsHash?: string;
|
||||
@@ -260,7 +267,8 @@ export class SubredditResources {
|
||||
async getCacheKeyCount() {
|
||||
if (this.cache.store.keys !== undefined) {
|
||||
if(this.cacheType === 'redis') {
|
||||
return (await this.cache.store.keys(`${this.prefix}*`)).length;
|
||||
const keys = await this.cache.store.keys(`${this.prefix}*`);
|
||||
return keys.length;
|
||||
}
|
||||
return (await this.cache.store.keys()).length;
|
||||
}
|
||||
@@ -382,19 +390,20 @@ export class SubredditResources {
|
||||
async getSubreddit(item: Submission | Comment) {
|
||||
try {
|
||||
let hash = '';
|
||||
const subName = getActivitySubredditName(item);
|
||||
if (this.subredditTTL !== false) {
|
||||
hash = `sub-${getActivitySubredditName(item)}`;
|
||||
hash = `sub-${subName}`;
|
||||
await this.stats.cache.subreddit.identifierRequestCount.set(hash, (await this.stats.cache.subreddit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
this.stats.cache.subreddit.requestTimestamps.push(Date.now());
|
||||
this.stats.cache.subreddit.requests++;
|
||||
const cachedSubreddit = await this.cache.get(hash);
|
||||
if (cachedSubreddit !== undefined && cachedSubreddit !== null) {
|
||||
this.logger.debug(`Cache Hit: Subreddit ${item.subreddit.display_name}`);
|
||||
this.logger.debug(`Cache Hit: Subreddit ${subName}`);
|
||||
// @ts-ignore
|
||||
return cachedSubreddit as Subreddit;
|
||||
}
|
||||
// @ts-ignore
|
||||
const subreddit = await this.client.getSubreddit(getActivitySubredditName(item)).fetch() as Subreddit;
|
||||
const subreddit = await this.client.getSubreddit(subName).fetch() as Subreddit;
|
||||
this.stats.cache.subreddit.miss++;
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, subreddit, {ttl: this.subredditTTL});
|
||||
@@ -402,7 +411,7 @@ export class SubredditResources {
|
||||
return subreddit as Subreddit;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
let subreddit = await this.client.getSubreddit(getActivitySubredditName(item));
|
||||
let subreddit = await this.client.getSubreddit(subName);
|
||||
|
||||
return subreddit as Subreddit;
|
||||
}
|
||||
@@ -412,6 +421,21 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
async hasSubreddit(name: string) {
|
||||
if (this.subredditTTL !== false) {
|
||||
const hash = `sub-${name}`;
|
||||
this.stats.cache.subreddit.requests++
|
||||
this.stats.cache.subreddit.requestTimestamps.push(Date.now());
|
||||
await this.stats.cache.subreddit.identifierRequestCount.set(hash, (await this.stats.cache.subreddit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
const val = await this.cache.get(hash);
|
||||
if(val === undefined || val === null) {
|
||||
this.stats.cache.subreddit.miss++;
|
||||
}
|
||||
return val !== undefined && val !== null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
|
||||
const userName = getActivityAuthorName(user);
|
||||
if (this.authorTTL !== false) {
|
||||
@@ -533,6 +557,67 @@ export class SubredditResources {
|
||||
return wikiContent;
|
||||
}
|
||||
|
||||
async cacheSubreddits(subs: (Subreddit | string)[]) {
|
||||
const allSubs = subs.map(x => typeof x !== 'string' ? x.display_name : x);
|
||||
const subNames = [...new Set(allSubs)];
|
||||
const uncachedSubs = [];
|
||||
|
||||
for(const s of subNames) {
|
||||
if(!(await this.hasSubreddit(s))) {
|
||||
uncachedSubs.push(s);
|
||||
}
|
||||
}
|
||||
if(uncachedSubs.length > 0) {
|
||||
// cache all uncached subs batchly-like
|
||||
const subResults = await this.client.getManySubreddits(uncachedSubs);
|
||||
for(const s of subResults) {
|
||||
// @ts-ignore
|
||||
await this.cache.set(`sub-${s.display_name}`, s, {ttl: this.subredditTTL});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async batchTestSubredditCriteria(items: (Comment | Submission)[], states: (SubredditState | StrongSubredditState)[]): Promise<(Comment | Submission)[]> {
|
||||
let passedItems: (Comment | Submission)[] = [];
|
||||
let unpassedItems: (Comment | Submission)[] = [];
|
||||
|
||||
const {nameOnly = [], full = []} = states.reduce((acc: {nameOnly: (SubredditState | StrongSubredditState)[], full: (SubredditState | StrongSubredditState)[]}, curr) => {
|
||||
if(subredditStateIsNameOnly(curr)) {
|
||||
return {...acc, nameOnly: acc.nameOnly.concat(curr)};
|
||||
}
|
||||
return {...acc, full: acc.full.concat(curr)};
|
||||
}, {nameOnly: [], full: []});
|
||||
|
||||
if(nameOnly.length === 0) {
|
||||
unpassedItems = items;
|
||||
} else {
|
||||
for(const item of items) {
|
||||
const subName = getActivitySubredditName(item);
|
||||
for(const state of nameOnly) {
|
||||
if(await this.isSubreddit({display_name: subName} as Subreddit, state, this.logger)) {
|
||||
passedItems.push(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
unpassedItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if(unpassedItems.length > 0 && full.length > 0) {
|
||||
await this.cacheSubreddits(unpassedItems.map(x => x.subreddit));
|
||||
for(const item of unpassedItems) {
|
||||
for(const state of full) {
|
||||
if(await this.isSubreddit(await this.getSubreddit(item), state, this.logger)) {
|
||||
passedItems.push(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return passedItems;
|
||||
}
|
||||
|
||||
async testSubredditCriteria(item: (Comment | Submission), state: SubredditState | StrongSubredditState) {
|
||||
if(Object.keys(state).length === 0) {
|
||||
return true;
|
||||
@@ -547,7 +632,8 @@ export class SubredditResources {
|
||||
return await this.isSubreddit({display_name: subName} as Subreddit, state, this.logger);
|
||||
}
|
||||
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
// see comments on shouldCacheSubredditStateCriteriaResult() for why this is needed
|
||||
if (this.filterCriteriaTTL !== false && shouldCacheSubredditStateCriteriaResult(state)) {
|
||||
try {
|
||||
const hash = `subredditCrit-${getActivitySubredditName(item)}-${objectHash.sha1(state)}`;
|
||||
await this.stats.cache.subredditCrit.identifierRequestCount.set(hash, (await this.stats.cache.subredditCrit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
@@ -603,6 +689,10 @@ export class SubredditResources {
|
||||
}
|
||||
|
||||
async testItemCriteria(i: (Comment | Submission), activityStates: TypedActivityStates) {
|
||||
// return early if nothing is being checked for so we don't store an empty cache result for this (duh)
|
||||
if(activityStates.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
let item = i;
|
||||
let states = activityStates;
|
||||
@@ -661,9 +751,20 @@ export class SubredditResources {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'over18':
|
||||
case 'over_18':
|
||||
// handling an edge case where user may have confused Comment/Submission state "over_18" with SubredditState "over18"
|
||||
|
||||
// @ts-ignore
|
||||
if (crit[k] !== subreddit.over18) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${subreddit.over18}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// @ts-ignore
|
||||
if (crit[k] !== undefined) {
|
||||
if (subreddit[k] !== undefined) {
|
||||
// @ts-ignore
|
||||
if (crit[k] !== subreddit[k]) {
|
||||
// @ts-ignore
|
||||
@@ -720,6 +821,10 @@ export class SubredditResources {
|
||||
}
|
||||
break;
|
||||
case 'reports':
|
||||
if (!item.can_mod_post) {
|
||||
log.debug(`Cannot test for reports on Activity in a subreddit bot account is not a moderato Activist. Skipping criteria...`);
|
||||
break;
|
||||
}
|
||||
const reportCompare = parseGenericValueComparison(crit[k] as string);
|
||||
if(!comparisonTextOp(item.num_reports, reportCompare.operator, reportCompare.value)) {
|
||||
// @ts-ignore
|
||||
@@ -744,6 +849,10 @@ export class SubredditResources {
|
||||
}
|
||||
break;
|
||||
case 'filtered':
|
||||
if (!item.can_mod_post) {
|
||||
log.debug(`Cannot test for 'filtered' state on Activity in a subreddit bot account is not a moderator for. Skipping criteria...`);
|
||||
break;
|
||||
}
|
||||
const filtered = activityIsFiltered(item);
|
||||
if (filtered !== crit['filtered']) {
|
||||
// @ts-ignore
|
||||
@@ -751,6 +860,13 @@ export class SubredditResources {
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'age':
|
||||
const ageTest = compareDurationValue(parseDurationComparison(crit[k] as string), dayjs.unix(await item.created));
|
||||
if (!ageTest) {
|
||||
log.debug(`Failed: Activity did not pass age test "${crit[k] as string}"`);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'title':
|
||||
if((item instanceof Comment)) {
|
||||
log.warn('`title` is not allowed in `itemIs` criteria when the main Activity is a Comment');
|
||||
@@ -759,7 +875,7 @@ export class SubredditResources {
|
||||
// @ts-ignore
|
||||
const titleReg = crit[k] as string;
|
||||
try {
|
||||
if(null === item.title.match(titleReg)) {
|
||||
if (null === item.title.match(titleReg)) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed to match title as regular expression: ${titleReg}`);
|
||||
return false;
|
||||
@@ -769,6 +885,31 @@ export class SubredditResources {
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'approved':
|
||||
case 'spam':
|
||||
if(!item.can_mod_post) {
|
||||
log.debug(`Cannot test for '${k}' state on Activity in a subreddit bot account is not a moderator for. Skipping criteria...`);
|
||||
break;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (item[k] !== crit[k]) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item[k]}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'op':
|
||||
if(item instanceof Submission) {
|
||||
log.warn(`On a Submission the 'op' property will always be true. Did you mean to use this on a comment instead?`);
|
||||
break;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (item.is_submitter !== crit[k]) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item[k]}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// @ts-ignore
|
||||
if (item[k] !== undefined) {
|
||||
@@ -779,7 +920,11 @@ export class SubredditResources {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
log.warn(`Tried to test for Item property '${k}' but it did not exist`);
|
||||
if(!item.can_mod_post) {
|
||||
log.warn(`Tried to test for Activity property '${k}' but it did not exist. This Activity is not in a subreddit the bot can mod so it may be that this property is only available to mods of that subreddit. Or the property may be misspelled.`);
|
||||
} else {
|
||||
log.warn(`Tried to test for Activity property '${k}' but it did not exist. Check the spelling of the property.`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -799,6 +944,8 @@ export class SubredditResources {
|
||||
const userName = getActivityAuthorName(item.author);
|
||||
const hash = `commentUserResult-${userName}-${item.link_id}-${objectHash.sha1(checkConfig)}`;
|
||||
this.stats.cache.commentCheck.requests++;
|
||||
this.stats.cache.commentCheck.requestTimestamps.push(Date.now());
|
||||
await this.stats.cache.commentCheck.identifierRequestCount.set(hash, (await this.stats.cache.commentCheck.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
let result = await this.cache.get(hash) as UserResultCache | undefined | null;
|
||||
if(result === null) {
|
||||
result = undefined;
|
||||
@@ -829,6 +976,33 @@ export class SubredditResources {
|
||||
const footerRawContent = await this.getContent(footer, item.subreddit);
|
||||
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
|
||||
}
|
||||
|
||||
async getImageHash(img: ImageData): Promise<string|undefined> {
|
||||
const hash = `imgHash-${img.baseUrl}`;
|
||||
const result = await this.cache.get(hash) as string | undefined | null;
|
||||
this.stats.cache.imageHash.requests++
|
||||
this.stats.cache.imageHash.requestTimestamps.push(Date.now());
|
||||
await this.stats.cache.imageHash.identifierRequestCount.set(hash, (await this.stats.cache.imageHash.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
if(result !== undefined && result !== null) {
|
||||
return result;
|
||||
}
|
||||
this.stats.cache.commentCheck.miss++;
|
||||
return undefined;
|
||||
// const hash = await this.cache.wrap(img.baseUrl, async () => await img.hash(true), { ttl }) as string;
|
||||
// if(img.hashResult === undefined) {
|
||||
// img.hashResult = hash;
|
||||
// }
|
||||
// return hash;
|
||||
}
|
||||
|
||||
async setImageHash(img: ImageData, hash: string, ttl: number): Promise<void> {
|
||||
await this.cache.set(`imgHash-${img.baseUrl}`, hash, {ttl});
|
||||
// const hash = await this.cache.wrap(img.baseUrl, async () => await img.hash(true), { ttl }) as string;
|
||||
// if(img.hashResult === undefined) {
|
||||
// img.hashResult = hash;
|
||||
// }
|
||||
// return hash;
|
||||
}
|
||||
}
|
||||
|
||||
export class BotResourcesManager {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Snoowrap from "snoowrap";
|
||||
import Snoowrap, {Listing} from "snoowrap";
|
||||
import {Subreddit} from "snoowrap/dist/objects";
|
||||
import {parseSubredditName} from "../util";
|
||||
|
||||
// const proxyFactory = (endpoint: string) => {
|
||||
// return class ProxiedSnoowrap extends Snoowrap {
|
||||
@@ -12,7 +14,28 @@ import Snoowrap from "snoowrap";
|
||||
// }
|
||||
// }
|
||||
|
||||
export class RequestTrackingSnoowrap extends Snoowrap {
|
||||
export class ExtendedSnoowrap extends Snoowrap {
|
||||
/**
|
||||
* https://www.reddit.com/r/redditdev/comments/jfltfx/comment/g9le48w/?utm_source=reddit&utm_medium=web2x&context=3
|
||||
* */
|
||||
async getManySubreddits(subs: (Subreddit | string)[]): Promise<Listing<Subreddit>> {
|
||||
// parse all names
|
||||
const names = subs.map(x => {
|
||||
if(typeof x !== 'string') {
|
||||
return x.display_name;
|
||||
}
|
||||
try {
|
||||
return parseSubredditName(x);
|
||||
} catch (err) {
|
||||
return x;
|
||||
}
|
||||
});
|
||||
|
||||
return await this.oauthRequest({uri: '/api/info', method: 'get', qs: { sr_name: names.join(',')}}) as Listing<Subreddit>;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestTrackingSnoowrap extends ExtendedSnoowrap {
|
||||
requestCount: number = 0;
|
||||
|
||||
oauthRequest(...args: any) {
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
} from "../Common/interfaces";
|
||||
import {
|
||||
compareDurationValue,
|
||||
comparisonTextOp, getActivityAuthorName,
|
||||
comparisonTextOp, escapeRegex, getActivityAuthorName,
|
||||
isActivityWindowCriteria, isStatusError,
|
||||
normalizeName,
|
||||
parseDuration,
|
||||
parseDurationComparison,
|
||||
parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison,
|
||||
parseRuleResultsToMarkdownSummary,
|
||||
parseRuleResultsToMarkdownSummary, parseStringToRegex,
|
||||
parseSubredditName,
|
||||
truncateStringToLength
|
||||
} from "../util";
|
||||
@@ -377,7 +377,7 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
|
||||
// @ts-ignore
|
||||
for (const c of authorOpts[k]) {
|
||||
if (c === css) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -393,7 +393,7 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
|
||||
// @ts-ignore
|
||||
for (const c of authorOpts[k]) {
|
||||
if (c === text) {
|
||||
return
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -463,6 +463,28 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'description':
|
||||
// @ts-ignore
|
||||
const desc = await item.author.subreddit?.display_name.public_description;
|
||||
const dVals = authorOpts[k] as string[];
|
||||
let passed = false;
|
||||
for(const val of dVals) {
|
||||
let reg = parseStringToRegex(val, 'i');
|
||||
if(reg === undefined) {
|
||||
reg = parseStringToRegex(`/.*${escapeRegex(val.trim())}.*/`, 'i');
|
||||
if(reg === undefined) {
|
||||
throw new SimpleError(`Could not convert 'description' value to a valid regex: ${authorOpts[k] as string}`);
|
||||
}
|
||||
}
|
||||
if(reg.test(desc)) {
|
||||
passed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!passed) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'userNotes':
|
||||
const notes = await userNotes.getUserNotes(item.author);
|
||||
const notePass = () => {
|
||||
@@ -661,13 +683,21 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
|
||||
}
|
||||
|
||||
export const activityIsRemoved = (item: Submission | Comment): boolean => {
|
||||
if (item instanceof Submission) {
|
||||
// when automod filters a post it gets this category
|
||||
return item.banned_at_utc !== null && item.removed_by_category !== 'automod_filtered';
|
||||
if(item.can_mod_post) {
|
||||
if (item instanceof Submission) {
|
||||
// when automod filters a post it gets this category
|
||||
return item.banned_at_utc !== null && item.removed_by_category !== 'automod_filtered';
|
||||
}
|
||||
// when automod filters a comment item.removed === false
|
||||
// so if we want to processing filtered comments we need to check for this
|
||||
return item.banned_at_utc !== null && item.removed;
|
||||
} else {
|
||||
if (item instanceof Submission) {
|
||||
return item.removed_by_category === 'moderator' || item.removed_by_category === 'deleted';
|
||||
}
|
||||
// in subreddits the bot does not mod it is not possible to tell the difference between a comment that was removed by the user and one that was removed by a mod
|
||||
return item.body === '[removed]';
|
||||
}
|
||||
// when automod filters a comment item.removed === false
|
||||
// so if we want to processing filtered comments we need to check for this
|
||||
return item.banned_at_utc !== null && item.removed;
|
||||
}
|
||||
|
||||
export const activityIsFiltered = (item: Submission | Comment): boolean => {
|
||||
|
||||
@@ -4,7 +4,7 @@ a {
|
||||
|
||||
.loading {
|
||||
height: 35px;
|
||||
fill: black;
|
||||
fill: white;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ a {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.dark .loading {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: none;
|
||||
}
|
||||
@@ -91,3 +87,19 @@ a {
|
||||
pointer-events: initial;
|
||||
text-decoration: initial;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: rgb(97, 175, 239)
|
||||
}
|
||||
.red {
|
||||
color: rgb(255 123 133);
|
||||
}
|
||||
.green {
|
||||
color: rgb(170 255 109);
|
||||
}
|
||||
.purple {
|
||||
color: rgb(224 124 253);
|
||||
}
|
||||
.yellow {
|
||||
color: rgb(253 198 94);
|
||||
}
|
||||
|
||||
10
src/Web/assets/public/questionsymbol.svg
Normal file
10
src/Web/assets/public/questionsymbol.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<symbol id="q" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
1
src/Web/assets/public/tailwind.min.css
vendored
Normal file
1
src/Web/assets/public/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,12 +1,11 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Congrats! You did the thing.</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
|
||||
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="/public/tailwind.min.css"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
@@ -15,13 +10,12 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<!--icons from https://heroicons.com -->
|
||||
</head>
|
||||
<body style="user-select: none;" class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body style="user-select: none;" class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen bg-gray-800">
|
||||
<%- include('partials/title') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="dark:text-white mb-3 pl-2">
|
||||
<div class="my-3 pl-2">
|
||||
Schema <a href="/config?schema=subreddit" id="subredditSchemaType">Subreddit</a> / <a href="/config?schema=operator" id="operatorSchemaType">Operator</a> |
|
||||
<span class="has-tooltip">
|
||||
<span style="z-index:999; margin-top: 30px;" class='tooltip rounded shadow-lg p-3 bg-gray-100 text-black space-y-2'>
|
||||
@@ -63,35 +57,6 @@
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
document.querySelector("#themeToggle").onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/monaco/dev/vs/loader.js"></script>
|
||||
<script>
|
||||
require.config({ paths: { vs: 'monaco/dev/vs' } });
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: 'Error'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Oops 😬</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
|
||||
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="/public/tailwind.min.css"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
@@ -20,19 +15,19 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white px-3 py-6 space-y-3">
|
||||
<div class="px-3 py-6 space-y-3">
|
||||
<% if(data.length === 0) { %>
|
||||
No events have been actioned yet!
|
||||
<% } %>
|
||||
<% data.forEach(function (eRes){ %>
|
||||
<div class="shadow-lg">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="peek"><%- eRes.activity.peek %></span><a target="_blank" href="https://reddit.com<%= eRes.activity.link%>">(Link)</a>
|
||||
@@ -42,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 pl-6 pt-3 space-y-2">
|
||||
<div class="p-4 pl-6 pt-3 space-y-2 bg-gray-500">
|
||||
<div><span class="font-semibold">Check:</span> <%= eRes.check %><span class="px-3">➔</span><%= eRes.ruleSummary %></div>
|
||||
<div>
|
||||
<span class="font-semibold">Rules:</span>
|
||||
@@ -67,34 +62,5 @@
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
document.querySelector("#themeToggle").onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you're setting up your bot. To get running:</div>
|
||||
<div class="text-lg text-semibold my-3">1. Set your redirect URL</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div class="min-w-screen min-h-screen font-sans">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you're accepting an invite to authorize an account to run on this ContextMod instance:</div>
|
||||
<div class="text-lg text-semibold my-3">1. Review permissions</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'Access Denied'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: ''}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Sorry!</div>
|
||||
<div class="space-y-3">
|
||||
@@ -28,6 +27,5 @@
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<%- include('partials/themeJs') %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: undefined}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/botsTab') %>
|
||||
<div class="container mx-auto">
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="bg-gray-700">
|
||||
<div class="pb-6 md:px-7">
|
||||
<div class="sub active" data-subreddit="All" data-bot="All">
|
||||
Instance is currently <b>OFFLINE</b>
|
||||
@@ -31,7 +30,6 @@
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
<%- include('partials/instanceTabJs') %>
|
||||
<%- include('partials/themeJs') %>
|
||||
<%- include('partials/logSettingsJs') %>
|
||||
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="space-x-4 py-1 md:px-10 leading-6 font-semibold bg-gray-500 dark:bg-gray-700 text-white">
|
||||
<div class="space-x-4 py-1 md:px-10 leading-6 font-semibold bg-gray-700">
|
||||
<div class="container mx-auto">
|
||||
<% if(locals.bots !== undefined) { %>
|
||||
<ul id="botTabs" class="inline-flex flex-wrap">
|
||||
<% bots.forEach(function (data){ %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<li class="my-3 px-3">
|
||||
<span data-bot="<%= data.system.name %>" class="rounded-md py-2 px-3 tabSelectWrapper">
|
||||
<a class="tabSelect font-normal pointer hover:font-bold"
|
||||
data-bot="<%= data.system.name %>">
|
||||
@@ -16,7 +16,7 @@
|
||||
</li>
|
||||
<% }) %>
|
||||
<% if(locals.isOperator === true && locals.instanceId !== undefined) { %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<li class="my-3 px-3">
|
||||
<span class="rounded-md py-2 px-3 border">
|
||||
<a class="font-normal pointer hover:font-bold" href="/auth/helper">
|
||||
Add Bot +
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="py-3 flex items-center justify-around font-semibold text-white">
|
||||
<div class="py-3 flex items-center justify-around font-semibold">
|
||||
<div>
|
||||
<a href="https://github.com/FoxxMD/context-mod">ContextMod Web</a> created by /u/FoxxMD
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
|
||||
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="/public/tailwind.min.css"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-500 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<% if(locals.instances !== undefined) { %>
|
||||
<ul class="inline-flex flex-wrap">
|
||||
<% instances.forEach(function (data) { %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<li class="my-3 px-3">
|
||||
<span data-instance="<%= data.friendly %>" class="has-tooltip instanceSelectWrapper rounded-md py-3 px-3">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black' style="margin-top:3em">
|
||||
<div class="stats">
|
||||
@@ -34,20 +34,6 @@
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<span class="inline-block mr-4">
|
||||
<label style="font-size:2.5px;">
|
||||
<input class='toggle-checkbox' type='checkbox' id="themeToggle" checked></input>
|
||||
<div class='toggle-slot'>
|
||||
<div class='sun-icon-wrapper'>
|
||||
<div class="iconify sun-icon" data-icon="feather-sun" data-inline="false"></div>
|
||||
</div>
|
||||
<div class='toggle-button'></div>
|
||||
<div class='moon-icon-wrapper'>
|
||||
<div class="iconify moon-icon" data-icon="feather-moon" data-inline="false"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<a href="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="space-x-4 pt-2 md:px-5 leading-6 font-semibold bg-gray-white dark:bg-gray-500 text-white">
|
||||
<div class="space-x-4 pt-2 md:px-5 leading-6 font-semibold bg-gray-800">
|
||||
<div class="container mx-auto">
|
||||
<% if(locals.bots !== undefined) { %>
|
||||
<% bots.forEach(function (botData){ %>
|
||||
<ul data-bot="<%= botData.system.name %>" class="inline-flex flex-wrap subreddit nestedTabs">
|
||||
<% botData.subreddits.forEach(function (data){ %>
|
||||
<li class="my-3 pr-3 dark:text-white">
|
||||
<li class="my-3 pr-3">
|
||||
<span data-subreddit="<%= data.name %>" class="rounded-md py-2 px-3 tabSelectWrapper">
|
||||
<a class="tabSelect font-normal pointer hover:font-bold"
|
||||
data-subreddit="<%= data.name %>">
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
});
|
||||
|
||||
const themeToggle = document.querySelector("#themeToggle");
|
||||
if(themeToggle !== null) {
|
||||
themeToggle.checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
themeToggle.onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-500">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
@@ -7,20 +7,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<span class="inline-block mr-4">
|
||||
<label style="font-size:2.5px;">
|
||||
<input class='toggle-checkbox' type='checkbox' id="themeToggle" checked></input>
|
||||
<div class='toggle-slot'>
|
||||
<div class='sun-icon-wrapper'>
|
||||
<div class="iconify sun-icon" data-icon="feather-sun" data-inline="false"></div>
|
||||
</div>
|
||||
<div class='toggle-button'></div>
|
||||
<div class='moon-icon-wrapper'>
|
||||
<div class="iconify moon-icon" data-icon="feather-moon" data-inline="false"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<a href="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<html>
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: undefined}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div class="min-w-screen min-h-screen font-sans">
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/botsTab') %>
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="container mx-auto">
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="">
|
||||
<div class="pb-6 md:px-7">
|
||||
<% bots.forEach(function (bot){ %>
|
||||
<% bot.subreddits.forEach(function (data){ %>
|
||||
<div class="sub <%= bot.system.running ? '' : 'offline' %>" data-subreddit="<%= data.name %>" data-bot="<%= bot.system.name %>">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 gap-5">
|
||||
<div class="bg-white shadow-md rounded my-3 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 bg-gray-700 ">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4>Overview</h4>
|
||||
<% if (data.name === 'All') { %>
|
||||
@@ -63,10 +62,7 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
@@ -99,10 +95,7 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
@@ -136,10 +129,7 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
@@ -209,11 +199,8 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -235,8 +222,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<% if (data.name === 'All') { %>
|
||||
<div class="bg-white shadow-md rounded my-3 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 bg-gray-700 ">
|
||||
<h4>API</h4>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
@@ -250,9 +237,40 @@
|
||||
<label>Api Usage</label>
|
||||
<span><span id="apiLimit"><%= data.apiLimit %></span>/600 (~<span
|
||||
id="apiAvg"><%= data.apiAvg %></span>req/s)</span>
|
||||
<label>Depleted</label>
|
||||
<label>
|
||||
<span class="has-tooltip">
|
||||
<span style="margin-top:55px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>
|
||||
<p>Reddit restricts api usage, per account, to <b>600 requests every 10 minutes.</b></p>
|
||||
<p><b>Depleted</b> is the estimated time until all 600 requests are used based on <b>Api Usage.</b></p>
|
||||
<p>API usage is sustainable when <b>Depleted</b> is greater than <b>Limit Reset</b></p>
|
||||
</span>
|
||||
<span>
|
||||
Depleted <svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span>in ~<span id="apiDepletion"><%= data.apiDepletion %></span></span>
|
||||
<label>Limit Reset</label>
|
||||
<label>
|
||||
<span class="has-tooltip">
|
||||
<span style="margin-top:35px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>
|
||||
<p>Reddit restricts api usage, per account, to <b>600 requests every 10 minutes.</b></p>
|
||||
<p><b>Limit Reset</b> is the amount of time remaining in the current 10 minute period until the limit is reset to 600.</p>
|
||||
</span>
|
||||
<span>
|
||||
Limit Reset <svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="has-tooltip">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
|
||||
<span id="limitReset"><%= data.limitReset %></span>
|
||||
@@ -264,8 +282,8 @@
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<div class="bg-white shadow-md rounded my-3 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700 ">
|
||||
<h4>Config
|
||||
<span>
|
||||
<span class="has-tooltip">
|
||||
@@ -316,8 +334,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="bg-white shadow-md rounded my-3 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700 ">
|
||||
<h4>Usage</h4>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
@@ -440,10 +458,7 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
@@ -494,6 +509,16 @@
|
||||
<span>
|
||||
</span>
|
||||
|
||||
<label class="newRow">Subreddit Criteria</label>
|
||||
<span class="newRow"><%= data.stats.cache.types.subredditCrit.requests %> | <%= data.stats.cache.types.subredditCrit.miss %> (<%= data.stats.cache.types.subredditCrit.missPercent %>) miss</span>
|
||||
<span class="newRow">
|
||||
- <span class="font-mono">SubredditState</span> results
|
||||
</span>
|
||||
<label>Avgs</label>
|
||||
<span>Hits/Key <%= data.stats.cache.types.subredditCrit.identifierAverageHit %> | Hit Interval <%= data.stats.cache.types.subredditCrit.averageTimeBetweenHits %>s</span>
|
||||
<span>
|
||||
</span>
|
||||
|
||||
<label class="newRow">Comment Check</label>
|
||||
<span class="newRow"><%= data.stats.cache.types.commentCheck.requests %> | <%= data.stats.cache.types.commentCheck.miss %> (<%= data.stats.cache.types.commentCheck.missPercent %>) miss</span>
|
||||
<span class="newRow">
|
||||
@@ -520,6 +545,14 @@
|
||||
<span>
|
||||
</span>
|
||||
|
||||
<label class="newRow">Subreddits</label>
|
||||
<span class="newRow"><%= data.stats.cache.types.subreddit.requests %> | <%= data.stats.cache.types.subreddit.miss %> (<%= data.stats.cache.types.subreddit.missPercent %>) miss</span>
|
||||
<span class="newRow"></span>
|
||||
<label>Avgs</label>
|
||||
<span>Hits/Key <%= data.stats.cache.types.subreddit.identifierAverageHit %> | Hit Interval <%= data.stats.cache.types.subreddit.averageTimeBetweenHits %>s</span>
|
||||
<span>
|
||||
</span>
|
||||
|
||||
<label class="newRow">Content</label>
|
||||
<span class="newRow"><%= data.stats.cache.types.content.requests %> | <%= data.stats.cache.types.content.miss %> (<%= data.stats.cache.types.content.missPercent %>) miss</span>
|
||||
<span class="newRow">
|
||||
@@ -562,10 +595,7 @@
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
@@ -610,7 +640,6 @@
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<%- include('partials/instanceTabJs') %>
|
||||
<%- include('partials/themeJs') %>
|
||||
<%- include('partials/logSettingsJs') %>
|
||||
<script>
|
||||
window.sort = 'desc';
|
||||
|
||||
295
src/util.ts
295
src/util.ts
@@ -9,13 +9,30 @@ import {InvalidOptionArgumentError} from "commander";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {Comment} from "snoowrap";
|
||||
import {inflateSync, deflateSync} from "zlib";
|
||||
import sizeOf from 'image-size';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import os from 'os';
|
||||
import {
|
||||
ActivityWindowCriteria, CacheOptions, CacheProvider,
|
||||
ActivityWindowCriteria,
|
||||
CacheOptions,
|
||||
CacheProvider,
|
||||
DurationComparison,
|
||||
GenericComparison, HistoricalStats, HistoricalStatsDisplay, ImageData, ImageDetection, LogInfo, NamedGroup,
|
||||
PollingOptionsStrong, RedditEntity, RedditEntityType, RegExResult, ResembleResult, ResourceStats, StatusCodeError,
|
||||
StringOperator, StrongSubredditState, SubredditState
|
||||
GenericComparison,
|
||||
HistoricalStats,
|
||||
HistoricalStatsDisplay, ImageComparisonResult,
|
||||
//ImageData,
|
||||
ImageDetection,
|
||||
//ImageDownloadOptions,
|
||||
LogInfo,
|
||||
NamedGroup,
|
||||
PollingOptionsStrong,
|
||||
RedditEntity,
|
||||
RedditEntityType,
|
||||
RegExResult,
|
||||
ResourceStats,
|
||||
StatusCodeError,
|
||||
StringOperator,
|
||||
StrongSubredditState,
|
||||
SubredditState
|
||||
} from "./Common/interfaces";
|
||||
import JSON5 from "json5";
|
||||
import yaml, {JSON_SCHEMA} from "js-yaml";
|
||||
@@ -32,6 +49,20 @@ import {MESSAGE} from "triple-beam";
|
||||
import {RedditUser} from "snoowrap/dist/objects";
|
||||
import reRegExp from '@stdlib/regexp-regexp';
|
||||
import fetch, {Response} from "node-fetch";
|
||||
import { URL } from "url";
|
||||
import ImageData from "./Common/ImageData";
|
||||
import {Sharp, SharpOptions} from "sharp";
|
||||
// @ts-ignore
|
||||
import {blockhashData, hammingDistance} from 'blockhash';
|
||||
//import {ResembleSingleCallbackComparisonResult} from "resemblejs";
|
||||
|
||||
// want to guess how many concurrent image comparisons we should be doing
|
||||
// assuming, conservatively and based on real-world results, that comparing 30 images takes about ~30MB memory...
|
||||
// and we really want to use less than a fourth of available ram (should be low-footprint!)...
|
||||
// and base-line operation of RCB is usually around 40MB (liberal)
|
||||
const availMemory = (os.freemem() / (1024 * 1024)) / 4 + 40;
|
||||
export const imageCompareMaxConcurrencyGuess = Math.min(3, Math.max(Math.floor(availMemory/30), 1));
|
||||
|
||||
|
||||
const ReReg = reRegExp();
|
||||
|
||||
@@ -93,6 +124,7 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
leaf,
|
||||
itemId,
|
||||
timestamp,
|
||||
durationMs,
|
||||
// @ts-ignore
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
@@ -122,7 +154,7 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
}
|
||||
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
|
||||
|
||||
return `${timestamp} ${level.padEnd(7)}: ${instance !== undefined ? `|${instance}| ` : ''}${bot !== undefined ? `~${bot}~ ` : ''}${subreddit !== undefined ? `{${subreddit}} ` : ''}${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
return `${timestamp} ${level.padEnd(7)}: ${instance !== undefined ? `|${instance}| ` : ''}${bot !== undefined ? `~${bot}~ ` : ''}${subreddit !== undefined ? `{${subreddit}} ` : ''}${labelContent} ${msg}${durationMs !== undefined ? ` Elapsed: ${durationMs}ms (${formatNumber(durationMs/1000)}s) ` : ''}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
|
||||
@@ -724,11 +756,11 @@ export const formatLogLineToHtml = (log: string | LogInfo) => {
|
||||
stripPrefix: false,
|
||||
sanitizeHtml: true,
|
||||
})
|
||||
.replace(/(\s*debug\s*):/i, '<span class="debug text-pink-400">$1</span>:')
|
||||
.replace(/(\s*warn\s*):/i, '<span class="warn text-yellow-400">$1</span>:')
|
||||
.replace(/(\s*info\s*):/i, '<span class="info text-blue-300">$1</span>:')
|
||||
.replace(/(\s*error\s*):/i, '<span class="error text-red-400">$1</span>:')
|
||||
.replace(/(\s*verbose\s*):/i, '<span class="error text-purple-400">$1</span>:')
|
||||
.replace(/(\s*debug\s*):/i, '<span class="debug blue">$1</span>:')
|
||||
.replace(/(\s*warn\s*):/i, '<span class="warn yellow">$1</span>:')
|
||||
.replace(/(\s*info\s*):/i, '<span class="info green">$1</span>:')
|
||||
.replace(/(\s*error\s*):/i, '<span class="error red">$1</span>:')
|
||||
.replace(/(\s*verbose\s*):/i, '<span class="error purple">$1</span>:')
|
||||
.replaceAll('\n', '<br />');
|
||||
//.replace(HYPERLINK_REGEX, '<a target="_blank" href="$&">$&</a>');
|
||||
return `<div class="logLine">${logContent}</div>`
|
||||
@@ -904,17 +936,25 @@ export interface StrongSubredditStateOptions {
|
||||
|
||||
export const toStrongSubredditState = (s: SubredditState, opts?: StrongSubredditStateOptions): StrongSubredditState => {
|
||||
const {defaultFlags, generateDescription = false} = opts || {};
|
||||
const {name: nameVal, stateDescription} = s;
|
||||
const {name: nameValRaw, stateDescription} = s;
|
||||
|
||||
let nameReg: RegExp | undefined;
|
||||
if (nameVal !== undefined) {
|
||||
if (!(nameVal instanceof RegExp)) {
|
||||
if (nameValRaw !== undefined) {
|
||||
if (!(nameValRaw instanceof RegExp)) {
|
||||
let nameVal = nameValRaw.trim();
|
||||
nameReg = parseStringToRegex(nameVal, defaultFlags);
|
||||
if (nameReg === undefined) {
|
||||
nameReg = new RegExp(parseSubredditName(nameVal), defaultFlags);
|
||||
try {
|
||||
const parsedVal = parseSubredditName(nameVal);
|
||||
nameVal = parsedVal;
|
||||
} catch (err) {
|
||||
// oh well
|
||||
const f = 1;
|
||||
}
|
||||
nameReg = parseStringToRegex(`/^${nameVal}$/`, defaultFlags);
|
||||
}
|
||||
} else {
|
||||
nameReg = nameVal;
|
||||
nameReg = nameValRaw;
|
||||
}
|
||||
}
|
||||
const strongState = {
|
||||
@@ -929,6 +969,13 @@ export const toStrongSubredditState = (s: SubredditState, opts?: StrongSubreddit
|
||||
return strongState;
|
||||
}
|
||||
|
||||
export const convertSubredditsRawToStrong = (x: (SubredditState | string), opts: StrongSubredditStateOptions): StrongSubredditState => {
|
||||
if (typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, opts);
|
||||
}
|
||||
return toStrongSubredditState(x, opts);
|
||||
}
|
||||
|
||||
export async function readConfigFile(path: string, opts: any) {
|
||||
const {log, throwOnNotFound = true} = opts;
|
||||
try {
|
||||
@@ -1011,7 +1058,8 @@ export const cacheStats = (): ResourceStats => {
|
||||
submission: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
comment: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
subreddit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
commentCheck: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0}
|
||||
commentCheck: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
imageHash: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1162,36 +1210,23 @@ export const parseRuleResultsToMarkdownSummary = (ruleResults: RuleResult[]): st
|
||||
}
|
||||
|
||||
export const isValidImageURL = (str: string): boolean => {
|
||||
return !!str.match(/\w+\.(jpg|jpeg|gif|png|tiff|bmp)$/gi);
|
||||
}
|
||||
|
||||
export const getImageDataFromUrl = async (url: string, aggressive = false): Promise<[Response?, ImageData?, string?]> => {
|
||||
if (!aggressive && !isValidImageURL(url)) {
|
||||
return [undefined, undefined, 'URL did not end with a valid image extension'];
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const ct = response.headers.get('Content-Type');
|
||||
if (ct !== null && ct.includes('image')) {
|
||||
let buffer = await response.buffer();
|
||||
const dimensions = sizeOf(buffer);
|
||||
return [response, {
|
||||
data: buffer,
|
||||
width: dimensions.width as number - 5,
|
||||
height: dimensions.height as number - 5,
|
||||
pixels: (dimensions.height as number - 5) * (dimensions.width as number - 5)
|
||||
}];
|
||||
}
|
||||
return [response, undefined, 'Content-Type for fetched URL did not contain "image"'];
|
||||
}
|
||||
return [response, undefined, `URL response was not OK: (${response.status})${response.statusText}`];
|
||||
} catch (err) {
|
||||
return [undefined, undefined, `Error occurred while fetching response from URL: ${err.message}`];
|
||||
}
|
||||
return !!str.match(/\w+\.(jpg|jpeg|gif|png|tiff|bmp|webp)$/gi);
|
||||
}
|
||||
|
||||
let resembleCIFunc: Function;
|
||||
type SharpCreate = (input?:
|
||||
| Buffer
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray
|
||||
| Int8Array
|
||||
| Uint16Array
|
||||
| Int16Array
|
||||
| Uint32Array
|
||||
| Int32Array
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
| string, options?: SharpOptions) => Sharp;
|
||||
let sharpImg: SharpCreate;
|
||||
|
||||
const getCIFunc = async () => {
|
||||
if (resembleCIFunc === undefined) {
|
||||
@@ -1205,23 +1240,142 @@ const getCIFunc = async () => {
|
||||
return resembleCIFunc;
|
||||
}
|
||||
|
||||
export const compareImages = async (data1: ImageData, data2: ImageData, threshold?: number): Promise<[ResembleResult, boolean?]> => {
|
||||
let ci: Function;
|
||||
export const getSharpAsync = async (): Promise<SharpCreate> => {
|
||||
if (sharpImg === undefined) {
|
||||
const sharpModule = await import('sharp');
|
||||
if (sharpModule === undefined) {
|
||||
throw new Error('Could not import sharp');
|
||||
}
|
||||
// @ts-ignore
|
||||
sharpImg = sharpModule.default;
|
||||
}
|
||||
return sharpImg;
|
||||
}
|
||||
|
||||
export const compareImages = async (data1: ImageData, data2: ImageData, threshold: number, variantDimensionDiff = 0): Promise<[ImageComparisonResult, boolean, string[]]> => {
|
||||
let results: ImageComparisonResult | undefined;
|
||||
const errors: string[] = [];
|
||||
|
||||
results = await pixelImageCompare(data1, data2);
|
||||
|
||||
// may decide to bring resemble back at some point in the future if pixelmatch has issues
|
||||
// but for now...
|
||||
// sharp is a *much* more useful utility and i'd rather have it as a dependency than node-canvas
|
||||
// it's much faster, uses less memory, and its libraries more likely to already be available on a host
|
||||
// -- with it i can control how images are normalized for dimensions which is basically what resemble was doing anyway (using canvas)
|
||||
|
||||
// try {
|
||||
// results = await pixelImageCompare(data1, data2);
|
||||
// } catch (err) {
|
||||
// if(!(err instanceof SimpleError)) {
|
||||
// errors.push(err.message);
|
||||
// }
|
||||
// // swallow this and continue with resemble
|
||||
// }
|
||||
// if (results === undefined) {
|
||||
// results = await resembleImageCompare(data1, data2, threshold, variantDimensionDiff);
|
||||
// }
|
||||
|
||||
|
||||
return [results, results.misMatchPercentage < threshold, errors];
|
||||
}
|
||||
|
||||
export const pixelImageCompare = async (data1: ImageData, data2: ImageData): Promise<ImageComparisonResult> => {
|
||||
|
||||
let pixelDiff: number | undefined = undefined;
|
||||
|
||||
let sharpFunc: SharpCreate;
|
||||
|
||||
try {
|
||||
ci = await getCIFunc();
|
||||
sharpFunc = await getSharpAsync();
|
||||
} catch (err) {
|
||||
err.message = `Unable to do image comparison due to an issue importing the comparison library. It is likely 'node-canvas' is not installed (see ContextMod docs). Error Message: ${err.message}`;
|
||||
err.message = `Unable to do image comparison due to an issue importing the comparison library. It is likely sharp is not installed (see ContextMod docs). Error Message: ${err.message}`;
|
||||
throw err;
|
||||
}
|
||||
const results = await ci(data1.data, data2.data, {
|
||||
returnEarlyThreshold: threshold !== undefined ? Math.min(threshold + 5, 100) : undefined,
|
||||
}) as ResembleResult;
|
||||
|
||||
const sameImage = threshold === undefined ? undefined : results.rawMisMatchPercentage < threshold;
|
||||
return [results, sameImage];
|
||||
const [refImg, compareImg, width, height] = await data1.normalizeImagesForComparison('pixel', data2);
|
||||
const time = Date.now();
|
||||
// ensureAlpha() is imperative here because pixelmatch expects an alpha layer
|
||||
pixelDiff = pixelmatch(await refImg.ensureAlpha().raw().toBuffer(), await compareImg.ensureAlpha().raw().toBuffer(), null, width, height);
|
||||
return {
|
||||
isSameDimensions: true,
|
||||
dimensionDifference: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
misMatchPercentage: pixelDiff / (width * height),
|
||||
analysisTime: Date.now() - time,
|
||||
}
|
||||
}
|
||||
|
||||
// see comments in compareImages
|
||||
//
|
||||
|
||||
// export const resembleImageCompare = async (data1: ImageData, data2: ImageData, threshold?: number, variantDimensionDiff = 0): Promise<ImageComparisonResult> => {
|
||||
// let ci: Function;
|
||||
//
|
||||
// try {
|
||||
// ci = await getCIFunc();
|
||||
// } catch (err) {
|
||||
// err.message = `Unable to do image comparison due to an issue importing the comparison library. It is likely 'node-canvas' is not installed (see ContextMod docs). Error Message: ${err.message}`;
|
||||
// throw err;
|
||||
// }
|
||||
//
|
||||
// let results: ImageComparisonResult | undefined = undefined;
|
||||
// // @ts-ignore
|
||||
// let resResult: ResembleSingleCallbackComparisonResult = undefined;
|
||||
//
|
||||
// //const [minWidth, minHeight] = getMinimumDimensions(data1, data2);
|
||||
// const compareOptions = {
|
||||
// // "ignore": [
|
||||
// // 'colors' // ~100% than nothing because resemble computes brightness information from rgb for each pixel
|
||||
// // ],
|
||||
// // boundingBox is ~30% slower than no restrictions
|
||||
// // because resemble has to check that each pixel is within the box
|
||||
// //
|
||||
// // output: {
|
||||
// // // compare at most 800x800 section to increase performance
|
||||
// // // -- potentially allow this to be user-configurable in the future if not sufficient for dup detection
|
||||
// // boundingBox: {
|
||||
// // left: 0,
|
||||
// // top: 0,
|
||||
// // right: Math.min(minWidth, 800),
|
||||
// // bottom: Math.min(minHeight, 800)
|
||||
// // },
|
||||
// // },
|
||||
// returnEarlyThreshold: threshold !== undefined ? Math.min(threshold + 5, 100) : undefined,
|
||||
// };
|
||||
//
|
||||
// if(data1.preferredResolution !== undefined) {
|
||||
// const [prefWidth, prefHeight] = data1.preferredResolution;
|
||||
// const prefImgData = data2.getSimilarResolutionVariant(prefWidth, prefHeight, variantDimensionDiff);
|
||||
// if(prefImgData !== undefined) {
|
||||
// let refThumbnail;
|
||||
// try {
|
||||
// refThumbnail = data1.getSimilarResolutionVariant(prefWidth, prefHeight) as ImageData;
|
||||
// resResult = await ci(await (await refThumbnail.sharp()).clone().resize(400, null, {fit: 'outside'}).jpeg().toBuffer()
|
||||
// , await (await prefImgData.sharp()).clone().resize(400, null, {fit: 'outside'}).jpeg().toBuffer()
|
||||
// , compareOptions) as ResembleSingleCallbackComparisonResult;
|
||||
// } catch(err) {
|
||||
// throw err;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if(resResult === undefined) {
|
||||
// resResult = await ci(await (await data1.sharp()).clone().resize(400, null, {fit: 'outside'}).jpeg().toBuffer(),
|
||||
// await (await data2.sharp()).clone().resize(400, null, {fit: 'outside'}).jpeg().toBuffer(), compareOptions) as ResembleSingleCallbackComparisonResult;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// return {
|
||||
// isSameDimensions: resResult.isSameDimensions,
|
||||
// dimensionDifference: resResult.dimensionDifference,
|
||||
// // @ts-ignore
|
||||
// misMatchPercentage: resResult.rawMisMatchPercentage,
|
||||
// analysisTime: resResult.analysisTime
|
||||
// };
|
||||
// }
|
||||
|
||||
export const createHistoricalStatsDisplay = (data: HistoricalStats): HistoricalStatsDisplay => {
|
||||
const display: any = {};
|
||||
for(const [k, v] of Object.entries(data)) {
|
||||
@@ -1235,3 +1389,38 @@ export const createHistoricalStatsDisplay = (data: HistoricalStats): HistoricalS
|
||||
|
||||
return display as HistoricalStatsDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the state criteria being checked are
|
||||
* 1 ) expensive to compute or
|
||||
* 2 ) require additional api requests
|
||||
*
|
||||
* If neither then do not cache results as the number of unique keys (sub-state) increases AT LEAST linearly taking up space (especially in memory cache)
|
||||
* when they are probably not necessary to begin with
|
||||
* */
|
||||
export const shouldCacheSubredditStateCriteriaResult = (state: SubredditState | StrongSubredditState): boolean => {
|
||||
// currently there are no scenarios where we need to cache results
|
||||
// since only things computed from state are comparisons for properties already cached on subreddit object
|
||||
// and regexes for name which aren't that costly
|
||||
// -- so just return false
|
||||
return false;
|
||||
}
|
||||
|
||||
export const subredditStateIsNameOnly = (state: SubredditState | StrongSubredditState): boolean => {
|
||||
const critCount = Object.entries(state).filter(([key, val]) => {
|
||||
return val !== undefined && !['name','stateDescription'].includes(key);
|
||||
}).length;
|
||||
return critCount === 0;
|
||||
}
|
||||
|
||||
export const absPercentDifference = (num1: number, num2: number) => {
|
||||
return Math.abs((num1 - num2) / num1) * 100;
|
||||
}
|
||||
|
||||
export const bitsToHexLength = (bits: number): number => {
|
||||
return Math.pow(bits, 2) / 4;
|
||||
}
|
||||
|
||||
export const escapeRegex = (val: string) => {
|
||||
return val.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user