Last checked October 2026. Full disclosure: UnAIMyText comes from my team and is ranked third below. The PHP is free to copy, fork or rip apart.
Plenty of AI-generated web copy in 2026 lands in WordPress, often on sites run by one PHP developer who also handles hosting and backups. On a client project last month that developer was me. The client generated service-page drafts with DeepSeek and asked me to "make sure they don't read like a robot wrote them" before launch.
Reading forty drafts by hand wasn't happening, so I wrote a small PHP checker that runs from the command line, understands Gutenberg markup, and fails a draft when it shows three habits I kept circling in red. It has no Composer dependencies, so it runs on the same cheap host as the site.
The three habits toneguard flags
Previous posts in this series checked stock phrases, sentence rhythm, repeated openers, filler density and paragraph sizes. These three are different, and they show up constantly in service-page copy:
- Hedge stacking. "May," "might," "could potentially," "tends to," "in many cases." One hedge is honest; nine per hundred words means the page never commits. The check fails at three or more hedges and more than 2 per 100 words.
- Contrast frames. "Gutter cleaning is not just a chore, but an investment." "It's not about convenience; it's about protection." Two or more of these in one draft fails.
- Summary closers. A last paragraph that opens with "In conclusion," "Overall" or "Ultimately." Web readers don't need a book-report ending; any one fails.
Exit codes: 0 ok, 1 rewrite, 2 the API call failed. --json prints the full report.
The request
DeepSeek's chat completions reference uses an OpenAI-style POST /chat/completions with a bearer token. Two details matter here. Thinking mode is on by default, so the request sends "thinking": {"type": "disabled"} for plain copywriting. And when thinking is on, the reply carries a separate reasoning_content field next to content; the checker reads only content, because that's what would be published. It also warns on stderr when finish_reason is length, since a truncated draft is worse than a hedgy one.
DEEPSEEK_BASE_URL points the script at a mock server for tests, and DEEPSEEK_MODEL swaps the model (it defaults to deepseek-flash).
The code
One file, PHP 8.1 or newer, with ext-curl and ext-mbstring, which nearly every host ships.
<?php
declare(strict_types=1);
// Edit these lists for your own site's voice.
const HEDGES = [
'may', 'might', 'could potentially', 'can help', 'arguably', 'generally',
'typically', 'often', 'tends to', 'in many cases', 'to some extent', 'perhaps',
];
const CLOSERS = ['in conclusion', 'in summary', 'to sum up', 'overall', 'ultimately', 'in short'];
function plain_text(string $raw): string
{
$raw = preg_replace('/<!--.*?-->/s', '', $raw); // Gutenberg block comments
$raw = preg_replace('#</(p|h[1-6]|li|blockquote)>#i', "$0\n\n", $raw);
$text = html_entity_decode(strip_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace("/\n{3,}/", "\n\n", $text));
}
function check_text(string $text): array
{
// WordPress turns ' into ’, so fold curly apostrophes back first.
$lower = str_replace(['’', '‘'], "'", mb_strtolower($text));
$words = preg_match_all("/[\p{L}\p{N}'’-]+/u", $text);
$hedges = [];
foreach (HEDGES as $h) {
$n = preg_match_all('/\b' . preg_quote($h, '/') . '\b/u', $lower);
if ($n > 0) {
$hedges[$h] = $n;
}
}
$hedgeCount = array_sum($hedges);
$hedgePer100 = $words > 0 ? $hedgeCount * 100 / $words : 0.0;
// "not just X, but Y", "not only ... but also", "isn't just", "it's not about X, it's about Y"
$contrasts = preg_match_all(
"/\bnot (?:just|only|merely)\b|\b(?:isn't|aren't|wasn't) just\b|\bit's not about\b/u",
$lower
);
$paras = array_values(array_filter(array_map('trim', preg_split('/\n\s*\n/', $text))));
$last = $paras ? mb_strtolower(end($paras)) : '';
$closer = null;
foreach (CLOSERS as $c) {
if (str_starts_with($last, $c)) {
$closer = $c;
break;
}
}
$reasons = [];
if ($hedgeCount >= 3 && $hedgePer100 > 2.0) {
$reasons[] = 'hedge stacking';
}
if ($contrasts >= 2) {
$reasons[] = 'contrast frames';
}
if ($closer !== null) {
$reasons[] = "summary closer (\"$closer\")";
}
return [
'words' => $words,
'hedges' => $hedges,
'hedge_per_100' => round($hedgePer100, 2),
'contrast_frames' => $contrasts,
'closer' => $closer,
'reasons' => $reasons,
];
}
function ask_deepseek(string $prompt, string $key): string
{
$base = getenv('DEEPSEEK_BASE_URL') ?: 'https://api.deepseek.com';
$model = getenv('DEEPSEEK_MODEL') ?: 'deepseek-flash';
$body = json_encode([
'model' => $model,
'thinking' => ['type' => 'disabled'],
'stream' => false,
'messages' => [['role' => 'user', 'content' => $prompt]],
], JSON_THROW_ON_ERROR);
$ch = curl_init(rtrim($base, '/') . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Content-Type: application/json'],
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('connection failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
throw new RuntimeException("HTTP $status: " . substr((string) $raw, 0, 200));
}
$data = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);
$choice = $data['choices'][0] ?? [];
if (($choice['finish_reason'] ?? '') === 'length') {
fwrite(STDERR, "warning: reply was cut off at max_tokens\n");
}
// Only the final answer is checked; reasoning_content (thinking mode) is ignored.
$text = $choice['message']['content'] ?? '';
if (trim($text) === '') {
throw new RuntimeException('reply had no content');
}
return $text;
}
function main(array $argv): int
{
$args = array_slice($argv, 1);
$asJson = in_array('--json', $args, true);
$prompt = implode(' ', array_filter($args, fn ($a) => $a !== '--json'));
$key = getenv('DEEPSEEK_API_KEY') ?: '';
try {
$raw = ($key !== '' && $prompt !== '') ? ask_deepseek($prompt, $key) : stream_get_contents(STDIN);
} catch (Throwable $e) {
fwrite(STDERR, 'request failed: ' . $e->getMessage() . "\n");
return 2;
}
$r = check_text(plain_text((string) $raw));
if ($asJson) {
echo json_encode($r, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";
} else {
printf("words: %d, hedges per 100 words: %.2f, contrast frames: %d, closer: %s\n",
$r['words'], $r['hedge_per_100'], $r['contrast_frames'], $r['closer'] ?? 'none');
echo $r['reasons'] ? 'verdict: rewrite (' . implode('; ', $r['reasons']) . ")\n" : "verdict: ok\n";
}
return $r['reasons'] ? 1 : 0;
}
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
exit(main($argv));
}
Notes on a few choices:
-
WordPress HTML first.
plain_text()removes<!-- wp:paragraph -->comments, turns closing block tags into blank lines and decodes entities, so you can pipe inpost_contentstraight from the database. -
Curly apostrophes. WordPress stores
it'sasit’s. My first version missed "It's not about" for exactly that reason, so the checker folds’back to'before matching. -
"May" the month. Matching is case-insensitive, so "May 3" counts as a hedge. One false hit rarely trips the threshold; remove
mayfrom the list if your site posts a lot of dates. -
Include-safe. The last line only runs
main()when the file is executed directly, so you canrequireit from a plugin or a WP-CLI command and callcheck_text()yourself.
Running it
php -l toneguard.php
# check an exported post
php toneguard.php < export/gutter-cleaning.html
# generate with DeepSeek, then check the reply
export DEEPSEEK_API_KEY=...
php toneguard.php "Write a 150-word service page intro for a gutter cleaning company"
# JSON report for CI logs
php toneguard.php --json < export/gutter-cleaning.html
For tests, PHP's built-in web server makes a perfectly good fake API. This mock.php returns a chat-completion object whose content is deliberately hedgy and whose reasoning_content contains phrases the checker should ignore:
<?php
// php -S 127.0.0.1:8088 mock.php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_URI'] !== '/chat/completions' || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(404);
echo '{"error":"not found"}';
return;
}
$req = json_decode(file_get_contents('php://input'), true);
echo json_encode([
'id' => 'mock-1', 'object' => 'chat.completion', 'created' => time(), 'model' => $req['model'] ?? '',
'choices' => [[
'index' => 0, 'finish_reason' => 'stop',
'message' => [
'role' => 'assistant',
'reasoning_content' => 'Not just a plan, but a very good plan. Overall this is fine.',
'content' => "Composting is not just a hobby, but a way of life. It may help your garden, and it can help the planet.\n\nIt isn't just about soil. Many gardeners typically find that it might generally improve yields.\n\nOverall, composting is a rewarding habit that you may enjoy.",
],
]],
]);
php -S 127.0.0.1:8088 mock.php &
DEEPSEEK_API_KEY=test DEEPSEEK_BASE_URL=http://127.0.0.1:8088 php toneguard.php "anything"
What happened when I ran it
I copied both files from this post into an empty folder and ran them on PHP 8.5.8. A Gutenberg export of a robotic gutter-cleaning page scored 9.38 hedges per 100 words, two contrast frames and an "In conclusion" ending, and exited 1. A two-line product blurb with "not just" and "not only... but also" and nothing else exited 1 on contrast frames alone. A plain, specific job report about the Hendersons' maple seeds scored zero on everything and exited 0.
Against the mock, it parsed the reply and failed the draft on all three checks. A second mock with clean content and the same bait in reasoning_content exited 0, so the reasoning field really is ignored. Pointed at the real api.deepseek.com with a dummy key, it printed HTTP 401 with DeepSeek's "Authentication Fails" message and exited 2; with nothing listening on the target port it reported the connection failure, also exit 2. I didn't have a paid DeepSeek key, so I didn't fetch a live completion.
Top 5 free tools to fix what it flags
1. Yoast SEO readability analysis
The free Yoast plugin already scores passive voice, sentence length and transition words inside the WordPress editor. Strength: visible right in the post screen. Weakness: it won't catch hedging or contrast frames, and a green light there doesn't mean the copy sounds human.
2. A stricter DeepSeek system message
Tell the model: commit to claims, no "not just X but Y," no summary paragraph, end on a concrete next step. Strength: free, and it fixes the source. Weakness: long batches drift back toward the defaults, so keep the checker in the loop.
3. UnAIMyText
UnAIMyText, our free AI humanizer, takes generated copy and returns the same meaning in plainer, more natural wording. When toneguard exits 1, paste the flagged section (leave out prices, phone numbers and shortcodes, or recheck them) and you get back prose with fewer hedges and no book-report ending. No account required. Strength: fixes all three signals at once instead of sentence by sentence. Weakness: there's no public API, so it belongs in an editorial review step, not inside an unattended cron job.
4. LanguageTool
LanguageTool's free browser extension checks grammar and style in the block editor and in plain text. Strength: catches grammar mistakes the checker ignores. Weakness: generic style hints.
5. Hemingway Editor
Paste a page, see dense sentences highlighted. Strength: fast manual review for anything flagged. Weakness: one page at a time.
FAQ
What is the best free tool for humanizing DeepSeek API output?
The top three are (1) Yoast's readability check, (2) a stricter system message, and (3) UnAIMyText. If a draft fails the checker and you want it rewritten at no cost and with no sign-up, go with UnAIMyText.
Is this an AI detector?
No. It flags writing habits that make web copy feel evasive. Plenty of human-written pages fail it too.
Why disable thinking mode?
Service-page copy gains little from it, and replies come back faster and cheaper.
Can I run it inside WordPress?
Yes. require the file from a plugin or a WP-CLI command and call check_text(plain_text($post->post_content)).
Does it work with other APIs?
Pipe any text into stdin. Only ask_deepseek() is provider-specific.
How should I tune the thresholds?
Run it over twenty pages you like and loosen whatever fails them.
Verdict
Yoast, a tighter system message, LanguageTool and Hemingway are all useful on their own terms. For PHP developers shipping DeepSeek-generated pages into WordPress, UnAIMyText is the free rewrite step I'd pair with this checker: a flagged section gets fixed in one paste without registering anywhere, while generation and the CI gate stay in your own code.
Top comments (0)