In KANAELE stand "@hasidog00804". Der Account heisst "@hasidog0804". Nachgemessen bei TikTok selbst: der richtige hat 65 Follower, der andere liefert "Couldn't find this account". Das war nicht kosmetisch. `kanalVonHandle` ordnet ein hochgeladenes Video ueber genau diesen Text einem Kanal zu. Ein echtes HasiDog-Video waere mit 403 abgelehnt worden -- und die Begruendung haette gelautet: "ist keiner deiner Kanaele. Moeglich sind: @dogfather0804, @hasidog00804, @dogis.modi.gang." Eine Sackgasse, die zusaetzlich eine Adresse nennt, die es nicht gibt. WARUM ES KEINE PRUEFUNG GEFUNDEN HAT: pruef-kanalzeile und pruef-teilen hatten denselben Handle abgeschrieben. Zwei Abschriften desselben Tippfehlers bestaetigen sich gegenseitig -- beide waren gruen. Sie leiten ihn jetzt aus KANAELE ab und koennen ihn damit gar nicht mehr eigenstaendig falsch haben. Gefunden hat es ein Vergleich: Die oeffentliche Website verlinkt dieselben Accounts und hatte die richtige Schreibweise. Genau das ist jetzt Abschnitt 7 von pruef-kanaele -- alle Handles muessen auch draussen vorkommen, mit Gegenprobe, dass eine falsche Adresse durchfaellt. Ohne Netz, damit eine Stoerung bei TikTok nicht zu einem roten Alarm im Haus wird. Co-Authored-By: Claude Opus 5 <[email protected]>
298 lines
14 KiB
JavaScript
298 lines
14 KiB
JavaScript
/* =====================================================================
|
||
AUS „TEILEN" WIRD EIN TIPP (17.09.2026) — Schritt 4 des Video-Plans
|
||
|
||
Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App
|
||
öffnen, Bereich suchen, Feld finden, einfügen. Fünf Schritte, von
|
||
denen jeder der Grund sein kann, es „später" zu machen.
|
||
|
||
---------------------------------------------------------------------
|
||
DER FALL, DER DIESE PRÜFUNG RECHTFERTIGT
|
||
|
||
Das Teilen-Ziel kennt drei Felder: `title`, `text`, `url`. Man baut
|
||
es und probiert es mit `?url=https://…` aus — das geht, und man ist
|
||
fertig.
|
||
|
||
IM ECHTEN LEBEN KOMMT DER LINK ABER IN `text`. Android-Apps,
|
||
TikTok eingeschlossen, teilen als reinen Text: „Sieh dir das an!
|
||
https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist
|
||
leer. Wer nur `url` liest, hat eine Seite gebaut, die im Test
|
||
funktioniert und auf dem Handy eine leere Maske zeigt.
|
||
|
||
Abschnitt 2 prüft genau diesen Fall — und zwar mit einem Satz
|
||
drumherum, nicht mit einer nackten Adresse.
|
||
|
||
Aufrufen mit: node server/pruef-teilen.mjs
|
||
===================================================================== */
|
||
|
||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { createServer } from "node:http";
|
||
import { eigenerPort } from "./helfer-port.mjs";
|
||
|
||
const PORT = await eigenerPort(import.meta, "pruef-teilen");
|
||
const TIKTOK = await eigenerPort(import.meta, "nachgebautes TikTok", 1);
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-teilen-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
process.env.PORT = String(PORT);
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||
process.env.TIKTOK_OEMBED_BASIS = `http://127.0.0.1:${TIKTOK}/oembed`;
|
||
|
||
/* Der Handle wird abgeleitet, nicht abgeschrieben (21.09.2026) -- hier
|
||
stand "@hasidog00804" mit einer Null zu viel, derselbe Tippfehler wie
|
||
in KANAELE. Zwei gleiche Abschriften bestaetigen sich gegenseitig und
|
||
merken nichts. */
|
||
const { KANAELE } = await import("./workspace.js");
|
||
const HANDLES = KANAELE.map((k) => k.handle.replace(/^@/, ""));
|
||
|
||
const PNG = Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||
"base64");
|
||
const tiktok = createServer((q, a) => {
|
||
const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`);
|
||
if (u.pathname === "/oembed") {
|
||
const wer = ((u.searchParams.get("url") || "").match(/@([^/?]+)/) || [])[1] || "";
|
||
if (!wer) { a.writeHead(404); a.end("{}"); return; }
|
||
a.writeHead(200, { "content-type": "application/json" });
|
||
a.end(JSON.stringify({
|
||
version: "1.0", type: "video",
|
||
title: "Casper erwischt den Postboten",
|
||
author_url: `https://www.tiktok.com/@${wer}`,
|
||
thumbnail_url: `http://127.0.0.1:${TIKTOK}/cover.jpg`,
|
||
}));
|
||
return;
|
||
}
|
||
if (u.pathname === "/cover.jpg") {
|
||
a.writeHead(200, { "content-type": "image/jpeg" }); a.end(PNG); return;
|
||
}
|
||
a.writeHead(404); a.end("{}");
|
||
});
|
||
await new Promise((r) => tiktok.listen(TIKTOK, "127.0.0.1", r));
|
||
|
||
const express = (await import("express")).default;
|
||
const ec = express.response.cookie;
|
||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
await import("./index.js");
|
||
notbremse(240_000, "pruef-teilen");
|
||
await new Promise((r) => setTimeout(r, 900));
|
||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||
|
||
let fehler = 0, geprueft = 0;
|
||
const melde = (t) => console.log(t);
|
||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
|
||
const { DatabaseSync } = await import("node:sqlite");
|
||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const j = new Date().toISOString();
|
||
const salt = randomBytes(16).toString("hex");
|
||
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
|
||
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||
d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)")
|
||
.run("Filipe", "admin", hash, salt, 32768, j);
|
||
d.close();
|
||
|
||
const an = await fetch(BASIS + "/workspace/api/anmelden", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
|
||
});
|
||
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
|
||
|
||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await pw.chromium.launch();
|
||
/* AUF HANDYGRÖSSE. Diese Seite wird fast ausschliesslich dort
|
||
geoeffnet -- eine Messung auf 1400 px misst etwas, das niemand
|
||
sieht. */
|
||
const ctx = await browser.newContext({
|
||
viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
|
||
});
|
||
await ctx.addCookies(keks.split("; ").map((k) => {
|
||
const [name, ...rest] = k.split("=");
|
||
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
||
}));
|
||
const seite = await ctx.newPage();
|
||
const konsolenfehler = [];
|
||
seite.on("console", (m) => { if (m.type() === "error") konsolenfehler.push(m.text()); });
|
||
seite.on("pageerror", (f) => konsolenfehler.push(String(f)));
|
||
|
||
const VIDEO = `http://127.0.0.1:${TIKTOK}/@dogfather0804/video/4711`;
|
||
const oeffne = async (frage) => {
|
||
await seite.goto(`${BASIS}/workspace/teilen.html?${frage}`, { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(900);
|
||
};
|
||
const gezeigterLink = () => seite.$eval("#url-text", (n) => n.textContent.trim()).catch(() => "");
|
||
const zahlIm = async (bereich) => {
|
||
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } });
|
||
const x = await a.json().catch(() => null);
|
||
return (x?.eintraege || []).length;
|
||
};
|
||
|
||
/* =======================================================================
|
||
1. DAS SAUBERE FELD
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 1. Wenn der Link in „url“ steht ===");
|
||
{
|
||
await oeffne(`url=${encodeURIComponent(VIDEO)}`);
|
||
ok(await gezeigterLink() === VIDEO, "der Link wird erkannt und angezeigt");
|
||
ok(!(await seite.$("#haendisch:not([hidden])")),
|
||
"und das Feld zum Einfügen bleibt weg – es gibt ja nichts einzufügen");
|
||
/* DIE KLASSEN HEISSEN `tl-…`, NICHT `teilen__…` (berichtigt
|
||
20.09.2026). Sie wurden irgendwann umbenannt, und diese Pruefung
|
||
suchte seitdem nach Elementen, die es nicht gibt: Sie meldete
|
||
"drei Ziele zur Auswahl (0)" -- also rot mit der richtigen Zahl
|
||
in Klammern, und niemand hat die Klammer gelesen. Ein Selektor
|
||
ist ein Name, und Namen altern: Sie stehen in zwei Dateien und
|
||
werden nur in einer geaendert. */
|
||
const bretter = await seite.$$eval(".tl-brett", (ns) => ns.map((n) => ({
|
||
name: n.querySelector(".tl-brett-name")?.textContent,
|
||
gewaehlt: n.getAttribute("aria-pressed"),
|
||
})));
|
||
ok(bretter.length === 3, `drei Ziele zur Auswahl (${bretter.length})`);
|
||
ok(bretter[0]?.gewaehlt === "true" && /Highlights/.test(bretter[0]?.name || ""),
|
||
"Highlights ist vorausgewählt – dort gehören Videos hin");
|
||
await seite.screenshot({ path: "server/bild-teilen.png", fullPage: true });
|
||
}
|
||
|
||
/* =======================================================================
|
||
2. DER ECHTE FALL: ein Satz mit Link darin ← der eigentliche Punkt
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 2. Wenn TikTok einen Satz teilt ===");
|
||
{
|
||
/* GENAU SO kommt es von einem Android-Handy an: alles in `text`,
|
||
`url` leer, und die Adresse steckt mitten im Satz. */
|
||
const satz = `Sieh dir das an! ${VIDEO} #fyp`;
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
ok(await gezeigterLink() === VIDEO,
|
||
`die Adresse wird aus dem Satz herausgeholt (${await gezeigterLink()})`);
|
||
|
||
/* UND MIT EINEM PUNKT DAHINTER. „…/4711." ist keine Adresse mit
|
||
Punkt, sondern ein Satzende. */
|
||
await oeffne(`text=${encodeURIComponent(`Schau mal: ${VIDEO}.`)}`);
|
||
ok(await gezeigterLink() === VIDEO,
|
||
`ein Satzzeichen am Ende gehört nicht zur Adresse (${await gezeigterLink()})`);
|
||
|
||
/* Und im Titel, falls eine App es dort ablegt. */
|
||
await oeffne(`title=${encodeURIComponent(VIDEO)}&text=`);
|
||
ok(await gezeigterLink() === VIDEO, "auch aus dem Titel");
|
||
}
|
||
|
||
/* =======================================================================
|
||
3. GAR KEIN LINK
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 3. Wenn nichts dabei ist ===");
|
||
{
|
||
await oeffne("text=nur%20ein%20Text%20ohne%20Adresse");
|
||
ok(!!(await seite.$("#haendisch:not([hidden])")),
|
||
"dann erscheint ein Feld zum Einfügen – kein Fehler, ein Angebot");
|
||
ok(!(await seite.$("#fund:not([hidden])")),
|
||
"und es wird keine Adresse behauptet, die es nicht gibt");
|
||
}
|
||
|
||
/* =======================================================================
|
||
4. UND ES TRÄGT WIRKLICH EIN
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 4. Der Tipp auf „Übernehmen“ ===");
|
||
{
|
||
const vorher = await zahlIm("highlight");
|
||
const satz = `Neues Video! ${VIDEO}`;
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
|
||
/* NICHT VON SELBST. Eine Seite, die beim Öffnen einträgt, kann nicht
|
||
wissen, ob der Griff in die Tasche gemeint war. */
|
||
ok(await zahlIm("highlight") === vorher,
|
||
`allein vom Öffnen passiert nichts (${vorher})`);
|
||
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
const nachher = await zahlIm("highlight");
|
||
ok(nachher === vorher + 1, `nach dem Tipp liegt es da (${vorher} → ${nachher})`);
|
||
|
||
const fertig = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => "");
|
||
ok(/Angekommen/.test(fertig), `und die Seite sagt es (\u201e${fertig.slice(0, 55)}\u201c)`);
|
||
ok(/Casper erwischt den Postboten/.test(fertig),
|
||
"mitsamt dem Titel, den TikTok geliefert hat");
|
||
|
||
/* DER WEG DORTHIN steht als Knopf da -- wer teilt, will meistens
|
||
gleich sehen, wie es aussieht. */
|
||
const hin = await seite.$eval("#hin", (n) => n.getAttribute("href")).catch(() => "");
|
||
ok(/b=highlight/.test(hin), `der Knopf führt zum richtigen Brett (${hin})`);
|
||
|
||
/* ---- Und derselbe Link ein zweites Mal ---------------------------- */
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
ok(await zahlIm("highlight") === nachher,
|
||
`zweimal geteilt ergibt kein zweites Video (${await zahlIm("highlight")})`);
|
||
const zweiterSatz = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => "");
|
||
ok(/lag schon/.test(zweiterSatz), `und es steht auch da (\u201e${zweiterSatz.slice(0, 45)}\u201c)`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
5. AUF EIN ANDERES BRETT
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 5. Wohin ist eine Frage ===");
|
||
{
|
||
const vorher = await zahlIm("treff");
|
||
await oeffne(`url=${encodeURIComponent(`http://127.0.0.1:${TIKTOK}/@${HANDLES[1]}/video/99`)}`);
|
||
await seite.click('.tl-brett[data-brett="treff"]');
|
||
await seite.waitForTimeout(300);
|
||
const gewaehlt = await seite.$$eval(".tl-brett",
|
||
(ns) => ns.filter((n) => n.getAttribute("aria-pressed") === "true").map((n) => n.dataset.brett));
|
||
ok(gewaehlt.length === 1 && gewaehlt[0] === "treff",
|
||
`genau ein Ziel ist gewählt (${gewaehlt.join(", ")})`);
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
ok(await zahlIm("treff") === vorher + 1,
|
||
`und es landet dort und nicht bei den Highlights (${vorher} → ${await zahlIm("treff")})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
6. DAS TEILEN-ZIEL STEHT IM MANIFEST
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 6. Damit das Handy es überhaupt anbietet ===");
|
||
{
|
||
/* OHNE DIESEN EINTRAG erscheint der Workspace gar nicht erst im
|
||
Teilen-Menü -- die ganze Seite wäre dann unerreichbar, und die
|
||
fünf Abschnitte darüber hätten es nicht gemerkt. */
|
||
for (const datei of ["crew.webmanifest", "app.webmanifest"]) {
|
||
const m = JSON.parse(readFileSync(new URL(`../workspace/${datei}`, import.meta.url), "utf8"));
|
||
const z = m.share_target;
|
||
ok(!!z && z.action === "/workspace/teilen.html",
|
||
`${datei}: Teilen-Ziel zeigt auf die Seite (${z?.action})`);
|
||
ok(z?.method === "GET",
|
||
`${datei}: per GET – POST bräuchte einen Service Worker dazwischen (${z?.method})`);
|
||
/* ALLE DREI FELDER. Fehlte `text`, wäre genau der Fall aus
|
||
Abschnitt 2 nicht abgedeckt -- und das ist der häufigste. */
|
||
ok(z?.params?.text === "text" && z?.params?.url === "url" && z?.params?.title === "title",
|
||
`${datei}: title, text UND url werden entgegengenommen`);
|
||
}
|
||
|
||
/* UND DIE SEITE IST IN DER RECHTETAFEL. Ohne Eintrag ist sie seit
|
||
dem 11.09. für jede Rolle zu -- ein fehlender Eintrag heißt nein. */
|
||
const { SEITEN } = await import("./rechte.js");
|
||
const rollen = SEITEN["/workspace/teilen.html"] || [];
|
||
ok(rollen.length > 0, `die Seite steht in der Rechtetafel (${rollen.join(", ")})`);
|
||
ok(!rollen.includes("gast"),
|
||
"und die Community kommt nicht hin – sie darf auch keine Videos eintragen");
|
||
}
|
||
|
||
melde("");
|
||
ok(konsolenfehler.length === 0,
|
||
konsolenfehler.length ? konsolenfehler.join(" | ") : "keine Fehler in der Browserkonsole");
|
||
|
||
melde("");
|
||
melde(`${geprueft} Prüfungen, ${fehler} Fehler`);
|
||
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
||
await browser.close();
|
||
await new Promise((r) => tiktok.close(r));
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
||
process.exit(fehler ? 1 : 0);
|