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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user