Product cover images via Open Library; Gutenberg literature filter; cron root fix

Cover sync (OpenLibraryAdapter + CoverSync + sync-covers command): fetches
a real cover from Open Library's free, keyless, explicitly-licensed-for-
this-use Covers API and attaches it as a genuine Media Library attachment
(not a hotlinked <img> — WooCommerce's shop loop/gallery/structured data
all need a real _thumbnail_id). ISBN-first, title/author-search fallback
via the confirmed cover_i field, matching the pattern already established
in HardcoverAdapter.

Two real bugs found and fixed while verifying this against the actual
catalog, not assumed:
- A Range-header HEAD-equivalent probe (added to avoid double-fetching)
  caused Open Library's server to redirect with a misleading content-type,
  producing a false positive on a known-fake ISBN. Removed — fetch once,
  verify the real bytes.
- Their search endpoint has genuine transient failures under repeated
  querying (same request, same input, failed then succeeded seconds
  later) — added retry-with-backoff on network errors/5xx, matching how
  the rest of this codebase already treats transient failures as
  retriable rather than fatal.

Also found chasing what looked like a third cover-sync bug, but wasn't
one: uploads/2026/08 was owned by root, silently blocking www-data-run
wp-cli from writing new files. Root cause was a gap in the earlier
root-hardening pass (docker-compose.yml, Commands.php) — it fixed our own
deploy.sh/backup.sh/Makefile invocations but missed the cron sidecar's
own internal process, which was still running its wp-cli loop as root via
a leftover --allow-root. Fixed at the container level (`user: www-data`
on the cron service) since it's a plain shell loop with none of
php-fpm's master-process-needs-root-to-drop-privileges concern.

Gutenberg importer: filters to actual literature via LoCC (Library of
Congress Classification) — verified directly against the real catalog
that novels consistently get a P* code while government documents/
speeches/law get E/JK/KF/DA and never a P code. Removes the Declaration
of Independence, Bill of Rights, etc. from what was importing as "books."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 14:48:40 -04:00
co-authored by Claude Sonnet 5
parent 71b8065f20
commit dcad1b7512
6 changed files with 306 additions and 2 deletions
@@ -7,6 +7,7 @@ use Bookstore\Core\Catalog\Work;
use Bookstore\Core\Import\GutenbergImporter;
use Bookstore\Core\Import\SyntheticOfferGenerator;
use Bookstore\Core\Integration\HardcoverAdapter;
use Bookstore\Core\Product\CoverSync;
use Bookstore\Core\Product\ProductSync;
use Bookstore\Core\Taxonomy\TagTaxonomies;
@@ -190,4 +191,44 @@ class Commands
\WP_CLI::success("Hardcover sync: {$matched} matched, {$missed} missed, {$processed} processed.");
}
/**
* Attaches a real product image from Open Library's Covers API to
* each Work's product (skips products that already have one).
*
* ## OPTIONS
*
* [--limit=<number>]
* : Max works to process this run (default: all).
*
* [--delay-ms=<number>]
* : Delay between lookups in milliseconds (default 1000) — stays
* comfortably under Open Library's stated 100-requests-per-5-minutes
* rate limit for search-based lookups.
*
* ## EXAMPLES
*
* wp bookstore sync-covers --limit=20
*
* @subcommand sync-covers
*/
public function sync_covers($args, $assoc_args)
{
$limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : PHP_INT_MAX;
$delay_ms = (int) ($assoc_args['delay-ms'] ?? 1000);
$result = CoverSync::sync_all($limit, $delay_ms, function (int $processed, int $attached, int $missed) {
if ($processed % 10 === 0) {
\WP_CLI::log(" processed {$processed} (attached {$attached}, missed {$missed})...");
}
});
\WP_CLI::success(sprintf(
'Covers: %d attached, %d missed, %d already had one, %d processed.',
$result['attached'],
$result['missed'],
$result['skipped'],
$result['processed']
));
}
}
@@ -56,7 +56,7 @@ class GutenbergImporter
throw new \RuntimeException('PG catalog file is empty');
}
$col = array_flip($header);
foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects'] as $required) {
foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects', 'LoCC'] as $required) {
if (!isset($col[$required])) {
fclose($handle);
throw new \RuntimeException("PG catalog missing expected column: {$required}");
@@ -79,6 +79,9 @@ class GutenbergImporter
if ($pg_id <= 0 || $title === '') {
continue;
}
if (!self::is_literature($row[$col['LoCC']] ?? '')) {
continue; // government documents, speeches, law, history — not "books"
}
$isbn13 = self::synthetic_isbn13($pg_id);
if (Isbn::exists($isbn13)) {
@@ -132,6 +135,28 @@ class GutenbergImporter
return $body . $check;
}
/**
* LoCC (Library of Congress Classification) is real, structured data —
* verified against PG's actual catalog: novels consistently get a P*
* code (Language & Literature: PR English, PQ Romance languages, PS
* American, PZ fiction, etc.), while speeches/legal/historical
* documents get E/JK/KF/DA (History, Political Science, Law) and
* never a P code at all. A row can have several semicolon-separated
* LoCC codes; any one of them starting with P is enough to count as
* literature. Missing LoCC data is treated as "not a book" — stricter
* than necessary for a handful of edge cases, but matches what was
* actually asked for: no non-book documents in the catalog.
*/
private static function is_literature(string $locc_raw): bool
{
foreach (explode(';', $locc_raw) as $code) {
if (str_starts_with(ltrim($code), 'P')) {
return true;
}
}
return false;
}
/**
* PG's Authors column lists every contributor for anthology/collection
* works (sometimes dozens, semicolon-separated), which can run well
@@ -0,0 +1,126 @@
<?php
namespace Bookstore\Core\Integration;
defined('ABSPATH') || exit;
/**
* Open Library Covers API — free, keyless, and explicitly licensed for
* this ("intended for displaying covers on public facing websites," their
* own docs). Verified directly (not inferred): a missing cover still
* returns HTTP 200, but as a 43-byte 1x1 placeholder GIF with no
* content-type header, versus a real cover's `content-type: image/jpeg`
* — status code alone can't tell the two apart.
*
* A Range-header HEAD-equivalent probe was tried first to avoid a double
* fetch, but Open Library's server does something different with Range
* present (redirects to an archive.org URL with a content-type that
* doesn't reflect the real resource) — confirmed by testing a known-fake
* ISBN through it and getting a false positive. Simpler and correct: just
* fetch once, verify the actual bytes.
*
* Also confirmed directly: their search endpoint has real transient
* failures under repeated querying (the exact same request for the exact
* same well-known book failed once, then succeeded seconds later with no
* code change) — every HTTP call here retries on network errors/5xx
* rather than treating one glitch as a permanent "no cover found."
*/
class OpenLibraryAdapter
{
private const COVERS_BASE = 'https://covers.openlibrary.org/b';
private const SEARCH_URL = 'https://openlibrary.org/search.json';
private const DEFAULT_DELAY_MS = 1000;
private const MIN_REAL_COVER_BYTES = 1000; // the placeholder is 43 bytes; any real cover is far larger
/**
* Tries ISBN first (works once real supplier ISBNs exist), falls back
* to title+author search via Open Library's `cover_i` field (needed
* for the current Gutenberg-synthetic catalog, whose ISBNs are fake).
* Returns the actual image bytes, not a URL — verifying "is this a
* real cover" requires the bytes anyway, so there's no cheaper
* intermediate step worth returning.
*/
public static function fetch_cover(string $isbn13, string $title, ?string $author, string $size = 'L'): ?string
{
$bytes = self::fetch_and_verify(self::COVERS_BASE . "/isbn/{$isbn13}-{$size}.jpg");
if ($bytes) {
return $bytes;
}
$cover_id = self::search_cover_id($title, $author);
if ($cover_id === null) {
return null;
}
return self::fetch_and_verify(self::COVERS_BASE . "/id/{$cover_id}-{$size}.jpg");
}
public static function throttle(int $delay_ms = self::DEFAULT_DELAY_MS): void
{
usleep($delay_ms * 1000);
}
private static function fetch_and_verify(string $url): ?string
{
$response = self::get_with_retry($url);
if ($response === null) {
return null;
}
if (wp_remote_retrieve_header($response, 'content-type') !== 'image/jpeg') {
return null;
}
$body = wp_remote_retrieve_body($response);
if (strlen($body) < self::MIN_REAL_COVER_BYTES) {
return null; // the 1x1 placeholder, not a real cover
}
return $body;
}
private static function search_cover_id(string $title, ?string $author): ?int
{
$args = ['title' => $title, 'limit' => 5, 'fields' => 'cover_i,author_name'];
if ($author) {
$args['author'] = $author;
}
$url = self::SEARCH_URL . '?' . http_build_query($args);
$response = self::get_with_retry($url);
if ($response === null) {
return null;
}
$data = json_decode(wp_remote_retrieve_body($response), true);
foreach ($data['docs'] ?? [] as $doc) {
if (!empty($doc['cover_i'])) {
return (int) $doc['cover_i'];
}
}
return null;
}
/**
* Retries on network errors and 5xx/429 — real, observed transient
* failures, not hypothetical. Does NOT retry a clean 200 that just
* isn't what we wanted (a legitimate "no cover"/"no match" result is
* not a glitch and retrying it would just waste requests).
*/
private static function get_with_retry(string $url, int $max_attempts = 3, int $retry_delay_ms = 500): ?array
{
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
$response = wp_remote_get($url, ['timeout' => 20]);
if (!is_wp_error($response)) {
$code = wp_remote_retrieve_response_code($response);
if ($code === 200) {
return $response;
}
if ($code < 500 && $code !== 429) {
return null; // a definitive non-transient response (404, etc.)
}
}
if ($attempt < $max_attempts) {
usleep($retry_delay_ms * 1000);
}
}
return null;
}
}
@@ -0,0 +1,105 @@
<?php
namespace Bookstore\Core\Product;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\Work;
use Bookstore\Core\Integration\OpenLibraryAdapter;
defined('ABSPATH') || exit;
/**
* Attaches a real Media Library image to each Work's product — not a
* hotlinked <img src>, which wouldn't show up in the shop loop, gallery,
* or structured data (those all read from a real attachment). Skips
* products that already have one, so re-runs are idempotent and cheap.
*/
class CoverSync
{
/**
* @param callable|null $on_progress function(int $processed, int $attached, int $missed): void
* @return array{attached: int, missed: int, skipped: int, processed: int}
*/
public static function sync_all(int $limit, int $delay_ms, ?callable $on_progress = null): array
{
require_once ABSPATH . 'wp-admin/includes/image.php';
$attached = 0;
$missed = 0;
$skipped = 0;
$processed = 0;
$offset = 0;
$batch_size = 50;
while ($processed < $limit) {
$works = Work::all_paginated($batch_size, $offset);
if (empty($works)) {
break;
}
foreach ($works as $work) {
if ($processed >= $limit) {
break;
}
$product_id = (int) ($work->wc_product_id ?? 0);
if (!$product_id) {
continue; // no product yet — run sync-products first
}
if (has_post_thumbnail($product_id)) {
$skipped++;
continue;
}
$isbns = Isbn::isbns_for_work((int) $work->work_id);
$isbn13 = $isbns[0] ?? '';
$bytes = OpenLibraryAdapter::fetch_cover($isbn13, $work->title, $work->primary_author);
if ($bytes) {
if (self::attach_cover($product_id, $work->title, $bytes)) {
$attached++;
} else {
$missed++;
}
} else {
$missed++;
}
$processed++;
OpenLibraryAdapter::throttle($delay_ms);
if ($on_progress) {
$on_progress($processed, $attached, $missed);
}
}
$offset += $batch_size;
}
return ['attached' => $attached, 'missed' => $missed, 'skipped' => $skipped, 'processed' => $processed];
}
private static function attach_cover(int $product_id, string $title, string $bytes): bool
{
$filename = sanitize_file_name($title) . '-cover.jpg';
$upload = wp_upload_bits($filename, null, $bytes);
if (!empty($upload['error'])) {
return false;
}
$attachment_id = wp_insert_attachment([
'post_mime_type' => 'image/jpeg',
'post_title' => $title . ' cover',
'post_status' => 'inherit',
], $upload['file'], $product_id);
if (!$attachment_id || is_wp_error($attachment_id)) {
return false;
}
$metadata = wp_generate_attachment_metadata($attachment_id, $upload['file']);
wp_update_attachment_metadata($attachment_id, $metadata);
return (bool) set_post_thumbnail($product_id, $attachment_id);
}
}