import { defineClassifier } from "$hatk"; /** * Spam judged per gallery rather than per account. * * Grain's moderation reports are filed against records rather than accounts, * and are resolved by labelling the gallery rather than removing its poster. * This scores the thing the label lands on. * * The distinction the questions turn on is that grain is a photo-sharing * service. Real galleries caption photographs; the spam ones are articles, * product listings, or bare links that happen to carry an image. "Nepal * Mudslides — Nepal after mudslides destruction" is a caption. "How AI Agent * Era Redraws SSD & Storage Value" followed by four paragraphs is not. */ export default defineClassifier({ subject: "record", collections: ["social.grain.gallery"], label: "spam", threshold: 0.6, questions: { promotional_listing: { type: "noul", instructions: "Is this gallery the poster advertising something they profit from, rather than sharing photographs? Every gallery on grain carries photographs; judge what the text is for.", criteria: { true: "Sells or promotes something to the reader on the poster's behalf — a crypto platform, an ebook, a paid service, an affiliate link, a discount, a company's marketing. Use the poster's other galleries: many near-identical listings make it one.", false: "Photographs shared as photographs, whatever they show. Mentioning, joking about, photographing or reviewing a product, an ad, a shop or an event is NOT a promotional listing, and neither is sharing a link to an article, a talk or one's own blog, or selling a single personal item. A photographer naming their own shop, print store, portfolio, or Ko-fi alongside their work is NOT a promotional listing — the photographs are still the point. A poster whose other galleries are varied, personal posts is sharing, not listing.", }, }, article_not_caption: { type: "noul", instructions: "Is the gallery's text an article or blog post that happens to carry an image, rather than a caption describing the photographs?", criteria: { true: "Reads as written-for-search prose with an explanatory headline and body paragraphs — the kind of content-farm article that exists to rank, not to accompany a picture.", false: "A caption, a title, a place name, a date, a note about the shot or the trip, or nothing at all. Length alone does not make a caption an article: a long, personal story about the photographs is still a caption.", }, }, }, async buildState({ db, subject }) { const v = subject.value ?? {}; const title = typeof v.title === "string" ? v.title.trim() : ""; const description = typeof v.description === "string" ? v.description.trim() : ""; // A gallery carrying almost no text has no question to answer. The cut is // deliberately low: a short title can still be spam — "MIS EBOOKS" is ten // characters. if (title.length + description.length < 8) return null; // The poster's other galleries. One gallery alone cannot show the pattern // that separates a listing from a post: "MIS EBOOKS" fifteen times over is // spam, while a product joke in a varied personal archive is not. const [{ count }] = (await db.query( `SELECT COUNT(*) AS count FROM "social.grain.gallery" WHERE did = $1`, [subject.did], )) as { count: number }[]; const others = (await db.query( `SELECT title, description FROM "social.grain.gallery" WHERE did = $1 AND uri <> $2 ORDER BY created_at DESC LIMIT 6`, [subject.did, subject.uri], )) as { title: string | null; description: string | null }[]; return { title: title || null, description: description || null, poster: { galleries: Number(count), otherGalleries: others.map((g) => [g.title, g.description].filter(Boolean).join(" — ").slice(0, 120), ), }, }; }, });