forked from MapComplete/MastodonBot
Add overpass reports, add nomention and nobot support
This commit is contained in:
parent
d2ddc3227d
commit
c699f13c78
8 changed files with 315 additions and 89 deletions
26
README.md
Normal file
26
README.md
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# Mastodon-Bot
|
||||||
|
|
||||||
|
This is a bot which is built to post a daily message about mapcomplete-usage.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
It fetches latest changesets from OsmCha, processes them and posts them to Mastodon.
|
||||||
|
|
||||||
|
Changesets which have the `add-image`-tag are downloaded from the OSM-api and crawled for images, of which some are picked to add to the posts.
|
||||||
|
|
||||||
|
Note that the image selection process is opinionated: some themes (artwork, nature, trees, ...) have a higher probability of being picked.
|
||||||
|
Furthermore, it tries to pick at most one image per contributor - so images by the same contributor will only be used if there are more images to add then contributors.
|
||||||
|
|
||||||
|
## Your bot mentioned me - I don't want that!
|
||||||
|
|
||||||
|
You can indicate this to the bot in the following way:
|
||||||
|
|
||||||
|
### On Mastodon
|
||||||
|
|
||||||
|
- You can mute or block the bot so that you won't see the posts. Your user account on OpenStreetMap or your Mastodon-username will still be mentioned in the posts
|
||||||
|
- You can add the hashtag `#nobot` or `#no-mapcomplete-bot` to your profile description. The bot will not mention you with your Mastodon-handle, but it will still post your OSM-username, your pictures and a report of your contributions
|
||||||
|
|
||||||
|
### On your OSM-user profile:
|
||||||
|
|
||||||
|
- Add `#no-bot` or `#no-mapcomplete-bot` to your user profile and your contributions (map changes and pictures) will not be included in the bot at all
|
||||||
|
- Add `#nobotmention` or `#nomapcompletebotmention` to your user profile. Your contributions and pictures will still be listed in the bot, with your OSM-username. However, your OSM-username will _not_ be replaced by your Mastodon-handle, thus _not_ pinging you.
|
|
@ -1,8 +1,31 @@
|
||||||
import {LoginSettings} from "./Mastodon";
|
import {LoginSettings} from "./Mastodon";
|
||||||
|
|
||||||
export interface MapCompleteUsageOverview {
|
export interface MapCompleteUsageOverview {
|
||||||
topContributorsNumberToShow: number,
|
showTopContributors?: boolean,
|
||||||
topThemesNumberToShow: number,
|
showTopThemes?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Term to use in 'created/moved/deleted one point'
|
||||||
|
*/
|
||||||
|
poiName?: "point" | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Term to use in 'created/moved/deleted n points'
|
||||||
|
*/
|
||||||
|
poisName?: "points" | string
|
||||||
|
|
||||||
|
report?:{
|
||||||
|
overpassQuery: string,
|
||||||
|
/**
|
||||||
|
* Values {total} and {date} will be substituted
|
||||||
|
*/
|
||||||
|
post: string
|
||||||
|
/**
|
||||||
|
* Default: global bbox
|
||||||
|
* Use something like `[bbox:51.19999983412068,2.8564453125,51.41976382669736,3.416748046875]` if only a certain region should be sent
|
||||||
|
*/
|
||||||
|
bbox?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of days to look back.
|
* The number of days to look back.
|
||||||
|
@ -35,5 +58,5 @@ export default interface Config {
|
||||||
/** IF set: prints to console instead of to Mastodon*/
|
/** IF set: prints to console instead of to Mastodon*/
|
||||||
dryrun?: boolean
|
dryrun?: boolean
|
||||||
},
|
},
|
||||||
actions: {mapCompleteUsageOverview : MapCompleteUsageOverview} []
|
actions: MapCompleteUsageOverview []
|
||||||
}
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import {login, LoginParams} from 'masto';
|
import {login, LoginParams} from 'masto';
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {stat} from "fs";
|
|
||||||
|
|
||||||
export interface LoginSettings {
|
export interface LoginSettings {
|
||||||
url: string,
|
url: string,
|
||||||
|
@ -22,41 +21,96 @@ export interface CreateStatusParamsBase {
|
||||||
readonly mediaIds?: readonly string[];
|
readonly mediaIds?: readonly string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MastodonPoster {
|
export default class MastodonPoster {
|
||||||
/**
|
/**
|
||||||
* The actual instance, see https://www.npmjs.com/package/mastodon
|
* The actual instance, see https://www.npmjs.com/package/mastodon
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private readonly instance ;
|
private readonly instance;
|
||||||
private _dryrun: boolean;
|
private _dryrun: boolean;
|
||||||
|
private _userInfoCache: Record<string, any> = {}
|
||||||
|
|
||||||
private constructor(masto, dryrun: boolean) {
|
private constructor(masto, dryrun: boolean) {
|
||||||
this.instance = masto
|
this.instance = masto
|
||||||
this._dryrun = dryrun;
|
this._dryrun = dryrun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async doStuff(){
|
public static async construct(settings: LoginParams & { dryrun?: boolean }) {
|
||||||
|
return new MastodonPoster(await login(settings), settings.dryrun ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async writeMessage(text: string, options?: CreateStatusParamsBase): Promise<{id: string}>{
|
public async writeMessage(text: string, options?: CreateStatusParamsBase): Promise<{ id: string }> {
|
||||||
if(this._dryrun){
|
|
||||||
console.log("Dryrun enabled - not posting",options?.visibility??"public","message: \n" + text.split("\n").map(txt => " > "+txt).join("\n"))
|
if (options?.visibility === "direct" && text.indexOf("@") < 0) {
|
||||||
|
throw ("Error: you try to send a direct message, but it has no username...")
|
||||||
|
}
|
||||||
|
if (text.length > 500) {
|
||||||
|
console.log(text.split("\n").map(txt => " > " + txt).join("\n"))
|
||||||
|
throw "Error: text is too long:" + text.length
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length == 0) {
|
||||||
|
console.log("Not posting an empty message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._dryrun) {
|
||||||
|
console.log("Dryrun enabled - not posting", options?.visibility ?? "public", "message: \n" + text.split("\n").map(txt => " > " + txt).join("\n"))
|
||||||
return {id: "some_id"}
|
return {id: "some_id"}
|
||||||
}
|
}
|
||||||
const statusUpate = await this.instance.v1.statuses.create({
|
const statusUpate = await this.instance.v1.statuses.create({
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
...(options??{}),
|
...(options ?? {}),
|
||||||
status: text
|
status: text
|
||||||
})
|
})
|
||||||
console.dir(statusUpate)
|
|
||||||
console.log("Posted to", statusUpate.url)
|
console.log("Posted to", statusUpate.url)
|
||||||
console.log(text.split("\n").map(txt => " > "+txt).join("\n"))
|
console.log(text.split("\n").map(txt => " > " + txt).join("\n"))
|
||||||
return statusUpate
|
return statusUpate
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async construct(settings: LoginParams & {dryrun?: boolean}) {
|
public async hasNoBot(username: string): Promise<boolean> {
|
||||||
return new MastodonPoster(await login(settings), settings.dryrun ?? false)
|
const info = await this.userInfoFor(username)
|
||||||
|
const descrParts = info.note?.replace(/-/g, "")?.toLowerCase()?.split(" ") ?? []
|
||||||
|
if (descrParts.indexOf("#nobot") >= 0 || descrParts.indexOf("#nomapcompletebot") >= 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const nobot = info.fields.find(f => f.name === "nobot").value
|
||||||
|
if (nobot.toLowerCase() === "yes" || nobot.toLowerCase() === "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async userInfoFor(username: string): Promise<{
|
||||||
|
id: string,
|
||||||
|
/*Fully qualified user name*/
|
||||||
|
acct: string
|
||||||
|
displayname: string,
|
||||||
|
bot: boolean,
|
||||||
|
/* User-set biography */
|
||||||
|
note: string,
|
||||||
|
url: string,
|
||||||
|
avatar: string,
|
||||||
|
avatarStatic: string,
|
||||||
|
header: string,
|
||||||
|
headerStatic: string,
|
||||||
|
followersCount: number,
|
||||||
|
followingCount: number,
|
||||||
|
statusesCount: number,
|
||||||
|
fields: { name: string, value: string }[]
|
||||||
|
}> {
|
||||||
|
if (this._userInfoCache[username]) {
|
||||||
|
return this._userInfoCache[username]
|
||||||
|
}
|
||||||
|
const acct = await this.instance.v1.accounts.lookup({
|
||||||
|
acct: username,
|
||||||
|
});
|
||||||
|
const info = await this.instance.v1.accounts.fetch(acct.id)
|
||||||
|
this._userInfoCache[username] = info
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads the image; returns the id of the image
|
* Uploads the image; returns the id of the image
|
||||||
|
@ -64,15 +118,25 @@ export default class MastodonPoster {
|
||||||
* @param description
|
* @param description
|
||||||
*/
|
*/
|
||||||
public async uploadImage(path: string, description: string): Promise<string> {
|
public async uploadImage(path: string, description: string): Promise<string> {
|
||||||
if(this._dryrun){
|
if (this._dryrun) {
|
||||||
console.log("As dryrun is enabled: not uploading ", path)
|
console.log("As dryrun is enabled: not uploading ", path)
|
||||||
return "some_id"
|
return "some_id"
|
||||||
}
|
}
|
||||||
console.log("Uploading", path)
|
console.log("Uploading", path)
|
||||||
|
try {
|
||||||
|
|
||||||
|
const mediaAttachment = await this.instance.v2.mediaAttachments.create({
|
||||||
|
file: new Blob([fs.readFileSync(path)]),
|
||||||
|
description
|
||||||
|
})
|
||||||
|
return mediaAttachment.id
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Could not upload image " + path + " due to ", e, "Trying again...")
|
||||||
const mediaAttachment = await this.instance.v2.mediaAttachments.create({
|
const mediaAttachment = await this.instance.v2.mediaAttachments.create({
|
||||||
file: new Blob([fs.readFileSync(path)]),
|
file: new Blob([fs.readFileSync(path)]),
|
||||||
description
|
description
|
||||||
})
|
})
|
||||||
return mediaAttachment.id
|
return mediaAttachment.id
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import Utils from "./Utils";
|
import Utils from "./Utils";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
import MastodonPoster from "./Mastodon";
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
"id": number,
|
"id": number,
|
||||||
|
@ -40,7 +41,27 @@ export default class OsmUserInfo {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async GetMastodonLink(): Promise<string | undefined> {
|
public async hasNoBotTag(): Promise<{
|
||||||
|
nobot: boolean,
|
||||||
|
nomention: boolean
|
||||||
|
}>{
|
||||||
|
const description = (await this.getUserInfo()).description ?? ""
|
||||||
|
const split = description.toLowerCase().replace(/-/g, "").split(" ")
|
||||||
|
const nobot = split.indexOf("#nobot") >=0 || split.indexOf("#nomapcompletebot") >= 0
|
||||||
|
const nomention = split.indexOf("#nobotmention") >=0 || split.indexOf("#nomapcompletebotmention") >= 0
|
||||||
|
return {nobot, nomention}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Mastodon username of the this OSM-user to ping them.
|
||||||
|
* @param mastodonApi: will be used to lookup the metadata of the user; if they have '#nobot' in their bio, don't mention them
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public async GetMastodonUsername(mastodonApi: MastodonPoster): Promise<string | undefined> {
|
||||||
|
const {nomention} = await this.hasNoBotTag()
|
||||||
|
if(nomention){
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
const mastodonLinks = await this.getMeLinks()
|
const mastodonLinks = await this.getMeLinks()
|
||||||
|
|
||||||
if (mastodonLinks.length <= 0) {
|
if (mastodonLinks.length <= 0) {
|
||||||
|
@ -48,7 +69,13 @@ export default class OsmUserInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(mastodonLinks[0])
|
const url = new URL(mastodonLinks[0])
|
||||||
return url.pathname.substring(1) + "@" + url.host
|
const username = url.pathname.substring(1) + "@" + url.host
|
||||||
|
|
||||||
|
if(await mastodonApi.hasNoBot(username)){
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const useraccount = await mastodonApi.userInfoFor(username)
|
||||||
|
return useraccount.acct
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMeLinks(): Promise<string[]> {
|
public async getMeLinks(): Promise<string[]> {
|
||||||
|
@ -80,7 +107,7 @@ export default class OsmUserInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = `${this._backend}api/0.6/user/${this._userId}.json`
|
const url = `${this._backend}api/0.6/user/${this._userId}.json`
|
||||||
console.log("Looking up user info about ", this._userId)
|
console.log("Looking up OSM user info about ", this._userId)
|
||||||
const res = await Utils.DownloadJson(url);
|
const res = await Utils.DownloadJson(url);
|
||||||
this._userData = res.user
|
this._userData = res.user
|
||||||
if (this._cachingPath !== undefined) {
|
if (this._cachingPath !== undefined) {
|
||||||
|
|
28
src/Overpass.ts
Normal file
28
src/Overpass.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import Utils from "./Utils";
|
||||||
|
|
||||||
|
export default class Overpass {
|
||||||
|
private readonly bbox: string;
|
||||||
|
private readonly overpassQuery: string
|
||||||
|
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
bbox?: string,
|
||||||
|
overpassQuery: string
|
||||||
|
}) {
|
||||||
|
this.bbox = options.bbox ?? "";
|
||||||
|
this.overpassQuery = options.overpassQuery
|
||||||
|
}
|
||||||
|
private constructUrl() {
|
||||||
|
// https://overpass-api.de/api/interpreter?data=[out:json][timeout:180];(nwr[%22memorial%22=%22ghost_bike%22];);out%20body;out%20meta;%3E;out%20skel%20qt;
|
||||||
|
const param = `[out:json][timeout:180]${this.bbox};(${this.overpassQuery});out body;out meta;>;out skel qt;`
|
||||||
|
return `https://overpass-api.de/api/interpreter?data=${(param)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public query(){
|
||||||
|
console.log("Querying overpass: ", this.constructUrl())
|
||||||
|
return Utils.DownloadJson(this.constructUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -5,12 +5,14 @@ import OsmUserInfo from "./OsmUserInfo";
|
||||||
import Config, {MapCompleteUsageOverview} from "./Config";
|
import Config, {MapCompleteUsageOverview} from "./Config";
|
||||||
import MastodonPoster from "./Mastodon";
|
import MastodonPoster from "./Mastodon";
|
||||||
import ImgurAttribution from "./ImgurAttribution";
|
import ImgurAttribution from "./ImgurAttribution";
|
||||||
|
import Overpass from "./Overpass";
|
||||||
|
|
||||||
type ImageInfo = { image: string, changeset: ChangeSetData }
|
type ImageInfo = { image: string, changeset: ChangeSetData }
|
||||||
|
|
||||||
export class Postbuilder {
|
export class Postbuilder {
|
||||||
private static readonly metakeys = [
|
private static readonly metakeys = [
|
||||||
"answer",
|
"answer",
|
||||||
|
"create",
|
||||||
"add-image",
|
"add-image",
|
||||||
"move",
|
"move",
|
||||||
"delete",
|
"delete",
|
||||||
|
@ -33,7 +35,13 @@ export class Postbuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getStatisticsFor(changesetsMade?: ChangeSetData[]): { total: number, addImage?: number, deleted: number, answered?: number, moved?: number, summaryText?: string } {
|
getStatisticsFor(changesetsMade?: ChangeSetData[]): { total: number,
|
||||||
|
answered?: number,
|
||||||
|
created?: number,
|
||||||
|
addImage?: number,
|
||||||
|
deleted: number,
|
||||||
|
moved?: number,
|
||||||
|
summaryText?: string } {
|
||||||
|
|
||||||
const stats: Record<string, number> = {}
|
const stats: Record<string, number> = {}
|
||||||
changesetsMade ??= this._changesetsMade
|
changesetsMade ??= this._changesetsMade
|
||||||
|
@ -48,11 +56,20 @@ export class Postbuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
let overview: string[] = []
|
let overview: string[] = []
|
||||||
const {answer, move} = stats
|
const {answer, move, create} = stats
|
||||||
const deleted = stats.delete
|
const deleted = stats.delete
|
||||||
const images = stats["add-image"]
|
const images = stats["add-image"]
|
||||||
const plantnetDetected = stats["plantnet-ai-detection"]
|
const plantnetDetected = stats["plantnet-ai-detection"]
|
||||||
const linkedImages = stats["link-image"]
|
const linkedImages = stats["link-image"]
|
||||||
|
const poi = this._config.poiName ?? "point"
|
||||||
|
const pois = this._config.poisName ?? "points"
|
||||||
|
if(create){
|
||||||
|
if (create == 1) {
|
||||||
|
overview.push("added one "+poi)
|
||||||
|
} else {
|
||||||
|
overview.push("added " + create +" "+pois)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (answer) {
|
if (answer) {
|
||||||
if (answer == 1) {
|
if (answer == 1) {
|
||||||
overview.push("answered one question")
|
overview.push("answered one question")
|
||||||
|
@ -70,17 +87,17 @@ export class Postbuilder {
|
||||||
|
|
||||||
if (move) {
|
if (move) {
|
||||||
if (move == 1) {
|
if (move == 1) {
|
||||||
overview.push("moved one point")
|
overview.push("moved one "+poi)
|
||||||
} else {
|
} else {
|
||||||
overview.push("moved " + move + " points")
|
overview.push("moved " + move + " "+pois)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
if (deleted == 1) {
|
if (deleted == 1) {
|
||||||
overview.push("delted one deleted")
|
overview.push("deleted one "+poi)
|
||||||
} else {
|
} else {
|
||||||
overview.push("deleted " + deleted + " points")
|
overview.push("deleted " + deleted + " "+pois)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,16 +137,18 @@ export class Postbuilder {
|
||||||
|
|
||||||
const themes = new Histogram(changesetsMade, cs => cs.properties.theme)
|
const themes = new Histogram(changesetsMade, cs => cs.properties.theme)
|
||||||
|
|
||||||
let username = await userinfo.GetMastodonLink() ?? inf.display_name
|
let username = await userinfo.GetMastodonUsername(this._poster) ?? inf.display_name
|
||||||
|
|
||||||
const statistics = this.getStatisticsFor(changesetsMade)
|
const statistics = this.getStatisticsFor(changesetsMade)
|
||||||
|
|
||||||
let thematicMaps = "maps " + Utils.commasAnd(themes.keys())
|
let thematicMaps = " with the thematic maps " + Utils.commasAnd(themes.keys())
|
||||||
if (themes.keys().length === 1) {
|
if (this._config?.themeWhitelist?.length === 1) {
|
||||||
thematicMaps = "map " + Utils.commasAnd(themes.keys())
|
thematicMaps = ""
|
||||||
|
} else if (themes.keys().length === 1) {
|
||||||
|
thematicMaps = " with the thematic map " + Utils.commasAnd(themes.keys())
|
||||||
}
|
}
|
||||||
|
|
||||||
return username + " " + statistics.summaryText + " with the thematic " + thematicMaps
|
return username + " " + statistics.summaryText + thematicMaps
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOverviewForTheme(theme: string, changesetsMade: ChangeSetData[]): Promise<string> {
|
async createOverviewForTheme(theme: string, changesetsMade: ChangeSetData[]): Promise<string> {
|
||||||
|
@ -206,8 +225,21 @@ export class Postbuilder {
|
||||||
|
|
||||||
public async buildMessage(date: string): Promise<void> {
|
public async buildMessage(date: string): Promise<void> {
|
||||||
const changesets = this._changesetsMade
|
const changesets = this._changesetsMade
|
||||||
const perContributor = new Histogram(changesets, cs => cs.properties.uid)
|
let lastPostId: string = undefined
|
||||||
|
|
||||||
|
|
||||||
|
if(this._config.report){
|
||||||
|
const report = this._config.report
|
||||||
|
const overpass = new Overpass(report)
|
||||||
|
const data = await overpass.query()
|
||||||
|
const total = data.elements.length
|
||||||
|
const date = data.osm3s.timestamp_osm_base.substring(0, 10)
|
||||||
|
lastPostId = (await this._poster.writeMessage(
|
||||||
|
report.post.replace(/{total}/g, ""+total).replace(/{date}/g, date)
|
||||||
|
)).id
|
||||||
|
}
|
||||||
|
|
||||||
|
const perContributor = new Histogram(changesets, cs => cs.properties.uid)
|
||||||
const topContributors = perContributor.sortedByCount({
|
const topContributors = perContributor.sortedByCount({
|
||||||
countMethod: cs => {
|
countMethod: cs => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
|
@ -230,17 +262,25 @@ export class Postbuilder {
|
||||||
} = await this.prepareImages(changesets, 12)
|
} = await this.prepareImages(changesets, 12)
|
||||||
|
|
||||||
let timePeriod = "Yesterday"
|
let timePeriod = "Yesterday"
|
||||||
if(this._config.numberOfDays > 1){
|
if (this._config.numberOfDays > 1) {
|
||||||
timePeriod = "In the past "+this._config.numberOfDays+" days"
|
timePeriod = "In the past " + this._config.numberOfDays + " days"
|
||||||
}
|
}
|
||||||
|
const singleTheme = this._config?.themeWhitelist?.length === 1 ? "/" + this._config.themeWhitelist[0] : ""
|
||||||
let toSend: string[] = [
|
let toSend: string[] = [
|
||||||
timePeriod+", " + perContributor.keys().length + " different persons made " + totalStats.total + " changes to #OpenStreetMap using https://mapcomplete.osm.be .\n",
|
`${timePeriod}, ${perContributor.keys().length} persons made ${totalStats.total} changes to #OpenStreetMap using https://mapcomplete.osm.be${singleTheme} .
|
||||||
|
`,
|
||||||
]
|
]
|
||||||
|
|
||||||
for (let i = 0; i < this._config.topContributorsNumberToShow - 1 && i < topContributors.length; i++) {
|
if (this._config.showTopContributors && topContributors.length > 0) {
|
||||||
const uid = topContributors[i].key
|
for (const topContributor of topContributors) {
|
||||||
|
const uid = topContributor.key
|
||||||
const changesetsMade = perContributor.get(uid)
|
const changesetsMade = perContributor.get(uid)
|
||||||
try {
|
try {
|
||||||
|
const userInfo = new OsmUserInfo(Number(uid))
|
||||||
|
const {nobot} = await userInfo.hasNoBotTag()
|
||||||
|
if (nobot) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const overview = await this.createOverviewForContributor(uid, changesetsMade)
|
const overview = await this.createOverviewForContributor(uid, changesetsMade)
|
||||||
if (overview.length + toSend.join("\n").length > 500) {
|
if (overview.length + toSend.join("\n").length > 500) {
|
||||||
break
|
break
|
||||||
|
@ -249,11 +289,10 @@ export class Postbuilder {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could not add contributor " + uid, e)
|
console.error("Could not add contributor " + uid, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
lastPostId = (await this._poster.writeMessage(toSend.join("\n"), {mediaIds: attachmentIds.slice(0, 4)})).id
|
||||||
const firstPost = await this._poster.writeMessage(toSend.join("\n"), {mediaIds: attachmentIds.slice(0, 4)})
|
|
||||||
toSend = []
|
toSend = []
|
||||||
|
}
|
||||||
|
|
||||||
const perTheme = new Histogram(changesets, cs => {
|
const perTheme = new Histogram(changesets, cs => {
|
||||||
return cs.properties.theme;
|
return cs.properties.theme;
|
||||||
|
@ -263,17 +302,24 @@ export class Postbuilder {
|
||||||
countMethod: cs => this.getStatisticsFor([cs]).total,
|
countMethod: cs => this.getStatisticsFor([cs]).total,
|
||||||
dropZeroValues: true
|
dropZeroValues: true
|
||||||
})
|
})
|
||||||
toSend.push("")
|
if (this._config.showTopThemes && mostPopularThemes.length > 0) {
|
||||||
for (let i = 0; i < this._config.topThemesNumberToShow && i < mostPopularThemes.length; i++) {
|
|
||||||
const theme = mostPopularThemes[i].key
|
for (const theme of mostPopularThemes) {
|
||||||
const changesetsMade = perTheme.get(theme)
|
const themeId = theme.key
|
||||||
toSend.push(await this.createOverviewForTheme(theme, changesetsMade))
|
const changesetsMade = perTheme.get(themeId)
|
||||||
|
const overview = await this.createOverviewForTheme(themeId, changesetsMade)
|
||||||
|
if (overview.length + toSend.join("\n").length > 500) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toSend.push(overview)
|
||||||
}
|
}
|
||||||
|
|
||||||
const secondPost = await this._poster.writeMessage(toSend.join("\n"), {
|
lastPostId = (await this._poster.writeMessage(toSend.join("\n"), {
|
||||||
inReplyToId: firstPost["id"],
|
inReplyToId: lastPostId,
|
||||||
mediaIds: attachmentIds.slice(4, 8)
|
mediaIds: attachmentIds.slice(4, 8)
|
||||||
})
|
})).id
|
||||||
|
toSend = []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const authorNames = Array.from(new Set<string>(imgAuthors))
|
const authorNames = Array.from(new Set<string>(imgAuthors))
|
||||||
|
@ -282,10 +328,10 @@ export class Postbuilder {
|
||||||
"Images in this thread are randomly selected from them and were made by: ",
|
"Images in this thread are randomly selected from them and were made by: ",
|
||||||
...authorNames.map(auth => "- " + auth),
|
...authorNames.map(auth => "- " + auth),
|
||||||
"",
|
"",
|
||||||
"All changes were made on " + date + (this._config.numberOfDays > 1 ? " or at most "+this._config.numberOfDays+"days before": "")
|
"All changes were made on " + date + (this._config.numberOfDays > 1 ? ` or at most ${this._config.numberOfDays} days before` : "")
|
||||||
|
|
||||||
].join("\n"), {
|
].join("\n"), {
|
||||||
inReplyToId: secondPost["id"],
|
inReplyToId: lastPostId,
|
||||||
mediaIds: attachmentIds.slice(8, 12)
|
mediaIds: attachmentIds.slice(8, 12)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -301,6 +347,13 @@ export class Postbuilder {
|
||||||
const seenURLS = new Set<string>()
|
const seenURLS = new Set<string>()
|
||||||
for (const changeset of withImage) {
|
for (const changeset of withImage) {
|
||||||
|
|
||||||
|
const userinfo = new OsmUserInfo(Number(changeset.properties.uid))
|
||||||
|
const {nobot} = await userinfo.hasNoBotTag()
|
||||||
|
if (nobot) {
|
||||||
|
console.log("Not indexing images of user", changeset.properties.user)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const url = this._globalConfig.osmBackend + "/api/0.6/changeset/" + changeset.id + "/download"
|
const url = this._globalConfig.osmBackend + "/api/0.6/changeset/" + changeset.id + "/download"
|
||||||
const osmChangeset = await Utils.DownloadXml(url)
|
const osmChangeset = await Utils.DownloadXml(url)
|
||||||
const osmChangesetTags: { k: string, v: string }[] = Array.from(osmChangeset.getElementsByTagName("tag"))
|
const osmChangesetTags: { k: string, v: string }[] = Array.from(osmChangeset.getElementsByTagName("tag"))
|
||||||
|
@ -308,7 +361,7 @@ export class Postbuilder {
|
||||||
.filter(kv => kv.k.startsWith("image"))
|
.filter(kv => kv.k.startsWith("image"))
|
||||||
|
|
||||||
for (const kv of osmChangesetTags) {
|
for (const kv of osmChangesetTags) {
|
||||||
if(seenURLS.has(kv.v)){
|
if (seenURLS.has(kv.v)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenURLS.add(kv.v)
|
seenURLS.add(kv.v)
|
||||||
|
@ -325,8 +378,8 @@ export class Postbuilder {
|
||||||
let authorName = cs.user
|
let authorName = cs.user
|
||||||
try {
|
try {
|
||||||
const authorInfo = new OsmUserInfo(Number(cs.uid), this._globalConfig)
|
const authorInfo = new OsmUserInfo(Number(cs.uid), this._globalConfig)
|
||||||
authorName = (await authorInfo.GetMastodonLink()) ?? cs.user
|
authorName = (await authorInfo.GetMastodonUsername(this._poster)) ?? cs.user
|
||||||
}catch (e) {
|
} catch (e) {
|
||||||
console.log("Could not fetch more info about contributor", authorName, cs.uid, "due to", e)
|
console.log("Could not fetch more info about contributor", authorName, cs.uid, "due to", e)
|
||||||
}
|
}
|
||||||
imgAuthors.push(authorName)
|
imgAuthors.push(authorName)
|
||||||
|
|
|
@ -5,7 +5,13 @@ import {DOMParser} from '@xmldom/xmldom'
|
||||||
export default class Utils {
|
export default class Utils {
|
||||||
public static async DownloadJson(url, headers?: any): Promise<any> {
|
public static async DownloadJson(url, headers?: any): Promise<any> {
|
||||||
const data = await Utils.Download(url, headers)
|
const data = await Utils.Download(url, headers)
|
||||||
|
try{
|
||||||
|
|
||||||
return JSON.parse(data.content)
|
return JSON.parse(data.content)
|
||||||
|
}catch (e) {
|
||||||
|
console.log("Could not parse the result of ", url,": not a valid json:\n ",data.content)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Sum(t: (number | undefined)[]) {
|
public static Sum(t: (number | undefined)[]) {
|
||||||
|
|
23
src/index.ts
23
src/index.ts
|
@ -17,6 +17,7 @@ export class Main {
|
||||||
}
|
}
|
||||||
|
|
||||||
async main() {
|
async main() {
|
||||||
|
const poster = await MastodonPoster.construct(this._config.mastodonAuth)
|
||||||
|
|
||||||
if (fakedom === undefined || window === undefined) {
|
if (fakedom === undefined || window === undefined) {
|
||||||
throw "FakeDom not initialized"
|
throw "FakeDom not initialized"
|
||||||
|
@ -27,7 +28,6 @@ export class Main {
|
||||||
console.log("Created the caching directory at", this._config.cacheDir)
|
console.log("Created the caching directory at", this._config.cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const poster = await MastodonPoster.construct(this._config.mastodonAuth)
|
|
||||||
|
|
||||||
const notice = await poster.writeMessage("@pietervdvn@en.osm.town Starting MapComplete bot...", {
|
const notice = await poster.writeMessage("@pietervdvn@en.osm.town Starting MapComplete bot...", {
|
||||||
visibility: "direct"
|
visibility: "direct"
|
||||||
|
@ -42,7 +42,7 @@ export class Main {
|
||||||
|
|
||||||
const end = Date.now()
|
const end = Date.now()
|
||||||
const timeNeeded = Math.floor((end - start) / 1000)
|
const timeNeeded = Math.floor((end - start) / 1000)
|
||||||
await poster.writeMessage("Finished running MapComplete bot, this took " + timeNeeded + "seconds", {
|
await poster.writeMessage("@pietervdvn@en.osm.town Finished running MapComplete bot, this took " + timeNeeded + "seconds", {
|
||||||
inReplyToId: notice.id,
|
inReplyToId: notice.id,
|
||||||
visibility: "direct"
|
visibility: "direct"
|
||||||
})
|
})
|
||||||
|
@ -50,7 +50,7 @@ export class Main {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
const end = Date.now()
|
const end = Date.now()
|
||||||
const timeNeeded = Math.floor((end - start) / 1000)
|
const timeNeeded = Math.floor((end - start) / 1000)
|
||||||
await poster.writeMessage("Running MapComplete bot failed in " + timeNeeded + "seconds, the error is " + e, {
|
await poster.writeMessage("@pietervdvn@en.osm.town Running MapComplete bot failed in " + timeNeeded + "seconds, the error is " + e, {
|
||||||
inReplyToId: notice.id,
|
inReplyToId: notice.id,
|
||||||
visibility: "direct"
|
visibility: "direct"
|
||||||
})
|
})
|
||||||
|
@ -58,14 +58,13 @@ export class Main {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runMapCompleteOverviewAction(poster: MastodonPoster, action: { mapCompleteUsageOverview: MapCompleteUsageOverview }) {
|
private async runMapCompleteOverviewAction(poster: MastodonPoster, action: MapCompleteUsageOverview) {
|
||||||
console.log("Fetching recent changesets...")
|
console.log("Fetching recent changesets...")
|
||||||
const osmcha = new OsmCha(this._config)
|
const osmcha = new OsmCha(this._config)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|
||||||
const overviewSettings = action.mapCompleteUsageOverview
|
|
||||||
let changesets: ChangeSetData[] = []
|
let changesets: ChangeSetData[] = []
|
||||||
const days = overviewSettings.numberOfDays ?? 1
|
const days = action.numberOfDays ?? 1
|
||||||
if (days < 1) {
|
if (days < 1) {
|
||||||
throw new Error("Invalid config: numberOfDays should be >= 1")
|
throw new Error("Invalid config: numberOfDays should be >= 1")
|
||||||
}
|
}
|
||||||
|
@ -82,19 +81,19 @@ export class Main {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overviewSettings.themeWhitelist?.length > 0) {
|
if (action.themeWhitelist?.length > 0) {
|
||||||
const allowedThemes = new Set(overviewSettings.themeWhitelist)
|
const allowedThemes = new Set(action.themeWhitelist)
|
||||||
const beforeCount = changesets.length
|
const beforeCount = changesets.length
|
||||||
changesets = changesets.filter(cs => allowedThemes.has(cs.properties.theme))
|
changesets = changesets.filter(cs => allowedThemes.has(cs.properties.theme))
|
||||||
if (changesets.length == 0) {
|
if (changesets.length == 0) {
|
||||||
console.log("No changesets found for themes", overviewSettings.themeWhitelist.join(", "))
|
console.log("No changesets found for themes", action.themeWhitelist.join(", "))
|
||||||
return console.log("No changesets found for themes", overviewSettings.themeWhitelist.join(", "))
|
return console.log("No changesets found for themes", action.themeWhitelist.join(", "))
|
||||||
}
|
}
|
||||||
console.log("Filtering for ", overviewSettings.themeWhitelist, "yielded", changesets.length, "changesets (" + beforeCount + " before)")
|
console.log("Filtering for ", action.themeWhitelist, "yielded", changesets.length, "changesets (" + beforeCount + " before)")
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Building post...")
|
console.log("Building post...")
|
||||||
await new Postbuilder(overviewSettings, this._config, poster, changesets).buildMessage(today.getUTCFullYear() + "-" + Utils.TwoDigits(today.getUTCMonth() + 1) + "-" + Utils.TwoDigits(today.getUTCDate() - 1))
|
await new Postbuilder(action, this._config, poster, changesets).buildMessage(today.getUTCFullYear() + "-" + Utils.TwoDigits(today.getUTCMonth() + 1) + "-" + Utils.TwoDigits(today.getUTCDate() - 1))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue